assistant2: Allow clicking on @mentions (#27846)

https://github.com/user-attachments/assets/f6f7c115-5c40-48f9-a099-2b691993967b

Release Notes:

- N/A
This commit is contained in:
Bennet Bo Fenner 2025-04-01 17:44:20 +02:00 committed by GitHub
parent 12037dc2c6
commit feb1d37798
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 266 additions and 39 deletions

View file

@ -1,5 +1,6 @@
use crate::AssistantPanel; use crate::AssistantPanel;
use crate::context::{AssistantContext, ContextId}; use crate::context::{AssistantContext, ContextId};
use crate::context_picker::MentionLink;
use crate::thread::{ use crate::thread::{
LastRestoreCheckpoint, MessageId, MessageSegment, RequestKind, Thread, ThreadError, LastRestoreCheckpoint, MessageId, MessageSegment, RequestKind, Thread, ThreadError,
ThreadEvent, ThreadFeedback, ThreadEvent, ThreadFeedback,
@ -7,8 +8,10 @@ use crate::thread::{
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus}; use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus};
use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill}; use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill};
use anyhow::Context as _;
use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting}; use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting};
use collections::HashMap; use collections::HashMap;
use editor::scroll::Autoscroll;
use editor::{Editor, MultiBuffer}; use editor::{Editor, MultiBuffer};
use gpui::{ use gpui::{
AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength, AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength,
@ -63,6 +66,7 @@ impl RenderedMessage {
fn from_segments( fn from_segments(
segments: &[MessageSegment], segments: &[MessageSegment],
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
window: &Window, window: &Window,
cx: &mut App, cx: &mut App,
) -> Self { ) -> Self {
@ -71,12 +75,18 @@ impl RenderedMessage {
segments: Vec::with_capacity(segments.len()), segments: Vec::with_capacity(segments.len()),
}; };
for segment in segments { for segment in segments {
this.push_segment(segment, window, cx); this.push_segment(segment, workspace.clone(), window, cx);
} }
this this
} }
fn append_thinking(&mut self, text: &String, window: &Window, cx: &mut App) { fn append_thinking(
&mut self,
text: &String,
workspace: WeakEntity<Workspace>,
window: &Window,
cx: &mut App,
) {
if let Some(RenderedMessageSegment::Thinking { if let Some(RenderedMessageSegment::Thinking {
content, content,
scroll_handle, scroll_handle,
@ -88,13 +98,25 @@ impl RenderedMessage {
scroll_handle.scroll_to_bottom(); scroll_handle.scroll_to_bottom();
} else { } else {
self.segments.push(RenderedMessageSegment::Thinking { self.segments.push(RenderedMessageSegment::Thinking {
content: render_markdown(text.into(), self.language_registry.clone(), window, cx), content: render_markdown(
text.into(),
self.language_registry.clone(),
workspace,
window,
cx,
),
scroll_handle: ScrollHandle::default(), scroll_handle: ScrollHandle::default(),
}); });
} }
} }
fn append_text(&mut self, text: &String, window: &Window, cx: &mut App) { fn append_text(
&mut self,
text: &String,
workspace: WeakEntity<Workspace>,
window: &Window,
cx: &mut App,
) {
if let Some(RenderedMessageSegment::Text(markdown)) = self.segments.last_mut() { if let Some(RenderedMessageSegment::Text(markdown)) = self.segments.last_mut() {
markdown.update(cx, |markdown, cx| markdown.append(text, cx)); markdown.update(cx, |markdown, cx| markdown.append(text, cx));
} else { } else {
@ -102,21 +124,35 @@ impl RenderedMessage {
.push(RenderedMessageSegment::Text(render_markdown( .push(RenderedMessageSegment::Text(render_markdown(
SharedString::from(text), SharedString::from(text),
self.language_registry.clone(), self.language_registry.clone(),
workspace,
window, window,
cx, cx,
))); )));
} }
} }
fn push_segment(&mut self, segment: &MessageSegment, window: &Window, cx: &mut App) { fn push_segment(
&mut self,
segment: &MessageSegment,
workspace: WeakEntity<Workspace>,
window: &Window,
cx: &mut App,
) {
let rendered_segment = match segment { let rendered_segment = match segment {
MessageSegment::Thinking(text) => RenderedMessageSegment::Thinking { MessageSegment::Thinking(text) => RenderedMessageSegment::Thinking {
content: render_markdown(text.into(), self.language_registry.clone(), window, cx), content: render_markdown(
text.into(),
self.language_registry.clone(),
workspace,
window,
cx,
),
scroll_handle: ScrollHandle::default(), scroll_handle: ScrollHandle::default(),
}, },
MessageSegment::Text(text) => RenderedMessageSegment::Text(render_markdown( MessageSegment::Text(text) => RenderedMessageSegment::Text(render_markdown(
text.into(), text.into(),
self.language_registry.clone(), self.language_registry.clone(),
workspace,
window, window,
cx, cx,
)), )),
@ -136,6 +172,7 @@ enum RenderedMessageSegment {
fn render_markdown( fn render_markdown(
text: SharedString, text: SharedString,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
workspace: WeakEntity<Workspace>,
window: &Window, window: &Window,
cx: &mut App, cx: &mut App,
) -> Entity<Markdown> { ) -> Entity<Markdown> {
@ -210,7 +247,80 @@ fn render_markdown(
..Default::default() ..Default::default()
}; };
cx.new(|cx| Markdown::new(text, markdown_style, Some(language_registry), None, cx)) cx.new(|cx| {
Markdown::new(text, markdown_style, Some(language_registry), None, cx).open_url(
move |text, window, cx| {
open_markdown_link(text, workspace.clone(), window, cx);
},
)
})
}
fn open_markdown_link(
text: SharedString,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) {
let Some(workspace) = workspace.upgrade() else {
cx.open_url(&text);
return;
};
match MentionLink::try_parse(&text, &workspace, cx) {
Some(MentionLink::File(path, entry)) => workspace.update(cx, |workspace, cx| {
if entry.is_dir() {
workspace.project().update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(entry.id));
})
} else {
workspace
.open_path(path, None, true, window, cx)
.detach_and_log_err(cx);
}
}),
Some(MentionLink::Symbol(path, symbol_name)) => {
let open_task = workspace.update(cx, |workspace, cx| {
workspace.open_path(path, None, true, window, cx)
});
window
.spawn(cx, async move |cx| {
let active_editor = open_task
.await?
.downcast::<Editor>()
.context("Item is not an editor")?;
active_editor.update_in(cx, |editor, window, cx| {
let symbol_range = editor
.buffer()
.read(cx)
.snapshot(cx)
.outline(None)
.and_then(|outline| {
outline
.find_most_similar(&symbol_name)
.map(|(_, item)| item.range.clone())
})
.context("Could not find matching symbol")?;
editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
s.select_anchor_ranges([symbol_range.start..symbol_range.start])
});
anyhow::Ok(())
})
})
.detach_and_log_err(cx);
}
Some(MentionLink::Thread(thread_id)) => workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
panel.update(cx, |panel, cx| {
panel
.open_thread(&thread_id, window, cx)
.detach_and_log_err(cx)
});
}
}),
None => cx.open_url(&text),
}
} }
struct EditMessageState { struct EditMessageState {
@ -318,8 +428,13 @@ impl ActiveThread {
self.messages.push(*id); self.messages.push(*id);
self.list_state.splice(old_len..old_len, 1); self.list_state.splice(old_len..old_len, 1);
let rendered_message = let rendered_message = RenderedMessage::from_segments(
RenderedMessage::from_segments(segments, self.language_registry.clone(), window, cx); segments,
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
);
self.rendered_messages_by_id.insert(*id, rendered_message); self.rendered_messages_by_id.insert(*id, rendered_message);
} }
@ -334,8 +449,13 @@ impl ActiveThread {
return; return;
}; };
self.list_state.splice(index..index + 1, 1); self.list_state.splice(index..index + 1, 1);
let rendered_message = let rendered_message = RenderedMessage::from_segments(
RenderedMessage::from_segments(segments, self.language_registry.clone(), window, cx); segments,
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
);
self.rendered_messages_by_id.insert(*id, rendered_message); self.rendered_messages_by_id.insert(*id, rendered_message);
} }
@ -360,6 +480,7 @@ impl ActiveThread {
render_markdown( render_markdown(
tool_label.into(), tool_label.into(),
self.language_registry.clone(), self.language_registry.clone(),
self.workspace.clone(),
window, window,
cx, cx,
), ),
@ -401,12 +522,12 @@ impl ActiveThread {
} }
ThreadEvent::StreamedAssistantText(message_id, text) => { ThreadEvent::StreamedAssistantText(message_id, text) => {
if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
rendered_message.append_text(text, window, cx); rendered_message.append_text(text, self.workspace.clone(), window, cx);
} }
} }
ThreadEvent::StreamedAssistantThinking(message_id, text) => { ThreadEvent::StreamedAssistantThinking(message_id, text) => {
if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) { if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
rendered_message.append_thinking(text, window, cx); rendered_message.append_thinking(text, self.workspace.clone(), window, cx);
} }
} }
ThreadEvent::MessageAdded(message_id) => { ThreadEvent::MessageAdded(message_id) => {

View file

@ -16,7 +16,7 @@ use gpui::{
App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
}; };
use multi_buffer::MultiBufferRow; use multi_buffer::MultiBufferRow;
use project::ProjectPath; use project::{Entry, ProjectPath};
use symbol_context_picker::SymbolContextPicker; use symbol_context_picker::SymbolContextPicker;
use thread_context_picker::{ThreadContextEntry, render_thread_context_entry}; use thread_context_picker::{ThreadContextEntry, render_thread_context_entry};
use ui::{ use ui::{
@ -30,6 +30,7 @@ use crate::context_picker::fetch_context_picker::FetchContextPicker;
use crate::context_picker::file_context_picker::FileContextPicker; use crate::context_picker::file_context_picker::FileContextPicker;
use crate::context_picker::thread_context_picker::ThreadContextPicker; use crate::context_picker::thread_context_picker::ThreadContextPicker;
use crate::context_store::ContextStore; use crate::context_store::ContextStore;
use crate::thread::ThreadId;
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -568,6 +569,7 @@ pub(crate) fn insert_crease_for_mention(
return; return;
}; };
let start = start.bias_right(&snapshot);
let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len); let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
let placeholder = FoldPlaceholder { let placeholder = FoldPlaceholder {
@ -677,3 +679,77 @@ fn fold_toggle(
.into_any_element() .into_any_element()
} }
} }
pub enum MentionLink {
File(ProjectPath, Entry),
Symbol(ProjectPath, String),
Thread(ThreadId),
}
impl MentionLink {
pub fn for_file(file_name: &str, full_path: &str) -> String {
format!("[@{}](file:{})", file_name, full_path)
}
pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name)
}
pub fn for_fetch(url: &str) -> String {
format!("[@{}]({})", url, url)
}
pub fn for_thread(thread: &ThreadContextEntry) -> String {
format!("[@{}](thread:{})", thread.summary, thread.id)
}
pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
fn extract_project_path_from_link(
path: &str,
workspace: &Entity<Workspace>,
cx: &App,
) -> Option<ProjectPath> {
let path = PathBuf::from(path);
let worktree_name = path.iter().next()?;
let path: PathBuf = path.iter().skip(1).collect();
let worktree_id = workspace
.read(cx)
.visible_worktrees(cx)
.find(|worktree| worktree.read(cx).root_name() == worktree_name)
.map(|worktree| worktree.read(cx).id())?;
Some(ProjectPath {
worktree_id,
path: path.into(),
})
}
let (prefix, link, target) = {
let mut parts = link.splitn(3, ':');
let prefix = parts.next();
let link = parts.next();
let target = parts.next();
(prefix, link, target)
};
match (prefix, link, target) {
(Some("file"), Some(path), _) => {
let project_path = extract_project_path_from_link(path, workspace, cx)?;
let entry = workspace
.read(cx)
.project()
.read(cx)
.entry_for_path(&project_path, cx)?;
Some(MentionLink::File(project_path, entry))
}
(Some("symbol"), Some(path), Some(symbol_name)) => {
let project_path = extract_project_path_from_link(path, workspace, cx)?;
Some(MentionLink::Symbol(project_path, symbol_name.to_string()))
}
(Some("thread"), Some(thread_id), _) => {
let thread_id = ThreadId::from(thread_id);
Some(MentionLink::Thread(thread_id))
}
_ => None,
}
}
}

View file

@ -24,7 +24,9 @@ use crate::thread_store::ThreadStore;
use super::fetch_context_picker::fetch_url_content; use super::fetch_context_picker::fetch_url_content;
use super::thread_context_picker::ThreadContextEntry; use super::thread_context_picker::ThreadContextEntry;
use super::{ContextPickerMode, recent_context_picker_entries, supported_context_picker_modes}; use super::{
ContextPickerMode, MentionLink, recent_context_picker_entries, supported_context_picker_modes,
};
pub struct ContextPickerCompletionProvider { pub struct ContextPickerCompletionProvider {
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
@ -154,7 +156,7 @@ impl ContextPickerCompletionProvider {
} else { } else {
IconName::MessageBubbles IconName::MessageBubbles
}; };
let new_text = format!("@thread {}", thread_entry.summary); let new_text = MentionLink::for_thread(&thread_entry);
let new_text_len = new_text.len(); let new_text_len = new_text.len();
Completion { Completion {
old_range: source_range.clone(), old_range: source_range.clone(),
@ -198,7 +200,7 @@ impl ContextPickerCompletionProvider {
context_store: Entity<ContextStore>, context_store: Entity<ContextStore>,
http_client: Arc<HttpClientWithUrl>, http_client: Arc<HttpClientWithUrl>,
) -> Completion { ) -> Completion {
let new_text = format!("@fetch {}", url_to_fetch); let new_text = MentionLink::for_fetch(&url_to_fetch);
let new_text_len = new_text.len(); let new_text_len = new_text.len();
Completion { Completion {
old_range: source_range.clone(), old_range: source_range.clone(),
@ -279,7 +281,7 @@ impl ContextPickerCompletionProvider {
crease_icon_path.clone() crease_icon_path.clone()
}; };
let new_text = format!("@file {}", full_path); let new_text = MentionLink::for_file(&file_name, &full_path);
let new_text_len = new_text.len(); let new_text_len = new_text.len();
Completion { Completion {
old_range: source_range.clone(), old_range: source_range.clone(),
@ -326,17 +328,22 @@ impl ContextPickerCompletionProvider {
.read(cx) .read(cx)
.root_name(); .root_name();
let (file_name, _) = super::file_context_picker::extract_file_name_and_directory( let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory(
&symbol.path.path, &symbol.path.path,
path_prefix, path_prefix,
); );
let full_path = if let Some(directory) = directory {
format!("{}{}", directory, file_name)
} else {
file_name.to_string()
};
let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId); let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
let mut label = CodeLabel::plain(symbol.name.clone(), None); let mut label = CodeLabel::plain(symbol.name.clone(), None);
label.push_str(" ", None); label.push_str(" ", None);
label.push_str(&file_name, comment_id); label.push_str(&file_name, comment_id);
let new_text = format!("@symbol {}:{}", file_name, symbol.name); let new_text = MentionLink::for_symbol(&symbol.name, &full_path);
let new_text_len = new_text.len(); let new_text_len = new_text.len();
Some(Completion { Some(Completion {
old_range: source_range.clone(), old_range: source_range.clone(),
@ -400,7 +407,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
}; };
let snapshot = buffer.read(cx).snapshot(); let snapshot = buffer.read(cx).snapshot();
let source_range = snapshot.anchor_after(state.source_range.start) let source_range = snapshot.anchor_before(state.source_range.start)
..snapshot.anchor_before(state.source_range.end); ..snapshot.anchor_before(state.source_range.end);
let thread_store = self.thread_store.clone(); let thread_store = self.thread_store.clone();
@ -646,6 +653,15 @@ impl MentionCompletion {
if last_mention_start >= line.len() { if last_mention_start >= line.len() {
return Some(Self::default()); return Some(Self::default());
} }
if last_mention_start > 0
&& line
.chars()
.nth(last_mention_start - 1)
.map_or(false, |c| !c.is_whitespace())
{
return None;
}
let rest_of_line = &line[last_mention_start + 1..]; let rest_of_line = &line[last_mention_start + 1..];
let mut mode = None; let mut mode = None;
@ -746,6 +762,8 @@ mod tests {
argument: Some("main.rs".to_string()), argument: Some("main.rs".to_string()),
}) })
); );
assert_eq!(MentionCompletion::try_parse("test@", 0), None);
} }
#[gpui::test] #[gpui::test]
@ -914,44 +932,50 @@ mod tests {
}); });
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt",); assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt)",);
assert!(!editor.has_visible_completions_menu()); assert!(!editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 25)] vec![Point::new(0, 6)..Point::new(0, 36)]
); );
}); });
cx.simulate_input(" "); cx.simulate_input(" ");
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt ",); assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt) ",);
assert!(!editor.has_visible_completions_menu()); assert!(!editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 25)] vec![Point::new(0, 6)..Point::new(0, 36)]
); );
}); });
cx.simulate_input("Ipsum "); cx.simulate_input("Ipsum ");
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt Ipsum ",); assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum ",
);
assert!(!editor.has_visible_completions_menu()); assert!(!editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 25)] vec![Point::new(0, 6)..Point::new(0, 36)]
); );
}); });
cx.simulate_input("@file "); cx.simulate_input("@file ");
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt Ipsum @file ",); assert_eq!(
editor.text(cx),
"Lorem [@one.txt](file:dir/a/one.txt) Ipsum @file ",
);
assert!(editor.has_visible_completions_menu()); assert!(editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![Point::new(0, 6)..Point::new(0, 25)] vec![Point::new(0, 6)..Point::new(0, 36)]
); );
}); });
@ -964,14 +988,14 @@ mod tests {
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!( assert_eq!(
editor.text(cx), editor.text(cx),
"Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt" "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)"
); );
assert!(!editor.has_visible_completions_menu()); assert!(!editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![ vec![
Point::new(0, 6)..Point::new(0, 25), Point::new(0, 6)..Point::new(0, 36),
Point::new(0, 32)..Point::new(0, 53) Point::new(0, 43)..Point::new(0, 77)
] ]
); );
}); });
@ -981,14 +1005,14 @@ mod tests {
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!( assert_eq!(
editor.text(cx), editor.text(cx),
"Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt\n@" "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n@"
); );
assert!(editor.has_visible_completions_menu()); assert!(editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![ vec![
Point::new(0, 6)..Point::new(0, 25), Point::new(0, 6)..Point::new(0, 36),
Point::new(0, 32)..Point::new(0, 53) Point::new(0, 43)..Point::new(0, 77)
] ]
); );
}); });
@ -1002,15 +1026,15 @@ mod tests {
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!( assert_eq!(
editor.text(cx), editor.text(cx),
"Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt\n@file dir/b/six.txt" "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n[@six.txt](file:dir/b/six.txt)"
); );
assert!(!editor.has_visible_completions_menu()); assert!(!editor.has_visible_completions_menu());
assert_eq!( assert_eq!(
crease_ranges(editor, cx), crease_ranges(editor, cx),
vec![ vec![
Point::new(0, 6)..Point::new(0, 25), Point::new(0, 6)..Point::new(0, 36),
Point::new(0, 32)..Point::new(0, 53), Point::new(0, 43)..Point::new(0, 77),
Point::new(1, 0)..Point::new(1, 19) Point::new(1, 0)..Point::new(1, 30)
] ]
); );
}); });

View file

@ -58,6 +58,12 @@ impl std::fmt::Display for ThreadId {
} }
} }
impl From<&str> for ThreadId {
fn from(value: &str) -> Self {
Self(value.into())
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
pub struct MessageId(pub(crate) usize); pub struct MessageId(pub(crate) usize);