feat(cmd): load tock history once and feed autocomplete into day + tui
9d5829b4396a732c55b1ab7eefc68fe3315f1136
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 22:26
parent d713c8fc
9 files changed
README.md +4 −0
@@ -20,5 +20,9 @@ that range, `enter` edits the logged session under the cursor, `d` deletes
it (with confirm), `n/p/t` change day, `r` reloads, `q` quits.
Reads `~/.config/ticktock/config.json` (`slot_minutes`, `grid_start`,
`grid_end`, `project`) if present.
+- In the add/edit form, **project**, **description**, and **tags** autocomplete
+ from your tock history (type-ahead ghost; Tab/→ to accept). Values stay free
+ text. History comes from the tock sqlite DB when that backend is active,
+ otherwise from `tock last`.
Requires `tock` on PATH.
cmd/ticktock/main.go +10 −2
@@ -9,11 +9,19 @@ tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"ticktock/internal/config"
+ "ticktock/internal/history"
"ticktock/internal/store"
"ticktock/internal/tui/day"
+ "ticktock/internal/tui/form"
"ticktock/internal/tui/grid"
)
+// loadSuggest reads tock history once and shapes it for the form.
+func loadSuggest() form.Suggest {
+ h := history.Load()
+ return form.Suggest{Projects: h.Projects, Descriptions: h.Descriptions, Tags: h.Tags}
+}
+
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "ticktock",
@@ -34,7 +42,7 @@ arg := ""
if len(args) == 1 {
arg = args[0]
}
- m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg))
+ m := grid.New(store.New(store.ExecRunner{}), config.Load(), resolveDate(arg), loadSuggest())
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
},
@@ -51,7 +59,7 @@ arg := ""
if len(args) == 1 {
arg = args[0]
}
- m := day.New(store.New(store.ExecRunner{}), resolveDate(arg))
+ m := day.New(store.New(store.ExecRunner{}), resolveDate(arg), loadSuggest())
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
return err
},
internal/tui/day/edit.go +5 −2
@@ -11,8 +11,11 @@ // formValues aliases the shared form value type so existing day code/tests keep
// their names.
type formValues = form.Values
-// newEditForm builds the edit form bound to fv, prefilled from e.
-func newEditForm(e store.Entry, fv *formValues) *huh.Form { return form.New(e, fv) }
+// newEditForm builds the edit form bound to fv, prefilled from e, with history
+// suggestions attached.
+func newEditForm(e store.Entry, fv *formValues, sug form.Suggest) *huh.Form {
+ return form.New(e, fv, sug)
+}
// buildUpdated turns edited form values into a new Entry on the given date.
func buildUpdated(date string, orig store.Entry, f formValues) (store.Entry, error) {
internal/tui/day/model.go +5 −3
@@ -9,6 +9,7 @@ tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"ticktock/internal/store"
+ "ticktock/internal/tui/form"
)
// Model is the day-view Bubble Tea model.
@@ -29,6 +30,7 @@ fv *formValues
editKey string
editOrig store.Entry
status string
+ sug form.Suggest
}
type entriesMsg struct {
@@ -37,7 +39,7 @@ err error
}
// New builds a day model for the given date (YYYY-MM-DD).
-func New(s store.Store, date string) Model {
+func New(s store.Store, date string, sug form.Suggest) Model {
cols := []table.Column{
{Title: "Key", Width: 13},
{Title: "Time", Width: 15},
@@ -49,7 +51,7 @@ {Title: "Notes", Width: 30},
}
t := table.New(table.WithColumns(cols), table.WithFocused(true), table.WithHeight(14))
t.SetStyles(tableStyles())
- return Model{store: s, date: date, tbl: t}
+ return Model{store: s, date: date, tbl: t, sug: sug}
}
func (m Model) Init() tea.Cmd { return m.load() }
@@ -115,7 +117,7 @@ m.editing = true
m.editKey = e.Key
m.editOrig = e
m.fv = &formValues{}
- m.form = newEditForm(e, m.fv)
+ m.form = newEditForm(e, m.fv, m.sug)
return m, m.form.Init()
}
return m, nil
internal/tui/day/model_test.go +5 −4
@@ -9,6 +9,7 @@ tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/exp/teatest"
"ticktock/internal/store"
+ "ticktock/internal/tui/form"
)
type fakeStore struct {
@@ -91,7 +92,7 @@ }
func TestDayRendersTableAndTotals(t *testing.T) {
fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
- tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+ tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}),
teatest.WithInitialTermSize(140, 24))
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("ARCHER")) &&
@@ -134,7 +135,7 @@
// loaded builds a Model with entries already loaded and the cursor at row i.
func loaded(entries []store.Entry, i int) Model {
fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": entries}}
- m := New(fs, "2026-07-07")
+ m := New(fs, "2026-07-07", form.Suggest{})
tm, _ := m.Update(entriesMsg{entries: entries})
m = tm.(Model)
m.tbl.SetCursor(i)
@@ -194,7 +195,7 @@ // SafeReplace. Guards the value-receiver copy bug where huh wrote edits into a
// stale Model copy and applyEdit re-saved the unchanged original.
func TestEditFieldReachesStore(t *testing.T) {
fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
- tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+ tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}),
teatest.WithInitialTermSize(140, 24))
// Wait for the table, then open the editor on row 0 (2026-07-07-01).
@@ -239,7 +240,7 @@ // TestCtrlSSubmitsFromAnyField retypes the start field then submits with Ctrl+S
// WITHOUT tabbing to the last field, and asserts the typed value is stored.
func TestCtrlSSubmitsFromAnyField(t *testing.T) {
fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
- tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+ tm := teatest.NewTestModel(t, New(fs, "2026-07-07", form.Suggest{}),
teatest.WithInitialTermSize(140, 24))
teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
return bytes.Contains(b, []byte("ARCHER"))
internal/tui/form/form.go +18 −16
@@ -46,19 +46,14 @@ Tags: SplitTags(v.Tags), Notes: v.Note,
}, nil
}
-func inputs(v *Values) []huh.Field {
- return []huh.Field{
- huh.NewInput().Title("start (HH:MM)").Value(&v.Start),
- huh.NewInput().Title("end (HH:MM)").Value(&v.End),
- huh.NewInput().Title("project").Value(&v.Project),
- huh.NewInput().Title("description").Value(&v.Desc),
- huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags),
- huh.NewInput().Title("note").Value(&v.Note),
- }
+// Suggest carries autocomplete candidates for the form's inputs.
+type Suggest struct {
+ Projects, Descriptions, Tags []string
}
-// New prefills *v from e and returns the edit form bound to v.
-func New(e store.Entry, v *Values) *huh.Form {
+// New prefills *v from e and returns the edit form bound to v, with history
+// suggestions attached to project/description/tags.
+func New(e store.Entry, v *Values, sug Suggest) *huh.Form {
v.Start = e.Start.Format("15:04")
if e.Running() {
v.End = ""
@@ -67,18 +62,25 @@ v.End = e.End.Format("15:04")
}
v.Project, v.Desc = e.Project, e.Description
v.Tags, v.Note = strings.Join(e.Tags, ", "), e.Notes
- return huh.NewForm(huh.NewGroup(inputs(v)...))
+ return huh.NewForm(huh.NewGroup(
+ huh.NewInput().Title("start (HH:MM)").Value(&v.Start),
+ huh.NewInput().Title("end (HH:MM)").Value(&v.End),
+ huh.NewInput().Title("project").Value(&v.Project).Suggestions(sug.Projects),
+ huh.NewInput().Title("description").Value(&v.Desc).Suggestions(sug.Descriptions),
+ huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags).Suggestions(sug.Tags),
+ huh.NewInput().Title("note").Value(&v.Note),
+ ))
}
// AddForm builds a form for a new entry whose start/end are already fixed by a
// grid selection. Start/end are prefilled and placed last so the cursor lands
// on project, yet the times remain editable if the selection needs tweaking.
-func AddForm(start, end, project string, v *Values) *huh.Form {
+func AddForm(start, end, project string, v *Values, sug Suggest) *huh.Form {
*v = Values{Start: start, End: end, Project: project}
return huh.NewForm(huh.NewGroup(
- huh.NewInput().Title("project").Value(&v.Project),
- huh.NewInput().Title("description").Value(&v.Desc),
- huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags),
+ huh.NewInput().Title("project").Value(&v.Project).Suggestions(sug.Projects),
+ huh.NewInput().Title("description").Value(&v.Desc).Suggestions(sug.Descriptions),
+ huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags).Suggestions(sug.Tags),
huh.NewInput().Title("note").Value(&v.Note),
huh.NewInput().Title("start (HH:MM)").Value(&v.Start),
huh.NewInput().Title("end (HH:MM)").Value(&v.End),
internal/tui/form/form_test.go +1 −1
@@ -45,7 +45,7 @@ }
func TestAddFormPrefillsStartEndProject(t *testing.T) {
var v Values
- _ = AddForm("10:00", "10:30", "ARCHER", &v)
+ _ = AddForm("10:00", "10:30", "ARCHER", &v, Suggest{})
if v.Start != "10:00" || v.End != "10:30" || v.Project != "ARCHER" {
t.Errorf("add-form prefill wrong: %+v", v)
}
internal/tui/grid/model.go +6 −4
@@ -47,6 +47,8 @@
confirmDelete bool
confirmKey string
+ sug form.Suggest
+
status string
err error
quit bool
@@ -58,8 +60,8 @@ err error
}
// New builds a grid model for date (YYYY-MM-DD).
-func New(s store.Store, cfg config.Config, date string) Model {
- return Model{store: s, cfg: cfg, date: date, col: ColActivity}
+func New(s store.Store, cfg config.Config, date string, sug form.Suggest) Model {
+ return Model{store: s, cfg: cfg, date: date, col: ColActivity, sug: sug}
}
func (m Model) Init() tea.Cmd { return m.load() }
@@ -216,7 +218,7 @@ m.anchor = nil
start, end := m.rangeTimes(lo, hi)
m.editing, m.adding = true, true
m.fv = &form.Values{}
- m.form = form.AddForm(start, end, m.cfg.Project, m.fv)
+ m.form = form.AddForm(start, end, m.cfg.Project, m.fv, m.sug)
return m, m.form.Init()
}
@@ -253,7 +255,7 @@ }
m.editing, m.adding = true, false
m.editKey, m.editOrig = e.Key, e
m.fv = &form.Values{}
- m.form = form.New(e, m.fv)
+ m.form = form.New(e, m.fv, m.sug)
return m, m.form.Init()
}
internal/tui/grid/model_test.go +2 −1
@@ -9,6 +9,7 @@ tea "github.com/charmbracelet/bubbletea"
"ticktock/internal/config"
"ticktock/internal/store"
+ "ticktock/internal/tui/form"
)
type fakeStore struct {
@@ -35,7 +36,7 @@ func special(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} }
// loaded returns a model that has already ingested its entries.
func loaded(f *fakeStore) Model {
- m := New(f, cfg(), "2026-07-07")
+ m := New(f, cfg(), "2026-07-07", form.Suggest{})
nm, _ := m.Update(entriesMsg{entries: f.entries})
return nm.(Model)
}