feat(tui): implement keyboard navigation, jump, page scroll, and help overlay#162
Conversation
…w:issues TUI Wire all keyboard actions defined in DEFAULT_BINDINGS but previously missing handlers: jump-top (g/Home), jump-bottom (G/End), page-up/page-down (PgUp/PgDn), tab-focus-reverse (Shift+Tab), help (?), and force-quit (Ctrl+C). - index.cjs: add PAGE_SIZE constant, helpVisible state, handlers for all missing actions; quit now closes help/search before exiting; force-quit calls process.exit - keyboard.cjs: add Ctrl+C → force-quit binding to DEFAULT_BINDINGS - renderer.cjs: add help overlay (blessed.box, centered, lists all shortcuts); render() accepts helpVisible to show/hide overlay; Ctrl+C passes through in search mode; status bar now shows [?] help hint; cursor char via unicode escape - github.cjs: add createdAt, url, body, comments to listIssues JSON fields so the detail pane and static table age column have complete data Closes #118 Co-Authored-By: Stephen Miller <21005207+snipcodeit@users.noreply.github.com>
snipcodeit
left a comment
There was a problem hiding this comment.
Review: PR #162 — keyboard navigation, jump, page scroll, help overlay
Overall the implementation is solid — the keyboard handler wiring is clean and the help overlay approach works well. However there are several issues that need addressing before merge.
CRITICAL: tab-focus-reverse references a pane that does not exist in this PR
In lib/tui/index.cjs, the tab-focus-reverse handler cycles through ['list', 'detail', 'filter']. But 'filter' as a focusable pane is implemented in PR #163, not here. If this PR is merged before #163, Tab in reverse order will cycle to a filter pane that has no render path, causing a silent visual bug (the pane focus silently shifts to an unrendered state).
The PR should either:
- Document this as a hard dependency on #163 and ensure they are merged together, OR
- Use
['list', 'detail']here and extend it in #163
The help overlay also shows [f] filter in the hint text — the f key for filter-focus is added in PR #163, not here. The hint will show a non-functional shortcut.
CRITICAL: No test file added
The test plan lists 7 unit-testable assertions (e.g. "all 7 events fire" inline command). These should live in test/tui-keyboard.test.cjs rather than inline command snippets in the PR description. The TUI test plan specifically tests KeyboardHandler dispatching which is side-effect-free and straightforward to unit test:
const { KeyboardHandler, DEFAULT_BINDINGS } = require('./lib/tui/keyboard.cjs');
describe('DEFAULT_BINDINGS — all new bindings dispatch correctly', () => {
it('dispatches jump-top on g', () => { ... });
it('dispatches jump-bottom on G', () => { ... });
it('dispatches page-up / page-down', () => { ... });
it('dispatches help on ?', () => { ... });
it('dispatches force-quit on Ctrl+C', () => { ... });
it('dispatches tab-focus-reverse on Shift+Tab', () => { ... });
});Without this, the new bindings have zero automated coverage.
Double Ctrl+C dispatch in search mode
In renderer.cjs, the search mode key handler explicitly dispatches '\u0003' to the keyboard when Ctrl+C is pressed:
if (ch === '\u0003' || key.full === 'C-c') {
keyboard.dispatch('\u0003', key);
return;
}But DEFAULT_BINDINGS also registers '\u0003': 'force-quit' so keyboard.dispatch would fire the force-quit event. This is correct, but the comment says // Always allow Ctrl+C to force-quit even in search mode — it would be clearer to emit 'force-quit' directly rather than going through raw character dispatch:
keyboard.emit('force-quit');
return;This avoids relying on the binding table being correct and makes intent explicit.
startKeyboard() already in use — verify no double-registration
The startKeyboard() method in renderer.cjs registers a screen-level key handler. If createBlessedRenderer() is called more than once (e.g. in tests or restart scenarios), startKeyboard() could register multiple listeners. Confirm there is a guard or that it is only ever called once per lifecycle.
Good things
- Hierarchical quit (help → search → exit) is clean and correct
force-quitas a separate event fromquitis the right design- Unicode escape
\u2588for cursor fix is correct helpVisiblethreaded cleanly throughdraw()→renderer.render()
- Add test/keyboard-nav.test.cjs with 25 tests covering KeyboardHandler dispatch, helpVisible toggle pattern, updateSearch, bind/unbind, DEFAULT_BINDINGS export, and custom bindings constructor - Add comment to lib/tui/index.cjs tab-focus-reverse handler noting that the 'filter' tab stop depends on the filter pane from PR #119
Summary
DEFAULT_BINDINGSbut had no handlers:jump-top(g/Home),jump-bottom(G/End),page-up/page-down(PgUp/PgDn),tab-focus-reverse(Shift+Tab),help(?), andforce-quit(Ctrl+C)blessed.box) that lists all keyboard shortcuts, toggled with?and closeable withqquitnow hierarchically closes help or search mode before exiting the browsercreatedAt,url,body, andcommentstolistIssuesso the detail pane and static table age column have complete dataCloses #118
Milestone Context
Changes
lib/tui/index.cjsPAGE_SIZE = 10constant for page scroll amounthelpVisiblestate variablehelpVisibletorenderer.render()indraw()jump-top,jump-bottom,page-up,page-down,tab-focus-reverse,help,force-quitquithandler to close help overlay or exit search mode before quittinglib/tui/keyboard.cjsCtrl+C(\u0003) →force-quitbinding toDEFAULT_BINDINGSlib/tui/renderer.cjshelpOverlay(blessed.box, centered, 52×20) listing all keyboard shortcutsrender()to accepthelpVisibleand callhelpOverlay.show()/.hide()[?] helphint in normal mode\u2588) instead of literal█lib/github.cjscreatedAt,url,body,commentsto thelistIssuesJSON field listTest Plan
MGW_NO_TUI=1 node bin/mgw.cjs issues --limit 5— static table shows Age column with relative times (not-)MGW_NO_TUI=1 node bin/mgw.cjs issues --search "tui"— search filters static table correctlynode -e "const { KeyboardHandler, DEFAULT_BINDINGS } = require('./lib/tui/keyboard.cjs'); const kb = new KeyboardHandler(); let hits = []; ['jump-top','jump-bottom','page-up','page-down','help','force-quit','tab-focus-reverse'].forEach(e => kb.on(e, () => hits.push(e))); kb.dispatch('g'); kb.dispatch('G'); kb.dispatch('\u001B[5~'); kb.dispatch('\u001B[6~'); kb.dispatch('?'); kb.dispatch('\u0003'); kb.dispatch('\u001B[Z'); console.log(hits.join(','));"— all 7 events fire?opens help overlay listing shortcuts,?orqcloses itg/Home jumps to first item,G/End jumps to last🤖 Generated with Claude Code