▍ humdrum codex / ticktock v0.0.2

feat(cmd): rename binary to tt, tui→timeline (alias tui), add --version + menu command

c6d037f137e773902f081e11ca6fc7718fa53ea8
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 23:03

parent 646cc4ea

feat(cmd): rename binary to tt, tui→timeline (alias tui), add --version + menu command

Also adds native-Charm 'tt menu' over tock (start/stop/current/continue/note/
tag/report/list/last/watch/analyze) — full parity with the old gum tock-menu,
no gum dependency. Pure tock-argv builders are unit-tested; the huh collection
and stdio-inherited exec are a thin seam.

6 files changed

Makefile +5 −3
@@ -1,7 +1,9 @@
-.PHONY: build test install
+.PHONY: build test install vet
 build:
-	go build -o bin/ticktock ./cmd/ticktock
+	go build -o bin/tt ./cmd/ticktock
 test:
 	go test ./...
+vet:
+	go vet ./...
 install: build
-	ln -sfn "$(PWD)/bin/ticktock" "$(HOME)/.local/bin/ticktock"
+	ln -sfn "$(PWD)/bin/tt" "$(HOME)/.local/bin/tt"
cmd/ticktock/main.go +25 −7
@@ -14,7 +14,11 @@ 	"ticktock/internal/store"
 	"ticktock/internal/tui/day"
 	"ticktock/internal/tui/form"
 	"ticktock/internal/tui/grid"
+	"ticktock/internal/tui/menu"
 )
+
+// version is overridden at release build time via -ldflags -X main.version.
+var version = "dev"
 
 // loadSuggest reads tock history once and shapes it for the form.
 func loadSuggest() form.Suggest {
@@ -24,19 +28,22 @@ }
 
 func newRootCmd() *cobra.Command {
 	root := &cobra.Command{
-		Use:   "ticktock",
-		Short: "Charm-native front-end for the tock time tracker",
+		Use:     "tt",
+		Short:   "Charm-native front-end for the tock time tracker",
+		Version: version,
 	}
 	root.AddCommand(newDayCmd())
-	root.AddCommand(newTuiCmd())
+	root.AddCommand(newTimelineCmd())
+	root.AddCommand(newMenuCmd())
 	return root
 }
 
-func newTuiCmd() *cobra.Command {
+func newTimelineCmd() *cobra.Command {
 	return &cobra.Command{
-		Use:   "tui [date|today|yesterday]",
-		Short: "Spatial day grid; range-select to add entries",
-		Args:  cobra.MaximumNArgs(1),
+		Use:     "timeline [date|today|yesterday]",
+		Aliases: []string{"tui"},
+		Short:   "Spatial day grid; range-select to add entries",
+		Args:    cobra.MaximumNArgs(1),
 		RunE: func(cmd *cobra.Command, args []string) error {
 			arg := ""
 			if len(args) == 1 {
@@ -62,6 +69,17 @@ 			}
 			m := day.New(store.New(store.ExecRunner{}), resolveDate(arg), loadSuggest())
 			_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
 			return err
+		},
+	}
+}
+
+func newMenuCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "menu",
+		Short: "Guided menu over tock (start/stop/note/tag/report/…)",
+		Args:  cobra.NoArgs,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return menu.Run(loadSuggest())
 		},
 	}
 }
cmd/ticktock/main_test.go +37 −19
@@ -2,34 +2,52 @@ package main
 
 import "testing"
 
-func TestRootHasDayCommand(t *testing.T) {
-	root := newRootCmd()
-	var found bool
-	for _, c := range root.Commands() {
-		if c.Name() == "day" {
-			found = true
+func hasCmd(names []string, want string) bool {
+	for _, n := range names {
+		if n == want {
+			return true
 		}
 	}
-	if !found {
-		t.Fatalf("root command is missing the 'day' subcommand")
+	return false
+}
+
+func cmdNames() []string {
+	var out []string
+	for _, c := range newRootCmd().Commands() {
+		out = append(out, c.Name())
 	}
+	return out
 }
 
-func TestTuiCommandRegistered(t *testing.T) {
-	root := newRootCmd()
-	var found bool
-	for _, c := range root.Commands() {
-		if c.Name() == "tui" {
-			found = true
+func TestRootUse(t *testing.T) {
+	if got := newRootCmd().Use; got != "tt" {
+		t.Fatalf("root Use = %q, want tt", got)
+	}
+}
+
+func TestSubcommandsRegistered(t *testing.T) {
+	names := cmdNames()
+	for _, want := range []string{"day", "timeline", "menu"} {
+		if !hasCmd(names, want) {
+			t.Errorf("missing subcommand %q (have %v)", want, names)
 		}
 	}
-	if !found {
-		t.Fatal("expected a `tui` subcommand")
+}
+
+func TestTimelineHasTuiAlias(t *testing.T) {
+	for _, c := range newRootCmd().Commands() {
+		if c.Name() == "timeline" {
+			if !hasCmd(c.Aliases, "tui") {
+				t.Errorf("timeline should alias tui, aliases=%v", c.Aliases)
+			}
+			return
+		}
 	}
+	t.Fatal("no timeline command")
 }
 
-func TestRootUse(t *testing.T) {
-	if got := newRootCmd().Use; got != "ticktock" {
-		t.Fatalf("root Use = %q, want %q", got, "ticktock")
+func TestVersionSet(t *testing.T) {
+	if newRootCmd().Version == "" {
+		t.Error("root command should carry a version")
 	}
 }
internal/tui/menu/args.go +61 −0
@@ -0,0 +1,61 @@
+package menu
+
+import "strings"
+
+// splitTags splits a comma-separated tag string, trimming blanks; nil if empty.
+func splitTags(s string) []string {
+	var out []string
+	for _, p := range strings.Split(s, ",") {
+		if p = strings.TrimSpace(p); p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}
+
+func startArgs(project, desc string, tags []string, note string) []string {
+	a := []string{"start", "-p", project}
+	if desc != "" {
+		a = append(a, "-d", desc)
+	}
+	if note != "" {
+		a = append(a, "--note", note)
+	}
+	for _, t := range tags {
+		a = append(a, "--tag", t)
+	}
+	return a
+}
+
+func stopArgs(note string) []string {
+	a := []string{"stop"}
+	if note != "" {
+		a = append(a, "--note", note)
+	}
+	return a
+}
+
+func noteArgs(text string) []string { return []string{"note", text} }
+
+func tagArgs(tags []string) []string { return append([]string{"tag"}, tags...) }
+
+func continueArgs(num string) []string {
+	a := []string{"continue"}
+	if num != "" {
+		a = append(a, num)
+	}
+	return a
+}
+
+func reportArgs(period, date string) []string {
+	switch period {
+	case "today":
+		return []string{"report", "--today"}
+	case "yesterday":
+		return []string{"report", "--yesterday"}
+	case "date":
+		return []string{"report", "--date", date}
+	default: // "all"
+		return []string{"report"}
+	}
+}
internal/tui/menu/args_test.go +72 −0
@@ -0,0 +1,72 @@
+package menu
+
+import (
+	"strings"
+	"testing"
+)
+
+func joined(a []string) string { return strings.Join(a, " ") }
+
+func TestStartArgs(t *testing.T) {
+	got := startArgs("ARCHER", "Admin", []string{"emails", "sorted"}, "inbox zero")
+	want := "start -p ARCHER -d Admin --note inbox zero --tag emails --tag sorted"
+	if joined(got) != want {
+		t.Fatalf("got %q want %q", joined(got), want)
+	}
+	if joined(startArgs("ARCHER", "", nil, "")) != "start -p ARCHER" {
+		t.Errorf("minimal start wrong: %q", joined(startArgs("ARCHER", "", nil, "")))
+	}
+}
+
+func TestStopArgs(t *testing.T) {
+	if joined(stopArgs("")) != "stop" {
+		t.Errorf("stop no-note wrong")
+	}
+	if joined(stopArgs("done")) != "stop --note done" {
+		t.Errorf("stop note wrong: %q", joined(stopArgs("done")))
+	}
+}
+
+func TestNoteAndTagArgs(t *testing.T) {
+	if joined(noteArgs("hello world")) != "note hello world" {
+		t.Errorf("note wrong")
+	}
+	if joined(tagArgs([]string{"review", "urgent"})) != "tag review urgent" {
+		t.Errorf("tag wrong: %q", joined(tagArgs([]string{"review", "urgent"})))
+	}
+}
+
+func TestContinueArgs(t *testing.T) {
+	if joined(continueArgs("")) != "continue" {
+		t.Errorf("continue no-num wrong")
+	}
+	if joined(continueArgs("3")) != "continue 3" {
+		t.Errorf("continue num wrong")
+	}
+}
+
+func TestReportArgs(t *testing.T) {
+	cases := map[string]string{
+		"today":     "report --today",
+		"yesterday": "report --yesterday",
+		"all":       "report",
+	}
+	for period, want := range cases {
+		if joined(reportArgs(period, "")) != want {
+			t.Errorf("report %s = %q, want %q", period, joined(reportArgs(period, "")), want)
+		}
+	}
+	if joined(reportArgs("date", "2026-07-07")) != "report --date 2026-07-07" {
+		t.Errorf("report date wrong")
+	}
+}
+
+func TestSplitTags(t *testing.T) {
+	got := splitTags(" a , ,b ,c")
+	if strings.Join(got, "|") != "a|b|c" {
+		t.Errorf("splitTags = %v", got)
+	}
+	if splitTags("   ") != nil {
+		t.Errorf("blank should be nil")
+	}
+}
internal/tui/menu/menu.go +126 −0
@@ -0,0 +1,126 @@
+// Package menu is a native-Charm guided menu over the tock CLI. It collects
+// input with huh and shells to tock; it never stores anything.
+package menu
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+
+	"github.com/charmbracelet/huh"
+
+	"ticktock/internal/tui/form"
+)
+
+type action struct{ key, label string }
+
+var actions = []action{
+	{"start", "▶  start      — start an activity"},
+	{"stop", "⏹  stop       — stop the running activity"},
+	{"current", "⏱  current    — what's running now"},
+	{"continue", "🔁 continue   — restart a recent activity"},
+	{"note", "📝 note       — append a note to the last activity"},
+	{"tag", "🏷  tag        — append tags to the last activity"},
+	{"report", "📊 report     — time report"},
+	{"list", "📅 list       — calendar view"},
+	{"last", "🕘 last       — recent unique activities"},
+	{"watch", "⏲  watch      — full-screen stopwatch"},
+	{"analyze", "🧠 analyze    — productivity patterns"},
+}
+
+// runTock execs tock inheriting stdio so full-screen commands work and output
+// prints normally.
+func runTock(args []string) error {
+	cmd := exec.Command("tock", args...)
+	cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+	return cmd.Run()
+}
+
+func input(title string, value *string) error {
+	return huh.NewForm(huh.NewGroup(
+		huh.NewInput().Title(title).Value(value),
+	)).Run()
+}
+
+// Run shows the menu and performs the chosen action.
+func Run(sug form.Suggest) error {
+	var key string
+	opts := make([]huh.Option[string], 0, len(actions))
+	for _, a := range actions {
+		opts = append(opts, huh.NewOption(a.label, a.key))
+	}
+	err := huh.NewForm(huh.NewGroup(
+		huh.NewSelect[string]().Title("tock — pick an action").
+			Options(opts...).Value(&key).Filtering(true),
+	)).Run()
+	if err != nil || key == "" {
+		return nil // cancelled
+	}
+
+	switch key {
+	case "start":
+		var project, desc, tags, note string
+		if err := huh.NewForm(huh.NewGroup(
+			huh.NewInput().Title("project").Value(&project).Suggestions(sug.Projects),
+			huh.NewInput().Title("description").Value(&desc).Suggestions(sug.Descriptions),
+			huh.NewInput().Title("tags (comma-sep, blank=none)").Value(&tags).Suggestions(sug.Tags),
+			huh.NewInput().Title("note (blank=none)").Value(&note),
+		)).Run(); err != nil {
+			return nil
+		}
+		if project == "" {
+			return fmt.Errorf("project is required")
+		}
+		return runTock(startArgs(project, desc, splitTags(tags), note))
+	case "stop":
+		var note string
+		if err := input("closing note (blank=none)", &note); err != nil {
+			return nil
+		}
+		return runTock(stopArgs(note))
+	case "continue":
+		_ = runTock([]string{"last"}) // show recent for reference
+		var num string
+		if err := input("number to continue (blank=last)", &num); err != nil {
+			return nil
+		}
+		return runTock(continueArgs(num))
+	case "note":
+		var text string
+		if err := input("note text", &text); err != nil || text == "" {
+			return nil
+		}
+		return runTock(noteArgs(text))
+	case "tag":
+		var tags string
+		if err := input("tags (comma-sep)", &tags); err != nil {
+			return nil
+		}
+		t := splitTags(tags)
+		if len(t) == 0 {
+			return nil
+		}
+		return runTock(tagArgs(t))
+	case "report":
+		var period string
+		if err := huh.NewForm(huh.NewGroup(
+			huh.NewSelect[string]().Title("which period?").Options(
+				huh.NewOption("today", "today"),
+				huh.NewOption("yesterday", "yesterday"),
+				huh.NewOption("specific date", "date"),
+				huh.NewOption("all", "all"),
+			).Value(&period),
+		)).Run(); err != nil {
+			return nil
+		}
+		date := ""
+		if period == "date" {
+			if err := input("date (YYYY-MM-DD)", &date); err != nil || date == "" {
+				return nil
+			}
+		}
+		return runTock(reportArgs(period, date))
+	default: // current, list, last, watch, analyze — direct passthrough
+		return runTock([]string{key})
+	}
+}