detached a5fad05

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:
549549 - `examples/extensions/weather/` — LLM-callable tool
550550 - `examples/extensions/guard/` — event subscriptions + tool-call
551551 interception (refuses dangerous bash patterns)
552+- `examples/extensions/todo/` — interactive persistent panel + tool
553+- `examples/extensions/scratchpad/` — source-run TypeScript commands + tool
552554
553555 ### Hot reload
554556
@@ -549,6 +549,8 @@ See:
549 - `examples/extensions/weather/` — LLM-callable tool549 - `examples/extensions/weather/` — LLM-callable tool
550 - `examples/extensions/guard/` — event subscriptions + tool-call550 - `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 reload555 ### 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
3939
4040 ```json
4141 {
42- "exec": "tsx",
43- "args": ["index.ts"]
42+ "exec": "npx",
43+ "args": ["-y", "tsx", "./index.ts"]
4444 }
4545 ```
4646
@@ -39,8 +39,8 @@ authoring works too — rename `index.js` → `index.ts`, install
39 39
40 ```json40 ```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 @@
33 "version": "1.0.0",
44 "exec": "node",
55 "args": ["index.js"],
6- "language": "typescript",
6+ "language": "javascript",
77 "description": "registers /now and /uptime — a tiny time toolkit",
88 "enabled": true
99 }
@@ -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": true8 "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)
22
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.
66
77 Demonstrates:
88
@@ -13,16 +13,7 @@ Demonstrates:
1313
1414 ## Requirements
1515
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).
2617
2718 ## Install
2819
@@ -32,6 +23,8 @@ From this directory:
3223 zot ext install .
3324 ```
3425
26+This copies the manifest + source into `$ZOT_HOME/extensions/scratchpad/`.
27+
3528 ## Use
3629
3730 In zot:
@@ -65,8 +58,9 @@ without any infrastructure beyond `tsx`. If you want richer ergonomics
6558
6659 ## See also
6760
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
7266 - `docs/extensions.md` — full protocol reference
@@ -1,8 +1,8 @@
1-# scratchpad — TypeScript extension example1+# 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 and4+./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 ## Requirements14 ## 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 ## Install18 ## 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 ## Use28 ## 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 also59 ## 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 SDK62+- `examples/extensions/hello` — Go SDK slash commands
70-- `examples/extensions/weather` — Go SDK, exposes one tool63+- `examples/extensions/weather` — Go SDK tool example
71-- `examples/extensions/guard` — Go SDK, demonstrates intercepts64+- `examples/extensions/guard` — Go SDK intercept example
65+- `examples/extensions/todo` — interactive panel example
72 - `docs/extensions.md` — full protocol reference66 - `docs/extensions.md` — full protocol reference
M examples/extensions/scratchpad/extension.json +2 -2
@@ -1,8 +1,8 @@
11 {
22 "name": "scratchpad",
33 "version": "1.0.0",
4- "exec": "tsx",
5- "args": ["index.ts"],
4+ "exec": "npx",
5+ "args": ["-y", "tsx", "./index.ts"],
66 "language": "typescript",
77 "description": "scratchpad notes (commands) + read_notes (tool) — written in plain .ts via tsx",
88 "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": true8 "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:
248248 zot -p "prompt" print final text, exit
249249 zot --json "prompt" newline-delimited json events, exit
250250 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/
251254 zot telegram-bot setup configure a telegram bot (from BotFather)
252255 zot telegram-bot run foreground bridge (ctrl+c to stop)
253256 zot telegram-bot start background bridge (detached)
@@ -297,5 +300,11 @@ flags:
297300 --list-models print known models and exit
298301 -h, --help this message
299302 -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
300309 `, version)
301310 }
@@ -248,6 +248,9 @@ usage:
248 zot -p "prompt" print final text, exit248 zot -p "prompt" print final text, exit
249 zot --json "prompt" newline-delimited json events, exit249 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 exit300 --list-models print known models and exit
298 -h, --help this message301 -h, --help this message
299 -v, --version version info302 -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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close