Confidently Build
Terminal Apps
Rooibos helps you build interactive terminal applications. Keep your code understandable and testable as it scales. Rooibos lets you focus on behavior and user experience.
$ gem install rooibos
Currently in betaAPIs may change before 1.0
Get Started
Three commands to a working app. The CLI generates a starter project with tests, type signatures, and more. Keep it for yourself, or ship it as a RubyGem.
In Seconds
$ rooibos my_app
$ cd my_app
$ rooibos
That's it. Open lib/my_app.rb to make it your own.
The Pattern
Rooibos uses Model-View-Update, the architecture behind Elm, Redux, and Bubble Tea.
State lives in one place. Updates flow in one direction. The runtime handles rendering and runs background work for you.
Hello, MVU
The simplest Rooibos app. Press any key to increment the counter. Press Ctrl+C to quit.
require "rooibos"
module Counter
# Init: How do you create the initial model?
Init = -> { 0 }
# View: What does the user see?
View = -> (model, tui) { tui.(text: <<~END) }
Current count: #{model}.
Press any key to increment.
Press Ctrl+C to quit.
END
# Update: What happens when things change?
Update = -> (message, model) {
if message.
Rooibos::Command.
elsif message.
model + 1
end
}
end
Rooibos.(Counter)
That's the whole pattern: Model holds state, Init creates it, View renders it, and Update changes it. The runtime handles everything else.
Get Started
Learn the Essentials
60 lines of code Build a Complete File Browser Navigate directories, open files, handle errors, style content, vim keybindings — all working.
A file browser in sixty lines. It opens files, navigates directories, handles errors, styles directories and hidden files differently, and supports vim-style keyboard shortcuts. If you can do this much with this little code, imagine how easy your app will be to build.
require "rooibos"
module FileBrowser
# Model: What state does your app need?
Model = Data.(:path, :entries, :selected, :error)
Init = -> {
path = Dir.
entries = Entries[path]
Ractor.( # Ensures thread safety
Model.(path:, entries:, selected: entries., error: nil))
}
View = -> (model, tui) {
tui.(
titles: [model. || model.,
{ content: KEYS, position: :bottom, alignment: :right}],
borders: [:all],
border_style: if model. then tui.(fg: :red) else nil end,
children: [tui.(items: model..(&ListItem[model, tui]),
selected_index: model..(model.),
highlight_symbol: "",
highlight_style: tui.(modifiers: [:reversed]))]
)
}
Update = -> (message, model) {
return model.(error: ERROR) if message.
model = model.(error: nil) if model. && message.
if message. || message. then Rooibos::Command.
elsif message. || message. then model.(selected: model..)
elsif message. || message. then model.(selected: model..)
elsif message. || message. then Select[:-, model]
elsif message. || message. then Select[:+, model]
elsif message. then Open[model]
elsif message. then Navigate[File.(model.), model]
end
}
private # Lines below this are implementation details
KEYS = "↑/↓/Home/End: Select | Enter: Open | Esc: Navigate Up | q: Quit"
ERROR = "Sorry, opening the selected file failed."
ListItem = -> (model, tui) { -> (name) {
modifiers = name.(".") ? [:dim] : []
fg = :blue if name.("/")
tui.(content: name, style: tui.(fg:, modifiers:))
} }
Select = -> (operator, model) {
new_index = model..(model.).(operator, 1)
model.(selected: model.[new_index.(0, model.. - 1)])
}
Open = -> (model) {
full = File.(model., model..("/"))
model..("/") ? Navigate[full, model] : Rooibos::Command.(full)
}
Navigate = -> (path, model) {
entries = Entries[path]
model.(path:, entries:, selected: entries., error: nil)
}
Entries = -> (path) {
Dir.(path). { |name|
File.(File.(path, name)) ? "#{name}/" : name
}. { |name| [name.("/") ? 0 : 1, name.] }
}
end
Rooibos.(FileBrowser)
Batteries Included
Applications fetch data, run shell commands, and set timers. Rooibos Commands run off the main thread and send results back as messages.
# HTTP requests
Update = -> (message, model) {
case message
in :fetch_users
[model.(loading: true), Rooibos::Command.(:get, "/api/users", :got_users)]
in { type: :http, envelope: :got_users, status: 200, body: }
model.(loading: false, users: JSON.(body))
in { type: :http, envelope: :got_users, status: }
model.(error: "HTTP #{status}")
end
}
# Shell commands
Update = -> (message, model) {
case message
in :list_files
Rooibos::Command.("ls -la", :listed_files)
in { type: :system, envelope: :listed_files, stdout:, status: 0 }
model.(files: stdout..(&:chomp))
in { type: :system, envelope: :listed_files, stderr:, status: }
model.(error: stderr)
end
}
# Timers
Update = -> (message, model) {
case message
in { type: :timer, envelope: :tick, elapsed: }
[model.(frame: model. + 1), Rooibos::Command.(1.0 / 24, :tick)]
end
}
Commands included:
Build your own with Command.custom: out.put outputs
results, out.source outsources work, out.standing stands up streams that remain
outstanding until you outwait them.
Every command produces a message, and Update handles it the same way.
Scale Up
Large applications decompose into fragments. Each fragment has its own Model, View, Update, and Init. Parents compose children. The pattern scales.
# The Router DSL eliminates boilerplate:
module Dashboard
include Rooibos::Router
:stats, to: StatsPanel
:network, to: NetworkPanel
do
:ctrl_c, -> { Rooibos::Command. }
when: -> (model) { !model. } do
:q, -> { Rooibos::Command. }
:s, -> { StatsPanel. }
:p, -> { NetworkPanel. }
end
end
Update =
# ... Model, Init, View below
end
Declare routes and keymaps. The router generates Update for you. Use guards to ignore keys when needed.
Learn more:
Testing
Rooibos makes TUIs so easy to test, you'll save more time by writing tests than by not testing.
# Unit test Update, View, and Init. No terminal needed.
def test_moves_selection_down_with_j
model = Ractor.(FileBrowser::Model.(
path: "/", entries: %w[bin exe lib], selected: "bin", error: nil))
message = RatatuiRuby::Event::Key.(code: "j")
result = FileBrowser::Update.(message, model)
"exe", result.
end
# Style assertions. Draw to a headless terminal, verify colors.
def test_directories_are_blue
(60, 10) do
model = Ractor.(FileBrowser::Model.(
path: "/", entries: %w[file.txt subdir/], selected: "file.txt", error: nil))
widget = FileBrowser::View.(model, RatatuiRuby::TUI.)
RatatuiRuby. { |frame| frame.(widget, frame.) }
(1, 2) # "subdir/" at column 1, row 2
end
end
# System tests. Inject events, run the full app, snapshot the result.
def test_selection_moves_down
(120, 30) do
Dir. do |dir|
FileUtils.(File.(dir, "a"))
FileUtils.(File.(dir, "b"))
FileUtils.(File.(dir, "c"))
(:down)
(:ctrl_c)
Rooibos.(
model: Ractor.(FileBrowser::Model.(
path: dir, entries: %w[a b c], selected: "a", error: nil)),
view: FileBrowser::View,
update: FileBrowser::Update
)
("selection_moved_down")
end
end
end
What's included:
Snapshots record both plain text and ANSI colors. Normalization blocks mask
dynamic content (timestamps, temp paths) for reproducibility. Run UPDATE_SNAPSHOTS=1 rake test to regenerate baselines.
The Ecosystem
Rooibos builds on RatatuiRuby, a RubyGem built on Ratatui. You get native performance with the joy of Ruby. Rooibos is one way to manage state and composition. Kit is another.
RatatuiRuby
The rendering layer beneath Rooibos and Kit. Use it directly when you want full control.
- Imperative programming with direct control
- Inline viewports for rich CLI moments
- Unmanaged mode for maximum flexibility
Kit
Component-based architecture. Encapsulate state, input handling, and rendering in reusable pieces.
- OOP with stateful components
- Separate UI state from domain logic
- Built-in focus management & click handling
All three use the same widget library, declarative styles, and rendering engine. Each composes cleanly, and tests easily with snapshots and event injection. Both Rooibos and Kit keep state easy to reason about.
Pick the paradigm that fits your brain. You can't choose wrong.
Coming From...
Rooibos is a Ruby framework using patterns you may already know. If you've used React, Redux, Elm, Rails, or Bubble Tea, you'll feel at home.
Rails Developers
Rails taught you MVC. Rooibos uses the same separation: Model holds state, View renders it, Update handles input. Rails processes one request and exits. Rooibos runs a loop, responding to events as they arrive.
React & Redux Developers
If you've used useReducer or Redux, you already know the pattern. Model is your state
tree. Update is your reducer. View is your component. Messages are actions. The mental model transfers
directly.
Bubble Tea & Go Developers
Rooibos brings The Elm Architecture to Ruby the same way Bubble Tea brought it to Go. Commands, messages, and models work the same way. If you've built a Bubble Tea app, you can build a Rooibos app.
RatatuiRuby Developers
You've written the loop yourself with RatatuiRuby. Rooibos takes it over, manages rendering, runs Commands off the main thread, and routes messages to Update. You write logic; Rooibos handles plumbing.