Skip to content

Commit 447a768

Browse files
committed
fix(tui): bind keypad mirrors for navigation keybinds
Terminals using the kitty keyboard protocol (e.g. Windows Terminal) report NumPad navigation keys as CSI-u sequences that decode to keypad names such as kpend and kphome. Default keybinds only named the bare navigation keys, so every NumPad keystroke was silently discarded. Add the keypad mirror to every default that names a bare navigation key, keeping the invariant that a default binding one also binds its keypad counterpart. Add a definitions-level invariant test and an end-to-end test that feeds real CSI 57424u bytes through the renderer. Closes anomalyco#55533
1 parent c6d1c09 commit 447a768

5 files changed

Lines changed: 104 additions & 47 deletions

File tree

‎packages/tui/src/config/keybind.ts‎

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,14 @@ export const Definitions = {
6363
"location.reload": keybind("none", "Reload configuration"),
6464
"diff.open": keybind("none", "Open diff viewer"),
6565
"diff.close": keybind("escape,q", "Close diff viewer"),
66-
"diff.down": keybind("j,down", "Move diff viewer down"),
67-
"diff.up": keybind("k,up", "Move diff viewer up"),
68-
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
69-
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
66+
"diff.down": keybind("j,down,kpdown", "Move diff viewer down"),
67+
"diff.up": keybind("k,up,kpup", "Move diff viewer up"),
68+
"diff.page.down": keybind("pagedown,ctrl+f,kppagedown", "Page diff viewer down"),
69+
"diff.page.up": keybind("pageup,ctrl+b,kppageup", "Page diff viewer up"),
7070
"diff.half_page.down": keybind("ctrl+d", "Scroll diff viewer down half a page"),
7171
"diff.half_page.up": keybind("ctrl+u", "Scroll diff viewer up half a page"),
72-
"diff.first": keybind("gg,home", "Go to the start of the diff"),
73-
"diff.last": keybind("shift+g,end", "Go to the end of the diff"),
72+
"diff.first": keybind("gg,home,kphome", "Go to the start of the diff"),
73+
"diff.last": keybind("shift+g,end,kpend", "Go to the end of the diff"),
7474
// Retain shipped configuration names without registering the removed tree navigation commands.
7575
"diff.toggle": keybind("none", "Deprecated: file tree is mouse-controlled"),
7676
"diff.expand": keybind("none", "Deprecated: file tree is mouse-controlled"),
@@ -135,10 +135,10 @@ export const Definitions = {
135135
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
136136
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
137137
"session.verbosity.cycle": keybind("none", "Cycle transcript verbosity"),
138-
"session.child.first": keybind("down", "Toggle subagent picker"),
139-
"session.child.next": keybind("right", "Go to next child session"),
140-
"session.child.previous": keybind("left", "Go to previous child session"),
141-
"session.parent": keybind("up", "Go to parent session"),
138+
"session.child.first": keybind("down,kpdown", "Toggle subagent picker"),
139+
"session.child.next": keybind("right,kpright", "Go to next child session"),
140+
"session.child.previous": keybind("left,kpleft", "Go to previous child session"),
141+
"session.parent": keybind("up,kpup", "Go to parent session"),
142142
"session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"),
143143
"session.quick_switch.1": keybind("<leader>1", "Switch to session in quick slot 1"),
144144
"session.quick_switch.2": keybind("<leader>2", "Switch to session in quick slot 2"),
@@ -176,14 +176,14 @@ export const Definitions = {
176176
"variant.cycle": keybind("ctrl+t", "Cycle model variants"),
177177
"variant.list": keybind("none", "List model variants"),
178178

179-
"session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
180-
"session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
179+
"session.page.up": keybind("pageup,ctrl+alt+b,kppageup", "Scroll messages up by one page"),
180+
"session.page.down": keybind("pagedown,ctrl+alt+f,kppagedown", "Scroll messages down by one page"),
181181
"session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"),
182182
"session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"),
183183
"session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"),
184184
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
185-
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
186-
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
185+
"session.first": keybind("ctrl+g,home,alt+home,kphome", "Navigate to first message"),
186+
"session.last": keybind("ctrl+alt+g,end,kpend", "Navigate to last message"),
187187
"session.message.next": keybind("none", "Navigate to next message"),
188188
"session.message.previous": keybind("none", "Navigate to previous message"),
189189
"session.message.user.next": keybind("none", "Navigate to next user message"),
@@ -207,10 +207,10 @@ export const Definitions = {
207207
"prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
208208
"input.submit": keybind("return", "Submit input"),
209209
"input.newline": keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
210-
"input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
211-
"input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
212-
"input.move.up": keybind("up", "Move cursor up in input"),
213-
"input.move.down": keybind("down", "Move cursor down in input"),
210+
"input.move.left": keybind("left,ctrl+b,kpleft", "Move cursor left in input"),
211+
"input.move.right": keybind("right,ctrl+f,kpright", "Move cursor right in input"),
212+
"input.move.up": keybind("up,kpup", "Move cursor up in input"),
213+
"input.move.down": keybind("down,kpdown", "Move cursor down in input"),
214214
"input.select.left": keybind("shift+left", "Select left in input"),
215215
"input.select.right": keybind("shift+right", "Select right in input"),
216216
"input.select.up": keybind("shift+up", "Select up in input"),
@@ -231,7 +231,7 @@ export const Definitions = {
231231
"input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"),
232232
"input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"),
233233
"input.backspace": keybind("backspace,shift+backspace", "Backspace in input"),
234-
"input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
234+
"input.delete": keybind("ctrl+d,delete,shift+delete,kpdelete", "Delete character in input"),
235235
"input.undo": keybind("ctrl+-,super+z", "Undo in input"),
236236
"input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"),
237237
"input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
@@ -241,27 +241,27 @@ export const Definitions = {
241241
"input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
242242
"input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
243243
"input.select.all": keybind("super+a", "Select all in input"),
244-
"prompt.history.previous": keybind("up", "Previous history item"),
245-
"prompt.history.next": keybind("down", "Next history item"),
244+
"prompt.history.previous": keybind("up,kpup", "Previous history item"),
245+
"prompt.history.next": keybind("down,kpdown", "Next history item"),
246246

247-
"composer.subagent.up": keybind("up", "Previous subagent"),
248-
"composer.subagent.down": keybind("down", "Next subagent"),
247+
"composer.subagent.up": keybind("up,kpup", "Previous subagent"),
248+
"composer.subagent.down": keybind("down,kpdown", "Next subagent"),
249249
"composer.subagent.select": keybind("return", "Navigate to subagent"),
250250
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
251-
"composer.shell.up": keybind("up", "Previous shell"),
252-
"composer.shell.down": keybind("down", "Next shell"),
251+
"composer.shell.up": keybind("up,kpup", "Previous shell"),
252+
"composer.shell.down": keybind("down,kpdown", "Next shell"),
253253
"composer.shell.select": keybind("return", "View shell output"),
254254
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
255-
"composer.terminal.up": keybind("up,k", "Previous terminal"),
256-
"composer.terminal.down": keybind("down,j", "Next terminal"),
255+
"composer.terminal.up": keybind("up,k,kpup", "Previous terminal"),
256+
"composer.terminal.down": keybind("down,j,kpdown", "Next terminal"),
257257
"composer.terminal.select": keybind("return", "Select terminal"),
258258

259-
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
260-
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
261-
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
262-
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
263-
"dialog.select.home": keybind("home", "Move to first dialog item"),
264-
"dialog.select.end": keybind("end", "Move to last dialog item"),
259+
"dialog.select.prev": keybind("up,ctrl+p,kpup", "Move to previous dialog item"),
260+
"dialog.select.next": keybind("down,ctrl+n,kpdown", "Move to next dialog item"),
261+
"dialog.select.page_up": keybind("pageup,kppageup", "Move up one page in dialog"),
262+
"dialog.select.page_down": keybind("pagedown,kppagedown", "Move down one page in dialog"),
263+
"dialog.select.home": keybind("home,kphome", "Move to first dialog item"),
264+
"dialog.select.end": keybind("end,kpend", "Move to last dialog item"),
265265
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
266266
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
267267
"dialog.integration.rename": keybind("ctrl+r", "Rename integration account"),
@@ -271,8 +271,8 @@ export const Definitions = {
271271
"dialog.move_session.move": keybind("ctrl+m", "Move session to worktree"),
272272
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
273273
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"),
274-
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
275-
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
274+
"prompt.autocomplete.prev": keybind("up,ctrl+p,kpup", "Move to previous autocomplete item"),
275+
"prompt.autocomplete.next": keybind("down,ctrl+n,kpdown", "Move to next autocomplete item"),
276276
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
277277
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
278278
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),

‎packages/tui/test/config-v2.test.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -223,21 +223,21 @@ test("accepts every v2-only named command ID", () => {
223223

224224
test("centralizes named command defaults and resolves explicit none", () => {
225225
const defaults = {
226-
"composer.subagent.up": "up",
227-
"composer.subagent.down": "down",
226+
"composer.subagent.up": "up,kpup",
227+
"composer.subagent.down": "down,kpdown",
228228
"composer.subagent.select": "return",
229229
"composer.subagent.interrupt": "ctrl+d",
230-
"composer.shell.up": "up",
231-
"composer.shell.down": "down",
230+
"composer.shell.up": "up,kpup",
231+
"composer.shell.down": "down,kpdown",
232232
"composer.shell.kill": "ctrl+d",
233-
"diff.down": "j,down",
234-
"diff.up": "k,up",
235-
"diff.page.down": "pagedown,ctrl+f",
236-
"diff.page.up": "pageup,ctrl+b",
233+
"diff.down": "j,down,kpdown",
234+
"diff.up": "k,up,kpup",
235+
"diff.page.down": "pagedown,ctrl+f,kppagedown",
236+
"diff.page.up": "pageup,ctrl+b,kppageup",
237237
"diff.half_page.down": "ctrl+d",
238238
"diff.half_page.up": "ctrl+u",
239-
"diff.first": "gg,home",
240-
"diff.last": "shift+g,end",
239+
"diff.first": "gg,home,kphome",
240+
"diff.last": "shift+g,end,kpend",
241241
"diff.next_file": "n,alt+down",
242242
"diff.previous_file": "p,alt+up",
243243
"diff.next_hunk": "]",

‎packages/tui/test/keybind.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,29 @@ test("binds agent cycling only to shift+tab by default", () => {
55
expect(TuiKeybind.Definitions["agent.cycle"].default).toBe("shift+tab")
66
expect(TuiKeybind.Definitions["agent.cycle.reverse"].default).toBe("none")
77
})
8+
9+
test("binds keypad mirrors for every default using a bare navigation key", () => {
10+
const mirrors = {
11+
up: "kpup",
12+
down: "kpdown",
13+
left: "kpleft",
14+
right: "kpright",
15+
home: "kphome",
16+
end: "kpend",
17+
pageup: "kppageup",
18+
pagedown: "kppagedown",
19+
} as const
20+
21+
const violations = Object.entries(TuiKeybind.Definitions).flatMap(([id, definition]) => {
22+
const value = definition.default
23+
if (typeof value !== "string") return []
24+
const tokens = value.split(",").map((token) => token.trim())
25+
const missing = tokens
26+
.filter((token): token is keyof typeof mirrors => token in mirrors)
27+
.map((token) => mirrors[token])
28+
.filter((mirror) => !tokens.includes(mirror))
29+
return missing.length ? [`${id}: ${missing.join(", ")}`] : []
30+
})
31+
32+
expect(violations).toEqual([])
33+
})

‎packages/tui/test/keymap.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,34 @@ test("global commands stay reachable when the mode changes", async () => {
139139
app.renderer.destroy()
140140
}
141141
})
142+
143+
test("keypad CSI-u sequences reach commands bound to their kp names", async () => {
144+
const calls: string[] = []
145+
146+
function Harness() {
147+
Keymap.createLayer(() => ({
148+
commands: [
149+
{ id: "session.last", run: () => void calls.push("session.last") },
150+
{ id: "session.parent", run: () => void calls.push("session.parent") },
151+
],
152+
}))
153+
return <box />
154+
}
155+
156+
const app = await testRender(() => (
157+
<ConfigProvider config={createTuiResolvedConfig()}>
158+
<Keymap.Provider>
159+
<Harness />
160+
</Keymap.Provider>
161+
</ConfigProvider>
162+
))
163+
try {
164+
// Windows Terminal reports NumPad End/Home as kitty CSI-u sequences
165+
// (57424 = KP_End, 57423 = KP_Home) even outside full kitty mode.
166+
await app.mockInput.pressKeys(["\u001b[57424u", "\u001b[57423u"])
167+
await app.renderOnce()
168+
expect(calls).toEqual(["session.last"])
169+
} finally {
170+
app.renderer.destroy()
171+
}
172+
})

‎packages/tui/test/mini/runtime.boot.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ describe("run runtime boot", () => {
1717
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
1818
expect(result.keybinds.get("variant.cycle")?.[0]?.key).toBe("ctrl+t")
1919
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("escape")
20-
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up")
21-
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
20+
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("up,kpup")
21+
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down,kpdown")
2222
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
2323
expect(result.keybinds.get("app.clear")?.[0]?.key).toBe("ctrl+l")
2424
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")

0 commit comments

Comments
 (0)