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(¬e),
+ )).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)", ¬e); 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})
+ }
+}