1package main
2
3import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10
11 "github.com/bnema/zut/packages/agent/ext"
12)
13
14type todoItem struct {
15 Text string `json:"text"`
16 Done bool `json:"done"`
17}
18
19type store struct {
20 Items []todoItem `json:"items"`
21 Cursor int `json:"cursor"`
22}
23
24type toolArgs struct {
25 Action string `json:"action"`
26 Title string `json:"title"`
27 NewTitle string `json:"new_title"`
28}
29
30type app struct {
31 e *ext.Extension
32 mu sync.Mutex
33 path string
34 st store
35 mode string
36 edit string
37}
38
39const panelID = "todos-main"
40
41func main() {
42 e := ext.New("todo-panel", "0.3.0")
43 a := &app{e: e}
44 e.Command("todo", "open a persistent todo panel", func(args string) ext.Response {
45 if err := a.ensureLoaded(); err != nil {
46 return ext.Errorf("load todos: %v", err)
47 }
48 a.mu.Lock()
49 defer a.mu.Unlock()
50 return ext.OpenPanel(panelID, a.title(), a.renderLines(), a.footer())
51 })
52 schema, _ := json.Marshal(map[string]any{
53 "type": "object",
54 "properties": map[string]any{
55 "action": map[string]any{"type": "string", "enum": []string{"list", "add", "complete", "edit", "remove"}},
56 "title": map[string]any{"type": "string"},
57 "new_title": map[string]any{"type": "string"},
58 },
59 "required": []string{"action"},
60 })
61 e.Tool("todo_manage", "List, add, complete, edit, or remove todos by title.", schema, a.handleTool)
62 e.OnPanelKey(panelID, a.handleKey, nil)
63 if err := e.Run(); err != nil {
64 fmt.Fprintln(os.Stderr, err)
65 os.Exit(1)
66 }
67}
68
69func (a *app) ensureLoaded() error {
70 a.mu.Lock()
71 defer a.mu.Unlock()
72 if a.path != "" {
73 return nil
74 }
75 dir := a.e.Host().DataDir
76 if dir == "" {
77 dir = a.e.Host().ExtensionDir
78 }
79 if dir == "" {
80 return fmt.Errorf("host did not provide extension_dir/data_dir")
81 }
82 if err := os.MkdirAll(dir, 0o755); err != nil {
83 return err
84 }
85 a.path = filepath.Join(dir, "todos.json")
86 b, err := os.ReadFile(a.path)
87 if err != nil {
88 if os.IsNotExist(err) {
89 a.st = store{Items: []todoItem{{Text: "Build something fun", Done: false}}}
90 return a.saveLocked()
91 }
92 return err
93 }
94 if len(b) == 0 {
95 a.st = store{}
96 return nil
97 }
98 if err := json.Unmarshal(b, &a.st); err != nil {
99 return err
100 }
101 a.clampLocked()
102 return nil
103}
104
105func (a *app) saveLocked() error {
106 a.clampLocked()
107 b, err := json.MarshalIndent(a.st, "", " ")
108 if err != nil {
109 return err
110 }
111 return os.WriteFile(a.path, b, 0o644)
112}
113
114func (a *app) clampLocked() {
115 if len(a.st.Items) == 0 {
116 a.st.Cursor = 0
117 return
118 }
119 if a.st.Cursor < 0 {
120 a.st.Cursor = 0
121 }
122 if a.st.Cursor >= len(a.st.Items) {
123 a.st.Cursor = len(a.st.Items) - 1
124 }
125}
126
127func (a *app) title() string {
128 done := 0
129 for _, it := range a.st.Items {
130 if it.Done {
131 done++
132 }
133 }
134 return fmt.Sprintf("Todos (%d/%d done)", done, len(a.st.Items))
135}
136
137func (a *app) renderLines() []string {
138 if a.mode == "add" || a.mode == "edit" {
139 label := "Add todo"
140 if a.mode == "edit" {
141 label = "Edit todo"
142 }
143 return []string{
144 " " + label,
145 "",
146 " " + a.edit + "▌",
147 "",
148 " Enter saves, Esc cancels",
149 }
150 }
151 lines := []string{}
152 if len(a.st.Items) == 0 {
153 return []string{" No todos yet.", "", " Press a to add one."}
154 }
155 for i, it := range a.st.Items {
156 cursor := " "
157 if i == a.st.Cursor {
158 cursor = "› "
159 }
160 box := "□"
161 if it.Done {
162 box = "✓"
163 }
164 text := it.Text
165 if it.Done {
166 text += " (done)"
167 }
168 lines = append(lines, cursor+box+" "+text)
169 }
170 return lines
171}
172
173func (a *app) footer() string {
174 if a.mode == "add" || a.mode == "edit" {
175 return "type text - enter save - esc cancel - backspace delete"
176 }
177 return "↑/↓ navigate - x toggle - a add - e edit - d delete - r refresh - esc close"
178}
179
180func (a *app) rerenderLocked() {
181 a.e.RenderPanel(panelID, a.title(), a.renderLines(), a.footer())
182}
183
184func (a *app) handleKey(key, text string) {
185 if err := a.ensureLoaded(); err != nil {
186 a.e.Notify("error", fmt.Sprintf("todo: %v", err))
187 return
188 }
189 a.mu.Lock()
190 defer a.mu.Unlock()
191 if a.mode == "add" || a.mode == "edit" {
192 a.handleEditModeLocked(key, text)
193 return
194 }
195 switch key {
196 case "up":
197 a.st.Cursor--
198 case "down":
199 a.st.Cursor++
200 case "rune":
201 switch strings.ToLower(text) {
202 case "x":
203 if len(a.st.Items) > 0 {
204 a.st.Items[a.st.Cursor].Done = !a.st.Items[a.st.Cursor].Done
205 }
206 case "d":
207 if len(a.st.Items) > 0 {
208 a.st.Items = append(a.st.Items[:a.st.Cursor], a.st.Items[a.st.Cursor+1:]...)
209 }
210 case "a":
211 a.mode = "add"
212 a.edit = ""
213 case "e":
214 if len(a.st.Items) > 0 {
215 a.mode = "edit"
216 a.edit = a.st.Items[a.st.Cursor].Text
217 }
218 case "r":
219 }
220 }
221 a.clampLocked()
222 if err := a.saveLocked(); err != nil {
223 a.e.Notify("error", fmt.Sprintf("save todos: %v", err))
224 }
225 a.rerenderLocked()
226}
227
228func (a *app) handleEditModeLocked(key, text string) {
229 switch key {
230 case "enter":
231 trimmed := strings.TrimSpace(a.edit)
232 if trimmed != "" {
233 if a.mode == "add" {
234 a.st.Items = append(a.st.Items, todoItem{Text: trimmed})
235 a.st.Cursor = len(a.st.Items) - 1
236 } else if a.mode == "edit" && len(a.st.Items) > 0 {
237 a.st.Items[a.st.Cursor].Text = trimmed
238 }
239 }
240 a.mode = ""
241 a.edit = ""
242 case "backspace":
243 if len(a.edit) > 0 {
244 r := []rune(a.edit)
245 a.edit = string(r[:len(r)-1])
246 }
247 case "esc":
248 a.mode = ""
249 a.edit = ""
250 case "rune":
251 if text != "" {
252 a.edit += text
253 }
254 }
255 if err := a.saveLocked(); err != nil {
256 a.e.Notify("error", fmt.Sprintf("save todos: %v", err))
257 }
258 a.rerenderLocked()
259}
260
261func (a *app) handleTool(args json.RawMessage) ext.ToolResult {
262 if err := a.ensureLoaded(); err != nil {
263 return ext.TextErrorResult("load todos: " + err.Error())
264 }
265 var in toolArgs
266 if err := json.Unmarshal(args, &in); err != nil {
267 return ext.TextErrorResult("invalid args: " + err.Error())
268 }
269 a.mu.Lock()
270 defer a.mu.Unlock()
271 switch in.Action {
272 case "list":
273 if len(a.st.Items) == 0 {
274 return ext.TextResult("No todos.")
275 }
276 var lines []string
277 for _, it := range a.st.Items {
278 mark := "[ ]"
279 if it.Done {
280 mark = "[x]"
281 }
282 lines = append(lines, mark+" "+it.Text)
283 }
284 return ext.TextResult(strings.Join(lines, "\n"))
285 case "add":
286 if strings.TrimSpace(in.Title) == "" {
287 return ext.TextErrorResult("title is required for add")
288 }
289 a.st.Items = append(a.st.Items, todoItem{Text: strings.TrimSpace(in.Title)})
290 a.st.Cursor = len(a.st.Items) - 1
291 case "complete":
292 idx := a.findByTitleLocked(in.Title)
293 if idx < 0 {
294 return ext.TextErrorResult("todo not found: " + in.Title)
295 }
296 a.st.Items[idx].Done = true
297 a.st.Cursor = idx
298 case "edit":
299 idx := a.findByTitleLocked(in.Title)
300 if idx < 0 {
301 return ext.TextErrorResult("todo not found: " + in.Title)
302 }
303 if strings.TrimSpace(in.NewTitle) == "" {
304 return ext.TextErrorResult("new_title is required for edit")
305 }
306 a.st.Items[idx].Text = strings.TrimSpace(in.NewTitle)
307 a.st.Cursor = idx
308 case "remove":
309 idx := a.findByTitleLocked(in.Title)
310 if idx < 0 {
311 return ext.TextErrorResult("todo not found: " + in.Title)
312 }
313 a.st.Items = append(a.st.Items[:idx], a.st.Items[idx+1:]...)
314 case "":
315 return ext.TextErrorResult("action is required")
316 default:
317 return ext.TextErrorResult("unsupported action: " + in.Action)
318 }
319 if err := a.saveLocked(); err != nil {
320 return ext.TextErrorResult("save todos: " + err.Error())
321 }
322 if a.mode == "" {
323 a.rerenderLocked()
324 }
325 return ext.TextResult("ok")
326}
327
328func (a *app) findByTitleLocked(title string) int {
329 needle := strings.TrimSpace(strings.ToLower(title))
330 for i, it := range a.st.Items {
331 if strings.TrimSpace(strings.ToLower(it.Text)) == needle {
332 return i
333 }
334 }
335 return -1
336}