Skip to content

Handling Input

Interactive terminal apps live and die by their input handling. Vue TermUI gives you composables for global keyboard events, and forwards per-element mouse events through <Box>.

Keyboard

onKeyDown registers a handler that runs on every key press while the calling component is mounted. The listener is cleaned up automatically when the component unmounts:

vue
<script setup lang="ts">
import { onKeyDown, useExit, ref } from 'vue-termui'

const exit = useExit()
const last = ref('')

onKeyDown((key) => {
  last.value = key.name
  if (key.name === 'q' || (key.ctrl && key.name === 'c')) exit()
})
</script>

<template>
  <Text>Last key: {{ last }}</Text>
</template>

The KeyEvent

Each handler receives a parsed KeyEvent:

FieldTypeDescription
namestringNormalized key name: 'a', 'return', 'escape', 'up'
ctrlbooleanCtrl held
metabooleanMeta (Command/Windows) held
shiftbooleanShift held
optionbooleanAlt/Option held
sequencestringThe raw escape sequence that produced the event
eventTypestring'press', 'release' or 'repeat'
preventDefault()fnMark handled so OpenTUI stops propagating it
stopPropagation()fnStop the event reaching further handlers

Matching keys

Check key.name (and the modifier booleans) to branch:

ts
onKeyDown((key) => {
  if (key.name === 'up') moveUp()
  else if (key.name === 'down') moveDown()
  else if (key.ctrl && key.name === 's') save()
  else if (key.name === 'return') submit()
})

Common names include return, escape, tab, space, backspace, delete, the arrows up / down / left / right, and single characters like a, 1, ?.

Key releases

onKeyUp fires on key release. Releases are only reported through the Kitty keyboard protocol's event types enhancement, which is off by default — opt in with the events flag in the renderer config:

ts
// main.ts
const app = await createApp(App, null, {
  useKittyKeyboard: { events: true },
})
ts
import { onKeyUp } from 'vue-termui'
onKeyUp((key) => console.log('released', key.name))

Even with the flag on, only terminals that support the protocol report releases — everywhere else onKeyUp never fires. See Keybinding Best Practices for the wider protocol picture and why releases should stay optional in your UX.

Removing a listener early

Both composables return a function that removes the listener before unmount, if you need it:

ts
const stop = onKeyDown((key) => {
  /* ... */
})
// later
stop()

Mouse

There is no global mouse stream. Instead, mouse events are delivered per element — attach a handler to a <Box> (or any component) and OpenTUI routes the event to it natively:

vue
<script setup lang="ts">
import { Box, Text, ref } from 'vue-termui'
const clicks = ref(0)
</script>

<template>
  <Box border :padding="1" @mouseDown="clicks++">
    <Text>Clicked {{ clicks }} times</Text>
  </Box>
</template>

Use camelCase event names

OpenTUI listeners are camelCase — @mouseDown, @keyDown. The all-lowercase @mousedown / @keydown compile to onMousedown / onKeydown, which don't match. Stick to camelCase (or bind the property directly with :onMouseDown="handler").

Available handlers include @mouseDown, @mouseUp, @mouseMove, @mouseOver, @mouseOut, @mouseDrag, @mouseDrop and @mouseScroll. Because the handler is on the element, you know exactly which box was interacted with — no hit-testing of global coordinates required.

Event modifiers

@keyDown and the @mouse* events on components support Vue-style modifiers, so you can filter and manage events declaratively instead of branching inside the handler. These are Vue's own modifiers — vue-termui just makes terminal key/mouse events carry the fields Vue looks at, so no extra setup is needed.

vue
<template>
  <!-- fires only for Ctrl+C -->
  <Box @keyDown.ctrl.c="quit" />

  <!-- key-name modifiers; OpenTUI's names are accepted, plus friendly aliases -->
  <Box @keyDown.enter="submit" @keyDown.esc="cancel" />

  <!-- mouse button + event flow -->
  <Box @mouseDown.right="openMenu" @mouseDown.stop.prevent="select" />
</template>

Element key events need focus

Unlike the global onKeyDown composable, @keyDown on an element only fires while that element is focused — see Focus Management.

The supported modifiers:

KindModifiersEffect
System chords.ctrl, .shift, .alt (.option), .meta (.cmd)Fire only while the chord is held
Exact chord.exactFire only when exactly the listed system modifiers are held
Key names.enter, .esc, .space, .tab, .up/.down/.left/.right, .a, .1, …Fire only when key.name matches (Enter is return, Esc is escape)
Mouse buttons.left, .middle, .rightFire only for that button
Event flow.stop, .preventCall stopPropagation() / preventDefault() once the guard passes

Chain them freely (@keyDown.ctrl.shift.k, @mouseDown.stop.left). Multiple key names on one binding match any of them (@keyDown.enter.esc). The DOM-only .self, .capture, .passive and .once modifiers have no terminal equivalent and aren't supported.

Tips

  • Put global shortcuts (quit, help, navigation) in a top-level component so they're always active.
  • Use Focus Management to route keys to the right widget — e.g. an <Input> should receive typing only while it's focused.
  • Call key.preventDefault() when you've handled a key and don't want it to bubble (for example, to stop a focused widget from also reacting).

Released under the MIT License.