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 authored and GitHub committed 2026-02-06 15:59:00 -08:00
1 parent daeef06bec
commit 84bce2b8e6
15 files changed
+865 -116

No files matched your search

+60 -10
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> {