@lelouchhe/webagent 0.1.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.
- package/LICENSE +21 -0
- package/README.md +244 -0
- package/bin/webagent.mjs +23 -0
- package/config.toml +28 -0
- package/dist/icons/icon-180.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon.svg +4 -0
- package/dist/index.html +46 -0
- package/dist/js/app.mmjqzu9r.js +10 -0
- package/dist/js/commands.mmjqzu9r.js +454 -0
- package/dist/js/connection.mmjqzu9r.js +76 -0
- package/dist/js/events.mmjqzu9r.js +612 -0
- package/dist/js/images.mmjqzu9r.js +58 -0
- package/dist/js/input.mmjqzu9r.js +196 -0
- package/dist/js/render.mmjqzu9r.js +200 -0
- package/dist/js/state.mmjqzu9r.js +176 -0
- package/dist/manifest.json +26 -0
- package/dist/styles.mmjqzu9r.css +555 -0
- package/dist/sw.js +5 -0
- package/package.json +56 -0
- package/src/bridge.ts +317 -0
- package/src/config.ts +65 -0
- package/src/routes.ts +147 -0
- package/src/server.ts +159 -0
- package/src/session-manager.ts +223 -0
- package/src/store.ts +140 -0
- package/src/title-service.ts +81 -0
- package/src/types.ts +81 -0
- package/src/ws-handler.ts +264 -0
|
@@ -0,0 +1,454 @@
|
|
|
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.mmjqzu9r.js';
|
|
8
|
+
import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.mmjqzu9r.js';
|
|
9
|
+
import { loadHistory } from './events.mmjqzu9r.js';
|
|
10
|
+
|
|
11
|
+
// --- Slash command execution ---
|
|
12
|
+
|
|
13
|
+
export async function handleSlashCommand(text) {
|
|
14
|
+
const parts = text.split(/\s+/);
|
|
15
|
+
const cmd = parts[0].toLowerCase();
|
|
16
|
+
const arg = parts.slice(1).join(' ').trim();
|
|
17
|
+
|
|
18
|
+
switch (cmd) {
|
|
19
|
+
case '/new': {
|
|
20
|
+
resetSessionUI();
|
|
21
|
+
addSystem('Creating new session…');
|
|
22
|
+
requestNewSession({ cwd: arg || state.sessionCwd });
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
case '/pwd':
|
|
27
|
+
addSystem(`📁 ${state.sessionCwd || 'unknown'}`);
|
|
28
|
+
return true;
|
|
29
|
+
|
|
30
|
+
case '/sessions':
|
|
31
|
+
addSystem('Removed. Use /switch to see all sessions.');
|
|
32
|
+
return true;
|
|
33
|
+
|
|
34
|
+
case '/delete': {
|
|
35
|
+
if (!arg) {
|
|
36
|
+
addSystem('Usage: /delete <title or id prefix>');
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch('/api/sessions');
|
|
41
|
+
const sessions = await res.json();
|
|
42
|
+
const query = arg.toLowerCase();
|
|
43
|
+
const match = sessions.find(s =>
|
|
44
|
+
s.id !== state.sessionId &&
|
|
45
|
+
(s.id.startsWith(arg) || (s.title && s.title.toLowerCase().includes(query)))
|
|
46
|
+
);
|
|
47
|
+
if (!match) {
|
|
48
|
+
addSystem(`err: No session matching "${arg}"`);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: match.id }));
|
|
52
|
+
addSystem(`Deleted: ${match.title || match.id.slice(0, 8) + '…'}`);
|
|
53
|
+
} catch {
|
|
54
|
+
addSystem('err: Failed to delete session');
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case '/prune': {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch('/api/sessions');
|
|
62
|
+
const sessions = await res.json();
|
|
63
|
+
const toDelete = sessions.filter(s => s.id !== state.sessionId);
|
|
64
|
+
if (toDelete.length === 0) {
|
|
65
|
+
addSystem('No other sessions to prune.');
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
for (const s of toDelete) {
|
|
69
|
+
state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: s.id }));
|
|
70
|
+
}
|
|
71
|
+
addSystem(`Pruned ${toDelete.length} session(s).`);
|
|
72
|
+
} catch {
|
|
73
|
+
addSystem('err: Failed to prune sessions');
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case '/switch': {
|
|
79
|
+
if (!arg) {
|
|
80
|
+
addSystem('Usage: /switch <title or id prefix>');
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch('/api/sessions');
|
|
85
|
+
const sessions = await res.json();
|
|
86
|
+
const query = arg.toLowerCase();
|
|
87
|
+
const match = sessions.find(s =>
|
|
88
|
+
s.id.startsWith(arg) ||
|
|
89
|
+
(s.title && s.title.toLowerCase().includes(query))
|
|
90
|
+
);
|
|
91
|
+
if (!match) {
|
|
92
|
+
addSystem(`err: No session matching "${arg}"`);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
resetSessionUI();
|
|
96
|
+
state.sessionId = match.id;
|
|
97
|
+
state.sessionTitle = match.title || null;
|
|
98
|
+
setHashSessionId(match.id);
|
|
99
|
+
updateSessionInfo(match.id, match.title);
|
|
100
|
+
await loadHistory(match.id);
|
|
101
|
+
scrollToBottom(true);
|
|
102
|
+
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: match.id }));
|
|
103
|
+
} catch {
|
|
104
|
+
addSystem('err: Failed to switch session');
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case '/cancel':
|
|
110
|
+
if (state.busy) {
|
|
111
|
+
sendCancel();
|
|
112
|
+
addSystem('^X');
|
|
113
|
+
} else {
|
|
114
|
+
addSystem('Nothing to cancel.');
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
|
|
118
|
+
case '/help':
|
|
119
|
+
case '?':
|
|
120
|
+
addSystem('? — Show help');
|
|
121
|
+
addSystem('/help — Show help (alias)');
|
|
122
|
+
addSystem('!<command> — Run bash command');
|
|
123
|
+
for (const c of SLASH_COMMANDS) {
|
|
124
|
+
const label = c.args ? `${c.cmd} ${c.args}` : c.cmd;
|
|
125
|
+
addSystem(`${label} — ${c.desc}`);
|
|
126
|
+
}
|
|
127
|
+
addSystem('--- Shortcuts ---');
|
|
128
|
+
for (const s of SHORTCUTS) {
|
|
129
|
+
addSystem(`${s.key} — ${s.desc}`);
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
|
|
133
|
+
case '/model':
|
|
134
|
+
case '/mode':
|
|
135
|
+
case '/think': {
|
|
136
|
+
const configMap = { '/model': 'model', '/mode': 'mode', '/think': 'reasoning_effort' };
|
|
137
|
+
const configId = configMap[cmd];
|
|
138
|
+
const opt = getConfigOption(configId);
|
|
139
|
+
if (!arg) {
|
|
140
|
+
const valueName = opt?.options.find(o => o.value === opt.currentValue)?.name || opt?.currentValue || 'unknown';
|
|
141
|
+
addSystem(`${opt?.name || configId}: ${valueName}`);
|
|
142
|
+
addSystem(`Type ${cmd} + space to pick from list`);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (!opt) {
|
|
146
|
+
addSystem(`err: ${cmd.slice(1)} is not available.`);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
const query = arg.trim();
|
|
150
|
+
const normalize = (s) => s.toLowerCase().replace(/[\s_]+/g, '-');
|
|
151
|
+
const normalizedQuery = normalize(query);
|
|
152
|
+
let match = opt.options.find(o => normalize(o.value) === normalizedQuery || normalize(o.name) === normalizedQuery);
|
|
153
|
+
if (!match) {
|
|
154
|
+
const matches = opt.options.filter(o =>
|
|
155
|
+
normalize(o.value).includes(normalizedQuery) || normalize(o.name).includes(normalizedQuery)
|
|
156
|
+
);
|
|
157
|
+
if (matches.length === 1) {
|
|
158
|
+
match = matches[0];
|
|
159
|
+
} else if (matches.length > 1) {
|
|
160
|
+
addSystem(`err: Ambiguous "${arg}". Type ${cmd} + space to see options.`);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!match) {
|
|
165
|
+
addSystem(`err: Unknown "${arg}". Type ${cmd} + space to see options.`);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
state.ws.send(JSON.stringify({ type: 'set_config_option', sessionId: state.sessionId, configId, value: match.value }));
|
|
169
|
+
addSystem(`${opt.name} → ${match.name}`);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
default:
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Slash command autocomplete ---
|
|
179
|
+
|
|
180
|
+
const SLASH_COMMANDS = [
|
|
181
|
+
{ cmd: '/cancel', args: '', desc: 'Cancel current response' },
|
|
182
|
+
{ cmd: '/delete', args: '<title|id>', desc: 'Delete a session' },
|
|
183
|
+
{ cmd: '/mode', args: '[name]', desc: 'Pick or switch mode' },
|
|
184
|
+
{ cmd: '/model', args: '[name]', desc: 'Pick or switch model' },
|
|
185
|
+
{ cmd: '/new', args: '[cwd]', desc: 'New session' },
|
|
186
|
+
{ cmd: '/prune', args: '', desc: 'Delete all sessions except current' },
|
|
187
|
+
{ cmd: '/pwd', args: '', desc: 'Show working directory' },
|
|
188
|
+
{ cmd: '/switch', args: '<title|id>', desc: 'Switch to session' },
|
|
189
|
+
{ cmd: '/think', args: '[level]', desc: 'Pick or switch reasoning effort' },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const SHORTCUTS = [
|
|
193
|
+
{ key: 'Enter', desc: 'Send message' },
|
|
194
|
+
{ key: 'Shift+Enter', desc: 'New line' },
|
|
195
|
+
{ key: '^X', desc: 'Cancel current response' },
|
|
196
|
+
{ key: '^M', desc: 'Cycle mode (Agent → Plan → Autopilot)' },
|
|
197
|
+
{ key: '^U', desc: 'Upload image' },
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
let slashIdx = -1;
|
|
201
|
+
let slashFiltered = [];
|
|
202
|
+
let slashMode = 'commands';
|
|
203
|
+
let slashConfigId = null;
|
|
204
|
+
let cachedSessions = null;
|
|
205
|
+
let slashDismissed = null;
|
|
206
|
+
|
|
207
|
+
export function updateSlashMenu() {
|
|
208
|
+
const text = dom.input.value;
|
|
209
|
+
|
|
210
|
+
if (slashDismissed !== null) {
|
|
211
|
+
if (text === slashDismissed) return;
|
|
212
|
+
slashDismissed = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// /new — show path picker
|
|
216
|
+
const newMatch = text.match(/^\/new /);
|
|
217
|
+
if (newMatch) {
|
|
218
|
+
const query = text.slice(newMatch[0].length).toLowerCase();
|
|
219
|
+
fetchPathsForMenu(query);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// /switch or /delete — show session picker
|
|
224
|
+
const switchMatch = text.match(/^\/(switch|delete) /);
|
|
225
|
+
if (switchMatch) {
|
|
226
|
+
const query = text.slice(switchMatch[0].length).toLowerCase();
|
|
227
|
+
fetchSessionsForMenu(query, switchMatch[1]);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// /model, /mode, /think — show config option picker
|
|
232
|
+
const configMatch = text.match(/^\/(model|mode|think) /);
|
|
233
|
+
if (configMatch) {
|
|
234
|
+
const configMap = { model: 'model', mode: 'mode', think: 'reasoning_effort' };
|
|
235
|
+
const configId = configMap[configMatch[1]];
|
|
236
|
+
const query = text.slice(configMatch[0].length).toLowerCase();
|
|
237
|
+
showConfigMenu(configId, query);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!text.startsWith('/') || text.includes(' ')) {
|
|
242
|
+
hideSlashMenu();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
slashMode = 'commands';
|
|
246
|
+
const prefix = text.toLowerCase();
|
|
247
|
+
slashFiltered = SLASH_COMMANDS.filter(c => c.cmd.startsWith(prefix));
|
|
248
|
+
if (slashFiltered.length === 0) {
|
|
249
|
+
hideSlashMenu();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
slashIdx = 0;
|
|
253
|
+
renderSlashMenu();
|
|
254
|
+
dom.slashMenu.classList.add('active');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function fetchSessionsForMenu(query, mode = 'switch') {
|
|
258
|
+
if (!cachedSessions) {
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch('/api/sessions');
|
|
261
|
+
cachedSessions = await res.json();
|
|
262
|
+
setTimeout(() => { cachedSessions = null; }, 5000);
|
|
263
|
+
} catch { return; }
|
|
264
|
+
}
|
|
265
|
+
slashMode = mode;
|
|
266
|
+
const items = cachedSessions
|
|
267
|
+
.filter(s => {
|
|
268
|
+
if (!query) return true;
|
|
269
|
+
return (s.title && s.title.toLowerCase().includes(query)) || s.id.startsWith(query);
|
|
270
|
+
});
|
|
271
|
+
slashFiltered = items;
|
|
272
|
+
if (slashFiltered.length === 0) {
|
|
273
|
+
hideSlashMenu();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
slashIdx = 0;
|
|
277
|
+
renderSlashMenu();
|
|
278
|
+
dom.slashMenu.classList.add('active');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function fetchPathsForMenu(query) {
|
|
282
|
+
if (!cachedSessions) {
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch('/api/sessions');
|
|
285
|
+
cachedSessions = await res.json();
|
|
286
|
+
setTimeout(() => { cachedSessions = null; }, 5000);
|
|
287
|
+
} catch { return; }
|
|
288
|
+
}
|
|
289
|
+
slashMode = 'new';
|
|
290
|
+
// Deduplicate paths, keeping the most recent last_active_at for each
|
|
291
|
+
const pathMap = new Map();
|
|
292
|
+
for (const s of cachedSessions) {
|
|
293
|
+
const existing = pathMap.get(s.cwd);
|
|
294
|
+
if (!existing || (s.last_active_at || s.created_at) > (existing.time)) {
|
|
295
|
+
pathMap.set(s.cwd, { cwd: s.cwd, time: s.last_active_at || s.created_at });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
let items = [...pathMap.values()].sort((a, b) => b.time.localeCompare(a.time));
|
|
299
|
+
if (query) {
|
|
300
|
+
items = items.filter(p => p.cwd.toLowerCase().includes(query));
|
|
301
|
+
}
|
|
302
|
+
slashFiltered = items;
|
|
303
|
+
if (slashFiltered.length === 0) {
|
|
304
|
+
hideSlashMenu();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
slashIdx = 0;
|
|
308
|
+
renderSlashMenu();
|
|
309
|
+
dom.slashMenu.classList.add('active');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function showConfigMenu(configId, query) {
|
|
313
|
+
const opt = getConfigOption(configId);
|
|
314
|
+
if (!opt) { hideSlashMenu(); return; }
|
|
315
|
+
slashMode = 'config';
|
|
316
|
+
slashConfigId = configId;
|
|
317
|
+
slashFiltered = opt.options.filter(o => {
|
|
318
|
+
if (!query) return true;
|
|
319
|
+
return o.value.toLowerCase().includes(query) || o.name.toLowerCase().includes(query);
|
|
320
|
+
});
|
|
321
|
+
if (slashFiltered.length === 0) {
|
|
322
|
+
hideSlashMenu();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
slashIdx = 0;
|
|
326
|
+
renderSlashMenu();
|
|
327
|
+
dom.slashMenu.classList.add('active');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function renderSlashMenu() {
|
|
331
|
+
if (slashMode === 'new') {
|
|
332
|
+
const currentCwd = (state.sessionCwd || '').toLowerCase();
|
|
333
|
+
dom.slashMenu.innerHTML = slashFiltered.map((p, i) => {
|
|
334
|
+
const isCurrent = p.cwd.toLowerCase() === currentCwd;
|
|
335
|
+
const prefix = isCurrent ? '* ' : ' ';
|
|
336
|
+
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
337
|
+
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + p.cwd)}</span></div>`;
|
|
338
|
+
}).join('');
|
|
339
|
+
} else if (slashMode === 'config') {
|
|
340
|
+
const current = getConfigValue(slashConfigId)?.toLowerCase() || '';
|
|
341
|
+
dom.slashMenu.innerHTML = slashFiltered.map((o, i) => {
|
|
342
|
+
const isCurrent = o.value.toLowerCase() === current;
|
|
343
|
+
const prefix = isCurrent ? '* ' : ' ';
|
|
344
|
+
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
345
|
+
return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + o.name)}</span></div>`;
|
|
346
|
+
}).join('');
|
|
347
|
+
} else if (slashMode === 'switch' || slashMode === 'delete') {
|
|
348
|
+
dom.slashMenu.innerHTML = slashFiltered.map((s, i) => {
|
|
349
|
+
const isCurrent = s.id === state.sessionId;
|
|
350
|
+
const prefix = isCurrent ? '* ' : ' ';
|
|
351
|
+
const label = s.title || s.id.slice(0, 8) + '…';
|
|
352
|
+
const time = formatLocalTime(s.last_active_at || s.created_at);
|
|
353
|
+
const style = isCurrent ? ' style="color:var(--green)"' : '';
|
|
354
|
+
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>`;
|
|
355
|
+
}).join('');
|
|
356
|
+
} else {
|
|
357
|
+
dom.slashMenu.innerHTML = slashFiltered.map((c, i) => {
|
|
358
|
+
const label = c.args ? `${c.cmd} ${c.args}` : c.cmd;
|
|
359
|
+
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>`;
|
|
360
|
+
}).join('');
|
|
361
|
+
}
|
|
362
|
+
const sel = dom.slashMenu.querySelector('.selected');
|
|
363
|
+
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function hideSlashMenu() {
|
|
367
|
+
dom.slashMenu.classList.remove('active');
|
|
368
|
+
slashIdx = -1;
|
|
369
|
+
slashFiltered = [];
|
|
370
|
+
slashMode = 'commands';
|
|
371
|
+
slashDismissed = dom.input.value;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function selectSlashItem(idx) {
|
|
375
|
+
if (idx < 0 || idx >= slashFiltered.length) return;
|
|
376
|
+
|
|
377
|
+
if (slashMode === 'new') {
|
|
378
|
+
const p = slashFiltered[idx];
|
|
379
|
+
dom.input.value = '';
|
|
380
|
+
hideSlashMenu();
|
|
381
|
+
resetSessionUI();
|
|
382
|
+
addSystem('Creating new session…');
|
|
383
|
+
requestNewSession({ cwd: p.cwd });
|
|
384
|
+
} else if (slashMode === 'config') {
|
|
385
|
+
const o = slashFiltered[idx];
|
|
386
|
+
const configId = slashConfigId;
|
|
387
|
+
const opt = getConfigOption(configId);
|
|
388
|
+
dom.input.value = '';
|
|
389
|
+
hideSlashMenu();
|
|
390
|
+
state.ws.send(JSON.stringify({ type: 'set_config_option', sessionId: state.sessionId, configId, value: o.value }));
|
|
391
|
+
addSystem(`${opt?.name || configId} → ${o.name}`);
|
|
392
|
+
} else if (slashMode === 'switch') {
|
|
393
|
+
const s = slashFiltered[idx];
|
|
394
|
+
dom.input.value = '';
|
|
395
|
+
hideSlashMenu();
|
|
396
|
+
resetSessionUI();
|
|
397
|
+
state.sessionId = s.id;
|
|
398
|
+
state.sessionTitle = s.title || null;
|
|
399
|
+
setHashSessionId(s.id);
|
|
400
|
+
updateSessionInfo(s.id, s.title);
|
|
401
|
+
addSystem('Switching…');
|
|
402
|
+
loadHistory(s.id).then(loaded => { if (loaded) scrollToBottom(true); });
|
|
403
|
+
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: s.id }));
|
|
404
|
+
} else if (slashMode === 'delete') {
|
|
405
|
+
const s = slashFiltered[idx];
|
|
406
|
+
dom.input.value = '';
|
|
407
|
+
hideSlashMenu();
|
|
408
|
+
state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: s.id }));
|
|
409
|
+
addSystem(`Deleted: ${s.title || s.id.slice(0, 8) + '…'}`);
|
|
410
|
+
} else {
|
|
411
|
+
const item = slashFiltered[idx];
|
|
412
|
+
dom.input.value = item.cmd + (item.args ? ' ' : '');
|
|
413
|
+
hideSlashMenu();
|
|
414
|
+
dom.input.focus();
|
|
415
|
+
if (['/new', '/switch', '/delete', '/model', '/mode', '/think'].includes(item.cmd)) {
|
|
416
|
+
slashDismissed = null;
|
|
417
|
+
updateSlashMenu();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
updateNewBtnVisibility();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Handle keyboard navigation within the slash menu
|
|
424
|
+
export function handleSlashMenuKey(e) {
|
|
425
|
+
if (!dom.slashMenu.classList.contains('active')) return false;
|
|
426
|
+
if (e.key === 'ArrowDown') {
|
|
427
|
+
slashIdx = (slashIdx + 1) % slashFiltered.length;
|
|
428
|
+
renderSlashMenu();
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
if (e.key === 'ArrowUp') {
|
|
432
|
+
slashIdx = (slashIdx - 1 + slashFiltered.length) % slashFiltered.length;
|
|
433
|
+
renderSlashMenu();
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
if (e.key === 'Tab') {
|
|
437
|
+
selectSlashItem(slashIdx);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- Event listeners ---
|
|
444
|
+
|
|
445
|
+
dom.slashMenu.addEventListener('mousedown', (e) => {
|
|
446
|
+
e.preventDefault();
|
|
447
|
+
const item = e.target.closest('.slash-item');
|
|
448
|
+
if (item) selectSlashItem(Number(item.dataset.idx));
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
dom.input.addEventListener('input', () => {
|
|
452
|
+
updateSlashMenu();
|
|
453
|
+
dom.inputArea.classList.toggle('bash-mode', dom.input.value.startsWith('!'));
|
|
454
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// WebSocket connection lifecycle
|
|
2
|
+
|
|
3
|
+
import { state, setBusy, getHashSessionId, requestNewSession, resetSessionUI, setConnectionStatus, clearCancelTimer } from './state.mmjqzu9r.js';
|
|
4
|
+
import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.mmjqzu9r.js';
|
|
5
|
+
import { handleEvent, loadHistory, loadNewEvents } from './events.mmjqzu9r.js';
|
|
6
|
+
|
|
7
|
+
export function connect() {
|
|
8
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
9
|
+
setConnectionStatus('connecting', 'connecting');
|
|
10
|
+
state.ws = new WebSocket(`${proto}//${location.host}`);
|
|
11
|
+
|
|
12
|
+
state.ws.onopen = async () => {
|
|
13
|
+
setConnectionStatus('connecting', 'session loading');
|
|
14
|
+
|
|
15
|
+
const existingId = getHashSessionId();
|
|
16
|
+
|
|
17
|
+
// Incremental reconnect: same session still in memory — skip DOM wipe
|
|
18
|
+
if (existingId && existingId === state.sessionId) {
|
|
19
|
+
await loadNewEvents(existingId);
|
|
20
|
+
scrollToBottom(false);
|
|
21
|
+
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: existingId }));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Full load: different session in hash, or first connect to a hash
|
|
26
|
+
if (existingId) {
|
|
27
|
+
resetSessionUI();
|
|
28
|
+
const loaded = await loadHistory(existingId);
|
|
29
|
+
if (loaded) {
|
|
30
|
+
scrollToBottom(true);
|
|
31
|
+
}
|
|
32
|
+
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: existingId }));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// No session in URL — try to resume last active session
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch('/api/sessions');
|
|
39
|
+
const sessions = await res.json();
|
|
40
|
+
if (sessions.length > 0) {
|
|
41
|
+
const last = sessions[0];
|
|
42
|
+
resetSessionUI();
|
|
43
|
+
const loaded = await loadHistory(last.id);
|
|
44
|
+
if (loaded) {
|
|
45
|
+
scrollToBottom(true);
|
|
46
|
+
}
|
|
47
|
+
state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: last.id }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
52
|
+
// No previous sessions — create new
|
|
53
|
+
requestNewSession();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
state.ws.onclose = () => {
|
|
57
|
+
setConnectionStatus('disconnected', 'disconnected');
|
|
58
|
+
finishThinking();
|
|
59
|
+
finishAssistant();
|
|
60
|
+
if (state.currentBashEl) { finishBash(state.currentBashEl, null, 'disconnected'); }
|
|
61
|
+
state.pendingToolCallIds.clear();
|
|
62
|
+
state.pendingPermissionRequestIds.clear();
|
|
63
|
+
state.pendingPromptDone = false;
|
|
64
|
+
state.turnEnded = false;
|
|
65
|
+
clearCancelTimer();
|
|
66
|
+
setBusy(false);
|
|
67
|
+
setTimeout(connect, 3000);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
state.ws.onerror = () => state.ws.close();
|
|
71
|
+
|
|
72
|
+
state.ws.onmessage = (e) => {
|
|
73
|
+
const msg = JSON.parse(e.data);
|
|
74
|
+
handleEvent(msg);
|
|
75
|
+
};
|
|
76
|
+
}
|