@lelouchhe/webagent 0.1.6 → 0.1.9
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.
- package/README.md +21 -18
- package/config.toml +1 -1
- package/dist/index.html +3 -2
- package/dist/js/app.IXP5KGP6.js +8 -0
- package/dist/{styles.mmlj9sk2.css → styles.01a9ju9l.css} +28 -1
- package/dist/sw.js +1 -1
- package/lib/event-handler.js +15 -6
- package/lib/push-service.js +66 -10
- package/lib/routes.js +816 -89
- package/lib/server.js +12 -10
- package/lib/session-manager.js +79 -2
- package/lib/shared/constants.js +16 -0
- package/lib/sse-manager.js +80 -0
- package/lib/store.js +61 -7
- package/lib/types.js +0 -35
- package/package.json +5 -6
- package/dist/js/app.mmlj9sk2.js +0 -34
- package/dist/js/commands.mmlj9sk2.js +0 -647
- package/dist/js/connection.mmlj9sk2.js +0 -87
- package/dist/js/events.mmlj9sk2.js +0 -674
- package/dist/js/images.mmlj9sk2.js +0 -58
- package/dist/js/input.mmlj9sk2.js +0 -215
- package/dist/js/render.mmlj9sk2.js +0 -200
- package/dist/js/state.mmlj9sk2.js +0 -178
- package/lib/ws-handler.js +0 -280
|
@@ -1,647 +0,0 @@
|
|
|
1
|
-
// Slash commands and autocomplete menu
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
state, dom, setBusy, resetSessionUI, requestNewSession, sendCancel,
|
|
5
|
-
getConfigOption, getConfigValue, setHashSessionId, updateSessionInfo,
|
|
6
|
-
updateNewBtnVisibility,
|
|
7
|
-
} from './state.mmlj9sk2.js';
|
|
8
|
-
import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.mmlj9sk2.js';
|
|
9
|
-
import { loadHistory } from './events.mmlj9sk2.js';
|
|
10
|
-
|
|
11
|
-
// --- Push notification helpers ---
|
|
12
|
-
|
|
13
|
-
async function subscribePush() {
|
|
14
|
-
try {
|
|
15
|
-
const reg = await navigator.serviceWorker?.ready;
|
|
16
|
-
if (!reg) return;
|
|
17
|
-
const res = await fetch('/api/push/vapid-key');
|
|
18
|
-
if (!res.ok) return;
|
|
19
|
-
const { publicKey } = await res.json();
|
|
20
|
-
const sub = await reg.pushManager.subscribe({
|
|
21
|
-
userVisibleOnly: true,
|
|
22
|
-
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
|
23
|
-
});
|
|
24
|
-
const json = sub.toJSON();
|
|
25
|
-
await fetch('/api/push/subscribe', {
|
|
26
|
-
method: 'POST',
|
|
27
|
-
headers: { 'Content-Type': 'application/json' },
|
|
28
|
-
body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
|
|
29
|
-
});
|
|
30
|
-
} catch (err) {
|
|
31
|
-
console.error('[push] subscribe failed:', err);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function unsubscribePush() {
|
|
36
|
-
try {
|
|
37
|
-
const reg = await navigator.serviceWorker?.ready;
|
|
38
|
-
if (!reg) return;
|
|
39
|
-
const sub = await reg.pushManager.getSubscription();
|
|
40
|
-
if (!sub) return;
|
|
41
|
-
const endpoint = sub.endpoint;
|
|
42
|
-
await sub.unsubscribe();
|
|
43
|
-
await fetch('/api/push/unsubscribe', {
|
|
44
|
-
method: 'POST',
|
|
45
|
-
headers: { 'Content-Type': 'application/json' },
|
|
46
|
-
body: JSON.stringify({ endpoint }),
|
|
47
|
-
});
|
|
48
|
-
} catch (err) {
|
|
49
|
-
console.error('[push] unsubscribe failed:', err);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function urlBase64ToUint8Array(base64String) {
|
|
54
|
-
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
55
|
-
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
56
|
-
const raw = atob(base64);
|
|
57
|
-
const arr = new Uint8Array(raw.length);
|
|
58
|
-
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
|
59
|
-
return arr;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function hasActiveSubscription() {
|
|
63
|
-
try {
|
|
64
|
-
const reg = await navigator.serviceWorker?.ready;
|
|
65
|
-
if (!reg) return false;
|
|
66
|
-
const sub = await reg.pushManager.getSubscription();
|
|
67
|
-
return sub !== null;
|
|
68
|
-
} catch { return false; }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// --- Slash command execution ---
|
|
72
|
-
|
|
73
|
-
export async function handleSlashCommand(text) {
|
|
74
|
-
const parts = text.split(/\s+/);
|
|
75
|
-
const cmd = parts[0].toLowerCase();
|
|
76
|
-
const arg = parts.slice(1).join(' ').trim();
|
|
77
|
-
|
|
78
|
-
switch (cmd) {
|
|
79
|
-
case '/new': {
|
|
80
|
-
resetSessionUI();
|
|
81
|
-
addSystem('Creating new session…');
|
|
82
|
-
requestNewSession({ cwd: arg || state.sessionCwd });
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
case '/pwd':
|
|
87
|
-
addSystem(`📁 ${state.sessionCwd || 'unknown'}`);
|
|
88
|
-
return true;
|
|
89
|
-
|
|
90
|
-
case '/sessions':
|
|
91
|
-
addSystem('Removed. Use /switch to see all sessions.');
|
|
92
|
-
return true;
|
|
93
|
-
|
|
94
|
-
case '/delete': {
|
|
95
|
-
if (!arg) {
|
|
96
|
-
addSystem('Usage: /delete <title or id prefix>');
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
99
|
-
try {
|
|
100
|
-
const res = await fetch('/api/sessions');
|
|
101
|
-
const sessions = await res.json();
|
|
102
|
-
const query = arg.toLowerCase();
|
|
103
|
-
const match = sessions.find(s =>
|
|
104
|
-
s.id !== state.sessionId &&
|
|
105
|
-
(s.id.startsWith(arg) || (s.title && s.title.toLowerCase().includes(query)))
|
|
106
|
-
);
|
|
107
|
-
if (!match) {
|
|
108
|
-
addSystem(`err: No session matching "${arg}"`);
|
|
109
|
-
return true;
|
|
110
|
-
}
|
|
111
|
-
state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: match.id }));
|
|
112
|
-
addSystem(`Deleted: ${match.title || match.id.slice(0, 8) + '…'}`);
|
|
113
|
-
} catch {
|
|
114
|
-
addSystem('err: Failed to delete session');
|
|
115
|
-
}
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
case '/prune': {
|
|
120
|
-
try {
|
|
121
|
-
const res = await fetch('/api/sessions');
|
|
122
|
-
const sessions = await res.json();
|
|
123
|
-
const toDelete = sessions.filter(s => s.id !== state.sessionId);
|
|
124
|
-
if (toDelete.length === 0) {
|
|
125
|
-
addSystem('No other sessions to prune.');
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
for (const s of toDelete) {
|
|
129
|
-
state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: s.id }));
|
|
130
|
-
}
|
|
131
|
-
addSystem(`Pruned ${toDelete.length} session(s).`);
|
|
132
|
-
} catch {
|
|
133
|
-
addSystem('err: Failed to prune sessions');
|
|
134
|
-
}
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
case '/switch': {
|
|
139
|
-
if (!arg) {
|
|
140
|
-
addSystem('Usage: /switch <title or id prefix>');
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
const res = await fetch('/api/sessions');
|
|
145
|
-
const sessions = await res.json();
|
|
146
|
-
const query = arg.toLowerCase();
|
|
147
|
-
const match = sessions.find(s =>
|
|
148
|
-
s.id.startsWith(arg) ||
|
|
149
|
-
(s.title && s.title.toLowerCase().includes(query))
|
|
150
|
-
);
|
|
151
|
-
if (!match) {
|
|
152
|
-
addSystem(`err: No session matching "${arg}"`);
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
resetSessionUI();
|
|
156
|
-
state.sessionId = match.id;
|
|
157
|
-
state.sessionTitle = match.title || null;
|
|
158
|
-
setHashSessionId(match.id);
|
|
159
|
-
updateSessionInfo(match.id, match.title);
|
|
160
|
-
await loadHistory(match.id);
|
|
161
|
-
scrollToBottom(true);
|
|
162
|
-
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: match.id }));
|
|
163
|
-
} catch {
|
|
164
|
-
addSystem('err: Failed to switch session');
|
|
165
|
-
}
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
case '/cancel':
|
|
170
|
-
if (state.busy) {
|
|
171
|
-
sendCancel();
|
|
172
|
-
addSystem('^X');
|
|
173
|
-
} else {
|
|
174
|
-
addSystem('Nothing to cancel.');
|
|
175
|
-
}
|
|
176
|
-
return true;
|
|
177
|
-
|
|
178
|
-
case '/help':
|
|
179
|
-
case '?':
|
|
180
|
-
addSystem('? — Show help');
|
|
181
|
-
addSystem('/help — Show help (alias)');
|
|
182
|
-
addSystem('!<command> — Run bash command');
|
|
183
|
-
for (const c of SLASH_COMMANDS) {
|
|
184
|
-
const label = c.args ? `${c.cmd} ${c.args}` : c.cmd;
|
|
185
|
-
addSystem(`${label} — ${c.desc}`);
|
|
186
|
-
}
|
|
187
|
-
addSystem('--- Shortcuts ---');
|
|
188
|
-
for (const s of SHORTCUTS) {
|
|
189
|
-
addSystem(`${s.key} — ${s.desc}`);
|
|
190
|
-
}
|
|
191
|
-
return true;
|
|
192
|
-
|
|
193
|
-
case '/model':
|
|
194
|
-
case '/mode':
|
|
195
|
-
case '/think': {
|
|
196
|
-
const configMap = { '/model': 'model', '/mode': 'mode', '/think': 'reasoning_effort' };
|
|
197
|
-
const configId = configMap[cmd];
|
|
198
|
-
const opt = getConfigOption(configId);
|
|
199
|
-
if (!arg) {
|
|
200
|
-
const valueName = opt?.options.find(o => o.value === opt.currentValue)?.name || opt?.currentValue || 'unknown';
|
|
201
|
-
addSystem(`${opt?.name || configId}: ${valueName}`);
|
|
202
|
-
addSystem(`Type ${cmd} + space to pick from list`);
|
|
203
|
-
return true;
|
|
204
|
-
}
|
|
205
|
-
if (!opt) {
|
|
206
|
-
addSystem(`err: ${cmd.slice(1)} is not available.`);
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
const query = arg.trim();
|
|
210
|
-
const normalize = (s) => s.toLowerCase().replace(/[\s_]+/g, '-');
|
|
211
|
-
const normalizedQuery = normalize(query);
|
|
212
|
-
let match = opt.options.find(o => normalize(o.value) === normalizedQuery || normalize(o.name) === normalizedQuery);
|
|
213
|
-
if (!match) {
|
|
214
|
-
const matches = opt.options.filter(o =>
|
|
215
|
-
normalize(o.value).includes(normalizedQuery) || normalize(o.name).includes(normalizedQuery)
|
|
216
|
-
);
|
|
217
|
-
if (matches.length === 1) {
|
|
218
|
-
match = matches[0];
|
|
219
|
-
} else if (matches.length > 1) {
|
|
220
|
-
addSystem(`err: Ambiguous "${arg}". Type ${cmd} + space to see options.`);
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
if (!match) {
|
|
225
|
-
addSystem(`err: Unknown "${arg}". Type ${cmd} + space to see options.`);
|
|
226
|
-
return true;
|
|
227
|
-
}
|
|
228
|
-
state.ws.send(JSON.stringify({ type: 'set_config_option', sessionId: state.sessionId, configId, value: match.value }));
|
|
229
|
-
addSystem(`${opt.name} → ${match.name}`);
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
case '/notify': {
|
|
234
|
-
if (typeof Notification === 'undefined') {
|
|
235
|
-
addSystem('err: notifications not supported in this browser');
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const sub = arg.toLowerCase();
|
|
240
|
-
|
|
241
|
-
if (sub === 'on') {
|
|
242
|
-
if (Notification.permission === 'denied') {
|
|
243
|
-
addSystem('notify: blocked — allow in browser site settings to enable');
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
if (Notification.permission !== 'granted') {
|
|
247
|
-
const result = await Notification.requestPermission();
|
|
248
|
-
if (result !== 'granted') {
|
|
249
|
-
addSystem('notify: blocked — allow in browser site settings to enable');
|
|
250
|
-
return true;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
const alreadyActive = await hasActiveSubscription();
|
|
254
|
-
await subscribePush();
|
|
255
|
-
addSystem(alreadyActive ? 'notify: already enabled' : 'notify: enabled');
|
|
256
|
-
return true;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
if (sub === 'off') {
|
|
260
|
-
await unsubscribePush();
|
|
261
|
-
addSystem('notify: disabled');
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// No argument — show status based on actual subscription
|
|
266
|
-
const perm = Notification.permission;
|
|
267
|
-
if (perm === 'denied') {
|
|
268
|
-
addSystem('notify: blocked — allow in browser site settings to enable');
|
|
269
|
-
} else if (perm === 'granted' && await hasActiveSubscription()) {
|
|
270
|
-
addSystem('notify: enabled');
|
|
271
|
-
} else {
|
|
272
|
-
addSystem('notify: off — use /notify on to enable');
|
|
273
|
-
}
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
default:
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// --- Slash command autocomplete ---
|
|
283
|
-
|
|
284
|
-
const SLASH_COMMANDS = [
|
|
285
|
-
{ cmd: '/cancel', args: '', desc: 'Cancel current response' },
|
|
286
|
-
{ cmd: '/delete', args: '<title|id>', desc: 'Delete a session' },
|
|
287
|
-
{ cmd: '/mode', args: '[name]', desc: 'Pick or switch mode' },
|
|
288
|
-
{ cmd: '/model', args: '[name]', desc: 'Pick or switch model' },
|
|
289
|
-
{ cmd: '/new', args: '[cwd]', desc: 'New session' },
|
|
290
|
-
{ cmd: '/notify', args: '[on|off]', desc: 'Toggle background notifications' },
|
|
291
|
-
{ cmd: '/prune', args: '', desc: 'Delete all sessions except current' },
|
|
292
|
-
{ cmd: '/pwd', args: '', desc: 'Show working directory' },
|
|
293
|
-
{ cmd: '/switch', args: '<title|id>', desc: 'Switch to session' },
|
|
294
|
-
{ cmd: '/think', args: '[level]', desc: 'Pick or switch reasoning effort' },
|
|
295
|
-
];
|
|
296
|
-
|
|
297
|
-
const SHORTCUTS = [
|
|
298
|
-
{ key: 'Enter', desc: 'Send message' },
|
|
299
|
-
{ key: 'Shift+Enter', desc: 'New line' },
|
|
300
|
-
{ key: '^X', desc: 'Cancel current response' },
|
|
301
|
-
{ key: '^M', desc: 'Cycle mode (Agent → Plan → Autopilot)' },
|
|
302
|
-
{ key: '^U', desc: 'Upload image' },
|
|
303
|
-
];
|
|
304
|
-
|
|
305
|
-
let slashIdx = -1;
|
|
306
|
-
let slashFiltered = [];
|
|
307
|
-
let slashMode = 'commands';
|
|
308
|
-
let slashConfigId = null;
|
|
309
|
-
let cachedSessions = null;
|
|
310
|
-
let slashDismissed = null;
|
|
311
|
-
|
|
312
|
-
export function updateSlashMenu() {
|
|
313
|
-
const text = dom.input.value;
|
|
314
|
-
|
|
315
|
-
if (slashDismissed !== null) {
|
|
316
|
-
if (text === slashDismissed) return;
|
|
317
|
-
slashDismissed = null;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// /new — show path picker
|
|
321
|
-
const newMatch = text.match(/^\/new /);
|
|
322
|
-
if (newMatch) {
|
|
323
|
-
const query = text.slice(newMatch[0].length).toLowerCase();
|
|
324
|
-
fetchPathsForMenu(query);
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// /switch or /delete — show session picker
|
|
329
|
-
const switchMatch = text.match(/^\/(switch|delete) /);
|
|
330
|
-
if (switchMatch) {
|
|
331
|
-
const query = text.slice(switchMatch[0].length).toLowerCase();
|
|
332
|
-
fetchSessionsForMenu(query, switchMatch[1]);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// /model, /mode, /think — show config option picker
|
|
337
|
-
const configMatch = text.match(/^\/(model|mode|think) /);
|
|
338
|
-
if (configMatch) {
|
|
339
|
-
const configMap = { model: 'model', mode: 'mode', think: 'reasoning_effort' };
|
|
340
|
-
const configId = configMap[configMatch[1]];
|
|
341
|
-
const query = text.slice(configMatch[0].length).toLowerCase();
|
|
342
|
-
showConfigMenu(configId, query);
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// /notify — show on/off picker
|
|
347
|
-
const notifyMatch = text.match(/^\/notify /);
|
|
348
|
-
if (notifyMatch) {
|
|
349
|
-
const query = text.slice(notifyMatch[0].length).toLowerCase();
|
|
350
|
-
showNotifyMenu(query);
|
|
351
|
-
return;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (!text.startsWith('/') || text.includes(' ')) {
|
|
355
|
-
hideSlashMenu();
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
slashMode = 'commands';
|
|
359
|
-
const prefix = text.toLowerCase();
|
|
360
|
-
slashFiltered = SLASH_COMMANDS.filter(c => c.cmd.startsWith(prefix));
|
|
361
|
-
if (slashFiltered.length === 0) {
|
|
362
|
-
hideSlashMenu();
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
slashIdx = 0;
|
|
366
|
-
renderSlashMenu();
|
|
367
|
-
dom.slashMenu.classList.add('active');
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function fetchSessionsForMenu(query, mode = 'switch') {
|
|
371
|
-
if (!cachedSessions) {
|
|
372
|
-
try {
|
|
373
|
-
const res = await fetch('/api/sessions');
|
|
374
|
-
cachedSessions = await res.json();
|
|
375
|
-
setTimeout(() => { cachedSessions = null; }, 5000);
|
|
376
|
-
} catch { return; }
|
|
377
|
-
}
|
|
378
|
-
slashMode = mode;
|
|
379
|
-
const items = cachedSessions
|
|
380
|
-
.filter(s => {
|
|
381
|
-
if (!query) return true;
|
|
382
|
-
return (s.title && s.title.toLowerCase().includes(query)) || s.id.startsWith(query);
|
|
383
|
-
});
|
|
384
|
-
slashFiltered = items;
|
|
385
|
-
if (slashFiltered.length === 0) {
|
|
386
|
-
hideSlashMenu();
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
slashIdx = 0;
|
|
390
|
-
renderSlashMenu();
|
|
391
|
-
dom.slashMenu.classList.add('active');
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
async function fetchPathsForMenu(query) {
|
|
395
|
-
if (!cachedSessions) {
|
|
396
|
-
try {
|
|
397
|
-
const res = await fetch('/api/sessions');
|
|
398
|
-
cachedSessions = await res.json();
|
|
399
|
-
setTimeout(() => { cachedSessions = null; }, 5000);
|
|
400
|
-
} catch { return; }
|
|
401
|
-
}
|
|
402
|
-
slashMode = 'new';
|
|
403
|
-
// Deduplicate paths, keeping the most recent last_active_at for each
|
|
404
|
-
const pathMap = new Map();
|
|
405
|
-
for (const s of cachedSessions) {
|
|
406
|
-
const existing = pathMap.get(s.cwd);
|
|
407
|
-
if (!existing || (s.last_active_at || s.created_at) > (existing.time)) {
|
|
408
|
-
pathMap.set(s.cwd, { cwd: s.cwd, time: s.last_active_at || s.created_at });
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
let items = [...pathMap.values()].sort((a, b) => b.time.localeCompare(a.time));
|
|
412
|
-
if (query) {
|
|
413
|
-
items = items.filter(p => p.cwd.toLowerCase().includes(query));
|
|
414
|
-
}
|
|
415
|
-
slashFiltered = items;
|
|
416
|
-
if (slashFiltered.length === 0) {
|
|
417
|
-
hideSlashMenu();
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
slashIdx = 0;
|
|
421
|
-
renderSlashMenu();
|
|
422
|
-
dom.slashMenu.classList.add('active');
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
function showConfigMenu(configId, query) {
|
|
426
|
-
const opt = getConfigOption(configId);
|
|
427
|
-
if (!opt) { hideSlashMenu(); return; }
|
|
428
|
-
slashMode = 'config';
|
|
429
|
-
slashConfigId = configId;
|
|
430
|
-
slashFiltered = opt.options.filter(o => {
|
|
431
|
-
if (!query) return true;
|
|
432
|
-
return o.value.toLowerCase().includes(query) || o.name.toLowerCase().includes(query);
|
|
433
|
-
});
|
|
434
|
-
if (slashFiltered.length === 0) {
|
|
435
|
-
hideSlashMenu();
|
|
436
|
-
return;
|
|
437
|
-
}
|
|
438
|
-
slashIdx = 0;
|
|
439
|
-
renderSlashMenu();
|
|
440
|
-
dom.slashMenu.classList.add('active');
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const NOTIFY_OPTIONS = [
|
|
444
|
-
{ value: 'on', name: 'on', desc: 'Enable background notifications' },
|
|
445
|
-
{ value: 'off', name: 'off', desc: 'Disable background notifications' },
|
|
446
|
-
];
|
|
447
|
-
|
|
448
|
-
function showNotifyMenu(query) {
|
|
449
|
-
slashMode = 'notify';
|
|
450
|
-
slashFiltered = NOTIFY_OPTIONS.filter(o => {
|
|
451
|
-
if (!query) return true;
|
|
452
|
-
return o.value.includes(query) || o.name.includes(query);
|
|
453
|
-
});
|
|
454
|
-
if (slashFiltered.length === 0) {
|
|
455
|
-
hideSlashMenu();
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
slashIdx = 0;
|
|
459
|
-
renderSlashMenu();
|
|
460
|
-
dom.slashMenu.classList.add('active');
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function renderSlashMenu() {
|
|
464
|
-
if (slashMode === 'new') {
|
|
465
|
-
const currentCwd = (state.sessionCwd || '').toLowerCase();
|
|
466
|
-
dom.slashMenu.innerHTML = slashFiltered.map((p, i) => {
|
|
467
|
-
const isCurrent = p.cwd.toLowerCase() === currentCwd;
|
|
468
|
-
const prefix = isCurrent ? '* ' : ' ';
|
|
469
|
-
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
470
|
-
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + p.cwd)}</span></div>`;
|
|
471
|
-
}).join('');
|
|
472
|
-
} else if (slashMode === 'config') {
|
|
473
|
-
const current = getConfigValue(slashConfigId)?.toLowerCase() || '';
|
|
474
|
-
dom.slashMenu.innerHTML = slashFiltered.map((o, i) => {
|
|
475
|
-
const isCurrent = o.value.toLowerCase() === current;
|
|
476
|
-
const prefix = isCurrent ? '* ' : ' ';
|
|
477
|
-
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
478
|
-
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + o.name)}</span></div>`;
|
|
479
|
-
}).join('');
|
|
480
|
-
} else if (slashMode === 'notify') {
|
|
481
|
-
const perm = typeof Notification !== 'undefined' ? Notification.permission : 'unsupported';
|
|
482
|
-
const currentVal = perm === 'granted' ? 'on' : 'off';
|
|
483
|
-
dom.slashMenu.innerHTML = slashFiltered.map((o, i) => {
|
|
484
|
-
const isCurrent = o.value === currentVal;
|
|
485
|
-
const prefix = isCurrent ? '* ' : ' ';
|
|
486
|
-
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
487
|
-
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + o.name)}</span><span class="slash-desc">${escHtml(o.desc)}</span></div>`;
|
|
488
|
-
}).join('');
|
|
489
|
-
} else if (slashMode === 'switch' || slashMode === 'delete') {
|
|
490
|
-
dom.slashMenu.innerHTML = slashFiltered.map((s, i) => {
|
|
491
|
-
const isCurrent = s.id === state.sessionId;
|
|
492
|
-
const prefix = isCurrent ? '* ' : ' ';
|
|
493
|
-
const label = s.title || s.id.slice(0, 8) + '…';
|
|
494
|
-
const time = formatLocalTime(s.last_active_at || s.created_at);
|
|
495
|
-
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
496
|
-
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + label)}</span><span class="slash-desc">${escHtml(s.cwd)} (${escHtml(time)})</span></div>`;
|
|
497
|
-
}).join('');
|
|
498
|
-
} else {
|
|
499
|
-
dom.slashMenu.innerHTML = slashFiltered.map((c, i) => {
|
|
500
|
-
const label = c.args ? `${c.cmd} ${c.args}` : c.cmd;
|
|
501
|
-
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd">${escHtml(label)}</span><span class="slash-desc">${escHtml(c.desc)}</span></div>`;
|
|
502
|
-
}).join('');
|
|
503
|
-
}
|
|
504
|
-
const sel = dom.slashMenu.querySelector('.selected');
|
|
505
|
-
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
export function hideSlashMenu() {
|
|
509
|
-
dom.slashMenu.classList.remove('active');
|
|
510
|
-
slashIdx = -1;
|
|
511
|
-
slashFiltered = [];
|
|
512
|
-
slashMode = 'commands';
|
|
513
|
-
slashDismissed = dom.input.value;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Tab: fill input only, never execute
|
|
517
|
-
function tabCompleteSlashItem(idx) {
|
|
518
|
-
if (idx < 0 || idx >= slashFiltered.length) return;
|
|
519
|
-
|
|
520
|
-
if (slashMode === 'commands') {
|
|
521
|
-
const item = slashFiltered[idx];
|
|
522
|
-
dom.input.value = item.cmd + (item.args ? ' ' : '');
|
|
523
|
-
hideSlashMenu();
|
|
524
|
-
dom.input.focus();
|
|
525
|
-
if (['/new', '/switch', '/delete', '/model', '/mode', '/think', '/notify'].includes(item.cmd)) {
|
|
526
|
-
slashDismissed = null;
|
|
527
|
-
updateSlashMenu();
|
|
528
|
-
}
|
|
529
|
-
} else if (slashMode === 'config') {
|
|
530
|
-
const o = slashFiltered[idx];
|
|
531
|
-
const configCmd = { model: '/model', mode: '/mode', reasoning_effort: '/think' }[slashConfigId] || `/${slashConfigId}`;
|
|
532
|
-
dom.input.value = `${configCmd} ${o.name}`;
|
|
533
|
-
hideSlashMenu();
|
|
534
|
-
dom.input.focus();
|
|
535
|
-
} else if (slashMode === 'notify') {
|
|
536
|
-
const o = slashFiltered[idx];
|
|
537
|
-
dom.input.value = `/notify ${o.value}`;
|
|
538
|
-
hideSlashMenu();
|
|
539
|
-
dom.input.focus();
|
|
540
|
-
} else if (slashMode === 'new') {
|
|
541
|
-
const p = slashFiltered[idx];
|
|
542
|
-
dom.input.value = `/new ${p.cwd}`;
|
|
543
|
-
hideSlashMenu();
|
|
544
|
-
dom.input.focus();
|
|
545
|
-
} else if (slashMode === 'switch') {
|
|
546
|
-
const s = slashFiltered[idx];
|
|
547
|
-
dom.input.value = `/switch ${s.title || s.id}`;
|
|
548
|
-
hideSlashMenu();
|
|
549
|
-
dom.input.focus();
|
|
550
|
-
} else if (slashMode === 'delete') {
|
|
551
|
-
const s = slashFiltered[idx];
|
|
552
|
-
dom.input.value = `/delete ${s.title || s.id}`;
|
|
553
|
-
hideSlashMenu();
|
|
554
|
-
dom.input.focus();
|
|
555
|
-
}
|
|
556
|
-
updateNewBtnVisibility();
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Click: fill input AND execute (equivalent to tab + enter)
|
|
560
|
-
function selectSlashItem(idx) {
|
|
561
|
-
if (idx < 0 || idx >= slashFiltered.length) return;
|
|
562
|
-
|
|
563
|
-
if (slashMode === 'new') {
|
|
564
|
-
const p = slashFiltered[idx];
|
|
565
|
-
dom.input.value = '';
|
|
566
|
-
hideSlashMenu();
|
|
567
|
-
resetSessionUI();
|
|
568
|
-
addSystem('Creating new session…');
|
|
569
|
-
requestNewSession({ cwd: p.cwd });
|
|
570
|
-
} else if (slashMode === 'config') {
|
|
571
|
-
const o = slashFiltered[idx];
|
|
572
|
-
const configId = slashConfigId;
|
|
573
|
-
const opt = getConfigOption(configId);
|
|
574
|
-
dom.input.value = '';
|
|
575
|
-
hideSlashMenu();
|
|
576
|
-
state.ws.send(JSON.stringify({ type: 'set_config_option', sessionId: state.sessionId, configId, value: o.value }));
|
|
577
|
-
addSystem(`${opt?.name || configId} → ${o.name}`);
|
|
578
|
-
} else if (slashMode === 'switch') {
|
|
579
|
-
const s = slashFiltered[idx];
|
|
580
|
-
dom.input.value = '';
|
|
581
|
-
hideSlashMenu();
|
|
582
|
-
resetSessionUI();
|
|
583
|
-
state.sessionId = s.id;
|
|
584
|
-
state.sessionTitle = s.title || null;
|
|
585
|
-
setHashSessionId(s.id);
|
|
586
|
-
updateSessionInfo(s.id, s.title);
|
|
587
|
-
addSystem('Switching…');
|
|
588
|
-
loadHistory(s.id).then(loaded => { if (loaded) scrollToBottom(true); });
|
|
589
|
-
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: s.id }));
|
|
590
|
-
} else if (slashMode === 'delete') {
|
|
591
|
-
const s = slashFiltered[idx];
|
|
592
|
-
dom.input.value = '';
|
|
593
|
-
hideSlashMenu();
|
|
594
|
-
state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: s.id }));
|
|
595
|
-
addSystem(`Deleted: ${s.title || s.id.slice(0, 8) + '…'}`);
|
|
596
|
-
} else if (slashMode === 'notify') {
|
|
597
|
-
const o = slashFiltered[idx];
|
|
598
|
-
dom.input.value = `/notify ${o.value}`;
|
|
599
|
-
hideSlashMenu();
|
|
600
|
-
// Trigger command execution by simulating send
|
|
601
|
-
handleSlashCommand(dom.input.value);
|
|
602
|
-
dom.input.value = '';
|
|
603
|
-
} else {
|
|
604
|
-
const item = slashFiltered[idx];
|
|
605
|
-
dom.input.value = item.cmd + (item.args ? ' ' : '');
|
|
606
|
-
hideSlashMenu();
|
|
607
|
-
dom.input.focus();
|
|
608
|
-
if (['/new', '/switch', '/delete', '/model', '/mode', '/think', '/notify'].includes(item.cmd)) {
|
|
609
|
-
slashDismissed = null;
|
|
610
|
-
updateSlashMenu();
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
updateNewBtnVisibility();
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Handle keyboard navigation within the slash menu
|
|
617
|
-
export function handleSlashMenuKey(e) {
|
|
618
|
-
if (!dom.slashMenu.classList.contains('active')) return false;
|
|
619
|
-
if (e.key === 'ArrowDown') {
|
|
620
|
-
slashIdx = (slashIdx + 1) % slashFiltered.length;
|
|
621
|
-
renderSlashMenu();
|
|
622
|
-
return true;
|
|
623
|
-
}
|
|
624
|
-
if (e.key === 'ArrowUp') {
|
|
625
|
-
slashIdx = (slashIdx - 1 + slashFiltered.length) % slashFiltered.length;
|
|
626
|
-
renderSlashMenu();
|
|
627
|
-
return true;
|
|
628
|
-
}
|
|
629
|
-
if (e.key === 'Tab') {
|
|
630
|
-
tabCompleteSlashItem(slashIdx);
|
|
631
|
-
return true;
|
|
632
|
-
}
|
|
633
|
-
return false;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// --- Event listeners ---
|
|
637
|
-
|
|
638
|
-
dom.slashMenu.addEventListener('mousedown', (e) => {
|
|
639
|
-
e.preventDefault();
|
|
640
|
-
const item = e.target.closest('.slash-item');
|
|
641
|
-
if (item) selectSlashItem(Number(item.dataset.idx));
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
dom.input.addEventListener('input', () => {
|
|
645
|
-
updateSlashMenu();
|
|
646
|
-
dom.inputArea.classList.toggle('bash-mode', dom.input.value.startsWith('!'));
|
|
647
|
-
});
|