package askcli import ( "fmt" "io" "os" "regexp" "slices" "strings" "codeberg.org/snonux/hexai/internal/termprint" ) // FormatTaskList renders a table of tasks using the terminal's width when possible. func FormatTaskList(tasks []TaskExport, aliases map[string]string) string { return FormatTaskListForWidth(tasks, aliases, 0) } // FormatTaskListForWidth renders a table of tasks constrained to the provided terminal width. func FormatTaskListForWidth(tasks []TaskExport, aliases map[string]string, terminalWidth int) string { widths := taskListWidthsFor(tasks, aliases, terminalWidth) var b strings.Builder writeTaskListHeader(&b, widths) writeTaskListSeparator(&b, widths) for _, t := range tasks { writeTaskListRow(&b, widths, t, aliases) } return b.String() } type taskListWidths struct { Urgency int Priority int ID int Status int Started int Tags int Description int } func taskListWidthsFor(tasks []TaskExport, aliases map[string]string, terminalWidth int) taskListWidths { widths := taskListWidths{ Urgency: len("Urg"), Priority: len("Pri"), ID: len("ID"), Status: len("Status"), Started: len("Started"), Tags: len("Tags"), Description: len("Description"), } longestDescription := widths.Description for _, t := range tasks { widths.Urgency = max(widths.Urgency, len(fmt.Sprintf("%.1f", t.Urgency))) widths.Priority = max(widths.Priority, len(t.Priority)) widths.ID = max(widths.ID, len(displayTaskAlias(t.UUID, aliases))) widths.Status = max(widths.Status, len(t.Status)) widths.Started = max(widths.Started, len(formatTaskStarted(t))) widths.Tags = max(widths.Tags, len(formatTaskTags(t.Tags))) longestDescription = max(longestDescription, len(oneLineDescription(t.Description))) } widths.Description = taskListDescriptionWidth(widths, terminalWidth, longestDescription) return widths } func writeTaskListHeader(b *strings.Builder, widths taskListWidths) { fmt.Fprintf( b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", widths.Urgency, "Urg", widths.Priority, "Pri", widths.ID, "ID", widths.Status, "Status", widths.Started, "Started", widths.Tags, "Tags", widths.Description, "Description", ) } func writeTaskListSeparator(b *strings.Builder, widths taskListWidths) { total := widths.Urgency + widths.Priority + widths.ID + widths.Status + widths.Started + widths.Tags + widths.Description + 18 _, _ = io.WriteString(b, strings.Repeat("-", total)+"\n") } func writeTaskListRow(b *strings.Builder, widths taskListWidths, t TaskExport, aliases map[string]string) { fmt.Fprintf( b, "%-*s | %-*s | %-*s | %-*s | %-*s | %-*s | %-*s\n", widths.Urgency, fmt.Sprintf("%.1f", t.Urgency), widths.Priority, t.Priority, widths.ID, displayTaskAlias(t.UUID, aliases), widths.Status, t.Status, widths.Started, formatTaskStarted(t), widths.Tags, formatTaskTags(t.Tags), widths.Description, formatTaskDescription(t.Description, widths.Description), ) } func formatTaskTags(tags []string) string { if len(tags) == 0 { return "-" } return strings.Join(tags, ",") } func formatTaskDescription(desc string, width int) string { desc = oneLineDescription(desc) if width <= 0 || len(desc) <= width { return desc } if width <= 3 { return desc[:width] } return desc[:width-3] + "..." } // newlineRun matches a run of one or more line breaks together with any inline // whitespace surrounding them. var newlineRun = regexp.MustCompile(`[ \t]*[\r\n]+[ \t]*`) // oneLineDescription collapses any embedded line breaks in a task description // into a single space so multi-line descriptions render on one line and never // break the tab-separated completion output or the task list table. func oneLineDescription(desc string) string { if !strings.ContainsAny(desc, "\r\n") { return desc } return strings.TrimSpace(newlineRun.ReplaceAllString(desc, " ")) } func formatTaskStarted(t TaskExport) string { if t.Start == "" { return "no" } return "yes" } func taskListDescriptionWidth(widths taskListWidths, terminalWidth, longestDescription int) int { if terminalWidth <= 0 { return longestDescription } available := terminalWidth - taskListFixedWidth(widths) if available < len("Description") { return len("Description") } if available < longestDescription { return available } return longestDescription } func taskListFixedWidth(widths taskListWidths) int { return widths.Urgency + widths.Priority + widths.ID + widths.Status + widths.Started + widths.Tags + 18 } func detectTaskListTerminalWidth(w io.Writer) int { return termprint.DetectTerminalWidth(w) } // FormatTaskInfo builds a human-readable block describing a single task. func FormatTaskInfo(t TaskExport, alias string, dependencyAliases map[string]string) string { var b strings.Builder fmt.Fprintf(&b, "ID: %s\n", alias) if debugEnabled() { fmt.Fprintf(&b, "UUID: %s\n", t.UUID) } fmt.Fprintf(&b, "Description: %s\n", t.Description) fmt.Fprintf(&b, "Status: %s\n", t.Status) fmt.Fprintf(&b, "Started: %s\n", formatTaskStarted(t)) fmt.Fprintf(&b, "Priority: %s\n", t.Priority) fmt.Fprintf(&b, "Urgency: %.1f\n", t.Urgency) if len(t.Tags) > 0 { fmt.Fprintf(&b, "Tags: %s\n", strings.Join(t.Tags, ", ")) } if t.Start != "" { fmt.Fprintf(&b, "Start time: %s\n", t.Start) } if len(t.Depends) > 0 { fmt.Fprintf(&b, "Depends: %s\n", formatTaskDependencies(t.Depends, dependencyAliases)) } if len(t.Annotations) > 0 { _, _ = io.WriteString(&b, "Annotations:\n") for _, a := range t.Annotations { fmt.Fprintf(&b, " - %s (added %s)\n", a.Description, a.Entry) } } return b.String() } func debugEnabled() bool { _, ok := os.LookupEnv("HEXAI_DEBUG") return ok } // FormatSuccess returns the success string written to stdout after a task command runs. func FormatSuccess(alias string) string { return fmt.Sprintf("ok %s\n", alias) } // FormatCreatedTask returns the success string written to stdout after ask add creates a task. func FormatCreatedTask(alias string) string { return fmt.Sprintf("created task %s\n", alias) } // FormatError formats error output using the optional task identifier when available. func FormatError(err error, taskID string) string { if taskID != "" { return fmt.Sprintf("error %s: %v\n", taskID, err) } return fmt.Sprintf("error: %v\n", err) } func formatTaskDependencies(depends []string, aliases map[string]string) string { items := make([]string, 0, len(depends)) for _, uuid := range depends { items = append(items, displayTaskAlias(uuid, aliases)) } slices.Sort(items) return strings.Join(items, ", ") }