What Is a TUI?#

A TUI (Terminal User Interface) is an interactive application that runs in the terminal but behaves more like a desktop app — it has screens, navigation, live-updating panels, and keyboard shortcuts.

Examples you’ve probably used: htop, lazygit, k9s, ncdu. These aren’t just text output — they’re full interactive applications that happen to live in your terminal.

Go’s ecosystem has an excellent framework for building these: Bubbletea (from Charm Bracelet). It uses the Elm architecture — a functional, message-driven model that makes complex TUIs manageable.

The Elm Architecture in Go#

Bubbletea is based on three things:

Model — your application’s entire state. A struct.

Update — a function that takes the current model + a message and returns a new model. Pure function, no side effects.

View — a function that takes the model and returns a string (what to render). Pure function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
type model struct {
    screen     Screen
    trades     []Trade
    cursor     int
    loading    bool
    err        error
}

type Screen int
const (
    ScreenSetup Screen = iota
    ScreenCommands
    ScreenConfig
    ScreenDatabase
    ScreenMonitor
    ScreenLogs
)

// Update handles all input and state changes
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "1": return m.switchScreen(ScreenSetup), nil
        case "2": return m.switchScreen(ScreenCommands), nil
        case "3": return m.switchScreen(ScreenConfig), nil
        case "4": return m.switchScreen(ScreenDatabase), nil
        case "5": return m.switchScreen(ScreenMonitor), nil
        case "6": return m.switchScreen(ScreenLogs), nil
        case "q", "ctrl+c": return m, tea.Quit
        }
    case TradesMsg:
        // New trade data arrived from background goroutine
        m.trades = msg.Trades
        return m, nil
    case errMsg:
        m.err = msg.err
        return m, nil
    }
    return m, nil
}

// View renders the current state as a string
func (m model) View() string {
    switch m.screen {
    case ScreenSetup:    return m.viewSetup()
    case ScreenCommands: return m.viewCommands()
    case ScreenConfig:   return m.viewConfig()
    case ScreenDatabase: return m.viewDatabase()
    case ScreenMonitor:  return m.viewMonitor()
    case ScreenLogs:     return m.viewLogs()
    }
    return ""
}

The key insight: Update never mutates state — it returns a new model. This makes state changes predictable and easy to reason about. All the “what happens when the user presses this key” logic lives in one function.

The Six Screens#

A full market data TUI has six screens, each with a distinct purpose:

Screen 1: Setup#

Guides the user through first-time configuration — selecting which trading pairs and data types to collect, configuring ClickHouse/Redis connections, running the schema bootstrap.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (m model) viewSetup() string {
    var b strings.Builder
    b.WriteString(titleStyle.Render("Setup Wizard") + "\n\n")

    steps := []struct{ done bool; label string }{
        {m.infraRunning,   "Infrastructure (ClickHouse + Redis)"},
        {m.profileExists,  "Backfill profile created"},
        {m.schemaApplied,  "Schema applied to ClickHouse"},
        {m.binaryBuilt,    "Binary built"},
    }

    for _, step := range steps {
        icon := "○"
        style := dimStyle
        if step.done {
            icon = "✓"
            style = greenStyle
        }
        b.WriteString(style.Render(fmt.Sprintf("  %s  %s", icon, step.label)) + "\n")
    }
    return b.String()
}

Screen 2: Commands#

A quick reference for all CLI subcommands — collect, serve, backfill, schema — with descriptions and example invocations. A living cheatsheet inside the app.

Screen 3: Config Editor#

An interactive TOML editor for config.toml. Shows current values, lets you navigate and edit fields with keyboard shortcuts, writes changes back to disk.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type configField struct {
    key     string
    value   string
    editing bool
}

func (m model) viewConfig() string {
    var b strings.Builder
    for i, field := range m.configFields {
        prefix := "  "
        if i == m.cursor {
            prefix = "> "
        }
        if field.editing {
            b.WriteString(fmt.Sprintf("%s%s: %s█\n", prefix, field.key, field.value))
        } else {
            b.WriteString(fmt.Sprintf("%s%s: %s\n", prefix, field.key, field.value))
        }
    }
    return b.String()
}

Screen 4: ClickHouse Browser#

A table browser that runs SHOW TABLES, then lets you navigate into any table and see live row counts, schema, and recent data. Like a minimal DB client inside the TUI.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type dbBrowserMsg struct {
    tables []tableInfo
}

// Background command — runs async, sends result as a message
func fetchTables(ch *clickhouse.Conn) tea.Cmd {
    return func() tea.Msg {
        rows, _ := ch.Query(ctx, "SELECT name, total_rows FROM system.tables WHERE database = 'binance_data'")
        // ... parse rows ...
        return dbBrowserMsg{tables: tables}
    }
}

tea.Cmd is a function that returns a tea.Msg. When returned from Update, Bubbletea runs it in a goroutine and sends the result back as a message. This is how you do async operations without blocking the render loop.

Screen 5: Live Monitor#

The most complex screen. Shows the sync_tasks table in real-time — what’s pending, in progress, done, or failed. Updates every 2 seconds.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type tickMsg time.Time

// Periodic tick command
func tick() tea.Cmd {
    return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
        return tickMsg(t)
    })
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg.(type) {
    case tickMsg:
        return m, tea.Batch(
            fetchSyncTasks(m.clickhouse),  // refresh data
            tick(),                         // schedule next tick
        )
    case syncTasksMsg:
        m.syncTasks = msg.tasks
        return m, nil
    }
    // ...
}

tea.Batch runs multiple commands concurrently. On each tick: fetch new data from ClickHouse, and schedule the next tick. The monitor screen is always live without any extra complexity.

Screen 6: Live Logs#

A scrollable log viewer that shows recent collector output — trade counts, gap detections, recovery actions, errors. Implemented as a ring buffer of log lines.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type logMsg struct{ line string }

// Attach to zerolog output
type teeWriter struct {
    logLines chan string
}

func (w *teeWriter) Write(p []byte) (int, error) {
    w.logLines <- string(p)
    return len(p), nil
}

The logger writes to both stdout and a channel. The TUI reads from the channel and displays lines in a scrollable viewport using Bubbletea’s viewport component.

Styling with Lipgloss#

Charm’s Lipgloss library provides styled text using a CSS-inspired API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import "github.com/charmbracelet/lipgloss"

var (
    titleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("#EEC35E")).
        BorderStyle(lipgloss.RoundedBorder()).
        Padding(0, 1)

    greenStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#00FF41"))

    dimStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("#555555"))

    tableStyle = lipgloss.NewStyle().
        Border(lipgloss.NormalBorder()).
        BorderForeground(lipgloss.Color("#333333"))
)

Styles are composable:

1
2
3
4
// Combine styles
activeTabStyle = tabStyle.Copy().
    Bold(true).
    BorderBottom(false)

Lipgloss handles terminal width, color fallback for terminals that don’t support 256 colors, and the layout math for side-by-side panels.

Starting the TUI#

1
2
3
4
5
6
7
8
func RunTUI() error {
    m := initialModel()
    p := tea.NewProgram(m, tea.WithAltScreen())
    // WithAltScreen: use the alternate terminal screen
    // so the TUI doesn't pollute the scroll buffer
    _, err := p.Run()
    return err
}

WithAltScreen runs the TUI in the terminal’s alternate screen buffer — when you quit, the terminal returns to exactly how it looked before, with no leftover TUI output in the scroll history.

Why Build a TUI Instead of a Web Dashboard?#

A web dashboard requires a running HTTP server, a browser, and frontend assets. A TUI runs anywhere you have SSH access — no ports, no browser, no network exposure beyond the SSH connection itself.

For a system that runs on a server you SSH into, a TUI is the natural interface. It starts with the binary, requires no separate web server, and works over any terminal connection including tmux and screen sessions on remote machines.

The cost: TUI development is more manual than React — no component library, no hot reload, layout math done by hand. Bubbletea makes it manageable, but a 6-screen TUI is still a significant amount of code.