From 2c89667d1eb7f1c0c68435ca67695463d25c069a Mon Sep 17 00:00:00 2001 From: addyCooks Date: Fri, 18 Sep 2026 02:44:14 +0530 Subject: [PATCH] fix(stats): stop fast arrow presses from wedging the range tabs The left/right handlers stepped from the `range` captured in the input handler's closure. Ink re-registers `useInput` handlers in a passive effect that runs after the frame is painted, so a press arriving in that window was dispatched with the previous render's value, recomputed the tab it had already moved to, and React bailed out - leaving the tabs stuck until another key broke the tie. Both directions now step from the value React holds, which also collapses the two near-identical branches into one. The spec pressed once per tick and polled the frame in between, which is exactly the window that hides this, so it only failed when the passive effect lost the race - intermittently on CI, consistently on Windows. It now sends two presses in one tick to pin the bug deterministically, and adds a left-arrow press so the shared step is covered in both directions. --- .changeset/stats-range-arrow-keys.md | 5 +++++ source/commands/stats.spec.tsx | 9 ++++++++ source/components/stats/stats-display.tsx | 25 +++++++++++++---------- 3 files changed, 28 insertions(+), 11 deletions(-) create mode 100644 .changeset/stats-range-arrow-keys.md 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},