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}