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

+100 -25
View File
@@ -164,6 +164,7 @@ use crate::bottom_pane::ExperimentalFeaturesView;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
@@ -210,6 +211,8 @@ mod skills;
use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use crate::streaming::chunking::AdaptiveChunkingPolicy;
use crate::streaming::commit_tick::CommitTickScope;
use crate::streaming::commit_tick::run_commit_tick;
@@ -639,7 +642,7 @@ pub(crate) struct UserMessage {
text: String,
local_images: Vec<LocalImageAttachment>,
text_elements: Vec<TextElement>,
mention_paths: HashMap<String, String>,
mention_bindings: Vec<MentionBinding>,
}
impl From<String> for UserMessage {
@@ -649,7 +652,7 @@ impl From<String> for UserMessage {
local_images: Vec::new(),
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_paths: HashMap::new(),
mention_bindings: Vec::new(),
}
}
}
@@ -661,7 +664,7 @@ impl From<&str> for UserMessage {
local_images: Vec::new(),
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_paths: HashMap::new(),
mention_bindings: Vec::new(),
}
}
}
@@ -687,7 +690,7 @@ pub(crate) fn create_initial_user_message(
text,
local_images,
text_elements,
mention_paths: HashMap::new(),
mention_bindings: Vec::new(),
})
}
}
@@ -701,14 +704,14 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
text,
text_elements,
local_images,
mention_paths,
mention_bindings,
} = message;
if local_images.is_empty() {
return UserMessage {
text,
text_elements,
local_images,
mention_paths,
mention_bindings,
};
}
@@ -763,7 +766,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
text: rebuilt,
local_images: remapped_images,
text_elements: rebuilt_elements,
mention_paths,
mention_bindings,
}
}
@@ -1666,7 +1669,7 @@ impl ChatWidget {
text: self.bottom_pane.composer_text(),
text_elements: self.bottom_pane.composer_text_elements(),
local_images: self.bottom_pane.composer_local_images(),
mention_paths: HashMap::new(),
mention_bindings: self.bottom_pane.composer_mention_bindings(),
};
let mut to_merge: Vec<UserMessage> = self.queued_user_messages.drain(..).collect();
@@ -1678,7 +1681,7 @@ impl ChatWidget {
text: String::new(),
text_elements: Vec::new(),
local_images: Vec::new(),
mention_paths: HashMap::new(),
mention_bindings: Vec::new(),
};
let mut combined_offset = 0usize;
let mut next_image_label = 1usize;
@@ -1700,7 +1703,7 @@ impl ChatWidget {
elem
}));
combined.local_images.extend(message.local_images);
combined.mention_paths.extend(message.mention_paths);
combined.mention_bindings.extend(message.mention_bindings);
}
Some(combined)
@@ -1711,11 +1714,15 @@ impl ChatWidget {
text,
local_images,
text_elements,
mention_paths: _,
mention_bindings,
} = user_message;
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
self.bottom_pane
.set_composer_text(text, text_elements, local_image_paths);
self.bottom_pane.set_composer_text_with_mention_bindings(
text,
text_elements,
local_image_paths,
mention_bindings,
);
}
fn on_plan_update(&mut self, update: UpdatePlanArgs) {
@@ -3022,7 +3029,9 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
if self.is_session_configured() && !self.is_plan_streaming_in_tui() {
// Submitted is only emitted when steer is enabled.
@@ -3045,7 +3054,9 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
self.queue_user_message(user_message);
}
@@ -3414,7 +3425,7 @@ impl ChatWidget {
.bottom_pane
.take_recent_submission_images_with_placeholders(),
text_elements: prepared_elements,
mention_paths: self.bottom_pane.take_mention_paths(),
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
@@ -3551,13 +3562,18 @@ impl ChatWidget {
text,
local_images,
text_elements,
mention_paths,
mention_bindings,
} = user_message;
if text.is_empty() && local_images.is_empty() {
return;
}
if !local_images.is_empty() && !self.current_model_supports_images() {
self.restore_blocked_image_submission(text, text_elements, local_images, mention_paths);
self.restore_blocked_image_submission(
text,
text_elements,
local_images,
mention_bindings,
);
return;
}
@@ -3594,16 +3610,43 @@ impl ChatWidget {
});
}
let mentions = collect_tool_mentions(&text, &mention_paths);
let mentions = collect_tool_mentions(&text, &HashMap::new());
let bound_names: HashSet<String> = mention_bindings
.iter()
.map(|binding| binding.mention.clone())
.collect();
let mut skill_names_lower: HashSet<String> = HashSet::new();
let mut selected_skill_paths: HashSet<PathBuf> = HashSet::new();
if let Some(skills) = self.bottom_pane.skills() {
skill_names_lower = skills
.iter()
.map(|skill| skill.name.to_ascii_lowercase())
.collect();
for binding in &mention_bindings {
let path = binding
.path
.strip_prefix("skill://")
.unwrap_or(binding.path.as_str());
let path = Path::new(path);
if let Some(skill) = skills.iter().find(|skill| skill.path.as_path() == path)
&& selected_skill_paths.insert(skill.path.clone())
{
items.push(UserInput::Skill {
name: skill.name.clone(),
path: skill.path.clone(),
});
}
}
let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills);
for skill in skill_mentions {
if bound_names.contains(skill.name.as_str())
|| !selected_skill_paths.insert(skill.path.clone())
{
continue;
}
items.push(UserInput::Skill {
name: skill.name.clone(),
path: skill.path.clone(),
@@ -3611,9 +3654,33 @@ impl ChatWidget {
}
}
let mut selected_app_ids: HashSet<String> = HashSet::new();
if let Some(apps) = self.connectors_for_mentions() {
for binding in &mention_bindings {
let Some(app_id) = binding
.path
.strip_prefix("app://")
.filter(|id| !id.is_empty())
else {
continue;
};
if !selected_app_ids.insert(app_id.to_string()) {
continue;
}
if let Some(app) = apps.iter().find(|app| app.id == app_id) {
items.push(UserInput::Mention {
name: app.name.clone(),
path: binding.path.clone(),
});
}
}
let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower);
for app in app_mentions {
let slug = codex_core::connectors::connector_mention_slug(&app);
if bound_names.contains(&slug) || !selected_app_ids.insert(app.id.clone()) {
continue;
}
let app_id = app.id.as_str();
items.push(UserInput::Mention {
name: app.name.clone(),
@@ -3654,8 +3721,16 @@ impl ChatWidget {
// Persist the text to cross-session message history.
if !text.is_empty() {
let encoded_mentions = mention_bindings
.iter()
.map(|binding| LinkedMention {
mention: binding.mention.clone(),
path: binding.path.clone(),
})
.collect::<Vec<_>>();
let history_text = encode_history_mentions(&text, &encoded_mentions);
self.codex_op_tx
.send(Op::AddToHistory { text: text.clone() })
.send(Op::AddToHistory { text: history_text })
.unwrap_or_else(|e| {
tracing::error!("failed to send AddHistory op: {e}");
});
@@ -3678,7 +3753,7 @@ impl ChatWidget {
///
/// The blocked-image path intentionally keeps the draft in the composer so
/// users can remove attachments and retry. We must restore
/// `mention_paths` alongside visible text; restoring only `$name` tokens
/// mention bindings alongside visible text; restoring only `$name` tokens
/// makes the draft look correct while degrading mention resolution to
/// name-only heuristics on retry.
fn restore_blocked_image_submission(
@@ -3686,15 +3761,15 @@ impl ChatWidget {
text: String,
text_elements: Vec<TextElement>,
local_images: Vec<LocalImageAttachment>,
mention_paths: HashMap<String, String>,
mention_bindings: Vec<MentionBinding>,
) {
// Preserve the user's composed payload so they can retry after changing models.
let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect();
self.bottom_pane.set_composer_text_with_mention_paths(
self.bottom_pane.set_composer_text_with_mention_bindings(
text,
text_elements,
local_image_paths,
mention_paths,
mention_bindings,
);
self.add_to_history(history_cell::new_warning_event(
self.image_inputs_not_supported_message(),
@@ -6436,7 +6511,7 @@ impl ChatWidget {
text,
local_images: Vec::new(),
text_elements: Vec::new(),
mention_paths: HashMap::new(),
mention_bindings: Vec::new(),
};
if should_queue {
self.queue_user_message(user_message);