Skip to content

Shift+Tab favorite cycling only alternates between two models, 3rd+ favorites unreachable #767

Description

@yumhum

Summary

Shift+Tab (model_favorite_cycle) only ever alternates between two favorites, no matter how many are favorited. Effort variants of the same model are the most visible casualty, but the bug is not actually about effort: any 3rd, 4th, … favorite is unreachable too.

The favorites store does record effort correctly. The cycling logic is what's broken.

Repro

Favorite three models, e.g. claude-opus-5 (high), claude-opus-4-8 (high), claude-opus-4-8 (med).

~/.config/jcode/model_picker_favorites.json:

{
  "version": 1,
  "favorites": [
    "claude-opus-5\u001fAnthropic\u001fclaude-oauth\u001fhigh",
    "claude-opus-4-8\u001fAnthropic\u001fclaude-oauth\u001fhigh",
    "claude-opus-4-8\u001fAnthropic\u001fclaude-oauth\u001fmedium",
    "claude-opus-4-8\u001fAnthropic\u001fclaude-oauth\u001flow"
  ]
}

Press Shift+Tab repeatedly.

Expected: cycle through all four favorites.
Actual: ping-pongs between claude-opus-5 (high) and claude-opus-4-8 (high) forever. (med) and (low) are never selectable.

Version: v0.67.1 (88a19f38e), Linux x86_64, provider claude-oauth.

Root cause

Two behaviours combine. All line numbers are crates/jcode-tui/src/tui/app/inline_interactive.rs at v0.67.1.

1. selected resets to 0 on every press.

cycle_model_favorite_hotkey (L2996) opens a fresh picker, cycles, then confirms with Enter — which closes the picker. The next press opens a brand-new picker with selected: 0 (L1837). The previous_picker restore right below it only applies when a model picker is already open, which it never is here.

So cycle_selected_model_favorite (L2963) always starts its scan from index 0:

let total = picker.filtered.len();
for offset in 1..=total {
    let next = (picker.selected + offset) % total;   // picker.selected is always 0
    ...
}

It therefore always returns the first favorite below index 0 — a fixed function of list order, not a cursor advancing through the list.

2. The just-selected model sorts to index 0.

The comparator (L1711 area) orders is_current first, then is_favorite:

a_current.cmp(&b_current)
    .then(a_favorite.cmp(&b_favorite))
    .then(a_recent.cmp(&b_recent))
    .then(a_usage.cmp(&b_usage))
    ...

The model you just switched to is is_current, so it occupies slot 0. The scan starts at 1 and stops at the first favorite it hits — the highest-ranked other favorite, which is stable because usage_score (L191) is stable. That produces a 2-cycle.

Ranking for the repro (usage = count * 100 + 50):

[0] C*  claude-opus-5 (high)     usage=2550   <- is_current, scan starts after this
[1]  *  claude-opus-4-8 (high)   usage=950    <- always picked
[2]  *  claude-opus-4-8 (low)    usage=0      <- never reached
[3]  *  claude-opus-4-8 (med)    usage=0      <- never reached

Selecting opus-4-8 (high) makes it current, so it moves to slot 0 and opus-5 (high) becomes the top non-current favorite. The two swap forever; entries below can only be reached if every favorite above them is simultaneously current, which is impossible.

Not effort-specific

Same simulation with three different models all at high (no effort variation anywhere):

cycle: opus-4-8 (high) -> opus-5 (high) -> opus-4-8 (high) -> ...
unreachable favorites: ['claude-fable-5 (high)']

With exactly two favorites the behaviour is correct, which is why this hasn't been noticed: the bug is invisible until you favorite a third entry.

Effort variants just make it hit sooner, since favoriting high/med/low of one model is a natural way to reach three favorites.

Suggested fix

The cycle needs a position that survives the picker being closed, since ranking by is_current guarantees index 0 is a moving target.

Options, roughly in order of preference:

  1. Cycle over the persisted favorites list directly, independent of picker ordering. Keep a stable order (insertion order, or sort by the store key) plus a "last cycled" index in app state; Shift+Tab advances that index and applies the model. This decouples the hotkey from picker sort entirely and gives a predictable, user-controllable order.
  2. Seed picker.selected from the current model before scanning: find the entry matching current model + effort and start the scan from there. Cheaper, but still tied to picker sort order.

Happy to send a PR if you'd like — option 1 seems right, though it does mean deciding what the canonical favorite order should be (insertion order is probably least surprising).

Verification

I replicated the entry construction, the sort comparator, and cycle_selected_model_favorite in a standalone script driven by the real model_picker_favorites.json / model_picker_usage.json above, and it reproduces the reported 2-cycle exactly, including which entries are unreachable. Happy to attach the script if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    autonomous: clearHands-off: unambiguous bug, obvious fix, no decisions. Don't even look - an agent can fully solve.bugSomething isn't workingpriority: mediumP2 - normal prioritytriage: fixed-pending-releaseFixed in code/committed; will close automatically on next releasetriage: reproducibleClear repro + clear fix path

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions