diff --git a/.changeset/stats-range-arrow-keys.md b/.changeset/stats-range-arrow-keys.md new file mode 100644 index 000000000..5431625ff --- /dev/null +++ b/.changeset/stats-range-arrow-keys.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed `/stats` dropping arrow-key presses and freezing on a range tab. The range stepped from the value captured in the input handler's closure, but Ink re-registers that handler in a passive effect that runs after the frame is painted — so a press arriving before the effect landed was dispatched with the previous render's range, recomputed the tab it had already moved to, and wedged there until another key broke the tie. The range now steps from the value React holds. diff --git a/source/commands/stats.spec.tsx b/source/commands/stats.spec.tsx index 8a915ece3..c08edde6a 100644 --- a/source/commands/stats.spec.tsx +++ b/source/commands/stats.spec.tsx @@ -117,7 +117,16 @@ test('StatsDisplay changes range with arrow keys and closes on Escape', async t await waitFor(frameMatches(/\[3m\]/)); t.regex(stripAnsi(lastFrame() ?? ''), /\[3m\]/); + // Two presses in one tick. Ink re-registers the input handler in a passive + // effect that runs after the frame is painted, so the second press is still + // dispatched with the previous render's range - stepping from that captured + // value lands on 3m again and wedges the tabs there. stdin.write('\u001B[C'); + stdin.write('\u001B[C'); + await waitFor(frameMatches(/\[7d\]/)); + t.regex(stripAnsi(lastFrame() ?? ''), /\[7d\]/); + + stdin.write('\u001B[D'); await waitFor(frameMatches(/\[all-time\]/)); t.regex(stripAnsi(lastFrame() ?? ''), /\[all-time\]/); diff --git a/source/components/stats/stats-display.tsx b/source/components/stats/stats-display.tsx index 0d7253cc1..ffee82fab 100644 --- a/source/components/stats/stats-display.tsx +++ b/source/components/stats/stats-display.tsx @@ -100,17 +100,20 @@ export function StatsDisplay({ return; } // Match Settings tabs: left/right only (no letter shortcut). - if (key.leftArrow) { - const idx = STATS_RANGES.indexOf(range); - const prev = - STATS_RANGES[(idx - 1 + STATS_RANGES.length) % STATS_RANGES.length]; - if (prev) setRange(prev); - return; - } - if (key.rightArrow) { - const idx = STATS_RANGES.indexOf(range); - const next = STATS_RANGES[(idx + 1) % STATS_RANGES.length]; - if (next) setRange(next); + // Step from the range React holds, not the one this closure captured: + // Ink re-registers the handler in a passive effect that runs after the + // frame is painted, so a press arriving right after a range change is + // still dispatched here with the previous render's value. + if (key.leftArrow || key.rightArrow) { + const delta = key.rightArrow ? 1 : -1; + setRange(prev => { + const idx = STATS_RANGES.indexOf(prev); + return ( + STATS_RANGES[ + (idx + delta + STATS_RANGES.length) % STATS_RANGES.length + ] ?? prev + ); + }); } }, {isActive: interactive},