#11681·tabby

Hotkeys fire twice per keypress: xterm customKeyEventHandler re-pushes keyup events into HotkeysService as keydowns

Author: MintSpearCandyCreated Sep 16, 2026Updated Sep 16, 2026

Summary

A single keypress of a hotkey chord can dispatch the hotkey twice. Most visible with split-bottom / split-right: one Ctrl+Shift+S press creates two panes. It affects any hotkey whose modifiers are still held when the final key is released. Intermittent for human typing (depends on key release order and event-loop timing), but reliably reproducible with synthesized input.

(I searched the tracker for existing reports — hotkey twice, split twice, duplicate hotkey, pushKeyEvent, customKeyEventHandler — and found nothing covering this, so filing.)

Environment

  • Tabby 1.0.235, Windows 11, local (PowerShell) terminal — reproduced
  • Verified the same code paths exist unchanged on current master (89c9447)

Reproduction

  1. Focus any terminal pane
  2. Press Ctrl+Shift+S (split-bottom), hold the final key ~100–150 ms, and release S before Ctrl/Shift
  3. Two panes are created from one press

Instrumented HotkeysService (prototype probes from a plugin) for a single press:

pushKeyEvent('keydown', type=keydown, key=S, ts=43834.70)   <- real keydown (xterm custom handler route)
pushKeyEvent('keydown', type=keydown, key=S, ts=43834.70)   <- same event again (document listener route)
Matched hotkey split-bottom                                  <- 1st split
Matched hotkey split-bottom                                  <- 2nd split, two panes created
pushKeyEvent('keyup',   type=keyup,   key=S, ts=43955.90)   <- real keyup
pushKeyEvent('keydown', type=keyup,   key=S, ts=43955.90)   <- GHOST: the keyup re-pushed as a keydown

Root cause

tabby-terminal/src/frontends/xtermFrontend.ts (master):

// line ~247
this.xterm.attachCustomKeyEventHandler((event: KeyboardEvent) => {
    ...
    const handled = keyboardEventHandler('keydown', event)   // hardcodes 'keydown', ignores event.type
    ...
})

// line ~355
const oldKeyUp = this.xtermCore._keyUp.bind(this.xtermCore)
this.xtermCore._keyUp = (e: KeyboardEvent) => {
    this.xtermCore.updateCursorStyle(e)
    if (keyboardEventHandler('keyup', e)) {   // pushes a correct 'keyup'
        oldKeyUp(e)                           // xterm's original _keyUp then invokes
    }                                         // customKeyEventHandler again -> the wrapper pushes
}                                             // the SAME keyup event labeled 'keydown'

xterm invokes customKeyEventHandler from both _keyDown and _keyUp, so every keyup of a chord gets re-pushed as a keydown.

In tabby-core/src/services/hotkeys.service.ts this slips past the dedup:

  • pushKeyEvent (line ~130) only drops pushes whose timeStamp === lastEventTimestamp — the ghost carries the keyup's fresh timestamp, so it passes
  • the keydown branch (lines ~169/201) unconditionally sets recognitionPhase = true and re-adds the key to pressedKeys; with the modifiers still held, pressedKeystroke reassembles the full chord (Ctrl+Shift+S)
  • matchActiveHotkey() matches again → emitHotkeyOn fires a second time

Why it's intermittent for humans: the ghost only re-matches when the final key is released before the modifiers (chord still complete at keyup), and the duplicate document-listener push is only deduped when event-loop ordering lets lastEventTimestamp catch it (the zone.run emission defers, which widens the race).

Suggested fix

Two independent layers, either helps, both cheap:

  1. Label events truthfully in the wrapper:
const handled = keyboardEventHandler(event.type, event)
  1. Harden HotkeysService.pushKeyEvent: ignore a 'keydown' push whose nativeEvent.type === 'keyup' — a genuine keydown can never carry type: 'keyup'. As defense in depth, emitHotkeyOn could also suppress the same hotkey id emitted twice within a few ms (intentional re-presses and key auto-repeat are both far outside that window).