-
Notifications
You must be signed in to change notification settings - Fork 60
fix: g (go to top) was advertised in help but never handled - #152
fix: g (go to top) was advertised in help but never handled #152piotrsynowiec wants to merge 1 commit into
Conversation
keys.Top is declared, bound to "g", and carries help text ("gg",
"top"). help.FromKeyMap builds the help modal by reflecting over every
binding that has help text, so ? has always listed the shortcut. But
handleNormalMode had no case for keys.Top and no handleGoToTop existed,
so pressing g did nothing -- in the sidebar, the messages pane, the
thread panel and the threads list alike. G worked; its counterpart
silently did not.
Every panel model already had GoToTop. Only the App-level handler and
its dispatch case were missing, which reads as intended-then-forgotten
rather than a deliberate omission -- nothing else would explain four
unused GoToTop implementations and a documented binding.
handleGoToTop mirrors handleGoToBottom, with one addition: reaching the
top of the channel history is slk's backfill trigger, so the messages
branch fires maybeFetchOlderHistory the way the wheel and k paths do.
Tests cover the sidebar and the messages pane, plus a guard that the
help modal really does advertise "top" -- which is what makes the
missing handler a user-visible defect rather than dead configuration.
Verified by reverting the fix and confirming both fail on it.
Thanks, @piotrsynowiec. I checked this independently and ran into the same issue before finding this PR. It looks like keys.Top is declared, help.FromKeyMap includes it because it has help text, but handleNormalMode doesn't handle it. All four models already have a working GoToTop, but nothing currently calls it.
I only have one suggestion: the binding.
Right now, this makes g a command, so pressing it once jumps to the top. But the help text says gg, and that's also how vim handles it: g is a prefix, not a command by itself. Both versions are usable, and with the current binding, typing gg just jumps twice, so this isn't really a bug. Still, using g on its own means it can't be used as a prefix later:
- Any future
gcommand becomes impossible, since the firstghas already acted. The ones that would actually fit a messenger probably aren't the text-editing motions but things likegt/gTfor next / previous channel (vim's tab bindings, which slk has no equivalent for today outside the finder),gxto open the link in the selected message, orgffor a file attachment. - The help says
gg, but the actual binding is justg, which is a little confusing.
The chord version doesn't add much code, and the project already has a similar pattern with pendingWinCmd for the ctrl+w prefix. The three main changes would be:
// app.go — alongside pendingWinCmd pendingGoTop bool // mode_normal.go — intercepted with the other pending chords if a.pendingGoTop { a.pendingGoTop = false a.statusbar.SetHelpHint(a.defaultHelpHint()) return a.handleGoTopChord(msg) } // mode_normal.go — arm it case key.Matches(msg, a.keys.Top): a.pendingGoTop = true a.statusbar.SetHelpHint("g ...") return nil
Then we'd add a small handleGoTopChord that only acts when the second key is g and quietly ignores anything else, just like handleWindowChord. SetMode should also clear pendingGoTop, just as it clears pendingWinCmd; otherwise, since ctrl+c is handled before mode dispatch, the prefix could remain active.
Ignoring the second key is important here: in vim, g is a prefix, so gj shouldn't fall through and move down.
Either approach is fine—the bug is real, and this PR fixes it, which is the important part. I just think it's worth deciding now whether g should stay available for future commands. I have the chord version written and tested locally if it's helpful; I can open it as a follow-up on top of this rather than make it compete with the current PR.
gammons
commented
Sep 3, 2026
The bug is real: internal/ui/keys.go:89 declares Top with help text gg, there's no keys.Top case anywhere in mode_normal.go, and help.FromKeyMap shows it regardless. Four unused GoToTop implementations exist (sidebar/model.go:846, messages/model.go:965, threadsview/model.go:305, thread/model.go:769). Intended, then forgotten. Good find, and g/G is table stakes for a vim-modal TUI.
But the one piece of logic that isn't a copy of handleGoToBottom is dead code. I measured it on your branch:
before g: yOffset=586 AtTop=false ViewportAtTop=false
after g: yOffset=586 AtTop=true ViewportAtTop=false cmd_nil=true
RESULT: g returned nil cmd -> backfill did NOT fire
handleGoToTop returns a.maybeFetchOlderHistory(a.messagepane.ViewportAtTop()), but ViewportAtTop() reads m.yOffset (messages/model.go:987), which GoToTop() never touches — it only sets selected = 0 (messages/model.go:965-970). yOffset is recomputed lazily during render, i.e. after the cmd is returned. So from any scrolled-down position — the only position where g is useful — the backfill never fires.
The right predicate is the selection-based AtTop(), which is what the k path uses at app.go:1328. Your comment says you're firing "the same one the wheel and k paths fire," but you copied the wheel predicate (app.go:1454, reducer_mouse.go:122) onto a selection move. One identifier:
after g: AtTop=true ViewportAtTop=false cmd_nil=false
RESULT: backfill cmd returned
The tests never touch the broken part. TestGoToTopScrollsChannelHistory asserts a.messagepane.AtTop() while the handler branches on ViewportAtTop() — two different predicates, and asserting the one the code doesn't use is exactly why this shipped green. It's also near-tautological (GoToTop sets selected = 0; AtTop() is selected == 0). TestTopKeyIsAdvertisedInHelp passes on unmodified main, so it guards the help entry, not the fix. Nothing asserts the backfill cmd at all.
Also please drop the two t.Skip guards in gototop_test.go:47 and :76 — a test that silently skips when the fixture doesn't cooperate is a test that can silently stop testing.
On g vs gg — @rfist is right, and this is the bigger issue. The entire justification is "help says gg, pressing g does nothing," and the fix makes g an immediate command while leaving help saying gg and the binding as g. The stated defect is only half-resolved.
We already have the mechanism: pendingWinCmd (app.go:143-146, mode_normal.go:42-46, armed at :92-95, cleared in SetMode at app.go:1613). A pendingGoTop chord is ~15 lines against an established in-repo pattern, and it keeps g free as a prefix for gt/gT/gx/gf — all of which suit a messenger better than vim's text motions. Once g ships as an immediate command, reclaiming it is a breaking change.
Either implement the chord (@rfist has it written) or change keys.go:89 to key.WithHelp("g", "top"). Not both ways.
No conflict with #163 or #164 — both touch the same switch and keys.go block but neither binds g. Trivial rebase.
Your CI lint failure is not your fault — old golangci-lint panicking under go1.27, fixed on main by 6d39fe5. Your test job passed.
gammons
commented
Sep 3, 2026
#171 has landed and I've re-run CI here — the lint failure is gone and this is green now. That was the stale golangci-lint/go1.27 issue, not anything you did.
Note that #171 also enabled gofmt as an enforced lint check, so please run gofmt -w over your changes when you push the next revision.
My review above still stands — that's what's needed to move this forward.
What breaks
Press
?. The help modal listsgg— top. Pressg. Nothing happens.Gjumps to the bottom in every panel. Its counterpart does nothing, anywhere: sidebar, messages pane, thread panel, threads list.Why
keys.Topis fully declared:help.FromKeyMapbuilds the help modal by reflecting over every binding that carries help text, soTophas always been shown to users — a binding does not have to be wired to be advertised.But
handleNormalModehas a case forkeys.Bottomand none forkeys.Top, and nohandleGoToTopexists. The binding is declared, documented, and unreachable.Worth noting:
GoToTopis already implemented onsidebar,messages,threadsviewandthread— four models, none of them called from anywhere. That is what makes this look like intended-then-forgotten rather than a deliberate omission; nothing else explains four unused implementations sitting behind a documented shortcut.The fix
Add
handleGoToTop, mirroringhandleGoToBottom, and dispatchkeys.TopinhandleNormalMode.One addition over a pure mirror: arriving at the top of the channel history is slk's history-backfill trigger, so the messages branch fires
maybeFetchOlderHistorythe same way the wheel andkpaths already do. Without that,gwould land at the top and not load the older messages every other route to that position loads.Testing
Three tests:
gmoves the sidebar selection to the first row,gmoves the messages pane to the top, and a guard that the help modal genuinely lists "top".That third one is the point of the pair. A binding nothing dispatches on is only a bug because users are told it exists — if the help entry is ever removed, the test says so instead of quietly passing.
Verified by reverting the fix and confirming the tests fail on the defect.
Full suite passes.
How I found it
While auditing a related class of defect: decisions slk makes in more than one place — keyboard vs mouse, per-view routing — where one copy learned about a new case and the other did not. This one is the degenerate version: the decision has one copy, and it was never written.
🤖 Generated with Claude Code