vue-termui

repository·main·Indexed 21 days ago

https://github.com/vue-terminal/vue-termui

A framework for building terminal-based user interfaces using Vue 3, powered by the OpenTUI native engine. It provides a set of components including Box, Text, Input, Select, and ProgressBar, and supports rendering Three.js WebGPU scenes via @vue-termui/three. Requires Node.js 26.3+ with the --experimental-ffi flag or Bun.

Tokens
44K
Snippets
163
Records
204
Agent score
76%

What's inside vue-termui

  1. What is Vue TermUI?

    main

    Vue TermUI is a custom Vue 3 renderer that allows you to build Terminal User Interfaces (TUIs) using standard Vue patterns. Instead of rendering to the DOM, components render to the terminal using real flexbox layouts, styled text, and support for keyboard/mouse input and focus management.

    If you are familiar with Vue, you can use the Composition API, refs, computeds, slots, and directives like v-if, v-for, and v-model to build interactive terminal applications.

    <script setup lang="ts">
    import { Box, Text, useInterval, ref } from 'vue-termui'
    
    const count = ref(0)
    useInterval(() => count.value++, 1000)
    </script>
    
    <template>
      <Box border borderStyle="rounded" :padding="1">
        <Text bold fg="#42b883">Uptime: {{ count }}s</Text>
      </Box>
    </template>
  2. Render Three.js WebGPU scenes in vue-termui

    main

    Use @vue-termui/three to render Three.js WebGPU scenes directly inside a vue-termui terminal application. This package is a port of @opentui/three designed to run on Node.js (version 26.3.0 or higher) using the --experimental-ffi flag.

    <script setup lang="ts">
    import { shallowRef } from 'vue-termui'
    import { Three, onFrame } from '@vue-termui/three'
    import { Scene, PerspectiveCamera } from 'three'
    
    const scene = new Scene()
    const camera = new PerspectiveCamera(45, 1, 0.1, 100)
    // build your scene...
    </script>
    
    <template>
      <Three :scene="scene" :camera="camera" />
    </template>
  3. How the `<Box>` component works

    main

    The <Box> component is the fundamental layout building block in Vue TermUI, acting as the terminal equivalent of a <div>. It is a flexbox container, meaning layout, borders, padding, margins, and background are handled natively using props that mirror CSS flexbox properties. By default, boxes lay their children out in a row.

    <script setup lang="ts">
    import { Box, Text } from 'vue-termui'
    </script>
    
    <template>
      <Box flexDirection="column" :gap="1" border borderStyle="rounded" :padding="1">
        <Text>First row</Text>
        <Text>Second row</Text>
      </Box>
    </template>
  4. Avoid legacy terminal input collisions

    main

    In the legacy terminal input model, many key combinations are encoded using the same byte sequences, making them indistinguishable to your application. Avoid relying on these combinations for distinct actions:

    Collision TypeReason
    Ctrl+I vs TabBoth send 0x09
    Ctrl+M vs EnterBoth send 0x0D
    Ctrl+[ vs EscBoth send 0x1B
    Ctrl+H vs BackspaceBoth send 0x08 (on some terminals)
    Ctrl+letter vs Ctrl+Shift+letterShift is often dropped when Ctrl is held
    Esc vs Alt + keyAlt is encoded as an Esc prefix, requiring timing guesses to distinguish

    Note on Modifiers: The platform 'command' modifier (Cmd / Super / Win) has no encoding in the legacy model. Cmd+Enter will not reach your app via legacy input.

  5. When to use `<Newline>` vs `<Box>` gap

    main

    Choose between <Newline> and <Box> based on your layout needs:

    1. Use <Box> with gap for spacing between rows in a structured column layout. This is generally cleaner for layout management.
      <Box flexDirection="column" :gap="1">
        <Text>first</Text>
        <Text>second</Text>
      </Box>
    2. Use <Newline> when you need a break within a flow of text, such as separating paragraphs inside a single block or adding vertical space between inline <Text> runs.
  6. Implement scoped navigation within a component

    main

    If you need navigation that is restricted to a specific component (like a sidebar, menu, or tab bar) rather than the whole app, do not use useFocusManager. Instead, follow this pattern:

    1. Maintain an ordered list of the component's focusable children (using refs).
    2. Track the currently focused index.
    3. Use onKeyDown to intercept specific keys (like arrow keys) to increment or decrement the index.
    4. Call the .focus() method on the child element corresponding to the new index.
    <script setup lang="ts">
    import { Box, onKeyDown, onMounted, nextTick, ref } from 'vue-termui'
    import MenuItem from './MenuItem.vue'
    
    const items = ['New file', 'Open', 'Save', 'Quit']
    
    // Public instances of each MenuItem, collected via function refs.
    const links = ref<Array<{ focus: () => void; focused: boolean } | null>>([])
    
    function focusedIndex() {
      return links.value.findIndex((l) => l?.focused)
    }
    function focusAt(i: number) {
      const n = items.length
      links.value[((i % n) + n) % n]?.focus() // wraps around
    }
    
    onMounted(async () => {
      await nextTick() // wait for children to mount + register
      focusAt(0)
    })
    
    onKeyDown((key) => {
      const current = focusedIndex()
      if (current < 0) return // focus is elsewhere; ignore
      if (key.name === 'down') focusAt(current + 1)
      else if (key.name === 'up') focusAt(current - 1)
      else if (key.name === 'return') run(items[current])
    })
    
    function run(item: string) {
      /* ... */
    }
    </script>
    
    <template>
      <Box flexDirection="column">
        <MenuItem
          v-for="(label, i) in items"
          :key="label"
          :ref="(el) => (links[i] = el as any)"
          :label="label"
        />
      </Box>
    </template>
  7. Understand the TUI input stack

    main

    When designing keybindings for a TUI, you must account for the fact that an input event passes through several layers before reaching your application. Any layer can consume (swallow) the keypress before it reaches your handlers.

    The stack consists of:

    1. OS / Window Manager: Handles global shortcuts (e.g., fullscreen, window management).
    2. Terminal Emulator: Handles menu shortcuts, selection, and mouse protocols.
    3. Multiplexer (e.g., tmux/screen): Handles prefix keys, copy-mode, and specific keytables.
    4. Your App: Only receives the key events that survived the previous layers.

    Practical consequence: Do not build core functionality on key combinations that are likely to be claimed by the OS, terminal, or multiplexer (like Cmd modifiers on macOS or Ctrl+b in tmux).

  8. How Vue TermUI works

    main

    Vue TermUI functions as a Vue custom renderer built on top of OpenTUI, a high-performance native terminal renderer.

    Core Mechanics:

    • Component Mapping: Vue components map to OpenTUI renderables. For example, <Box> maps to a flexbox container and <Text> maps to styled text.
    • Native Heavy Lifting: OpenTUI manages layout (via real flexbox), drawing, the alternate screen buffer, and input handling.
    • Reactivity & Diffing: Because it is a genuine custom renderer rather than simple string concatenation, updates are diffed and patched just like in a web browser. Only the parts of the terminal that changed are redrawn, ensuring high performance.
    TIP

    This version is a ground-up rewrite on top of OpenTUI. If you are migrating from an earlier version that used TuiBox or onKeyData, note that the API has changed.

  9. Use absolute positioning for free-floating elements

    main

    To place elements at specific coordinates regardless of other components in the flow, use the position="absolute" prop on a Box.

    Note on precision: The renderer does not automatically round coordinates. For smooth motion, it is recommended to maintain position state as floating-point numbers (e.g., in a reactive object) and only use Math.round() when binding those values to the :left and :top props.

    ```vue\n<template>\n  <Box\n    position="absolute"\n    :left="Math.round(pos.x)"\n    :top="Math.round(pos.y)"\n    :width="boxWidth"\n    :height="boxHeight"\n  >\n    <!-- Content -->\n  </Box>\n</template>\n```
  10. Distinguish between highlighting and selecting in `<Select>`

    main

    It is important to understand the difference between the current highlight and a committed selection:

    1. Highlighting (v-model): Tracks which option the user is currently hovering over/navigating with arrow keys. This is useful for real-time UI updates (e.g., showing a preview of the highlighted item).
    2. Selecting (@select): Tracks when the user actually confirms their choice by pressing <kbd>Enter</kbd>. This is where you should trigger your primary application logic.
    <script setup lang="ts">
    import { Select, ref, computed } from 'vue-termui'
    import type { SelectOption } from 'vue-termui'
    
    const options: SelectOption[] = [
      { name: 'Small', value: 'sm' },
      { name: 'Medium', value: 'md' },
      { name: 'Large', value: 'lg' },
    ]
    
    const index = ref(0)
    const highlighted = computed(() => options[index.value])
    const chosen = ref<SelectOption | null>(null)
    </script>
    
    <template>
      <Select v-model="index" :options="options" focus @select="(o) => (chosen = o)" />
      <Text>Highlighted: {{ highlighted?.name }}</Text>
      <Text v-if="chosen">Chosen: {{ chosen.name }} ({{ chosen.value }})</Text>
    </template>
  11. Runtime requirements for vue-termui

    main

    Because vue-termui renders through the OpenTUI native engine, it requires Foreign Function Interface (FFI) support. You have two primary options for your runtime:

    1. Node.js: Use Node ≥ 26.3 and run your application with the --experimental-ffi flag. You can use --disable-warning=ExperimentalWarning to suppress experimental warnings.
    2. Bun: Use Bun, which supports FFI natively without additional flags.
  12. Manage application lifecycle and exiting

    main

    To prevent the terminal process from exiting immediately, use await app.waitUntilExit(). This promise resolves when the application terminates via Ctrl+C, a process signal (like SIGTERM), or a programmatic call to app.exit() or useExit().

    Exiting the application automatically restores the terminal (cursor, screen buffer, and raw mode) to ensure the user is left with a clean prompt.

    Exit Triggers:

    TriggerResult
    Ctrl+CRenderer tears down, terminal is restored
    app.exit()Programmatic exit (idempotent)
    useExit()Programmatic exit from inside a component
    Process signal (e.g. SIGTERM)Renderer tears down gracefully
    const app = await createApp(App)
    app.mount()
    
    // Wait for user to quit before running cleanup
    await app.waitUntilExit()
    console.log('Goodbye!')