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
16 changes: 11 additions & 5 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
}
return true, cleanup, startupOptions{}, nil

case "info":
if iErr := runInfo(os.Stdout, args); iErr != nil {
fmt.Fprintf(os.Stderr, "info: %v\n", iErr)
return true, cleanup, startupOptions{}, iErr
}
return true, cleanup, startupOptions{}, nil

case "man":
if mErr := runMan(os.Stdout); mErr != nil {
fmt.Fprintf(os.Stderr, "man: %v\n", mErr)
Expand All @@ -203,7 +210,6 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
// dynamic candidates. Deliberately omitted from help and usage.
runComplete(os.Stdout, args)
return true, cleanup, startupOptions{}, nil

case "--demo":
c, demoErr := setupDemo()
if demoErr != nil {
Expand Down Expand Up @@ -352,7 +358,7 @@ const bashCompletionScript = `# bash completion for dispatch
_dispatch_completion() {
local cur="${COMP_WORDS[COMP_CWORD]}"
local bin="${COMP_WORDS[0]}"
local commands="help version open new doctor update completion stats search tags config export man"
local commands="help version open new doctor update completion stats search tags config export info man"
local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query"

if [[ "${COMP_CWORD}" -eq 1 ]]; then
Expand Down Expand Up @@ -386,7 +392,7 @@ const zshCompletionScript = `#compdef dispatch disp
_dispatch_completion() {
local -a commands flags configsubs shells aliases configkeys
local bin=${words[1]}
commands=(help version open new doctor update completion stats search tags config export man)
commands=(help version open new doctor update completion stats search tags config export info man)
configsubs=(list get set unset edit path)
flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query)

Expand Down Expand Up @@ -438,7 +444,7 @@ 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 tags config export man'
complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export info man'
complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query'
complete -c $bin -n '__dispatch_after completion' -a "($bin __complete shells)"
complete -c $bin -n '__dispatch_after open' -a "($bin __complete aliases)"
Expand All @@ -447,7 +453,7 @@ end
`

const powershellCompletionScript = `# PowerShell completion for dispatch
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'man')
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'info', 'man')
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query')
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path')

Expand Down
181 changes: 181 additions & 0 deletions cmd/dispatch/info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package main

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

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

// infoGetDetailFn loads a session's aggregated detail by ID. It is a seam so
// tests can substitute the store lookup. It reuses the export loader, which
// returns (nil, nil) when no session with the given ID exists.
var infoGetDetailFn = defaultExportGetDetail

// sessionInfo is the concise, count-oriented view of a session emitted by the
// info command. Unlike export, it summarizes the conversation with counts
// rather than including every turn.
type sessionInfo struct {
ID string `json:"id"`
Summary string `json:"summary,omitempty"`
Repository string `json:"repository,omitempty"`
Branch string `json:"branch,omitempty"`
Directory string `json:"directory,omitempty"`
HostType string `json:"host_type,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
LastActiveAt string `json:"last_active_at,omitempty"`
Turns int `json:"turns"`
Files int `json:"files"`
Checkpoints int `json:"checkpoints"`
Commits int `json:"commits"`
PRs int `json:"prs"`
Issues int `json:"issues"`
}

// runInfo prints a concise summary of a single session as text, or as JSON
// with --json. args is the full argument slice with args[0] == "info".
func runInfo(w io.Writer, args []string) error {
if w == nil {
w = io.Discard
}

id, asJSON, err := parseInfoArgs(args)
if err != nil {
return err
}

detail, err := infoGetDetailFn(id)
if err != nil {
return err
}
if detail == nil {
return fmt.Errorf("session %q not found", id)
}

info := buildSessionInfo(detail)
if asJSON {
return writeInfoJSON(w, info)
}
return writeInfoText(w, info)
}

// parseInfoArgs extracts the session ID and the --json flag from the info
// subcommand arguments. args[0] is expected to be "info".
func parseInfoArgs(args []string) (id string, asJSON bool, err error) {
rest := args
if len(rest) > 0 {
rest = rest[1:] // drop the "info" token
}

var positionals []string
for _, arg := range rest {
switch {
case arg == "--json":
asJSON = true
case strings.HasPrefix(arg, "-"):
return "", false, fmt.Errorf("unknown flag: %s", arg)
default:
positionals = append(positionals, arg)
}
}

switch len(positionals) {
case 0:
return "", false, errors.New("info requires a session ID")
case 1:
return positionals[0], asJSON, nil
default:
return "", false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals))
}
}

// buildSessionInfo reduces a full SessionDetail to the concise info view,
// counting external references by type.
func buildSessionInfo(detail *data.SessionDetail) sessionInfo {
s := detail.Session
info := sessionInfo{
ID: s.ID,
Summary: s.Summary,
Repository: s.Repository,
Branch: s.Branch,
Directory: s.Cwd,
HostType: s.HostType,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
LastActiveAt: s.LastActiveAt,
Turns: s.TurnCount,
Files: s.FileCount,
Checkpoints: len(detail.Checkpoints),
}
for _, ref := range detail.Refs {
switch strings.ToLower(ref.RefType) {
case "commit":
info.Commits++
case "pr":
info.PRs++
case "issue":
info.Issues++
}
}
return info
}

// writeInfoJSON encodes info as indented JSON.
func writeInfoJSON(w io.Writer, info sessionInfo) error {
b, err := json.MarshalIndent(info, "", " ")
if err != nil {
return fmt.Errorf("encoding session info as JSON: %w", err)
}
_, err = fmt.Fprintln(w, string(b))
return err
}

// writeInfoText prints a human-readable summary, one field per line. Optional
// string fields are omitted when empty; counts always print.
func writeInfoText(w io.Writer, info sessionInfo) error {
var b strings.Builder
fmt.Fprintf(&b, "Session %s\n", info.ID)

writeField := func(label, value string) {
if value != "" {
fmt.Fprintf(&b, " %-12s %s\n", label, value)
}
}
writeField("Summary:", info.Summary)
writeField("Repository:", info.Repository)
writeField("Branch:", info.Branch)
writeField("Directory:", info.Directory)
writeField("Host:", info.HostType)
writeField("Created:", info.CreatedAt)
writeField("Updated:", info.UpdatedAt)
writeField("Last active:", info.LastActiveAt)

fmt.Fprintf(&b, " %-12s %d\n", "Turns:", info.Turns)
fmt.Fprintf(&b, " %-12s %d\n", "Files:", info.Files)
fmt.Fprintf(&b, " %-12s %d\n", "Checkpoints:", info.Checkpoints)
fmt.Fprintf(&b, " %-12s %s\n", "Refs:", formatRefCounts(info))

_, err := io.WriteString(w, b.String())
return err
}

// formatRefCounts renders the reference breakdown as a compact summary such as
// "3 commits, 1 pr, 0 issues", using singular labels when a count is one.
func formatRefCounts(info sessionInfo) string {
return fmt.Sprintf("%s, %s, %s",
pluralize(info.Commits, "commit", "commits"),
pluralize(info.PRs, "pr", "prs"),
pluralize(info.Issues, "issue", "issues"))
}

// pluralize renders n with a singular label when n is exactly one.
func pluralize(n int, singular, plural string) string {
if n == 1 {
return "1 " + singular
}
return fmt.Sprintf("%d %s", n, plural)
}
Loading