ember-tui
v2.0.1
Published
Ember Terminal UI library
Readme
Ember TUI
Ember.js Terminal UI Library. Build and test your CLI tool using ember.js.
Ember TUI provides the same component-based UI building experience that Ember.js offers in the browser, but for command-line apps. It uses Yoga to build Flexbox layouts in the terminal, so most CSS-like properties are available in Ember TUI as well. If you are already familiar with Ember.js, you already know Ember TUI.
Since Ember TUI is built on Ember.js, all features of Ember.js are supported. Head over to the Ember.js website for documentation on how to use it. Only Ember TUI's methods are documented in this readme.
start developing
to create a new terminal app just run
npx ember-tui create-app <my-cli-app> --pnpm
pnpm prebuild
pnpm startthat will create the app with emberjs blueprint and adjust some files for ember-tui
Contents
Getting Started
Ember TUI uses Yoga, a Flexbox layout engine, to build great user interfaces for your CLIs using familiar CSS-like properties you've used when building apps for the browser.
It's important to remember that each element is a Flexbox container.
Think of it as if every <div> in the browser had display: flex.
See <Box> built-in component below for documentation on how to use Flexbox layouts in Ember TUI.
Note that all text must be wrapped in a <Text> component.
Components
<Text>
This component can display text and change its style to make it bold, underlined, italic, or strikethrough.
import { render, Text } from 'ember-tui';
const template = <template>
<Text @color="green">I am green</Text>
<Text @color="black" @backgroundColor="white">I am black on white</Text>
<Text @color="#ffffff">I am white</Text>
<Text @bold={{true}}>I am bold</Text>
<Text @italic={{true}}>I am italic</Text>
<Text @underline={{true}}>I am underline</Text>
<Text @strikethrough={{true}}>I am strikethrough</Text>
<Text @inverse={{true}}>I am inversed</Text>
</template>;
render(template);Note: <Text> allows only text nodes and nested <Text> components inside of it. For example, <Box> component can't be used inside <Text>.
color
Type: string
Change text color. Ember TUI uses chalk under the hood, so all its functionality is supported.
<Text @color="green">Green</Text>
<Text @color="#005cc5">Blue</Text>
<Text @color="rgb(232, 131, 136)">Red</Text>backgroundColor
Type: string
Same as color above, but for background.
<Text @backgroundColor="green" @color="white">Green</Text>
<Text @backgroundColor="#005cc5" @color="white">Blue</Text>
<Text @backgroundColor="rgb(232, 131, 136)" @color="white">Red</Text>dimColor
Type: boolean
Default: false
Dim the color (make it less bright).
<Text @color="red" @dimColor={{true}}>Dimmed Red</Text>bold
Type: boolean
Default: false
Make the text bold.
italic
Type: boolean
Default: false
Make the text italic.
underline
Type: boolean
Default: false
Make the text underlined.
strikethrough
Type: boolean
Default: false
Make the text crossed with a line.
inverse
Type: boolean
Default: false
Invert background and foreground colors.
<Text @inverse={{true}} @color="yellow">Inversed Yellow</Text>wrap
Type: string
Allowed values: wrap truncate truncate-start truncate-middle truncate-end
Default: wrap
This property tells Ember TUI to wrap or truncate text if its width is larger than the container.
If wrap is passed (the default), Ember TUI will wrap text and split it into multiple lines.
If truncate-* is passed, Ember TUI will truncate text instead, resulting in one line of text with the rest cut off.
<Box @width={{7}}>
<Text>Hello World</Text>
</Box>
//=> 'Hello\nWorld'
// `truncate` is an alias to `truncate-end`
<Box @width={{7}}>
<Text @wrap="truncate">Hello World</Text>
</Box>
//=> 'Hello…'
<Box @width={{7}}>
<Text @wrap="truncate-middle">Hello World</Text>
</Box>
//=> 'He…ld'
<Box @width={{7}}>
<Text @wrap="truncate-start">Hello World</Text>
</Box>
//=> '…World'<Box>
<Box> is an essential Ember TUI component to build your layout.
It's like <div style="display: flex"> in the browser.
import { render, Box, Text } from 'ember-tui';
const template = <template>
<Box @margin={{2}}>
<Text>This is a box with margin</Text>
</Box>
</template>;
render(template);Dimensions
width
Type: number string
Width of the element in spaces. You can also set it as a percentage, which will calculate the width based on the width of the parent element.
<Box @width={{4}}>
<Text>X</Text>
</Box>
//=> 'X '<Box @width={{10}}>
<Box @width="50%">
<Text>X</Text>
</Box>
<Text>Y</Text>
</Box>
//=> 'X Y'height
Type: number string
Height of the element in lines (rows). You can also set it as a percentage, which will calculate the height based on the height of the parent element.
<Box @height={{4}}>
<Text>X</Text>
</Box>
//=> 'X\n\n\n'minWidth
Type: number
Sets a minimum width of the element.
minHeight
Type: number
Sets a minimum height of the element.
Padding
paddingTop
Type: number
Default: 0
Top padding.
paddingBottom
Type: number
Default: 0
Bottom padding.
paddingLeft
Type: number
Default: 0
Left padding.
paddingRight
Type: number
Default: 0
Right padding.
paddingX
Type: number
Default: 0
Horizontal padding. Equivalent to setting paddingLeft and paddingRight.
paddingY
Type: number
Default: 0
Vertical padding. Equivalent to setting paddingTop and paddingBottom.
padding
Type: number
Default: 0
Padding on all sides. Equivalent to setting paddingTop, paddingBottom, paddingLeft and paddingRight.
<Box @paddingTop={{2}}><Text>Top</Text></Box>
<Box paddingBottom={{2}}><Text>Bottom</Text></Box>
<Box @paddingLeft={{2}}><Text>Left</Text></Box>
<Box paddingRight={{2}}><Text>Right</Text></Box>
<Box paddingX={{2}}><Text>Left and right</Text></Box>
<Box paddingY={{2}}><Text>Top and bottom</Text></Box>
<Box padding={{2}}><Text>Top, bottom, left and right</Text></Box>Margin
marginTop
Type: number
Default: 0
Top margin.
marginBottom
Type: number
Default: 0
Bottom margin.
marginLeft
Type: number
Default: 0
Left margin.
marginRight
Type: number
Default: 0
Right margin.
marginX
Type: number
Default: 0
Horizontal margin. Equivalent to setting marginLeft and marginRight.
marginY
Type: number
Default: 0
Vertical margin. Equivalent to setting marginTop and marginBottom.
margin
Type: number
Default: 0
Margin on all sides. Equivalent to setting marginTop, marginBottom, marginLeft and marginRight.
<Box @marginTop={{2}}><Text>Top</Text></Box>
<Box marginBottom={{2}}><Text>Bottom</Text></Box>
<Box marginLeft={{2}}><Text>Left</Text></Box>
<Box @marginRight={{2}}><Text>Right</Text></Box>
<Box marginX={{2}}><Text>Left and right</Text></Box>
<Box marginY={{2}}><Text>Top and bottom</Text></Box>
<Box margin={{2}}><Text>Top, bottom, left and right</Text></Box>Gap
gap
Type: number
Default: 0
Size of the gap between an element's columns and rows. A shorthand for columnGap and rowGap.
<Box @gap={{1}} @width={{3}} @flexWrap="wrap">
<Text>A</Text>
<Text>B</Text>
<Text>C</Text>
</Box>
// A B
//
// CcolumnGap
Type: number
Default: 0
Size of the gap between an element's columns.
<Box @columnGap={{1}}>
<Text>A</Text>
<Text>B</Text>
</Box>
// A BrowGap
Type: number
Default: 0
Size of the gap between an element's rows.
<Box @flexDirection="column" @rowGap={{1}}>
<Text>A</Text>
<Text>B</Text>
</Box>
// A
//
// BFlex
flexGrow
Type: number
Default: 0
See flex-grow.
<Box>
<Text>Label:</Text>
<Box @flexGrow={{1}}>
<Text>Fills all remaining space</Text>
</Box>
</Box>flexShrink
Type: number
Default: 1
See flex-shrink.
<Box @width={{20}}>
<Box @flexShrink={{2}} @width={{10}}>
<Text>Will be 1/4</Text>
</Box>
<Box @width={{10}}>
<Text>Will be 3/4</Text>
</Box>
</Box>flexBasis
Type: number string
See flex-basis.
<Box @width={{6}}>
<Box @flexBasis={{3}}>
<Text>X</Text>
</Box>
<Text>Y</Text>
</Box>
//=> 'X Y'flexDirection
Type: string
Allowed values: row row-reverse column column-reverse
See flex-direction.
<Box>
<Box @marginRight={{1}}>
<Text>X</Text>
</Box>
<Text>Y</Text>
</Box>
// X Y
<Box @flexDirection="row-reverse">
<Text>X</Text>
<Box @marginRight={{1}}>
<Text>Y</Text>
</Box>
</Box>
// Y X
<Box @flexDirection="column">
<Text>X</Text>
<Text>Y</Text>
</Box>
// X
// YflexWrap
Type: string
Allowed values: nowrap wrap wrap-reverse
See flex-wrap.
<Box @width={{2}} @flexWrap="wrap">
<Text>A</Text>
<Text>BC</Text>
</Box>
// A
// B CalignItems
Type: string
Allowed values: flex-start center flex-end
See align-items.
<Box @alignItems="flex-start">
<Box @marginRight={{1}}>
<Text>X</Text>
</Box>
<Text>A<Newline/>B<Newline/>C</Text>
</Box>
// X A
// B
// CalignSelf
Type: string
Default: auto
Allowed values: auto flex-start center flex-end
See align-self.
<Box @height={{3}}>
<Box @alignSelf="flex-start">
<Text>X</Text>
</Box>
</Box>
// X
//
//justifyContent
Type: string
Allowed values: flex-start center flex-end space-between space-around space-evenly
See justify-content.
<Box @justifyContent="flex-start">
<Text>X</Text>
</Box>
// [X ]
<Box @justifyContent="center">
<Text>X</Text>
</Box>
// [ X ]
<Box @justifyContent="flex-end">
<Text>X</Text>
</Box>
// [ X]Visibility
display
Type: string
Allowed values: flex none
Default: flex
Set this property to none to hide the element.
overflowX
Type: string
Allowed values: visible hidden
Default: visible
Behavior for an element's overflow in the horizontal direction.
overflowY
Type: string
Allowed values: visible hidden
Default: visible
Behavior for an element's overflow in the vertical direction.
overflow
Type: string
Allowed values: visible hidden
Default: visible
A shortcut for setting overflowX and overflowY at the same time.
Borders
borderStyle
Type: string
Allowed values: single double round bold singleDouble doubleSingle classic
Add a border with a specified style.
If borderStyle is undefined (the default), no border will be added.
Ember TUI uses border styles from the cli-boxes module.
<Box @flexDirection="column">
<Box>
<Box @borderStyle="single" @marginRight={{2}}>
<Text>single</Text>
</Box>
<Box @borderStyle="double" @marginRight={{2}}>
<Text>double</Text>
</Box>
<Box @borderStyle="round" @marginRight={{2}}>
<Text>round</Text>
</Box>
<Box @borderStyle="bold">
<Text>bold</Text>
</Box>
</Box>
</Box>borderColor
Type: string
Change border color.
A shorthand for setting borderTopColor, borderRightColor, borderBottomColor, and borderLeftColor.
<Box @borderStyle="round" @borderColor="green">
<Text>Green Rounded Box</Text>
</Box>borderDimColor
Type: boolean
Default: false
Dim the border color.
A shorthand for setting borderTopDimColor, borderBottomDimColor, borderLeftDimColor, and borderRightDimColor.
<Box @borderStyle="round" @borderDimColor={{true}}>
<Text>Hello world</Text>
</Box>borderTop / borderRight / borderBottom / borderLeft
Type: boolean
Default: true
Determines whether the respective border is visible.
Background
backgroundColor
Type: string
Background color for the element.
Accepts the same values as color in the <Text> component.
<Box @flexDirection="column">
<Box @backgroundColor="red" @width={{20}} @height={{5}} @alignSelf="flex-start">
<Text>Red background</Text>
</Box>
<Box @backgroundColor="#FF8800" @width={{20}} @height={{3}} @marginTop={{1}} @alignSelf="flex-start">
<Text>Orange background</Text>
</Box>
</Box>The background color fills the entire <Box> area and is inherited by child <Text> components unless they specify their own backgroundColor.
overlay
Type: boolean
Default: false
When true, the box's background color is drawn on top of existing content without erasing the characters underneath.
Only the background color of each cell is replaced; the character and its foreground styles are preserved.
This is primarily useful for absolutely-positioned boxes (see position: absolute via Yoga) that need to tint a region of the screen without obscuring the text already rendered there.
<Box @position="absolute" @backgroundColor="blue" @overlay={{true}} @width={{10}} @height={{3}}>
</Box>Note:
overlayonly has a visible effect when abackgroundColoris also set. Without a background color there is nothing to overlay.
<Newline>
Adds one or more newline (\n) characters.
Must be used within <Text> components.
count
Type: number
Default: 1
Number of newlines to insert.
import { render, Text, Newline } from 'ember-tui';
const template = <template>
<Text>
<Text @color="green">Hello</Text>
<Newline />
<Text @color="red">World</Text>
</Text>
</template>;
render(template);Output:
Hello
World<Spacer>
A flexible space that expands along the major axis of its containing layout. It's useful as a shortcut for filling all the available space between elements.
For example, using <Spacer> in a <Box> with default flex direction (row) will position "Left" on the left side and will push "Right" to the right side.
import { render, Box, Text, Spacer } from 'ember-tui';
const template = <template>
<Box>
<Text>Left</Text>
<Spacer />
<Text>Right</Text>
</Box>
</template>;
render(template);<Transform>
Transform a string representation of components before they're written to output. For example, you might want to apply a gradient to text or create some text effects.
Note: <Transform> must be applied only to <Text> children components and shouldn't change the dimensions of the output; otherwise, the layout will be incorrect.
import { render, Transform, Text } from 'ember-tui';
const template = <template>
<Transform @transform={{fn (output) => output.toUpperCase()}}>
<Text>Hello World</Text>
</Transform>
</template>;
render(template);Since the transform function converts all characters to uppercase, the final output rendered to the terminal will be "HELLO WORLD", not "Hello World".
transform(outputLine, index)
Type: Function
Function that transforms children output. It accepts children and must return transformed children as well.
children
Type: string
Output of child components.
index
Type: number
The zero-indexed line number of the line that's currently being transformed.
Mouse Events
Ember TUI supports terminal mouse events, including clicks, hover, scroll, and drag.
Mouse events are attached to <Box> components using Ember's standard {{on}} modifier — no special API is required.
Enabling mouse tracking
Call enableMouseTracking once at startup (before rendering) to tell the terminal to start reporting mouse events.
Call disableMouseTracking when the app exits to restore the terminal to its default state.
import { render, enableMouseTracking, disableMouseTracking } from 'ember-tui';
enableMouseTracking(process.stdout);
// ... render your app ...
process.on('exit', () => disableMouseTracking(process.stdout));Supported event types
| Event type | Fired when… |
|---------------|-----------------------------------------------------------|
| mousedown | A mouse button is pressed |
| mouseup | A mouse button is released |
| click | A button is pressed and released on the same element |
| mousemove | The cursor moves (with or without a button held) |
| wheel | The scroll wheel is turned |
| mouseenter | The cursor moves into the element's bounding box |
| mouseleave | The cursor moves out of the element's bounding box |
Attaching listeners
Use Ember's {{on}} modifier on any <Box> component:
import { on } from '@ember/modifier';
import { Box, Text } from 'ember-tui';
<template>
<Box
{{on "mouseenter" this.handleEnter}}
{{on "mouseleave" this.handleLeave}}
{{on "click" this.handleClick}}
@borderStyle="single"
>
<Text>Click me!</Text>
</Box>
</template>TerminalMouseEvent
Every listener receives a TerminalMouseEvent object with the following properties:
| Property | Type | Description |
|--------------------|-----------|-----------------------------------------------------------------------------|
| type | string | Event type (mousedown, mouseup, click, mousemove, wheel, etc.) |
| x | number | 1-based terminal column of the cursor |
| y | number | 1-based terminal row of the cursor |
| button | number | Button index: 0 = left, 1 = middle, 2 = right, -1 = none |
| buttons | number | Bitmask of currently pressed buttons (browser MouseEvent convention) |
| deltaY | number | Wheel delta: -1 = scroll up, 1 = scroll down, 0 = not a wheel event |
| ctrlKey | boolean | true when Ctrl was held |
| altKey | boolean | true when Alt / Meta was held |
| shiftKey | boolean | true when Shift was held |
| rawInput | string | Raw terminal escape sequence (useful for debugging) |
| preventDefault() | function| No-op stub for API compatibility |
| stopPropagation()| function| No-op stub for API compatibility |
Example — interactive hover box
The following component changes its background colour and border when the cursor hovers over it:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { Box, Text } from 'ember-tui';
export default class HoverBox extends Component {
@tracked isHovered = false;
onMouseEnter = () => { this.isHovered = true; };
onMouseLeave = () => { this.isHovered = false; };
<template>
<Box
{{on "mousemove" this.onMouseEnter}}
{{on "mouseleave" this.onMouseLeave}}
@backgroundColor={{if this.isHovered "blue" "gray"}}
@borderStyle="single"
@borderColor={{if this.isHovered "white" "gray"}}
@width={{24}}
@height={{3}}
@alignItems="center"
@justifyContent="center"
>
<Text @color="white" @bold={{this.isHovered}}>
{{if this.isHovered "▶ Hover!" " Hover!"}}
</Text>
</Box>
</template>
}Keyboard Events
Ember TUI surfaces terminal keyboard input as browser-like keydown events.
Use Ember's standard {{on}} modifier on any <Box> to start receiving keystrokes — no special API is required.
Note: Unlike mouse events,
keydownis broadcast to every registered listener regardless of which element has visual focus. There is no hit-testing; all<Box>elements that registered akeydownlistener will receive every keystroke.
Enabling keyboard input
Raw-mode is enabled automatically by render() when stdin is a TTY, so no extra setup is needed.
Attaching a listener
import { on } from '@ember/modifier';
import { Box, Text } from 'ember-tui';
<template>
<Box {{on "keydown" this.handleKey}}>
<Text>Press any key…</Text>
</Box>
</template>TerminalKeyEvent
Every listener receives a TerminalKeyEvent object with the following properties:
| Property | Type | Description |
|--------------------|-----------|-----------------------------------------------------------------------------------------------|
| type | string | Always "keydown" |
| key | string | Logical key value: 'a', 'A', 'ArrowUp', 'Enter', etc. |
| code | string | Mapped key name / code (same as key for most keys) |
| keyCode | number | Numeric char-code of the first byte |
| ctrlKey | boolean | true when a Ctrl+key combination was pressed |
| altKey | boolean | true when an Alt / Meta combination was pressed |
| shiftKey | boolean | true when Shift was held (uppercase letters and symbols) |
| ambiguous | boolean | true when the sequence maps to two keys, e.g. \t → Tab or Ctrl+I |
| rawInput | string | Raw terminal escape sequence (useful for debugging) |
| preventDefault() | function| No-op stub for API compatibility |
| stopPropagation()| function| No-op stub for API compatibility |
Recognized key names
The following special keys are mapped to their standard names:
| Terminal sequence | key / code |
|-------------------|-----------------|
| Arrow keys | ArrowUp ArrowDown ArrowLeft ArrowRight |
| \r / \n | Enter |
| \t | Tab |
| \x1b[Z | Tab (Shift+Tab, shiftKey: true) |
| \x7f / \b | Backspace |
| \x1b | Escape |
| | Space |
| \x1b[H | Home |
| \x1b[F | End |
| \x1b[5~ | PageUp |
| \x1b[6~ | PageDown |
| \x1b[3~ | Delete |
| \x1b[2~ | Insert |
| Ctrl+<letter> | raw control char; ctrlKey: true, code = base letter |
| Alt+<key> | altKey: true, key = the character after ESC |
Example — key logger
The following component displays the last key pressed and a running log of recent keystrokes:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { Box, Text } from 'ember-tui';
export default class KeyLogger extends Component {
@tracked lastKey = '—';
@tracked log: string[] = [];
handleKey = (event: any) => {
this.lastKey = event.key;
this.log = [`${event.key}${event.ctrlKey ? ' (Ctrl)' : ''}${event.altKey ? ' (Alt)' : ''}`, ...this.log].slice(0, 8);
};
<template>
<Box
{{on "keydown" this.handleKey}}
@flexDirection="column"
@borderStyle="round"
@borderColor="cyan"
@padding={{1}}
@gap={{1}}
>
<Text @bold={{true}} @color="cyan">Last key: <Text @color="white">{{this.lastKey}}</Text></Text>
{{#each this.log as |entry|}}
<Text @color="gray">{{entry}}</Text>
{{/each}}
</Box>
</template>
}API
render(tree, options?)
Mount a component and render the output.
tree
Type: Component
The root component to render.
options
Type: object
stdout
Type: stream.Writable
Default: process.stdout
Output stream where the app will be rendered.
stdin
Type: stream.Readable
Default: process.stdin
Input stream where the app will listen for input.
stderr
Type: stream.Writable
Default: process.stderr
Error stream.
startRender(document, options?)
Start the render loop for a document.
document
Type: DocumentNode
The document node to render.
options
Type: object
enableMouse
Type: boolean
Default: false
When true, enables terminal mouse tracking by calling enableMouseTracking automatically at startup.
Mouse tracking is also disabled automatically on process exit (exit, SIGINT, SIGTERM).
noRedrawOnBackBufferWrite
Type: boolean
Default: false
When true, the renderer will not trigger a full redraw when writes to the back buffer are detected.
Useful in environments where background writes (e.g. subprocesses printing to stdout) would otherwise cause unnecessary screen flicker.
Dirty Tracking
Ember TUI uses an internal dirty-tracking system to minimise the amount of work done on every render cycle. Instead of re-rendering the entire component tree on every tick, only the nodes that have actually changed are re-processed.
How it works
Every ElementNode in the virtual DOM carries two flags:
| Flag | Meaning |
|------------------|------------------------------------------------------------------------------|
| _isDirty | This node's own attributes or content changed and it must be re-rendered. |
| _childrenDirty | At least one descendant is dirty; the node itself may still be clean. |
When a node's attributes change (e.g. a new @color or @width is passed to a <Box> or <Text>), markDirty() is called automatically.
The flag propagates upward through the entire ancestor chain so that no parent is skipped during traversal.
During rendering, the engine uses a skipClean pass: if a node is neither dirty itself nor has any dirty descendants (and is not covered by a dirty absolute box), its subtree is skipped entirely.
Once a node has been rendered, both flags are cleared via clearDirty().
Absolute-positioned box overlap
Boxes with position="absolute" can visually cover other nodes.
The renderer tracks these overlap relationships:
- When an absolute box moves or changes, every node it overlaps is automatically marked dirty so the underlying content is repainted correctly.
- When an absolute box is removed or its
positionchanges,clearOverlapTracking()is called, which marks the previously-covered nodes dirty (usingmarkSubtreeDirty()) so stale ANSI codes are cleared from the output buffer.
This means you do not need to do anything special when animating or toggling absolutely-positioned overlays — the dirty system handles repainting the content underneath.
Practical impact
- Reduced CPU usage — static parts of the UI (unchanged text, stable boxes) are not reprocessed every frame.
- Correct overlay clearing — content behind a removed or moved absolute box is always repainted.
- Transparent to the developer — dirtiness is set automatically by
setAttributeand child-insertion hooks; there is no public API to call.
Examples
Check out the examples directory for more examples:
Colors Demo - Demonstrates text colors and styles
Box Layout Demo - Shows flexbox layout capabilities
Lorem Ipsum - Text wrapping and layout
Hover Demo - Interactive mouse hover effects
License
MIT
