mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
daeef06bec
commit
84bce2b8e6
15 files changed
+865
-116
No files matched your search
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user