A practical guide for anyone adding features, fixing bugs, or debugging tasklin.
- Getting started
- Project conventions
- How the TUI works
- Adding a new TUI screen
- Adding a new config field
- Adding a new ticket field
- Working with the store
- Adding a git hook
- Testing
- Debugging
- Common pitfalls
git clone https://github.com/frankcruz/tasklin
cd tasklin
make build # compile
make run-sample # launch against 1 000 pre-loaded tickets
make test # run all testsThe sample project is regenerated automatically on first run. Use make sample CLEAN=1 to wipe and regenerate it.
The TUI (internal/tui/tui.go) never reads or writes files directly. All persistence goes through internal/store:
// correct
m.persist() // calls store.WriteTickets(m.tickets)
// wrong — don't do this
yaml.Marshal(m.tickets)
os.WriteFile(...)The Makefile injects version metadata via -ldflags. Using go build . directly produces a binary with no version info.
The auto-commit script uses process substitution < <(...) which is a bash-only feature. Always pass scripts to exec.Command("bash", "-c", script).
clampScroll() keeps colScroll[colIdx] in sync with the cursor position. Skipping it causes the board to display the wrong window of tickets.
m.statuses is a sorted copy of m.cfg.Statuses. After any status add, rename, reorder, or delete, call:
m.statuses = store.SortedStatuses(m.cfg.Statuses)m.colScroll = make([]int, len(m.statuses))This prevents out-of-bounds panics and stale scroll positions.
Ticket.Status stores the status name, not an ID. Rename requires iterating all tickets:
for k := range m.tickets {
if m.tickets[k].Status == oldName {
m.tickets[k].Status = newName
}
}The TUI uses the Bubble Tea framework, which follows the Elm architecture: Model, Init, Update, View.
The TUI is driven by a viewMode iota. Every keypress flows through Update → handleKey → the handler for the current mode:
Update(tea.KeyMsg)
└── handleKey()
├── viewNew / viewEdit → handleInput()
├── viewMove → handleMove()
├── viewDetail → handleDetail()
├── viewHelp → (inline: return to viewBoard)
├── viewConfig → handleConfig()
├── viewConfigEdit → handleConfig()
├── viewStatuses → handleStatuses()
├── viewStatusEdit → handleStatuses()
└── viewBoard (default) → handleBoard()
View() calls the appropriate view* method for the current mode:
View()
├── viewBoard → viewBoard()
├── viewDetail → viewDetail()
├── viewMove → viewMove()
├── viewNew → viewInputScreen("New ticket")
├── viewEdit → viewInputScreen("Edit ticket")
├── viewHelp → viewHelp()
├── viewConfig → viewConfigScreen()
├── viewConfigEdit → viewConfigScreen() (same view, editing state differs)
├── viewStatuses → viewStatusesScreen()
└── viewStatusEdit → viewStatusesScreen() (same view, editing state differs)
m.inputBuf is a single string field used by all text-input modes (new ticket, edit ticket, config field edit, status name/color edit). Always clear it before entering any input mode:
m.mode = viewNew
m.inputBuf = ""Follow these steps to add a new screen (e.g. viewSearch):
// internal/tui/tui.go — viewMode iota
const (
viewBoard viewMode = iota
viewDetail
// ... existing modes ...
viewSearch // ← add here
)In handleBoard(), add a case for the key that opens your screen:
case "/":
m.mode = viewSearch
m.inputBuf = ""func (m Model) handleSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
m.mode = viewBoard
m.inputBuf = ""
case "enter":
// act on m.inputBuf
case "backspace":
if len(m.inputBuf) > 0 {
_, size := utf8.DecodeLastRuneInString(m.inputBuf)
m.inputBuf = m.inputBuf[:len(m.inputBuf)-size]
}
default:
if msg.Type == tea.KeyRunes {
m.inputBuf += msg.String()
}
}
return m, nil
}case viewSearch:
return m.handleSearch(msg)func (m Model) viewSearch() string {
// use lipgloss for styling
// return a string that fills m.width × m.height
}case viewSearch:
return m.viewSearch()Update viewHelp() to include the new key binding.
- Add the key to the keyboard shortcuts table in
README.md - Add an ASCII art mockup in
docs/ui-reference.md
Config fields are defined in configFields:
var configFields = []cfgFieldDef{
{"Auto-commit on Done", "bool"},
{"Default Done status", "string"},
{"Title limit (0 = unlimited)", "int"},
{"Manage statuses", "statuses"},
// ← add new field here
}// internal/model/model.go
type Config struct {
// ... existing fields ...
MyNewField string `yaml:"my_new_field"`
}func DefaultConfig() Config {
return Config{
// ... existing defaults ...
MyNewField: "default-value",
}
}{"My new field", "string"}, // or "bool" / "int"Find the switch on configFields[m.cfgRowIdx].kind and ensure your new type is handled. For string, int, and bool, the existing cases likely cover it. Verify the index lines up with your field's position in configFields.
type Ticket struct {
// ... existing fields ...
Priority int `yaml:"priority,omitempty"`
}In viewBoard(), the title line is built as:
title := truncate(fmt.Sprintf("[%d] %s", t.ID, t.Title), contentWidth-3)Adjust this to include your field if appropriate.
Add the field to the YAML schema example in docs/data-model.md.
tickets, err := s.ReadTickets()
cfg, err := s.ReadConfig()
deleted, err := s.ReadDeleted()_ = s.WriteTickets(tickets)
_ = s.WriteConfig(cfg)id, err := s.NextID() // reads both tickets.yaml and deleted.yamlgs, err := store.ReadGlobalState()
overrides := store.GetBranchOverrides(gs, projectDir, branch)
tickets = store.ApplyBranchOverrides(tickets, overrides)
// after a move on a non-main branch:
store.SetBranchOverride(gs, projectDir, branch, ticketID, newStatus)
_ = store.WriteGlobalState(gs)Hook scripts are generated in internal/hooks/hooks.go and installed by cmd/init.go.
func MyNewHook(binaryPath string) string {
return fmt.Sprintf(`#!/bin/sh
# my hook logic
%s _transition ...
`, binaryPath)
}hookPath := filepath.Join(gitDir, "hooks", "my-hook-name")
if err := os.WriteFile(hookPath, []byte(hooks.MyNewHook(binaryPath)), 0755); err != nil {
return err
}Tests live next to the packages they test:
internal/store/store_test.go
internal/hooks/hooks_test.go
internal/model/model_test.go
internal/tui/tui_test.go
Run all tests:
make test # with race detector and coverage
make test-ci # CI mode, writes coverage.outPrefer table-driven tests:
func TestNextID(t *testing.T) {
cases := []struct {
name string
active []model.Ticket
deleted []model.Ticket
want int
}{
{"empty store", nil, nil, 1},
{"active only", tickets(1, 2, 3), nil, 4},
{"with deleted", tickets(1, 2), tickets(3, 5), 6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// ...
})
}
}The TUI tests (tui_test.go) create a Model directly and fire synthetic key messages:
m, _ := tui.New(store, dir)
m, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
// assert m.Mode() == viewNew, etc.Use the exported accessor methods (ColIdx(), RowIdx(), Mode()) rather than accessing model fields directly from tests.
tasklin stores everything as human-readable YAML. When something looks wrong in the TUI, check the files directly:
cat .todo/tickets.yaml
cat .todo/config.yaml
cat ~/.config/tasklin/state.yamlmake sample CLEAN=1
make run-sampleThis gives you 1 000 tickets across 4 statuses with a known-good initial state.
The TUI owns the terminal, so fmt.Println won't appear on screen. Write debug output to stderr or a log file:
fmt.Fprintln(os.Stderr, "debug:", someValue)
// or
f, _ := os.OpenFile("/tmp/tasklin-debug.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
fmt.Fprintln(f, "rowIdx:", m.RowIdx())Then run:
make run 2>/tmp/tasklin-debug.log
tail -f /tmp/tasklin-debug.logModel.Mode() (exported) returns the current viewMode. Useful for asserting TUI state in tests and for adding conditional debug output.
| Pitfall | Symptom | Fix |
|---|---|---|
Forgot clampScroll() after rowIdx change |
Board scrolls to wrong position | Call m.clampScroll() after every cursor move |
Forgot SortedStatuses() after mutating statuses |
Columns out of order or stale | Call m.statuses = store.SortedStatuses(m.cfg.Statuses) |
Forgot colScroll resize after status add/delete |
Index out of range panic | Call m.colScroll = make([]int, len(m.statuses)) |
Using sh for the auto-commit script |
syntax error near unexpected token '<' |
Use exec.Command("bash", "-c", script) |
| Status rename without ticket migration | Tickets disappear from board | Iterate m.tickets and update Status strings before persisting |
go build . instead of make build |
Binary has no version/commit info | Always use make build |
Mutating m.cfg.Statuses without saving |
Config changes lost on restart | Call m.store.WriteConfig(m.cfg) after any config mutation |