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	b.WriteString(renderTaskList(state.Tasks))
 88	return b.String()
 89}
 90
 91// RenderTasksOnly renders just the tasks without the goal header.
 92func RenderTasksOnly(tasks []task.Task) string {
 93	return renderTaskList(tasks)
 94}
 95
 96func renderTaskList(tasks []task.Task) string {
 97	var b strings.Builder
 98
 99	legend := buildLegend(tasks)
100	if legend != "" {
101		b.WriteString("Legend: ")
102		b.WriteString(legend)
103		b.WriteString("\n")
104	}
105
106	if len(tasks) == 0 {
107		b.WriteString("No tasks yet.\n")
108		return b.String()
109	}
110
111	sorted := make([]task.Task, len(tasks))
112	copy(sorted, tasks)
113	sort.Slice(sorted, func(i, j int) bool {
114		if sorted[i].CreatedSeq != sorted[j].CreatedSeq {
115			return sorted[i].CreatedSeq < sorted[j].CreatedSeq
116		}
117		if !sorted[i].CreatedAt.Equal(sorted[j].CreatedAt) {
118			return sorted[i].CreatedAt.Before(sorted[j].CreatedAt)
119		}
120		return sorted[i].ID < sorted[j].ID
121	})
122
123	for _, t := range sorted {
124		icon := statusIcons[t.Status]
125		if icon == "" {
126			icon = "?"
127		}
128		b.WriteString(icon)
129		b.WriteString(" ")
130		b.WriteString(strings.TrimSpace(t.Title))
131		b.WriteString(" [")
132		b.WriteString(strings.TrimSpace(t.ID))
133		b.WriteString("]\n")
134
135		if desc := strings.TrimSpace(t.Description); desc != "" {
136			writeIndentedBlock(&b, desc, "  ")
137		}
138	}
139
140	return b.String()
141}
142
143func buildLegend(tasks []task.Task) string {
144	present := map[task.Status]bool{}
145	for _, t := range tasks {
146		present[t.Status] = true
147	}
148
149	var parts []string
150	for _, status := range legendBaseOrder {
151		parts = append(parts, legendEntry(status))
152	}
153	for _, status := range legendOptionalOrder {
154		if present[status] {
155			parts = append(parts, legendEntry(status))
156		}
157	}
158	return strings.Join(parts, "  ")
159}
160
161func legendEntry(status task.Status) string {
162	icon := statusIcons[status]
163	if icon == "" {
164		icon = "?"
165	}
166	return icon + " " + statusLabel(status)
167}
168
169func writeIndentedBlock(b *strings.Builder, text string, prefix string) {
170	lines := strings.Split(text, "\n")
171	for _, line := range lines {
172		b.WriteString(prefix)
173		b.WriteString(strings.TrimSpace(line))
174		b.WriteString("\n")
175	}
176}
177
178func statusLabel(status task.Status) string {
179	return strings.ReplaceAll(status.String(), "_", " ")
180}