detached 1c74882

fix(extensions): route through manager from runSlash default branch

extension commands appeared in the autocomplete popup but invoking
them produced "unknown command: /summon". The submit-handler path
already tried the extension manager before erroring, but the popup-
enter path (suggest.Selection -> runSlash) bypassed that check and
fell straight into runSlash's switch, where the default case bailed
with the generic error.

Fix: runSlash's default branch now also consults
cfg.Extensions.HasCommand and dispatches via invokeExtensionCommand
when matched. Both UI paths (typed-and-enter, popup-enter) now route
identically. Built-in cases above default still always win on
conflict.

Also adds examples/extensions/clock — a node extension demonstrating
the wire protocol from a non-Go runtime. Pure stdlib (readline +
process), no npm install. Registers /now (display) and /uptime
(prompt). Documented in its README; the protocol works the same
from any language.
commit
1c7488285b06bade13d269bc6b7dde46172b85ac
author
patriceckhart · · 2026-04-19 12:20 UTC
parent
13607ed
signature
unsigned
4 files · +240 -0
A examples/extensions/clock/README.md +53 -0
@@ -0,0 +1,53 @@
1+# clock — example zot extension (Node, no dependencies)
2+
3+A minimal TypeScript-style extension showing the wire protocol from
4+the Node side without any SDK. Pure stdlib (`readline`, `process`).
5+
6+## Requirements
7+
8+Node 18 or newer (uses ESM). No `npm install` step.
9+
10+## Install
11+
12+From this directory:
13+
14+```bash
15+zot ext install .
16+```
17+
18+This copies the manifest + script into `$ZOT_HOME/extensions/clock/`.
19+zot picks it up the next time you launch the TUI.
20+
21+## Use
22+
23+In zot:
24+
25+- `/now` — extension pushes a styled note showing local
26+ and ISO time (no model call)
27+- `/uptime` — extension asks the agent to comment on how
28+ long the clock extension has been running
29+- `/uptime caching` — same, but the agent's comment is steered by
30+ the trailing args
31+
32+## Why JavaScript and not TypeScript
33+
34+The file uses JSDoc types (`@typedef`, `@param`) so it type-checks
35+under `tsc --checkJs` without a build step. Authentic TypeScript
36+authoring works too — rename `index.js` → `index.ts`, install
37+[`tsx`](https://www.npmjs.com/package/tsx), and update
38+`extension.json`:
39+
40+```json
41+{
42+ "exec": "tsx",
43+ "args": ["index.ts"]
44+}
45+```
46+
47+zot doesn't care which one you use; it just spawns whatever `exec`
48+points at and reads/writes JSON lines on its stdio.
49+
50+## See also
51+
52+- `examples/extensions/hello` — Go version using the `pkg/zotext` SDK
53+- `docs/extensions.md` — full protocol reference
@@ -0,0 +1,53 @@
1+# clock — example zot extension (Node, no dependencies)
2+
3+A minimal TypeScript-style extension showing the wire protocol from
4+the Node side without any SDK. Pure stdlib (`readline`, `process`).
5+
6+## Requirements
7+
8+Node 18 or newer (uses ESM). No `npm install` step.
9+
10+## Install
11+
12+From this directory:
13+
14+```bash
15+zot ext install .
16+```
17+
18+This copies the manifest + script into `$ZOT_HOME/extensions/clock/`.
19+zot picks it up the next time you launch the TUI.
20+
21+## Use
22+
23+In zot:
24+
25+- `/now` — extension pushes a styled note showing local
26+ and ISO time (no model call)
27+- `/uptime` — extension asks the agent to comment on how
28+ long the clock extension has been running
29+- `/uptime caching` — same, but the agent's comment is steered by
30+ the trailing args
31+
32+## Why JavaScript and not TypeScript
33+
34+The file uses JSDoc types (`@typedef`, `@param`) so it type-checks
35+under `tsc --checkJs` without a build step. Authentic TypeScript
36+authoring works too — rename `index.js` → `index.ts`, install
37+[`tsx`](https://www.npmjs.com/package/tsx), and update
38+`extension.json`:
39+
40+```json
41+{
42+ "exec": "tsx",
43+ "args": ["index.ts"]
44+}
45+```
46+
47+zot doesn't care which one you use; it just spawns whatever `exec`
48+points at and reads/writes JSON lines on its stdio.
49+
50+## See also
51+
52+- `examples/extensions/hello` — Go version using the `pkg/zotext` SDK
53+- `docs/extensions.md` — full protocol reference
A examples/extensions/clock/extension.json +9 -0
@@ -0,0 +1,9 @@
1+{
2+ "name": "clock",
3+ "version": "1.0.0",
4+ "exec": "node",
5+ "args": ["index.js"],
6+ "language": "typescript",
7+ "description": "registers /now and /uptime — a tiny time toolkit",
8+ "enabled": true
9+}
@@ -0,0 +1,9 @@
1+{
2+ "name": "clock",
3+ "version": "1.0.0",
4+ "exec": "node",
5+ "args": ["index.js"],
6+ "language": "typescript",
7+ "description": "registers /now and /uptime — a tiny time toolkit",
8+ "enabled": true
9+}
A examples/extensions/clock/index.js +164 -0
@@ -0,0 +1,164 @@
1+// clock — a zot extension written in plain Node (no dependencies).
2+//
3+// Registers two slash commands:
4+// /now — pushes the current local time into the chat as
5+// a one-shot note (no model call, no transcript)
6+// /uptime — submits a prompt asking the agent to comment on
7+// how long this extension has been running
8+//
9+// Why .js and not .ts: this file uses JSDoc types so it can be
10+// type-checked by tsc / tsserver without a build step. Renaming to
11+// .ts and updating extension.json's args to ["--import","tsx",
12+// "index.ts"] (with tsx installed) works too. The extension protocol
13+// itself is language-agnostic; what matters is that `exec` produces
14+// a process that reads JSON lines from stdin and writes them to
15+// stdout.
16+//
17+// Install:
18+// zot ext install /path/to/this/dir
19+//
20+// Then in zot:
21+// /now
22+// /uptime
23+
24+import { createInterface } from "node:readline";
25+import { stdin, stdout, stderr } from "node:process";
26+
27+const NAME = "clock";
28+const VERSION = "1.0.0";
29+const STARTED_AT = Date.now();
30+
31+/** @typedef {{type: string, id?: string, [k: string]: unknown}} Frame */
32+
33+/**
34+ * Send a frame to zot. One JSON object per line; flush immediately
35+ * so the host doesn't sit waiting on a buffer.
36+ * @param {Frame} obj
37+ */
38+function send(obj) {
39+ stdout.write(JSON.stringify(obj) + "\n");
40+}
41+
42+/**
43+ * stderr is captured by zot to $ZOT_HOME/logs/ext-clock.log; perfect
44+ * for debug output. Anything written to stdout would corrupt the
45+ * protocol stream.
46+ * @param {string} msg
47+ */
48+function log(msg) {
49+ stderr.write(`[${NAME}] ${msg}\n`);
50+}
51+
52+// 1. Hello first.
53+send({
54+ type: "hello",
55+ name: NAME,
56+ version: VERSION,
57+ capabilities: ["commands"],
58+});
59+
60+// 2. Register every command we can handle.
61+send({
62+ type: "register_command",
63+ name: "now",
64+ description: "show the current local time (no model call)",
65+});
66+send({
67+ type: "register_command",
68+ name: "uptime",
69+ description: "ask the agent to riff on how long the clock ext has run",
70+});
71+
72+// 3. Read frames until stdin closes (zot shuts us down).
73+const rl = createInterface({ input: stdin, crlfDelay: Infinity });
74+
75+rl.on("line", (line) => {
76+ /** @type {Frame} */
77+ let frame;
78+ try {
79+ frame = JSON.parse(line);
80+ } catch (err) {
81+ log(`malformed frame: ${err}`);
82+ return;
83+ }
84+
85+ switch (frame.type) {
86+ case "hello_ack":
87+ log(
88+ `connected to zot ${frame.zot_version} (${frame.provider}/${frame.model})`,
89+ );
90+ break;
91+
92+ case "command_invoked":
93+ handleCommand(frame);
94+ break;
95+
96+ case "shutdown":
97+ send({ type: "shutdown_ack" });
98+ rl.close();
99+ break;
100+
101+ default:
102+ log(`unknown frame type: ${frame.type}`);
103+ }
104+});
105+
106+rl.on("close", () => {
107+ log("read loop closed; exiting");
108+ process.exit(0);
109+});
110+
111+/**
112+ * @param {Frame & {name?: string, args?: string}} frame
113+ */
114+function handleCommand(frame) {
115+ const name = String(frame.name ?? "");
116+ const args = String(frame.args ?? "").trim();
117+ const id = String(frame.id ?? "");
118+
119+ switch (name) {
120+ case "now": {
121+ const now = new Date();
122+ const human = now.toLocaleString(undefined, {
123+ weekday: "short",
124+ year: "numeric",
125+ month: "short",
126+ day: "numeric",
127+ hour: "2-digit",
128+ minute: "2-digit",
129+ second: "2-digit",
130+ });
131+ const iso = now.toISOString();
132+ send({
133+ type: "command_response",
134+ id,
135+ action: "display",
136+ display: `local: ${human}\niso : ${iso}`,
137+ });
138+ return;
139+ }
140+
141+ case "uptime": {
142+ const ms = Date.now() - STARTED_AT;
143+ const seconds = Math.round(ms / 1000);
144+ const focus = args ? `Focus on the topic: ${args}.` : "";
145+ send({
146+ type: "command_response",
147+ id,
148+ action: "prompt",
149+ prompt:
150+ `The clock extension has been running for ${seconds}s in this zot session. ` +
151+ `Riff on that briefly in one short sentence — be a little dramatic. ${focus}`.trim(),
152+ });
153+ return;
154+ }
155+
156+ default:
157+ send({
158+ type: "command_response",
159+ id,
160+ action: "noop",
161+ error: `clock: unknown command /${name}`,
162+ });
163+ }
164+}
@@ -0,0 +1,164 @@
1+// clock — a zot extension written in plain Node (no dependencies).
2+//
3+// Registers two slash commands:
4+// /now — pushes the current local time into the chat as
5+// a one-shot note (no model call, no transcript)
6+// /uptime — submits a prompt asking the agent to comment on
7+// how long this extension has been running
8+//
9+// Why .js and not .ts: this file uses JSDoc types so it can be
10+// type-checked by tsc / tsserver without a build step. Renaming to
11+// .ts and updating extension.json's args to ["--import","tsx",
12+// "index.ts"] (with tsx installed) works too. The extension protocol
13+// itself is language-agnostic; what matters is that `exec` produces
14+// a process that reads JSON lines from stdin and writes them to
15+// stdout.
16+//
17+// Install:
18+// zot ext install /path/to/this/dir
19+//
20+// Then in zot:
21+// /now
22+// /uptime
23+
24+import { createInterface } from "node:readline";
25+import { stdin, stdout, stderr } from "node:process";
26+
27+const NAME = "clock";
28+const VERSION = "1.0.0";
29+const STARTED_AT = Date.now();
30+
31+/** @typedef {{type: string, id?: string, [k: string]: unknown}} Frame */
32+
33+/**
34+ * Send a frame to zot. One JSON object per line; flush immediately
35+ * so the host doesn't sit waiting on a buffer.
36+ * @param {Frame} obj
37+ */
38+function send(obj) {
39+ stdout.write(JSON.stringify(obj) + "\n");
40+}
41+
42+/**
43+ * stderr is captured by zot to $ZOT_HOME/logs/ext-clock.log; perfect
44+ * for debug output. Anything written to stdout would corrupt the
45+ * protocol stream.
46+ * @param {string} msg
47+ */
48+function log(msg) {
49+ stderr.write(`[${NAME}] ${msg}\n`);
50+}
51+
52+// 1. Hello first.
53+send({
54+ type: "hello",
55+ name: NAME,
56+ version: VERSION,
57+ capabilities: ["commands"],
58+});
59+
60+// 2. Register every command we can handle.
61+send({
62+ type: "register_command",
63+ name: "now",
64+ description: "show the current local time (no model call)",
65+});
66+send({
67+ type: "register_command",
68+ name: "uptime",
69+ description: "ask the agent to riff on how long the clock ext has run",
70+});
71+
72+// 3. Read frames until stdin closes (zot shuts us down).
73+const rl = createInterface({ input: stdin, crlfDelay: Infinity });
74+
75+rl.on("line", (line) => {
76+ /** @type {Frame} */
77+ let frame;
78+ try {
79+ frame = JSON.parse(line);
80+ } catch (err) {
81+ log(`malformed frame: ${err}`);
82+ return;
83+ }
84+
85+ switch (frame.type) {
86+ case "hello_ack":
87+ log(
88+ `connected to zot ${frame.zot_version} (${frame.provider}/${frame.model})`,
89+ );
90+ break;
91+
92+ case "command_invoked":
93+ handleCommand(frame);
94+ break;
95+
96+ case "shutdown":
97+ send({ type: "shutdown_ack" });
98+ rl.close();
99+ break;
100+
101+ default:
102+ log(`unknown frame type: ${frame.type}`);
103+ }
104+});
105+
106+rl.on("close", () => {
107+ log("read loop closed; exiting");
108+ process.exit(0);
109+});
110+
111+/**
112+ * @param {Frame & {name?: string, args?: string}} frame
113+ */
114+function handleCommand(frame) {
115+ const name = String(frame.name ?? "");
116+ const args = String(frame.args ?? "").trim();
117+ const id = String(frame.id ?? "");
118+
119+ switch (name) {
120+ case "now": {
121+ const now = new Date();
122+ const human = now.toLocaleString(undefined, {
123+ weekday: "short",
124+ year: "numeric",
125+ month: "short",
126+ day: "numeric",
127+ hour: "2-digit",
128+ minute: "2-digit",
129+ second: "2-digit",
130+ });
131+ const iso = now.toISOString();
132+ send({
133+ type: "command_response",
134+ id,
135+ action: "display",
136+ display: `local: ${human}\niso : ${iso}`,
137+ });
138+ return;
139+ }
140+
141+ case "uptime": {
142+ const ms = Date.now() - STARTED_AT;
143+ const seconds = Math.round(ms / 1000);
144+ const focus = args ? `Focus on the topic: ${args}.` : "";
145+ send({
146+ type: "command_response",
147+ id,
148+ action: "prompt",
149+ prompt:
150+ `The clock extension has been running for ${seconds}s in this zot session. ` +
151+ `Riff on that briefly in one short sentence — be a little dramatic. ${focus}`.trim(),
152+ });
153+ return;
154+ }
155+
156+ default:
157+ send({
158+ type: "command_response",
159+ id,
160+ action: "noop",
161+ error: `clock: unknown command /${name}`,
162+ });
163+ }
164+}
M internal/agent/modes/interactive.go +14 -0
@@ -1135,6 +1135,20 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
11351135 i.statusErr = ""
11361136 i.mu.Unlock()
11371137 default:
1138+ // Last-resort fallback: try the extension manager. Built-in
1139+ // cases above always win; this branch only fires for slash
1140+ // commands the extension manager registered. Same routing as
1141+ // the editor's submit-handler dispatch path so the autocomplete
1142+ // "enter on highlighted suggestion" flow also works.
1143+ extName := strings.TrimPrefix(parts[0], "/")
1144+ if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1145+ rest := ""
1146+ if len(parts) > 1 {
1147+ rest = strings.Join(parts[1:], " ")
1148+ }
1149+ go i.invokeExtensionCommand(ctx, extName, rest)
1150+ return false
1151+ }
11381152 i.mu.Lock()
11391153 i.statusErr = "unknown command: " + parts[0]
11401154 i.mu.Unlock()
@@ -1135,6 +1135,20 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1135 i.statusErr = ""1135 i.statusErr = ""
1136 i.mu.Unlock()1136 i.mu.Unlock()
1137 default:1137 default:
1138+ // Last-resort fallback: try the extension manager. Built-in
1139+ // cases above always win; this branch only fires for slash
1140+ // commands the extension manager registered. Same routing as
1141+ // the editor's submit-handler dispatch path so the autocomplete
1142+ // "enter on highlighted suggestion" flow also works.
1143+ extName := strings.TrimPrefix(parts[0], "/")
1144+ if i.cfg.Extensions != nil && i.cfg.Extensions.HasCommand(extName) {
1145+ rest := ""
1146+ if len(parts) > 1 {
1147+ rest = strings.Join(parts[1:], " ")
1148+ }
1149+ go i.invokeExtensionCommand(ctx, extName, rest)
1150+ return false
1151+ }
1138 i.mu.Lock()1152 i.mu.Lock()
1139 i.statusErr = "unknown command: " + parts[0]1153 i.statusErr = "unknown command: " + parts[0]
1140 i.mu.Unlock()1154 i.mu.Unlock()

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