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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/agent_ui/src/terminal_thread_metadata_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ pub(crate) fn terminal_title_without_prefix(title: &str) -> &str {
.unwrap_or(title)
}

fn terminal_title_prefix(title: &str) -> Option<&str> {
pub fn terminal_title_prefix(title: &str) -> Option<&str> {
let mut prefix_byte_len = 0;
let mut saw_prefix_character = false;
let mut saw_whitespace_after_prefix = false;
Expand Down
1 change: 1 addition & 0 deletions crates/sidebar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
unicode-segmentation.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
Expand Down
63 changes: 52 additions & 11 deletions crates/sidebar/src/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use action_log::DiffStats;
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
use agent_ui::terminal_thread_metadata_store::{
TerminalThreadMetadata, TerminalThreadMetadataStore,
TerminalThreadMetadata, TerminalThreadMetadataStore, terminal_title_prefix,
};
use agent_ui::thread_metadata_store::{
ThreadMetadata, ThreadMetadataStore, WorktreePaths, worktree_info_from_thread_paths,
Expand Down Expand Up @@ -55,6 +55,7 @@ use ui::{
Scrollbars, Tab, ThreadItem, ThreadItemWorktreeInfo, TintColor, Tooltip, WithScrollbar,
prelude::*, render_modifiers,
};
use unicode_segmentation::UnicodeSegmentation as _;
use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
Expand Down Expand Up @@ -226,37 +227,77 @@ impl ThreadEntryWorkspace {
}
}

/// If the title begins with a non-letter, non-whitespace character (such as a
/// leading emoji or symbol the user prefixed their title with), splits that
/// character out so it can be displayed in place of the entry's icon
/// If the title begins with a decorative prefix (such as a leading emoji,
/// spinner glyph, or symbol the agent prefixed the title with), splits that
/// prefix off so a single representative glyph can be displayed in place of the
/// entry's icon.
fn split_leading_icon_char(
title: &SharedString,
highlight_positions: &[usize],
) -> Option<(SharedString, SharedString, Vec<usize>)> {
let first_char = title.chars().next()?;
if first_char.is_alphabetic() || first_char.is_whitespace() {
return None;
}
let prefix = terminal_title_prefix(title)?;
let icon_char = pick_icon_glyph(prefix)?;

let trimmed_title = title[first_char.len_utf8()..].trim_start();
let stripped_len = prefix.len();
let trimmed_title = &title[stripped_len..];
if trimmed_title.is_empty() {
return None;
}

let stripped_len = title.len() - trimmed_title.len();
let adjusted_positions = highlight_positions
.iter()
.filter(|&&position| position >= stripped_len)
.map(|&position| position - stripped_len)
.collect();

Some((
first_char.to_string().into(),
icon_char,
trimmed_title.to_string().into(),
adjusted_positions,
))
}

/// Picks a single glyph to render as the icon from a detected title prefix.
///
/// We only ever show one glyph, so this makes a best effort to choose a
/// meaningful one by glancing at the leading characters of the prefix:
/// runs of `.` are condensed into a single ellipsis, surrounding ASCII brackets
/// are stripped (so `[!]` yields `!`), and a leading run of the same character
/// is collapsed (so `>>>` yields `>`). The result is the first grapheme cluster
/// of whatever remains, keeping multi-codepoint emoji intact.
fn pick_icon_glyph(prefix: &str) -> Option<SharedString> {
let prefix = prefix.trim();
if prefix.is_empty() {
return None;
}

// Condense a leading run of dots (`...`) into a single ellipsis.
if prefix.starts_with("..") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably do this after stripping the brackets, to catch [..] and [...] but I can fast follow with that!

return Some("\u{2026}".into());
}

// Strip a single pair of surrounding ASCII brackets, e.g. `[!]` -> `!`.
let unwrapped = match prefix.chars().next() {
Some('[') => prefix.strip_prefix('[').and_then(|s| s.strip_suffix(']')),
Some('(') => prefix.strip_prefix('(').and_then(|s| s.strip_suffix(')')),
Some('{') => prefix.strip_prefix('{').and_then(|s| s.strip_suffix('}')),
Some('<') => prefix.strip_prefix('<').and_then(|s| s.strip_suffix('>')),
_ => None,
};
let prefix = unwrapped
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(prefix);

// Take the first grapheme cluster so multi-codepoint emoji stay intact.
let first_grapheme = prefix.graphemes(true).next()?;
if first_grapheme.trim().is_empty() {
return None;
}

Some(first_grapheme.to_string().into())
}

fn draft_display_label_for_thread_metadata(
metadata: &ThreadMetadata,
workspace: &ThreadEntryWorkspace,
Expand Down
38 changes: 30 additions & 8 deletions crates/sidebar/src/sidebar_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14733,26 +14733,48 @@ async fn test_cmd_click_project_header_returns_to_last_active_linked_worktree_wo

#[test]
fn test_split_leading_icon_char() {
// A leading symbol is pulled out and trimmed from the title.
// A leading symbol set off by whitespace is pulled out and trimmed from the
// title.
let (icon, title, positions) =
split_leading_icon_char(&"✳ Implement separate config".into(), &[]).unwrap();
assert_eq!(icon.as_ref(), "✳");
assert_eq!(title.as_ref(), "Implement separate config");
assert_eq!(positions, Vec::<usize>::new());

// No leading symbol when the title starts with a letter.
// No prefix when the title starts with a letter.
assert!(split_leading_icon_char(&"Implement separate config".into(), &[]).is_none());

// Whitespace is not treated as an icon character.
// Leading whitespace is not treated as a prefix.
assert!(split_leading_icon_char(&" leading space".into(), &[]).is_none());

// Numbers are non-letters, so they are treated as icon characters.
let (icon, title, _) = split_leading_icon_char(&"1 first".into(), &[]).unwrap();
assert_eq!(icon.as_ref(), "1");
assert_eq!(title.as_ref(), "first");
// An alphanumeric prefix such as a version marker is not treated as an icon.
assert!(split_leading_icon_char(&"v1 Running".into(), &[]).is_none());
assert!(split_leading_icon_char(&"1 first".into(), &[]).is_none());

// A title consisting only of a symbol is left untouched.
// A title consisting only of a symbol (no whitespace separator) is left
// untouched.
assert!(split_leading_icon_char(&"✳".into(), &[]).is_none());
assert!(split_leading_icon_char(&"✳Thinking".into(), &[]).is_none());

// A run of the same symbol collapses to a single glyph.
let (icon, title, _) = split_leading_icon_char(&">>> Thinking".into(), &[]).unwrap();
assert_eq!(icon.as_ref(), ">");
assert_eq!(title.as_ref(), "Thinking");

// Surrounding ASCII brackets are stripped so the inner glyph is used.
let (icon, title, _) = split_leading_icon_char(&"[!] codex waiting".into(), &[]).unwrap();
assert_eq!(icon.as_ref(), "!");
assert_eq!(title.as_ref(), "codex waiting");

// A run of dots is condensed into an ellipsis.
let (icon, title, _) = split_leading_icon_char(&"... working".into(), &[]).unwrap();
assert_eq!(icon.as_ref(), "\u{2026}");
assert_eq!(title.as_ref(), "working");

// Multi-codepoint emoji are kept intact rather than sliced mid-cluster.
let (icon, title, _) = split_leading_icon_char(&"🇺🇸 flag".into(), &[]).unwrap();
assert_eq!(icon.as_ref(), "🇺🇸");
assert_eq!(title.as_ref(), "flag");

// Highlight positions are shifted to account for the stripped prefix, and
// positions that fall inside the stripped prefix are dropped.
Expand Down
Loading