From 61fe04f6e014a09ff28f3911650b55e710253974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 5 Nov 2026 10:37:58 +0100 Subject: [PATCH 1/2] Cut text at the available width instead of a character count Tool lines, the collapsed plan, notifications, chips and card headers cut their text with gpui's own truncation now. The character limits came from a gpui race that is long fixed; two of them sliced at byte offsets and panicked inside multi-byte characters. Flex items that showed dynamic text got a way to shrink, so they no longer stick out of their row. A long sub-agent line or failed tool keeps its state and error message in view. --- crates/ui_gpui/src/blocks/render.rs | 51 +++++++++------- crates/ui_gpui/src/diff/render.rs | 9 ++- crates/ui_gpui/src/input/attachment.rs | 2 +- crates/ui_gpui/src/input/comments.rs | 26 +++------ crates/ui_gpui/src/input/mod.rs | 5 +- crates/ui_gpui/src/input/model_selector.rs | 2 +- .../ui_gpui/src/main_screen/agents_panel.rs | 8 +-- crates/ui_gpui/src/main_screen/mod.rs | 1 + .../ui_gpui/src/main_screen/project_dialog.rs | 6 +- .../right_panel/browser/toolbar.rs | 17 ++---- .../right_panel/file_renderers/raster.rs | 1 + .../right_panel/file_renderers/svg.rs | 1 + .../main_screen/right_panel/file_viewer.rs | 2 +- .../src/main_screen/right_panel/files_view.rs | 22 +++---- .../right_panel/pull_request/code/aside.rs | 4 +- .../right_panel/pull_request/code/body.rs | 13 +---- .../pull_request/code/file_header.rs | 4 +- .../right_panel/pull_request/code/toolbar.rs | 4 +- .../right_panel/pull_request/header.rs | 13 ++++- .../right_panel/pull_request/repo_picker.rs | 3 +- .../right_panel/pull_request/summary.rs | 2 +- .../pull_request/summary/picker.rs | 3 +- .../main_screen/right_panel/review_view.rs | 6 +- .../ui_gpui/src/main_screen/tests/monitors.rs | 25 ++++++++ .../src/messages/activity_indicator.rs | 23 +++++++- crates/ui_gpui/src/messages/message_item.rs | 6 +- .../src/settings_screen/general_section.rs | 1 + crates/ui_gpui/src/settings_screen/mod.rs | 52 ++++++++++------- .../provider_forms/ai_core_form.rs | 6 +- .../chatgpt_subscription_form.rs | 8 ++- .../src/settings_screen/providers_section.rs | 4 ++ .../src/settings_screen/skills_section.rs | 3 + crates/ui_gpui/src/shared/file_icons.rs | 1 + crates/ui_gpui/src/shared/mod.rs | 1 + crates/ui_gpui/src/shared/plan_banner.rs | 58 +++++++------------ crates/ui_gpui/src/shared/text.rs | 48 +++++++++++++++ crates/ui_gpui/src/sidebar/project_picker.rs | 3 +- crates/ui_gpui/src/sidebar/projects_view.rs | 3 +- crates/ui_gpui/src/sidebar/session_item.rs | 6 +- crates/ui_gpui/src/tool_cards/code_card.rs | 14 +---- crates/ui_gpui/src/tool_cards/diff_card.rs | 3 +- .../ui_gpui/src/tool_cards/inline_renderer.rs | 8 +-- crates/ui_gpui/src/tool_cards/mod.rs | 15 +++-- .../ui_gpui/src/tool_cards/sub_agent_line.rs | 41 +++++-------- .../ui_gpui/src/tool_cards/terminal_card.rs | 36 ++++++------ 45 files changed, 317 insertions(+), 253 deletions(-) create mode 100644 crates/ui_gpui/src/shared/text.rs diff --git a/crates/ui_gpui/src/blocks/render.rs b/crates/ui_gpui/src/blocks/render.rs index 1177cf3c2..b3fbfdcb5 100644 --- a/crates/ui_gpui/src/blocks/render.rs +++ b/crates/ui_gpui/src/blocks/render.rs @@ -5,6 +5,7 @@ //! thinking, tool-use, compaction, image). use super::{BlockData, BlockView, ToolBlockState, ToolUseBlock}; +use crate::shared::text::single_line; use crate::shared::{file_icons, frame_profile}; use code_assistant_core::ui::ToolStatus; @@ -106,7 +107,7 @@ impl BlockView { }; let header_text_color = theme.muted_foreground; let icon = file_icons::get().get_tool_icon(&block.name); - let label = renderer.describe(block); + let label = single_line(&renderer.describe(block)); div() .w_full() @@ -131,6 +132,8 @@ impl BlockView { )) .child( div() + .min_w_0() + .truncate() .text_size(rems(0.75)) .text_color(header_text_color) .child(label), @@ -171,15 +174,13 @@ impl BlockView { }; // Description text - let description = if block.status == ToolStatus::Error { - if let Some(ref msg) = block.status_message { - format!("{} — {}", renderer.describe(block), msg) - } else { - renderer.describe(block) - } - } else { - renderer.describe(block) - }; + let description = single_line(&renderer.describe(block)); + // Its own element, so a long description can't push it out of view. + let error_message = block + .status_message + .as_deref() + .filter(|_| block.status == ToolStatus::Error) + .map(|message| format!("— {}", single_line(message))); // Optional right-aligned pill (e.g. the MCP server name). let header_tag = renderer.header_tag(block); @@ -283,14 +284,22 @@ impl BlockView { // Description text .child( div() + .min_w_0() + .truncate() .text_size(rems(0.8125)) .text_color(desc_color) - .overflow_hidden() - .text_overflow(gpui_kit::TextOverflow::Truncate(SharedString::from( - "…", - ))) .child(description), - ), + ) + .when_some(error_message, |d, message| { + d.child( + div() + .min_w_0() + .truncate() + .text_size(rems(0.8125)) + .text_color(desc_color) + .child(message), + ) + }), ) // Optional pill (e.g. MCP server name), right-aligned before the // chevron. @@ -399,6 +408,8 @@ impl BlockView { .child( div() .flex_1() + .min_w_0() + .truncate() .text_size(rems(0.8125)) .text_color(color) .child(label), @@ -665,10 +676,8 @@ impl BlockView { .text_size(rems(0.8125)) .italic() .text_color(muted) - .overflow_hidden() - .text_overflow(gpui_kit::TextOverflow::Truncate( - SharedString::from("…"), - )) + .min_w_0() + .truncate() .child(header_text), ), ) @@ -996,13 +1005,15 @@ impl BlockView { .hover(|s| s.text_color(theme.foreground)) .child( gpui_kit::svg() + .flex_none() .size(px(14.)) .path("icons/message_bubbles.svg") .text_color(theme.muted_foreground), ) - .child(label) + .child(div().min_w_0().truncate().child(label)) .child( gpui_kit::svg() + .flex_none() .size(px(10.)) .path(if expanded { "icons/chevron_down.svg" diff --git a/crates/ui_gpui/src/diff/render.rs b/crates/ui_gpui/src/diff/render.rs index bd2937c36..c42ea1f44 100644 --- a/crates/ui_gpui/src/diff/render.rs +++ b/crates/ui_gpui/src/diff/render.rs @@ -46,15 +46,14 @@ pub(crate) fn render_diff_chunk( return rows; } let (_, ctx_color) = unchanged_row_colors(theme); - let separator = div().w_full().flex().text_color(ctx_color.opacity(0.5)); + let separator = div().w_full().text_color(ctx_color.opacity(0.5)); let separator = match header { + // Not a flex container: text directly in one doesn't shrink. Some(header) => separator .px_3() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .child(SharedString::from(format!("⋯ {header}"))), - None => separator.justify_center().child("⋯"), + None => separator.flex().justify_center().child("⋯"), }; div() .flex() diff --git a/crates/ui_gpui/src/input/attachment.rs b/crates/ui_gpui/src/input/attachment.rs index 12124d89f..019a6828b 100644 --- a/crates/ui_gpui/src/input/attachment.rs +++ b/crates/ui_gpui/src/input/attachment.rs @@ -103,7 +103,7 @@ impl AttachmentView { div() .text_xs() .text_color(cx.theme().muted_foreground) - .text_ellipsis() + .truncate() .w_full() .text_center() .child(filename.clone()), diff --git a/crates/ui_gpui/src/input/comments.rs b/crates/ui_gpui/src/input/comments.rs index a3f13b1b4..739550c89 100644 --- a/crates/ui_gpui/src/input/comments.rs +++ b/crates/ui_gpui/src/input/comments.rs @@ -8,6 +8,7 @@ use super::{InputArea, InputAreaEvent}; use crate::shared::dismiss::dismiss_layer; +use crate::shared::text::single_line; use code_assistant_core::line_comments::LineComment; use code_assistant_core::persistence::DraftAttachment; use gpui_kit::component::{ActiveTheme, Icon, Sizable, Size, ThemeStyled}; @@ -17,9 +18,6 @@ use gpui_kit::{ }; use std::collections::BTreeSet; -/// Characters of a comment's text shown in the chip. -const PREVIEW_CHARS: usize = 40; - impl InputArea { /// The draft's line comments, in the order they were made. pub fn comments(&self) -> &[LineComment] { @@ -96,13 +94,7 @@ impl InputArea { match comments { [] => String::new(), [c] => { - let text: String = c.text.chars().take(PREVIEW_CHARS).collect(); - let ellipsis = if c.text.chars().count() > PREVIEW_CHARS { - "…" - } else { - "" - }; - format!("{} · {text}{ellipsis}", c.location_label()) + format!("{} · {}", c.location_label(), single_line(&c.text)) } _ if comments.iter().any(|c| c.on_message) => { format!("{} comments", comments.len()) @@ -161,9 +153,7 @@ impl InputArea { .child( div() .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .text_xs() .text_color(theme.foreground) .child(label), @@ -248,9 +238,7 @@ impl InputArea { ) .child( div() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .text_xs() .font_family("Menlo") .text_color(theme.muted_foreground) @@ -346,8 +334,10 @@ mod tests { InputArea::comments_label(&[comment("/p/src/lib.rs", "no unwrap here")]), "lib.rs:3-5 · no unwrap here" ); - let long = "a".repeat(50); - assert!(InputArea::comments_label(&[comment("/p/a.rs", &long)]).ends_with("a…")); + assert_eq!( + InputArea::comments_label(&[comment("/p/src/lib.rs", "first\nsecond")]), + "lib.rs:3-5 · first second" + ); assert_eq!( InputArea::comments_label(&[comment("/p/a.rs", "x"), comment("/p/a.rs", "y")]), "2 comments in a.rs" diff --git a/crates/ui_gpui/src/input/mod.rs b/crates/ui_gpui/src/input/mod.rs index b2ac0b15a..9f90bbe8b 100644 --- a/crates/ui_gpui/src/input/mod.rs +++ b/crates/ui_gpui/src/input/mod.rs @@ -728,13 +728,16 @@ impl InputArea { })) .child( div() + .min_w_0() .flex() .items_baseline() .gap_2() .text_sm() - .child(item.label.clone()) + .child(div().flex_none().child(item.label.clone())) .child( div() + .min_w_0() + .truncate() .text_xs() .text_color(cx.theme().muted_foreground) .child(item.detail.clone()), diff --git a/crates/ui_gpui/src/input/model_selector.rs b/crates/ui_gpui/src/input/model_selector.rs index 88bab7373..72df38f8a 100644 --- a/crates/ui_gpui/src/input/model_selector.rs +++ b/crates/ui_gpui/src/input/model_selector.rs @@ -90,7 +90,7 @@ impl SelectItem for ModelItem { .min_w_0() .flex() .items_center() - .child(div().min_w_0().text_ellipsis().child(self.name.clone())), + .child(div().min_w_0().truncate().child(self.name.clone())), ); Some(row.into_any_element()) diff --git a/crates/ui_gpui/src/main_screen/agents_panel.rs b/crates/ui_gpui/src/main_screen/agents_panel.rs index 47a8aaabc..97816a840 100644 --- a/crates/ui_gpui/src/main_screen/agents_panel.rs +++ b/crates/ui_gpui/src/main_screen/agents_panel.rs @@ -182,9 +182,7 @@ fn sub_agent_row( div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() + .truncate() .text_size(rems(0.8125)) .text_color(foreground) .child(agent.title.clone()), @@ -208,9 +206,7 @@ fn sub_agent_row( div() .flex_none() .w(px(160.)) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() + .truncate() .text_size(rems(0.75)) .text_color(muted) .map(|column| { diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index 0b032d678..0c9d18979 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -1469,6 +1469,7 @@ impl MainScreen { .text_xs() .text_color(cx.theme().muted_foreground) .line_clamp(4) + .text_ellipsis() .child(request.summary.clone()), ), ) diff --git a/crates/ui_gpui/src/main_screen/project_dialog.rs b/crates/ui_gpui/src/main_screen/project_dialog.rs index f35fb2e54..a3192ae2d 100644 --- a/crates/ui_gpui/src/main_screen/project_dialog.rs +++ b/crates/ui_gpui/src/main_screen/project_dialog.rs @@ -185,8 +185,7 @@ impl Render for NewProjectDialog { div() .text_xs() .text_color(cx.theme().danger.opacity(0.8)) - .overflow_hidden() - .text_ellipsis() + .truncate() .child(SharedString::from(existing_path.clone())), ), ), @@ -236,8 +235,7 @@ impl Render for NewProjectDialog { div() .text_xs() .text_color(cx.theme().muted_foreground) - .overflow_hidden() - .text_ellipsis() + .truncate() .child(SharedString::from(path_display)), ) // Name input diff --git a/crates/ui_gpui/src/main_screen/right_panel/browser/toolbar.rs b/crates/ui_gpui/src/main_screen/right_panel/browser/toolbar.rs index 915479f9c..324b020fd 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/browser/toolbar.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/browser/toolbar.rs @@ -3,6 +3,7 @@ use super::BrowserPanel; use crate::Gpui; +use crate::shared::text::single_line; use code_assistant_core::session::browsers::BrowserKey; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::input::{Input, InputEvent, InputState}; @@ -49,7 +50,7 @@ impl BrowserPanel { let label = if t.title.is_empty() { &t.url } else { &t.title }; chip( SharedString::from(format!("tab-{}", t.id)), - truncate(label, 28), + single_line(label), Some(t.id.as_str()) == watched_tab, cx, ) @@ -84,9 +85,7 @@ impl BrowserPanel { .bg(theme.muted) .text_xs() .text_color(theme.foreground) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .when(in_control, |el| el.cursor_text()) .on_click(cx.listener(|this, _, window, cx| this.edit_address(window, cx))) .child(tab.map(|t| t.url.clone()).unwrap_or_default()) @@ -246,6 +245,8 @@ fn chip( let theme = cx.theme(); div() .id(id) + .max_w(px(220.)) + .truncate() .px_2() .py_0p5() .rounded(px(4.)) @@ -269,14 +270,6 @@ fn browser_label(key: &BrowserKey) -> String { } } -fn truncate(text: &str, max: usize) -> String { - if text.chars().count() <= max { - return text.to_string(); - } - let cut: String = text.chars().take(max.saturating_sub(1)).collect(); - format!("{cut}…") -} - /// What the user typed as an address, with `https://` when it has no scheme. fn with_scheme(typed: &str) -> String { if typed.contains("://") || typed.starts_with("about:") || typed.starts_with("data:") { diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_renderers/raster.rs b/crates/ui_gpui/src/main_screen/right_panel/file_renderers/raster.rs index e121dcc09..1bc69a891 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_renderers/raster.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_renderers/raster.rs @@ -175,6 +175,7 @@ impl Render for RasterPreview { let muted = theme.muted_foreground; let notice = move |text: String| { div() + .min_w_0() .text_sm() .text_color(muted) .child(text) diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_renderers/svg.rs b/crates/ui_gpui/src/main_screen/right_panel/file_renderers/svg.rs index 50cf152ec..04d376c4b 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_renderers/svg.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_renderers/svg.rs @@ -224,6 +224,7 @@ impl Render for SvgPreview { let themed = self.themed_background; let notice = |text: String| { div() + .min_w_0() .text_sm() .text_color(muted) .child(text) diff --git a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs index be028e4e6..9a99747d2 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/file_viewer.rs @@ -765,7 +765,7 @@ impl FileViewer { .bg(theme.warning.opacity(0.12)) .text_xs() .text_color(theme.foreground) - .child(div().flex_1().child(text)) + .child(div().flex_1().min_w_0().child(text)) .when(actions, |d| { d.child( Button::new("file-reload-disk") diff --git a/crates/ui_gpui/src/main_screen/right_panel/files_view.rs b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs index d121013af..aa6e07a21 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/files_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/files_view.rs @@ -860,10 +860,7 @@ impl FilesView { .pl(indent(*depth) + px(14.)) .text_xs() .text_color(theme.muted_foreground) - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() - .child(text.clone()) + .child(div().min_w_0().truncate().child(text.clone())) .into_any_element(), TreeRow::Entry { path, @@ -927,9 +924,7 @@ impl FilesView { div() .flex_1() .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .text_xs() .text_color(fg) .child(name.clone()), @@ -993,7 +988,8 @@ impl FilesView { )) .child( div() - .flex_none() + .min_w_0() + .truncate() .text_xs() .text_color(theme.foreground) .child( @@ -1005,9 +1001,7 @@ impl FilesView { div() .flex_1() .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .text_xs() .text_color(theme.muted_foreground) .child(dir.to_owned()), @@ -1165,9 +1159,7 @@ impl FilesView { div() .flex_1() .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .text_xs() .text_color(theme.muted_foreground) .child(path), @@ -1447,7 +1439,7 @@ impl Render for FilesView { .p_4() .text_sm() .text_color(theme.muted_foreground) - .child(error.clone()) + .child(div().min_w_0().child(error.clone())) .into_any_element(); } diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/aside.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/aside.rs index 284461a17..f52ee7a02 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/aside.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/aside.rs @@ -150,9 +150,7 @@ impl CodeTab { div() .flex_1() .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_ellipsis() + .truncate() .text_color(color) .child(SharedString::from(name.to_owned())) }; diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs index 5b529c1ef..a088fd2ab 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/body.rs @@ -380,9 +380,7 @@ impl CodeTab { .px_3() .text_xs() .text_color(muted) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() + .truncate() .child(path.to_owned()), ); for ix in ixs { @@ -460,14 +458,7 @@ fn hairline_label(label: SharedString, cx: &App) -> AnyElement { .text_xs() .text_color(theme.muted_foreground) .child(line()) - .child( - div() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .child(label), - ) + .child(div().min_w_0().truncate().child(label)) .child(line()) .into_any_element() } diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs index 6f59438e1..1ff71310a 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/file_header.rs @@ -83,9 +83,7 @@ impl CodeTab { div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() + .truncate() .text_color(theme.foreground) .child(path), ) diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs index 821e5ae4a..666b68381 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/code/toolbar.rs @@ -419,9 +419,7 @@ fn scope_menu( div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() + .truncate() .child(commit.message_headline.clone()), ) .child( diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs index baa0dbaad..4cb1bed6b 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/header.rs @@ -478,6 +478,8 @@ impl DetailHeader { .child(avatar(facts.author.as_ref(), px(18.), cx)) .child( div() + .min_w_0() + .truncate() .font_weight(FontWeight::MEDIUM) .text_color(cx.theme().foreground) .child( @@ -569,7 +571,8 @@ impl DetailHeader { .gap_1p5() .child( div() - .flex_none() + .min_w_0() + .truncate() .font_family(cx.theme().mono_font_family.clone()) .child(format!("{} ←", facts.base)), ) @@ -623,11 +626,17 @@ impl DetailHeader { .text_color(warning) .child( svg() + .flex_none() .size(px(13.)) .path("icons/triangle_alert.svg") .text_color(warning), ) - .child(format!("{behind} behind {}", detail.base_branch)) + .child( + div() + .min_w_0() + .truncate() + .child(format!("{behind} behind {}", detail.base_branch)), + ) .child(div().flex_1()) .when_some(method, |row, method| { row.child( diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/repo_picker.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/repo_picker.rs index 504a2c823..4bc0ae26d 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/repo_picker.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/repo_picker.rs @@ -161,7 +161,8 @@ impl RepoPicker { .hover(|s| s.bg(theme.muted)) .child( div() - .flex_none() + .min_w_0() + .truncate() .text_color(theme.foreground) .child(repo.name.clone()), ) diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs index f8d4a67a9..71712b239 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary.rs @@ -280,7 +280,7 @@ impl SummaryTab { ), ) .when(!text.trim().is_empty(), |card| { - card.child(div().text_sm().line_clamp(6).child(text)) + card.child(div().text_sm().line_clamp(6).text_ellipsis().child(text)) }) .into_any_element() } diff --git a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs index 22691cf8b..f4e2a518a 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/pull_request/summary/picker.rs @@ -202,7 +202,8 @@ impl CandidatePicker { .gap_1() .child( div() - .flex_none() + .min_w_0() + .truncate() .text_color(theme.foreground) .child(entry.label.clone()), ) diff --git a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs index 3291587fc..d250fdda5 100644 --- a/crates/ui_gpui/src/main_screen/right_panel/review_view.rs +++ b/crates/ui_gpui/src/main_screen/right_panel/review_view.rs @@ -938,6 +938,8 @@ impl ReviewView { .child( div() .flex_1() + .min_w_0() + .truncate() .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(theme.foreground) @@ -986,6 +988,7 @@ impl ReviewView { .py_1() .text_xs() .text_color(cx.theme().muted_foreground) + .truncate() .child(text) .into_any_element() } @@ -1106,8 +1109,7 @@ impl ReviewView { div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() + .truncate() .text_xs() .text_color(fg) .child(file.path.clone()), diff --git a/crates/ui_gpui/src/main_screen/tests/monitors.rs b/crates/ui_gpui/src/main_screen/tests/monitors.rs index 1dd753b15..2a75c0061 100644 --- a/crates/ui_gpui/src/main_screen/tests/monitors.rs +++ b/crates/ui_gpui/src/main_screen/tests/monitors.rs @@ -118,3 +118,28 @@ fn notifications_show_as_notifications(cx: &mut TestAppContext) { ] ); } + +#[gpui_kit::test] +fn a_long_notification_stays_within_the_window(cx: &mut TestAppContext) { + let core = TestCore::new(MockLLMProvider::new(Vec::new()).into_factory()); + let session_id = core.create_session(); + let job = job_exited_message(12345, &"docker pull postgres ".repeat(40), Some(0)); + core.store() + .update_entry(&session_id, |session| { + session.add_message(Message::new_user(job.as_str())); + Ok(()) + }) + .unwrap(); + + let mut test = MainScreenTest::with_core(cx, core); + test.gpui.cmd_load_session(session_id, None); + test.wait_until("the notification is shown", |test| { + test.shows("notification-label") + }); + + let label = test.cx.debug_bounds("notification-label").unwrap(); + // The widest a message gets. + assert!(label.size.width <= gpui_kit::px(720.), "{label:?}"); + // One line, not wrapped. + assert!(label.size.height < gpui_kit::px(30.), "{label:?}"); +} diff --git a/crates/ui_gpui/src/messages/activity_indicator.rs b/crates/ui_gpui/src/messages/activity_indicator.rs index 81426b25a..408522f9c 100644 --- a/crates/ui_gpui/src/messages/activity_indicator.rs +++ b/crates/ui_gpui/src/messages/activity_indicator.rs @@ -122,12 +122,21 @@ pub fn render_activity_indicator( .gap_2() .child( div() + .flex_none() .text_size(rems(0.875)) .text_color(color) .child(braille_char.to_string()), ) .when_some(label, |d, label| { - d.child(div().text_size(rems(0.8125)).text_color(color).child(label)) + d.child( + div() + .min_w_0() + .line_clamp(2) + .text_ellipsis() + .text_size(rems(0.8125)) + .text_color(color) + .child(label), + ) }) .into_any_element() } @@ -140,6 +149,7 @@ pub fn render_armed_wakeups( view: &MessagesView, cx: &mut Context, ) -> gpui_kit::AnyElement { + use crate::shared::text::single_line; use crate::tool_cards::wakeup_renderer::countdown; let color = cx.theme().muted_foreground; @@ -155,6 +165,7 @@ pub fn render_armed_wakeups( .px_3() .child( div() + .flex_none() .w(gpui_kit::px(14.)) .flex() .justify_center() @@ -162,7 +173,14 @@ pub fn render_armed_wakeups( .text_color(color) .child(BRAILLE_FRAMES[frame]), ) - .child(div().text_size(rems(0.8125)).text_color(color).child(text)) + .child( + div() + .min_w_0() + .truncate() + .text_size(rems(0.8125)) + .text_color(color) + .child(single_line(&text)), + ) }; let mut lines = Vec::new(); @@ -180,6 +198,7 @@ pub fn render_armed_wakeups( let monitor_id = monitor.id; let stop = div() .id(("stop-monitor", monitor_id as usize)) + .flex_none() .text_size(rems(0.75)) .text_color(color) .cursor_pointer() diff --git a/crates/ui_gpui/src/messages/message_item.rs b/crates/ui_gpui/src/messages/message_item.rs index c9be2c5c5..7315f11c4 100644 --- a/crates/ui_gpui/src/messages/message_item.rs +++ b/crates/ui_gpui/src/messages/message_item.rs @@ -259,6 +259,7 @@ fn render_notification( cx: &Context, ) -> gpui_kit::AnyElement { use super::super::shared::file_icons; + use super::super::shared::text::single_line; let theme = cx.theme(); div() .px_3() @@ -280,9 +281,12 @@ fn render_notification( )) .child( div() + .debug_selector(|| "notification-label".into()) + .min_w_0() + .truncate() .text_size(rems(0.8125)) .text_color(theme.muted_foreground) - .child(notification.label.clone()), + .child(single_line(¬ification.label)), ), ) .when(!notification.body.is_empty(), |container| { diff --git a/crates/ui_gpui/src/settings_screen/general_section.rs b/crates/ui_gpui/src/settings_screen/general_section.rs index 8fae1e2c5..012870498 100644 --- a/crates/ui_gpui/src/settings_screen/general_section.rs +++ b/crates/ui_gpui/src/settings_screen/general_section.rs @@ -533,6 +533,7 @@ impl GeneralSection { ) .child( div() + .min_w_0() .text_xs() .font_family("monospace") .text_color(cx.theme().foreground) diff --git a/crates/ui_gpui/src/settings_screen/mod.rs b/crates/ui_gpui/src/settings_screen/mod.rs index 8eca6138b..8496a80fc 100644 --- a/crates/ui_gpui/src/settings_screen/mod.rs +++ b/crates/ui_gpui/src/settings_screen/mod.rs @@ -317,28 +317,38 @@ impl Render for SettingsScreen { ), ) // Right content area (scrollable) - .child(div().flex_1().h_full().overflow_y_scrollbar().p_6().child( - match self.active_section { - SettingsSection::General => { - self.general_section.clone().into_any_element() - } - SettingsSection::Providers => { - self.providers_section.clone().into_any_element() - } + .child( + div() + .flex_1() + .min_w_0() + .h_full() + .overflow_y_scrollbar() + .p_6() + .child(match self.active_section { + SettingsSection::General => { + self.general_section.clone().into_any_element() + } + SettingsSection::Providers => { + self.providers_section.clone().into_any_element() + } - SettingsSection::Models => { - self.models_section.clone().into_any_element() - } - SettingsSection::Skills => { - self.skills_section.clone().into_any_element() - } - SettingsSection::Tools => self.tools_section.clone().into_any_element(), - SettingsSection::McpServers => { - self.mcp_section.clone().into_any_element() - } - SettingsSection::Voice => self.voice_section.clone().into_any_element(), - }, - )), + SettingsSection::Models => { + self.models_section.clone().into_any_element() + } + SettingsSection::Skills => { + self.skills_section.clone().into_any_element() + } + SettingsSection::Tools => { + self.tools_section.clone().into_any_element() + } + SettingsSection::McpServers => { + self.mcp_section.clone().into_any_element() + } + SettingsSection::Voice => { + self.voice_section.clone().into_any_element() + } + }), + ), ) } } diff --git a/crates/ui_gpui/src/settings_screen/provider_forms/ai_core_form.rs b/crates/ui_gpui/src/settings_screen/provider_forms/ai_core_form.rs index 74e316b97..23b6e3190 100644 --- a/crates/ui_gpui/src/settings_screen/provider_forms/ai_core_form.rs +++ b/crates/ui_gpui/src/settings_screen/provider_forms/ai_core_form.rs @@ -607,6 +607,8 @@ impl Render for AiCoreProviderForm { .child( div() .flex_1() + .min_w_0() + .truncate() .text_xs() .text_color(cx.theme().foreground) .child(SharedString::from(entry_clone.model_id)), @@ -620,10 +622,10 @@ impl Render for AiCoreProviderForm { .child( div() .flex_1() + .min_w_0() + .truncate() .text_xs() .text_color(cx.theme().foreground) - .overflow_x_hidden() - .text_ellipsis() .child(SharedString::from(entry_clone.deployment_id)), ) .child( diff --git a/crates/ui_gpui/src/settings_screen/provider_forms/chatgpt_subscription_form.rs b/crates/ui_gpui/src/settings_screen/provider_forms/chatgpt_subscription_form.rs index 17bed6874..62e01f5d9 100644 --- a/crates/ui_gpui/src/settings_screen/provider_forms/chatgpt_subscription_form.rs +++ b/crates/ui_gpui/src/settings_screen/provider_forms/chatgpt_subscription_form.rs @@ -340,7 +340,13 @@ impl ChatGptSubscriptionForm { .text_color(cx.theme().muted_foreground) .child(SharedString::from(label.to_string())), ) - .child(div().text_xs().child(SharedString::from(value.to_string()))) + .child( + div() + .min_w_0() + .truncate() + .text_xs() + .child(SharedString::from(value.to_string())), + ) } } diff --git a/crates/ui_gpui/src/settings_screen/providers_section.rs b/crates/ui_gpui/src/settings_screen/providers_section.rs index a1e8956db..ba71ac51e 100644 --- a/crates/ui_gpui/src/settings_screen/providers_section.rs +++ b/crates/ui_gpui/src/settings_screen/providers_section.rs @@ -330,11 +330,13 @@ impl ProvidersSection { .child( div() .flex_1() + .min_w_0() .flex() .flex_col() .gap(px(2.)) .child( div() + .truncate() .text_sm() .font_weight(gpui_kit::FontWeight::MEDIUM) .text_color(cx.theme().foreground) @@ -342,6 +344,7 @@ impl ProvidersSection { ) .child( div() + .truncate() .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(subtitle)), @@ -915,6 +918,7 @@ impl ProvidersSection { .child( div() .flex_1() + .min_w_0() .flex() .flex_col() .gap(px(2.)) diff --git a/crates/ui_gpui/src/settings_screen/skills_section.rs b/crates/ui_gpui/src/settings_screen/skills_section.rs index ac6f80691..f6959ca73 100644 --- a/crates/ui_gpui/src/settings_screen/skills_section.rs +++ b/crates/ui_gpui/src/settings_screen/skills_section.rs @@ -151,6 +151,8 @@ impl SkillsSection { .gap_2() .child( div() + .min_w_0() + .truncate() .text_sm() .font_weight(gpui_kit::FontWeight::MEDIUM) .text_color(cx.theme().foreground) @@ -158,6 +160,7 @@ impl SkillsSection { ) .child( div() + .flex_none() .text_xs() .text_color(cx.theme().muted_foreground) .child(SharedString::from(format!( diff --git a/crates/ui_gpui/src/shared/file_icons.rs b/crates/ui_gpui/src/shared/file_icons.rs index 7476b394e..668351e5a 100644 --- a/crates/ui_gpui/src/shared/file_icons.rs +++ b/crates/ui_gpui/src/shared/file_icons.rs @@ -347,6 +347,7 @@ pub fn render_icon_container( let size_px = px(size); div() + .flex_none() .w(size_px) .h(size_px) .flex() diff --git a/crates/ui_gpui/src/shared/mod.rs b/crates/ui_gpui/src/shared/mod.rs index c4706dd21..5b93b6ba4 100644 --- a/crates/ui_gpui/src/shared/mod.rs +++ b/crates/ui_gpui/src/shared/mod.rs @@ -13,5 +13,6 @@ pub mod review_cache; pub mod review_draft_store; pub mod segmented_switch; pub mod settings; +pub mod text; pub mod theme; pub mod ui_state; diff --git a/crates/ui_gpui/src/shared/plan_banner.rs b/crates/ui_gpui/src/shared/plan_banner.rs index b47aaf103..674b8d289 100644 --- a/crates/ui_gpui/src/shared/plan_banner.rs +++ b/crates/ui_gpui/src/shared/plan_banner.rs @@ -1,5 +1,6 @@ use super::collapse_animation::CollapseAnimator; use super::file_icons; +use super::text::single_line; use code_assistant_core::types::{PlanItemStatus, PlanState}; use gpui_kit::prelude::*; @@ -131,7 +132,7 @@ impl Render for PlanBanner { let header = if show_inline_active { if let Some(active) = in_progress_item { - let truncated = truncate_text(&normalize_single_line(&active.content), 60); + let content = single_line(&active.content); div() .flex() .items_center() @@ -169,13 +170,13 @@ impl Render for PlanBanner { ) .child( div() + .debug_selector(|| "plan-active-item".into()) .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() + .truncate() .text_size(rems(0.6875)) .text_color(cx.theme().info) - .child(SharedString::from(truncated)), + .child(SharedString::from(content)), ), ) .child( @@ -390,7 +391,7 @@ fn render_plan_items(plan: &PlanState, cx: &mut Context) -> gpui_kit .text_size(rems(0.75)) .line_height(rems(1.125)) .text_color(text_color) - .child(SharedString::from(normalize_single_line(&entry.content))), + .child(SharedString::from(single_line(&entry.content))), ) })) } @@ -399,28 +400,6 @@ fn render_plan_items(plan: &PlanState, cx: &mut Context) -> gpui_kit // Text helpers // --------------------------------------------------------------------------- -fn normalize_single_line(content: &str) -> String { - content - .lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" ") -} - -fn truncate_text(text: &str, max_len: usize) -> String { - if text.chars().count() <= max_len { - text.to_string() - } else { - let mut truncated = text - .chars() - .take(max_len.saturating_sub(1)) - .collect::(); - truncated.push('…'); - truncated - } -} - #[cfg(test)] mod tests { use super::*; @@ -553,16 +532,19 @@ mod tests { .unwrap(); } - #[test] - fn test_normalize_single_line() { - assert_eq!(normalize_single_line(" hello \n world "), "hello world"); - assert_eq!(normalize_single_line("single"), "single"); - assert_eq!(normalize_single_line(" \n \n "), ""); - } - - #[test] - fn test_truncate_text() { - assert_eq!(truncate_text("short", 10), "short"); - assert_eq!(truncate_text("a long text", 6), "a lon…"); + #[gpui_kit::test] + fn a_long_active_item_is_cut_at_the_available_width(cx: &mut TestAppContext) { + cx.update(init_test_globals); + let (banner, cx) = cx.add_window_view(|_, cx| PlanBanner::new(cx)); + let content = "a long plan item ".repeat(40); + let plan = make_plan(vec![(content.as_str(), PlanItemStatus::InProgress)]); + banner.update(cx, |banner, cx| banner.set_plan(Some(plan), true, cx)); + cx.run_until_parked(); + + let item = cx.debug_bounds("plan-active-item").unwrap(); + let window = cx.update(|window, _| window.viewport_size()); + assert!(item.right() <= window.width, "{item:?} in {window:?}"); + // One line, not wrapped. + assert!(item.size.height < gpui_kit::px(30.), "{item:?}"); } } diff --git a/crates/ui_gpui/src/shared/text.rs b/crates/ui_gpui/src/shared/text.rs new file mode 100644 index 000000000..8b9b725a9 --- /dev/null +++ b/crates/ui_gpui/src/shared/text.rs @@ -0,0 +1,48 @@ +//! Dynamic text for one-line rows. +//! +//! Rows cut text at the width they have (`.truncate()`, or +//! `.line_clamp(n).text_ellipsis()`), never at a number of characters. A flex +//! item only gets narrower than its text with `min_w_0()` or +//! `overflow_hidden()`, on itself and on any flex item around it. + +/// More than any window shows on a line. Keeps a huge tool parameter from +/// being shaped as a whole on every layout just to be cut anyway. +const MAX_SHAPED_CHARS: usize = 1024; + +/// The start of `text` that is worth laying out. +pub fn visible_head(text: &str) -> &str { + text.char_indices() + .nth(MAX_SHAPED_CHARS) + .map_or(text, |(ix, _)| &text[..ix]) +} + +/// `text` on one line: `.truncate()` would keep its line breaks and show +/// several lines. +pub fn single_line(text: &str) -> String { + visible_head(text) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_breaks_become_spaces() { + assert_eq!(single_line(" hello \n\n world "), "hello world"); + assert_eq!(single_line("single"), "single"); + assert_eq!(single_line(" \n \n "), ""); + } + + #[test] + fn huge_text_is_limited_on_a_character_boundary() { + let text = "ä".repeat(MAX_SHAPED_CHARS + 10); + assert_eq!(single_line(&text).chars().count(), MAX_SHAPED_CHARS); + let short = "ä".repeat(10); + assert_eq!(visible_head(&short), short); + } +} diff --git a/crates/ui_gpui/src/sidebar/project_picker.rs b/crates/ui_gpui/src/sidebar/project_picker.rs index 694080f69..9e0bcb00b 100644 --- a/crates/ui_gpui/src/sidebar/project_picker.rs +++ b/crates/ui_gpui/src/sidebar/project_picker.rs @@ -246,8 +246,7 @@ impl ProjectPicker { div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() + .truncate() .text_sm() .text_color(cx.theme().foreground) .child(SharedString::from(title)), diff --git a/crates/ui_gpui/src/sidebar/projects_view.rs b/crates/ui_gpui/src/sidebar/projects_view.rs index 5d66f702d..719c2e287 100644 --- a/crates/ui_gpui/src/sidebar/projects_view.rs +++ b/crates/ui_gpui/src/sidebar/projects_view.rs @@ -329,8 +329,7 @@ impl SessionSidebar { div() .flex_1() .min_w_0() - .overflow_hidden() - .text_ellipsis() + .truncate() .text_xs() .font_weight(gpui_kit::FontWeight::MEDIUM) .text_color(cx.theme().foreground) diff --git a/crates/ui_gpui/src/sidebar/session_item.rs b/crates/ui_gpui/src/sidebar/session_item.rs index 0e254c74b..ecc735f78 100644 --- a/crates/ui_gpui/src/sidebar/session_item.rs +++ b/crates/ui_gpui/src/sidebar/session_item.rs @@ -534,8 +534,7 @@ impl Render for SessionListItem { .child( div() .w_full() - .overflow_hidden() - .text_ellipsis() + .truncate() .text_xs() .text_color(title_color) .when(unread, |el| el.font_semibold()) @@ -552,8 +551,7 @@ impl Render for SessionListItem { .child( div() .min_w_0() - .overflow_hidden() - .text_ellipsis() + .truncate() .text_color(subtitle_color) .child(SharedString::from(subtitle)), ) diff --git a/crates/ui_gpui/src/tool_cards/code_card.rs b/crates/ui_gpui/src/tool_cards/code_card.rs index e05822144..7fc360532 100644 --- a/crates/ui_gpui/src/tool_cards/code_card.rs +++ b/crates/ui_gpui/src/tool_cards/code_card.rs @@ -31,28 +31,18 @@ impl ToolBlockRenderer for CodeCardRenderer { match tool.name.as_str() { "read_files" => { if let Some(paths) = get_param(tool, "paths") { - let display = if paths.len() > 80 { - format!("{}…", &paths[..77]) - } else { - paths.to_string() - }; - format!("Read {}", display) + format!("Read {paths}") } else { "Read files".to_string() } } "search_files" => { if let Some(regex) = get_param(tool, "regex") { - let display = if regex.len() > 60 { - format!("{}…", ®ex[..57]) - } else { - regex.to_string() - }; // Trim surrounding quotes from the display value to avoid // visual duplication with the wrapping quotes we add // (e.g. regex `cursor_"` would otherwise show as // `Search for "cursor_""`) - let trimmed = display.strip_suffix('"').unwrap_or(&display); + let trimmed = regex.strip_suffix('"').unwrap_or(regex); let trimmed = trimmed.strip_prefix('"').unwrap_or(trimmed); format!("Search for \"{}\"", trimmed) } else { diff --git a/crates/ui_gpui/src/tool_cards/diff_card.rs b/crates/ui_gpui/src/tool_cards/diff_card.rs index f8d66997a..b4bb0ce99 100644 --- a/crates/ui_gpui/src/tool_cards/diff_card.rs +++ b/crates/ui_gpui/src/tool_cards/diff_card.rs @@ -131,9 +131,10 @@ impl ToolBlockRenderer for DiffCardRenderer { }; let label = div() .id(SharedString::from(format!("tool-path-{}", tool.id))) + .min_w_0() + .truncate() .text_size(rems(0.75)) .text_color(header_text_color) - .overflow_hidden() .child(header_label); // A single, still existing file opens in the Files view. let single_path = get_param(tool, "path").filter(|_| tool.name != "delete_files"); diff --git a/crates/ui_gpui/src/tool_cards/inline_renderer.rs b/crates/ui_gpui/src/tool_cards/inline_renderer.rs index ad6fbaea5..048d45fbe 100644 --- a/crates/ui_gpui/src/tool_cards/inline_renderer.rs +++ b/crates/ui_gpui/src/tool_cards/inline_renderer.rs @@ -90,13 +90,7 @@ impl InlineToolRenderer { for param in &tool.parameters { let placeholder = format!("{{{}}}", param.name); if result.contains(&placeholder) { - // Truncate long values for the description line - let display_value = if param.value.len() > 80 { - format!("{}…", ¶m.value[..77]) - } else { - param.value.clone() - }; - result = result.replace(&placeholder, &display_value); + result = result.replace(&placeholder, ¶m.value); } } result diff --git a/crates/ui_gpui/src/tool_cards/mod.rs b/crates/ui_gpui/src/tool_cards/mod.rs index 0ea9b2c5a..235fc84af 100644 --- a/crates/ui_gpui/src/tool_cards/mod.rs +++ b/crates/ui_gpui/src/tool_cards/mod.rs @@ -319,13 +319,18 @@ mod tests { assert_eq!(renderer.describe(&tool), "Read files"); } + /// The row cuts them at its width; a slice at a byte count used to panic + /// inside a multi-byte character. #[test] - fn test_describe_long_value_truncated() { + fn test_describe_keeps_long_values() { let renderer = code_card::CodeCardRenderer; - let long_path = "a".repeat(100); + let long_path = "ä".repeat(100); let tool = make_tool("read_files", &[("paths", &long_path)]); - let desc = renderer.describe(&tool); - assert!(desc.len() < 100); - assert!(desc.ends_with('…')); + assert_eq!(renderer.describe(&tool), format!("Read {long_path}")); + let tool = make_tool("search_files", &[("regex", &long_path)]); + assert_eq!( + renderer.describe(&tool), + format!("Search for \"{long_path}\"") + ); } } diff --git a/crates/ui_gpui/src/tool_cards/sub_agent_line.rs b/crates/ui_gpui/src/tool_cards/sub_agent_line.rs index d7803c898..5ac6ef869 100644 --- a/crates/ui_gpui/src/tool_cards/sub_agent_line.rs +++ b/crates/ui_gpui/src/tool_cards/sub_agent_line.rs @@ -13,9 +13,6 @@ use gpui_kit::{ StatefulInteractiveElement, Styled, Window, div, px, rems, }; -/// Longest instruction excerpt the line shows. -const LABEL_CHARS: usize = 80; - pub struct SubAgentLineRenderer; fn param<'a>(tool: &'a ToolUseBlock, name: &str) -> Option<&'a str> { @@ -29,18 +26,11 @@ fn output(tool: &ToolUseBlock) -> Option { tool.output.as_deref().and_then(SubAgentOutput::from_json) } -/// The first line of the instructions, cut at [`LABEL_CHARS`]. -fn label(tool: &ToolUseBlock) -> String { - let Some(line) = - param(tool, "instructions").and_then(|i| i.lines().map(str::trim).find(|l| !l.is_empty())) - else { - return "Sub-agent".into(); - }; - if line.chars().count() <= LABEL_CHARS { - return line.to_string(); - } - let cut: String = line.chars().take(LABEL_CHARS).collect(); - format!("{}…", cut.trim_end()) +/// The first line of the instructions. +fn label(tool: &ToolUseBlock) -> &str { + param(tool, "instructions") + .and_then(|i| i.lines().map(str::trim).find(|l| !l.is_empty())) + .unwrap_or_default() } impl ToolBlockRenderer for SubAgentLineRenderer { @@ -60,7 +50,11 @@ impl ToolBlockRenderer for SubAgentLineRenderer { _ if tool.status == ToolStatus::Error => "failed", _ => "running", }; - format!("Sub-agent · {} {state}", label(tool)) + // The state comes first: the row cuts a long label at its width. + match label(tool) { + "" => format!("Sub-agent {state}"), + label => format!("Sub-agent {state} · {label}"), + } } fn header_tag(&self, tool: &ToolUseBlock) -> Option { @@ -145,7 +139,7 @@ mod tests { ); assert_eq!( SubAgentLineRenderer.describe(&tool), - "Sub-agent · Find the parser running" + "Sub-agent running · Find the parser" ); assert_eq!(SubAgentLineRenderer.header_tag(&tool), None); @@ -155,7 +149,7 @@ mod tests { tool.output = Some(done.to_json()); assert_eq!( SubAgentLineRenderer.describe(&tool), - "Sub-agent · Find the parser finished" + "Sub-agent finished · Find the parser" ); assert_eq!( SubAgentLineRenderer.header_tag(&tool).as_deref(), @@ -166,16 +160,13 @@ mod tests { tool.output = None; assert_eq!( SubAgentLineRenderer.describe(&tool), - "Sub-agent · Find the parser failed" + "Sub-agent failed · Find the parser" ); } #[test] - fn long_instructions_are_cut() { - let long = "word ".repeat(40); - let tool = make_tool("spawn_agent", &[("instructions", long.as_str())]); - let line = SubAgentLineRenderer.describe(&tool); - assert!(line.ends_with("… running"), "{line}"); - assert!(line.chars().count() < LABEL_CHARS + 30, "{line}"); + fn no_instructions_leave_just_the_state() { + let tool = make_tool("spawn_agent", &[]); + assert_eq!(SubAgentLineRenderer.describe(&tool), "Sub-agent running"); } } diff --git a/crates/ui_gpui/src/tool_cards/terminal_card.rs b/crates/ui_gpui/src/tool_cards/terminal_card.rs index 6c860ffe9..698e3b2c2 100644 --- a/crates/ui_gpui/src/tool_cards/terminal_card.rs +++ b/crates/ui_gpui/src/tool_cards/terminal_card.rs @@ -10,6 +10,7 @@ use super::{CardRenderContext, ToolBlockRenderer, ToolBlockStyle, animated_card_ use crate::Gpui; use crate::blocks::{BlockView, ToolUseBlock}; use crate::shared::file_icons; +use crate::shared::text::{single_line, visible_head}; use crate::terminal::pool::TerminalPool; use code_assistant_core::ui::ToolStatus; use gpui_kit::AppContext as _; // brings .new() into scope on Context @@ -102,8 +103,7 @@ impl ToolBlockRenderer for TerminalCardRenderer { fn describe(&self, tool: &ToolUseBlock) -> String { // Extract command_line parameter for description if let Some(cmd) = tool.parameters.iter().find(|p| p.name == "command_line") { - let display = truncate_str(&cmd.value, 60); - format!("$ {}", display) + format!("$ {}", cmd.value) } else { "execute command".to_string() } @@ -309,10 +309,12 @@ impl ToolBlockRenderer for TerminalCardRenderer { .overflow_hidden() .child( div() + .min_w_0() + .line_clamp(2) + .text_ellipsis() .text_size(rems(0.75)) .text_color(header_text_color) - .overflow_hidden() - .child(truncate_str(&display_command, 200)), + .child(SharedString::from(visible_head(display_command.trim()))), ); // Show project name in parentheses if it differs from the session project @@ -330,7 +332,7 @@ impl ToolBlockRenderer for TerminalCardRenderer { header_left = header_left.child(label_row); } - let mut header_right = div().flex().flex_row().items_center().gap_2(); + let mut header_right = div().flex_none().flex().flex_row().items_center().gap_2(); if is_running { // Running: spinning icon + elapsed time (after 2s) @@ -571,7 +573,7 @@ impl TerminalCardRenderer { let header_label = if !cwd_text.is_empty() { cwd_text } else { - truncate_str(command, 50) + single_line(command) }; let terminal_icon = file_icons::get().get_tool_icon("execute_command"); @@ -594,6 +596,7 @@ impl TerminalCardRenderer { .items_center() .child( div() + .min_w_0() .flex() .flex_row() .items_center() @@ -606,6 +609,8 @@ impl TerminalCardRenderer { )) .child( div() + .min_w_0() + .truncate() .text_size(rems(0.75)) .text_color(header_text_color) .child(header_label), @@ -613,6 +618,7 @@ impl TerminalCardRenderer { ) .child( div() + .flex_none() .flex() .flex_row() .items_center() @@ -670,19 +676,22 @@ impl TerminalCardRenderer { div() .flex() .flex_row() - .items_center() .gap_1p5() .child( div() + .flex_none() .text_size(rems(0.75)) .text_color(theme.muted_foreground.opacity(0.6)) .child("$"), ) .child( div() + .min_w_0() + .line_clamp(2) + .text_ellipsis() .text_size(rems(0.78125)) .text_color(theme.foreground) - .child(truncate_str(command, 80)), + .child(SharedString::from(visible_head(command.trim()))), ), ), ) @@ -858,17 +867,6 @@ fn abbreviate_path(path: &str) -> String { path.to_string() } -/// Truncate a string to at most `max_chars` characters, adding "…" if truncated. -fn truncate_str(s: &str, max_chars: usize) -> String { - let char_count = s.chars().count(); - if char_count > max_chars { - let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect(); - format!("{}…", truncated) - } else { - s.to_string() - } -} - /// Format elapsed seconds into a human-readable string. fn format_elapsed(secs: u64) -> String { if secs < 60 { From 8cf7299ce5ac3b6b3ab456df0a51d6ff3b8bc849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 5 Nov 2026 10:43:30 +0100 Subject: [PATCH 2/2] Note how the GPUI frontend cuts text --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 3b89c1d48..e63ed4d31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,9 @@ Layer 5 (binary): code_assistant — CLI, config, feature-gated frontend - GPUI frontend based on gpui-kit (gpui via `gpui_kit::*`, gpui-component via `gpui_kit::component`) with custom components - GPUI API reference (contexts, entities, tasks, elements, actions, events): `docs/gpui-reference.md` +- Text is cut at the available width, never at a character count: `.truncate()` + or `.line_clamp(n).text_ellipsis()`, with `min_w_0()` on flex items around it + and `shared::text::single_line` for text that may hold line breaks - The right panel shows the agent's browser live (CDP screencast, feature `browser-panel`, default): `docs/browser-panel-plan.md`. GPUI must not run inside a tokio `block_on` (tokio's cooperation budget would never renew on