Skip to main content

Overview

toni is built using the Bubble Tea TUI framework, following a clean separation of concerns across database, model, UI, and command layers. The architecture emphasizes:
  • Local-first data: All state persists to SQLite
  • Modal interaction: Vim-style navigation and insert modes
  • Message-driven updates: Elm-inspired unidirectional data flow
  • Component isolation: Each screen manages its own state and rendering

Framework: Bubble Tea

Bubble Tea is an Elm-inspired TUI framework that implements the Model-Update-View (MVU) pattern:
┌─────────────────────────────────────────┐
│                 View                    │
│          (renders current state)        │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│                 User                    │
│         (keyboard input)                │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│               Update                    │
│   (processes Msg, updates Model)        │
└────────────────┬────────────────────────┘


┌─────────────────────────────────────────┐
│                Model                    │
│         (application state)             │
└─────────────────────────────────────────┘
Every interaction follows this cycle: user input creates messages, messages update the model, and the view re-renders.

Layer Architecture

Database Layer (internal/db/)

Provides typed database operations with zero ORM overhead:
  • db.go: Database initialization, schema management, and connection handling
  • restaurants.go: Restaurant CRUD operations and aggregation queries
  • visits.go: Visit tracking with joined queries for list views
  • want_to_visit.go: Wishlist management
  • undo_helpers.go: Support for undo/redo operations
All queries use raw SQL with database/sql for maximum control and performance. Key patterns:
  • Functions accept *sql.DB as first parameter
  • Return domain types from internal/model
  • No database-specific types leak into upper layers
// Example: Clean layer boundary
func ListVisits(db *sql.DB, filter string) ([]model.VisitRow, error)

Model Layer (internal/model/)

Defines domain types and Bubble Tea messages: types.go:
  • Domain entities: Restaurant, Visit, WantToVisit
  • Row types for list views: VisitRow, RestaurantRow, WantToVisitRow
  • Mutation types: NewVisit, UpdateVisit, etc.
msg.go:
  • Bubble Tea messages for state transitions
  • Screen and Mode enums
  • Data-loading messages: VisitsLoadedMsg, RestaurantDetailLoadedMsg
  • Action messages: VisitSavedMsg, DeleteVisitMsg, FormCancelledMsg
Messages are the only way to communicate state changes in Bubble Tea. They’re sent via tea.Cmd and processed in the Update function.

UI Layer (internal/ui/)

Implements all screens, forms, and visual components:
  • app.go: Root model with routing, mode handling, and global state (internal/ui/app.go:24)
  • visits.go, restaurants.go, want_to_visit.go: List screen views
  • visit_detail.go, restaurant_detail.go: Detail screens
  • restaurant_form.go, visit_form.go: Input forms with validation
  • table_controls.go: Reusable table component with sorting, filtering, column management
  • styles.go: Lip Gloss styling definitions
  • keys.go: Keybinding definitions for navigation and forms
  • graphics.go: ASCII art and visual elements
  • help.go: Context-aware help system
  • prefs.go: User preferences persistence
Root Model (internal/ui/app.go:24):
type Model struct {
    db               *sql.DB
    yelpClient       *search.YelpClient
    termCapabilities TerminalCapabilities
    screen           model.Screen     // Current screen
    mode             model.Mode       // Nav or Insert
    
    // Screen-specific models
    visits            *VisitsModel
    restaurants       *RestaurantsModel
    visitForm         *VisitFormModel
    // ...
}

Command Layer (cmd/)

Handles CLI argument parsing and initialization:
  • root.go: Flag parsing, database path resolution, Yelp API configuration (cmd/root.go:20)
  • onboarding.go: First-run experience and settings collection

Supporting Layers

internal/search/: Yelp Fusion API client for restaurant autocomplete (internal/search/yelp.go:14) internal/util/: Formatting utilities for dates, ratings, and user-facing strings (internal/util/format.go:1)

State Management

Screen Navigation

toni implements a screen-based state machine with navigation and insert modes:
ScreenVisits ──(r)──> ScreenRestaurants
    │                       │
    │(enter)           (enter)│
    ▼                       ▼
ScreenVisitDetail    ScreenRestaurantDetail
    │                       │
    │(e)                 (e)│
    ▼                       ▼
ScreenVisitForm      ScreenRestaurantForm
The active screen is stored in Model.screen and determines:
  • Which keybindings are active
  • Which sub-model handles updates
  • What’s rendered in View()

Mode System

Navigation Mode (ModeNav):
  • Vim-style movement: j/k, gg, G, Ctrl+d/Ctrl+u
  • Actions: a (add), e (edit), d (delete), v (log visit)
  • Screen switching: r (restaurants), w (want to visit)
Insert Mode (ModeInsert):
  • Active during form editing
  • Tab/Shift+Tab for field navigation
  • Ctrl+S to save, Esc to cancel
  • Restaurant autocomplete in visit forms

Message Passing

All state changes flow through messages:
1

User Action

User presses a key or completes an action
2

Command Created

Update() returns a tea.Cmd that will produce a message
return m, loadVisitsCmd(m.db, "")
3

Command Executes

Command runs asynchronously (often a database query)
func loadVisitsCmd(db *sql.DB, filter string) tea.Cmd {
    return func() tea.Msg {
        visits, err := db.ListVisits(db, filter)
        if err != nil {
            return model.ErrorMsg{Err: err}
        }
        return model.VisitsLoadedMsg{Visits: visits}
    }
}
4

Message Received

Update() receives the message and updates state
case model.VisitsLoadedMsg:
    m.visits = NewVisitsModel(msg.Visits)
    return m, nil
5

View Renders

View() re-renders with updated state

Undo/Redo System

toni implements operation-level undo with action stacking:
  • Each destructive operation (save, delete) creates an undoAction
  • Actions capture “before” and “after” state
  • u triggers undo, Ctrl+r triggers redo
  • Undo actions stored in Model.undoStack and Model.redoStack
Example flow:
// On delete (internal/ui/app.go:198)
m.pushUndoAction(m.buildDeleteVisitAction(msg))

// On undo
func (m *Model) undoCmd() tea.Cmd {
    action := m.popUndoAction()
    return restoreUndoAction(m.db, action)
}

Terminal Capabilities

toni detects terminal features at startup:
  • True color support: Enables 24-bit color in supported terminals
  • Unicode support: Falls back to ASCII if needed
  • Dimensions: Adapts layout to available width/height
Minimum usable size: 72×18 characters (internal/ui/app.go:19)

Data Flow Example

Adding a new visit:
  1. User presses a on visits screen
  2. handleVisitsNav() sets mode = ModeInsert, screen = ScreenVisitForm
  3. VisitFormModel renders with empty fields
  4. User types restaurant name → triggers Yelp autocomplete
  5. User fills fields and presses Ctrl+S
  6. Form validation runs
  7. saveVisitCmd() executes database insert
  8. VisitSavedMsg received → mode returns to ModeNav
  9. loadVisitsCmd() refreshes the visits list
  10. View re-renders with new visit visible

Key Design Decisions

Pure Go

No CGO dependencies via modernc.org/sqlite - single binary, easy distribution

Single Database

All state in one SQLite file for trivial backup and portability

Optional Cloud

Yelp API enhances UX but app works fully offline

Modal UI

Vim-inspired navigation keeps hands on keyboard

Performance Characteristics

  • Startup time: <100ms (database open + schema init + first query)
  • Render latency: 1-2ms for typical screen (thanks to Lip Gloss caching)
  • Database queries: <10ms for list views with <10k records
  • Autocomplete: 100-500ms depending on network (Yelp API)
All queries use indexed columns, and list views use LIMIT to cap result size.

Build docs developers (and LLMs) love