Welcome Contributors!
toni is a small, focused project that welcomes contributions of all kinds:
Bug fixes and error handling improvements
UI/UX enhancements
New features (restaurant photos, export, tags, etc.)
Performance optimizations
Documentation improvements
Test coverage
Before starting major work, open an issue to discuss the approach. This prevents duplicate effort and ensures alignment with project goals.
Getting Started
Fork the repository
Click Fork on GitHub to create your own copy
Clone your fork
git clone https://github.com/YOUR_USERNAME/toni.git
cd toni
Create a branch
git checkout -b fix/visit-date-validation
# or
git checkout -b feature/export-csv
Make changes
Edit code, test locally, commit frequently
Push and open PR
git push origin fix/visit-date-validation
Then open a Pull Request on GitHub
Code Style
Go Conventions
toni follows standard Go style :
Key conventions :
Naming
Error Handling
Comments
// Exported types: PascalCase
type RestaurantDetail struct { /* ... */ }
// Unexported types: camelCase
type visitFormState struct { /* ... */ }
// Constants: PascalCase or ALL_CAPS for exported
const DefaultTimeout = 5 * time . Second
const maxRetries = 3
// Acronyms: uppercase (ID, not Id; URL, not Url)
type VisitID int64
Project-Specific Conventions
Layer Boundaries
Database layer (internal/db/):
Functions accept *sql.DB as first parameter
Return domain types from internal/model
Never return *sql.Rows or sql.Result directly
Use descriptive function names: GetRestaurantWithStats, not GetRestaurant2
// Good
func ListVisits ( db * sql . DB , filter string ) ([] model . VisitRow , error )
// Bad
func GetVisits ( db * sql . DB ) ( * sql . Rows , error )
Message Types
Bubble Tea messages (internal/model/msg.go):
Suffix with Msg: VisitsLoadedMsg, not VisitsLoaded
Include context for debugging: operation type, before/after state
Document when messages trigger commands
// Good: includes undo context
type VisitSavedMsg struct {
ID int64
Operation string // "insert" or "update"
Before * Visit // nil for insert
After Visit
}
// Bad: not enough context
type VisitSavedMsg struct {
ID int64
}
UI Components
Bubble Tea models (internal/ui/):
Implement Init(), Update(), View() interface
Keep View() pure - no side effects
Use tea.Cmd for async operations, never block in Update()
// Good: async database query
func ( m Model ) handleSave () ( tea . Model , tea . Cmd ) {
return m , saveVisitCmd ( m . db , m . visitForm . data )
}
func saveVisitCmd ( db * sql . DB , data VisitData ) tea . Cmd {
return func () tea . Msg {
// Runs in background
id , err := db . CreateVisit ( db , data )
if err != nil {
return ErrorMsg { Err : err }
}
return VisitSavedMsg { ID : id }
}
}
// Bad: blocking in Update
func ( m Model ) handleSave () ( tea . Model , tea . Cmd ) {
id , err := db . CreateVisit ( m . db , m . visitForm . data ) // BLOCKS!
// ...
}
Styling
Lip Gloss styles (internal/ui/styles.go):
Define styles as package-level variables
Reuse base styles, compose with Copy()
Use semantic names: ErrorStyle, not RedText
// Base styles
var (
ColorPrimary = lipgloss . Color ( "#7D56F4" )
ColorError = lipgloss . Color ( "#FF6F61" )
)
// Composed styles
var ErrorStyle = lipgloss . NewStyle ().
Foreground ( ColorError ).
Bold ( true )
var ErrorBanner = ErrorStyle . Copy ().
Padding ( 1 , 2 ).
Border ( lipgloss . RoundedBorder ())
Testing
Manual Testing
Before submitting a PR , test:
Happy Path
Create restaurant
Log visit with rating
Edit visit
Delete visit
Edge Cases
Empty database
Invalid date formats
Missing required fields
Keyboard shortcuts
Terminal Sizes
Minimum (72×18)
Standard (100×30)
Large (200×50)
Terminals
iTerm2 / Alacritty
Windows Terminal
Basic xterm
Test database :
# Use a throwaway database
go run main.go --db ./test.db
# Start fresh each test
rm ./test.db && go run main.go --db ./test.db
Automated Tests
When adding tests (currently none exist):
// internal/db/visits_test.go
func TestCreateVisit ( t * testing . T ) {
db := setupTestDB ( t )
defer db . Close ()
visit := model . NewVisit {
RestaurantID : 1 ,
VisitedOn : "2026-03-04" ,
Rating : ptr ( 8.5 ),
}
id , err := CreateVisit ( db , visit )
if err != nil {
t . Fatalf ( "CreateVisit failed: %v " , err )
}
if id == 0 {
t . Error ( "Expected non-zero ID" )
}
}
Test helpers :
setupTestDB(t) - Create in-memory SQLite DB
ptr(v) - Helper for pointer values
Table-driven tests for multiple cases
Pull Request Guidelines
Use conventional commits style:
feat: add CSV export functionality
fix: correct date validation in visit form
docs: update architecture diagram
refactor: extract table component
perf: optimize visit list query
test: add database layer tests
Prefixes :
feat: - New feature
fix: - Bug fix
docs: - Documentation only
refactor: - Code restructuring, no behavior change
perf: - Performance improvement
test: - Add or update tests
chore: - Build, dependencies, tooling
PR Description Template
## Summary
Brief description of what this PR does.
## Motivation
Why is this change needed? What problem does it solve?
## Changes
- List specific changes
- One per line
- Be specific
## Testing
How did you test this?
- [ ] Tested in iTerm2
- [ ] Tested with empty database
- [ ] Tested keyboard shortcuts
## Screenshots (if UI change)
[Attach before/after screenshots]
## Checklist
- [ ] Code follows project style
- [ ] Manual testing completed
- [ ] No new warnings or errors
- [ ] Documentation updated (if needed)
Review Process
Self-review
Before requesting review:
Read your own diff
Run go fmt ./...
Test all changed functionality
Check for leftover debug code
CI checks
Wait for automated checks (when added):
Build succeeds
Tests pass
Linter passes
Maintainer review
Maintainers will:
Review code quality
Test functionality
Suggest improvements
Approve or request changes
Merge
After approval:
Squash and merge (usually)
Delete branch
Response time : Expect review within 3-5 days. Ping after 1 week if no response.
Common Contribution Patterns
Adding a New Screen
Define screen enum
// internal/model/msg.go
const (
// ...
ScreenExport Screen = iota
)
Create screen model
// internal/ui/export.go
type ExportModel struct {
// state
}
func ( m ExportModel ) Update ( msg tea . Msg ) ( ExportModel , tea . Cmd )
func ( m ExportModel ) View ( width , height int ) string
Add to root model
// internal/ui/app.go
type Model struct {
// ...
export * ExportModel
}
Handle in Update
case model . ScreenExport :
if m . export != nil {
content = m . export . View ( m . width , contentHeight )
}
Add navigation
case "x" : // Export shortcut
m . screen = model . ScreenExport
m . export = NewExportModel ()
return m , nil
Adding a Database Column
Create migration
// internal/db/migrations.go (create if needed)
const migration002 = `
ALTER TABLE visits ADD COLUMN photos TEXT;
`
Update schema version
// internal/db/db.go
const schemaVersion = 2
func migrate ( db * sql . DB ) error {
// Check current version, run migrations
}
Update model types
// internal/model/types.go
type Visit struct {
// ...
Photos string
}
Update queries
// internal/db/visits.go
const createVisitSQL = `
INSERT INTO visits (restaurant_id, visited_on, rating, notes, photos)
VALUES (?, ?, ?, ?, ?)
`
Always test migrations with existing databases! Don’t break user data.
Adding a Keybinding
Define key
// internal/ui/keys.go
type KeyMap struct {
// ...
Export key . Binding
}
func DefaultKeyMap () KeyMap {
return KeyMap {
// ...
Export : key . NewBinding (
key . WithKeys ( "x" ),
key . WithHelp ( "x" , "export" ),
),
}
}
Handle in Update
// internal/ui/app.go
case "x" :
return m . handleExport ()
Update help
// internal/ui/help.go
func RenderHelp ( screen Screen , mode Mode , width int ) string {
// Add to relevant screen's help text
}
Development Tips
Debugging TUI Apps
Problem : fmt.Println() doesn’t work in full-screen TUI.
Solution : Log to a file.
// At startup
logFile , _ := os . OpenFile ( "/tmp/toni.log" , os . O_CREATE | os . O_APPEND | os . O_WRONLY , 0644 )
defer logFile . Close ()
log . SetOutput ( logFile )
// In code
log . Printf ( "Debug: visitID= %d , rating= %v " , visitID , rating )
Then tail the log:
Quick Iteration
Watch for changes and rebuild :
# Install air (live reload)
go install github.com/cosmtrek/air@latest
# Run with auto-reload
air
Or use a simple watch script:
#!/bin/bash
while true ; do
go build -o toni . && ./toni --db ./test.db
sleep 1
done
Testing Terminal Output
Capture rendered output :
// In test
import " github.com/charmbracelet/x/exp/teatest "
func TestVisitsList ( t * testing . T ) {
model := ui . New ( testDB , nil , ui . TerminalCapabilities {})
tm := teatest . NewTestModel ( t , model )
tm . Send ( tea . WindowSizeMsg { Width : 100 , Height : 30 })
output := tm . FinalOutput ( t )
if ! strings . Contains ( output , "Visits" ) {
t . Error ( "Expected 'Visits' in output" )
}
}
Project Goals and Non-Goals
Goals ✓
Local-first : All data in SQLite, zero cloud dependencies
Fast : Startup <100ms, renders <5ms
Keyboard-driven : Full navigation without mouse
Offline-capable : Core features work without network
Portable : Single binary, no installation
Non-Goals ✗
Multi-user : toni is single-user by design
Web UI : Terminal-only, no web interface planned
Mobile apps : Desktop/laptop terminals only
Complex analytics : Basic stats only, not a BI tool
Social features : No sharing, reviews, or friends
When in doubt , prioritize:
Simplicity over features
Performance over polish
Keyboard over mouse
Local over cloud
Getting Help
Stuck? Have questions?
Recognition
All contributors will be:
Listed in CONTRIBUTORS.md (when created)
Mentioned in release notes
Credited in commit history
Thank you for contributing to toni!