list, search, and tags
A note you can't find again was never really saved. This phase turns til from a log into a memory: list learns flags for "the last few" and "only this tag," search finds that thing you half-remember from March, and tags shows you the shape of what you've been learning. Along the way you'll meet text/tabwriter, the standard library's answer to "how do real CLIs print those neat aligned columns?"
One design rule carries this whole phase: the functions that decide are separate from the functions that print. searchNotes takes notes and returns notes; something else formats them. It reads like fussiness today - in phase 5 it's what makes the logic testable without capturing terminal output.
Table output with tabwriter
Print notes with plain Printf and columns wander - a 2-digit ID pushes its row a character right of a 1-digit one, tags of different lengths shove the text around. Fixed-width format strings like %-20s fix alignment but truncate or overflow the moment real data shows up.
text/tabwriter solves it properly: you write rows with tab characters (\t) between cells, it buffers everything, measures the widest cell in each column, and pads every cell to match when you call Flush. Columns fit the data, whatever the data is.
// printTable renders notes as an aligned table.
The NewWriter arguments in order: the destination, minimum cell width (0), tab width (4, unused with spaces), padding between columns (2 spaces), the padding character, and flags (none). You rarely change these.
⚠️ Gotcha: because tabwriter buffers, forgetting w.Flush() prints nothing at all. No error, no partial output - the rows sit in the buffer and the program exits. If a table-printing function ever goes silently mute, look for the missing Flush before anything else.
list, grown up
list gets its own flag set - the same pattern as add, which is the payoff of NewFlagSet: each subcommand's flags live in their own world.
// filterByTag returns only the notes carrying the given tag.
Order matters here, and it's a decision, not an accident: filter first, then limit. til list -tag go -n 3 means "my last three Go notes" - so we narrow to the tag before taking the tail. Do it the other way around and a tag that hasn't appeared recently returns nothing, which is never what you meant. notes[len(notes)-*n:] takes the last n elements - the most recent, since add always appends.
(runList now takes args - remember to pass os.Args[2:] in the main switch. The full listing below has it.)
search
Search is a filter with a lowercase trick. Lowercasing both the query and the text before comparing is the standard way to get case-insensitive matching - strings.Contains itself is strictly case-sensitive, and a search tool that misses "Defer" when you type "defer" feels broken even when it's technically working.
// searchNotes returns notes whose text contains the query, case-insensitively.
This is a linear scan of every note - and for this tool, that's the right call. Even thousands of notes scan in well under a blink; anything cleverer would be solving a problem you don't have.
Note the "no results" path prints a sentence and returns nil: finding nothing is a valid answer, not an error. Exit codes should mean "the tool failed," not "the world disappointed you."
tags
The tag summary is a counting problem, and counting in Go means a map:
⚠️ Gotcha: the detour through a sorted names slice isn't decoration. Go maps iterate in deliberately randomized order - print the map directly and the tags come out shuffled differently on every run. The runtime does this on purpose, so nobody accidentally depends on an ordering the language never promised. Any time map contents face a human, collect the keys and sort them.
The full program
Here's main.go complete, so you and I are looking at the same file. New since phase 3: printTable, filterByTag, the rewritten runList, searchNotes/runSearch, runTags, two new imports (sort, text/tabwriter), and the expanded usage and main switch. The Note type, parseTags, and all three storage functions are unchanged.
package main
// parseTags turns "Go, CLI" into ["go", "cli"].
// storePath returns the notes file location: ~/.til/notes.json.
// loadNotes reads the store. A missing file is a first run, not an error.
// saveNotes writes the store atomically: temp file first, then rename.
// searchNotes returns notes whose text contains the query, case-insensitively.
// filterByTag returns only the notes carrying the given tag.
// printTable renders notes as an aligned table.
Run it all
Add a third note so the filters have something to disagree about, then put every command through its paces:
$ go run . add -tags git "git stash pops in LIFO order too"
Added note #3
$ go run . list
ID DATE TAGS NOTE
1 2026-07-06 go defer runs in LIFO order
2 2026-07-06 go,cli flag.NewFlagSet gives each subcommand its own flags
3 2026-07-06 git git stash pops in LIFO order too
What just happened: tabwriter measured every column - the widest tag cell is go,cli, so the whole TAGS column sized itself to that - and padded the rest to match. No column widths appear anywhere in our code.
$ go run . search lifo
ID DATE TAGS NOTE
1 2026-07-06 go defer runs in LIFO order
3 2026-07-06 git git stash pops in LIFO order too
$ go run . list -tag go -n 1
ID DATE TAGS NOTE
2 2026-07-06 go,cli flag.NewFlagSet gives each subcommand its own flags
$ go run . tags
TAG NOTES
cli 1
git 1
go 2
What just happened: search lifo matched "LIFO" in two notes despite the case difference. list -tag go -n 1 narrowed to the two Go-tagged notes, then kept the most recent one. And tags counted every tag across all notes and printed them alphabetically - run it ten times, same order ten times, because we sorted.
Finish with a fresh binary, since this is now a tool worth keeping built:
$ go build
$ ./til list -n 2
ID DATE TAGS NOTE
2 2026-07-06 go,cli flag.NewFlagSet gives each subcommand its own flags
3 2026-07-06 git git stash pops in LIFO order too
What you have now
The complete feature set: capture, browse, filter, search, and summarize - with output that looks like a tool someone maintains. What you don't have is proof it keeps working. The logic is now spread across enough functions that a careless edit could quietly break search or corrupt the round-trip to disk. Next phase we pin it down with real tests - and you'll see why every function in this file that makes a decision takes plain data in and returns plain data out.
Three checks on the ideas that carry this phase:
[
{
"q": "How does text/tabwriter get the columns to line up?",
"choices": [
"You configure fixed column widths when creating the writer",
"It uses your terminal's built-in tab stops directly",
"It buffers all rows, measures each column's widest cell, and pads with spaces on Flush"
],
"answer": 2,
"explain": "That buffering is also the gotcha: skip Flush and the buffered rows are simply discarded - the table prints nothing."
},
{
"q": "You run: til list -tag go -n 3. What does the code do?",
"choices": [
"Takes the last 3 notes overall, then keeps the go-tagged ones",
"Selects the go-tagged notes first, then shows the last 3 of those",
"Prints an error because -tag and -n cannot combine"
],
"answer": 1,
"explain": "Filter first, then limit - so the command means 'my last three Go notes'. The other order would often return nothing."
},
{
"q": "Why does runTags copy the map keys into a slice and sort them before printing?",
"choices": [
"Go maps iterate in deliberately randomized order, so unsorted output would shuffle on every run",
"tabwriter requires its rows in alphabetical order",
"Sorting makes the map lookups faster"
],
"answer": 0,
"explain": "The runtime randomizes map iteration on purpose so programs can't depend on an unpromised order. Sort the keys whenever map contents face a human."
}
]
Check your understanding 3 questions
1. How does text/tabwriter get the columns to line up?
2. You run: til list -tag go -n 3. What does the code do?
3. Why does runTags copy the map keys into a slice and sort them before printing?