Files
pad/internal/cli/format.go
T
David Barkhausen b9381bf5f1 feat(cli): markdown output on the remaining list surfaces; broaden ANSI stripping (#1080)
Completes #898 and fixes #1076.

Markdown on the seven surfaces left out of #1070, so `--format markdown` is now
honestly global and the flag help collapses to "table, json, markdown":

- `item comments`, `item deps`, `project activity`, `attachment list`,
  `library list`, `role list`, `workspace members`.

Two of those are not tabular, and markdown follows the terminal shape rather
than forcing a table onto them:

- `item comments` keeps the attribution-line-then-body form, and the body is
  emitted VERBATIM. A comment body is authored as markdown; escaping it would
  turn its lists and code fences into literal text. Only the attribution line,
  which we construct, is sanitized.
- `item deps` keeps its two sections as `## Blocks` / `## Blocked by` lists.
  Colour carried the direction in the terminal (yellow out, red in); headings
  carry it here.

New shared spine: `cli.RenderMarkdownTable(w, headers, rows)`. Every cell is
escaped, and ragged rows are padded or truncated to the header width so a short
or long row can't shift the column count and break the table. Wiring a surface
is now naming columns and mapping rows.

#1076 — ANSI stripping covered only SGR (`ESC[…m`), so non-SGR CSI sequences,
OSC-8 hyperlinks, and stray C0 controls survived, both in the table width maths
and in markdown output whose doc comment promised escape-free text. Replaced
`sgrPattern` with `ansiPattern` + `stripANSI` covering OSC, CSI, two-character
Fe escapes, and stray C0/DEL, with TAB/LF/CR deliberately preserved for callers
that normalize them. `displayWidth` now uses it too: a control sequence is
zero-width, so counting it was a column-alignment bug of the same family.

Tests: 12 stripping cases, 4 table-helper cases (including ragged rows), 4
renderer cases for the two non-tabular surfaces, and the routing test extended
to 8 subtests — one per surface, driven through cobra against an httptest
server. Also covers the two gaps named in #1076: `item starred` and the scoped
`item list <collection>` path. Each new guard was proven by mutating the source
and watching it fail, not just by passing.

Gates: go build ./... PASS; go vet PASS; gofmt clean; golangci-lint 0 issues.
Both touched packages show the same 6+2 pre-existing Windows failures as clean
main under an identical sandboxed run.
2026-08-14 22:35:23 -04:00

615 lines
19 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
"text/tabwriter"
"time"
"unicode/utf8"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/fatih/color"
"golang.org/x/term"
)
// Color definitions for reuse across the CLI.
var (
Bold = color.New(color.Bold)
Dim = color.New(color.Faint)
BoldCyan = color.New(color.Bold, color.FgCyan)
)
// StatusColor returns a *color.Color appropriate for the given status string.
func StatusColor(status string) *color.Color {
s := strings.ToLower(strings.ReplaceAll(status, "_", "-"))
switch s {
case "done", "completed", "fixed", "implemented", "resolved", "accepted":
return color.New(color.FgGreen)
case "in-progress", "in_progress", "exploring", "fixing", "building",
"researching", "planning", "triaged", "in-sprint", "in_sprint", "paused":
return color.New(color.FgYellow)
case "open", "new", "draft", "todo", "planned", "proposed", "raw", "ready":
return color.New(color.FgBlue)
case "cancelled", "rejected", "wontfix":
return color.New(color.FgRed)
case "active", "published":
return color.New(color.FgCyan)
case "archived", "disabled":
return color.New(color.Faint)
default:
return color.New(color.Reset)
}
}
// PriorityColor returns a *color.Color appropriate for the given priority string.
func PriorityColor(priority string) *color.Color {
switch strings.ToLower(priority) {
case "critical", "urgent":
return color.New(color.FgRed, color.Bold)
case "high":
return color.New(color.FgYellow)
case "medium":
return color.New(color.FgWhite)
case "low":
return color.New(color.Faint)
default:
return color.New(color.Reset)
}
}
// ColorizedStatus returns a status icon + status text with appropriate color.
func ColorizedStatus(status string) string {
icon := statusIconChar(status)
c := StatusColor(status)
return c.Sprintf("%s %s", icon, status)
}
// statusIconChar returns just the icon character for a status (no text, no color).
func statusIconChar(status string) string {
s := strings.ToLower(strings.ReplaceAll(status, "_", "-"))
switch s {
case "active", "open":
return "●"
case "draft", "raw", "new", "planned", "proposed", "ready":
return "○"
case "completed", "done", "fixed", "implemented", "resolved", "accepted":
return "✓"
case "archived", "disabled":
return "⊘"
case "in-progress", "exploring", "fixing", "building", "researching",
"planning", "triaged", "in-sprint", "paused":
return "◐"
case "cancelled", "rejected", "wontfix":
return "✗"
default:
return "·"
}
}
// ItemRef returns the reference string (e.g. "TASK-5") for an item, or empty string.
func ItemRef(item models.Item) string {
if item.CollectionPrefix != "" && item.ItemNumber != nil {
return fmt.Sprintf("%s-%d", item.CollectionPrefix, *item.ItemNumber)
}
return ""
}
// StatusIcon returns a colorized status indicator.
func StatusIcon(status string) string {
return ColorizedStatus(status)
}
// RelativeTime returns a human-readable relative time string.
func RelativeTime(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
m := int(d.Minutes())
if m == 1 {
return "1 min ago"
}
return fmt.Sprintf("%d mins ago", m)
case d < 24*time.Hour:
h := int(d.Hours())
if h == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", h)
case d < 7*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
default:
return t.Format("Jan 2, 2006")
}
}
// ansiPattern matches the zero-width escape and control sequences that can
// appear in a string we are about to measure or render. Compiled once at package
// scope so the cost is paid a single time.
//
// It deliberately covers more than SGR (#1076). The original pattern matched
// only `ESC [ … m` — the colour codes fatih/color emits — so a non-SGR CSI
// sequence, an OSC-8 hyperlink, or a stray control byte carried in stored data
// survived "ANSI stripping" and either miscounted a table column or leaked into
// markdown output that promised to be escape-free.
//
// The alternation is ordered: OSC first (it ends at BEL or ST and may contain
// bytes the later alternatives would otherwise claim), then CSI, then the
// two-character Fe escapes, then any remaining stray C0 control or DEL. TAB, LF,
// and CR are excluded — they are meaningful whitespace, and callers that need
// them normalized (SanitizeMarkdownText) handle them explicitly.
var ansiPattern = regexp.MustCompile(
"\x1b\\][^\x07\x1b]*(?:\x07|\x1b\\\\)" + // OSC … BEL | ST (e.g. OSC-8 hyperlinks)
"|\x1b\\[[0-9;?:<=>]*[ -/]*[@-~]" + // CSI … final byte (SGR, cursor moves, erases)
"|\x1b[@-Z\\\\-_]" + // two-character Fe escapes
"|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") // stray C0 controls + DEL (includes bare ESC)
// stripANSI removes escape and control sequences from s, leaving the visible
// text. TAB, LF, and CR are preserved.
func stripANSI(s string) string {
return ansiPattern.ReplaceAllString(s, "")
}
// displayWidth returns the visible column width of s: escape and control
// sequences are stripped, then the remaining runes are counted. Every rune is
// approximated as one column — emoji and other wide runes therefore
// under-count. That is a pre-existing limitation of the CLI's table rendering
// (matching the old tabwriter behavior) and is intentionally out of scope; the
// alternative is a new go-runewidth dependency we don't want to add.
func displayWidth(s string) int {
return utf8.RuneCountInString(stripANSI(s))
}
// padCell left-aligns s in a column of the given visible width by appending
// spaces. If s is already at least that wide the string is returned unchanged
// (never panics on a negative repeat count).
func padCell(s string, width int) string {
gap := width - displayWidth(s)
if gap <= 0 {
return s
}
return s + strings.Repeat(" ", gap)
}
// truncateTitle limits an uncolorized, SGR-free title to maxRunes visible
// columns, appending an ellipsis when it had to cut. Callers strip SGR escapes
// first (see the row-build loop), so every rune counts as exactly one column —
// matching displayWidth — and slicing by runes can never cut mid-escape.
func truncateTitle(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
if maxRunes == 1 {
return "…"
}
return string(runes[:maxRunes-1]) + "…"
}
// itemStatusPriority pulls the "status" and "priority" string values out of an
// item's fields JSON blob. Missing keys, non-string values, or malformed JSON
// yield empty strings.
func itemStatusPriority(fieldsJSON string) (status, priority string) {
if fieldsJSON == "" || fieldsJSON == "{}" || fieldsJSON == "null" {
return "", ""
}
var fields map[string]any
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
return "", ""
}
if s, ok := fields["status"].(string); ok {
status = s
}
if p, ok := fields["priority"].(string); ok {
priority = p
}
return status, priority
}
// PrintItemTable prints items in a width-aware, ANSI-safe table. It probes the
// terminal width (falling back to 120 columns when stdout is piped or the size
// is unavailable) and delegates layout to renderItemTable.
func PrintItemTable(items []models.Item) {
maxWidth := 120
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
maxWidth = w
}
renderItemTable(os.Stdout, items, maxWidth)
}
// renderItemTable is the testable core of PrintItemTable: it lays the item
// list out into maxWidth columns and writes to w. Columns are
// REF/TITLE · STATUS · PRIORITY · COLLECTION · UPDATED. The four short columns
// are sized to their widest cell; the flexible REF/TITLE column absorbs the
// remaining width, truncating ONLY the title (never the ref) so each row fits.
//
// tabwriter is deliberately not used here: it counts ANSI color escape bytes
// as visible width and so misaligns colorized output on a TTY. All width math
// goes through displayWidth, which strips those escapes.
func renderItemTable(w io.Writer, items []models.Item, maxWidth int) {
if len(items) == 0 {
fmt.Fprintln(w, "No items found.")
return
}
headerColor := color.New(color.Faint)
// Pre-render every cell so we can measure visible widths before laying
// out. The REF/TITLE cell is assembled later (it depends on the computed
// title budget), so we stash its raw pieces.
type row struct {
pin string // rendered pin marker ("" or "* " in yellow)
ref string // raw ref, e.g. "TASK-5" — never truncated
title string // raw title — the only thing we truncate
archived bool
status string // rendered short-column cells
priority string
collection string
updated string
}
rows := make([]row, 0, len(items))
for _, item := range items {
var r row
if item.Pinned {
r.pin = color.YellowString("* ")
}
r.ref = ItemRef(item)
// Strip any SGR escapes a title might carry (we apply our own Bold
// styling). This keeps displayWidth == rune count for the title, so
// truncateTitle can slice by runes without ever cutting mid-escape or
// mis-accounting the width budget.
r.title = stripANSI(item.Title)
r.archived = item.DeletedAt != nil
status, priority := itemStatusPriority(item.Fields)
if status != "" {
r.status = ColorizedStatus(status)
} else {
r.status = Dim.Sprint("—")
}
if priority != "" {
r.priority = PriorityColor(priority).Sprint(priority)
} else {
r.priority = Dim.Sprint("—")
}
collLabel := item.CollectionName
if item.CollectionIcon != "" {
collLabel = item.CollectionIcon + " " + collLabel
}
r.collection = collLabel
r.updated = Dim.Sprint(RelativeTime(item.UpdatedAt))
rows = append(rows, r)
}
// Short-column widths: max visible width over the header + every row.
statusW := displayWidth(headerColor.Sprint("STATUS"))
priorityW := displayWidth(headerColor.Sprint("PRIORITY"))
collectionW := displayWidth(headerColor.Sprint("COLLECTION"))
updatedW := displayWidth(headerColor.Sprint("UPDATED"))
for _, r := range rows {
statusW = max(statusW, displayWidth(r.status))
priorityW = max(priorityW, displayWidth(r.priority))
collectionW = max(collectionW, displayWidth(r.collection))
updatedW = max(updatedW, displayWidth(r.updated))
}
const sep = 2 // spaces between columns; 4 gaps between the 5 columns.
titleBudget := maxWidth - statusW - priorityW - collectionW - updatedW - sep*4
// Clamp to a non-negative floor: on a genuinely narrow terminal the four
// fixed columns alone can exceed maxWidth, driving titleBudget negative.
// Per DR-1 the ref and the short columns are never truncated, so such a
// terminal necessarily overflows — we degrade to "ref + as much title as
// fits (often none)" rather than mangle the ref or slice an ANSI escape.
// The floor just keeps the width arithmetic well-defined (padCell already
// tolerates over-wide cells).
if titleBudget < 0 {
titleBudget = 0
}
gap := strings.Repeat(" ", sep)
// The final column is never padded (avoids trailing whitespace); every
// earlier cell is padded to its column width so the columns line up.
writeRow := func(refCell, status, priority, collection, updated string) {
fmt.Fprintln(w, strings.Join([]string{
padCell(refCell, titleBudget),
padCell(status, statusW),
padCell(priority, priorityW),
padCell(collection, collectionW),
updated,
}, gap))
}
// The flexible-column header obeys the same title budget as the data rows
// (a no-op at normal widths where "TITLE" easily fits) so the header can't
// overflow past the data cells in the degraded narrow-terminal regime.
writeRow(
headerColor.Sprint(truncateTitle("TITLE", titleBudget)),
headerColor.Sprint("STATUS"),
headerColor.Sprint("PRIORITY"),
headerColor.Sprint("COLLECTION"),
headerColor.Sprint("UPDATED"),
)
for _, r := range rows {
writeRow(
buildRefCell(r.pin, r.ref, r.title, r.archived, titleBudget),
r.status, r.priority, r.collection, r.updated,
)
}
}
// buildRefCell assembles the flexible REF/TITLE cell within a visible budget.
// Only the title is truncated — the pin marker, ref, and "(archived)" suffix
// are always shown in full — so a narrow terminal degrades to ref + as much
// title as fits (possibly none) rather than ever hiding or slicing the ref.
// Truncation happens on the raw title before colorizing, so an ANSI escape is
// never cut mid-sequence.
func buildRefCell(pin, ref, title string, archived bool, budget int) string {
// Visible width consumed by everything except the title text.
fixed := displayWidth(pin)
refPrefix := ""
if ref != "" {
refPrefix = BoldCyan.Sprint(ref) + " "
fixed += displayWidth(ref) + 2
}
archivedSuffix := ""
if archived {
archivedSuffix = " " + Dim.Sprint("(archived)")
fixed += 2 + len("(archived)")
}
shown := truncateTitle(title, budget-fixed)
cell := pin + refPrefix
if shown != "" {
cell += Bold.Sprint(shown)
}
return cell + archivedSuffix
}
// PrintItemTitles prints just item titles.
func PrintItemTitles(items []models.Item) {
for _, item := range items {
fmt.Println(item.Title)
}
}
// FormatFieldSummary returns a formatted summary of item fields.
// Example output: "status: open | priority: high | category: platform"
func FormatFieldSummary(fieldsJSON string) string {
if fieldsJSON == "" || fieldsJSON == "{}" || fieldsJSON == "null" {
return ""
}
var fields map[string]any
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
return ""
}
if len(fields) == 0 {
return ""
}
// Sort keys for consistent output
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
v := fields[k]
str := fmt.Sprintf("%v", v)
if str == "" || str == "<nil>" {
continue
}
// Colorize well-known fields
switch k {
case "status":
parts = append(parts, fmt.Sprintf("%s: %s", k, StatusColor(str).Sprint(str)))
case "priority":
parts = append(parts, fmt.Sprintf("%s: %s", k, PriorityColor(str).Sprint(str)))
default:
parts = append(parts, fmt.Sprintf("%s: %s", k, str))
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " | ")
}
// PrintJSON prints any value as indented (human-readable) JSON.
func PrintJSON(v interface{}) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(v)
}
// PrintJSONCompact prints any value as compact (no-indent) JSON — a single
// line plus trailing newline. Preferred for machine-consumed payloads where
// pretty-print whitespace is pure overhead (e.g. `pad bootstrap`, whose
// canonical consumer is the /pad agent skill). Human inspection has the
// --format markdown variant.
func PrintJSONCompact(v interface{}) error {
enc := json.NewEncoder(os.Stdout)
return enc.Encode(v)
}
// PrintItemMeta prints item metadata header with colors.
func PrintItemMeta(item *models.Item) {
label := color.New(color.Faint)
// Item ref + Title
ref := ItemRef(*item)
if ref != "" {
fmt.Printf("%s %s\n", BoldCyan.Sprint(ref), Bold.Sprint(item.Title))
} else {
fmt.Printf("%s\n", Bold.Sprint(item.Title))
}
if item.DeletedAt != nil {
fmt.Printf("%s %s\n", label.Sprint("Archived: "),
color.New(color.FgYellow, color.Bold).Sprintf("⚠ yes (soft-deleted %s) — restore before editing", RelativeTime(*item.DeletedAt)))
}
if item.CollectionName != "" {
collLabel := item.CollectionName
if item.CollectionIcon != "" {
collLabel = item.CollectionIcon + " " + collLabel
}
fmt.Printf("%s %s\n", label.Sprint("Collection:"), collLabel)
}
// Parent link
if item.ParentRef != "" {
ref := item.ParentRef
title := item.ParentTitle
parentStr := ref
if title != "" {
parentStr = ref + " " + title
}
fmt.Printf("%s %s\n", label.Sprint("Parent: "), parentStr)
}
// Assignment: user + role
if item.AssignedUserName != "" || item.AgentRoleName != "" {
assignStr := ""
if item.AssignedUserName != "" && item.AgentRoleName != "" {
roleLabel := item.AgentRoleName
if item.AgentRoleIcon != "" {
roleLabel = item.AgentRoleIcon + " " + roleLabel
}
assignStr = fmt.Sprintf("%s (%s)", item.AssignedUserName, roleLabel)
} else if item.AssignedUserName != "" {
assignStr = item.AssignedUserName
} else {
roleLabel := item.AgentRoleName
if item.AgentRoleIcon != "" {
roleLabel = item.AgentRoleIcon + " " + roleLabel
}
assignStr = roleLabel
}
fmt.Printf("%s %s\n", label.Sprint("Assigned: "), assignStr)
}
tags := item.Tags
if tags == "[]" || tags == "" || tags == "null" {
tags = Dim.Sprint("(none)")
}
fmt.Printf("%s %s\n", label.Sprint("Tags: "), tags)
if item.Pinned {
fmt.Printf("%s %s\n", label.Sprint("Pinned: "), color.YellowString("★ yes"))
}
fmt.Printf("%s %s by %s via %s\n",
label.Sprint("Updated: "),
Dim.Sprint(RelativeTime(item.UpdatedAt)),
Dim.Sprint(item.LastModifiedBy),
Dim.Sprint(item.Source),
)
fmt.Println(Dim.Sprint("───────────────────────────────────────────"))
}
// PrintCollectionTable prints collections in a formatted table.
func PrintCollectionTable(collections []models.Collection) {
if len(collections) == 0 {
fmt.Println("No collections found.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "NAME\tSLUG\tITEMS\tDEFAULT\n")
for _, col := range collections {
icon := col.Icon
if icon == "" {
icon = " "
}
def := ""
if col.IsDefault {
def = "yes"
}
fmt.Fprintf(w, "%s %s\t%s\t%d\t%s\n",
icon, col.Name,
col.Slug,
col.ItemCount,
def,
)
}
w.Flush()
}
// PrintLinkTable prints item links in a formatted table.
func PrintLinkTable(links []models.ItemLink) {
if len(links) == 0 {
fmt.Println("No links found.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "TYPE\tSOURCE\tTARGET\tCREATED\n")
for _, link := range links {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
link.LinkType,
link.SourceTitle,
link.TargetTitle,
RelativeTime(link.CreatedAt),
)
}
w.Flush()
}
// PrintActivityTable prints activity entries in a table.
func PrintActivityTable(activities []models.Activity) {
if len(activities) == 0 {
fmt.Println("No recent activity.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, "ACTION\tACTOR\tSOURCE\tWHEN\n")
for _, a := range activities {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
a.Action,
a.Actor,
a.Source,
RelativeTime(a.CreatedAt),
)
}
w.Flush()
}
// PrintCommentTable prints comments in a formatted table.
func PrintCommentTable(comments []models.Comment) {
if len(comments) == 0 {
fmt.Println("No comments.")
return
}
for i, c := range comments {
badge := c.CreatedBy
if c.Author != "" && c.Author != c.CreatedBy {
badge = c.Author + " (" + c.CreatedBy + ")"
}
fmt.Printf("💬 %s • %s via %s\n", badge, RelativeTime(c.CreatedAt), c.Source)
fmt.Println(c.Body)
if i < len(comments)-1 {
fmt.Println()
}
}
}