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", "wont-fix", "won't fix", "won't-fix": 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", "wont-fix", "won't fix", "won't-fix": 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. // LeaseCountdown renders a lease expiry as a short human countdown // ("expires in 12m"). Sub-minute leases show seconds; anything longer // rounds to whole minutes — a lease TTL is coarse coordination state, so // second-level precision past the first minute is noise. func LeaseCountdown(expires time.Time) string { d := time.Until(expires) if d <= 0 { return "expired" } if d < time.Minute { return fmt.Sprintf("expires in %ds", int(d.Seconds())) } return fmt.Sprintf("expires in %dm", int(d.Round(time.Minute).Minutes())) } 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) lease string // rendered lease marker ("" or "» " in cyan) — live execution lease (#1221) 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("* ") } // A leased row gets a glyph on the ref/title cell rather than its // own column: leases are absent on most rows, and a permanent // column would tax every row's title budget for the empty case. // Holder + expiry detail lives on `item show` and in JSON. if item.Lease != nil { r.lease = color.CyanString("» ") } 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.lease, 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) } } // FormatFieldValue renders one field value for a human: a string as itself, a // structured value (a json field's object or array) as compact JSON, anything // else as Go prints it. Go's own `%v` printed a json field as // `[map[name:target …]]`, which is neither JSON nor readable (BUG-3448). // // Decode the field blob with models.DecodeJSONKeepingNumbers so a number // arrives as json.Number and keeps its digits; a float64 rounds anything // above 2^53 (codex r1). A JSON null prints as null, which an empty value // would not say. func FormatFieldValue(v any) string { switch t := v.(type) { case nil: return "null" case string: return t case json.Number: return t.String() case map[string]any, []any: if b, err := json.Marshal(t); err == nil { return string(b) } } return fmt.Sprintf("%v", v) } // summariseFieldValue is FormatFieldValue for a one-line summary: a // structured value is reported by its size, so one json field cannot turn // the line into a paragraph (BUG-3448). func summariseFieldValue(v any) string { switch t := v.(type) { case nil: return "" // an empty field has nothing to summarise case []any: if len(t) == 1 { return "(1 entry)" } return fmt.Sprintf("(%d entries)", len(t)) case map[string]any: if len(t) == 1 { return "(1 key)" } return fmt.Sprintf("(%d keys)", len(t)) } return FormatFieldValue(v) } // 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 := models.DecodeJSONKeepingNumbers([]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 { str := summariseFieldValue(fields[k]) if str == "" { 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) } // Live execution lease (#1221) — who is actively executing this item // right now, distinct from Assigned's longer-term ownership. The // server omits expired leases, so presence here means live. if item.Lease != nil { fmt.Printf("%s %s\n", label.Sprint("Lease: "), fmt.Sprintf("%s (%s)", item.Lease.Holder, LeaseCountdown(item.Lease.ExpiresAt))) } 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. // PrintCommentTable deliberately does NOT render Comment.AgentName (TASK-2760). // The name is self-declared client text; the web surfaces isolate it in its // own element so a bidi control or lookalike cannot redraw the author, // source or timestamp beside it, and a terminal line has no such element — // composing the name into this attribution string is exactly the fragment // shape agentActor.ts forbids. `--format json` carries the field verbatim // for agents and scripts; a terminal-safe rendering (control-character // stripping, a fixed-width column) is IDEA-2766's, not this function's. // CommentBodyForDisplay is a comment's body, or a placeholder for a // tombstone (BUG-3252), whose body is empty and whose replies are still // listed. func CommentBodyForDisplay(c models.Comment) string { if c.Deleted { return "(deleted)" } return c.Body } 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 + ")" } edited := "" if c.IsEdited() { edited = " • edited" } fmt.Printf("💬 %s • %s via %s%s • id %s\n", badge, RelativeTime(c.CreatedAt), c.Source, edited, c.ID) fmt.Println(CommentBodyForDisplay(c)) if i < len(comments)-1 { fmt.Println() } } }