From 4e1364c5e7ebb1d7305343bea69c3d8bb1f24fc4 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:28:24 -0700 Subject: [PATCH] feat(cli): add Markdown output to dispatch notes Add a --markdown flag to dispatch notes list so notes can be pasted straight into issues, PRs, and docs. It prints a Markdown table with escaped cells and cannot be combined with --json or --csv. Docs and tests updated. Closes #430 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++- cmd/dispatch/main.go | 2 +- cmd/dispatch/man.go | 2 +- cmd/dispatch/notes.go | 28 ++++++++++++++-- cmd/dispatch/notes_test.go | 65 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4700a4ed..490de679 100644 --- a/README.md +++ b/README.md @@ -288,13 +288,15 @@ Manage session notes from the command line without editing `config.json` directl ```sh dispatch notes dispatch notes --json +dispatch notes --csv +dispatch notes --markdown dispatch notes get 0a1b2c3d dispatch notes set 0a1b2c3d "follow up after review" printf 'line one\nline two\n' | dispatch notes set 0a1b2c3d --stdin 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. Use `--stdin` to set a multiline note without shell quoting. +`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. Use `--stdin` to set a multiline note without shell quoting. Add `--json`, `--csv`, or `--markdown` to the list to reformat it for scripts, spreadsheets, or docs. ### Named Views diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 72865d66..994d3f72 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -245,7 +245,7 @@ Config commands: config import [--in F] Read config JSON from stdin or a file Notes commands: - notes [list] [--json] List notes attached to current sessions + notes [list] [--json|--csv|--markdown] List notes attached to current sessions notes get Print one session note notes set Set one session note notes set --stdin Read one session note from stdin diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go index 608698cd..50b963b8 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -110,7 +110,7 @@ var manCommands = []manEntry{ {"alias ", "Set, reassign, clear (--clear), or remove (--remove) a session alias."}, {"views [list] [--json|--csv]", "List configured named views."}, {"views use ", "Set or clear the active named view."}, - {"notes [command]", "List, get, set, or clear session notes."}, + {"notes [list] [--json|--csv|--markdown]", "List, get, set, or clear session notes."}, {"config ", "Read, change, validate, document, or transfer preferences."}, {"export [flags]", "Export a session as Markdown, JSON, HTML, or plain text."}, {"info [--json] [--refs]", "Print a concise session summary."}, diff --git a/cmd/dispatch/notes.go b/cmd/dispatch/notes.go index 8064858b..7a817576 100644 --- a/cmd/dispatch/notes.go +++ b/cmd/dispatch/notes.go @@ -41,7 +41,7 @@ func runNotes(w io.Writer, args []string) error { if len(rest) > 0 { rest = rest[1:] } - if len(rest) == 0 || rest[0] == "list" || rest[0] == "--json" || rest[0] == "--csv" { + if len(rest) == 0 || rest[0] == "list" || rest[0] == "--json" || rest[0] == "--csv" || rest[0] == "--markdown" { return runNotesList(w, rest) } @@ -60,6 +60,7 @@ func runNotes(w io.Writer, args []string) error { func runNotesList(w io.Writer, args []string) error { jsonOut := false csvOut := false + markdownOut := false tag := "" if len(args) > 0 && args[0] == "list" { args = args[1:] @@ -72,6 +73,8 @@ func runNotesList(w io.Writer, args []string) error { jsonOut = true case "--csv": csvOut = true + case "--markdown": + markdownOut = true case "--tag": if hasInline { tag = inline @@ -86,8 +89,8 @@ func runNotesList(w io.Writer, args []string) error { return fmt.Errorf("notes list does not take arguments, got %q", arg) } } - if jsonOut && csvOut { - return fmt.Errorf("--json and --csv cannot be combined") + if (jsonOut && csvOut) || (jsonOut && markdownOut) || (csvOut && markdownOut) { + return fmt.Errorf("--json, --csv, and --markdown cannot be combined") } tag = normalizeNotesTag(tag) @@ -111,6 +114,10 @@ func runNotesList(w io.Writer, args []string) error { if csvOut { return writeNotesCSV(w, report) } + if markdownOut { + writeNotesMarkdown(w, report) + return nil + } writeNotesText(w, report) return nil } @@ -285,3 +292,18 @@ func writeNotesCSV(w io.Writer, report notesReport) error { cw.Flush() return cw.Error() } + +func writeNotesMarkdown(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) + fmt.Fprintln(w, "| ID | Summary | Note |") + fmt.Fprintln(w, "|---|---|---|") + for _, entry := range report.Notes { + fmt.Fprintf(w, "| %s | %s | %s |\n", markdownCell(entry.ID), markdownCell(entry.Summary), markdownCell(entry.Note)) + } +} diff --git a/cmd/dispatch/notes_test.go b/cmd/dispatch/notes_test.go index 1ae13a59..969de29b 100644 --- a/cmd/dispatch/notes_test.go +++ b/cmd/dispatch/notes_test.go @@ -143,6 +143,71 @@ func TestRunNotesListCSV(t *testing.T) { } } +func TestRunNotesListMarkdown(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", "--markdown"}); err != nil { + t.Fatalf("runNotes markdown: %v", err) + } + out := buf.String() + for _, want := range []string{ + "# Dispatch notes", + "Notes: 2", + "| ID | Summary | Note |", + "|---|---|---|", + "| a | Auth fix | follow up |", + "| b | Build command | ready to ship |", + } { + if !strings.Contains(out, want) { + t.Errorf("Markdown output missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "orphan note") { + t.Fatalf("orphan note should not appear:\n%s", out) + } +} + +func TestRunNotesListMarkdownEmpty(t *testing.T) { + cfg := config.Default() + withConfigSeams(t, cfg) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { return notedSessions(), nil }) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes", "list", "--markdown"}); err != nil { + t.Fatalf("runNotes markdown empty: %v", err) + } + out := buf.String() + if !strings.Contains(out, "# Dispatch notes") || !strings.Contains(out, "No notes found.") { + t.Fatalf("empty markdown output = %q", out) + } + if strings.Contains(out, "| ID |") { + t.Fatalf("empty markdown should not print a table:\n%s", out) + } +} + +func TestRunNotesListMarkdownEscapes(t *testing.T) { + cfg := config.Default() + cfg.SessionNotes = map[string]string{"a": "line one\nline two | piped"} + withConfigSeams(t, cfg) + withNotesList(t, func(data.FilterOptions) ([]data.Session, error) { + return []data.Session{{ID: "a", Summary: "Sum | mary"}}, nil + }) + + var buf bytes.Buffer + if err := runNotes(&buf, []string{"notes", "--markdown"}); err != nil { + t.Fatalf("runNotes markdown escapes: %v", err) + } + out := buf.String() + if strings.Contains(out, "line one\nline two") { + t.Fatalf("newline not escaped in note cell:\n%s", out) + } + if !strings.Contains(out, `\|`) { + t.Fatalf("pipe not escaped in cells:\n%s", out) + } +} + func TestRunNotesGetSetClear(t *testing.T) { cfg := withConfigSeams(t, config.Default())