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