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}