TUI/Core: preserve duplicate skill/app mention selection across submit + resume (#10855)

## What changed

- In `codex-rs/core/src/skills/injection.rs`, we now honor explicit
`UserInput::Skill { name, path }` first, then fall back to text mentions
only when safe.
- In `codex-rs/tui/src/bottom_pane/chat_composer.rs`, mention selection
is now token-bound (selected mention is tied to the specific inserted
`$token`), and we snapshot bindings at submit time so selection is not
lost.
- In `codex-rs/tui/src/chatwidget.rs` and
`codex-rs/tui/src/bottom_pane/mod.rs`, submit/queue paths now consume
the submit-time mention snapshot (instead of rereading cleared composer
state).
- In `codex-rs/tui/src/mention_codec.rs` and
`codex-rs/tui/src/bottom_pane/chat_composer_history.rs`, history now
round-trips mention targets so resume restores the same selected
duplicate.
- In `codex-rs/tui/src/bottom_pane/skill_popup.rs` and
`codex-rs/tui/src/bottom_pane/chat_composer.rs`, duplicate labels are
normalized to `[Repo]` / `[App]`, app rows no longer show `Connected -`,
and description space is a bit wider.

<img width="550" height="163" alt="Screenshot 2026-02-05 at 9 56 56 PM"
src="https://github.com/user-attachments/assets/346a7eb2-a342-4a49-aec8-68dfec0c7d89"
/>
<img width="550" height="163" alt="Screenshot 2026-02-05 at 9 57 09 PM"
src="https://github.com/user-attachments/assets/5e04d9af-cccf-4932-98b3-c37183e445ed"
/>


## Before vs now

- Before: selecting a duplicate could still submit the default/repo
match, and resume could lose which duplicate was originally selected.
- Now: the exact selected target (skill path or app id) is preserved
through submit, queue/restore, and resume.

## Manual test

1. Build and run this branch locally:
   - `cd /Users/daniels/code/codex/codex-rs`
   - `cargo build -p codex-cli --bin codex`
   - `./target/debug/codex`
2. Open mention picker with `$` and pick a duplicate entry (not the
first one).
3. Confirm duplicate UI:
   - repo duplicate rows show `[Repo]`
   - app duplicate rows show `[App]`
   - app description does **not** start with `Connected -`
4. Submit the prompt, then press Up to restore draft and submit again.  
   Expected: it keeps the same selected duplicate target.
5. Use `/resume` to reopen the session and send again.  
Expected: restored mention still resolves to the same duplicate target.
This commit is contained in:
daniel-oai
2026-02-06 15:59:00 -08:00
committed by GitHub
parent daeef06bec
commit 84bce2b8e6
15 changed files with 865 additions and 116 deletions

View File

@@ -157,6 +157,7 @@ use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::clipboard_paste::normalize_pasted_path;
@@ -290,7 +291,8 @@ pub(crate) struct ChatComposer {
skills: Option<Vec<SkillMetadata>>,
connectors_snapshot: Option<ConnectorsSnapshot>,
dismissed_mention_popup_token: Option<String>,
mention_paths: HashMap<String, String>,
mention_bindings: HashMap<u64, ComposerMentionBinding>,
recent_submission_mention_bindings: Vec<MentionBinding>,
/// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior.
steer_enabled: bool,
collaboration_modes_enabled: bool,
@@ -309,6 +311,12 @@ struct FooterFlash {
expires_at: Instant,
}
#[derive(Clone, Debug)]
struct ComposerMentionBinding {
mention: String,
path: String,
}
/// Popup state at most one can be visible at any time.
enum ActivePopup {
None,
@@ -382,7 +390,8 @@ impl ChatComposer {
skills: None,
connectors_snapshot: None,
dismissed_mention_popup_token: None,
mention_paths: HashMap::new(),
mention_bindings: HashMap::new(),
recent_submission_mention_bindings: Vec::new(),
steer_enabled: false,
collaboration_modes_enabled: false,
config,
@@ -414,8 +423,21 @@ impl ChatComposer {
self.connectors_snapshot = connectors_snapshot;
}
pub(crate) fn take_mention_paths(&mut self) -> HashMap<String, String> {
std::mem::take(&mut self.mention_paths)
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
let elements = self.current_mention_elements();
let mut ordered = Vec::new();
for (id, mention) in elements {
if let Some(binding) = self.mention_bindings.remove(&id)
&& binding.mention == mention
{
ordered.push(MentionBinding {
mention: binding.mention,
path: binding.path,
});
}
}
self.mention_bindings.clear();
ordered
}
/// Enables or disables "Steer" behavior for submission keys.
@@ -518,7 +540,12 @@ impl ChatComposer {
};
// Persistent ↑/↓ history is text-only (backwards-compatible and avoids persisting
// attachments), but local in-session ↑/↓ history can rehydrate elements and image paths.
self.set_text_content(entry.text, entry.text_elements, entry.local_image_paths);
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
true
}
@@ -732,41 +759,41 @@ impl ChatComposer {
/// This is the "fresh draft" path: it clears pending paste payloads and
/// mention link targets. Callers restoring a previously submitted draft
/// that must keep `$name -> path` resolution should use
/// [`Self::set_text_content_with_mention_paths`] instead.
/// [`Self::set_text_content_with_mention_bindings`] instead.
pub(crate) fn set_text_content(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
) {
self.set_text_content_with_mention_paths(
self.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
HashMap::new(),
Vec::new(),
);
}
/// Replace the entire composer content while restoring mention link targets.
///
/// Mention popup insertion stores both visible text (for example `$file`)
/// and hidden `mention_paths` used to resolve the canonical target during
/// and hidden mention bindings used to resolve the canonical target during
/// submission. Use this method when restoring an interrupted or blocked
/// draft; if callers restore only text and images, mentions can appear
/// intact to users while resolving to the wrong target or dropping on
/// retry.
pub(crate) fn set_text_content_with_mention_paths(
pub(crate) fn set_text_content_with_mention_bindings(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
mention_paths: HashMap<String, String>,
mention_bindings: Vec<MentionBinding>,
) {
// Clear any existing content, placeholders, and attachments first.
self.textarea.set_text_clearing_elements("");
self.pending_pastes.clear();
self.attached_images.clear();
self.mention_paths = mention_paths;
self.mention_bindings.clear();
self.textarea.set_text_with_elements(&text, &text_elements);
@@ -782,6 +809,8 @@ impl ChatComposer {
}
}
self.bind_mentions_from_snapshot(mention_bindings);
self.textarea.set_cursor(0);
self.sync_popups();
}
@@ -808,12 +837,14 @@ impl ChatComposer {
.iter()
.map(|img| img.path.clone())
.collect();
let mention_bindings = self.snapshot_mention_bindings();
self.set_text_content(String::new(), Vec::new(), Vec::new());
self.history.reset_navigation();
self.history.record_local_submission(HistoryEntry {
text: previous.clone(),
text_elements,
local_image_paths,
mention_bindings,
});
Some(previous)
}
@@ -845,6 +876,14 @@ impl ChatComposer {
.collect()
}
pub(crate) fn mention_bindings(&self) -> Vec<MentionBinding> {
self.snapshot_mention_bindings()
}
pub(crate) fn take_recent_submission_mention_bindings(&mut self) -> Vec<MentionBinding> {
std::mem::take(&mut self.recent_submission_mention_bindings)
}
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
if self.attached_images.is_empty() {
return;
@@ -1476,10 +1515,7 @@ impl ChatComposer {
if close_popup {
if let Some((insert_text, path)) = selected_mention {
if let Some(path) = path.as_deref() {
self.record_mention_path(&insert_text, path);
}
self.insert_selected_mention(&insert_text);
self.insert_selected_mention(&insert_text, path.as_deref());
}
self.active_popup = ActivePopup::None;
}
@@ -1786,7 +1822,7 @@ impl ChatComposer {
self.textarea.set_cursor(new_cursor);
}
fn insert_selected_mention(&mut self, insert_text: &str) {
fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
@@ -1807,28 +1843,30 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;
let inserted = insert_text.to_string();
// Remove the active token and insert the selected mention as an atomic element.
self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);
let id = self.textarea.insert_element(insert_text);
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(&inserted);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
if let (Some(path), Some(mention)) =
(path, Self::mention_name_from_insert_text(insert_text))
{
self.mention_bindings.insert(
id,
ComposerMentionBinding {
mention,
path: path.to_string(),
},
);
}
// Mention insertion rebuilds plain text, so drop existing elements.
self.textarea.set_text_clearing_elements(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.insert_str(" ");
let new_cursor = start_idx
.saturating_add(insert_text.len())
.saturating_add(1);
self.textarea.set_cursor(new_cursor);
}
fn record_mention_path(&mut self, insert_text: &str, path: &str) {
let Some(name) = Self::mention_name_from_insert_text(insert_text) else {
return;
};
self.mention_paths.insert(name, path.to_string());
}
fn mention_name_from_insert_text(insert_text: &str) -> Option<String> {
let name = insert_text.strip_prefix('$')?;
if name.is_empty() {
@@ -1845,6 +1883,67 @@ impl ChatComposer {
}
}
fn current_mention_elements(&self) -> Vec<(u64, String)> {
self.textarea
.text_element_snapshots()
.into_iter()
.filter_map(|snapshot| {
Self::mention_name_from_insert_text(snapshot.text.as_str())
.map(|mention| (snapshot.id, mention))
})
.collect()
}
fn snapshot_mention_bindings(&self) -> Vec<MentionBinding> {
let mut ordered = Vec::new();
for (id, mention) in self.current_mention_elements() {
if let Some(binding) = self.mention_bindings.get(&id)
&& binding.mention == mention
{
ordered.push(MentionBinding {
mention: binding.mention.clone(),
path: binding.path.clone(),
});
}
}
ordered
}
fn bind_mentions_from_snapshot(&mut self, mention_bindings: Vec<MentionBinding>) {
self.mention_bindings.clear();
if mention_bindings.is_empty() {
return;
}
let text = self.textarea.text().to_string();
let mut scan_from = 0usize;
for binding in mention_bindings {
let token = format!("${}", binding.mention);
let Some(range) =
find_next_mention_token_range(text.as_str(), token.as_str(), scan_from)
else {
continue;
};
let id = if let Some(id) = self.textarea.add_element_range(range.clone()) {
Some(id)
} else {
self.textarea.element_id_for_exact_range(range.clone())
};
if let Some(id) = id {
self.mention_bindings.insert(
id,
ComposerMentionBinding {
mention: binding.mention,
path: binding.path,
},
);
scan_from = range.end;
}
}
}
/// Prepare text for submission/queuing. Returns None if submission should be suppressed.
/// On success, clears pending paste payloads because placeholders have been expanded.
///
@@ -1856,6 +1955,7 @@ impl ChatComposer {
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
let original_text_elements = self.textarea.text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self
.attached_images
.iter()
@@ -1864,6 +1964,7 @@ impl ChatComposer {
let original_pending_pastes = self.pending_pastes.clone();
let mut text_elements = original_text_elements.clone();
let input_starts_with_space = original_input.starts_with(' ');
self.recent_submission_mention_bindings.clear();
self.textarea.set_text_clearing_elements("");
if !self.pending_pastes.is_empty() {
@@ -1909,10 +2010,11 @@ impl ChatComposer {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(message, None),
)));
self.set_text_content(
self.set_text_content_with_mention_bindings(
original_input.clone(),
original_text_elements,
original_local_image_paths,
original_mention_bindings,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
@@ -1929,10 +2031,11 @@ impl ChatComposer {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(err.user_message()),
)));
self.set_text_content(
self.set_text_content_with_mention_bindings(
original_input.clone(),
original_text_elements,
original_local_image_paths,
original_mention_bindings,
);
self.pending_pastes.clone_from(&original_pending_pastes);
self.textarea.set_cursor(original_input.len());
@@ -1950,6 +2053,7 @@ impl ChatComposer {
if text.is_empty() && self.attached_images.is_empty() {
return None;
}
self.recent_submission_mention_bindings = original_mention_bindings.clone();
if record_history && (!text.is_empty() || !self.attached_images.is_empty()) {
let local_image_paths = self
.attached_images
@@ -1960,6 +2064,7 @@ impl ChatComposer {
text: text.clone(),
text_elements: text_elements.clone(),
local_image_paths,
mention_bindings: original_mention_bindings,
});
}
self.pending_pastes.clear();
@@ -2022,6 +2127,7 @@ impl ChatComposer {
let original_input = self.textarea.text().to_string();
let original_text_elements = self.textarea.text_elements();
let original_mention_bindings = self.snapshot_mention_bindings();
let original_local_image_paths = self
.attached_images
.iter()
@@ -2053,10 +2159,11 @@ impl ChatComposer {
}
} else {
// Restore text if submission was suppressed.
self.set_text_content(
self.set_text_content_with_mention_bindings(
original_input,
original_text_elements,
original_local_image_paths,
original_mention_bindings,
);
self.pending_pastes = original_pending_pastes;
(InputResult::None, true)
@@ -2245,10 +2352,11 @@ impl ChatComposer {
_ => unreachable!(),
};
if let Some(entry) = replace_entry {
self.set_text_content(
self.set_text_content_with_mention_bindings(
entry.text,
entry.text_elements,
entry.local_image_paths,
entry.mention_bindings,
);
return (InputResult::None, true);
}
@@ -2930,6 +3038,8 @@ impl ChatComposer {
insert_text: format!("${skill_name}"),
search_terms,
path: Some(skill.path.to_string_lossy().into_owned()),
category_tag: (skill.scope == codex_core::protocol::SkillScope::Repo)
.then(|| "[Repo]".to_string()),
});
}
}
@@ -2952,23 +3062,26 @@ impl ChatComposer {
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
category_tag: Some("[App]".to_string()),
});
}
}
let mut counts: HashMap<String, usize> = HashMap::new();
for mention in &mentions {
*counts.entry(mention.insert_text.clone()).or_insert(0) += 1;
}
for mention in &mut mentions {
if counts.get(&mention.insert_text).copied().unwrap_or(0) <= 1 {
mention.category_tag = None;
}
}
mentions
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = if connector.is_accessible {
"Connected"
} else {
"Can be installed"
};
match Self::connector_description(connector) {
Some(description) => format!("{status_label} - {description}"),
None => status_label.to_string(),
}
Self::connector_description(connector).unwrap_or_default()
}
fn connector_description(connector: &AppInfo) -> Option<String> {
@@ -3049,6 +3162,42 @@ fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option<Range<usize>> {
if token.is_empty() || from >= text.len() {
return None;
}
let bytes = text.as_bytes();
let token_bytes = token.as_bytes();
let mut index = from;
while index < bytes.len() {
if bytes[index] != b'$' {
index += 1;
continue;
}
let end = index.saturating_add(token_bytes.len());
if end > bytes.len() {
return None;
}
if &bytes[index..end] != token_bytes {
index += 1;
continue;
}
if bytes
.get(end)
.is_none_or(|byte| !is_mention_name_char(*byte))
{
return Some(index..end);
}
index = end;
}
None
}
impl Renderable for ChatComposer {
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if !self.input_enabled {
@@ -5513,6 +5662,40 @@ mod tests {
assert_eq!(vec![path], imgs);
}
#[test]
fn submit_captures_recent_mention_bindings_before_clearing_textarea() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let mention_bindings = vec![MentionBinding {
mention: "figma".to_string(),
path: "/tmp/user/figma/SKILL.md".to_string(),
}];
composer.set_text_content_with_mention_bindings(
"$figma please".to_string(),
Vec::new(),
Vec::new(),
mention_bindings.clone(),
);
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::Submitted { .. }));
assert_eq!(
composer.take_recent_submission_mention_bindings(),
mention_bindings
);
assert!(composer.take_mention_bindings().is_empty());
}
#[test]
fn history_navigation_restores_image_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -3,6 +3,8 @@ use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::MentionBinding;
use crate::mention_codec::decode_history_mentions;
use codex_core::protocol::Op;
use codex_protocol::user_input::TextElement;
@@ -11,6 +13,7 @@ pub(crate) struct HistoryEntry {
pub(crate) text: String,
pub(crate) text_elements: Vec<TextElement>,
pub(crate) local_image_paths: Vec<PathBuf>,
pub(crate) mention_bindings: Vec<MentionBinding>,
}
impl HistoryEntry {
@@ -19,14 +22,24 @@ impl HistoryEntry {
text: String::new(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
mention_bindings: Vec::new(),
}
}
pub(crate) fn from_text(text: String) -> Self {
let decoded = decode_history_mentions(&text);
Self {
text,
text: decoded.text,
text_elements: Vec::new(),
local_image_paths: Vec::new(),
mention_bindings: decoded
.mentions
.into_iter()
.map(|mention| MentionBinding {
mention: mention.mention,
path: mention.path,
})
.collect(),
}
}
}

View File

@@ -220,6 +220,7 @@ impl CommandPopup {
match_indices: indices.map(|v| v.into_iter().map(|i| i + 1).collect()),
display_shortcut: None,
description: Some(description),
category_tag: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,

View File

@@ -125,6 +125,7 @@ impl WidgetRef for &FileSearchPopup {
.map(|v| v.iter().map(|&i| i as usize).collect()),
display_shortcut: None,
description: None,
category_tag: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,

View File

@@ -258,6 +258,7 @@ impl ListSelectionView {
display_shortcut: item.display_shortcut,
match_indices: None,
description,
category_tag: None,
wrap_indent,
is_disabled,
disabled_reason: item.disabled_reason.clone(),

View File

@@ -13,7 +13,6 @@
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
@@ -55,6 +54,14 @@ pub(crate) struct LocalImageAttachment {
pub(crate) placeholder: String,
pub(crate) path: PathBuf,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MentionBinding {
/// Mention token text without the leading `$`.
pub(crate) mention: String,
/// Canonical mention target (for example `app://...` or absolute SKILL.md path).
pub(crate) path: String,
}
mod chat_composer;
mod chat_composer_history;
mod command_popup;
@@ -228,14 +235,19 @@ impl BottomPane {
self.request_redraw();
}
pub fn take_mention_paths(&mut self) -> HashMap<String, String> {
self.composer.take_mention_paths()
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
self.composer.take_mention_bindings()
}
/// Clear pending attachments and mention paths e.g. when a slash command doesn't submit text.
pub fn take_recent_submission_mention_bindings(&mut self) -> Vec<MentionBinding> {
self.composer.take_recent_submission_mention_bindings()
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
let _ = self.take_mention_paths();
let _ = self.take_recent_submission_mention_bindings();
let _ = self.take_mention_bindings();
}
pub fn set_steer_enabled(&mut self, enabled: bool) {
@@ -419,7 +431,7 @@ impl BottomPane {
///
/// This is intended for fresh input where mention linkage does not need to
/// survive; it routes to `ChatComposer::set_text_content`, which resets
/// `mention_paths`.
/// mention bindings.
pub(crate) fn set_composer_text(
&mut self,
text: String,
@@ -437,18 +449,18 @@ impl BottomPane {
/// Use this when rehydrating a draft after a local validation/gating
/// failure (for example unsupported image submit) so previously selected
/// mention targets remain stable across retry.
pub(crate) fn set_composer_text_with_mention_paths(
pub(crate) fn set_composer_text_with_mention_bindings(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
mention_paths: HashMap<String, String>,
mention_bindings: Vec<MentionBinding>,
) {
self.composer.set_text_content_with_mention_paths(
self.composer.set_text_content_with_mention_bindings(
text,
text_elements,
local_image_paths,
mention_paths,
mention_bindings,
);
self.request_redraw();
}
@@ -481,6 +493,10 @@ impl BottomPane {
self.composer.local_images()
}
pub(crate) fn composer_mention_bindings(&self) -> Vec<MentionBinding> {
self.composer.mention_bindings()
}
#[cfg(test)]
pub(crate) fn composer_local_image_paths(&self) -> Vec<PathBuf> {
self.composer.local_image_paths()

View File

@@ -30,6 +30,7 @@ pub(crate) struct GenericDisplayRow {
pub display_shortcut: Option<KeyBinding>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
pub category_tag: Option<String>, // optional right-side category label
pub disabled_reason: Option<String>, // optional disabled message
pub is_disabled: bool,
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
@@ -337,6 +338,10 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
}
full_spans.push(desc.clone().dim());
}
if let Some(tag) = row.category_tag.as_deref().filter(|tag| !tag.is_empty()) {
full_spans.push(" ".into());
full_spans.push(tag.to_string().dim());
}
Line::from(full_spans)
}

View File

@@ -24,8 +24,11 @@ pub(crate) struct MentionItem {
pub(crate) insert_text: String,
pub(crate) search_terms: Vec<String>,
pub(crate) path: Option<String>,
pub(crate) category_tag: Option<String>,
}
const MENTION_NAME_TRUNCATE_LEN: usize = 24;
pub(crate) struct SkillPopup {
query: String,
mentions: Vec<MentionItem>,
@@ -94,13 +97,14 @@ impl SkillPopup {
.into_iter()
.map(|(idx, indices, _score)| {
let mention = &self.mentions[idx];
let name = truncate_text(&mention.display_name, 21);
let name = truncate_text(&mention.display_name, MENTION_NAME_TRUNCATE_LEN);
let description = mention.description.clone().unwrap_or_default();
GenericDisplayRow {
name,
match_indices: indices,
display_shortcut: None,
description: Some(description).filter(|desc| !desc.is_empty()),
category_tag: mention.category_tag.clone(),
is_disabled: false,
disabled_reason: None,
wrap_indent: None,

View File

@@ -25,9 +25,17 @@ fn is_word_separator(ch: char) -> bool {
#[derive(Debug, Clone)]
struct TextElement {
id: u64,
range: Range<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TextElementSnapshot {
pub(crate) id: u64,
pub(crate) range: Range<usize>,
pub(crate) text: String,
}
#[derive(Debug)]
pub(crate) struct TextArea {
text: String,
@@ -35,6 +43,7 @@ pub(crate) struct TextArea {
wrap_cache: RefCell<Option<WrapCache>>,
preferred_col: Option<usize>,
elements: Vec<TextElement>,
next_element_id: u64,
kill_buffer: String,
}
@@ -58,6 +67,7 @@ impl TextArea {
wrap_cache: RefCell::new(None),
preferred_col: None,
elements: Vec::new(),
next_element_id: 1,
kill_buffer: String::new(),
}
}
@@ -87,7 +97,11 @@ impl TextArea {
if start >= end {
continue;
}
self.elements.push(TextElement { range: start..end });
let id = self.next_element_id();
self.elements.push(TextElement {
id,
range: start..end,
});
}
self.elements.sort_by_key(|e| e.range.start);
}
@@ -766,6 +780,28 @@ impl TextArea {
.collect()
}
pub(crate) fn text_element_snapshots(&self) -> Vec<TextElementSnapshot> {
self.elements
.iter()
.filter_map(|element| {
self.text
.get(element.range.clone())
.map(|text| TextElementSnapshot {
id: element.id,
range: element.range.clone(),
text: text.to_string(),
})
})
.collect()
}
pub(crate) fn element_id_for_exact_range(&self, range: Range<usize>) -> Option<u64> {
self.elements
.iter()
.find(|element| element.range == range)
.map(|element| element.id)
}
/// Renames a single text element in-place, keeping it atomic.
///
/// Use this when the element payload is an identifier (e.g. a placeholder) that must be
@@ -835,41 +871,47 @@ impl TextArea {
true
}
pub fn insert_element(&mut self, text: &str) {
pub fn insert_element(&mut self, text: &str) -> u64 {
let start = self.clamp_pos_for_insertion(self.cursor_pos);
self.insert_str_at(start, text);
let end = start + text.len();
self.add_element(start..end);
let id = self.add_element(start..end);
// Place cursor at end of inserted element
self.set_cursor(end);
id
}
/// Mark an existing text range as an atomic element without changing the text.
///
/// This is used to convert already-typed tokens (like `/plan`) into elements
/// so they render and edit atomically. Overlapping or duplicate ranges are ignored.
pub fn add_element_range(&mut self, range: Range<usize>) {
pub fn add_element_range(&mut self, range: Range<usize>) -> Option<u64> {
let start = self.clamp_pos_to_char_boundary(range.start.min(self.text.len()));
let end = self.clamp_pos_to_char_boundary(range.end.min(self.text.len()));
if start >= end {
return;
return None;
}
if self
.elements
.iter()
.any(|e| e.range.start == start && e.range.end == end)
{
return;
return None;
}
if self
.elements
.iter()
.any(|e| start < e.range.end && end > e.range.start)
{
return;
return None;
}
self.elements.push(TextElement { range: start..end });
let id = self.next_element_id();
self.elements.push(TextElement {
id,
range: start..end,
});
self.elements.sort_by_key(|e| e.range.start);
Some(id)
}
pub fn remove_element_range(&mut self, range: Range<usize>) -> bool {
@@ -884,10 +926,18 @@ impl TextArea {
len_before != self.elements.len()
}
fn add_element(&mut self, range: Range<usize>) {
let elem = TextElement { range };
fn add_element(&mut self, range: Range<usize>) -> u64 {
let id = self.next_element_id();
let elem = TextElement { id, range };
self.elements.push(elem);
self.elements.sort_by_key(|e| e.range.start);
id
}
fn next_element_id(&mut self) -> u64 {
let id = self.next_element_id;
self.next_element_id = self.next_element_id.saturating_add(1);
id
}
fn find_element_containing(&self, pos: usize) -> Option<usize> {