TUI skill mention popup shows duplicate entries for the same skill name

Resolved 💬 4 comments Opened Apr 26, 2026 by BaseInfinity Closed Apr 26, 2026
💡 Likely answer: A maintainer (etraut-openai, contributor) responded on this thread — see the highlighted reply below.

Summary

The TUI skill mention popup can show duplicate entries for the same skill invocation when the same skill name is available from multiple scopes and one copy has an interface display name.

Example observed from a repo with both user-level and repo-level SDLC skills:

  • SDLC [Skill] ...
  • sdlc [Skill] ...

Both entries insert the same $sdlc token, so the popup looks like two different commands even though the invocation name is identical. This is confusing in repos where a repo-local skill intentionally overrides or specializes a user-level skill.

Expected behavior

When multiple skills share the same SkillMetadata.name, the mention popup should show one entry for that invocation name. Repo-scoped skills should take precedence over user/system/admin scopes because repo-local instructions are the most specific.

Root cause

ChatComposer::mention_items currently iterates every skill metadata entry and builds one MentionItem per entry. The item display label comes from skill_display_name, which can use interface.display_name, while the inserted token still uses skill.name.

That means these two skills render as visually distinct rows but insert the same token:

  • user skill: name = "sdlc", interface.display_name = "SDLC"
  • repo skill: name = "sdlc", no display name

Patch shape tested locally

I tested a focused TUI patch that dedupes skill mention rows by skill.name before building MentionItems. The dedupe policy preserves first-seen order, but replaces an existing entry when a later skill with the same name has a higher-priority scope:

Repo > User > System > Admin

This matches the existing skill-root cache ranking used elsewhere in core-skills.

Regression tests added locally

  • mention_items_deduplicate_same_skill_name_preferring_repo_scope
  • mention_items_deduplicate_preserves_first_seen_skill_order
  • skill_mention_popup_dedupes_same_name_snapshot

The snapshot verifies the popup renders a single row:

sdlc  [Skill] Repo SDLC workflow

Local verification

Targeted tests pass:

cargo test -p codex-tui mention_items_deduplicate
cargo test -p codex-tui skill_mention_popup_dedupes_same_name_snapshot
just fmt

Full-suite note on Windows

The broader Windows cargo test -p codex-tui run is currently blocked locally by unrelated Windows TUI test failures:

  • without RUST_MIN_STACK, several unrelated app::tests::* cases abort with STATUS_STACK_OVERFLOW; see #19596
  • with RUST_MIN_STACK=16777216, stack overflow clears, but three unrelated status::helpers::*compose_agents_summary* path-display assertions fail because expected ~\AppData\... differs from actual C:\Users\...\AppData\...

Those blockers are separate from this skill mention popup patch; the targeted duplicate-skill tests pass.

I can open a PR with the focused patch if contributor permissions/invitation allow it.

View original on GitHub ↗

4 Comments

BaseInfinity · 4 months ago

Process note: I usually file issues like this after following a full local $sdlc pass, meaning I have a focused patch shape plus regression tests and targeted proof before opening the report. If maintainers prefer that I open a PR directly for this kind of already-proven issue, let me know and I can do that when contributor permissions allow it.

BaseInfinity · 4 months ago

Implementation-ready patch notes

Adding the exact local patch shape here so this issue is usable as a PR handoff if I cannot open the branch directly.

File to change

codex-rs/tui/src/bottom_pane/chat_composer.rs

Behavior to preserve

  • Keep plugin/app duplicate rows unchanged. This issue is only about duplicate skill rows with the same SkillMetadata.name.
  • Keep insert_text based on skill.name, not display_name.
  • Keep searching by both skill.name and display_name when they differ.
  • Preserve first-seen popup ordering for distinct skill names.
  • For duplicate skill names, prefer the more specific scope.

Scope precedence used by the local patch

Repo > User > System > Admin

This mirrors the repo-specific-over-global behavior users expect, and it matches the existing cache-key rank shape in codex-rs/core-skills/src/manager.rs.

Minimal code change

Import SkillScope near the other protocol imports:

use codex_protocol::protocol::SkillScope;

In ChatComposer::mention_items, change only the skill loop source:

if let Some(skills) = self.skills.as_ref() {
    for skill in deduped_skill_mentions(skills) {
        let display_name = skill_display_name(skill);
        let description = skill_description(skill);
        let skill_name = skill.name.clone();
        let search_terms = if display_name == skill.name {
            vec![skill_name.clone()]
        } else {
            vec![skill_name.clone(), display_name.clone()]
        };
        mentions.push(MentionItem {
            display_name,
            description,
            insert_text: format!("${skill_name}"),
            search_terms,
            path: Some(skill.path_to_skills_md.to_string_lossy().into_owned()),
            category_tag: Some("[Skill]".to_string()),
            sort_rank: 1,
        });
    }
}

Add this helper near skill_description:

fn deduped_skill_mentions(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> {
    let scope_rank = |scope: SkillScope| match scope {
        SkillScope::Repo => 0,
        SkillScope::User => 1,
        SkillScope::System => 2,
        SkillScope::Admin => 3,
    };
    let mut by_name: HashMap<&str, usize> = HashMap::new();
    let mut deduped: Vec<&SkillMetadata> = Vec::new();
    for skill in skills {
        if let Some(index) = by_name.get(skill.name.as_str()).copied() {
            let current = deduped[index];
            if scope_rank(skill.scope) < scope_rank(current.scope) {
                deduped[index] = skill;
            }
        } else {
            by_name.insert(skill.name.as_str(), deduped.len());
            deduped.push(skill);
        }
    }

    deduped
}

Tests added locally

In the existing mod tests for chat_composer.rs, add imports:

use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;

Local helper used by the tests:

fn test_skill(
    name: &str,
    description: &str,
    path_to_skills_md: AbsolutePathBuf,
    scope: SkillScope,
) -> SkillMetadata {
    SkillMetadata {
        name: name.to_string(),
        description: description.to_string(),
        short_description: None,
        interface: None,
        dependencies: None,
        policy: None,
        path_to_skills_md,
        scope,
    }
}

fn test_skill_with_display_name(
    name: &str,
    description: &str,
    display_name: &str,
    path_to_skills_md: AbsolutePathBuf,
    scope: SkillScope,
) -> SkillMetadata {
    let mut skill = test_skill(name, description, path_to_skills_md, scope);
    skill.interface = Some(SkillInterface {
        display_name: Some(display_name.to_string()),
        short_description: Some(description.to_string()),
        icon_small: None,
        icon_large: None,
        brand_color: None,
        default_prompt: None,
    });
    skill
}
Regression test 1: same skill name prefers repo scope
#[test]
fn mention_items_deduplicate_same_skill_name_preferring_repo_scope() {
    let repo_skill_path = test_path_buf("/repo/.agents/skills/sdlc/SKILL.md").abs();
    let user_skill_path = test_path_buf("/home/.codex/skills/sdlc/SKILL.md").abs();
    let (tx, _rx) = unbounded_channel::<AppEvent>();
    let sender = AppEventSender::new(tx);
    let mut composer = ChatComposer::new(
        /*has_input_focus*/ true,
        sender,
        /*enhanced_keys_supported*/ false,
        "Ask Codex to do anything".to_string(),
        /*disable_paste_burst*/ false,
    );
    composer.set_text_content("$sdlc".to_string(), Vec::new(), Vec::new());
    composer.set_skill_mentions(Some(vec![
        test_skill_with_display_name(
            "sdlc",
            "Global SDLC workflow",
            "SDLC",
            user_skill_path,
            SkillScope::User,
        ),
        test_skill(
            "sdlc",
            "Repo SDLC workflow",
            repo_skill_path.clone(),
            SkillScope::Repo,
        ),
    ]));

    let mentions = composer.mention_items();
    let sdlc_mentions: Vec<_> = mentions
        .iter()
        .filter(|mention| mention.insert_text == "$sdlc")
        .collect();

    assert_eq!(sdlc_mentions.len(), 1);
    assert_eq!(sdlc_mentions[0].display_name, "sdlc");
    assert_eq!(
        sdlc_mentions[0].path,
        Some(repo_skill_path.display().to_string())
    );
}
Regression test 2: distinct skill ordering stays stable
#[test]
fn mention_items_deduplicate_preserves_first_seen_skill_order() {
    let alpha_path = test_path_buf("/home/.codex/skills/alpha/SKILL.md").abs();
    let beta_path = test_path_buf("/repo/.agents/skills/beta/SKILL.md").abs();
    let repo_sdlc_path = test_path_buf("/repo/.agents/skills/sdlc/SKILL.md").abs();
    let user_sdlc_path = test_path_buf("/home/.codex/skills/sdlc/SKILL.md").abs();
    let (tx, _rx) = unbounded_channel::<AppEvent>();
    let sender = AppEventSender::new(tx);
    let mut composer = ChatComposer::new(
        /*has_input_focus*/ true,
        sender,
        /*enhanced_keys_supported*/ false,
        "Ask Codex to do anything".to_string(),
        /*disable_paste_burst*/ false,
    );
    composer.set_text_content("$".to_string(), Vec::new(), Vec::new());
    composer.set_skill_mentions(Some(vec![
        test_skill("alpha", "User alpha workflow", alpha_path, SkillScope::User),
        test_skill_with_display_name(
            "sdlc",
            "Global SDLC workflow",
            "SDLC",
            user_sdlc_path,
            SkillScope::User,
        ),
        test_skill("beta", "Repo beta workflow", beta_path, SkillScope::Repo),
        test_skill(
            "sdlc",
            "Repo SDLC workflow",
            repo_sdlc_path.clone(),
            SkillScope::Repo,
        ),
    ]));

    let mentions = composer.mention_items();
    let insert_texts: Vec<_> = mentions
        .iter()
        .map(|mention| mention.insert_text.as_str())
        .collect();

    assert_eq!(insert_texts, vec!["$alpha", "$sdlc", "$beta"]);
    assert_eq!(mentions[1].display_name, "sdlc");
    assert_eq!(mentions[1].path, Some(repo_sdlc_path.display().to_string()));
}
Snapshot test
#[test]
fn skill_mention_popup_dedupes_same_name_snapshot() {
    snapshot_composer_state(
        "skill_mention_popup_dedupes_same_name",
        /*enhanced_keys_supported*/ false,
        |composer| {
            composer.set_text_content("$sd".to_string(), Vec::new(), Vec::new());
            composer.set_skill_mentions(Some(vec![
                test_skill_with_display_name(
                    "sdlc",
                    "Global SDLC workflow",
                    "SDLC",
                    test_path_buf("/home/.codex/skills/sdlc/SKILL.md").abs(),
                    SkillScope::User,
                ),
                test_skill(
                    "sdlc",
                    "Repo SDLC workflow",
                    test_path_buf("/repo/.agents/skills/sdlc/SKILL.md").abs(),
                    SkillScope::Repo,
                ),
            ]));
        },
    );
}

Expected snapshot body:

"                                                                                                    "
"› $sd                                                                                               "
"                                                                                                    "
"                                                                                                    "
"                                                                                                    "
"                                                                                                    "
"  sdlc  [Skill] Repo SDLC workflow                                                                  "
"                                                                                                    "
"  Press enter to insert or esc to close                                                             "

Verification I ran locally

cargo test -p codex-tui mention_items_deduplicate
cargo test -p codex-tui skill_mention_popup_dedupes_same_name_snapshot
just fmt
git diff --check

Results:

  • mention_items_deduplicate: 2 passed
  • skill_mention_popup_dedupes_same_name_snapshot: 1 passed
  • formatter completed
  • whitespace check passed

Full-suite caveat

cargo test -p codex-tui is blocked on my Windows machine by unrelated upstream failures already split into separate issues:

  • #19596 for STATUS_STACK_OVERFLOW in unrelated app::tests::*
  • #19623 for Windows ~\AppData\... vs C:\Users\...\AppData\... status-helper path assertions

The duplicate skill-label patch is targeted to ChatComposer::mention_items; the focused tests above pass.

etraut-openai contributor · 4 months ago

Thanks for reporting the issue. The current behavior is intended, so I wouldn't consider this a bug.

These are not duplicate skills, even though they have the same name. Collapsing duplicate names would remove the user’s ability to choose a non-default same-name skill from the popup.

BaseInfinity · 4 months ago

Ahh I see, thanks for the response and clarification!