Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ Flags:
- `--calendar` adds a GitHub-style activity heatmap of sessions per day, with an intensity legend. It honors the `--repo`, `--branch`, `--since`, and `--until` filters.
- `--repo`, `--branch`, `--folder`, `--since`, and `--until` narrow which sessions are counted.

### Tags

Run `dispatch tags` to list every tag in use with the number of sessions that carry it, ordered by count.

```sh
dispatch tags
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.

### Export

Save a full session (metadata and the complete conversation) to a file with `dispatch export <id>`:
Expand Down
15 changes: 11 additions & 4 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
}
return true, cleanup, "", nil

case "tags":
if tErr := runTags(os.Stdout, args); tErr != nil {
fmt.Fprintf(os.Stderr, "tags: %v\n", tErr)
return true, cleanup, "", tErr
}
return true, cleanup, "", nil

case "config":
if cErr := runConfig(os.Stdout, args); cErr != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", cErr)
Expand Down Expand Up @@ -211,7 +218,7 @@ func runCompletion(w io.Writer, shell string) error {
const bashCompletionScript = `# bash completion for dispatch
_dispatch_completion() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local commands="help version open new doctor update completion stats search config export"
local commands="help version open new doctor update completion stats search tags config export"
local flags="-h --help -v --version --demo --clear-cache --reindex"

if [[ "${COMP_CWORD}" -eq 1 ]]; then
Expand All @@ -235,7 +242,7 @@ complete -F _dispatch_completion dispatch disp
const zshCompletionScript = `#compdef dispatch disp
_dispatch_completion() {
local -a commands shells flags configsubs
commands=(help version open new doctor update completion stats search config export)
commands=(help version open new doctor update completion stats search tags config export)
shells=(bash zsh fish powershell)
configsubs=(list get set edit path)
flags=(-h --help -v --version --demo --clear-cache --reindex)
Expand Down Expand Up @@ -271,14 +278,14 @@ end

for bin in dispatch disp
complete -c $bin -f
complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search config export'
complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export'
complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex'
complete -c $bin -n '__dispatch_using_completion' -a 'bash zsh fish powershell'
end
`

const powershellCompletionScript = `# PowerShell completion for dispatch
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'config', 'export')
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export')
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex')
$script:DispatchShells = @('bash', 'zsh', 'fish', 'powershell')
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'edit', 'path')
Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Commands:
doctor [--json] Print environment diagnostics (--json for machine-readable output)
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
config [get|set|list|edit|path]
Read or change preferences (see Config commands)
export <id> [flags] Export a session as Markdown or JSON
Expand Down
159 changes: 159 additions & 0 deletions cmd/dispatch/tags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package main

import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"

"github.com/jongio/dispatch/internal/data"
)

// tagsListSessionsFn loads sessions for the tags command. It is a package
// variable so tests can substitute a fixed set of sessions, matching the seam
// pattern used elsewhere in this package (see stats.go and cli.go).
var tagsListSessionsFn = defaultStatsListSessions

// tagsOptions holds the parsed flags for the tags command.
type tagsOptions struct {
json bool
}

// tagCount is one tag and the number of sessions that carry it.
type tagCount struct {
Tag string `json:"tag"`
Count int `json:"count"`
}

// tagsReport is the aggregate tag summary produced by the tags command.
type tagsReport struct {
TotalTags int `json:"total_tags"`
TaggedSessions int `json:"tagged_sessions"`
Tags []tagCount `json:"tags"`
}

// runTags prints the tags in use with a per-tag session count. args is the
// full argument slice with args[0] == "tags".
func runTags(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

opts, err := parseTagsArgs(args)
if err != nil {
return err
}

cfg, err := configLoadFn()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

sessions, err := tagsListSessionsFn(data.FilterOptions{})
if err != nil {
return err
}

report := buildTagsReport(cfg.SessionTags, sessions)
if opts.json {
return writeTagsJSON(w, report)
}
writeTagsText(w, report)
return nil
}

// parseTagsArgs reads the tags subcommand flags. args[0] is expected to be
// "tags". It rejects positional arguments and unknown flags.
func parseTagsArgs(args []string) (tagsOptions, error) {
var opts tagsOptions

rest := args
if len(rest) > 0 {
rest = rest[1:] // drop the "tags" token
}

for _, arg := range rest {
switch {
case arg == "--json":
opts.json = true
case strings.HasPrefix(arg, "-"):
return tagsOptions{}, fmt.Errorf("unknown flag: %s", arg)
default:
return tagsOptions{}, fmt.Errorf("tags does not take positional arguments, got %q", arg)
}
}

return opts, nil
}

// buildTagsReport counts how many of the given sessions carry each tag. Tags on
// sessions that are not present (for example, sessions deleted since they were
// tagged) are ignored so the counts reflect the current session store.
func buildTagsReport(sessionTags map[string][]string, sessions []data.Session) tagsReport {
report := tagsReport{Tags: []tagCount{}}

counts := map[string]int{}
for _, s := range sessions {
tags := sessionTags[s.ID]
if len(tags) == 0 {
continue
}
report.TaggedSessions++
for _, tag := range tags {
counts[tag]++
}
}

report.TotalTags = len(counts)
report.Tags = sortedTagCounts(counts)
return report
}

// sortedTagCounts converts a tag/count map into a slice ordered by count
// descending, then tag ascending for stable output.
func sortedTagCounts(counts map[string]int) []tagCount {
entries := make([]tagCount, 0, len(counts))
for tag, count := range counts {
entries = append(entries, tagCount{Tag: tag, Count: count})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Count != entries[j].Count {
return entries[i].Count > entries[j].Count
}
return entries[i].Tag < entries[j].Tag
})
return entries
}

// writeTagsJSON prints the report as a single JSON object.
func writeTagsJSON(w io.Writer, report tagsReport) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(report)
}

// writeTagsText prints the report in a plain, human-readable layout.
func writeTagsText(w io.Writer, report tagsReport) {
fmt.Fprintln(w, "Dispatch tags")
fmt.Fprintln(w)

if report.TotalTags == 0 {
fmt.Fprintln(w, "No tags found.")
return
}

fmt.Fprintf(w, "Tags: %d\n", report.TotalTags)
fmt.Fprintf(w, "Sessions: %d tagged\n", report.TaggedSessions)
fmt.Fprintln(w)

width := 0
for _, e := range report.Tags {
if len(e.Tag) > width {
width = len(e.Tag)
}
}
for _, e := range report.Tags {
fmt.Fprintf(w, " %-*s %d\n", width, e.Tag, e.Count)
}
}
Loading