Rooibos

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

Easy as 1, 2, 3

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 new my_app
$ cd my_app
$ rooibos run

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.

See it in action

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.paragraph(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.ctrl_c?
      Rooibos::Command.exit
    elsif message.key?
      model + 1
    end
  }
end

Rooibos.run(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

Built-in power

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.with(loading: true), Rooibos::Command.http(:get, "/api/users", :got_users)]
  in { type: :http, envelope: :got_users, status: 200, body: }
    model.with(loading: false, users: JSON.parse(body))
  in { type: :http, envelope: :got_users, status: }
    model.with(error: "HTTP #{status}")
  end
}

# Shell commands
Update = -> (message, model) {
  case message
  in :list_files
    Rooibos::Command.system("ls -la", :listed_files)
  in { type: :system, envelope: :listed_files, stdout:, status: 0 }
    model.with(files: stdout.lines.map(&:chomp))
  in { type: :system, envelope: :listed_files, stderr:, status: }
    model.with(error: stderr)
  end
}

# Timers
Update = -> (message, model) {
  case message
  in { type: :timer, envelope: :tick, elapsed: }
    [model.with(frame: model.frame + 1), Rooibos::Command.wait(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.

Separation of concerns

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

  route :stats, to: StatsPanel
  route :network, to: NetworkPanel

  keymap do
    key :ctrl_c, -> { Rooibos::Command.exit }
    only when: -> (model) { !model.modal_open } do
      key :q, -> { Rooibos::Command.exit }
      key :s, -> { StatsPanel.fetch_command }
      key :p, -> { NetworkPanel.ping_command }
    end
  end

  Update = from_router

  # ... Model, Init, View below
end

Declare routes and keymaps. The router generates Update for you. Use guards to ignore keys when needed.

Learn more:

Ship with confidence

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.make_shareable(FileBrowser::Model.new(
    path: "/", entries: %w[bin exe lib], selected: "bin", error: nil))
  message = RatatuiRuby::Event::Key.new(code: "j")

  result = FileBrowser::Update.call(message, model)

  assert_equal "exe", result.selected
end

# Style assertions. Draw to a headless terminal, verify colors.
def test_directories_are_blue
  with_test_terminal(60, 10) do
    model = Ractor.make_shareable(FileBrowser::Model.new(
      path: "/", entries: %w[file.txt subdir/], selected: "file.txt", error: nil))
    widget = FileBrowser::View.call(model, RatatuiRuby::TUI.new)

    RatatuiRuby.draw { |frame| frame.render_widget(widget, frame.area) }

    assert_blue(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
  with_test_terminal(120, 30) do
    Dir.mktmpdir do |dir|
      FileUtils.touch(File.join(dir, "a"))
      FileUtils.touch(File.join(dir, "b"))
      FileUtils.touch(File.join(dir, "c"))

      inject_key(:down)
      inject_key(:ctrl_c)

      Rooibos.run(
        model: Ractor.make_shareable(FileBrowser::Model.new(
          path: dir, entries: %w[a b c], selected: "a", error: nil)),
        view: FileBrowser::View,
        update: FileBrowser::Update
      )

      assert_snapshots("selection_moved_down")
    end
  end
end

What's included:

  • Unit tests for Update, View, Init
  • Style assertions (colors, modifiers)
  • Event injection (keys, mouse, resize)
  • Snapshot comparisons (text + ANSI)
  • Headless terminal for CI

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.

Beyond Functional

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.

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.

Familiar patterns

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.

Read the Rails developer guide →

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.

Read the React developer guide →

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.

Read the Go developer guide →

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.

Read the RatatuiRuby developer guide →