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

@@ -78,14 +78,17 @@ fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata,
);
}
/// Collect explicitly mentioned skills from `$name` text mentions.
/// Collect explicitly mentioned skills from structured and text mentions.
///
/// Text inputs are scanned once to extract `$skill-name` tokens, then we iterate `skills`
/// in their existing order to preserve prior ordering semantics. Explicit links are
/// resolved by path and plain names are only used when the match is unambiguous.
/// Structured `UserInput::Skill` selections are resolved first by path against
/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we
/// iterate `skills` in their existing order to preserve prior ordering semantics.
/// Explicit links are resolved by path and plain names are only used when the match
/// is unambiguous.
///
/// Complexity: `O(S + T + N_t * S)` time, `O(S)` space, where:
/// `S` = number of skills, `T` = total text length, `N_t` = number of text inputs.
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
pub(crate) fn collect_explicit_skill_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
@@ -102,12 +105,33 @@ pub(crate) fn collect_explicit_skill_mentions(
let mut selected: Vec<SkillMetadata> = Vec::new();
let mut seen_names: HashSet<String> = HashSet::new();
let mut seen_paths: HashSet<PathBuf> = HashSet::new();
let mut blocked_plain_names: HashSet<String> = HashSet::new();
for input in inputs {
if let UserInput::Skill { name, path } = input {
blocked_plain_names.insert(name.clone());
if selection_context.disabled_paths.contains(path) || seen_paths.contains(path) {
continue;
}
if let Some(skill) = selection_context
.skills
.iter()
.find(|skill| skill.path.as_path() == path.as_path())
{
seen_paths.insert(skill.path.clone());
seen_names.insert(skill.name.clone());
selected.push(skill.clone());
}
}
}
for input in inputs {
if let UserInput::Text { text, .. } = input {
let mentioned_names = extract_tool_mentions(text);
select_skills_from_mentions(
&selection_context,
&blocked_plain_names,
&mentioned_names,
&mut seen_names,
&mut seen_paths,
@@ -254,6 +278,7 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
/// Select mentioned skills while preserving the order of `skills`.
fn select_skills_from_mentions(
selection_context: &SkillSelectionContext<'_>,
blocked_plain_names: &HashSet<String>,
mentions: &ToolMentions<'_>,
seen_names: &mut HashSet<String>,
seen_paths: &mut HashSet<PathBuf>,
@@ -296,6 +321,9 @@ fn select_skills_from_mentions(
continue;
}
if blocked_plain_names.contains(skill.name.as_str()) {
continue;
}
if !mentions.plain_names.contains(skill.name.as_str()) {
continue;
}
@@ -586,10 +614,10 @@ mod tests {
}
#[test]
fn collect_explicit_skill_mentions_ignores_structured_inputs() {
fn collect_explicit_skill_mentions_prioritizes_structured_inputs() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let beta = make_skill("beta-skill", "/tmp/beta");
let skills = vec![alpha.clone(), beta];
let skills = vec![alpha.clone(), beta.clone()];
let inputs = vec![
UserInput::Text {
text: "please run $alpha-skill".to_string(),
@@ -604,7 +632,50 @@ mod tests {
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, vec![alpha]);
assert_eq!(selected, vec![beta, alpha]);
}
#[test]
fn collect_explicit_skill_mentions_skips_invalid_structured_and_blocks_plain_fallback() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![
UserInput::Text {
text: "please run $alpha-skill".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "alpha-skill".to_string(),
path: PathBuf::from("/tmp/missing"),
},
];
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]
fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fallback() {
let alpha = make_skill("alpha-skill", "/tmp/alpha");
let skills = vec![alpha];
let inputs = vec![
UserInput::Text {
text: "please run $alpha-skill".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "alpha-skill".to_string(),
path: PathBuf::from("/tmp/alpha"),
},
];
let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]);
let connector_counts = HashMap::new();
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
assert_eq!(selected, Vec::new());
}
#[test]