Building a Go TUI (Task Runner)¶
Step-by-step guide to building a Task Runner TUI in Go with Bubble Tea: task list, detail pane, footer, search/filter, create/edit/delete, JSON persistence, graceful quit, and file-based logging. For conceptual guidance, see TUI Applications (Go).
Overview¶
You will build a terminal UI that manages a list of tasks with local JSON storage. The app uses Bubble Tea (and optionally Lip Gloss) for the TUI, keeps model/update/view separate, and logs to a file.
What you will build¶
- Task list pane (left): scrollable list with selection
- Detail pane (right): title, description, status for the selected task
- Footer: key bindings
- Search/filter: filter tasks by title/description
- Create / Edit / Delete with JSON persistence
- Graceful quit (q)
- Log file outside the TUI
- Basic tests for model and storage
Prerequisites¶
- Go 1.21+
- A terminal that supports alternate screen and key events
Project bootstrap¶
Dependency installation¶
Project structure¶
task-runner-tui/
├── main.go # Entry, run Tea program
├── model.go # Model, Msg, Update, View
├── storage.go # load/save JSON
├── storage_test.go # Tests for storage
├── go.mod
├── go.sum
└── tasks.json # Created at runtime
Core state model¶
// model.go
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
}
type Model struct {
Tasks []Task
SelectedIndex int
FilterQuery string
NextID int
Width int
Height int
DataPath string
}
func (m Model) dataPath() string {
if m.DataPath != "" {
return m.DataPath
}
dir, _ := os.UserHomeDir()
return filepath.Join(dir, ".task_runner_tui", "tasks.json")
}
func (m Model) FilteredTasks() []Task {
if m.FilterQuery == "" {
return m.Tasks
}
q := strings.ToLower(m.FilterQuery)
var out []Task
for _, t := range m.Tasks {
if strings.Contains(strings.ToLower(t.Title), q) ||
strings.Contains(strings.ToLower(t.Description), q) {
out = append(out, t)
}
}
return out
}
func (m Model) SelectedTask() *Task {
filtered := m.FilteredTasks()
if len(filtered) == 0 || m.SelectedIndex < 0 || m.SelectedIndex >= len(filtered) {
return nil
}
return &filtered[m.SelectedIndex]
}
Event loop and message/update model¶
Bubble Tea uses Msg and Update(model, msg) (Model, Cmd).
// model.go (continued)
type msgSelectDown struct{}
type msgSelectUp struct{}
type msgFilter string
type msgNewTask struct{}
type msgDeleteTask struct{}
type msgQuit struct{}
type msgSaveDone struct{ err error }
type msgLoadDone struct{ tasks []Task; nextID int }
func (m Model) Init() tea.Cmd {
return loadTasksCmd(m.dataPath())
}
Add "os" and "path/filepath" to imports in model.go for dataPath(). Set DataPath in main before creating the program.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "j", "down":
filtered := m.FilteredTasks()
if len(filtered) > 0 && m.SelectedIndex < len(filtered)-1 {
m.SelectedIndex++
}
return m, nil
case "k", "up":
if m.SelectedIndex > 0 {
m.SelectedIndex--
}
return m, nil
case "n":
return m, m.newTaskCmd()
case "d":
return m, m.deleteTaskCmd()
case "/":
// In a full impl, switch to filter input mode; for brevity we skip
return m, nil
}
case tea.WindowSizeMsg:
m.Width = msg.Width
m.Height = msg.Height
return m, nil
case msgFilter:
m.FilterQuery = string(msg)
m.SelectedIndex = 0
return m, nil
case msgNewTask:
m.NextID++
m.Tasks = append(m.Tasks, Task{
ID: fmt.Sprintf("t%d", m.NextID),
Title: "New task",
Description: "",
Done: false,
})
m.SelectedIndex = len(m.FilteredTasks()) - 1
return m, saveTasksCmd(m.dataPath(), m.Tasks)
case msgDeleteTask:
t := m.SelectedTask()
if t == nil {
return m, nil
}
var newTasks []Task
for _, task := range m.Tasks {
if task.ID != t.ID {
newTasks = append(newTasks, task)
}
}
m.Tasks = newTasks
if m.SelectedIndex >= len(m.FilteredTasks()) && m.SelectedIndex > 0 {
m.SelectedIndex--
}
return m, saveTasksCmd(m.dataPath(), m.Tasks)
case msgLoadDone:
m.Tasks = msg.tasks
m.NextID = msg.nextID
return m, nil
case msgSaveDone:
if msg.err != nil {
// Could set an error state; for simplicity we ignore
}
return m, nil
}
return m, nil
}
func (m Model) newTaskCmd() tea.Cmd { return func() tea.Msg { return msgNewTask{} } }
func (m Model) deleteTaskCmd() tea.Cmd { return func() tea.Msg { return msgDeleteTask{} } }
Wire key "j"/"k" to msgSelectDown/msgSelectUp in a key handler that returns the right message. Example key handling in Update:
case tea.KeyMsg:
switch msg.String() {
case "j", "down":
filtered := m.FilteredTasks()
if len(filtered) > 0 && m.SelectedIndex < len(filtered)-1 {
m.SelectedIndex++
}
return m, nil
case "k", "up":
if m.SelectedIndex > 0 {
m.SelectedIndex--
}
return m, nil
// ... rest
}
Rendering layout¶
Use Lip Gloss for panes and the view.
// model.go (View)
func (m Model) View() string {
listStyle := lipgloss.NewStyle().Width(30).Height(m.Height - 2).Border(lipgloss.RoundedBorder())
detailStyle := lipgloss.NewStyle().Width(m.Width - 35).Padding(0, 1).Border(lipgloss.RoundedBorder())
footerStyle := lipgloss.NewStyle().Width(m.Width).Height(1)
filtered := m.FilteredTasks()
var list strings.Builder
for i, t := range filtered {
prefix := " "
if i == m.SelectedIndex {
prefix = "▸ "
}
list.WriteString(prefix + t.Title + "\n")
}
listStr := listStyle.Render(list.String())
detailStr := "No task selected"
if t := m.SelectedTask(); t != nil {
detailStr = fmt.Sprintf("%s\n\n%s\n\nDone: %v", t.Title, t.Description, t.Done)
}
detailStr = detailStyle.Render(detailStr)
footerStr := footerStyle.Render("j/k: move | n: new | d: delete | q: quit")
return lipgloss.JoinVertical(lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, listStr, detailStr),
footerStr,
)
}
Keyboard bindings¶
- j / k (or arrows): move selection
- n: new task
- d: delete selected task
- q / Ctrl+C: quit
- / (optional): focus filter input
Persistence layer¶
// storage.go
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type store struct {
Tasks []Task `json:"tasks"`
}
func dataPath() string {
dir, _ := os.UserHomeDir()
return filepath.Join(dir, ".task_runner_tui", "tasks.json")
}
func loadTasks(path string) ([]Task, int, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, 1, nil
}
return nil, 0, err
}
var s store
if err := json.Unmarshal(data, &s); err != nil {
return nil, 0, err
}
nextID := 1
for _, t := range s.Tasks {
var n int
if _, _ = fmt.Sscanf(t.ID, "t%d", &n); n >= nextID {
nextID = n + 1
}
}
return s.Tasks, nextID, nil
}
func saveTasks(path string, tasks []Task) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(store{Tasks: tasks}, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
Commands for async load/save:
func loadTasksCmd(path string) tea.Cmd {
return func() tea.Msg {
tasks, nextID, err := loadTasks(path)
if err != nil {
return msgLoadDone{tasks: nil, nextID: 1}
}
return msgLoadDone{tasks: tasks, nextID: nextID}
}
}
func saveTasksCmd(path string, tasks []Task) tea.Cmd {
return func() tea.Msg {
return msgSaveDone{err: saveTasks(path, tasks)}
}
}
Model needs a way to know the data path (e.g. store it in Model or use a global). Add dataPath() string that returns the path; in Init() call loadTasksCmd(m.dataPath()). Add a field to Model for path if needed, e.g. DataPath string, set from main.
Logging¶
Log to a file or stderr so the TUI is not overwritten:
// main.go or init
f, _ := os.OpenFile(filepath.Join(os.Getenv("HOME"), ".task_runner_tui", "tui.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
log.SetOutput(f)
log.SetFlags(log.Ldate | log.Ltime)
Log on load/save errors and key actions as needed.
Running the application¶
In main.go:
// main.go
package main
import (
"fmt"
"log"
"os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
)
func main() {
dir := filepath.Join(os.Getenv("HOME"), ".task_runner_tui")
_ = os.MkdirAll(dir, 0755)
logFile, err := os.OpenFile(filepath.Join(dir, "tui.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err == nil {
log.SetOutput(logFile)
}
log.SetFlags(log.Ldate | log.Ltime)
m := Model{DataPath: filepath.Join(dir, "tasks.json")}
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
Add DataPath string to Model and use it in dataPath() and Init().
Verification checklist¶
- Task list on left, detail on right, footer at bottom.
- j/k move selection; detail updates.
- n creates a task and persists.
- d deletes and persists.
- q quits; terminal restored.
- Log file written; no log lines in TUI.
-
go test ./...passes.
Troubleshooting¶
- Layout wrong: Ensure
tea.WindowSizeMsgis handled andm.Width/m.Heightare used in View. - Keys not working: Check KeyMsg string (lowercase); ensure no focus in an input that swallows keys.
- Load/save fails: Check permissions and path; log errors.
Extensions / next steps¶
- Add filter input (additional model state for "filter mode" and an input field).
- Edit task (modal or sub-view).
- SQLite or BoltDB for larger datasets.
- Unit tests for
Updatewith various messages and forloadTasks/saveTasks.
TL;DR summary¶
go mod init; addbubbleteaandlipgloss.- Define
Model,Msgtypes,Update,Viewinmodel.go; load/save instorage.go. - Use
tea.NewProgram(m, tea.WithAltScreen())inmain; log to file. - Wire j/k, n, d, q in
Update; I/O viatea.Cmdonly. - Run with
go run .; test withgo test ./....
For patterns and production guidance, see TUI Applications (Go).