## 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.
240 lines
6.4 KiB
Rust
240 lines
6.4 KiB
Rust
use std::collections::HashMap;
|
|
use std::collections::VecDeque;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct LinkedMention {
|
|
pub(crate) mention: String,
|
|
pub(crate) path: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct DecodedHistoryText {
|
|
pub(crate) text: String,
|
|
pub(crate) mentions: Vec<LinkedMention>,
|
|
}
|
|
|
|
pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> String {
|
|
if mentions.is_empty() || text.is_empty() {
|
|
return text.to_string();
|
|
}
|
|
|
|
let mut mentions_by_name: HashMap<&str, VecDeque<&str>> = HashMap::new();
|
|
for mention in mentions {
|
|
mentions_by_name
|
|
.entry(mention.mention.as_str())
|
|
.or_default()
|
|
.push_back(mention.path.as_str());
|
|
}
|
|
|
|
let bytes = text.as_bytes();
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut index = 0usize;
|
|
|
|
while index < bytes.len() {
|
|
if bytes[index] == b'$' {
|
|
let name_start = index + 1;
|
|
if let Some(first) = bytes.get(name_start)
|
|
&& is_mention_name_char(*first)
|
|
{
|
|
let mut name_end = name_start + 1;
|
|
while let Some(next) = bytes.get(name_end)
|
|
&& is_mention_name_char(*next)
|
|
{
|
|
name_end += 1;
|
|
}
|
|
|
|
let name = &text[name_start..name_end];
|
|
if let Some(path) = mentions_by_name.get_mut(name).and_then(VecDeque::pop_front) {
|
|
out.push('[');
|
|
out.push('$');
|
|
out.push_str(name);
|
|
out.push_str("](");
|
|
out.push_str(path);
|
|
out.push(')');
|
|
index = name_end;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
let Some(ch) = text[index..].chars().next() else {
|
|
break;
|
|
};
|
|
out.push(ch);
|
|
index += ch.len_utf8();
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText {
|
|
let bytes = text.as_bytes();
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut mentions = Vec::new();
|
|
let mut index = 0usize;
|
|
|
|
while index < bytes.len() {
|
|
if bytes[index] == b'['
|
|
&& let Some((name, path, end_index)) = parse_linked_tool_mention(text, bytes, index)
|
|
&& !is_common_env_var(name)
|
|
&& is_tool_path(path)
|
|
{
|
|
out.push('$');
|
|
out.push_str(name);
|
|
mentions.push(LinkedMention {
|
|
mention: name.to_string(),
|
|
path: path.to_string(),
|
|
});
|
|
index = end_index;
|
|
continue;
|
|
}
|
|
|
|
let Some(ch) = text[index..].chars().next() else {
|
|
break;
|
|
};
|
|
out.push(ch);
|
|
index += ch.len_utf8();
|
|
}
|
|
|
|
DecodedHistoryText {
|
|
text: out,
|
|
mentions,
|
|
}
|
|
}
|
|
|
|
fn parse_linked_tool_mention<'a>(
|
|
text: &'a str,
|
|
text_bytes: &[u8],
|
|
start: usize,
|
|
) -> Option<(&'a str, &'a str, usize)> {
|
|
let dollar_index = start + 1;
|
|
if text_bytes.get(dollar_index) != Some(&b'$') {
|
|
return None;
|
|
}
|
|
|
|
let name_start = dollar_index + 1;
|
|
let first_name_byte = text_bytes.get(name_start)?;
|
|
if !is_mention_name_char(*first_name_byte) {
|
|
return None;
|
|
}
|
|
|
|
let mut name_end = name_start + 1;
|
|
while let Some(next_byte) = text_bytes.get(name_end)
|
|
&& is_mention_name_char(*next_byte)
|
|
{
|
|
name_end += 1;
|
|
}
|
|
|
|
if text_bytes.get(name_end) != Some(&b']') {
|
|
return None;
|
|
}
|
|
|
|
let mut path_start = name_end + 1;
|
|
while let Some(next_byte) = text_bytes.get(path_start)
|
|
&& next_byte.is_ascii_whitespace()
|
|
{
|
|
path_start += 1;
|
|
}
|
|
if text_bytes.get(path_start) != Some(&b'(') {
|
|
return None;
|
|
}
|
|
|
|
let mut path_end = path_start + 1;
|
|
while let Some(next_byte) = text_bytes.get(path_end)
|
|
&& *next_byte != b')'
|
|
{
|
|
path_end += 1;
|
|
}
|
|
if text_bytes.get(path_end) != Some(&b')') {
|
|
return None;
|
|
}
|
|
|
|
let path = text[path_start + 1..path_end].trim();
|
|
if path.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let name = &text[name_start..name_end];
|
|
Some((name, path, path_end + 1))
|
|
}
|
|
|
|
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 is_common_env_var(name: &str) -> bool {
|
|
let upper = name.to_ascii_uppercase();
|
|
matches!(
|
|
upper.as_str(),
|
|
"PATH"
|
|
| "HOME"
|
|
| "USER"
|
|
| "SHELL"
|
|
| "PWD"
|
|
| "TMPDIR"
|
|
| "TEMP"
|
|
| "TMP"
|
|
| "LANG"
|
|
| "TERM"
|
|
| "XDG_CONFIG_HOME"
|
|
)
|
|
}
|
|
|
|
fn is_tool_path(path: &str) -> bool {
|
|
path.starts_with("app://")
|
|
|| path.starts_with("mcp://")
|
|
|| path.starts_with("skill://")
|
|
|| path
|
|
.rsplit(['/', '\\'])
|
|
.next()
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn decode_history_mentions_restores_visible_tokens() {
|
|
let decoded = decode_history_mentions(
|
|
"Use [$figma](app://figma-1) and [$figma](/tmp/figma/SKILL.md).",
|
|
);
|
|
assert_eq!(decoded.text, "Use $figma and $figma.");
|
|
assert_eq!(
|
|
decoded.mentions,
|
|
vec![
|
|
LinkedMention {
|
|
mention: "figma".to_string(),
|
|
path: "app://figma-1".to_string(),
|
|
},
|
|
LinkedMention {
|
|
mention: "figma".to_string(),
|
|
path: "/tmp/figma/SKILL.md".to_string(),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn encode_history_mentions_links_bound_mentions_in_order() {
|
|
let text = "$figma then $figma then $other";
|
|
let encoded = encode_history_mentions(
|
|
text,
|
|
&[
|
|
LinkedMention {
|
|
mention: "figma".to_string(),
|
|
path: "app://figma-app".to_string(),
|
|
},
|
|
LinkedMention {
|
|
mention: "figma".to_string(),
|
|
path: "/tmp/figma/SKILL.md".to_string(),
|
|
},
|
|
],
|
|
);
|
|
assert_eq!(
|
|
encoded,
|
|
"[$figma](app://figma-app) then [$figma](/tmp/figma/SKILL.md) then $other"
|
|
);
|
|
}
|
|
}
|