This document provides rules and patterns for AI assistants to follow when helping users build terminal UIs with TermUI.
TermUI is a direct-mode Terminal UI framework for Elixir using The Elm Architecture. Applications are built as components with:
init/1- Initialize stateevent_to_msg/2- Convert terminal events to domain messagesupdate/2- Handle messages and return new state + commandsview/1- Render state to a render tree
Every TermUI component MUST use use TermUI.Elm and implement the required callbacks:
defmodule MyApp do
use TermUI.Elm
alias TermUI.Event
alias TermUI.Renderer.Style
def init(_opts) do
%{count: 0}
end
def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit}
def event_to_msg(_event, _state), do: :ignore
def update(:increment, state), do: {%{state | count: state.count + 1}, []}
def update(:decrement, state), do: {%{state | count: state.count - 1}, []}
def update(:quit, state), do: {state, [:quit]}
def view(state) do
stack(:vertical, [
text("Count: #{state.count}", Style.new(fg: :cyan)),
text("Press Up/Down to change, Q to quit")
])
end
end
# Run with:
TermUI.Runtime.run(root: MyApp)The event_to_msg/2 callback transforms raw events into application messages:
# Return {:msg, message} to send to update/2
def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :submit}
# Return :ignore to discard events
def event_to_msg(%Event.Key{key: :f1}, _state), do: :ignore
# Return :propagate to pass to parent component
def event_to_msg(%Event.Key{key: :escape}, _state), do: :propagate
# Always include a catch-all clause
def event_to_msg(_event, _state), do: :ignoreEvent Types:
# Keyboard - key can be atom (:enter, :escape, :tab, :up, :down, etc.) or string ("a", "1")
%Event.Key{key: :enter | "a", char: nil | "a", modifiers: [:ctrl, :alt, :shift]}
# Mouse - action is :click, :double_click, :press, :release, :drag, :move, :scroll_up, :scroll_down
%Event.Mouse{action: :click, button: :left | :middle | :right, x: 0, y: 0, modifiers: []}
# Focus changes
%Event.Focus{action: :gained | :lost}
# Terminal resize
%Event.Resize{width: 80, height: 24}
# Clipboard paste
%Event.Paste{content: "pasted text"}
# Timer tick
%Event.Tick{interval: 1000}Creating events for testing:
Event.key(:enter)
Event.key("a", modifiers: [:ctrl])
Event.mouse(:click, :left, 10, 5)
Event.focus(:gained)The update/2 function MUST be pure (no side effects) and return {new_state, commands}:
# Correct - return tuple with state and command list
def update(:save, state) do
{%{state | saved: true}, []}
end
def update(:quit, state) do
{state, [:quit]}
end
# Commands are for side effects
def update(:start_timer, state) do
{state, [Command.timer(1000, :timer_fired)]}
endAvailable Commands:
:quit- Exit the applicationCommand.timer(ms, message)- Send message after delayCommand.none()- No-op command
The view/1 function returns a render tree using these helpers (imported by use TermUI.Elm):
# Text nodes
text("Plain text")
text("Styled", Style.new(fg: :red, attrs: [:bold]))
# Vertical/horizontal stacking
stack(:vertical, [child1, child2, child3])
stack(:horizontal, [left, middle, right])
# Empty node for conditional rendering
empty()
# Box container
box([child1, child2])Always use TermUI.Renderer.Style for colors and attributes:
alias TermUI.Renderer.Style
# Create styles
style = Style.new(fg: :cyan, bg: :black, attrs: [:bold])
# Fluent API
style = Style.new()
|> Style.fg(:green)
|> Style.bg(:black)
|> Style.bold()
|> Style.underline()
# Merge styles (later overrides)
combined = Style.merge(base_style, override_style)Valid colors: :black, :red, :green, :yellow, :blue, :magenta, :cyan, :white, :bright_black, :bright_red, etc., integers 0-255, or {r, g, b} tuples.
Valid attributes: :bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough
TermUI has two types of widgets:
- Simple Widgets - Stateless, call
Widget.render(keyword_opts)directly in view - Stateful Widgets - Use the StatefulComponent pattern:
new/init/handle_event/render
Simple Widgets (render with keyword options):
Gauge- Progress barsSparkline- Inline trend graphsBarChart- Bar chartsLineChart- Line charts
Stateful Widgets (use StatefulComponent pattern):
Table- Data tablesMenu- Menus with submenusTextInput- Text input fieldsDialog- Modal dialogsAlertDialog- Alert dialogsFormBuilder- Forms with validationTreeView- Hierarchical treesTabs- Tabbed interfacesLogViewer- Log displayProcessMonitor- BEAM process viewerSupervisionTreeViewer- Supervision tree viewerClusterDashboard- Cluster dashboardCommandPalette- Command paletteSplitPane- Resizable split layoutsViewport- Scrollable viewportsToast- Toast notificationsContextMenu- Context menus
Simple widgets render directly with keyword options:
alias TermUI.Widgets.Gauge
alias TermUI.Widgets.Sparkline
# In view/1 - call render directly
def view(state) do
stack(:vertical, [
# Gauge - progress bar
Gauge.render(value: state.cpu_percent, width: 30, show_value: true),
# Sparkline - inline trend graph
Sparkline.render(values: state.cpu_history, style: Style.new(fg: :cyan))
])
endStateful widgets MUST follow the StatefulComponent pattern:
# 1. Widget.new(opts) - Create props map
# 2. Widget.init(props) - Initialize state, returns {:ok, state}
# 3. Widget.handle_event(event, state) - Handle events, returns {:ok, new_state}
# 4. Widget.render(state, area) - Render to nodesComplete Example with TextInput:
defmodule MyApp do
use TermUI.Elm
alias TermUI.Event
alias TermUI.Widgets.TextInput
# 1. Initialize widget in init/1
def init(_opts) do
props = TextInput.new(
placeholder: "Enter your name...",
width: 40
)
{:ok, input_state} = TextInput.init(props)
%{
input: TextInput.set_focused(input_state, true),
submitted_value: nil
}
end
# 2. Route events to widget in event_to_msg/2
def event_to_msg(%Event.Key{key: "q"}, state) do
# Only quit if input is empty
if TextInput.get_value(state.input) == "" do
{:msg, :quit}
else
{:msg, {:input_event, %Event.Key{key: "q", char: "q"}}}
end
end
def event_to_msg(%Event.Key{key: :enter}, state) do
{:msg, {:submit, TextInput.get_value(state.input)}}
end
def event_to_msg(event, _state) do
{:msg, {:input_event, event}}
end
# 3. Handle widget events in update/2
def update(:quit, state), do: {state, [:quit]}
def update({:submit, value}, state) do
{%{state | submitted_value: value}, []}
end
def update({:input_event, event}, state) do
{:ok, new_input} = TextInput.handle_event(event, state.input)
{%{state | input: new_input}, []}
end
# 4. Render widget in view/1
def view(state) do
stack(:vertical, [
text("Name:", Style.new(fg: :cyan)),
TextInput.render(state.input, %{width: 50, height: 1}),
text(""),
if state.submitted_value do
text("Hello, #{state.submitted_value}!", Style.new(fg: :green))
else
text("Press Enter to submit", Style.new(fg: :bright_black))
end
])
end
endStateful widgets provide helper functions to query and modify state:
TextInput helpers:
# Get current value
value = TextInput.get_value(input_state)
# Get cursor position {row, col}
{row, col} = TextInput.get_cursor(input_state)
# Get number of lines
count = TextInput.get_line_count(input_state)
# Set focus state
input_state = TextInput.set_focused(input_state, true)
# Clear the input
input_state = TextInput.clear(input_state)FormBuilder helpers:
# Get all form values as a map
values = FormBuilder.get_values(form_state)
# Check if form is valid
valid? = FormBuilder.valid?(form_state)Table helpers:
# Get selected rows
selected = Table.get_selected(table_state)
# Update data
table_state = Table.set_data(table_state, new_data)TextInput:
TextInput.new(
value: "", # Initial text
placeholder: "Enter text...", # Shown when empty
width: 40, # Character width
multiline: false, # Enable multi-line
max_visible_lines: 5, # Lines before scrolling
enter_submits: false # Enter submits vs newline
)Table:
alias TermUI.Widgets.Table
alias TermUI.Widgets.Table.Column
Table.new(
columns: [
Column.new(:name, "Name"),
Column.new(:age, "Age", width: 10, align: :right)
],
data: [
%{name: "Alice", age: 30},
%{name: "Bob", age: 25}
],
selection_mode: :single, # :none, :single, :multi
sortable: true
)Menu:
Menu.new(
items: [
Menu.action(:new, "New File", shortcut: "Ctrl+N"),
Menu.action(:open, "Open...", shortcut: "Ctrl+O"),
Menu.separator(),
Menu.submenu(:recent, "Recent Files", [
Menu.action(:file1, "doc.txt"),
Menu.action(:file2, "notes.md")
]),
Menu.checkbox(:autosave, "Auto Save", checked: true)
]
)Dialog:
Dialog.new(
title: "Confirm Delete",
content: text("Are you sure?"),
buttons: [
%{id: :cancel, label: "Cancel"},
%{id: :confirm, label: "Delete"}
],
width: 50
)FormBuilder:
FormBuilder.new(
fields: [
%{id: :username, type: :text, label: "Username", required: true},
%{id: :password, type: :password, label: "Password"},
%{id: :role, type: :select, label: "Role",
options: [{"admin", "Admin"}, {"user", "User"}]},
%{id: :active, type: :checkbox, label: "Active"},
%{id: :theme, type: :radio, label: "Theme",
options: [{"light", "Light"}, {"dark", "Dark"}]}
],
submit_label: "Register",
label_width: 15,
field_width: 30
)ProcessMonitor:
ProcessMonitor.new(
update_interval: 1000,
show_system_processes: false,
thresholds: %{
queue_warning: 1000,
memory_warning: 50_000_000
}
)Viewport:
Viewport.new(
content: my_render_tree, # The content to scroll (render node)
content_width: 200, # Total width of content
content_height: 100, # Total height of content
width: 60, # Viewport width
height: 20, # Viewport height
scroll_x: 0, # Initial horizontal scroll
scroll_y: 0, # Initial vertical scroll
scroll_bars: :both # :none, :vertical, :horizontal, or :both
)Viewport helper functions:
# Get current scroll position
{x, y} = Viewport.get_scroll(state)
# Set scroll position (clamped to valid range)
state = Viewport.set_scroll(state, 50, 100)
# Scroll to make a position visible
state = Viewport.scroll_into_view(state, target_x, target_y)
# Update content
state = Viewport.set_content(state, new_content)
# Update content dimensions
state = Viewport.set_content_size(state, new_width, new_height)
# Check if scrollable
Viewport.can_scroll_vertical?(state) # true/false
Viewport.can_scroll_horizontal?(state) # true/falsePrimary layout is via stack/2 and stack/3:
# Vertical layout (top to bottom)
stack(:vertical, [
header,
content,
footer
])
# Horizontal layout (left to right)
stack(:horizontal, [
sidebar,
main_content
])
# With constraints
alias TermUI.Layout.Constraint
stack(:horizontal, [
{sidebar, Constraint.length(25)}, # Fixed 25 chars
{content, Constraint.fill()} # Fill remaining
])
stack(:vertical, [
{header, Constraint.length(3)}, # Fixed 3 rows
{content, Constraint.percentage(70)}, # 70% of space
{footer, Constraint.length(1)} # Fixed 1 row
])alias TermUI.Layout.Constraint
Constraint.length(20) # Fixed size in cells
Constraint.percentage(50) # Percentage of parent
Constraint.fill() # Fill remaining space
Constraint.ratio(2) # Proportional (2 parts)
# With bounds
Constraint.percentage(50)
|> Constraint.with_min(10)
|> Constraint.with_max(100)def init(_opts) do
%{status: :loading, data: nil, error: nil}
end
def update(:load, state) do
# Start loading
{%{state | status: :loading}, [Command.timer(0, :fetch_data)]}
end
def update(:fetch_data, state) do
case do_fetch() do
{:ok, data} -> {%{state | status: :ready, data: data}, []}
{:error, e} -> {%{state | status: :error, error: e}, []}
end
end
def view(state) do
case state.status do
:loading -> text("Loading...", Style.new(fg: :yellow))
:error -> text("Error: #{state.error}", Style.new(fg: :red))
:ready -> render_data(state.data)
end
enddef init(_opts) do
%{items: [], show_dialog: false, dialog: nil}
end
def update({:request_delete, item}, state) do
props = Dialog.new(
title: "Confirm Delete",
content: text("Delete #{item.name}?"),
buttons: [
%{id: :cancel, label: "Cancel"},
%{id: :confirm, label: "Delete"}
]
)
{:ok, dialog_state} = Dialog.init(props)
{%{state | show_dialog: true, dialog: dialog_state, deleting: item}, []}
end
def event_to_msg(event, %{show_dialog: true} = state) do
{:msg, {:dialog_event, event}}
end
def update({:dialog_event, %Event.Key{key: :enter}}, state) do
# Check which button is focused and handle accordingly
if state.dialog.focused_button == 1 do # Confirm button
items = Enum.reject(state.items, &(&1.id == state.deleting.id))
{%{state | items: items, show_dialog: false, dialog: nil}, []}
else
{%{state | show_dialog: false, dialog: nil}, []}
end
end
def update({:dialog_event, event}, state) do
{:ok, dialog} = Dialog.handle_event(event, state.dialog)
{%{state | dialog: dialog}, []}
end
def view(state) do
base_view = render_items(state.items)
if state.show_dialog do
stack(:vertical, [
base_view,
Dialog.render(state.dialog, %{width: 80, height: 24})
])
else
base_view
end
enddef init(_opts) do
{:ok, input1} = TextInput.init(TextInput.new(placeholder: "Name"))
{:ok, input2} = TextInput.init(TextInput.new(placeholder: "Email"))
%{
focused: :input1,
input1: TextInput.set_focused(input1, true),
input2: input2
}
end
def event_to_msg(%Event.Key{key: :tab}, _state), do: {:msg, :next_focus}
def update(:next_focus, state) do
case state.focused do
:input1 ->
{%{state |
focused: :input2,
input1: TextInput.set_focused(state.input1, false),
input2: TextInput.set_focused(state.input2, true)
}, []}
:input2 ->
{%{state |
focused: :input1,
input1: TextInput.set_focused(state.input1, true),
input2: TextInput.set_focused(state.input2, false)
}, []}
end
end
def event_to_msg(event, state) do
{:msg, {:input_event, state.focused, event}}
end
def update({:input_event, :input1, event}, state) do
{:ok, input} = TextInput.handle_event(event, state.input1)
{%{state | input1: input}, []}
end
def update({:input_event, :input2, event}, state) do
{:ok, input} = TextInput.handle_event(event, state.input2)
{%{state | input2: input}, []}
end
def view(state) do
stack(:vertical, [
text("Name:"),
TextInput.render(state.input1, %{width: 40, height: 1}),
text("Email:"),
TextInput.render(state.input2, %{width: 40, height: 1})
])
endUse the Viewport widget when you have content larger than the display area:
defmodule ScrollableLogViewer do
use TermUI.Elm
alias TermUI.Event
alias TermUI.Renderer.Style
alias TermUI.Widgets.Viewport
def init(_opts) do
# Generate large content
content = generate_log_content()
props = Viewport.new(
content: content,
content_width: 120, # Content is 120 chars wide
content_height: 500, # Content is 500 lines
width: 80, # Viewport shows 80 chars
height: 20, # Viewport shows 20 lines
scroll_bars: :both
)
{:ok, viewport} = Viewport.init(props)
%{viewport: viewport}
end
# Route events to viewport
def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit}
def event_to_msg(event, _state), do: {:msg, {:viewport_event, event}}
def update(:quit, state), do: {state, [:quit]}
def update({:viewport_event, event}, state) do
{:ok, viewport} = Viewport.handle_event(event, state.viewport)
{%{state | viewport: viewport}, []}
end
def view(state) do
stack(:vertical, [
text("Log Viewer (Arrow keys to scroll, Q to quit)", Style.new(fg: :cyan)),
text(""),
Viewport.render(state.viewport, %{width: 80, height: 20}),
text(""),
render_scroll_info(state.viewport)
])
end
defp render_scroll_info(viewport) do
{x, y} = Viewport.get_scroll(viewport)
text("Scroll position: (#{x}, #{y})", Style.new(fg: :bright_black))
end
defp generate_log_content do
lines = for i <- 1..500 do
{:text, "[#{timestamp(i)}] Log entry ##{i}: Some log message here with details"}
end
stack(:vertical, lines)
end
defp timestamp(i), do: "2024-01-01 12:#{rem(i, 60) |> Integer.to_string() |> String.pad_leading(2, "0")}:00"
endViewport Keyboard Navigation (built-in):
- Arrow keys: Scroll by one line/column
- Page Up/Down: Scroll by viewport height
- Home/End: Scroll to top/bottom
- Ctrl+Home/End: Scroll to top-left/bottom-right
Viewport Mouse Support (built-in):
- Mouse wheel: Scroll vertically
- Click on scroll bar track: Page scroll
- Drag scroll bar thumb: Direct positioning
# Polling pattern
def init(_opts) do
%{data: nil}
end
def update(:start_polling, state) do
{state, [Command.timer(0, :poll)]}
end
def update(:poll, state) do
data = fetch_latest_data()
{%{state | data: data}, [Command.timer(5000, :poll)]}
end
# Animation pattern
def update(:start_animation, state) do
{%{state | frame: 0, animating: true}, [Command.timer(50, :animate)]}
end
def update(:animate, %{animating: true, frame: frame} = state) do
if frame >= 60 do
{%{state | animating: false}, []}
else
{%{state | frame: frame + 1}, [Command.timer(50, :animate)]}
end
enddefmodule MyAppTest do
use ExUnit.Case
alias TermUI.Event
test "init returns expected state" do
state = MyApp.init([])
assert state.count == 0
end
test "up arrow sends increment" do
event = Event.key(:up)
assert {:msg, :increment} = MyApp.event_to_msg(event, %{})
end
test "increment updates count" do
state = %{count: 5}
{new_state, []} = MyApp.update(:increment, state)
assert new_state.count == 6
end
test "quit returns quit command" do
{_state, commands} = MyApp.update(:quit, %{})
assert :quit in commands
end
end# BAD - side effect in update
def update(:save, state) do
File.write!("data.json", Jason.encode!(state.data)) # NO!
{state, []}
end
# GOOD - use command for side effects
def update(:save, state) do
{%{state | saving: true}, [Command.timer(0, :do_save)]}
end
def update(:do_save, state) do
File.write!("data.json", Jason.encode!(state.data))
{%{state | saving: false, saved: true}, []}
end# BAD - using stateful widget without init
def view(state) do
TextInput.render(%{value: "hello"}, %{width: 40, height: 1}) # NO!
end
# GOOD - properly initialize in init/1
def init(_opts) do
{:ok, input} = TextInput.init(TextInput.new(value: "hello"))
%{input: input}
end
def view(state) do
TextInput.render(state.input, %{width: 40, height: 1})
end# BAD - missing catch-all
def event_to_msg(%Event.Key{key: :enter}, _), do: {:msg, :submit}
# Crashes on any other event!
# GOOD - always include catch-all
def event_to_msg(%Event.Key{key: :enter}, _), do: {:msg, :submit}
def event_to_msg(_event, _state), do: :ignore# Start app
TermUI.Runtime.run(root: MyApp)
# Common imports
alias TermUI.Event
alias TermUI.Renderer.Style
alias TermUI.Widgets.{Gauge, Sparkline, Table, Menu, TextInput, Dialog, FormBuilder, Viewport}
# Layout
stack(:vertical, [child1, child2])
stack(:horizontal, [left, right])
text("content", Style.new(fg: :cyan))
empty()
# Styles
Style.new(fg: :red, bg: :black, attrs: [:bold])
Style.new() |> Style.fg(:green) |> Style.bold()
# Events
%Event.Key{key: :enter | "a", modifiers: [:ctrl]}
%Event.Mouse{action: :click, button: :left, x: 0, y: 0}
%Event.Focus{action: :gained | :lost}
# Commands
{state, [:quit]}
{state, [Command.timer(1000, :tick)]}
{state, []}
# Simple widgets (call in view)
Gauge.render(value: 75, width: 30)
Sparkline.render(values: [1, 2, 3, 4, 5])
# Stateful widgets (init in init/1, handle in update/2, render in view/1)
props = TextInput.new(placeholder: "Enter...")
{:ok, input_state} = TextInput.init(props)
{:ok, input_state} = TextInput.handle_event(event, input_state)
TextInput.render(input_state, %{width: 40, height: 1})
# Viewport for scrollable content
props = Viewport.new(content: my_content, content_width: 200, content_height: 100, width: 60, height: 20)
{:ok, viewport_state} = Viewport.init(props)
{:ok, viewport_state} = Viewport.handle_event(event, viewport_state)
Viewport.render(viewport_state, %{width: 60, height: 20})
{x, y} = Viewport.get_scroll(viewport_state)
viewport_state = Viewport.scroll_into_view(viewport_state, target_x, target_y)