mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
99ffad1bca
* feat(server): carry the agent name onto comment rows in the timeline (TASK-2760) An agent's comment rendered under the human's name: the name is stamped only on the linked 'commented' activity, which the timeline suppresses because the comment card stands in for it. The comment list queries now LEFT JOIN that activity and surface the name as Comment.AgentName (top-level and nested replies, on the timeline and the comments endpoint alike, through one scan helper), mirrored onto comment-kind TimelineEntry.agent_name to match the actor_name idiom. The web comment card renders it verbatim in an isolated <bdi>, separate from the human author. Store join rather than a handler-side match: the two lists are paginated independently, so a handler join misses at page edges and reads as intermittently-correct attribution. Metadata is parsed in Go, not SQL, to keep the query free of a SQLite/Postgres dialect fork. * test(store): make the activity-window premise strict, not a same-second coin flip (TASK-2760) * fix(server): replies log + link their commented activity so the agent name reaches them (TASK-2760, codex r1) The dedicated reply route wrote no 'commented' activity, and the activity is the only row that carries the writing agent's name — so a reply through the web UI rendered under a generic chip no matter what the client sent. Also rewrites the README + SKILL.md claim that comments never show the name, moves the reply test onto the real route, and asserts order/limit under the join. * fix(store): exclude comment-linked activities in the timeline's activity query (TASK-2760, codex r2) buildTimeline suppressed a comment's linked activity only when that comment was on the same page; the two sources are paginated separately, so an activity could slip through as a standalone 'commented' card. The query now excludes linked rows via NOT EXISTS on idx_comments_activity (both dialects), exact regardless of either window, and the page-local guard is removed rather than kept as a dead one that reads as load-bearing. * fix(store): item-scope the comment/activity link and freeze comment-linked activities against debounce merges (TASK-2760, codex r3) The join keyed on activity id alone while nothing in the schema ties a comment's activity to its item — scope both the LEFT JOIN and the NOT EXISTS to the item. And CreateActivityDebounced could merge a later update into the 'updated' row a comment links to, overlaying its agent stamp and bumping created_at, so two agents under one set of credentials would silently re-attribute an earlier comment; comment-linked rows are no longer merge targets. Prose corrected: the linked row is a 'commented' row OR the 'updated' row of an update that carried the comment. * fix(server,web): keep the read-skew guard beside the SQL exclusion; nowrap on every 24ch agent label (TASK-2760, codex r4) The page-local guard covers a distinct failure from the query exclusion — a comment fetched then hard-deleted before the activity query runs — so it returns with that reason written down. Sweep: of the seven 24ch agent-label rules, three lacked white-space: nowrap (both timeline cards and EpisodeFeed), so a name with spaces wrapped instead of ellipsizing; the other four already had it. Prose nits corrected; the pre-link debounce race on update-with-comment is recorded on BUG-2716 with a pointer in the handler. * docs(server,cli): state the reverse read-skew at the guard and the CLI non-rendering decision (TASK-2760, codex r5) * fix(store): debounce merge refuses a comment-linked row inside the UPDATE itself (TASK-2760, codex r6) The read-then-write left a window in which a comment could link the chosen row before the merge overwrote its agent stamp. The merge is now one statement whose predicate re-checks the link under the row write, and a zero-row merge falls through to a fresh insert. Prose corrected: a later update looks past a frozen row, to an older unlinked one or a fresh one. * fix(store,test): one freeze mechanism, and the window-edge leak proven end to end (TASK-2760, matrix survivors) The debounce SELECT-side exclusion became redundant once the UPDATE's own predicate refused linked rows, and its 'look past to an older unlinked row' semantics folded a later change into an earlier entry — a linked row now simply ends the coalescing run. And the server suite could no longer tell the SQL exclusion from the restored in-memory guard, because it only exercised the same-page case; a test now drives the page-edge case codex found (comment outside its window, activity inside), where only the query can help. * fix(web): drop a duplicate nowrap in EpisodeFeed — the rule already had it (TASK-2760, codex r7) Corrects the round-4 sweep count: of seven 24ch agent-label rules, two lacked white-space: nowrap (both timeline cards), not three.
623 lines
20 KiB
Go
623 lines
20 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.
|
|
// 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.
|
|
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()
|
|
}
|
|
}
|
|
}
|