v0.50.0 c4ec31f

index.ts RawHistoryFolder

1// scratchpad — a real .ts zut extension with no SDK and no build step.
2//
3// Runs via `npx --yes tsx index.ts` (declared in extension.json).
4// First invocation downloads tsx into npm's cache; every subsequent
5// run is instant. Pure node + tsx, no other dependencies.
6//
7// What it does:
8//
9//   /note <text>    — append <text> to a process-local scratchpad
10//   /notes          — show the scratchpad inline (no model call)
11//   /clear-notes    — wipe the scratchpad
12//
13//   tool: read_notes() — the model can read the scratchpad on demand
14//
15// Use this as a template for any TypeScript extension that needs to
16// register slash commands, expose tools to the model, or both.
17
18import { createInterface } from "node:readline";
19import { stderr, stdin, stdout } from "node:process";
20import {
21  appendFileSync,
22  existsSync,
23  mkdirSync,
24  readFileSync,
25  writeFileSync,
26} from "node:fs";
27import { dirname, join } from "node:path";
28
29// ---- protocol types (a tiny subset of internal/extproto) ----
30
31type Frame = { type: string; id?: string; [k: string]: unknown };
32
33interface CommandInvoked {
34  type: "command_invoked";
35  id: string;
36  name: string;
37  args?: string;
38}
39
40interface ToolCall {
41  type: "tool_call";
42  id: string;
43  name: string;
44  args: Record<string, unknown>;
45}
46
47interface HelloAck {
48  type: "hello_ack";
49  protocol_version: number;
50  zut_version: string;
51  provider: string;
52  model: string;
53  cwd: string;
54}
55
56type Action = "prompt" | "insert" | "display" | "noop";
57
58interface CommandResponse {
59  type: "command_response";
60  id: string;
61  action: Action;
62  prompt?: string;
63  insert?: string;
64  display?: string;
65  error?: string;
66}
67
68interface ToolContent {
69  type: "text" | "image";
70  text?: string;
71  mime_type?: string;
72  data?: string; // base64
73}
74
75interface ToolResult {
76  type: "tool_result";
77  id: string;
78  content: ToolContent[];
79  is_error?: boolean;
80}
81
82// ---- I/O helpers ----
83
84const NAME = "scratchpad";
85const VERSION = "1.0.0";
86
87function send(frame: Frame): void {
88  stdout.write(JSON.stringify(frame) + "\n");
89}
90
91function log(msg: string): void {
92  // stderr is captured by zut to $ZUT_HOME/logs/ext-<name>.log;
93  // safe for debug output. stdout is reserved for the protocol.
94  stderr.write(`[${NAME}] ${msg}\n`);
95}
96
97// ---- the scratchpad state itself ----
98//
99// Notes persist as JSONL under <cwd>/.zut/scratchpad-notes.jsonl so
100// they survive zut restarts and stay scoped to the project. The path
101// is resolved once HelloAck arrives (which carries cwd); until then
102// notesPath is empty and reads/writes no-op safely.
103//
104// One note per line, format:  {"at":"<iso>","text":"<body>"}
105// Append-only on /note; full rewrite on /clear-notes.
106//
107// Single-writer assumption: only one zut session per cwd at a time.
108// Concurrent writes from two zut instances would interleave but not
109// corrupt JSONL line boundaries on POSIX (writes ≤ PIPE_BUF are
110// atomic). Good enough for a demo.
111
112type Note = { at: string; text: string };
113
114let notes: Note[] = [];
115let notesPath = "";
116
117function setNotesPath(cwd: string): void {
118  notesPath = join(cwd, ".zut", "scratchpad-notes.jsonl");
119  loadNotes();
120}
121
122function loadNotes(): void {
123  notes = [];
124  if (!notesPath || !existsSync(notesPath)) return;
125  try {
126    const raw = readFileSync(notesPath, "utf8");
127    for (const line of raw.split("\n")) {
128      const trimmed = line.trim();
129      if (!trimmed) continue;
130      try {
131        const parsed = JSON.parse(trimmed) as Note;
132        if (typeof parsed?.text === "string") notes.push(parsed);
133      } catch {
134        // skip malformed lines silently; the next /note will
135        // append correctly anyway.
136      }
137    }
138    log(`loaded ${notes.length} note(s) from ${notesPath}`);
139  } catch (err) {
140    log(`failed to read ${notesPath}: ${err}`);
141  }
142}
143
144function appendNote(text: string): number {
145  const note: Note = { at: new Date().toISOString(), text };
146  notes.push(note);
147  if (notesPath) {
148    try {
149      mkdirSync(dirname(notesPath), { recursive: true });
150      appendFileSync(notesPath, JSON.stringify(note) + "\n", "utf8");
151    } catch (err) {
152      log(`failed to persist note to ${notesPath}: ${err}`);
153    }
154  }
155  return notes.length;
156}
157
158function clearNotes(): void {
159  notes = [];
160  if (notesPath) {
161    try {
162      writeFileSync(notesPath, "", "utf8");
163    } catch (err) {
164      log(`failed to clear ${notesPath}: ${err}`);
165    }
166  }
167}
168
169function renderNotes(): string {
170  if (notes.length === 0) return "(scratchpad is empty)";
171  return notes
172    .map((n, i) => `${i + 1}. [${n.at}] ${n.text}`)
173    .join("\n");
174}
175
176// ---- handshake + registration ----
177
178send({
179  type: "hello",
180  protocol_version: 2,
181  name: NAME,
182  version: VERSION,
183  capabilities: ["commands", "tools"],
184});
185
186send({
187  type: "register_command",
188  name: "note",
189  description: "append text to the scratchpad",
190});
191send({
192  type: "register_command",
193  name: "notes",
194  description: "show the scratchpad",
195});
196send({
197  type: "register_command",
198  name: "clear-notes",
199  description: "wipe the scratchpad",
200});
201
202send({
203  type: "register_tool",
204  name: "read_notes",
205  description:
206    "Read the scratchpad. Use this when the user asks about notes or context they have stored, or refers to something from earlier they wanted you to remember.",
207  schema: {
208    type: "object",
209    properties: {},
210  },
211});
212
213// Sentinel: tells zut all initial registrations are flushed so the
214// agent's tool registry can be built without racing the read loop.
215send({ type: "ready" });
216
217// ---- frame loop ----
218
219const rl = createInterface({ input: stdin, crlfDelay: Infinity });
220
221rl.on("line", (line: string) => {
222  let frame: Frame;
223  try {
224    frame = JSON.parse(line) as Frame;
225  } catch (err) {
226    log(`malformed frame: ${err}`);
227    return;
228  }
229
230  switch (frame.type) {
231    case "hello_ack":
232      handleHelloAck(frame as unknown as HelloAck);
233      break;
234    case "command_invoked":
235      handleCommand(frame as unknown as CommandInvoked);
236      break;
237    case "tool_call":
238      handleToolCall(frame as unknown as ToolCall);
239      break;
240    case "shutdown":
241      send({ type: "shutdown_ack" });
242      rl.close();
243      break;
244    default:
245      log(`unknown frame: ${frame.type}`);
246  }
247});
248
249rl.on("close", () => {
250  log("read loop closed; exiting");
251  process.exit(0);
252});
253
254function handleHelloAck(ack: HelloAck): void {
255  if (ack.protocol_version !== 2) {
256    log(`unsupported protocol version: ${ack.protocol_version}`);
257    process.exitCode = 1;
258    rl.close();
259    return;
260  }
261  log(
262    `connected to zut ${ack.zut_version} ` +
263      `(${ack.provider}/${ack.model}, cwd=${ack.cwd})`,
264  );
265  if (ack.cwd) setNotesPath(ack.cwd);
266}
267
268function handleCommand(frame: CommandInvoked): void {
269  const args = (frame.args ?? "").trim();
270
271  switch (frame.name) {
272    case "note": {
273      if (args === "") {
274        respond(frame.id, {
275          type: "command_response",
276          id: frame.id,
277          action: "noop",
278          error: "note: usage is /note <text>",
279        });
280        return;
281      }
282      const n = appendNote(args);
283      respond(frame.id, {
284        type: "command_response",
285        id: frame.id,
286        action: "display",
287        display: `noted (#${n}): ${args}`,
288      });
289      return;
290    }
291
292    case "notes": {
293      respond(frame.id, {
294        type: "command_response",
295        id: frame.id,
296        action: "display",
297        display: renderNotes(),
298      });
299      return;
300    }
301
302    case "clear-notes": {
303      clearNotes();
304      respond(frame.id, {
305        type: "command_response",
306        id: frame.id,
307        action: "display",
308        display: "scratchpad cleared",
309      });
310      return;
311    }
312
313    default:
314      respond(frame.id, {
315        type: "command_response",
316        id: frame.id,
317        action: "noop",
318        error: `unknown command /${frame.name}`,
319      });
320  }
321}
322
323function handleToolCall(frame: ToolCall): void {
324  switch (frame.name) {
325    case "read_notes": {
326      sendToolResult(frame.id, [
327        { type: "text", text: renderNotes() },
328      ]);
329      return;
330    }
331    default:
332      sendToolResult(
333        frame.id,
334        [{ type: "text", text: `unknown tool ${frame.name}` }],
335        true,
336      );
337  }
338}
339
340// ---- send wrappers (typed so misuse is a compile error) ----
341
342function respond(_id: string, response: CommandResponse): void {
343  send(response);
344}
345
346function sendToolResult(
347  id: string,
348  content: ToolContent[],
349  is_error = false,
350): void {
351  const result: ToolResult = {
352    type: "tool_result",
353    id,
354    content,
355    is_error: is_error || undefined,
356  };
357  send(result);
358}

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 477

↑↓ moveenter openesc close