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:
- 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.
- 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.
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)andclaude-opus-4-8 (high)forever.(med)and(low)are never selectable.Version: v0.67.1 (
88a19f38e), Linux x86_64, providerclaude-oauth.Root cause
Two behaviours combine. All line numbers are
crates/jcode-tui/src/tui/app/inline_interactive.rsat v0.67.1.1.
selectedresets 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 withselected: 0(L1837). Theprevious_pickerrestore 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: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_currentfirst, thenis_favorite: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 becauseusage_score(L191) is stable. That produces a 2-cycle.Ranking for the repro (usage =
count * 100 + 50):Selecting
opus-4-8 (high)makes it current, so it moves to slot 0 andopus-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):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/lowof 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_currentguarantees index 0 is a moving target.Options, roughly in order of preference:
picker.selectedfrom 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_favoritein a standalone script driven by the realmodel_picker_favorites.json/model_picker_usage.jsonabove, and it reproduces the reported 2-cycle exactly, including which entries are unreachable. Happy to attach the script if useful.