docs(ext): refresh examples and help text
Adds the todo panel example under examples/extensions, updates example manifests and READMEs to match the current extension API, and surfaces extension install/load commands in zot --help.
- commit
- a5fad05fa3a54c6cf0547ee4b076147676fc198d
- author
- patriceckhart · · 2026-04-22 18:50 UTC
- parent
- 6b03aa3
- signature
- unsigned
- refs
- tag: v0.0.95
11 files · +464 -23
M docs/extensions.md +2 -0
| @@ -549,6 +549,8 @@ See: | |||
| 549 | 549 | - `examples/extensions/weather/` — LLM-callable tool | |
| 550 | 550 | - `examples/extensions/guard/` — event subscriptions + tool-call | |
| 551 | 551 | interception (refuses dangerous bash patterns) | |
| 552 | + | - `examples/extensions/todo/` — interactive persistent panel + tool | |
| 553 | + | - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool | |
| 552 | 554 | ||
| 553 | 555 | ### Hot reload | |
| 554 | 556 | ||
| @@ -549,6 +549,8 @@ See: | |||||
| 549 | - `examples/extensions/weather/` — LLM-callable tool | 549 | - `examples/extensions/weather/` — LLM-callable tool | ||
| 550 | - `examples/extensions/guard/` — event subscriptions + tool-call | 550 | - `examples/extensions/guard/` — event subscriptions + tool-call | ||
| 551 | interception (refuses dangerous bash patterns) | 551 | interception (refuses dangerous bash patterns) | ||
| 552 | + | - `examples/extensions/todo/` — interactive persistent panel + tool | |||
| 553 | + | - `examples/extensions/scratchpad/` — source-run TypeScript commands + tool | |||
| 552 | 554 | ||||
| 553 | ### Hot reload | 555 | ### Hot reload | ||
| 554 | 556 | ||||
M examples/extensions/clock/README.md +2 -2
| @@ -39,8 +39,8 @@ authoring works too — rename `index.js` → `index.ts`, install | |||
| 39 | 39 | ||
| 40 | 40 | ```json | |
| 41 | 41 | { | |
| 42 | - | "exec": "tsx", | |
| 43 | - | "args": ["index.ts"] | |
| 42 | + | "exec": "npx", | |
| 43 | + | "args": ["-y", "tsx", "./index.ts"] | |
| 44 | 44 | } | |
| 45 | 45 | ``` | |
| 46 | 46 | ||
| @@ -39,8 +39,8 @@ authoring works too — rename `index.js` → `index.ts`, install | |||||
| 39 | 39 | ||||
| 40 | ```json | 40 | ```json | ||
| 41 | { | 41 | { | ||
| 42 | - | "exec": "tsx", | 42 | + | "exec": "npx", |
| 43 | - | "args": ["index.ts"] | 43 | + | "args": ["-y", "tsx", "./index.ts"] |
| 44 | } | 44 | } | ||
| 45 | ``` | 45 | ``` | ||
| 46 | 46 | ||||
M examples/extensions/clock/extension.json +1 -1
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | "version": "1.0.0", | |
| 4 | 4 | "exec": "node", | |
| 5 | 5 | "args": ["index.js"], | |
| 6 | - | "language": "typescript", | |
| 6 | + | "language": "javascript", | |
| 7 | 7 | "description": "registers /now and /uptime — a tiny time toolkit", | |
| 8 | 8 | "enabled": true | |
| 9 | 9 | } | |
| @@ -3,7 +3,7 @@ | |||||
| 3 | "version": "1.0.0", | 3 | "version": "1.0.0", | ||
| 4 | "exec": "node", | 4 | "exec": "node", | ||
| 5 | "args": ["index.js"], | 5 | "args": ["index.js"], | ||
| 6 | - | "language": "typescript", | 6 | + | "language": "javascript", |
| 7 | "description": "registers /now and /uptime — a tiny time toolkit", | 7 | "description": "registers /now and /uptime — a tiny time toolkit", | ||
| 8 | "enabled": true | 8 | "enabled": true | ||
| 9 | } | 9 | } | ||
M examples/extensions/scratchpad/README.md +12 -18
| @@ -1,8 +1,8 @@ | |||
| 1 | - | # scratchpad — TypeScript extension example | |
| 1 | + | # scratchpad — example zot extension (TypeScript, source-run) | |
| 2 | 2 | ||
| 3 | - | Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx tsx`, | |
| 4 | - | which downloads itself into the npm cache on first invocation and | |
| 5 | - | runs from cache on every subsequent call. | |
| 3 | + | Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx -y tsx | |
| 4 | + | ./index.ts`, which downloads `tsx` into npm's cache on first | |
| 5 | + | invocation and then reuses the cached copy on subsequent runs. | |
| 6 | 6 | ||
| 7 | 7 | Demonstrates: | |
| 8 | 8 | ||
| @@ -13,16 +13,7 @@ Demonstrates: | |||
| 13 | 13 | ||
| 14 | 14 | ## Requirements | |
| 15 | 15 | ||
| 16 | - | Node 18+ and `tsx` on `$PATH`: | |
| 17 | - | ||
| 18 | - | ```bash | |
| 19 | - | npm install -g tsx | |
| 20 | - | ``` | |
| 21 | - | ||
| 22 | - | This is what `extension.json` invokes (`exec: "tsx"`). Without the | |
| 23 | - | global install, swap to `"exec": "npx"` + `"args": ["--yes", "tsx", | |
| 24 | - | "index.ts"]` — functional but adds ~1 second to each zot startup | |
| 25 | - | because npm checks the registry on every invocation. | |
| 16 | + | Node 18+ and `npx` (bundled with npm). | |
| 26 | 17 | ||
| 27 | 18 | ## Install | |
| 28 | 19 | ||
| @@ -32,6 +23,8 @@ From this directory: | |||
| 32 | 23 | zot ext install . | |
| 33 | 24 | ``` | |
| 34 | 25 | ||
| 26 | + | This copies the manifest + source into `$ZOT_HOME/extensions/scratchpad/`. | |
| 27 | + | ||
| 35 | 28 | ## Use | |
| 36 | 29 | ||
| 37 | 30 | In zot: | |
| @@ -65,8 +58,9 @@ without any infrastructure beyond `tsx`. If you want richer ergonomics | |||
| 65 | 58 | ||
| 66 | 59 | ## See also | |
| 67 | 60 | ||
| 68 | - | - `examples/extensions/clock` — JS sibling (no tsx required) | |
| 69 | - | - `examples/extensions/hello` — Go SDK | |
| 70 | - | - `examples/extensions/weather` — Go SDK, exposes one tool | |
| 71 | - | - `examples/extensions/guard` — Go SDK, demonstrates intercepts | |
| 61 | + | - `examples/extensions/clock` — JavaScript sibling (no build step) | |
| 62 | + | - `examples/extensions/hello` — Go SDK slash commands | |
| 63 | + | - `examples/extensions/weather` — Go SDK tool example | |
| 64 | + | - `examples/extensions/guard` — Go SDK intercept example | |
| 65 | + | - `examples/extensions/todo` — interactive panel example | |
| 72 | 66 | - `docs/extensions.md` — full protocol reference | |
| @@ -1,8 +1,8 @@ | |||||
| 1 | - | # scratchpad — TypeScript extension example | 1 | + | # scratchpad — example zot extension (TypeScript, source-run) |
| 2 | 2 | ||||
| 3 | - | Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx tsx`, | 3 | + | Real `.ts` (not `.js`), no build step, no SDK. Runs via `npx -y tsx |
| 4 | - | which downloads itself into the npm cache on first invocation and | 4 | + | ./index.ts`, which downloads `tsx` into npm's cache on first |
| 5 | - | runs from cache on every subsequent call. | 5 | + | invocation and then reuses the cached copy on subsequent runs. |
| 6 | 6 | ||||
| 7 | Demonstrates: | 7 | Demonstrates: | ||
| 8 | 8 | ||||
| @@ -13,16 +13,7 @@ Demonstrates: | |||||
| 13 | 13 | ||||
| 14 | ## Requirements | 14 | ## Requirements | ||
| 15 | 15 | ||||
| 16 | - | Node 18+ and `tsx` on `$PATH`: | 16 | + | Node 18+ and `npx` (bundled with npm). |
| 17 | - | ||||
| 18 | - | ```bash | |||
| 19 | - | npm install -g tsx | |||
| 20 | - | ``` | |||
| 21 | - | ||||
| 22 | - | This is what `extension.json` invokes (`exec: "tsx"`). Without the | |||
| 23 | - | global install, swap to `"exec": "npx"` + `"args": ["--yes", "tsx", | |||
| 24 | - | "index.ts"]` — functional but adds ~1 second to each zot startup | |||
| 25 | - | because npm checks the registry on every invocation. | |||
| 26 | 17 | ||||
| 27 | ## Install | 18 | ## Install | ||
| 28 | 19 | ||||
| @@ -32,6 +23,8 @@ From this directory: | |||||
| 32 | zot ext install . | 23 | zot ext install . | ||
| 33 | ``` | 24 | ``` | ||
| 34 | 25 | ||||
| 26 | + | This copies the manifest + source into `$ZOT_HOME/extensions/scratchpad/`. | |||
| 27 | + | ||||
| 35 | ## Use | 28 | ## Use | ||
| 36 | 29 | ||||
| 37 | In zot: | 30 | In zot: | ||
| @@ -65,8 +58,9 @@ without any infrastructure beyond `tsx`. If you want richer ergonomics | |||||
| 65 | 58 | ||||
| 66 | ## See also | 59 | ## See also | ||
| 67 | 60 | ||||
| 68 | - | - `examples/extensions/clock` — JS sibling (no tsx required) | 61 | + | - `examples/extensions/clock` — JavaScript sibling (no build step) |
| 69 | - | - `examples/extensions/hello` — Go SDK | 62 | + | - `examples/extensions/hello` — Go SDK slash commands |
| 70 | - | - `examples/extensions/weather` — Go SDK, exposes one tool | 63 | + | - `examples/extensions/weather` — Go SDK tool example |
| 71 | - | - `examples/extensions/guard` — Go SDK, demonstrates intercepts | 64 | + | - `examples/extensions/guard` — Go SDK intercept example |
| 65 | + | - `examples/extensions/todo` — interactive panel example | |||
| 72 | - `docs/extensions.md` — full protocol reference | 66 | - `docs/extensions.md` — full protocol reference | ||
M examples/extensions/scratchpad/extension.json +2 -2
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "name": "scratchpad", | |
| 3 | 3 | "version": "1.0.0", | |
| 4 | - | "exec": "tsx", | |
| 5 | - | "args": ["index.ts"], | |
| 4 | + | "exec": "npx", | |
| 5 | + | "args": ["-y", "tsx", "./index.ts"], | |
| 6 | 6 | "language": "typescript", | |
| 7 | 7 | "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx", | |
| 8 | 8 | "enabled": true | |
| @@ -1,8 +1,8 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | "name": "scratchpad", | 2 | "name": "scratchpad", | ||
| 3 | "version": "1.0.0", | 3 | "version": "1.0.0", | ||
| 4 | - | "exec": "tsx", | 4 | + | "exec": "npx", |
| 5 | - | "args": ["index.ts"], | 5 | + | "args": ["-y", "tsx", "./index.ts"], |
| 6 | "language": "typescript", | 6 | "language": "typescript", | ||
| 7 | "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx", | 7 | "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx", | ||
| 8 | "enabled": true | 8 | "enabled": true | ||
A examples/extensions/todo/README.md +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | # todo — example zot extension (Go, interactive panel) | |
| 2 | + | ||
| 3 | + | Demonstrates the interactive panel API plus a companion tool the model | |
| 4 | + | can call. The slash command opens a persistent todo panel; the tool lets | |
| 5 | + | zot read and update the same todo list. | |
| 6 | + | ||
| 7 | + | ## Requirements | |
| 8 | + | ||
| 9 | + | Go 1.22+. | |
| 10 | + | ||
| 11 | + | ## Install | |
| 12 | + | ||
| 13 | + | From this directory: | |
| 14 | + | ||
| 15 | + | ```bash | |
| 16 | + | zot ext install . | |
| 17 | + | ``` | |
| 18 | + | ||
| 19 | + | The example is configured to run directly from source: | |
| 20 | + | ||
| 21 | + | ```json | |
| 22 | + | { | |
| 23 | + | "exec": "go", | |
| 24 | + | "args": ["run", "."] | |
| 25 | + | } | |
| 26 | + | ``` | |
| 27 | + | ||
| 28 | + | That avoids architecture-specific binaries when sharing the example, as | |
| 29 | + | long as Go is installed. | |
| 30 | + | ||
| 31 | + | ## Optional local build | |
| 32 | + | ||
| 33 | + | ```bash | |
| 34 | + | cd examples/extensions/todo | |
| 35 | + | go build -o todo-panel . | |
| 36 | + | ``` | |
| 37 | + | ||
| 38 | + | If you do build it, change `extension.json` back to: | |
| 39 | + | ||
| 40 | + | ```json | |
| 41 | + | { | |
| 42 | + | "exec": "./todo-panel" | |
| 43 | + | } | |
| 44 | + | ``` | |
| 45 | + | ||
| 46 | + | ## Use | |
| 47 | + | ||
| 48 | + | In zot: | |
| 49 | + | ||
| 50 | + | - `/todo` opens the panel | |
| 51 | + | ||
| 52 | + | ## Features | |
| 53 | + | ||
| 54 | + | - `/todo` opens the panel | |
| 55 | + | - panel keys: | |
| 56 | + | - up/down - move | |
| 57 | + | - a - add with typed text | |
| 58 | + | - e - edit selected todo | |
| 59 | + | - x - toggle done | |
| 60 | + | - d - delete | |
| 61 | + | - r - redraw | |
| 62 | + | - esc - close panel | |
| 63 | + | - persistent storage in the extension data directory as `todos.json` | |
| 64 | + | - LLM tool: `todo_manage` | |
| 65 | + | - list | |
| 66 | + | - add | |
| 67 | + | - complete | |
| 68 | + | - edit | |
| 69 | + | - remove | |
| 70 | + | ||
| 71 | + | ## Natural-language examples | |
| 72 | + | ||
| 73 | + | - Create a new entry named "Call Georg" on my to-do list. | |
| 74 | + | - Complete task "Call Georg". | |
| 75 | + | - Edit task "Call Georg" to "Call Georg tomorrow". | |
| 76 | + | - Remove task "Call Georg". | |
| 77 | + | ||
| 78 | + | ## See also | |
| 79 | + | ||
| 80 | + | - `examples/extensions/hello` — Go SDK slash commands | |
| 81 | + | - `examples/extensions/weather` — Go SDK tool example | |
| 82 | + | - `examples/extensions/guard` — Go SDK intercept example | |
| 83 | + | - `examples/extensions/scratchpad` — TypeScript commands + tool | |
| 84 | + | - `docs/extensions.md` — full protocol reference | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | # todo — example zot extension (Go, interactive panel) | |||
| 2 | + | ||||
| 3 | + | Demonstrates the interactive panel API plus a companion tool the model | |||
| 4 | + | can call. The slash command opens a persistent todo panel; the tool lets | |||
| 5 | + | zot read and update the same todo list. | |||
| 6 | + | ||||
| 7 | + | ## Requirements | |||
| 8 | + | ||||
| 9 | + | Go 1.22+. | |||
| 10 | + | ||||
| 11 | + | ## Install | |||
| 12 | + | ||||
| 13 | + | From this directory: | |||
| 14 | + | ||||
| 15 | + | ```bash | |||
| 16 | + | zot ext install . | |||
| 17 | + | ``` | |||
| 18 | + | ||||
| 19 | + | The example is configured to run directly from source: | |||
| 20 | + | ||||
| 21 | + | ```json | |||
| 22 | + | { | |||
| 23 | + | "exec": "go", | |||
| 24 | + | "args": ["run", "."] | |||
| 25 | + | } | |||
| 26 | + | ``` | |||
| 27 | + | ||||
| 28 | + | That avoids architecture-specific binaries when sharing the example, as | |||
| 29 | + | long as Go is installed. | |||
| 30 | + | ||||
| 31 | + | ## Optional local build | |||
| 32 | + | ||||
| 33 | + | ```bash | |||
| 34 | + | cd examples/extensions/todo | |||
| 35 | + | go build -o todo-panel . | |||
| 36 | + | ``` | |||
| 37 | + | ||||
| 38 | + | If you do build it, change `extension.json` back to: | |||
| 39 | + | ||||
| 40 | + | ```json | |||
| 41 | + | { | |||
| 42 | + | "exec": "./todo-panel" | |||
| 43 | + | } | |||
| 44 | + | ``` | |||
| 45 | + | ||||
| 46 | + | ## Use | |||
| 47 | + | ||||
| 48 | + | In zot: | |||
| 49 | + | ||||
| 50 | + | - `/todo` opens the panel | |||
| 51 | + | ||||
| 52 | + | ## Features | |||
| 53 | + | ||||
| 54 | + | - `/todo` opens the panel | |||
| 55 | + | - panel keys: | |||
| 56 | + | - up/down - move | |||
| 57 | + | - a - add with typed text | |||
| 58 | + | - e - edit selected todo | |||
| 59 | + | - x - toggle done | |||
| 60 | + | - d - delete | |||
| 61 | + | - r - redraw | |||
| 62 | + | - esc - close panel | |||
| 63 | + | - persistent storage in the extension data directory as `todos.json` | |||
| 64 | + | - LLM tool: `todo_manage` | |||
| 65 | + | - list | |||
| 66 | + | - add | |||
| 67 | + | - complete | |||
| 68 | + | - edit | |||
| 69 | + | - remove | |||
| 70 | + | ||||
| 71 | + | ## Natural-language examples | |||
| 72 | + | ||||
| 73 | + | - Create a new entry named "Call Georg" on my to-do list. | |||
| 74 | + | - Complete task "Call Georg". | |||
| 75 | + | - Edit task "Call Georg" to "Call Georg tomorrow". | |||
| 76 | + | - Remove task "Call Georg". | |||
| 77 | + | ||||
| 78 | + | ## See also | |||
| 79 | + | ||||
| 80 | + | - `examples/extensions/hello` — Go SDK slash commands | |||
| 81 | + | - `examples/extensions/weather` — Go SDK tool example | |||
| 82 | + | - `examples/extensions/guard` — Go SDK intercept example | |||
| 83 | + | - `examples/extensions/scratchpad` — TypeScript commands + tool | |||
| 84 | + | - `docs/extensions.md` — full protocol reference | |||
A examples/extensions/todo/extension.json +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | { | |
| 2 | + | "name": "todo-panel", | |
| 3 | + | "version": "0.3.0", | |
| 4 | + | "exec": "go", | |
| 5 | + | "args": ["run", "."], | |
| 6 | + | "language": "go", | |
| 7 | + | "description": "persistent interactive todo panel", | |
| 8 | + | "enabled": true | |
| 9 | + | } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | { | |||
| 2 | + | "name": "todo-panel", | |||
| 3 | + | "version": "0.3.0", | |||
| 4 | + | "exec": "go", | |||
| 5 | + | "args": ["run", "."], | |||
| 6 | + | "language": "go", | |||
| 7 | + | "description": "persistent interactive todo panel", | |||
| 8 | + | "enabled": true | |||
| 9 | + | } | |||
A examples/extensions/todo/go.mod +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | module zot-todo-extension | |
| 2 | + | ||
| 3 | + | go 1.22 | |
| 4 | + | ||
| 5 | + | require github.com/patriceckhart/zot v0.0.0 | |
| 6 | + | ||
| 7 | + | replace github.com/patriceckhart/zot => /Users/pat/Developer/zot | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | module zot-todo-extension | |||
| 2 | + | ||||
| 3 | + | go 1.22 | |||
| 4 | + | ||||
| 5 | + | require github.com/patriceckhart/zot v0.0.0 | |||
| 6 | + | ||||
| 7 | + | replace github.com/patriceckhart/zot => /Users/pat/Developer/zot | |||
A examples/extensions/todo/main.go +336 -0
| @@ -0,0 +1,336 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/pkg/zotext" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type todoItem struct { | |
| 15 | + | Text string `json:"text"` | |
| 16 | + | Done bool `json:"done"` | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | type store struct { | |
| 20 | + | Items []todoItem `json:"items"` | |
| 21 | + | Cursor int `json:"cursor"` | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type toolArgs struct { | |
| 25 | + | Action string `json:"action"` | |
| 26 | + | Title string `json:"title"` | |
| 27 | + | NewTitle string `json:"new_title"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type app struct { | |
| 31 | + | ext *zotext.Extension | |
| 32 | + | mu sync.Mutex | |
| 33 | + | path string | |
| 34 | + | st store | |
| 35 | + | mode string | |
| 36 | + | edit string | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | const panelID = "todos-main" | |
| 40 | + | ||
| 41 | + | func main() { | |
| 42 | + | ext := zotext.New("todo-panel", "0.3.0") | |
| 43 | + | a := &app{ext: ext} | |
| 44 | + | ext.Command("todo", "open a persistent todo panel", func(args string) zotext.Response { | |
| 45 | + | if err := a.ensureLoaded(); err != nil { | |
| 46 | + | return zotext.Errorf("load todos: %v", err) | |
| 47 | + | } | |
| 48 | + | a.mu.Lock() | |
| 49 | + | defer a.mu.Unlock() | |
| 50 | + | return zotext.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 | + | ext.Tool("todo_manage", "List, add, complete, edit, or remove todos by title.", schema, a.handleTool) | |
| 62 | + | ext.OnPanelKey(panelID, a.handleKey, nil) | |
| 63 | + | if err := ext.Run(); err != nil { | |
| 64 | + | fmt.Fprintln(os.Stderr, err) | |
| 65 | + | os.Exit(1) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (a *app) ensureLoaded() error { | |
| 70 | + | a.mu.Lock() | |
| 71 | + | defer a.mu.Unlock() | |
| 72 | + | if a.path != "" { | |
| 73 | + | return nil | |
| 74 | + | } | |
| 75 | + | dir := a.ext.Host().DataDir | |
| 76 | + | if dir == "" { | |
| 77 | + | dir = a.ext.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 | + | ||
| 105 | + | func (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 | + | ||
| 114 | + | func (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 | + | ||
| 127 | + | func (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 | + | ||
| 137 | + | func (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 | + | ||
| 173 | + | func (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 | + | ||
| 180 | + | func (a *app) rerenderLocked() { | |
| 181 | + | a.ext.RenderPanel(panelID, a.title(), a.renderLines(), a.footer()) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (a *app) handleKey(key, text string) { | |
| 185 | + | if err := a.ensureLoaded(); err != nil { | |
| 186 | + | a.ext.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.ext.Notify("error", fmt.Sprintf("save todos: %v", err)) | |
| 224 | + | } | |
| 225 | + | a.rerenderLocked() | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (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.ext.Notify("error", fmt.Sprintf("save todos: %v", err)) | |
| 257 | + | } | |
| 258 | + | a.rerenderLocked() | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func (a *app) handleTool(args json.RawMessage) zotext.ToolResult { | |
| 262 | + | if err := a.ensureLoaded(); err != nil { | |
| 263 | + | return zotext.TextErrorResult("load todos: " + err.Error()) | |
| 264 | + | } | |
| 265 | + | var in toolArgs | |
| 266 | + | if err := json.Unmarshal(args, &in); err != nil { | |
| 267 | + | return zotext.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 zotext.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 zotext.TextResult(strings.Join(lines, "\n")) | |
| 285 | + | case "add": | |
| 286 | + | if strings.TrimSpace(in.Title) == "" { | |
| 287 | + | return zotext.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 zotext.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 zotext.TextErrorResult("todo not found: " + in.Title) | |
| 302 | + | } | |
| 303 | + | if strings.TrimSpace(in.NewTitle) == "" { | |
| 304 | + | return zotext.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 zotext.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 zotext.TextErrorResult("action is required") | |
| 316 | + | default: | |
| 317 | + | return zotext.TextErrorResult("unsupported action: " + in.Action) | |
| 318 | + | } | |
| 319 | + | if err := a.saveLocked(); err != nil { | |
| 320 | + | return zotext.TextErrorResult("save todos: " + err.Error()) | |
| 321 | + | } | |
| 322 | + | if a.mode == "" { | |
| 323 | + | a.rerenderLocked() | |
| 324 | + | } | |
| 325 | + | return zotext.TextResult("ok") | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func (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 | + | } | |
| @@ -0,0 +1,336 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/pkg/zotext" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type todoItem struct { | |||
| 15 | + | Text string `json:"text"` | |||
| 16 | + | Done bool `json:"done"` | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | type store struct { | |||
| 20 | + | Items []todoItem `json:"items"` | |||
| 21 | + | Cursor int `json:"cursor"` | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | type toolArgs struct { | |||
| 25 | + | Action string `json:"action"` | |||
| 26 | + | Title string `json:"title"` | |||
| 27 | + | NewTitle string `json:"new_title"` | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type app struct { | |||
| 31 | + | ext *zotext.Extension | |||
| 32 | + | mu sync.Mutex | |||
| 33 | + | path string | |||
| 34 | + | st store | |||
| 35 | + | mode string | |||
| 36 | + | edit string | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | const panelID = "todos-main" | |||
| 40 | + | ||||
| 41 | + | func main() { | |||
| 42 | + | ext := zotext.New("todo-panel", "0.3.0") | |||
| 43 | + | a := &app{ext: ext} | |||
| 44 | + | ext.Command("todo", "open a persistent todo panel", func(args string) zotext.Response { | |||
| 45 | + | if err := a.ensureLoaded(); err != nil { | |||
| 46 | + | return zotext.Errorf("load todos: %v", err) | |||
| 47 | + | } | |||
| 48 | + | a.mu.Lock() | |||
| 49 | + | defer a.mu.Unlock() | |||
| 50 | + | return zotext.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 | + | ext.Tool("todo_manage", "List, add, complete, edit, or remove todos by title.", schema, a.handleTool) | |||
| 62 | + | ext.OnPanelKey(panelID, a.handleKey, nil) | |||
| 63 | + | if err := ext.Run(); err != nil { | |||
| 64 | + | fmt.Fprintln(os.Stderr, err) | |||
| 65 | + | os.Exit(1) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (a *app) ensureLoaded() error { | |||
| 70 | + | a.mu.Lock() | |||
| 71 | + | defer a.mu.Unlock() | |||
| 72 | + | if a.path != "" { | |||
| 73 | + | return nil | |||
| 74 | + | } | |||
| 75 | + | dir := a.ext.Host().DataDir | |||
| 76 | + | if dir == "" { | |||
| 77 | + | dir = a.ext.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 | + | ||||
| 105 | + | func (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 | + | ||||
| 114 | + | func (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 | + | ||||
| 127 | + | func (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 | + | ||||
| 137 | + | func (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 | + | ||||
| 173 | + | func (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 | + | ||||
| 180 | + | func (a *app) rerenderLocked() { | |||
| 181 | + | a.ext.RenderPanel(panelID, a.title(), a.renderLines(), a.footer()) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (a *app) handleKey(key, text string) { | |||
| 185 | + | if err := a.ensureLoaded(); err != nil { | |||
| 186 | + | a.ext.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.ext.Notify("error", fmt.Sprintf("save todos: %v", err)) | |||
| 224 | + | } | |||
| 225 | + | a.rerenderLocked() | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (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.ext.Notify("error", fmt.Sprintf("save todos: %v", err)) | |||
| 257 | + | } | |||
| 258 | + | a.rerenderLocked() | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func (a *app) handleTool(args json.RawMessage) zotext.ToolResult { | |||
| 262 | + | if err := a.ensureLoaded(); err != nil { | |||
| 263 | + | return zotext.TextErrorResult("load todos: " + err.Error()) | |||
| 264 | + | } | |||
| 265 | + | var in toolArgs | |||
| 266 | + | if err := json.Unmarshal(args, &in); err != nil { | |||
| 267 | + | return zotext.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 zotext.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 zotext.TextResult(strings.Join(lines, "\n")) | |||
| 285 | + | case "add": | |||
| 286 | + | if strings.TrimSpace(in.Title) == "" { | |||
| 287 | + | return zotext.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 zotext.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 zotext.TextErrorResult("todo not found: " + in.Title) | |||
| 302 | + | } | |||
| 303 | + | if strings.TrimSpace(in.NewTitle) == "" { | |||
| 304 | + | return zotext.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 zotext.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 zotext.TextErrorResult("action is required") | |||
| 316 | + | default: | |||
| 317 | + | return zotext.TextErrorResult("unsupported action: " + in.Action) | |||
| 318 | + | } | |||
| 319 | + | if err := a.saveLocked(); err != nil { | |||
| 320 | + | return zotext.TextErrorResult("save todos: " + err.Error()) | |||
| 321 | + | } | |||
| 322 | + | if a.mode == "" { | |||
| 323 | + | a.rerenderLocked() | |||
| 324 | + | } | |||
| 325 | + | return zotext.TextResult("ok") | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func (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 | + | } | |||
A examples/extensions/todo/zot-todo-extension +0 -0 binary file: no text diff
binary file: no text diff
M internal/agent/args.go +9 -0
| @@ -248,6 +248,9 @@ usage: | |||
| 248 | 248 | zot -p "prompt" print final text, exit | |
| 249 | 249 | zot --json "prompt" newline-delimited json events, exit | |
| 250 | 250 | zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md) | |
| 251 | + | zot ext help extension manager help | |
| 252 | + | zot ext list list installed extensions | |
| 253 | + | zot ext install <path|url> install an extension into $ZOT_HOME/extensions/ | |
| 251 | 254 | zot telegram-bot setup configure a telegram bot (from BotFather) | |
| 252 | 255 | zot telegram-bot run foreground bridge (ctrl+c to stop) | |
| 253 | 256 | zot telegram-bot start background bridge (detached) | |
| @@ -297,5 +300,11 @@ flags: | |||
| 297 | 300 | --list-models print known models and exit | |
| 298 | 301 | -h, --help this message | |
| 299 | 302 | -v, --version version info | |
| 303 | + | ||
| 304 | + | extensions: | |
| 305 | + | zot ext install ./path/to/ext install a local extension | |
| 306 | + | zot ext install https://...git clone + install from git | |
| 307 | + | zot --ext ./path/to/ext load an extension for this run only | |
| 308 | + | zot ext help show all extension subcommands | |
| 300 | 309 | `, version) | |
| 301 | 310 | } | |
| @@ -248,6 +248,9 @@ usage: | |||||
| 248 | zot -p "prompt" print final text, exit | 248 | zot -p "prompt" print final text, exit | ||
| 249 | zot --json "prompt" newline-delimited json events, exit | 249 | zot --json "prompt" newline-delimited json events, exit | ||
| 250 | zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md) | 250 | zot rpc json-rpc loop on stdin/stdout (see docs/rpc.md) | ||
| 251 | + | zot ext help extension manager help | |||
| 252 | + | zot ext list list installed extensions | |||
| 253 | + | zot ext install <path|url> install an extension into $ZOT_HOME/extensions/ | |||
| 251 | zot telegram-bot setup configure a telegram bot (from BotFather) | 254 | zot telegram-bot setup configure a telegram bot (from BotFather) | ||
| 252 | zot telegram-bot run foreground bridge (ctrl+c to stop) | 255 | zot telegram-bot run foreground bridge (ctrl+c to stop) | ||
| 253 | zot telegram-bot start background bridge (detached) | 256 | zot telegram-bot start background bridge (detached) | ||
| @@ -297,5 +300,11 @@ flags: | |||||
| 297 | --list-models print known models and exit | 300 | --list-models print known models and exit | ||
| 298 | -h, --help this message | 301 | -h, --help this message | ||
| 299 | -v, --version version info | 302 | -v, --version version info | ||
| 303 | + | ||||
| 304 | + | extensions: | |||
| 305 | + | zot ext install ./path/to/ext install a local extension | |||
| 306 | + | zot ext install https://...git clone + install from git | |||
| 307 | + | zot --ext ./path/to/ext load an extension for this run only | |||
| 308 | + | zot ext help show all extension subcommands | |||
| 300 | `, version) | 309 | `, version) | ||
| 301 | } | 310 | } | ||