tokenmaw 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ const execFileAsync = promisify(execFile);
6
+ const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
7
+ const MAX_INCLUDE_FILES = 50;
8
+ /**
9
+ * Managed git worktrees for parallel maw sessions on one repository.
10
+ *
11
+ * Worktrees live under `<main-root>/.coder/worktrees/<name>` on branches named
12
+ * `maw/<name>`. Creation is conservative (a dirty source checkout still
13
+ * branches from HEAD, never from origin), removal refuses to touch dirty
14
+ * checkouts or delete branches, and a worktree is `git worktree lock`ed while
15
+ * a session uses it so concurrent cleanup cannot remove live state.
16
+ */
17
+ export class WorktreeManager {
18
+ cwd;
19
+ constructor(cwd) {
20
+ this.cwd = resolve(cwd);
21
+ }
22
+ async git(cwd, args) {
23
+ try {
24
+ const result = await execFileAsync('git', args, { cwd, timeout: 30_000, maxBuffer: 1024 * 1024 * 4 });
25
+ return { code: 0, stdout: result.stdout, stderr: result.stderr };
26
+ }
27
+ catch (error) {
28
+ const err = error;
29
+ return { code: err.code ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? err.message ?? String(error) };
30
+ }
31
+ }
32
+ async inGitRepo(cwd = this.cwd) {
33
+ const result = await this.git(cwd, ['rev-parse', '--is-inside-work-tree']);
34
+ return result.code === 0 && result.stdout.trim() === 'true';
35
+ }
36
+ /** Main (first) worktree root for the repository containing `cwd`. */
37
+ async mainRoot() {
38
+ if (!(await this.inGitRepo()))
39
+ throw new Error('worktree: not inside a git repository');
40
+ const common = await this.git(this.cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
41
+ if (common.code === 0) {
42
+ const dir = common.stdout.trim();
43
+ if (dir) {
44
+ const parent = dirname(dir);
45
+ if (basename(dir) === '.git' && parent)
46
+ return parent;
47
+ return dir;
48
+ }
49
+ }
50
+ const fallback = await this.git(this.cwd, ['worktree', 'list', '--porcelain']);
51
+ if (fallback.code === 0) {
52
+ const first = fallback.stdout.split('\n').find((line) => line.startsWith('worktree '));
53
+ if (first)
54
+ return first.slice('worktree '.length).trim();
55
+ }
56
+ return this.cwd;
57
+ }
58
+ async managedDir() {
59
+ const main = await this.mainRoot();
60
+ return join(main, '.coder', 'worktrees');
61
+ }
62
+ managedDirSync(mainRoot) {
63
+ return join(mainRoot, '.coder', 'worktrees');
64
+ }
65
+ branchFor(name) {
66
+ return `maw/${name}`;
67
+ }
68
+ async metadataPath(name, mainRoot) {
69
+ const dir = mainRoot ? this.managedDirSync(mainRoot) : await this.managedDir();
70
+ return join(dir, `${name}.json`);
71
+ }
72
+ async readMetadata(name, mainRoot) {
73
+ try {
74
+ const raw = await readFile(await this.metadataPath(name, mainRoot), 'utf8');
75
+ const parsed = JSON.parse(raw);
76
+ if (parsed && parsed.name === name)
77
+ return parsed;
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ return undefined;
83
+ }
84
+ async writeMetadata(mainRoot, name, branch) {
85
+ const path = join(this.managedDirSync(mainRoot), `${name}.json`);
86
+ await mkdir(dirname(path), { recursive: true });
87
+ await writeFile(path, `${JSON.stringify({ name, branch, createdAt: new Date().toISOString() }, null, 2)}\n`, 'utf8');
88
+ }
89
+ /** Keep the managed worktree dir out of the user's git status. */
90
+ async excludeManagedDir(mainRoot) {
91
+ const result = await this.git(mainRoot, ['rev-parse', '--git-path', 'info/exclude']);
92
+ if (result.code !== 0)
93
+ return;
94
+ const relative = result.stdout.trim();
95
+ if (!relative)
96
+ return;
97
+ const excludePath = resolve(mainRoot, relative);
98
+ let current = '';
99
+ try {
100
+ current = await readFile(excludePath, 'utf8');
101
+ }
102
+ catch {
103
+ current = '';
104
+ }
105
+ const line = '.coder/worktrees/';
106
+ if (current.split('\n').map((entry) => entry.trim()).includes(line))
107
+ return;
108
+ const next = `${current.trimEnd()}${current.trim() ? '\n' : ''}${line}\n`;
109
+ try {
110
+ await mkdir(dirname(excludePath), { recursive: true });
111
+ await writeFile(excludePath, next, 'utf8');
112
+ }
113
+ catch {
114
+ // Best effort; an untracked .coder dir is acceptable.
115
+ }
116
+ }
117
+ async isGitRepository() {
118
+ return this.inGitRepo();
119
+ }
120
+ /** Create (or reopen) a managed worktree and lock it for the caller. */
121
+ async create(name, options = {}) {
122
+ const trimmed = name.trim();
123
+ if (!NAME_PATTERN.test(trimmed)) {
124
+ throw new Error('worktree: name must be letters, digits, dot, underscore, or dash (max 64 chars)');
125
+ }
126
+ const main = await this.mainRoot();
127
+ const dir = this.managedDirSync(main);
128
+ const path = join(dir, trimmed);
129
+ const branch = this.branchFor(trimmed);
130
+ await this.excludeManagedDir(main);
131
+ let created = false;
132
+ if (!await this.existsDir(path)) {
133
+ await mkdir(dir, { recursive: true });
134
+ const head = await this.git(main, ['rev-parse', 'HEAD']);
135
+ if (head.code !== 0)
136
+ throw new Error(`worktree: cannot resolve HEAD (${head.stderr.trim()})`);
137
+ let base = head.stdout.trim();
138
+ if ((options.baseRef ?? 'fresh') === 'fresh') {
139
+ const originHead = await this.git(main, ['rev-parse', '--verify', 'origin/HEAD']);
140
+ if (originHead.code === 0)
141
+ base = originHead.stdout.trim();
142
+ }
143
+ const branchExists = (await this.git(main, ['rev-parse', '--verify', branch])).code === 0;
144
+ const args = branchExists
145
+ ? ['worktree', 'add', path, branch]
146
+ : ['worktree', 'add', '-b', branch, path, base];
147
+ const add = await this.git(main, args);
148
+ if (add.code !== 0) {
149
+ const stderr = add.stderr.trim();
150
+ if (/already exists|already.*checked out|already used/i.test(stderr)) {
151
+ throw new Error(`worktree: ${trimmed} is already in use (${stderr.split('\n')[0]})`);
152
+ }
153
+ throw new Error(`worktree: git worktree add failed: ${stderr.split('\n')[0]}`);
154
+ }
155
+ created = true;
156
+ }
157
+ await this.writeMetadata(main, trimmed, branch);
158
+ await this.git(main, ['worktree', 'lock', '--reason', `maw session in ${trimmed}`, path]);
159
+ if (created)
160
+ await this.copyIncludedFiles(main, path);
161
+ return { name: trimmed, path, branch, dirty: await this.dirty(path), locked: true, createdAt: (await this.readMetadata(trimmed, main))?.createdAt };
162
+ }
163
+ async existsDir(path) {
164
+ try {
165
+ return (await stat(path)).isDirectory();
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ /** Copy gitignored files matching `.worktreeinclude` from the main checkout. */
172
+ async copyIncludedFiles(mainRoot, worktreePath) {
173
+ let patterns = [];
174
+ try {
175
+ const raw = await readFile(join(mainRoot, '.worktreeinclude'), 'utf8');
176
+ patterns = raw.split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
177
+ }
178
+ catch {
179
+ return;
180
+ }
181
+ if (!patterns.length)
182
+ return;
183
+ const copied = new Set();
184
+ for (const pattern of patterns) {
185
+ const result = await this.git(mainRoot, [
186
+ 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', pattern,
187
+ ]);
188
+ if (result.code !== 0)
189
+ continue;
190
+ for (const file of result.stdout.split('\0').filter(Boolean)) {
191
+ if (copied.size >= MAX_INCLUDE_FILES)
192
+ return;
193
+ if (copied.has(file))
194
+ continue;
195
+ copied.add(file);
196
+ const source = join(mainRoot, file);
197
+ const target = join(worktreePath, file);
198
+ try {
199
+ await mkdir(dirname(target), { recursive: true });
200
+ await copyFile(source, target);
201
+ }
202
+ catch {
203
+ continue;
204
+ }
205
+ }
206
+ }
207
+ }
208
+ async dirty(worktreePath) {
209
+ const result = await this.git(worktreePath, ['status', '--porcelain', '--untracked-files=normal']);
210
+ return result.code === 0 && result.stdout.trim().length > 0;
211
+ }
212
+ async lockedPaths() {
213
+ const main = await this.mainRoot();
214
+ const result = await this.git(main, ['worktree', 'list', '--porcelain']);
215
+ const locked = new Set();
216
+ if (result.code !== 0)
217
+ return locked;
218
+ let currentPath;
219
+ for (const line of result.stdout.split('\n')) {
220
+ if (line.startsWith('worktree '))
221
+ currentPath = line.slice('worktree '.length).trim();
222
+ else if (line.startsWith('locked') && currentPath)
223
+ locked.add(resolve(currentPath));
224
+ else if (line === '')
225
+ currentPath = undefined;
226
+ }
227
+ return locked;
228
+ }
229
+ /** All managed worktrees with fresh status. */
230
+ async list() {
231
+ let dir;
232
+ try {
233
+ dir = await this.managedDir();
234
+ }
235
+ catch {
236
+ return [];
237
+ }
238
+ let names = [];
239
+ try {
240
+ names = (await readdir(dir)).filter((file) => file.endsWith('.json')).map((file) => file.slice(0, -'.json'.length));
241
+ }
242
+ catch {
243
+ return [];
244
+ }
245
+ const locked = await this.lockedPaths();
246
+ const list = [];
247
+ for (const name of names) {
248
+ const meta = await this.readMetadata(name);
249
+ const path = join(dir, name);
250
+ if (!await this.existsDir(path))
251
+ continue;
252
+ list.push({
253
+ name,
254
+ path,
255
+ branch: meta?.branch ?? this.branchFor(name),
256
+ dirty: await this.dirty(path),
257
+ locked: locked.has(resolve(path)),
258
+ createdAt: meta?.createdAt,
259
+ });
260
+ }
261
+ return list.sort((a, b) => a.name.localeCompare(b.name));
262
+ }
263
+ /** Identify the managed worktree containing `path`, if any. */
264
+ async containing(path) {
265
+ const resolved = resolve(path);
266
+ const all = await this.list();
267
+ return all.find((info) => resolved === info.path || resolved.startsWith(info.path + '/'));
268
+ }
269
+ async unlock(name) {
270
+ const info = await this.findExisting(name);
271
+ await this.git((await this.mainRoot()), ['worktree', 'unlock', info.path]);
272
+ }
273
+ async findExisting(name) {
274
+ const all = await this.list();
275
+ const info = all.find((item) => item.name === name);
276
+ if (!info)
277
+ throw new Error(`worktree: no managed worktree named ${name}`);
278
+ return info;
279
+ }
280
+ /** Remove a worktree. Refuses when it holds changes; never deletes the branch. */
281
+ async remove(name, options = {}) {
282
+ const info = await this.findExisting(name);
283
+ if (info.dirty && !options.force) {
284
+ throw new Error(`worktree: ${name} holds uncommitted changes; resolve or use force`);
285
+ }
286
+ const main = await this.mainRoot();
287
+ if (info.locked)
288
+ await this.git(main, ['worktree', 'unlock', info.path]);
289
+ const result = await this.git(main, ['worktree', 'remove', '--force', info.path]);
290
+ if (result.code !== 0 && !/is not a working tree|does not exist/i.test(result.stderr)) {
291
+ throw new Error(`worktree: remove failed: ${result.stderr.trim().split('\n')[0]}`);
292
+ }
293
+ await rm(await this.metadataPath(name, main), { force: true }).catch(() => undefined);
294
+ }
295
+ /**
296
+ * Startup/exit sweep: drop managed worktrees that are clean and unlocked.
297
+ * Dirty or locked worktrees (live sessions) are never touched.
298
+ */
299
+ async sweep() {
300
+ const removed = [];
301
+ let all = [];
302
+ try {
303
+ all = await this.list();
304
+ }
305
+ catch {
306
+ return removed;
307
+ }
308
+ for (const info of all) {
309
+ if (info.dirty || info.locked)
310
+ continue;
311
+ try {
312
+ await this.remove(info.name);
313
+ removed.push(info.name);
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ }
319
+ return removed;
320
+ }
321
+ }
@@ -0,0 +1,231 @@
1
+ import { PassThrough } from 'node:stream';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ const MARKER_START = '\x1b[200~';
4
+ const MARKER_END = '\x1b[201~';
5
+ /** Sequences that switch the terminal's bracketed paste mode on and off. */
6
+ export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h';
7
+ export const BRACKETED_PASTE_DISABLE = '\x1b[?2004l';
8
+ /**
9
+ * Terminals without bracketed-paste support cannot label a paste, but a real
10
+ * keyboard's Enter arrives as a lone `\r`, while `\n` only ever shows up in
11
+ * pasted text (browsers paste CRLF; LF-mode terminals paste LF). So a lone
12
+ * break stays a typed Enter and any larger chunk carrying `\n` is paste.
13
+ */
14
+ /** Browsers paste CRLF; stray CRs would otherwise be typed into the draft. */
15
+ const CRLF = /\r\n/g;
16
+ const CR = /\r/g;
17
+ /** Longest suffix of `text` that is a proper prefix of `marker`. */
18
+ const trailingMarkerPrefix = (text, marker) => {
19
+ for (let length = Math.min(text.length, marker.length - 1); length > 0; length--) {
20
+ if (marker.startsWith(text.slice(text.length - length)))
21
+ return length;
22
+ }
23
+ return 0;
24
+ };
25
+ /**
26
+ * Blessed 0.1.x decodes input byte-by-byte, so every line break inside a
27
+ * pasted chunk arrives as an individual Enter keypress and submits a
28
+ * half-typed draft. This wrapper enables the terminal's bracketed paste mode
29
+ * and reassembles `\x1b[200~ ... \x1b[201~` chunks so the TUI can treat the
30
+ * whole paste as literal content. Terminals without bracketed-paste support
31
+ * (Apple Terminal, some tmux/screen setups) get a fallback: chunks with line
32
+ * breaks are recognised as paste content and inserted literally, while a lone
33
+ * break remains a typed Enter that submits instantly.
34
+ *
35
+ * The real input stream is only proxied: blessed keeps talking to the
36
+ * returned stream (`pause`, `resume`, `setRawMode`, destroyed checks), so the
37
+ * screen lifecycle still restores the underlying terminal on exit.
38
+ */
39
+ export function enableBracketedPaste(rawInput, options = {}) {
40
+ const holdFlushMs = options.holdFlushMs ?? 40;
41
+ const real = rawInput;
42
+ const filtered = new PassThrough();
43
+ const decoder = new StringDecoder('utf8');
44
+ // Blessed only restores cooked mode when it believes raw mode is on, so the
45
+ // proxy must mirror the real stream's state (test doubles track it via
46
+ // setRawMode; a real TTY sets `isRaw` itself).
47
+ let rawMode = false;
48
+ Object.defineProperty(filtered, 'isRaw', {
49
+ get: () => (typeof real.isRaw === 'boolean' ? real.isRaw : rawMode),
50
+ set: (value) => { rawMode = value; },
51
+ });
52
+ // A terminal read error is unrecoverable; never let it crash as a second
53
+ // unhandled 'error' event on the proxy.
54
+ filtered.on('error', () => { });
55
+ const proxied = filtered;
56
+ let hold = '';
57
+ let holdTimer;
58
+ let insidePaste = false;
59
+ // Once the terminal has spoken bracketed paste, plain data is real
60
+ // keystrokes and the `\n` heuristic is retired for this session.
61
+ let bracketedSeen = false;
62
+ proxied.pasteActive = false;
63
+ const emit = (text, pasted) => {
64
+ if (!text)
65
+ return;
66
+ proxied.pasteActive = pasted;
67
+ // PassThrough drains listeners synchronously, so every keypress decoded
68
+ // from this write observes pasteActive.
69
+ filtered.write(text);
70
+ proxied.pasteActive = false;
71
+ };
72
+ const clearHoldTimer = () => {
73
+ if (holdTimer) {
74
+ clearTimeout(holdTimer);
75
+ holdTimer = undefined;
76
+ }
77
+ };
78
+ /** A partial marker held across reads must never wedge a bare Escape. */
79
+ const armHoldFlush = () => {
80
+ if (holdTimer || !hold || insidePaste)
81
+ return;
82
+ holdTimer = setTimeout(() => {
83
+ holdTimer = undefined;
84
+ if (hold) {
85
+ const pending = hold;
86
+ hold = '';
87
+ emit(pending, false);
88
+ }
89
+ }, holdFlushMs);
90
+ holdTimer.unref?.();
91
+ };
92
+ /** Consumes paste content up to (and including) the end marker or chunk end. */
93
+ const consumePasteBody = (text) => {
94
+ let rest = text;
95
+ for (;;) {
96
+ const end = rest.indexOf(MARKER_END);
97
+ if (end < 0) {
98
+ // Keep a potential partial end marker back so a read boundary
99
+ // cannot swallow its tail.
100
+ const keep = trailingMarkerPrefix(rest, MARKER_END);
101
+ const body = rest.slice(0, rest.length - keep);
102
+ if (body)
103
+ emit(body.replace(CRLF, '\n').replace(CR, '\n'), true);
104
+ hold = rest.slice(rest.length - keep);
105
+ insidePaste = true;
106
+ return '';
107
+ }
108
+ // Newlines inside a paste are literal content, never key presses.
109
+ const body = rest.slice(0, end);
110
+ if (body)
111
+ emit(body.replace(CRLF, '\n').replace(CR, '\n'), true);
112
+ insidePaste = false;
113
+ return rest.slice(end + MARKER_END.length);
114
+ }
115
+ };
116
+ /** Plain (marker-free) chunk: typed keystrokes, or a bracketless paste burst. */
117
+ const handlePlain = (chunk) => {
118
+ if (!chunk)
119
+ return;
120
+ // Hold back a partial start marker so a read boundary cannot leak it
121
+ // into the draft as stray characters.
122
+ const keep = trailingMarkerPrefix(chunk, MARKER_START);
123
+ if (keep > 0) {
124
+ hold = chunk.slice(chunk.length - keep);
125
+ chunk = chunk.slice(0, chunk.length - keep);
126
+ if (!chunk) {
127
+ armHoldFlush();
128
+ return;
129
+ }
130
+ }
131
+ if (bracketedSeen) {
132
+ emit(chunk, false);
133
+ return;
134
+ }
135
+ // A lone CR (typed Enter) keeps submitting; LF only rides in pastes.
136
+ // `\r\n` as a whole chunk is also a keyboard Enter (LF-mode terminals).
137
+ if (/^\r?$/.test(chunk) || chunk === '\r\n') {
138
+ emit(chunk === '\r\n' ? '\n' : chunk, false);
139
+ return;
140
+ }
141
+ if (/[\r\n]/.test(chunk)) {
142
+ emit(chunk.replace(CRLF, '\n').replace(CR, '\n'), true);
143
+ }
144
+ else {
145
+ emit(chunk, false);
146
+ }
147
+ };
148
+ const onData = (data) => {
149
+ clearHoldTimer();
150
+ let text = hold + (typeof data === 'string' ? data : decoder.write(data));
151
+ hold = '';
152
+ if (insidePaste)
153
+ text = consumePasteBody(text);
154
+ while (!insidePaste) {
155
+ const start = text.indexOf(MARKER_START);
156
+ if (start < 0)
157
+ break;
158
+ const before = text.slice(0, start);
159
+ text = text.slice(start + MARKER_START.length);
160
+ bracketedSeen = true;
161
+ if (before)
162
+ emit(before, false);
163
+ insidePaste = true;
164
+ text = consumePasteBody(text);
165
+ }
166
+ handlePlain(text);
167
+ armHoldFlush();
168
+ };
169
+ /** EOF or the real stream dying: flush what is held and close the proxy. */
170
+ const finish = () => {
171
+ clearHoldTimer();
172
+ if (hold) {
173
+ emit(hold, insidePaste);
174
+ hold = '';
175
+ }
176
+ if (!filtered.writableEnded)
177
+ filtered.end();
178
+ };
179
+ const onEnd = finish;
180
+ const onClose = finish;
181
+ const onError = (error) => {
182
+ filtered.destroy(error);
183
+ };
184
+ // Attach lazily: only start forwarding the real input once a consumer
185
+ // actually reads from this proxy. Tests monkeypatch `blessed.screen` and
186
+ // swap in their own input stream, so nothing ever consumes the proxy there —
187
+ // eagerly listening on the real stdin would accumulate listeners across
188
+ // tests and keep the process alive after the suite finished.
189
+ let attached = false;
190
+ const attach = () => {
191
+ if (attached)
192
+ return;
193
+ attached = true;
194
+ rawInput.on('data', onData);
195
+ rawInput.on('end', onEnd);
196
+ rawInput.on('close', onClose);
197
+ rawInput.on('error', onError);
198
+ };
199
+ filtered.on('newListener', (event) => {
200
+ if (event === 'data' || event === 'readable')
201
+ attach();
202
+ });
203
+ proxied.setRawMode = (mode) => {
204
+ rawMode = mode;
205
+ real.setRawMode?.(mode);
206
+ };
207
+ // Method overrides are installed via defineProperty so the PassThrough
208
+ // prototype methods remain reachable through their own names.
209
+ const override = (name, implementation) => {
210
+ Object.defineProperty(proxied, name, { value: implementation, writable: true, configurable: true });
211
+ };
212
+ override('pause', () => {
213
+ real.pause?.();
214
+ PassThrough.prototype.pause.call(filtered);
215
+ });
216
+ override('resume', () => {
217
+ real.resume?.();
218
+ PassThrough.prototype.resume.call(filtered);
219
+ });
220
+ override('destroy', ((error) => {
221
+ clearHoldTimer();
222
+ rawInput.removeListener('data', onData);
223
+ rawInput.removeListener('end', onEnd);
224
+ rawInput.removeListener('close', onClose);
225
+ rawInput.removeListener('error', onError);
226
+ // `filtered.destroy` resolves to this own override; calling through the
227
+ // prototype keeps teardown from recursing into itself.
228
+ PassThrough.prototype.destroy.call(filtered, error);
229
+ }));
230
+ return proxied;
231
+ }
@@ -1,6 +1,18 @@
1
1
  export const SLASH_COMMANDS = [
2
2
  { name: '/provider', description: 'Manage providers' },
3
3
  { name: '/model', description: 'Choose a model' },
4
+ { name: '/aside', description: 'Queue an aside to fold into the next message' },
5
+ { name: '/btw', description: 'Ask in a side conversation forked from this one' },
6
+ { name: '/back', description: 'Return from a side conversation to the parent session' },
7
+ { name: '/fork', description: 'Copy this conversation into a new saved session' },
8
+ { name: '/goal', description: 'Set a standing goal for this session' },
9
+ { name: '/cd', description: 'Switch the working directory' },
10
+ { name: '/pwd', description: 'Show the working directory' },
11
+ { name: '/worktree', description: 'Create or enter an isolated git worktree' },
12
+ { name: '/worktree-list', description: 'List managed worktrees' },
13
+ { name: '/worktree-exit', description: 'Return to the main checkout' },
14
+ { name: '/worktree-remove', description: 'Remove a clean worktree (branch kept)' },
15
+ { name: '/theme', description: 'Switch color theme' },
4
16
  { name: '/agents', description: 'Inspect agent specs' },
5
17
  { name: '/sessions', description: 'Open a saved conversation' },
6
18
  { name: '/new', description: 'Start a conversation' },