sapper-iq 1.1.34 → 1.1.36

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/sapper-ui.mjs ADDED
@@ -0,0 +1,1933 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Sapper Desktop UI v2 — Full-featured web frontend for Sapper
4
+ * Features: Agents/Skills CRUD, Sessions, File Browser/Editor,
5
+ * Thinking model display, Tool action cards, Quick Actions
6
+ */
7
+
8
+ import http from 'http';
9
+ import fs from 'fs';
10
+ import { spawn } from 'child_process';
11
+ import { fileURLToPath } from 'url';
12
+ import { dirname, join, resolve, basename } from 'path';
13
+ import ollama from 'ollama';
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = dirname(__filename);
17
+
18
+ const PORT = 3777;
19
+ const SAPPER_DIR = '.sapper';
20
+ const AGENTS_DIR = join(SAPPER_DIR, 'agents');
21
+ const SKILLS_DIR = join(SAPPER_DIR, 'skills');
22
+ const SESSIONS_DIR = join(SAPPER_DIR, 'sessions');
23
+
24
+ let workingDir = process.cwd();
25
+
26
+ // ─── Helpers ───────────────────────────────────────────────
27
+
28
+ function ensureDir(dir) {
29
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
30
+ }
31
+
32
+ function parseFrontmatter(raw) {
33
+ const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
34
+ if (!fmMatch) return { meta: {}, body: raw };
35
+ const meta = {};
36
+ for (const line of fmMatch[1].split('\n')) {
37
+ const idx = line.indexOf(':');
38
+ if (idx === -1) continue;
39
+ let key = line.slice(0, idx).trim();
40
+ let value = line.slice(idx + 1).trim();
41
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
42
+ value = value.slice(1, -1);
43
+ if (value.startsWith('[') && value.endsWith(']'))
44
+ value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
45
+ meta[key] = value;
46
+ }
47
+ if (!meta.name) {
48
+ const heading = fmMatch[2].match(/^#\s+(.+)/m);
49
+ meta.name = heading ? heading[1].trim() : 'Unnamed';
50
+ }
51
+ return { meta, body: fmMatch[2] };
52
+ }
53
+
54
+ function loadAgents() {
55
+ ensureDir(AGENTS_DIR);
56
+ const agents = {};
57
+ try {
58
+ for (const file of fs.readdirSync(AGENTS_DIR)) {
59
+ if (!file.endsWith('.md')) continue;
60
+ const name = file.replace('.md', '').toLowerCase();
61
+ const raw = fs.readFileSync(join(AGENTS_DIR, file), 'utf8');
62
+ const { meta, body } = parseFrontmatter(raw);
63
+ agents[name] = {
64
+ name: meta.name || name,
65
+ description: meta.description || name,
66
+ tools: meta.tools || null,
67
+ content: body,
68
+ };
69
+ }
70
+ } catch (e) {}
71
+ return agents;
72
+ }
73
+
74
+ function loadSkills() {
75
+ ensureDir(SKILLS_DIR);
76
+ const skills = {};
77
+ try {
78
+ for (const file of fs.readdirSync(SKILLS_DIR)) {
79
+ if (!file.endsWith('.md')) continue;
80
+ const name = file.replace('.md', '').toLowerCase();
81
+ const raw = fs.readFileSync(join(SKILLS_DIR, file), 'utf8');
82
+ const { meta, body } = parseFrontmatter(raw);
83
+ skills[name] = { name: meta.name || name, description: meta.description || name, content: body };
84
+ }
85
+ } catch (e) {}
86
+ return skills;
87
+ }
88
+
89
+ const IGNORE_DIRS = new Set(['node_modules', '.git', '.sapper', '__pycache__', '.next', 'dist', 'build', '.cache']);
90
+
91
+ function safePath(p) {
92
+ const resolved = resolve(workingDir, p || '.');
93
+ if (!resolved.startsWith(workingDir)) return null;
94
+ return resolved;
95
+ }
96
+
97
+ function buildSystemPrompt(agentContent = null, agentTools = null, skillContents = []) {
98
+ const now = new Date();
99
+ const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
100
+ const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
101
+ let prompt = `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
102
+ You can help with ANY task - coding, writing, research, planning, analysis, and more.
103
+
104
+ CURRENT DATE AND TIME: ${dateStr}, ${timeStr}
105
+ WORKING DIRECTORY: ${workingDir}
106
+
107
+ RULES:
108
+ 1. EXPLORE FIRST: Use LIST and READ to understand files before making changes.
109
+ 2. THINK IN STEPS: Explain what you found and what you plan to do before acting.
110
+ 3. BE PRECISE: When using PATCH, prefer LINE:number mode.
111
+ 4. VERIFY: After making changes, verify they work.
112
+ 5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content.
113
+
114
+ TOOL SYNTAX:
115
+ - [TOOL:LIST]dir[/TOOL] - List directory contents
116
+ - [TOOL:READ]file_path[/TOOL] - Read file contents
117
+ - [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
118
+ - [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
119
+ - [TOOL:PATCH]path:::old|||new[/TOOL] - Edit file (exact/fuzzy match)
120
+ - [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace line by number (PREFERRED)
121
+ - [TOOL:SHELL]command[/TOOL] - Run shell command
122
+ - [TOOL:MKDIR]path[/TOOL] - Create directory
123
+
124
+ PATCH TIPS:
125
+ - PREFER LINE:number mode. Always READ first.
126
+ - If PATCH fails, switch to LINE:number or WRITE.
127
+
128
+ You MUST use [TOOL:...][/TOOL] syntax to perform actions.
129
+ Do NOT show tool syntax as examples to the user — only use them to perform real actions.`;
130
+
131
+ if (agentContent) {
132
+ prompt += `\n\n═══ ACTIVE AGENT ═══\n${agentContent}\n═══ END AGENT ═══`;
133
+ if (agentTools && agentTools.length > 0) {
134
+ const allTools = ['READ', 'WRITE', 'PATCH', 'LIST', 'SEARCH', 'SHELL', 'MKDIR'];
135
+ const forbidden = allTools.filter(t => !agentTools.includes(t));
136
+ prompt += `\nTOOL RESTRICTION: ONLY use: ${agentTools.join(', ')}. FORBIDDEN: ${forbidden.join(', ')}.`;
137
+ }
138
+ }
139
+ if (skillContents.length > 0) {
140
+ prompt += `\n\n═══ SKILLS ═══\n${skillContents.join('\n---\n')}\n═══ END SKILLS ═══`;
141
+ }
142
+ return prompt;
143
+ }
144
+
145
+ // ─── Tool Execution ────────────────────────────────────────
146
+
147
+ const tools = {
148
+ list: (path) => {
149
+ try {
150
+ let dir = resolve(workingDir, path.trim() || '.');
151
+ if (dir === '/') dir = workingDir;
152
+ const entries = fs.readdirSync(dir);
153
+ return entries.filter(e => !IGNORE_DIRS.has(e) && !e.startsWith('.')).join('\n') || '(empty)';
154
+ } catch (e) { return `Error: ${e.message}`; }
155
+ },
156
+ read: (path) => {
157
+ try { return fs.readFileSync(resolve(workingDir, path.trim()), 'utf8'); }
158
+ catch (e) { return `Error: ${e.message}`; }
159
+ },
160
+ write: (path, content) => {
161
+ try {
162
+ const p = resolve(workingDir, path.trim());
163
+ fs.mkdirSync(dirname(p), { recursive: true });
164
+ fs.writeFileSync(p, content);
165
+ return `Successfully saved ${path.trim()}`;
166
+ } catch (e) { return `Error: ${e.message}`; }
167
+ },
168
+ mkdir: (path) => {
169
+ try { fs.mkdirSync(resolve(workingDir, path.trim()), { recursive: true }); return `Created ${path}`; }
170
+ catch (e) { return `Error: ${e.message}`; }
171
+ },
172
+ patch: (path, oldText, newText) => {
173
+ const p = resolve(workingDir, path.trim());
174
+ try {
175
+ const content = fs.readFileSync(p, 'utf8');
176
+ const lineMatch = oldText.match(/^LINE:(\d+)$/);
177
+ if (lineMatch) {
178
+ const n = parseInt(lineMatch[1], 10);
179
+ const lines = content.split('\n');
180
+ if (n < 1 || n > lines.length) return `Error: Line ${n} out of range (${lines.length} lines)`;
181
+ const old = lines[n - 1];
182
+ lines[n - 1] = newText;
183
+ fs.writeFileSync(p, lines.join('\n'));
184
+ return `Patched line ${n}: "${old}" → "${newText}"`;
185
+ }
186
+ if (content.includes(oldText)) {
187
+ fs.writeFileSync(p, content.replace(oldText, newText));
188
+ return `Successfully patched ${path.trim()}`;
189
+ }
190
+ if (content.includes(oldText.trim())) {
191
+ fs.writeFileSync(p, content.replace(oldText.trim(), newText.trim()));
192
+ return `Successfully patched ${path.trim()} (trimmed match)`;
193
+ }
194
+ const normalize = s => s.replace(/[\u{1F000}-\u{1FFFF}]/gu, '').replace(/\s+/g, ' ').trim();
195
+ const normOld = normalize(oldText);
196
+ const lines = content.split('\n');
197
+ const oldLines = oldText.trim().split('\n');
198
+ for (let i = 0; i <= lines.length - oldLines.length; i++) {
199
+ const win = lines.slice(i, i + oldLines.length).join('\n');
200
+ if (normalize(win) === normOld) {
201
+ const newContent = content.replace(win, newText.trim());
202
+ fs.writeFileSync(p, newContent);
203
+ return `Successfully patched ${path.trim()} (fuzzy match at line ${i + 1})`;
204
+ }
205
+ }
206
+ return `Error: Text not found in ${path.trim()}. Use LINE:number mode instead.`;
207
+ } catch (e) { return `Error: ${e.message}`; }
208
+ },
209
+ search: (pattern) => {
210
+ return new Promise((res) => {
211
+ const excludes = [...IGNORE_DIRS].join(',');
212
+ const cmd = `grep -rEin "${pattern.replace(/"/g, '\\"')}" . --exclude-dir={${excludes}} --include="*.{js,ts,jsx,tsx,py,java,go,rs,rb,php,c,cpp,h,css,scss,html,json,md,txt,yml,yaml,toml,sh}" 2>/dev/null | head -50`;
213
+ const proc = spawn('sh', ['-c', cmd], { cwd: workingDir });
214
+ let out = '';
215
+ proc.stdout.on('data', d => out += d);
216
+ proc.stderr.on('data', d => out += d);
217
+ proc.on('close', () => res(out.trim() || `No matches for: ${pattern}`));
218
+ });
219
+ },
220
+ shell: (cmd) => {
221
+ return new Promise((res) => {
222
+ const proc = spawn('sh', ['-c', cmd], { cwd: workingDir });
223
+ let out = '';
224
+ proc.stdout.on('data', d => out += d);
225
+ proc.stderr.on('data', d => out += d);
226
+ proc.on('close', code => {
227
+ let result = out.trim();
228
+ if (result.length > 10000) result = result.substring(0, 10000) + '\n...(truncated)';
229
+ res(result || `Completed (exit ${code})`);
230
+ });
231
+ });
232
+ },
233
+ };
234
+
235
+ async function executeTool(type, path, content, agentTools) {
236
+ if (agentTools && !agentTools.includes(type.toUpperCase())) {
237
+ return { result: `Error: Tool ${type.toUpperCase()} not allowed. Allowed: ${agentTools.join(', ')}`, blocked: true };
238
+ }
239
+ const t = type.toLowerCase();
240
+ let result;
241
+ if (t === 'list') result = tools.list(path);
242
+ else if (t === 'read') result = tools.read(path);
243
+ else if (t === 'mkdir') result = tools.mkdir(path);
244
+ else if (t === 'write') result = tools.write(path, content || '');
245
+ else if (t === 'patch') {
246
+ const parts = content?.split('|||');
247
+ if (parts && parts.length === 2) result = tools.patch(path, parts[0], parts[1]);
248
+ else result = 'Error: PATCH needs OLD_TEXT|||NEW_TEXT';
249
+ }
250
+ else if (t === 'search') result = await tools.search(path);
251
+ else if (t === 'shell') result = await tools.shell(path);
252
+ else result = `Unknown tool: ${type}`;
253
+ return { result, blocked: false };
254
+ }
255
+
256
+ // ─── Chat Engine ───────────────────────────────────────────
257
+
258
+ let abortFlag = false;
259
+
260
+ async function* chatStream(messages, model, agentTools) {
261
+ const MAX_TOOL_ROUNDS = 15;
262
+ let rounds = 0;
263
+ const patchFails = {};
264
+
265
+ while (true) {
266
+ if (abortFlag) { abortFlag = false; yield { type: 'system', data: 'Generation stopped' }; break; }
267
+
268
+ let fullMsg = '';
269
+ const response = await ollama.chat({ model, messages, stream: true });
270
+ for await (const chunk of response) {
271
+ if (abortFlag) { abortFlag = false; yield { type: 'system', data: 'Generation stopped' }; messages.push({ role: 'assistant', content: fullMsg }); return; }
272
+ const token = chunk.message?.content || '';
273
+ fullMsg += token;
274
+ yield { type: 'token', data: token };
275
+ }
276
+ messages.push({ role: 'assistant', content: fullMsg });
277
+
278
+ const clean = fullMsg.replace(/```[\s\S]*?```/g, '');
279
+ const toolMatches = [...clean.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
280
+
281
+ if (toolMatches.length === 0) break;
282
+
283
+ rounds++;
284
+ if (rounds >= MAX_TOOL_ROUNDS) {
285
+ messages.push({ role: 'user', content: 'STOP using tools. Provide your answer now with what you have.' });
286
+ yield { type: 'system', data: `Tool limit reached (${MAX_TOOL_ROUNDS} rounds)` };
287
+ continue;
288
+ }
289
+
290
+ for (const match of toolMatches) {
291
+ if (abortFlag) break;
292
+ const [, type, path, content] = match;
293
+ yield { type: 'tool_start', data: { tool: type.toUpperCase(), path } };
294
+
295
+ if (type.toLowerCase() === 'patch') {
296
+ const key = path.trim();
297
+ if ((patchFails[key] || 0) >= 3) {
298
+ const err = `Error: PATCH failed 3 times on ${key}. Use READ + LINE:number mode or WRITE instead.`;
299
+ messages.push({ role: 'user', content: `RESULT (${path}): ${err}` });
300
+ yield { type: 'tool_result', data: { tool: 'PATCH', path, result: err, blocked: true } };
301
+ continue;
302
+ }
303
+ }
304
+
305
+ const { result, blocked } = await executeTool(type, path, content, agentTools);
306
+
307
+ if (type.toLowerCase() === 'patch' && result.startsWith('Error:')) {
308
+ const key = path.trim();
309
+ patchFails[key] = (patchFails[key] || 0) + 1;
310
+ }
311
+
312
+ messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
313
+ yield { type: 'tool_result', data: { tool: type.toUpperCase(), path, result: result.substring(0, 3000), blocked } };
314
+ }
315
+ }
316
+ }
317
+
318
+ // ─── Session Management ────────────────────────────────────
319
+
320
+ function listSessions() {
321
+ ensureDir(SESSIONS_DIR);
322
+ try {
323
+ return fs.readdirSync(SESSIONS_DIR)
324
+ .filter(f => f.endsWith('.json'))
325
+ .map(f => {
326
+ try {
327
+ const data = JSON.parse(fs.readFileSync(join(SESSIONS_DIR, f), 'utf8'));
328
+ return { id: f.replace('.json', ''), name: data.name || 'Unnamed', created: data.created, msgCount: (data.messages || []).filter(m => m.role === 'user').length };
329
+ } catch { return null; }
330
+ })
331
+ .filter(Boolean)
332
+ .sort((a, b) => new Date(b.created) - new Date(a.created));
333
+ } catch { return []; }
334
+ }
335
+
336
+ function saveSession(id, name, messages, model, agentKey) {
337
+ ensureDir(SESSIONS_DIR);
338
+ const data = { name, created: new Date().toISOString(), model, agent: agentKey, messages };
339
+ fs.writeFileSync(join(SESSIONS_DIR, `${id}.json`), JSON.stringify(data));
340
+ }
341
+
342
+ function loadSessionData(id) {
343
+ try { return JSON.parse(fs.readFileSync(join(SESSIONS_DIR, `${id}.json`), 'utf8')); }
344
+ catch { return null; }
345
+ }
346
+
347
+ function deleteSessionFile(id) {
348
+ try { fs.unlinkSync(join(SESSIONS_DIR, `${id}.json`)); return true; }
349
+ catch { return false; }
350
+ }
351
+
352
+ function renameSession(id, newName) {
353
+ try {
354
+ const data = JSON.parse(fs.readFileSync(join(SESSIONS_DIR, `${id}.json`), 'utf8'));
355
+ data.name = newName;
356
+ fs.writeFileSync(join(SESSIONS_DIR, `${id}.json`), JSON.stringify(data));
357
+ return true;
358
+ } catch { return false; }
359
+ }
360
+
361
+ // ─── Agent/Skill CRUD ─────────────────────────────────────
362
+
363
+ function createAgentFile(name, description, agentTools, content) {
364
+ ensureDir(AGENTS_DIR);
365
+ const filename = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '.md';
366
+ let fm = '---\n';
367
+ fm += `name: "${name}"\n`;
368
+ fm += `description: "${description}"\n`;
369
+ if (agentTools && agentTools.length > 0) {
370
+ fm += `tools: [${agentTools.map(t => '"' + t + '"').join(', ')}]\n`;
371
+ }
372
+ fm += '---\n\n';
373
+ fs.writeFileSync(join(AGENTS_DIR, filename), fm + content);
374
+ return filename;
375
+ }
376
+
377
+ function deleteAgentFile(key) {
378
+ try { fs.unlinkSync(join(AGENTS_DIR, key + '.md')); return true; }
379
+ catch { return false; }
380
+ }
381
+
382
+ function createSkillFile(name, description, content) {
383
+ ensureDir(SKILLS_DIR);
384
+ const filename = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '.md';
385
+ let fm = '---\n';
386
+ fm += `name: "${name}"\n`;
387
+ fm += `description: "${description}"\n`;
388
+ fm += '---\n\n';
389
+ fs.writeFileSync(join(SKILLS_DIR, filename), fm + content);
390
+ return filename;
391
+ }
392
+
393
+ function deleteSkillFile(key) {
394
+ try { fs.unlinkSync(join(SKILLS_DIR, key + '.md')); return true; }
395
+ catch { return false; }
396
+ }
397
+
398
+ // ─── Directory Tree ────────────────────────────────────────
399
+
400
+ function getTreeEntries(dirPath) {
401
+ const safe = safePath(dirPath);
402
+ if (!safe) return [];
403
+ try {
404
+ const entries = fs.readdirSync(safe);
405
+ return entries
406
+ .filter(e => !IGNORE_DIRS.has(e) && !e.startsWith('.'))
407
+ .map(e => {
408
+ try {
409
+ const stat = fs.statSync(join(safe, e));
410
+ return { name: e, isDir: stat.isDirectory(), size: stat.size, modified: stat.mtime.toISOString() };
411
+ } catch { return { name: e, isDir: false, size: 0 }; }
412
+ })
413
+ .sort((a, b) => {
414
+ if (a.isDir && !b.isDir) return -1;
415
+ if (!a.isDir && b.isDir) return 1;
416
+ return a.name.localeCompare(b.name);
417
+ });
418
+ } catch { return []; }
419
+ }
420
+
421
+ // ─── Server State ──────────────────────────────────────────
422
+
423
+ let serverMessages = [];
424
+ let serverModel = '';
425
+ let serverAgent = null;
426
+ let serverAgentKey = null;
427
+ let serverAgentTools = null;
428
+ let currentSessionId = null;
429
+
430
+ function resetChat() {
431
+ const skills = loadSkills();
432
+ const skillContents = Object.values(skills).map(s => s.content);
433
+ serverMessages = [{
434
+ role: 'system',
435
+ content: buildSystemPrompt(serverAgent?.content || null, serverAgentTools, skillContents)
436
+ }];
437
+ }
438
+
439
+ function getVersion() {
440
+ try { return JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8')).version; }
441
+ catch { return '0.0.0'; }
442
+ }
443
+
444
+ function json(res, data, status = 200) {
445
+ res.writeHead(status, { 'Content-Type': 'application/json' });
446
+ res.end(JSON.stringify(data));
447
+ }
448
+
449
+ function readBody(req) {
450
+ return new Promise((resolve) => {
451
+ let body = '';
452
+ req.on('data', c => body += c);
453
+ req.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve({}); } });
454
+ });
455
+ }
456
+
457
+ // ─── HTML Builder ──────────────────────────────────────────
458
+
459
+ function buildHTML() {
460
+ return `<!DOCTYPE html>
461
+ <html lang="en">
462
+ <head>
463
+ <meta charset="UTF-8">
464
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
465
+ <title>Sapper</title>
466
+ <style>
467
+ :root {
468
+ --bg0: #0a0e14; --bg1: #0d1117; --bg2: #161b22; --bg3: #21262d; --bg4: #30363d; --bg5: #3d444d;
469
+ --fg0: #f0f6fc; --fg1: #e6edf3; --fg2: #8b949e; --fg3: #484f58;
470
+ --accent: #58a6ff; --accent2: #1f6feb; --green: #3fb950; --red: #f85149;
471
+ --orange: #d29922; --purple: #bc8cff; --cyan: #39d2c0; --pink: #f778ba;
472
+ --radius: 10px; --radius-sm: 6px;
473
+ }
474
+ * { margin: 0; padding: 0; box-sizing: border-box; }
475
+ body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; background: var(--bg1); color: var(--fg1); height: 100vh; display: flex; overflow: hidden; }
476
+
477
+ /* ── Sidebar ── */
478
+ .sidebar { width: 270px; background: var(--bg2); border-right: 1px solid var(--bg4); display: flex; flex-direction: column; flex-shrink: 0; }
479
+ .sidebar-header { padding: 16px 18px; border-bottom: 1px solid var(--bg4); display: flex; align-items: center; gap: 10px; }
480
+ .sidebar-header h1 { font-size: 18px; font-weight: 700; }
481
+ .sidebar-header h1 span { color: var(--accent); }
482
+ .sidebar-tabs { display: flex; border-bottom: 1px solid var(--bg4); }
483
+ .sidebar-tabs button { flex: 1; padding: 10px 4px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--fg3); font-size: 11px; font-weight: 600; cursor: pointer; text-transform: uppercase; letter-spacing: .5px; transition: all .15s; }
484
+ .sidebar-tabs button:hover { color: var(--fg2); }
485
+ .sidebar-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
486
+ .tab-panel { display: none; flex: 1; overflow-y: auto; padding: 8px; }
487
+ .tab-panel.active { display: flex; flex-direction: column; }
488
+ .tab-panel::-webkit-scrollbar { width: 5px; }
489
+ .tab-panel::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
490
+ .tab-create-btn { display: flex; align-items: center; gap: 6px; padding: 8px 12px; margin: 4px; background: var(--bg3); border: 1px dashed var(--bg5); border-radius: var(--radius-sm); color: var(--fg2); font-size: 12px; cursor: pointer; transition: all .15s; }
491
+ .tab-create-btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.08); }
492
+ .s-item { padding: 10px 12px; margin: 2px 4px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--fg2); transition: all .12s; border-radius: var(--radius-sm); border: 1px solid transparent; }
493
+ .s-item:hover { background: var(--bg3); color: var(--fg1); }
494
+ .s-item.active { background: rgba(88,166,255,.1); color: var(--accent); border-color: rgba(88,166,255,.2); }
495
+ .s-item .s-icon { width: 18px; text-align: center; flex-shrink: 0; font-size: 14px; }
496
+ .s-item .s-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
497
+ .s-item .s-meta { font-size: 10px; color: var(--fg3); }
498
+ .s-item .s-del { display: none; background: none; border: none; color: var(--fg3); cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; }
499
+ .s-item:hover .s-del { display: block; }
500
+ .s-item .s-del:hover { color: var(--red); background: rgba(248,81,73,.15); }
501
+
502
+ /* ── Quick Actions ── */
503
+ .qa-section { padding: 4px 8px; margin-top: auto; border-top: 1px solid var(--bg4); }
504
+ .qa-title { font-size: 10px; font-weight: 600; color: var(--fg3); text-transform: uppercase; letter-spacing: .8px; padding: 8px 8px 4px; }
505
+ .qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; }
506
+ .qa-btn { padding: 7px 6px; background: var(--bg3); border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--fg2); font-size: 11px; cursor: pointer; transition: all .12s; display: flex; align-items: center; gap: 4px; white-space: nowrap; overflow: hidden; }
507
+ .qa-btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.06); }
508
+ .qa-btn .qa-icon { font-size: 12px; flex-shrink: 0; }
509
+
510
+ /* ── Sidebar Footer ── */
511
+ .sidebar-footer { padding: 12px; border-top: 1px solid var(--bg4); }
512
+ .sidebar-footer select { width: 100%; background: var(--bg3); color: var(--fg1); border: 1px solid var(--bg4); border-radius: var(--radius-sm); padding: 6px 8px; font-size: 12px; cursor: pointer; outline: none; }
513
+ .sidebar-footer select:focus { border-color: var(--accent); }
514
+ .sidebar-footer .sf-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 11px; color: var(--fg3); }
515
+ .sidebar-footer .sf-model { background: var(--bg3); padding: 3px 8px; border-radius: 12px; font-size: 10px; color: var(--accent); }
516
+
517
+ /* ── Main ── */
518
+ .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
519
+
520
+ /* ── Topbar ── */
521
+ .topbar { height: 48px; background: var(--bg2); border-bottom: 1px solid var(--bg4); display: flex; align-items: center; padding: 0 16px; gap: 10px; flex-shrink: 0; }
522
+ .topbar .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
523
+ .topbar .agent-badge { background: linear-gradient(135deg, var(--accent2), var(--purple)); color: #fff; padding: 3px 10px; border-radius: 14px; font-size: 11px; font-weight: 600; }
524
+ .topbar .session-name { color: var(--fg2); font-size: 12px; cursor: pointer; padding: 2px 6px; border-radius: 4px; }
525
+ .topbar .session-name:hover { background: var(--bg3); }
526
+ .topbar .spacer { flex: 1; }
527
+ .topbar .tb-btn { background: var(--bg3); border: 1px solid var(--bg4); border-radius: var(--radius-sm); color: var(--fg2); padding: 5px 10px; font-size: 11px; cursor: pointer; display: flex; align-items: center; gap: 5px; transition: all .12s; }
528
+ .topbar .tb-btn:hover { border-color: var(--accent); color: var(--accent); }
529
+ .topbar .cwd { color: var(--fg3); font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
530
+
531
+ /* ── Chat ── */
532
+ .chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 6px; scroll-behavior: smooth; }
533
+ .chat::-webkit-scrollbar { width: 6px; }
534
+ .chat::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
535
+ .msg { max-width: 82%; padding: 12px 16px; border-radius: var(--radius); font-size: 14px; line-height: 1.6; word-break: break-word; animation: fadeIn .2s; }
536
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
537
+ .msg.user { background: var(--accent2); color: #fff; align-self: flex-end; border-bottom-right-radius: 3px; }
538
+ .msg.ai { background: var(--bg2); border: 1px solid var(--bg4); align-self: flex-start; border-bottom-left-radius: 3px; }
539
+ .msg.ai pre { background: var(--bg0); border: 1px solid var(--bg4); border-radius: var(--radius-sm); padding: 10px 12px; overflow-x: auto; margin: 8px 0; font-size: 13px; }
540
+ .msg.ai code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; }
541
+ .msg.ai :not(pre) > code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-size: 12px; }
542
+ .msg.ai h1, .msg.ai h2, .msg.ai h3, .msg.ai h4 { margin: 10px 0 4px; color: var(--accent); }
543
+ .msg.ai ul, .msg.ai ol { padding-left: 20px; margin: 4px 0; }
544
+ .msg.ai li { margin: 2px 0; }
545
+ .msg.ai blockquote { border-left: 3px solid var(--accent); padding-left: 12px; color: var(--fg2); margin: 6px 0; }
546
+ .msg.ai a { color: var(--accent); }
547
+ .msg.ai p { margin: 4px 0; }
548
+ .msg.ai hr { border: none; border-top: 1px solid var(--bg4); margin: 8px 0; }
549
+ .msg.ai table { border-collapse: collapse; margin: 8px 0; width: 100%; }
550
+ .msg.ai th, .msg.ai td { border: 1px solid var(--bg4); padding: 5px 8px; text-align: left; font-size: 13px; }
551
+ .msg.ai th { background: var(--bg3); }
552
+ .msg.system { background: transparent; color: var(--fg3); font-size: 12px; align-self: center; padding: 3px 10px; }
553
+
554
+ /* ── Thinking Block ── */
555
+ .think-block { background: rgba(188,140,255,.07); border: 1px solid rgba(188,140,255,.18); border-radius: 8px; margin: 8px 0; overflow: hidden; }
556
+ .think-header { padding: 7px 12px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--purple); user-select: none; }
557
+ .think-header:hover { background: rgba(188,140,255,.05); }
558
+ .think-chevron { font-size: 10px; transition: transform .15s; }
559
+ .think-block.open .think-chevron { transform: rotate(90deg); }
560
+ .think-content { padding: 8px 12px; font-size: 13px; color: var(--fg2); line-height: 1.5; border-top: 1px solid rgba(188,140,255,.12); max-height: 250px; overflow-y: auto; display: none; }
561
+ .think-block.open .think-content { display: block; }
562
+ .think-block.streaming .think-header .think-label { animation: pulse 1.5s infinite; }
563
+ @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
564
+
565
+ /* ── AI Exchange Container ── */
566
+ .ai-exchange { display: flex; flex-direction: column; gap: 4px; width: 100%; }
567
+
568
+ /* ── Tool Card ── */
569
+ .tool-card { background: var(--bg3); border: 1px solid var(--bg4); border-radius: 8px; margin: 4px 0; overflow: hidden; font-size: 13px; align-self: flex-start; max-width: 82%; }
570
+ .tool-card.done .tc-spinner { display: none; }
571
+ .tool-card-header { padding: 8px 12px; display: flex; align-items: center; gap: 8px; cursor: pointer; transition: background .12s; }
572
+ .tool-card-header:hover { background: var(--bg4); }
573
+ .tc-icon { font-size: 13px; flex-shrink: 0; }
574
+ .tc-name { color: var(--orange); font-weight: 600; font-family: 'SF Mono', monospace; font-size: 12px; }
575
+ .tc-path { color: var(--fg2); font-family: 'SF Mono', monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
576
+ .tc-status { margin-left: auto; font-size: 12px; flex-shrink: 0; }
577
+ .tc-chevron { font-size: 10px; color: var(--fg3); transition: transform .15s; flex-shrink: 0; }
578
+ .tool-card.expanded .tc-chevron { transform: rotate(90deg); }
579
+ .tool-card-body { display: none; padding: 8px 12px; border-top: 1px solid var(--bg4); }
580
+ .tool-card.expanded .tool-card-body { display: block; }
581
+ .tool-card-body pre { background: var(--bg1); padding: 8px; border-radius: 4px; white-space: pre-wrap; word-break: break-all; font-size: 11px; max-height: 200px; overflow-y: auto; margin: 0; color: var(--fg2); }
582
+ .tool-card.error { border-color: rgba(248,81,73,.3); }
583
+ .tool-card.error .tc-name { color: var(--red); }
584
+
585
+ /* ── Thinking Dots ── */
586
+ .thinking-dots { align-self: flex-start; display: flex; gap: 4px; padding: 14px 16px; }
587
+ .thinking-dots span { width: 7px; height: 7px; background: var(--fg3); border-radius: 50%; animation: bounce .6s ease-in-out infinite; }
588
+ .thinking-dots span:nth-child(2) { animation-delay: .1s; }
589
+ .thinking-dots span:nth-child(3) { animation-delay: .2s; }
590
+ @keyframes bounce { 0%,80%,100% { transform: scale(.7); opacity: .4; } 40% { transform: scale(1); opacity: 1; } }
591
+
592
+ /* ── Input ── */
593
+ .input-area { background: var(--bg2); border-top: 1px solid var(--bg4); padding: 12px 16px; flex-shrink: 0; }
594
+ .input-row { display: flex; gap: 8px; align-items: flex-end; }
595
+ .input-row textarea { flex: 1; background: var(--bg1); border: 1px solid var(--bg4); border-radius: var(--radius); padding: 10px 14px; color: var(--fg1); font-size: 14px; font-family: inherit; resize: none; outline: none; min-height: 44px; max-height: 150px; line-height: 1.5; transition: border-color .15s; }
596
+ .input-row textarea:focus { border-color: var(--accent); }
597
+ .input-row textarea::placeholder { color: var(--fg3); }
598
+ .in-btn { width: 42px; height: 42px; border-radius: var(--radius); border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .12s; flex-shrink: 0; }
599
+ .in-btn.send { background: var(--accent2); }
600
+ .in-btn.send:hover { background: var(--accent); }
601
+ .in-btn.send:disabled { opacity: .3; cursor: default; }
602
+ .in-btn.stop { background: var(--red); display: none; }
603
+ .in-btn.stop:hover { background: #da3633; }
604
+ .in-btn.stop.visible { display: flex; }
605
+ .input-hint { display: flex; gap: 14px; margin-top: 6px; font-size: 10px; color: var(--fg3); }
606
+ .input-hint kbd { background: var(--bg3); padding: 1px 4px; border-radius: 3px; font-family: monospace; font-size: 9px; }
607
+
608
+ /* ── Welcome ── */
609
+ .welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--fg3); gap: 10px; padding: 40px; text-align: center; }
610
+ .welcome .logo { font-size: 44px; margin-bottom: 6px; }
611
+ .welcome h2 { color: var(--fg1); font-size: 20px; }
612
+ .welcome p { max-width: 380px; line-height: 1.5; font-size: 14px; }
613
+ .welcome .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; justify-content: center; }
614
+ .welcome .chip { background: var(--bg3); border: 1px solid var(--bg4); border-radius: 18px; padding: 7px 14px; font-size: 12px; color: var(--fg2); cursor: pointer; transition: all .12s; }
615
+ .welcome .chip:hover { border-color: var(--accent); color: var(--accent); }
616
+
617
+ /* ── Right Panel ── */
618
+ .right-panel { width: 0; background: var(--bg2); border-left: 1px solid var(--bg4); display: flex; flex-direction: column; flex-shrink: 0; transition: width .2s; overflow: hidden; }
619
+ .right-panel.open { width: 380px; }
620
+ .rp-header { padding: 12px 16px; border-bottom: 1px solid var(--bg4); display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
621
+ .rp-header h3 { font-size: 13px; flex: 1; }
622
+ .rp-close { background: none; border: none; color: var(--fg2); cursor: pointer; font-size: 16px; padding: 2px 6px; border-radius: 4px; }
623
+ .rp-close:hover { background: var(--bg3); color: var(--fg1); }
624
+ .rp-tabs { display: flex; border-bottom: 1px solid var(--bg4); flex-shrink: 0; }
625
+ .rp-tabs button { flex: 1; padding: 8px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--fg3); font-size: 11px; cursor: pointer; }
626
+ .rp-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
627
+ .file-tree { flex: 1; overflow-y: auto; padding: 6px; font-size: 13px; }
628
+ .file-tree::-webkit-scrollbar { width: 5px; }
629
+ .file-tree::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
630
+ .ft-item { padding: 5px 8px; cursor: pointer; border-radius: 4px; color: var(--fg2); display: flex; align-items: center; gap: 6px; font-family: 'SF Mono', monospace; font-size: 12px; }
631
+ .ft-item:hover { background: var(--bg3); color: var(--fg1); }
632
+ .ft-item.dir { color: var(--accent); }
633
+ .ft-item.active { background: rgba(88,166,255,.1); color: var(--accent); }
634
+ .ft-indent { padding-left: 16px; }
635
+ .ft-icon { flex-shrink: 0; font-size: 13px; }
636
+ .ft-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
637
+
638
+ /* ── File Editor ── */
639
+ .file-editor { display: none; flex-direction: column; border-top: 1px solid var(--bg4); }
640
+ .file-editor.open { display: flex; flex: 1; min-height: 200px; }
641
+ .fe-header { padding: 8px 12px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid var(--bg4); background: var(--bg3); flex-shrink: 0; }
642
+ .fe-path { font-family: 'SF Mono', monospace; font-size: 11px; color: var(--fg2); flex: 1; overflow: hidden; text-overflow: ellipsis; }
643
+ .fe-btn { padding: 4px 10px; border: 1px solid var(--bg4); border-radius: 4px; background: var(--bg3); color: var(--fg2); font-size: 11px; cursor: pointer; }
644
+ .fe-btn:hover { border-color: var(--accent); color: var(--accent); }
645
+ .fe-btn.save { background: var(--accent2); border-color: var(--accent2); color: #fff; }
646
+ .fe-btn.save:hover { background: var(--accent); }
647
+ .fe-content { flex: 1; overflow: auto; }
648
+ .fe-content pre { padding: 10px 12px; margin: 0; font-family: 'SF Mono', monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; color: var(--fg1); min-height: 100%; }
649
+ .fe-content textarea { width: 100%; height: 100%; padding: 10px 12px; background: var(--bg1); border: none; color: var(--fg1); font-family: 'SF Mono', monospace; font-size: 12px; line-height: 1.5; resize: none; outline: none; }
650
+
651
+ /* ── Modal ── */
652
+ .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 200; align-items: center; justify-content: center; }
653
+ .modal-overlay.visible { display: flex; }
654
+ .modal { background: var(--bg2); border: 1px solid var(--bg4); border-radius: 12px; width: 480px; max-width: 90vw; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,.5); }
655
+ .modal-header { padding: 16px 20px; border-bottom: 1px solid var(--bg4); display: flex; align-items: center; }
656
+ .modal-header h3 { flex: 1; font-size: 15px; }
657
+ .modal-close { background: none; border: none; color: var(--fg2); cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 4px; }
658
+ .modal-close:hover { background: var(--bg3); }
659
+ .modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
660
+ .modal-footer { padding: 12px 20px; border-top: 1px solid var(--bg4); display: flex; justify-content: flex-end; gap: 8px; }
661
+ .m-field { margin-bottom: 14px; }
662
+ .m-field label { display: block; font-size: 12px; color: var(--fg2); margin-bottom: 5px; font-weight: 500; }
663
+ .m-field input, .m-field textarea { width: 100%; background: var(--bg1); border: 1px solid var(--bg4); border-radius: var(--radius-sm); padding: 8px 10px; color: var(--fg1); font-size: 13px; outline: none; font-family: inherit; }
664
+ .m-field input:focus, .m-field textarea:focus { border-color: var(--accent); }
665
+ .m-field textarea { min-height: 120px; resize: vertical; font-family: 'SF Mono', monospace; font-size: 12px; }
666
+ .m-field .checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; }
667
+ .m-field .checkbox-group label { display: flex; align-items: center; gap: 4px; background: var(--bg3); padding: 4px 10px; border-radius: 14px; font-size: 12px; cursor: pointer; color: var(--fg2); border: 1px solid var(--bg4); }
668
+ .m-field .checkbox-group label:has(input:checked) { background: rgba(88,166,255,.12); border-color: var(--accent); color: var(--accent); }
669
+ .m-field .checkbox-group input { display: none; }
670
+ .m-btn { padding: 8px 18px; border-radius: var(--radius-sm); border: 1px solid var(--bg4); font-size: 13px; cursor: pointer; font-weight: 500; }
671
+ .m-btn.primary { background: var(--accent2); border-color: var(--accent2); color: #fff; }
672
+ .m-btn.primary:hover { background: var(--accent); }
673
+ .m-btn.secondary { background: var(--bg3); color: var(--fg2); }
674
+ .m-btn.secondary:hover { background: var(--bg4); color: var(--fg1); }
675
+ </style>
676
+ </head>
677
+ <body>
678
+
679
+ <!-- Sidebar -->
680
+ <div class="sidebar">
681
+ <div class="sidebar-header">
682
+ <h1>&#9889; <span>Sapper</span></h1>
683
+ </div>
684
+ <div class="sidebar-tabs">
685
+ <button class="active" onclick="switchTab('sessions')">Sessions</button>
686
+ <button onclick="switchTab('agents')">Agents</button>
687
+ <button onclick="switchTab('skills')">Skills</button>
688
+ </div>
689
+ <div class="tab-panel active" id="sessionsPanel">
690
+ <div class="tab-create-btn" onclick="createNewChat()">&#10010; New Chat</div>
691
+ <div id="sessionList"></div>
692
+ </div>
693
+ <div class="tab-panel" id="agentsPanel">
694
+ <div class="tab-create-btn" onclick="openCreateAgent()">&#10010; Create Agent</div>
695
+ <div id="agentList"></div>
696
+ </div>
697
+ <div class="tab-panel" id="skillsPanel">
698
+ <div class="tab-create-btn" onclick="openCreateSkill()">&#10010; Create Skill</div>
699
+ <div id="skillList"></div>
700
+ </div>
701
+ <div class="qa-section">
702
+ <div class="qa-title">Quick Actions</div>
703
+ <div class="qa-grid">
704
+ <div class="qa-btn" onclick="qaAction('list')"><span class="qa-icon">&#128194;</span> Browse Dir</div>
705
+ <div class="qa-btn" onclick="qaAction('read')"><span class="qa-icon">&#128196;</span> Read File</div>
706
+ <div class="qa-btn" onclick="qaAction('write')"><span class="qa-icon">&#9998;</span> Create File</div>
707
+ <div class="qa-btn" onclick="qaAction('search')"><span class="qa-icon">&#128269;</span> Search</div>
708
+ <div class="qa-btn" onclick="qaAction('shell')"><span class="qa-icon">&#9654;</span> Terminal</div>
709
+ <div class="qa-btn" onclick="qaAction('mkdir')"><span class="qa-icon">&#128193;</span> New Dir</div>
710
+ <div class="qa-btn" onclick="qaAction('review')"><span class="qa-icon">&#128270;</span> Review</div>
711
+ <div class="qa-btn" onclick="qaAction('scan')"><span class="qa-icon">&#128202;</span> Scan</div>
712
+ </div>
713
+ </div>
714
+ <div class="sidebar-footer">
715
+ <div class="sf-row"><span>Model</span><span class="sf-model" id="modelTag">loading...</span></div>
716
+ <select id="modelSelect" onchange="selectModel()"></select>
717
+ </div>
718
+ </div>
719
+
720
+ <!-- Main -->
721
+ <div class="main">
722
+ <div class="topbar">
723
+ <div class="status-dot" id="statusDot"></div>
724
+ <div class="agent-badge" id="agentBadge">Sapper</div>
725
+ <div class="session-name" id="sessionName" onclick="renameCurrentSession()" title="Click to rename">New Chat</div>
726
+ <div class="spacer"></div>
727
+ <div class="cwd" id="cwdDisplay"></div>
728
+ <button class="tb-btn" onclick="toggleFilePanel()">&#128194; Files</button>
729
+ </div>
730
+ <div class="chat" id="chat">
731
+ <div class="welcome" id="welcome">
732
+ <div class="logo">&#9889;</div>
733
+ <h2>Sapper</h2>
734
+ <p>AI assistant with full filesystem access. Ask anything &mdash; code, write, analyze, build.</p>
735
+ <div class="chips">
736
+ <div class="chip" onclick="sendQuick('What files are in this project?')">&#128193; Explore project</div>
737
+ <div class="chip" onclick="sendQuick('Help me fix bugs in the codebase')">&#128027; Find bugs</div>
738
+ <div class="chip" onclick="sendQuick('Write a README for this project')">&#128221; Write docs</div>
739
+ <div class="chip" onclick="sendQuick('What are my tasks for today?')">&#128203; Today tasks</div>
740
+ </div>
741
+ </div>
742
+ </div>
743
+ <div class="input-area">
744
+ <div class="input-row">
745
+ <textarea id="input" placeholder="Message Sapper..." rows="1" onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
746
+ <button class="in-btn send" id="sendBtn" onclick="send()" title="Send">
747
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
748
+ </button>
749
+ <button class="in-btn stop" id="stopBtn" onclick="stopGeneration()" title="Stop">
750
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
751
+ </button>
752
+ </div>
753
+ <div class="input-hint">
754
+ <span><kbd>Enter</kbd> send</span>
755
+ <span><kbd>Shift+Enter</kbd> new line</span>
756
+ </div>
757
+ </div>
758
+ </div>
759
+
760
+ <!-- Right Panel -->
761
+ <div class="right-panel" id="rightPanel">
762
+ <div class="rp-header">
763
+ <h3>&#128194; Files</h3>
764
+ <button class="rp-close" onclick="toggleFilePanel()">&#10005;</button>
765
+ </div>
766
+ <div class="file-tree" id="fileTree"></div>
767
+ <div class="file-editor" id="fileEditor">
768
+ <div class="fe-header">
769
+ <span class="fe-path" id="fePath"></span>
770
+ <button class="fe-btn" id="feEditBtn" onclick="startEditing()">Edit</button>
771
+ <button class="fe-btn save" id="feSaveBtn" onclick="saveFileEdit()" style="display:none">Save</button>
772
+ <button class="fe-btn" id="feCancelBtn" onclick="cancelEdit()" style="display:none">Cancel</button>
773
+ </div>
774
+ <div class="fe-content" id="feContent"></div>
775
+ </div>
776
+ </div>
777
+
778
+ <!-- Modal -->
779
+ <div class="modal-overlay" id="modalOverlay">
780
+ <div class="modal">
781
+ <div class="modal-header">
782
+ <h3 id="modalTitle">Modal</h3>
783
+ <button class="modal-close" onclick="closeModal()">&#10005;</button>
784
+ </div>
785
+ <div class="modal-body" id="modalBody"></div>
786
+ <div class="modal-footer" id="modalFooter"></div>
787
+ </div>
788
+ </div>
789
+
790
+ <script>
791
+ // ─── State ─────────────────────────────────────────────────
792
+ var currentModel = '';
793
+ var currentAgent = null;
794
+ var currentAgentKey = null;
795
+ var currentSessionId = null;
796
+ var currentSessionName = 'New Chat';
797
+ var isStreaming = false;
798
+ var editingFilePath = null;
799
+ var editMode = false;
800
+ var BT = String.fromCharCode(96);
801
+ var NL = String.fromCharCode(10);
802
+ var chatEl = document.getElementById('chat');
803
+ var inputEl = document.getElementById('input');
804
+ var welcomeEl = document.getElementById('welcome');
805
+
806
+ // ─── Init ──────────────────────────────────────────────────
807
+ function init() {
808
+ Promise.all([
809
+ fetch('/api/models').then(function(r){return r.json();}),
810
+ fetch('/api/agents').then(function(r){return r.json();}),
811
+ fetch('/api/info').then(function(r){return r.json();}),
812
+ fetch('/api/sessions').then(function(r){return r.json();}),
813
+ fetch('/api/skills').then(function(r){return r.json();})
814
+ ]).then(function(results) {
815
+ var modelsRes = results[0], agentsRes = results[1], infoRes = results[2], sessionsRes = results[3], skillsRes = results[4];
816
+ var sel = document.getElementById('modelSelect');
817
+ sel.innerHTML = '';
818
+ for (var i = 0; i < modelsRes.models.length; i++) {
819
+ var opt = document.createElement('option');
820
+ opt.value = modelsRes.models[i]; opt.textContent = modelsRes.models[i];
821
+ sel.appendChild(opt);
822
+ }
823
+ if (modelsRes.models.length > 0) {
824
+ currentModel = modelsRes.models[0];
825
+ document.getElementById('modelTag').textContent = shortModel(currentModel);
826
+ }
827
+ renderAgentList(agentsRes.agents);
828
+ renderSkillList(skillsRes.skills);
829
+ renderSessionList(sessionsRes.sessions);
830
+ document.getElementById('cwdDisplay').textContent = infoRes.cwd;
831
+ inputEl.focus();
832
+ });
833
+ }
834
+
835
+ function shortModel(m) { return m.split(':')[0].substring(0, 18); }
836
+
837
+ // ─── Tab Switching ─────────────────────────────────────────
838
+ function switchTab(name) {
839
+ var tabs = document.querySelectorAll('.sidebar-tabs button');
840
+ var panels = document.querySelectorAll('.tab-panel');
841
+ for (var i = 0; i < tabs.length; i++) { tabs[i].classList.remove('active'); }
842
+ for (var i = 0; i < panels.length; i++) { panels[i].classList.remove('active'); }
843
+ document.getElementById(name + 'Panel').classList.add('active');
844
+ var tabNames = ['sessions', 'agents', 'skills'];
845
+ for (var i = 0; i < tabNames.length; i++) {
846
+ if (tabNames[i] === name) tabs[i].classList.add('active');
847
+ }
848
+ if (name === 'sessions') refreshSessions();
849
+ if (name === 'agents') refreshAgents();
850
+ if (name === 'skills') refreshSkills();
851
+ }
852
+
853
+ // ─── Sessions ──────────────────────────────────────────────
854
+ function renderSessionList(sessions) {
855
+ var el = document.getElementById('sessionList');
856
+ el.innerHTML = '';
857
+ for (var i = 0; i < sessions.length; i++) {
858
+ var s = sessions[i];
859
+ var item = document.createElement('div');
860
+ item.className = 's-item' + (s.id === currentSessionId ? ' active' : '');
861
+ item.innerHTML = '<span class="s-icon">&#128172;</span>' +
862
+ '<span class="s-label">' + esc(s.name) + '</span>' +
863
+ '<span class="s-meta">' + s.msgCount + '</span>' +
864
+ '<button class="s-del" onclick="event.stopPropagation(); deleteSession(&apos;' + esc(s.id) + '&apos;)" title="Delete">&#10005;</button>';
865
+ item.setAttribute('data-id', s.id);
866
+ item.onclick = (function(sid) { return function() { loadSessionById(sid); }; })(s.id);
867
+ el.appendChild(item);
868
+ }
869
+ }
870
+ function refreshSessions() {
871
+ fetch('/api/sessions').then(function(r){return r.json();}).then(function(d){renderSessionList(d.sessions);});
872
+ }
873
+ function createNewChat() {
874
+ currentSessionId = null;
875
+ currentSessionName = 'New Chat';
876
+ document.getElementById('sessionName').textContent = 'New Chat';
877
+ chatEl.innerHTML = '';
878
+ chatEl.appendChild(welcomeEl);
879
+ welcomeEl.style.display = 'flex';
880
+ fetch('/api/clear', {method: 'POST'});
881
+ refreshSessions();
882
+ }
883
+ function loadSessionById(id) {
884
+ fetch('/api/sessions/load', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:id})})
885
+ .then(function(r){return r.json();}).then(function(d) {
886
+ if (!d.session) return;
887
+ currentSessionId = id;
888
+ currentSessionName = d.session.name || 'Chat';
889
+ document.getElementById('sessionName').textContent = currentSessionName;
890
+ if (d.session.model) {
891
+ currentModel = d.session.model;
892
+ document.getElementById('modelSelect').value = currentModel;
893
+ document.getElementById('modelTag').textContent = shortModel(currentModel);
894
+ }
895
+ chatEl.innerHTML = '';
896
+ chatEl.appendChild(welcomeEl);
897
+ welcomeEl.style.display = 'none';
898
+ var msgs = d.session.messages || [];
899
+ for (var i = 0; i < msgs.length; i++) {
900
+ var m = msgs[i];
901
+ if (m.role === 'system') continue;
902
+ if (m.role === 'user' && m.content.indexOf('RESULT (') === 0) continue;
903
+ if (m.role === 'user' && m.content === 'STOP using tools. Provide your answer now with what you have.') continue;
904
+ if (m.role === 'user') addMsg('user', m.content);
905
+ else if (m.role === 'assistant') {
906
+ var el = addMsg('ai', '');
907
+ el.innerHTML = renderFullMessage(stripToolSyntax(m.content));
908
+ }
909
+ }
910
+ scrollDown();
911
+ refreshSessions();
912
+ });
913
+ }
914
+ function deleteSession(id) {
915
+ fetch('/api/sessions/delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:id})})
916
+ .then(function() {
917
+ if (currentSessionId === id) createNewChat();
918
+ refreshSessions();
919
+ });
920
+ }
921
+ function autoSaveSession() {
922
+ if (!currentSessionId) currentSessionId = 'session_' + Date.now();
923
+ if (currentSessionName === 'New Chat') {
924
+ var firstUser = null;
925
+ var msgs = chatEl.querySelectorAll('.msg.user');
926
+ if (msgs.length > 0) firstUser = msgs[0].textContent;
927
+ if (firstUser) currentSessionName = firstUser.substring(0, 50);
928
+ document.getElementById('sessionName').textContent = currentSessionName;
929
+ }
930
+ fetch('/api/sessions/save', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: currentSessionId, name: currentSessionName})});
931
+ }
932
+ function renameCurrentSession() {
933
+ if (!currentSessionId) return;
934
+ showPromptModal('Rename Session', 'Session name', currentSessionName, function(val) {
935
+ if (!val) return;
936
+ currentSessionName = val;
937
+ document.getElementById('sessionName').textContent = val;
938
+ fetch('/api/sessions/rename', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: currentSessionId, name: val})})
939
+ .then(function() { refreshSessions(); });
940
+ });
941
+ }
942
+
943
+ // ─── Agents ────────────────────────────────────────────────
944
+ function renderAgentList(agents) {
945
+ var el = document.getElementById('agentList');
946
+ el.innerHTML = '';
947
+ var defItem = document.createElement('div');
948
+ defItem.className = 's-item' + (!currentAgentKey ? ' active' : '');
949
+ defItem.innerHTML = '<span class="s-icon">&#9889;</span><span class="s-label">Sapper</span><span class="s-meta">default</span>';
950
+ defItem.onclick = function() { switchAgent(null, 'Sapper'); };
951
+ el.appendChild(defItem);
952
+ var keys = Object.keys(agents);
953
+ for (var i = 0; i < keys.length; i++) {
954
+ var k = keys[i], a = agents[k];
955
+ var item = document.createElement('div');
956
+ item.className = 's-item' + (currentAgentKey === k ? ' active' : '');
957
+ item.innerHTML = '<span class="s-icon">&#129302;</span>' +
958
+ '<span class="s-label">' + esc(a.name) + '</span>' +
959
+ '<button class="s-del" onclick="event.stopPropagation(); deleteAgent(&apos;' + esc(k) + '&apos;)" title="Delete">&#10005;</button>';
960
+ item.title = a.description || '';
961
+ item.onclick = (function(key, name) { return function() { switchAgent(key, name); }; })(k, a.name);
962
+ el.appendChild(item);
963
+ }
964
+ }
965
+ function refreshAgents() {
966
+ fetch('/api/agents').then(function(r){return r.json();}).then(function(d){renderAgentList(d.agents);});
967
+ }
968
+ function switchAgent(key, name) {
969
+ currentAgentKey = key;
970
+ currentAgent = key;
971
+ document.getElementById('agentBadge').textContent = name;
972
+ fetch('/api/agent', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({agent: key})});
973
+ addSystem('Switched to ' + name);
974
+ refreshAgents();
975
+ }
976
+ function deleteAgent(key) {
977
+ if (!confirm('Delete agent "' + key + '"?')) return;
978
+ fetch('/api/agents/delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({key: key})})
979
+ .then(function() {
980
+ if (currentAgentKey === key) switchAgent(null, 'Sapper');
981
+ refreshAgents();
982
+ });
983
+ }
984
+ function openCreateAgent() {
985
+ var allTools = ['read', 'write', 'patch', 'list', 'search', 'shell', 'mkdir'];
986
+ var body = '<div class="m-field"><label>Agent Name</label><input id="maName" placeholder="e.g. Code Reviewer"></div>' +
987
+ '<div class="m-field"><label>Description</label><input id="maDesc" placeholder="What does this agent do?"></div>' +
988
+ '<div class="m-field"><label>Allowed Tools</label><div class="checkbox-group" id="maTools">';
989
+ for (var i = 0; i < allTools.length; i++) {
990
+ body += '<label><input type="checkbox" value="' + allTools[i] + '" checked> ' + allTools[i].toUpperCase() + '</label>';
991
+ }
992
+ body += '</div></div>' +
993
+ '<div class="m-field"><label>Agent Instructions (Markdown)</label><textarea id="maContent" placeholder="# Agent Name' + NL + NL + 'Instructions for the agent..."></textarea></div>';
994
+ showModal('Create Agent', body,
995
+ '<button class="m-btn secondary" onclick="closeModal()">Cancel</button>' +
996
+ '<button class="m-btn primary" onclick="submitCreateAgent()">Create</button>');
997
+ }
998
+ function submitCreateAgent() {
999
+ var name = document.getElementById('maName').value.trim();
1000
+ var desc = document.getElementById('maDesc').value.trim();
1001
+ var content = document.getElementById('maContent').value;
1002
+ if (!name) { alert('Name is required'); return; }
1003
+ var checkboxes = document.querySelectorAll('#maTools input:checked');
1004
+ var tools = [];
1005
+ for (var i = 0; i < checkboxes.length; i++) tools.push(checkboxes[i].value);
1006
+ fetch('/api/agents/create', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:name, description:desc, tools:tools, content:content})})
1007
+ .then(function(r){return r.json();}).then(function() { closeModal(); refreshAgents(); });
1008
+ }
1009
+
1010
+ // ─── Skills ────────────────────────────────────────────────
1011
+ function renderSkillList(skills) {
1012
+ var el = document.getElementById('skillList');
1013
+ el.innerHTML = '';
1014
+ var keys = Object.keys(skills);
1015
+ if (keys.length === 0) {
1016
+ el.innerHTML = '<div style="padding:12px;color:var(--fg3);font-size:12px;">No skills yet</div>';
1017
+ return;
1018
+ }
1019
+ for (var i = 0; i < keys.length; i++) {
1020
+ var k = keys[i], s = skills[k];
1021
+ var item = document.createElement('div');
1022
+ item.className = 's-item';
1023
+ item.innerHTML = '<span class="s-icon">&#128218;</span>' +
1024
+ '<span class="s-label">' + esc(s.name) + '</span>' +
1025
+ '<button class="s-del" onclick="event.stopPropagation(); deleteSkill(&apos;' + esc(k) + '&apos;)" title="Delete">&#10005;</button>';
1026
+ item.title = s.description || '';
1027
+ el.appendChild(item);
1028
+ }
1029
+ }
1030
+ function refreshSkills() {
1031
+ fetch('/api/skills').then(function(r){return r.json();}).then(function(d){renderSkillList(d.skills);});
1032
+ }
1033
+ function deleteSkill(key) {
1034
+ if (!confirm('Delete skill "' + key + '"?')) return;
1035
+ fetch('/api/skills/delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({key: key})})
1036
+ .then(function() { refreshSkills(); });
1037
+ }
1038
+ function openCreateSkill() {
1039
+ var body = '<div class="m-field"><label>Skill Name</label><input id="msName" placeholder="e.g. Testing"></div>' +
1040
+ '<div class="m-field"><label>Description</label><input id="msDesc" placeholder="What does this skill teach?"></div>' +
1041
+ '<div class="m-field"><label>Skill Content (Markdown)</label><textarea id="msContent" placeholder="# Skill Name' + NL + NL + 'Knowledge and instructions..."></textarea></div>';
1042
+ showModal('Create Skill', body,
1043
+ '<button class="m-btn secondary" onclick="closeModal()">Cancel</button>' +
1044
+ '<button class="m-btn primary" onclick="submitCreateSkill()">Create</button>');
1045
+ }
1046
+ function submitCreateSkill() {
1047
+ var name = document.getElementById('msName').value.trim();
1048
+ var desc = document.getElementById('msDesc').value.trim();
1049
+ var content = document.getElementById('msContent').value;
1050
+ if (!name) { alert('Name is required'); return; }
1051
+ fetch('/api/skills/create', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:name, description:desc, content:content})})
1052
+ .then(function(r){return r.json();}).then(function() { closeModal(); refreshSkills(); });
1053
+ }
1054
+
1055
+ // ─── Model ─────────────────────────────────────────────────
1056
+ function selectModel() {
1057
+ currentModel = document.getElementById('modelSelect').value;
1058
+ document.getElementById('modelTag').textContent = shortModel(currentModel);
1059
+ fetch('/api/model', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({model: currentModel})});
1060
+ addSystem('Model: ' + currentModel);
1061
+ }
1062
+
1063
+ // ─── Chat ──────────────────────────────────────────────────
1064
+ function handleKey(e) {
1065
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
1066
+ }
1067
+ function autoResize(el) {
1068
+ el.style.height = 'auto';
1069
+ el.style.height = Math.min(el.scrollHeight, 150) + 'px';
1070
+ }
1071
+ function send(text) {
1072
+ var msg = text || inputEl.value.trim();
1073
+ if (!msg || isStreaming) return;
1074
+ inputEl.value = ''; inputEl.style.height = 'auto';
1075
+ welcomeEl.style.display = 'none';
1076
+ addMsg('user', msg);
1077
+ isStreaming = true;
1078
+ document.getElementById('sendBtn').disabled = true;
1079
+ document.getElementById('stopBtn').classList.add('visible');
1080
+ document.getElementById('statusDot').style.background = 'var(--orange)';
1081
+
1082
+ // Container holds all AI rounds + tool cards for this exchange
1083
+ var container = document.createElement('div');
1084
+ container.className = 'ai-exchange';
1085
+ chatEl.appendChild(container);
1086
+
1087
+ var dots = document.createElement('div');
1088
+ dots.className = 'thinking-dots';
1089
+ dots.innerHTML = '<span></span><span></span><span></span>';
1090
+ container.appendChild(dots);
1091
+
1092
+ fetch('/api/chat', {
1093
+ method: 'POST',
1094
+ headers: {'Content-Type': 'application/json'},
1095
+ body: JSON.stringify({message: msg})
1096
+ }).then(function(res) {
1097
+ var reader = res.body.getReader();
1098
+ var decoder = new TextDecoder();
1099
+ var buffer = '';
1100
+ var removedDots = false;
1101
+
1102
+ // Track current AI text segment
1103
+ var currentTextEl = null;
1104
+ var currentText = '';
1105
+ var renderTimer = null;
1106
+ var thinkEl = null;
1107
+ var thinkTextEl = null;
1108
+ var inThink = false;
1109
+ var thinkContent = '';
1110
+ var doneThinkContent = '';
1111
+ var lastRenderTime = 0;
1112
+
1113
+ function ensureTextEl() {
1114
+ if (!currentTextEl) {
1115
+ currentTextEl = document.createElement('div');
1116
+ currentTextEl.className = 'msg ai';
1117
+ container.appendChild(currentTextEl);
1118
+ currentText = '';
1119
+ }
1120
+ }
1121
+
1122
+ function renderCurrentText() {
1123
+ if (!currentTextEl) return;
1124
+ var cleaned = stripToolSyntax(doneThinkContent + currentText);
1125
+ if (cleaned) currentTextEl.innerHTML = renderMarkdown(cleaned);
1126
+ lastRenderTime = Date.now();
1127
+ }
1128
+
1129
+ function scheduleRender() {
1130
+ var now = Date.now();
1131
+ if (now - lastRenderTime > 80) {
1132
+ renderCurrentText();
1133
+ } else if (!renderTimer) {
1134
+ renderTimer = setTimeout(function() { renderTimer = null; renderCurrentText(); }, 80);
1135
+ }
1136
+ }
1137
+
1138
+ function startThinkBlock() {
1139
+ if (thinkEl) return;
1140
+ inThink = true;
1141
+ thinkContent = '';
1142
+ thinkEl = document.createElement('div');
1143
+ thinkEl.className = 'think-block open streaming';
1144
+ thinkEl.innerHTML = '<div class="think-header" onclick="this.parentElement.classList.toggle(&apos;open&apos;)">' +
1145
+ '<span class="think-chevron">&#9654;</span>' +
1146
+ '<span class="think-label">&#129504; Thinking...</span></div>' +
1147
+ '<div class="think-content"></div>';
1148
+ container.appendChild(thinkEl);
1149
+ thinkTextEl = thinkEl.querySelector('.think-content');
1150
+ }
1151
+
1152
+ function updateThinkBlock(text) {
1153
+ if (thinkTextEl) thinkTextEl.innerHTML = renderMarkdown(text);
1154
+ }
1155
+
1156
+ function endThinkBlock() {
1157
+ if (thinkEl) {
1158
+ thinkEl.classList.remove('streaming', 'open');
1159
+ thinkEl.querySelector('.think-label').innerHTML = '&#129504; Thinking (done)';
1160
+ updateThinkBlock(thinkContent);
1161
+ }
1162
+ inThink = false;
1163
+ thinkEl = null;
1164
+ thinkTextEl = null;
1165
+ }
1166
+
1167
+ function processToken(token) {
1168
+ // Handle <think> and </think> tags that may come split across tokens
1169
+ var remaining = token;
1170
+ while (remaining.length > 0) {
1171
+ if (inThink) {
1172
+ var closeIdx = remaining.indexOf('</think>');
1173
+ if (closeIdx !== -1) {
1174
+ thinkContent += remaining.substring(0, closeIdx);
1175
+ endThinkBlock();
1176
+ remaining = remaining.substring(closeIdx + 8);
1177
+ // Reset text element for content after thinking
1178
+ currentTextEl = null;
1179
+ } else {
1180
+ // Check for partial </think> at end
1181
+ var partial = false;
1182
+ for (var pLen = 1; pLen < 8 && pLen <= remaining.length; pLen++) {
1183
+ if ('</think>'.indexOf(remaining.substring(remaining.length - pLen)) === 0) {
1184
+ thinkContent += remaining.substring(0, remaining.length - pLen);
1185
+ remaining = remaining.substring(remaining.length - pLen);
1186
+ partial = true;
1187
+ break;
1188
+ }
1189
+ }
1190
+ if (!partial) {
1191
+ thinkContent += remaining;
1192
+ remaining = '';
1193
+ } else {
1194
+ // Buffer partial tag, will resolve on next token
1195
+ thinkContent += remaining;
1196
+ remaining = '';
1197
+ }
1198
+ if (thinkContent.length > 0 && Date.now() - lastRenderTime > 120) {
1199
+ updateThinkBlock(thinkContent);
1200
+ lastRenderTime = Date.now();
1201
+ }
1202
+ }
1203
+ } else {
1204
+ var openIdx = remaining.indexOf('<think>');
1205
+ if (openIdx !== -1) {
1206
+ var before = remaining.substring(0, openIdx);
1207
+ if (before.length > 0) {
1208
+ ensureTextEl();
1209
+ currentText += before;
1210
+ scheduleRender();
1211
+ }
1212
+ // Save what we have so far as done text
1213
+ if (currentText.length > 0) {
1214
+ renderCurrentText();
1215
+ doneThinkContent += currentText;
1216
+ currentText = '';
1217
+ currentTextEl = null;
1218
+ }
1219
+ startThinkBlock();
1220
+ remaining = remaining.substring(openIdx + 7);
1221
+ } else {
1222
+ ensureTextEl();
1223
+ currentText += remaining;
1224
+ scheduleRender();
1225
+ remaining = '';
1226
+ }
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ function processStream() {
1232
+ reader.read().then(function(result) {
1233
+ if (result.done) { finishStream(); return; }
1234
+ buffer += decoder.decode(result.value, {stream: true});
1235
+ var lines = buffer.split(NL);
1236
+ buffer = lines.pop();
1237
+ for (var i = 0; i < lines.length; i++) {
1238
+ var line = lines[i];
1239
+ if (line.indexOf('data: ') !== 0) continue;
1240
+ var raw = line.slice(6);
1241
+ if (raw === '[DONE]') continue;
1242
+ var evt;
1243
+ try { evt = JSON.parse(raw); } catch(e) { continue; }
1244
+ if (!removedDots) { dots.remove(); removedDots = true; }
1245
+ if (evt.type === 'token') {
1246
+ processToken(evt.data);
1247
+ scrollDown();
1248
+ } else if (evt.type === 'tool_start') {
1249
+ // Finish current text segment before showing tool
1250
+ if (currentText) { renderCurrentText(); doneThinkContent += currentText; currentText = ''; }
1251
+ currentTextEl = null;
1252
+ if (inThink) endThinkBlock();
1253
+
1254
+ var card = document.createElement('div');
1255
+ card.className = 'tool-card';
1256
+ card.setAttribute('data-tool', evt.data.tool);
1257
+ card.setAttribute('data-path', evt.data.path);
1258
+ card.innerHTML = '<div class="tool-card-header" onclick="this.parentElement.classList.toggle(&apos;expanded&apos;)">' +
1259
+ '<span class="tc-icon">&#9881;</span>' +
1260
+ '<span class="tc-name">' + esc(evt.data.tool) + '</span>' +
1261
+ '<span class="tc-path">' + esc(evt.data.path) + '</span>' +
1262
+ '<span class="tc-status tc-spinner">&#8987;</span>' +
1263
+ '<span class="tc-chevron">&#9654;</span></div>' +
1264
+ '<div class="tool-card-body"><pre>Running...</pre></div>';
1265
+ container.appendChild(card);
1266
+ scrollDown();
1267
+ } else if (evt.type === 'tool_result') {
1268
+ // Try to update the last pending tool card
1269
+ var pendingCards = container.querySelectorAll('.tool-card:not(.done)');
1270
+ var isErr = (evt.data.result && evt.data.result.indexOf('Error') !== -1) || evt.data.blocked;
1271
+ if (pendingCards.length > 0) {
1272
+ var lastCard = pendingCards[pendingCards.length - 1];
1273
+ lastCard.classList.add('done');
1274
+ if (isErr) lastCard.classList.add('error');
1275
+ var hdr = lastCard.querySelector('.tool-card-header');
1276
+ var statusSpan = lastCard.querySelector('.tc-status');
1277
+ if (statusSpan) statusSpan.innerHTML = isErr ? '&#10060;' : '&#9989;';
1278
+ var body = lastCard.querySelector('.tool-card-body pre');
1279
+ if (body) body.textContent = evt.data.result || '(no output)';
1280
+ } else {
1281
+ // Fallback: create result card
1282
+ var card = document.createElement('div');
1283
+ card.className = 'tool-card done' + (isErr ? ' error' : '');
1284
+ card.innerHTML = '<div class="tool-card-header" onclick="this.parentElement.classList.toggle(&apos;expanded&apos;)">' +
1285
+ '<span class="tc-icon">' + (isErr ? '&#10060;' : '&#9989;') + '</span>' +
1286
+ '<span class="tc-name">' + esc(evt.data.tool) + '</span>' +
1287
+ '<span class="tc-path">' + esc(evt.data.path) + '</span>' +
1288
+ '<span class="tc-chevron">&#9654;</span></div>' +
1289
+ '<div class="tool-card-body"><pre>' + esc(evt.data.result || '') + '</pre></div>';
1290
+ container.appendChild(card);
1291
+ }
1292
+ scrollDown();
1293
+ } else if (evt.type === 'system') {
1294
+ addSystem(evt.data);
1295
+ }
1296
+ }
1297
+ processStream();
1298
+ }).catch(function(e) {
1299
+ if (!removedDots) dots.remove();
1300
+ ensureTextEl();
1301
+ currentTextEl.innerHTML = '<span style="color:var(--red)">Stream error: ' + esc(e.message) + '</span>';
1302
+ finishStream();
1303
+ });
1304
+ }
1305
+
1306
+ function finishStream() {
1307
+ if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
1308
+ if (inThink) endThinkBlock();
1309
+ if (currentText) {
1310
+ renderCurrentText();
1311
+ }
1312
+ if (!removedDots) { dots.remove(); }
1313
+ // If nothing was rendered at all
1314
+ if (!container.querySelector('.msg.ai') && !container.querySelector('.tool-card') && !container.querySelector('.think-block')) {
1315
+ var empty = document.createElement('div');
1316
+ empty.className = 'msg ai';
1317
+ empty.innerHTML = '<em style="color:var(--fg3)">No response</em>';
1318
+ container.appendChild(empty);
1319
+ }
1320
+ isStreaming = false;
1321
+ document.getElementById('sendBtn').disabled = false;
1322
+ document.getElementById('stopBtn').classList.remove('visible');
1323
+ document.getElementById('statusDot').style.background = 'var(--green)';
1324
+ scrollDown();
1325
+ inputEl.focus();
1326
+ autoSaveSession();
1327
+ }
1328
+
1329
+ processStream();
1330
+ }).catch(function(e) {
1331
+ if (dots.parentNode) dots.remove();
1332
+ var errEl = document.createElement('div');
1333
+ errEl.className = 'msg ai';
1334
+ errEl.innerHTML = '<span style="color:var(--red)">Error: ' + esc(e.message) + '</span>';
1335
+ container.appendChild(errEl);
1336
+ isStreaming = false;
1337
+ document.getElementById('sendBtn').disabled = false;
1338
+ document.getElementById('stopBtn').classList.remove('visible');
1339
+ document.getElementById('statusDot').style.background = 'var(--green)';
1340
+ });
1341
+ }
1342
+ function sendQuick(text) { inputEl.value = text; send(); }
1343
+ function stopGeneration() {
1344
+ fetch('/api/stop', {method: 'POST'});
1345
+ }
1346
+ function addMsg(role, text) {
1347
+ welcomeEl.style.display = 'none';
1348
+ var el = document.createElement('div');
1349
+ el.className = 'msg ' + role;
1350
+ if (role === 'user') el.textContent = text;
1351
+ else el.innerHTML = renderFullMessage(text);
1352
+ chatEl.appendChild(el);
1353
+ scrollDown();
1354
+ return el;
1355
+ }
1356
+ function addSystem(text) {
1357
+ var el = document.createElement('div');
1358
+ el.className = 'msg system';
1359
+ el.textContent = text;
1360
+ chatEl.appendChild(el);
1361
+ scrollDown();
1362
+ }
1363
+ function scrollDown() { chatEl.scrollTop = chatEl.scrollHeight; }
1364
+
1365
+ // ─── Rendering ─────────────────────────────────────────────
1366
+ function stripToolSyntax(text) {
1367
+ var result = text;
1368
+ while (true) {
1369
+ var start = result.indexOf('[TOOL:');
1370
+ if (start === -1) break;
1371
+ var end = result.indexOf('[/TOOL]', start);
1372
+ if (end === -1) break;
1373
+ result = result.substring(0, start) + result.substring(end + 7);
1374
+ }
1375
+ return result.trim();
1376
+ }
1377
+
1378
+ function renderFullMessage(text) {
1379
+ if (!text) return '';
1380
+ var parts = parseThinkBlocks(text);
1381
+ var html = '';
1382
+ for (var i = 0; i < parts.length; i++) {
1383
+ var p = parts[i];
1384
+ if (p.type === 'thinking') {
1385
+ var cls = p.done ? 'think-block' : 'think-block open streaming';
1386
+ html += '<div class="' + cls + '">';
1387
+ html += '<div class="think-header" onclick="this.parentElement.classList.toggle(&apos;open&apos;)">';
1388
+ html += '<span class="think-chevron">&#9654;</span>';
1389
+ html += '<span class="think-label">&#129504; Thinking' + (p.done ? '' : '...') + '</span></div>';
1390
+ html += '<div class="think-content">' + renderMarkdown(p.content) + '</div></div>';
1391
+ } else {
1392
+ html += renderMarkdown(p.content);
1393
+ }
1394
+ }
1395
+ return html;
1396
+ }
1397
+
1398
+ function parseThinkBlocks(text) {
1399
+ var parts = [];
1400
+ var remaining = text;
1401
+ while (true) {
1402
+ var s = remaining.indexOf('<think>');
1403
+ if (s === -1) {
1404
+ if (remaining.length > 0) parts.push({type: 'text', content: remaining});
1405
+ break;
1406
+ }
1407
+ if (s > 0) parts.push({type: 'text', content: remaining.substring(0, s)});
1408
+ var e = remaining.indexOf('</think>', s + 7);
1409
+ if (e === -1) {
1410
+ parts.push({type: 'thinking', content: remaining.substring(s + 7), done: false});
1411
+ break;
1412
+ }
1413
+ parts.push({type: 'thinking', content: remaining.substring(s + 7, e), done: true});
1414
+ remaining = remaining.substring(e + 8);
1415
+ }
1416
+ return parts;
1417
+ }
1418
+
1419
+ function renderMarkdown(text) {
1420
+ if (!text) return '';
1421
+ var lines = text.split(NL);
1422
+ var html = '';
1423
+ var inCode = false;
1424
+ var codeLang = '';
1425
+ var codeLines = [];
1426
+ var inList = false;
1427
+
1428
+ for (var i = 0; i < lines.length; i++) {
1429
+ var line = lines[i];
1430
+ var trimmed = line.trim();
1431
+
1432
+ if (trimmed.indexOf(BT+BT+BT) === 0) {
1433
+ if (!inCode) {
1434
+ if (inList) { html += '</ul>'; inList = false; }
1435
+ inCode = true;
1436
+ codeLang = trimmed.slice(3).trim();
1437
+ codeLines = [];
1438
+ } else {
1439
+ html += '<pre><code class="lang-' + esc(codeLang) + '">' + esc(codeLines.join(NL)) + '</code></pre>';
1440
+ inCode = false;
1441
+ }
1442
+ continue;
1443
+ }
1444
+ if (inCode) { codeLines.push(line); continue; }
1445
+
1446
+ if (trimmed === '') {
1447
+ if (inList) { html += '</ul>'; inList = false; }
1448
+ html += '<br>';
1449
+ continue;
1450
+ }
1451
+
1452
+ var isLi = trimmed.length > 2 && (trimmed.charAt(0) === '-' || trimmed.charAt(0) === '*') && trimmed.charAt(1) === ' ';
1453
+ var isOl = false;
1454
+ var olMatch = trimmed.match(/^(\d+)\.\s/);
1455
+ if (olMatch) isOl = true;
1456
+
1457
+ if (!isLi && !isOl && inList) { html += '</ul>'; inList = false; }
1458
+
1459
+ if (trimmed.indexOf('### ') === 0) { html += '<h3>' + inlineFmt(esc(trimmed.slice(4))) + '</h3>'; continue; }
1460
+ if (trimmed.indexOf('## ') === 0) { html += '<h2>' + inlineFmt(esc(trimmed.slice(3))) + '</h2>'; continue; }
1461
+ if (trimmed.indexOf('# ') === 0) { html += '<h1>' + inlineFmt(esc(trimmed.slice(2))) + '</h1>'; continue; }
1462
+ if (trimmed.indexOf('> ') === 0) { html += '<blockquote>' + inlineFmt(esc(trimmed.slice(2))) + '</blockquote>'; continue; }
1463
+ if (trimmed === '---' || trimmed === '***' || trimmed === '___') { html += '<hr>'; continue; }
1464
+
1465
+ if (isLi) {
1466
+ if (!inList) { html += '<ul>'; inList = true; }
1467
+ html += '<li>' + inlineFmt(esc(trimmed.slice(2))) + '</li>';
1468
+ continue;
1469
+ }
1470
+ if (isOl) {
1471
+ if (!inList) { html += '<ul>'; inList = true; }
1472
+ var olText = trimmed.replace(/^\d+\.\s/, '');
1473
+ html += '<li>' + inlineFmt(esc(olText)) + '</li>';
1474
+ continue;
1475
+ }
1476
+
1477
+ html += '<p>' + inlineFmt(esc(line)) + '</p>';
1478
+ }
1479
+ if (inCode) html += '<pre><code>' + esc(codeLines.join(NL)) + '</code></pre>';
1480
+ if (inList) html += '</ul>';
1481
+ return html;
1482
+ }
1483
+
1484
+ function inlineFmt(text) {
1485
+ var parts = text.split(BT);
1486
+ var result = '';
1487
+ for (var i = 0; i < parts.length; i++) {
1488
+ if (i % 2 === 1) { result += '<code>' + parts[i] + '</code>'; }
1489
+ else {
1490
+ var s = parts[i];
1491
+ s = replacePairs(s, '**', '<strong>', '</strong>');
1492
+ s = replacePairs(s, '*', '<em>', '</em>');
1493
+ result += s;
1494
+ }
1495
+ }
1496
+ return result;
1497
+ }
1498
+
1499
+ function replacePairs(text, marker, open, close) {
1500
+ var r = '';
1501
+ var idx = 0;
1502
+ while (idx < text.length) {
1503
+ var s = text.indexOf(marker, idx);
1504
+ if (s === -1) { r += text.substring(idx); break; }
1505
+ var e = text.indexOf(marker, s + marker.length);
1506
+ if (e === -1) { r += text.substring(idx); break; }
1507
+ r += text.substring(idx, s) + open + text.substring(s + marker.length, e) + close;
1508
+ idx = e + marker.length;
1509
+ }
1510
+ return r;
1511
+ }
1512
+
1513
+ function esc(s) {
1514
+ if (!s) return '';
1515
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1516
+ }
1517
+
1518
+ // ─── File Panel ────────────────────────────────────────────
1519
+ function toggleFilePanel() {
1520
+ var rp = document.getElementById('rightPanel');
1521
+ if (rp.classList.contains('open')) { rp.classList.remove('open'); }
1522
+ else { rp.classList.add('open'); loadFileTree('.'); }
1523
+ }
1524
+ function closeFilePanel() { document.getElementById('rightPanel').classList.remove('open'); }
1525
+
1526
+ function loadFileTree(dirPath) {
1527
+ fetch('/api/tree?path=' + encodeURIComponent(dirPath)).then(function(r){return r.json();}).then(function(d) {
1528
+ var el = document.getElementById('fileTree');
1529
+ el.innerHTML = '';
1530
+ if (dirPath !== '.') {
1531
+ var up = document.createElement('div');
1532
+ up.className = 'ft-item';
1533
+ up.innerHTML = '<span class="ft-icon">&#11013;</span><span class="ft-name">..</span>';
1534
+ var parent = dirPath.split('/').slice(0, -1).join('/') || '.';
1535
+ up.onclick = function() { loadFileTree(parent); };
1536
+ el.appendChild(up);
1537
+ }
1538
+ for (var i = 0; i < d.entries.length; i++) {
1539
+ var entry = d.entries[i];
1540
+ var item = document.createElement('div');
1541
+ item.className = 'ft-item' + (entry.isDir ? ' dir' : '');
1542
+ var icon = entry.isDir ? '&#128194;' : '&#128196;';
1543
+ item.innerHTML = '<span class="ft-icon">' + icon + '</span><span class="ft-name">' + esc(entry.name) + '</span>';
1544
+ if (entry.isDir) {
1545
+ var subPath = (dirPath === '.' ? '' : dirPath + '/') + entry.name;
1546
+ item.onclick = (function(p) { return function() { loadFileTree(p); }; })(subPath);
1547
+ } else {
1548
+ var filePath = (dirPath === '.' ? '' : dirPath + '/') + entry.name;
1549
+ item.onclick = (function(p) { return function() { viewFile(p); }; })(filePath);
1550
+ }
1551
+ el.appendChild(item);
1552
+ }
1553
+ });
1554
+ }
1555
+
1556
+ function viewFile(path) {
1557
+ editMode = false;
1558
+ editingFilePath = path;
1559
+ fetch('/api/file/read?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d) {
1560
+ var editor = document.getElementById('fileEditor');
1561
+ editor.classList.add('open');
1562
+ document.getElementById('fePath').textContent = path;
1563
+ var content = document.getElementById('feContent');
1564
+ content.innerHTML = '<pre>' + esc(d.content || '') + '</pre>';
1565
+ document.getElementById('feEditBtn').style.display = '';
1566
+ document.getElementById('feSaveBtn').style.display = 'none';
1567
+ document.getElementById('feCancelBtn').style.display = 'none';
1568
+ });
1569
+ }
1570
+
1571
+ function startEditing() {
1572
+ if (!editingFilePath) return;
1573
+ editMode = true;
1574
+ var content = document.getElementById('feContent');
1575
+ var pre = content.querySelector('pre');
1576
+ var text = pre ? pre.textContent : '';
1577
+ content.innerHTML = '';
1578
+ var ta = document.createElement('textarea');
1579
+ ta.value = text;
1580
+ ta.id = 'feTextarea';
1581
+ content.appendChild(ta);
1582
+ document.getElementById('feEditBtn').style.display = 'none';
1583
+ document.getElementById('feSaveBtn').style.display = '';
1584
+ document.getElementById('feCancelBtn').style.display = '';
1585
+ }
1586
+
1587
+ function saveFileEdit() {
1588
+ var ta = document.getElementById('feTextarea');
1589
+ if (!ta || !editingFilePath) return;
1590
+ fetch('/api/file/write', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({path: editingFilePath, content: ta.value})})
1591
+ .then(function(r){return r.json();}).then(function(d) {
1592
+ if (d.ok) { viewFile(editingFilePath); addSystem('Saved ' + editingFilePath); }
1593
+ else addSystem('Error saving: ' + (d.error || 'unknown'));
1594
+ });
1595
+ }
1596
+
1597
+ function cancelEdit() { if (editingFilePath) viewFile(editingFilePath); }
1598
+
1599
+ // ─── Quick Actions ─────────────────────────────────────────
1600
+ function qaAction(type) {
1601
+ if (type === 'list') {
1602
+ showPromptModal('Browse Directory', 'Directory path (e.g. src)', '.', function(v) {
1603
+ sendQuick('List the contents of the directory: ' + v);
1604
+ });
1605
+ } else if (type === 'read') {
1606
+ showPromptModal('Read File', 'File path (e.g. src/index.js)', '', function(v) {
1607
+ if (v) sendQuick('Read and show me the file: ' + v);
1608
+ });
1609
+ } else if (type === 'write') {
1610
+ showPromptModal('Create File', 'File path to create', '', function(v) {
1611
+ if (v) sendQuick('Create a new file at ' + v + ' with appropriate starter content');
1612
+ });
1613
+ } else if (type === 'search') {
1614
+ showPromptModal('Search Files', 'Search pattern', '', function(v) {
1615
+ if (v) sendQuick('Search the codebase for: ' + v);
1616
+ });
1617
+ } else if (type === 'shell') {
1618
+ showPromptModal('Run Command', 'Shell command', '', function(v) {
1619
+ if (v) sendQuick('Run this command: ' + v);
1620
+ });
1621
+ } else if (type === 'mkdir') {
1622
+ showPromptModal('Create Directory', 'Directory path', '', function(v) {
1623
+ if (v) sendQuick('Create directory: ' + v);
1624
+ });
1625
+ } else if (type === 'review') {
1626
+ sendQuick('Review the codebase for bugs, issues, and improvements');
1627
+ } else if (type === 'scan') {
1628
+ sendQuick('List the project files and give me a complete overview of the project structure and purpose');
1629
+ }
1630
+ }
1631
+
1632
+ // ─── Modal ─────────────────────────────────────────────────
1633
+ function showModal(title, bodyHtml, footerHtml) {
1634
+ document.getElementById('modalTitle').textContent = title;
1635
+ document.getElementById('modalBody').innerHTML = bodyHtml;
1636
+ document.getElementById('modalFooter').innerHTML = footerHtml;
1637
+ document.getElementById('modalOverlay').classList.add('visible');
1638
+ }
1639
+ function closeModal() {
1640
+ document.getElementById('modalOverlay').classList.remove('visible');
1641
+ }
1642
+ function showPromptModal(title, placeholder, defaultVal, callback) {
1643
+ var body = '<div class="m-field"><label>' + esc(placeholder) + '</label><input id="promptInput" value="' + esc(defaultVal || '') + '"></div>';
1644
+ showModal(title, body,
1645
+ '<button class="m-btn secondary" onclick="closeModal()">Cancel</button>' +
1646
+ '<button class="m-btn primary" id="promptOk">OK</button>');
1647
+ var inp = document.getElementById('promptInput');
1648
+ inp.focus();
1649
+ inp.select();
1650
+ document.getElementById('promptOk').onclick = function() { closeModal(); callback(inp.value); };
1651
+ inp.onkeydown = function(e) { if (e.key === 'Enter') { closeModal(); callback(inp.value); } };
1652
+ }
1653
+
1654
+ // ─── Escape modal on Escape key ────────────────────────────
1655
+ document.addEventListener('keydown', function(e) {
1656
+ if (e.key === 'Escape') closeModal();
1657
+ });
1658
+
1659
+ init();
1660
+ </script>
1661
+ </body>
1662
+ </html>`;
1663
+ }
1664
+
1665
+ // ─── HTTP Server ───────────────────────────────────────────
1666
+
1667
+ const server = http.createServer(async (req, res) => {
1668
+ req.socket.setNoDelay(true);
1669
+ const url = new URL(req.url, `http://localhost:${PORT}`);
1670
+
1671
+ // ── Serve frontend ──
1672
+ if (req.method === 'GET' && url.pathname === '/') {
1673
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1674
+ res.end(buildHTML());
1675
+ return;
1676
+ }
1677
+
1678
+ // ── API: models ──
1679
+ if (req.method === 'GET' && url.pathname === '/api/models') {
1680
+ try {
1681
+ const list = await ollama.list();
1682
+ const names = (list.models || []).map(m => m.name);
1683
+ if (names.length > 0 && !serverModel) serverModel = names[0];
1684
+ json(res, { models: names });
1685
+ } catch (e) { json(res, { models: [], error: e.message }, 500); }
1686
+ return;
1687
+ }
1688
+
1689
+ // ── API: agents ──
1690
+ if (req.method === 'GET' && url.pathname === '/api/agents') {
1691
+ json(res, { agents: loadAgents() });
1692
+ return;
1693
+ }
1694
+
1695
+ // ── API: skills ──
1696
+ if (req.method === 'GET' && url.pathname === '/api/skills') {
1697
+ json(res, { skills: loadSkills() });
1698
+ return;
1699
+ }
1700
+
1701
+ // ── API: info ──
1702
+ if (req.method === 'GET' && url.pathname === '/api/info') {
1703
+ json(res, { cwd: workingDir, version: getVersion() });
1704
+ return;
1705
+ }
1706
+
1707
+ // ── API: sessions list ──
1708
+ if (req.method === 'GET' && url.pathname === '/api/sessions') {
1709
+ json(res, { sessions: listSessions() });
1710
+ return;
1711
+ }
1712
+
1713
+ // ── API: session save ──
1714
+ if (req.method === 'POST' && url.pathname === '/api/sessions/save') {
1715
+ const body = await readBody(req);
1716
+ if (body.id) {
1717
+ currentSessionId = body.id;
1718
+ saveSession(body.id, body.name || 'Chat', serverMessages, serverModel, serverAgentKey);
1719
+ }
1720
+ json(res, { ok: true });
1721
+ return;
1722
+ }
1723
+
1724
+ // ── API: session load ──
1725
+ if (req.method === 'POST' && url.pathname === '/api/sessions/load') {
1726
+ const body = await readBody(req);
1727
+ const session = loadSessionData(body.id);
1728
+ if (session) {
1729
+ serverMessages = session.messages || [];
1730
+ if (session.model) serverModel = session.model;
1731
+ currentSessionId = body.id;
1732
+ if (session.agent) {
1733
+ const agents = loadAgents();
1734
+ if (agents[session.agent]) {
1735
+ serverAgent = agents[session.agent];
1736
+ serverAgentKey = session.agent;
1737
+ }
1738
+ }
1739
+ }
1740
+ json(res, { session });
1741
+ return;
1742
+ }
1743
+
1744
+ // ── API: session delete ──
1745
+ if (req.method === 'POST' && url.pathname === '/api/sessions/delete') {
1746
+ const body = await readBody(req);
1747
+ deleteSessionFile(body.id);
1748
+ json(res, { ok: true });
1749
+ return;
1750
+ }
1751
+
1752
+ // ── API: session rename ──
1753
+ if (req.method === 'POST' && url.pathname === '/api/sessions/rename') {
1754
+ const body = await readBody(req);
1755
+ renameSession(body.id, body.name);
1756
+ json(res, { ok: true });
1757
+ return;
1758
+ }
1759
+
1760
+ // ── API: create agent ──
1761
+ if (req.method === 'POST' && url.pathname === '/api/agents/create') {
1762
+ const body = await readBody(req);
1763
+ const file = createAgentFile(body.name, body.description, body.tools, body.content || '');
1764
+ json(res, { ok: true, file });
1765
+ return;
1766
+ }
1767
+
1768
+ // ── API: delete agent ──
1769
+ if (req.method === 'POST' && url.pathname === '/api/agents/delete') {
1770
+ const body = await readBody(req);
1771
+ deleteAgentFile(body.key);
1772
+ json(res, { ok: true });
1773
+ return;
1774
+ }
1775
+
1776
+ // ── API: create skill ──
1777
+ if (req.method === 'POST' && url.pathname === '/api/skills/create') {
1778
+ const body = await readBody(req);
1779
+ const file = createSkillFile(body.name, body.description, body.content || '');
1780
+ json(res, { ok: true, file });
1781
+ return;
1782
+ }
1783
+
1784
+ // ── API: delete skill ──
1785
+ if (req.method === 'POST' && url.pathname === '/api/skills/delete') {
1786
+ const body = await readBody(req);
1787
+ deleteSkillFile(body.key);
1788
+ json(res, { ok: true });
1789
+ return;
1790
+ }
1791
+
1792
+ // ── API: file tree ──
1793
+ if (req.method === 'GET' && url.pathname === '/api/tree') {
1794
+ const dirPath = url.searchParams.get('path') || '.';
1795
+ json(res, { entries: getTreeEntries(dirPath) });
1796
+ return;
1797
+ }
1798
+
1799
+ // ── API: file read ──
1800
+ if (req.method === 'GET' && url.pathname === '/api/file/read') {
1801
+ const filePath = url.searchParams.get('path');
1802
+ const safe = safePath(filePath);
1803
+ if (!safe) { json(res, { error: 'Invalid path' }, 400); return; }
1804
+ try {
1805
+ const content = fs.readFileSync(safe, 'utf8');
1806
+ json(res, { content, path: filePath });
1807
+ } catch (e) { json(res, { error: e.message }, 500); }
1808
+ return;
1809
+ }
1810
+
1811
+ // ── API: file write ──
1812
+ if (req.method === 'POST' && url.pathname === '/api/file/write') {
1813
+ const body = await readBody(req);
1814
+ const safe = safePath(body.path);
1815
+ if (!safe) { json(res, { error: 'Invalid path' }, 400); return; }
1816
+ try {
1817
+ fs.mkdirSync(dirname(safe), { recursive: true });
1818
+ fs.writeFileSync(safe, body.content);
1819
+ json(res, { ok: true });
1820
+ } catch (e) { json(res, { error: e.message }, 500); }
1821
+ return;
1822
+ }
1823
+
1824
+ // ── API: select model ──
1825
+ if (req.method === 'POST' && url.pathname === '/api/model') {
1826
+ const body = await readBody(req);
1827
+ serverModel = body.model || serverModel;
1828
+ json(res, { ok: true, model: serverModel });
1829
+ return;
1830
+ }
1831
+
1832
+ // ── API: select agent ──
1833
+ if (req.method === 'POST' && url.pathname === '/api/agent') {
1834
+ const body = await readBody(req);
1835
+ const agentKey = body.agent;
1836
+ if (agentKey === null) {
1837
+ serverAgent = null;
1838
+ serverAgentKey = null;
1839
+ serverAgentTools = null;
1840
+ } else {
1841
+ const agents = loadAgents();
1842
+ if (agents[agentKey]) {
1843
+ serverAgent = agents[agentKey];
1844
+ serverAgentKey = agentKey;
1845
+ const toolMap = { read: 'READ', write: 'WRITE', edit: 'PATCH', patch: 'PATCH', list: 'LIST', search: 'SEARCH', shell: 'SHELL', mkdir: 'MKDIR' };
1846
+ serverAgentTools = serverAgent.tools
1847
+ ? (Array.isArray(serverAgent.tools) ? serverAgent.tools : [serverAgent.tools])
1848
+ .map(t => toolMap[t.toLowerCase()] || t.toUpperCase()).filter(Boolean)
1849
+ : null;
1850
+ }
1851
+ }
1852
+ resetChat();
1853
+ json(res, { ok: true });
1854
+ return;
1855
+ }
1856
+
1857
+ // ── API: clear chat ──
1858
+ if (req.method === 'POST' && url.pathname === '/api/clear') {
1859
+ resetChat();
1860
+ currentSessionId = null;
1861
+ json(res, { ok: true });
1862
+ return;
1863
+ }
1864
+
1865
+ // ── API: stop generation ──
1866
+ if (req.method === 'POST' && url.pathname === '/api/stop') {
1867
+ abortFlag = true;
1868
+ json(res, { ok: true });
1869
+ return;
1870
+ }
1871
+
1872
+ // ── API: chat (SSE streaming) ──
1873
+ if (req.method === 'POST' && url.pathname === '/api/chat') {
1874
+ const body = await readBody(req);
1875
+ const userMsg = body.message;
1876
+ if (!userMsg) { json(res, { error: 'No message' }, 400); return; }
1877
+ if (!serverModel) { json(res, { error: 'No model selected' }, 400); return; }
1878
+
1879
+ if (serverMessages.length === 0) resetChat();
1880
+ serverMessages.push({ role: 'user', content: userMsg });
1881
+
1882
+ res.writeHead(200, {
1883
+ 'Content-Type': 'text/event-stream',
1884
+ 'Cache-Control': 'no-cache, no-transform',
1885
+ 'Connection': 'keep-alive',
1886
+ 'X-Accel-Buffering': 'no',
1887
+ });
1888
+ res.flushHeaders();
1889
+
1890
+ try {
1891
+ const stream = chatStream(serverMessages, serverModel, serverAgentTools);
1892
+ for await (const evt of stream) {
1893
+ const ok = res.write(`data: ${JSON.stringify(evt)}\n\n`);
1894
+ if (!ok) await new Promise(r => res.once('drain', r));
1895
+ }
1896
+ } catch (e) {
1897
+ res.write(`data: ${JSON.stringify({ type: 'system', data: 'Error: ' + e.message })}\n\n`);
1898
+ }
1899
+ res.write('data: [DONE]\n\n');
1900
+ res.end();
1901
+ return;
1902
+ }
1903
+
1904
+ // ── 404 ──
1905
+ res.writeHead(404);
1906
+ res.end('Not found');
1907
+ });
1908
+
1909
+ // ─── Launch ────────────────────────────────────────────────
1910
+
1911
+ server.listen(PORT, () => {
1912
+ const url = `http://localhost:${PORT}`;
1913
+ console.log(`\n ⚡ Sapper UI running at ${url}\n`);
1914
+
1915
+ const browsers = [
1916
+ ['open', ['-na', 'Google Chrome', '--args', `--app=${url}`, '--new-window']],
1917
+ ['open', ['-na', 'Microsoft Edge', '--args', `--app=${url}`]],
1918
+ ['open', ['-na', 'Brave Browser', '--args', `--app=${url}`]],
1919
+ ['open', [url]],
1920
+ ];
1921
+
1922
+ let opened = false;
1923
+ for (const [cmd, args] of browsers) {
1924
+ if (opened) break;
1925
+ try {
1926
+ const proc = spawn(cmd, args, { stdio: 'ignore', detached: true });
1927
+ proc.unref();
1928
+ proc.on('error', () => {});
1929
+ opened = true;
1930
+ } catch {}
1931
+ }
1932
+ if (!opened) console.log(` Open manually: ${url}`);
1933
+ });