From 22f05bb132cebd318d5a02420aa9febf724c44b1 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:47:13 -0700 Subject: [PATCH] feat: add session notes CLI Add a notes command to list, read, set, and clear session notes through the existing config file. Document the command and cover text, JSON, and mutation flows with tests.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 14 +++ cmd/dispatch/cli.go | 7 ++ cmd/dispatch/main.go | 7 ++ cmd/dispatch/main_test.go | 17 ++-- cmd/dispatch/notes.go | 190 +++++++++++++++++++++++++++++++++++++ cmd/dispatch/notes_test.go | 140 +++++++++++++++++++++++++++ 6 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 cmd/dispatch/notes.go create mode 100644 cmd/dispatch/notes_test.go diff --git a/README.md b/README.md index eaec257..780b959 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,20 @@ dispatch tags --json Tags come from sessions you have tagged in the TUI. Counts are taken against the current session store, so tags left on sessions that no longer exist are not counted. Use `--json` for scripting. +### Notes + +Manage session notes from the command line without editing `config.json` directly: + +```sh +dispatch notes +dispatch notes --json +dispatch notes get 0a1b2c3d +dispatch notes set 0a1b2c3d "follow up after review" +dispatch notes clear 0a1b2c3d +``` + +`dispatch notes` lists notes for sessions that still exist in the session store. `set`, `get`, and `clear` operate on one session ID and use the same notes shown in the TUI preview. + ### Export Save a full session (metadata and the complete conversation) to a file with `dispatch export `: diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 813dfa9..b07ff36 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -127,6 +127,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "notes": + if nErr := runNotes(os.Stdout, args); nErr != nil { + fmt.Fprintf(os.Stderr, "notes: %v\n", nErr) + return true, cleanup, startupOptions{}, nErr + } + return true, cleanup, startupOptions{}, nil + case "config": if cErr := runConfig(os.Stdout, args); cErr != nil { fmt.Fprintf(os.Stderr, "config: %v\n", cErr) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index e6833e7..bffc143 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -124,6 +124,7 @@ Commands: stats [flags] Print session totals and breakdowns search [query] [flags] Print matching sessions as JSON (no TUI) tags [--json] List tags in use with per-tag session counts + notes [command] List, get, set, or clear session notes config [get|set|list|edit|path] Read or change preferences (see Config commands) export [flags] Export a session as Markdown or JSON @@ -159,6 +160,12 @@ Config commands: config edit Open the config file in your editor config path Print the config file path +Notes commands: + notes [list] [--json] List notes attached to current sessions + notes get Print one session note + notes set Set one session note + notes clear Clear one session note + Export flags: --format md|json Output format (default md) --out Write to a directory instead of the exports folder diff --git a/cmd/dispatch/main_test.go b/cmd/dispatch/main_test.go index 08e19a2..63a3379 100644 --- a/cmd/dispatch/main_test.go +++ b/cmd/dispatch/main_test.go @@ -456,16 +456,21 @@ func TestPrintUsage_Output(t *testing.T) { origStdout := os.Stdout os.Stdout = w + defer func() { os.Stdout = origStdout }() + + readDone := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + readDone <- buf.String() + }() printUsage() - w.Close() + _ = w.Close() os.Stdout = origStdout - - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - - output := buf.String() + output := <-readDone + _ = r.Close() for _, want := range []string{"dispatch", "help", "version", "update", "--demo"} { if !strings.Contains(output, want) { t.Errorf("printUsage() should mention %q, got:\n%s", want, output) diff --git a/cmd/dispatch/notes.go b/cmd/dispatch/notes.go new file mode 100644 index 0000000..3694f85 --- /dev/null +++ b/cmd/dispatch/notes.go @@ -0,0 +1,190 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +// notesListSessionsFn loads sessions for the notes command. It is a package +// variable so tests can substitute a fixed set of sessions. +var notesListSessionsFn = defaultStatsListSessions + +type noteEntry struct { + ID string `json:"id"` + Summary string `json:"summary"` + Note string `json:"note"` +} + +type notesReport struct { + TotalNotes int `json:"total_notes"` + Notes []noteEntry `json:"notes"` +} + +func runNotes(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + rest := args + if len(rest) > 0 { + rest = rest[1:] + } + if len(rest) == 0 || rest[0] == "list" || rest[0] == "--json" { + return runNotesList(w, rest) + } + + switch rest[0] { + case "get": + return runNotesGet(w, rest[1:]) + case "set": + return runNotesSet(w, rest[1:]) + case "clear": + return runNotesClear(w, rest[1:]) + default: + return fmt.Errorf("unknown notes subcommand %q (want list, get, set, or clear)", rest[0]) + } +} + +func runNotesList(w io.Writer, args []string) error { + jsonOut := false + if len(args) > 0 && args[0] == "list" { + args = args[1:] + } + for _, arg := range args { + switch arg { + case "--json": + jsonOut = true + default: + return fmt.Errorf("notes list does not take arguments, got %q", arg) + } + } + + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + sessions, err := notesListSessionsFn(data.FilterOptions{}) + if err != nil { + return err + } + report := buildNotesReport(cfg, sessions) + if jsonOut { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) + } + writeNotesText(w, report) + return nil +} + +func runNotesGet(w io.Writer, args []string) error { + if len(args) != 1 { + return fmt.Errorf("notes get requires a session ID") + } + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + fmt.Fprintln(w, cfg.SessionNotes[args[0]]) + return nil +} + +func runNotesSet(w io.Writer, args []string) error { + if len(args) < 2 { + return fmt.Errorf("notes set requires a session ID and note text") + } + sessionID := args[0] + note := strings.Join(args[1:], " ") + if strings.TrimSpace(sessionID) == "" { + return fmt.Errorf("notes set requires a session ID") + } + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + setSessionNote(cfg, sessionID, note) + if err := configSaveFn(cfg); err != nil { + return err + } + fmt.Fprintf(w, "Set note for %s\n", sessionID) + return nil +} + +func runNotesClear(w io.Writer, args []string) error { + if len(args) != 1 { + return fmt.Errorf("notes clear requires a session ID") + } + sessionID := args[0] + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + setSessionNote(cfg, sessionID, "") + if err := configSaveFn(cfg); err != nil { + return err + } + fmt.Fprintf(w, "Cleared note for %s\n", sessionID) + return nil +} + +func setSessionNote(cfg *config.Config, sessionID, note string) { + if strings.TrimSpace(sessionID) == "" { + return + } + if note == "" { + delete(cfg.SessionNotes, sessionID) + return + } + if cfg.SessionNotes == nil { + cfg.SessionNotes = make(map[string]string) + } + cfg.SessionNotes[sessionID] = note +} + +func buildNotesReport(cfg *config.Config, sessions []data.Session) notesReport { + report := notesReport{Notes: []noteEntry{}} + if cfg == nil || len(cfg.SessionNotes) == 0 { + return report + } + + for _, s := range sessions { + note := cfg.SessionNotes[s.ID] + if note == "" { + continue + } + report.Notes = append(report.Notes, noteEntry{ID: s.ID, Summary: s.Summary, Note: note}) + } + sort.Slice(report.Notes, func(i, j int) bool { + return report.Notes[i].ID < report.Notes[j].ID + }) + report.TotalNotes = len(report.Notes) + return report +} + +func writeNotesText(w io.Writer, report notesReport) { + fmt.Fprintln(w, "Dispatch notes") + fmt.Fprintln(w) + if report.TotalNotes == 0 { + fmt.Fprintln(w, "No notes found.") + return + } + fmt.Fprintf(w, "Notes: %d\n\n", report.TotalNotes) + idWidth := 0 + for _, entry := range report.Notes { + if len(entry.ID) > idWidth { + idWidth = len(entry.ID) + } + } + for _, entry := range report.Notes { + fmt.Fprintf(w, " %-*s %s\n", idWidth, entry.ID, entry.Note) + if entry.Summary != "" { + fmt.Fprintf(w, " %-*s %s\n", idWidth, "", entry.Summary) + } + } +} diff --git a/cmd/dispatch/notes_test.go b/cmd/dispatch/notes_test.go new file mode 100644 index 0000000..1d2c0d6 --- /dev/null +++ b/cmd/dispatch/notes_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +func withNotesList(t *testing.T, fn func(data.FilterOptions) ([]data.Session, error)) { + t.Helper() + prev := notesListSessionsFn + notesListSessionsFn = fn + t.Cleanup(func() { notesListSessionsFn = prev }) +} + +func notedSessions() []data.Session { + return []data.Session{ + {ID: "b", Summary: "Build command"}, + {ID: "a", Summary: "Auth fix"}, + {ID: "c", Summary: "No note"}, + } +} + +func notedConfig() *config.Config { + cfg := config.Default() + cfg.SessionNotes = map[string]string{ + "a": "follow up", + "b": "ready to ship", + "z": "orphan note", + } + return cfg +} + +func TestBuildNotesReport(t *testing.T) { + report := buildNotesReport(notedConfig(), notedSessions()) + if report.TotalNotes != 2 { + t.Fatalf("TotalNotes = %d, want 2", report.TotalNotes) + } + if len(report.Notes) != 2 || report.Notes[0].ID != "a" || report.Notes[1].ID != "b" { + t.Fatalf("notes sorted by ID without orphans = %+v", report.Notes) + } +} + +func TestRunNotesListText(t *testing.T) { + withConfigSeams(t, notedConfig()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes"}); err != nil { + t.Fatalf("runNotes: %v", err) + } + out := buf.String() + for _, want := range []string{"Dispatch notes", "Notes: 2", "a", "follow up", "ready to ship"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "orphan note") { + t.Fatalf("orphan note should not appear:\n%s", out) + } +} + +func TestRunNotesListJSON(t *testing.T) { + withConfigSeams(t, notedConfig()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes", "--json"}); err != nil { + t.Fatalf("runNotes json: %v", err) + } + var report notesReport + if err := json.Unmarshal(buf.Bytes(), &report); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, buf.String()) + } + if report.TotalNotes != 2 || len(report.Notes) != 2 { + t.Fatalf("report = %+v, want 2 notes", report) + } +} + +func TestRunNotesGetSetClear(t *testing.T) { + cfg := withConfigSeams(t, config.Default()) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes", "set", "ses-1", "needs", "review"}); err != nil { + t.Fatalf("set note: %v", err) + } + if cfg.SessionNotes["ses-1"] != "needs review" { + t.Fatalf("stored note = %q", cfg.SessionNotes["ses-1"]) + } + + buf.Reset() + if err := runNotes(&buf, []string{"notes", "get", "ses-1"}); err != nil { + t.Fatalf("get note: %v", err) + } + if strings.TrimSpace(buf.String()) != "needs review" { + t.Fatalf("get output = %q", buf.String()) + } + + buf.Reset() + if err := runNotes(&buf, []string{"notes", "clear", "ses-1"}); err != nil { + t.Fatalf("clear note: %v", err) + } + if _, ok := cfg.SessionNotes["ses-1"]; ok { + t.Fatal("note should be removed") + } +} + +func TestRunNotesErrors(t *testing.T) { + withConfigSeams(t, config.Default()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return nil, errors.New("boom") }) + for _, args := range [][]string{ + {"notes", "bogus"}, + {"notes", "get"}, + {"notes", "set", "ses-1"}, + {"notes", "clear"}, + {"notes", "list", "extra"}, + } { + if err := runNotes(&bytes.Buffer{}, args); err == nil { + t.Fatalf("expected error for args %v", args) + } + } +} + +func TestHandleArgsNotes(t *testing.T) { + withConfigSeams(t, notedConfig()) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + done, _, _, err := handleArgs([]string{"notes"}, &bytes.Buffer{}, nil) + if err != nil { + t.Fatalf("handleArgs notes: %v", err) + } + if !done { + t.Fatal("handleArgs should report done for notes") + } +}