1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package cli
  6
  7import (
  8	"context"
  9	"errors"
 10	"sort"
 11	"strings"
 12
 13	"git.secluded.site/np/internal/goal"
 14	"git.secluded.site/np/internal/task"
 15)
 16
 17var statusIcons = map[task.Status]string{
 18	task.StatusPending:    "☐",
 19	task.StatusInProgress: "⟳",
 20	task.StatusCompleted:  "☑",
 21	task.StatusFailed:     "☒",
 22	task.StatusCancelled:  "⊗",
 23}
 24
 25var legendBaseOrder = []task.Status{
 26	task.StatusPending,
 27	task.StatusInProgress,
 28	task.StatusCompleted,
 29}
 30
 31var legendOptionalOrder = []task.Status{
 32	task.StatusFailed,
 33	task.StatusCancelled,
 34}
 35
 36// PlanState captures goal/tasks for rendering.
 37type PlanState struct {
 38	Goal  *goal.Document
 39	Tasks []task.Task
 40}
 41
 42// BuildPlanState aggregates goal and tasks for sid using env.
 43func BuildPlanState(ctx context.Context, env *Environment, sid string) (PlanState, error) {
 44	if env == nil {
 45		return PlanState{}, errors.New("cli: environment not initialised")
 46	}
 47
 48	tasks, err := env.LoadTasks(ctx, sid)
 49	if err != nil {
 50		return PlanState{}, err
 51	}
 52
 53	state := PlanState{
 54		Tasks: tasks,
 55	}
 56
 57	goalDoc, ok, err := env.LoadGoal(ctx, sid)
 58	if err != nil {
 59		return PlanState{}, err
 60	}
 61	if ok {
 62		state.Goal = &goalDoc
 63	}
 64
 65	return state, nil
 66}
 67
 68// RenderPlan produces the textual plan layout consumed by LLM agents.
 69func RenderPlan(state PlanState) string {
 70	var b strings.Builder
 71
 72	if state.Goal != nil {
 73		b.WriteString(strings.TrimSpace(state.Goal.Title))
 74		b.WriteString("\n")
 75
 76		if desc := strings.TrimSpace(state.Goal.Description); desc != "" {
 77			b.WriteString("\n")
 78			writeIndentedBlock(&b, desc, "")
 79			b.WriteString("\n")
 80		} else {
 81			b.WriteString("\n")
 82		}
 83	} else {
 84		b.WriteString("No goal set yet\n\n")
 85	}
 86
 87	legend := buildLegend(state.Tasks)
 88	if legend != "" {
 89		b.WriteString("Legend: ")
 90		b.WriteString(legend)
 91		b.WriteString("\n")
 92	}
 93
 94	if len(state.Tasks) == 0 {
 95		b.WriteString("No tasks yet.\n")
 96		return b.String()
 97	}
 98
 99	sorted := make([]task.Task, len(state.Tasks))
100	copy(sorted, state.Tasks)
101	sort.Slice(sorted, func(i, j int) bool {
102		if sorted[i].CreatedSeq != sorted[j].CreatedSeq {
103			return sorted[i].CreatedSeq < sorted[j].CreatedSeq
104		}
105		if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
106			return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
107		}
108		return sorted[i].ID < sorted[j].ID
109	})
110
111	for _, t := range sorted {
112		icon := statusIcons[t.Status]
113		if icon == "" {
114			icon = "?"
115		}
116		b.WriteString(icon)
117		b.WriteString(" ")
118		b.WriteString(strings.TrimSpace(t.Title))
119		b.WriteString(" [")
120		b.WriteString(strings.TrimSpace(t.ID))
121		b.WriteString("]\n")
122
123		if desc := strings.TrimSpace(t.Description); desc != "" {
124			writeIndentedBlock(&b, desc, "  ")
125		}
126	}
127
128	return b.String()
129}
130
131func buildLegend(tasks []task.Task) string {
132	present := map[task.Status]bool{}
133	for _, t := range tasks {
134		present[t.Status] = true
135	}
136
137	var parts []string
138	for _, status := range legendBaseOrder {
139		parts = append(parts, legendEntry(status))
140	}
141	for _, status := range legendOptionalOrder {
142		if present[status] {
143			parts = append(parts, legendEntry(status))
144		}
145	}
146	return strings.Join(parts, "  ")
147}
148
149func legendEntry(status task.Status) string {
150	icon := statusIcons[status]
151	if icon == "" {
152		icon = "?"
153	}
154	return icon + " " + statusLabel(status)
155}
156
157func writeIndentedBlock(b *strings.Builder, text string, prefix string) {
158	lines := strings.Split(text, "\n")
159	for _, line := range lines {
160		b.WriteString(prefix)
161		b.WriteString(strings.TrimSpace(line))
162		b.WriteString("\n")
163	}
164}
165
166func statusLabel(status task.Status) string {
167	return strings.ReplaceAll(status.String(), "_", " ")
168}