sapper-iq 1.1.40 → 1.2.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.
Files changed (4) hide show
  1. package/README.md +224 -158
  2. package/package.json +7 -3
  3. package/sapper-ui.mjs +1577 -1863
  4. package/sapper.mjs +1 -1
package/sapper-ui.mjs CHANGED
@@ -1,2017 +1,1732 @@
1
1
  #!/usr/bin/env node
2
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
3
+ * Sapper Web Runs the real sapper.mjs inside a browser terminal,
4
+ * with a sidebar (Files / Config / Agents / Skills) and a document
5
+ * viewer/editor that auto-refreshes when Sapper modifies a file.
6
+ *
7
+ * Browser <--WebSocket--> Node <--pty--> sapper.mjs
8
+ * <--WebSocket--> Node <--fs.watch--> workspace
9
+ * <--REST--> Node <--fs--> files / config
6
10
  */
7
11
 
8
12
  import http from 'http';
9
13
  import fs from 'fs';
10
- import { spawn } from 'child_process';
14
+ import os from 'os';
11
15
  import { fileURLToPath } from 'url';
12
- import { dirname, join, resolve, basename } from 'path';
13
- import ollama from 'ollama';
16
+ import { dirname, join, resolve as pathResolve, relative, sep } from 'path';
17
+ import { spawn as ptySpawn } from 'node-pty';
18
+ import { WebSocketServer } from 'ws';
19
+ import { spawn } from 'child_process';
14
20
 
15
21
  const __filename = fileURLToPath(import.meta.url);
16
22
  const __dirname = dirname(__filename);
17
23
 
18
- const PORT = 3777;
19
- const SAPPER_DIR = '.sapper';
24
+ const PORT = parseInt(process.env.SAPPER_UI_PORT || '3777', 10);
25
+ const SAPPER_BIN = join(__dirname, 'sapper.mjs');
26
+ const workingDir = process.cwd();
27
+ const SAPPER_DIR = join(workingDir, '.sapper');
28
+ const CONFIG_FILE = join(SAPPER_DIR, 'config.json');
20
29
  const AGENTS_DIR = join(SAPPER_DIR, 'agents');
21
30
  const SKILLS_DIR = join(SAPPER_DIR, 'skills');
22
- const SESSIONS_DIR = join(SAPPER_DIR, 'sessions');
23
31
 
24
- let workingDir = process.cwd();
32
+ const IGNORE_NAMES = new Set([
33
+ '.git', 'node_modules', '.next', 'dist', 'build', '.cache',
34
+ '.DS_Store', '__pycache__', '.venv', 'venv',
35
+ ]);
25
36
 
26
- // ─── Helpers ───────────────────────────────────────────────
37
+ const DEBUG = !!process.env.SAPPER_UI_DEBUG;
38
+ const dbg = (...a) => { if (DEBUG) console.log('[ui]', ...a); };
27
39
 
28
- function ensureDir(dir) {
29
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
30
- }
40
+ // ─── Path safety ─────────────────────────────────────────────────
31
41
 
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
- };
42
+ function safePath(p) {
43
+ if (typeof p !== 'string') return null;
44
+ const cleaned = p.replace(/^\/+/, '');
45
+ const abs = pathResolve(workingDir, cleaned || '.');
46
+ if (abs !== workingDir && !abs.startsWith(workingDir + sep)) return null;
47
+ return abs;
48
+ }
49
+
50
+ function stripJSONC(src) {
51
+ // Remove // line comments and /* ... */ block comments outside strings.
52
+ let out = '';
53
+ let i = 0;
54
+ const n = src.length;
55
+ while (i < n) {
56
+ const c = src[i];
57
+ const c2 = src[i + 1];
58
+ if (c === '"' || c === "'") {
59
+ const quote = c;
60
+ out += c; i++;
61
+ while (i < n) {
62
+ const ch = src[i];
63
+ out += ch; i++;
64
+ if (ch === '\\' && i < n) { out += src[i]; i++; continue; }
65
+ if (ch === quote) break;
66
+ }
67
+ continue;
68
+ }
69
+ if (c === '/' && c2 === '/') { while (i < n && src[i] !== '\n') i++; continue; }
70
+ if (c === '/' && c2 === '*') {
71
+ i += 2;
72
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
73
+ i += 2; continue;
69
74
  }
70
- } catch (e) {}
71
- return agents;
75
+ out += c; i++;
76
+ }
77
+ return out;
72
78
  }
73
79
 
74
- function loadSkills() {
75
- ensureDir(SKILLS_DIR);
76
- const skills = {};
80
+ function readJSON(file, fallback = null) {
77
81
  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 };
82
+ const raw = fs.readFileSync(file, 'utf8');
83
+ try { return JSON.parse(raw); }
84
+ catch {
85
+ const cleaned = stripJSONC(raw).replace(/,(\s*[}\]])/g, '$1'); // also tolerate trailing commas
86
+ return JSON.parse(cleaned);
84
87
  }
85
- } catch (e) {}
86
- return skills;
88
+ } catch { return fallback; }
87
89
  }
88
90
 
89
- const IGNORE_DIRS = new Set(['node_modules', '.git', '.sapper', '__pycache__', '.next', 'dist', 'build', '.cache']);
91
+ function ensureDir(d) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
90
92
 
91
- // ─── .sapperignore Support ─────────────────────────────────
92
- const SAPPERIGNORE_FILE = '.sapperignore';
93
+ // ─── Markdown frontmatter (for agents/skills) ────────────────────
93
94
 
94
- function loadSapperIgnorePatterns() {
95
- const patterns = [];
96
- try {
97
- const ignorePath = join(workingDir, SAPPERIGNORE_FILE);
98
- if (fs.existsSync(ignorePath)) {
99
- const lines = fs.readFileSync(ignorePath, 'utf8').split('\n');
100
- for (const rawLine of lines) {
101
- const line = rawLine.trim();
102
- if (!line || line.startsWith('#')) continue;
103
- const negate = line.startsWith('!');
104
- const pattern = negate ? line.slice(1) : line;
105
- patterns.push({ pattern, negate });
106
- }
95
+ function parseFrontmatter(raw) {
96
+ const m = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
97
+ if (!m) return { meta: {}, body: raw };
98
+ const meta = {};
99
+ for (const line of m[1].split('\n')) {
100
+ const i = line.indexOf(':');
101
+ if (i === -1) continue;
102
+ let k = line.slice(0, i).trim();
103
+ let v = line.slice(i + 1).trim();
104
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
105
+ if (v.startsWith('[') && v.endsWith(']')) {
106
+ v = v.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
107
107
  }
108
- } catch (e) {}
109
- return patterns;
110
- }
111
-
112
- let _sapperIgnorePatterns = null;
113
- function getSapperIgnorePatterns() {
114
- if (_sapperIgnorePatterns === null) _sapperIgnorePatterns = loadSapperIgnorePatterns();
115
- return _sapperIgnorePatterns;
116
- }
117
-
118
- function ignorePatternToRegex(pattern) {
119
- try {
120
- let p = pattern.replace(/\/+$/, '');
121
- p = p.replace(/([.+^${}()|[\]\\])/g, '\\$1');
122
- p = p.replace(/\*\*/g, '<<<GLOBSTAR>>>');
123
- p = p.replace(/\*/g, '[^/]*');
124
- p = p.replace(/<<<GLOBSTAR>>>/g, '.*');
125
- p = p.replace(/\?/g, '[^/]');
126
- return new RegExp(`(^|/)${p}($|/)`, 'i');
127
- } catch (e) {
128
- return /^$/; // Return a regex that never matches on error
108
+ meta[k] = v;
129
109
  }
110
+ return { meta, body: m[2] };
130
111
  }
131
112
 
132
- function shouldIgnore(nameOrPath) {
133
- const baseName = nameOrPath.includes('/') ? nameOrPath.split('/').pop() : nameOrPath;
134
- if (IGNORE_DIRS.has(baseName)) return true;
135
- const patterns = getSapperIgnorePatterns();
136
- if (patterns.length === 0) return false;
137
- let ignored = false;
138
- for (const { pattern, negate } of patterns) {
139
- const regex = ignorePatternToRegex(pattern);
140
- if (regex.test(nameOrPath) || regex.test(baseName)) ignored = !negate;
113
+ function listMdDir(dir) {
114
+ if (!fs.existsSync(dir)) return [];
115
+ const out = [];
116
+ for (const f of fs.readdirSync(dir)) {
117
+ if (!f.endsWith('.md')) continue;
118
+ const full = join(dir, f);
119
+ try {
120
+ const raw = fs.readFileSync(full, 'utf8');
121
+ const { meta } = parseFrontmatter(raw);
122
+ out.push({
123
+ key: f.replace(/\.md$/, ''),
124
+ file: f,
125
+ name: meta.name || f.replace(/\.md$/, ''),
126
+ description: meta.description || '',
127
+ path: relative(workingDir, full),
128
+ });
129
+ } catch {}
141
130
  }
142
- return ignored;
131
+ return out.sort((a, b) => a.name.localeCompare(b.name));
143
132
  }
144
133
 
145
- function safePath(p) {
146
- const resolved = resolve(workingDir, p || '.');
147
- if (!resolved.startsWith(workingDir)) return null;
148
- return resolved;
149
- }
134
+ // ─── HTML page ───────────────────────────────────────────────────
150
135
 
151
- function buildSystemPrompt(agentContent = null, agentTools = null, skillContents = []) {
152
- const now = new Date();
153
- const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
154
- const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
155
- let prompt = `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
156
- You can help with ANY task - coding, writing, research, planning, analysis, and more.
157
-
158
- CURRENT DATE AND TIME: ${dateStr}, ${timeStr}
159
- WORKING DIRECTORY: ${workingDir}
160
-
161
- RULES:
162
- 1. EXPLORE FIRST: Use LIST and READ to understand files before making changes.
163
- 2. THINK IN STEPS: Explain what you found and what you plan to do before acting.
164
- 3. BE PRECISE: When using PATCH, prefer LINE:number mode.
165
- 4. VERIFY: After making changes, verify they work.
166
- 5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content.
167
-
168
- TOOL SYNTAX:
169
- - [TOOL:LIST]dir[/TOOL] - List directory contents
170
- - [TOOL:READ]file_path[/TOOL] - Read file contents
171
- - [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
172
- - [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
173
- - [TOOL:PATCH]path:::old|||new[/TOOL] - Edit file (exact/fuzzy match)
174
- - [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace line by number (PREFERRED)
175
- - [TOOL:SHELL]command[/TOOL] - Run shell command
176
- - [TOOL:MKDIR]path[/TOOL] - Create directory
177
-
178
- PATCH TIPS:
179
- - PREFER LINE:number mode. Always READ first.
180
- - If PATCH fails, switch to LINE:number or WRITE.
181
-
182
- You MUST use [TOOL:...][/TOOL] syntax to perform actions.
183
- Do NOT show tool syntax as examples to the user — only use them to perform real actions.`;
184
-
185
- if (agentContent) {
186
- prompt += `\n\n═══ ACTIVE AGENT ═══\n${agentContent}\n═══ END AGENT ═══`;
187
- if (agentTools && agentTools.length > 0) {
188
- const allTools = ['READ', 'WRITE', 'PATCH', 'LIST', 'SEARCH', 'SHELL', 'MKDIR'];
189
- const forbidden = allTools.filter(t => !agentTools.includes(t));
190
- prompt += `\nTOOL RESTRICTION: ONLY use: ${agentTools.join(', ')}. FORBIDDEN: ${forbidden.join(', ')}.`;
191
- }
136
+ function buildHTML() {
137
+ return `<!doctype html>
138
+ <html lang="en">
139
+ <head>
140
+ <meta charset="utf-8" />
141
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
142
+ <title>Sapper</title>
143
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css" />
144
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" />
145
+ <style>
146
+ :root {
147
+ --bg: #0a0e14;
148
+ --panel: #0d1117;
149
+ --panel2: #11161d;
150
+ --border: #21262d;
151
+ --border2: #30363d;
152
+ --fg: #e6edf3;
153
+ --muted: #8b949e;
154
+ --dim: #6e7681;
155
+ --accent: #58a6ff;
156
+ --accent2: #79c0ff;
157
+ --green: #3fb950;
158
+ --red: #f85149;
159
+ --yellow: #d29922;
160
+ --purple: #bc8cff;
192
161
  }
193
- if (skillContents.length > 0) {
194
- prompt += `\n\n═══ SKILLS ═══\n${skillContents.join('\n---\n')}\n═══ END SKILLS ═══`;
162
+ * { box-sizing: border-box; }
163
+ html, body { margin: 0; height: 100%; width: 100%; max-width: 100vw; overflow: hidden;
164
+ background: var(--bg); color: var(--fg);
165
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; }
166
+ button { font-family: inherit; }
167
+
168
+ #app { display: flex; flex-direction: column; height: 100vh; width: 100vw;
169
+ max-width: 100vw; overflow: hidden; }
170
+
171
+ /* ─── Top bar ─── */
172
+ #bar {
173
+ height: 38px; flex-shrink: 0; display: flex; align-items: center;
174
+ padding: 0 12px; gap: 10px;
175
+ background: var(--panel); border-bottom: 1px solid var(--border);
176
+ font-size: 12px;
195
177
  }
196
- return prompt;
197
- }
198
-
199
- // ─── Tool Execution ────────────────────────────────────────
200
-
201
- const tools = {
202
- list: (path) => {
203
- try {
204
- let dir = resolve(workingDir, path.trim() || '.');
205
- if (dir === '/') dir = workingDir;
206
- const entries = fs.readdirSync(dir);
207
- return entries.filter(e => !shouldIgnore(e) && !e.startsWith('.')).join('\n') || '(empty)';
208
- } catch (e) { return `Error: ${e.message}`; }
209
- },
210
- read: (path) => {
211
- try { return fs.readFileSync(resolve(workingDir, path.trim()), 'utf8'); }
212
- catch (e) { return `Error: ${e.message}`; }
213
- },
214
- write: (path, content) => {
215
- try {
216
- const p = resolve(workingDir, path.trim());
217
- fs.mkdirSync(dirname(p), { recursive: true });
218
- fs.writeFileSync(p, content);
219
- return `Successfully saved ${path.trim()}`;
220
- } catch (e) { return `Error: ${e.message}`; }
221
- },
222
- mkdir: (path) => {
223
- try { fs.mkdirSync(resolve(workingDir, path.trim()), { recursive: true }); return `Created ${path}`; }
224
- catch (e) { return `Error: ${e.message}`; }
225
- },
226
- patch: (path, oldText, newText) => {
227
- const p = resolve(workingDir, path.trim());
228
- try {
229
- const content = fs.readFileSync(p, 'utf8');
230
- const lineMatch = oldText.match(/^LINE:(\d+)$/);
231
- if (lineMatch) {
232
- const n = parseInt(lineMatch[1], 10);
233
- const lines = content.split('\n');
234
- if (n < 1 || n > lines.length) return `Error: Line ${n} out of range (${lines.length} lines)`;
235
- const old = lines[n - 1];
236
- lines[n - 1] = newText;
237
- fs.writeFileSync(p, lines.join('\n'));
238
- return `Patched line ${n}: "${old}" → "${newText}"`;
239
- }
240
- if (content.includes(oldText)) {
241
- fs.writeFileSync(p, content.replace(oldText, newText));
242
- return `Successfully patched ${path.trim()}`;
243
- }
244
- if (content.includes(oldText.trim())) {
245
- fs.writeFileSync(p, content.replace(oldText.trim(), newText.trim()));
246
- return `Successfully patched ${path.trim()} (trimmed match)`;
247
- }
248
- const normalize = s => s.replace(/[\u{1F000}-\u{1FFFF}]/gu, '').replace(/\s+/g, ' ').trim();
249
- const normOld = normalize(oldText);
250
- const lines = content.split('\n');
251
- const oldLines = oldText.trim().split('\n');
252
- for (let i = 0; i <= lines.length - oldLines.length; i++) {
253
- const win = lines.slice(i, i + oldLines.length).join('\n');
254
- if (normalize(win) === normOld) {
255
- const newContent = content.replace(win, newText.trim());
256
- fs.writeFileSync(p, newContent);
257
- return `Successfully patched ${path.trim()} (fuzzy match at line ${i + 1})`;
258
- }
259
- }
260
- return `Error: Text not found in ${path.trim()}. Use LINE:number mode instead.`;
261
- } catch (e) { return `Error: ${e.message}`; }
262
- },
263
- search: (pattern) => {
264
- return new Promise((res) => {
265
- const allIgnoreDirs = new Set(IGNORE_DIRS);
266
- for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
267
- if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
268
- }
269
- // Use args array to avoid command injection
270
- const args = ['-rEin', pattern, '.'];
271
- for (const dir of allIgnoreDirs) {
272
- args.push(`--exclude-dir=${dir}`);
273
- }
274
- args.push('--include=*.js', '--include=*.ts', '--include=*.jsx', '--include=*.tsx',
275
- '--include=*.py', '--include=*.java', '--include=*.go', '--include=*.rs',
276
- '--include=*.rb', '--include=*.php', '--include=*.c', '--include=*.cpp',
277
- '--include=*.h', '--include=*.css', '--include=*.scss', '--include=*.html',
278
- '--include=*.json', '--include=*.md', '--include=*.txt', '--include=*.yml',
279
- '--include=*.yaml', '--include=*.toml', '--include=*.sh');
280
- const proc = spawn('grep', args, { cwd: workingDir });
281
- let out = '';
282
- let lineCount = 0;
283
- proc.stdout.on('data', d => {
284
- const text = d.toString();
285
- const lines = text.split('\n');
286
- for (const line of lines) {
287
- if (lineCount >= 50) { proc.kill(); return; }
288
- if (line) { out += line + '\n'; lineCount++; }
289
- }
290
- });
291
- proc.stderr.on('data', () => {});
292
- proc.on('error', (err) => res(`Error searching: ${err.message}`));
293
- proc.on('close', () => res(out.trim() || `No matches for: ${pattern}`));
294
- });
295
- },
296
- shell: (cmd) => {
297
- return new Promise((res) => {
298
- const proc = spawn('sh', ['-c', cmd], { cwd: workingDir });
299
- let out = '';
300
- proc.stdout.on('data', d => out += d);
301
- proc.stderr.on('data', d => out += d);
302
- proc.on('error', (err) => {
303
- res(`Shell error: ${err.message}`);
304
- });
305
- proc.on('close', code => {
306
- let result = out.trim();
307
- if (result.length > 10000) result = result.substring(0, 10000) + '\n...(truncated)';
308
- res(result || `Completed (exit ${code})`);
309
- });
310
- });
311
- },
312
- };
313
-
314
- async function executeTool(type, path, content, agentTools) {
315
- if (agentTools && !agentTools.includes(type.toUpperCase())) {
316
- return { result: `Error: Tool ${type.toUpperCase()} not allowed. Allowed: ${agentTools.join(', ')}`, blocked: true };
178
+ #bar .title { font-weight: 700; letter-spacing: .3px; user-select: none; }
179
+ #bar .title span { color: var(--accent); }
180
+ #bar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); transition: background .2s; }
181
+ #bar .dot.on { background: var(--green); }
182
+ #bar .dot.err { background: var(--red); }
183
+ #bar .cwd { color: var(--muted); font-family: ui-monospace, 'SF Mono', monospace;
184
+ font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 35vw; }
185
+ #bar .spacer { flex: 1; }
186
+ #bar button {
187
+ background: transparent; color: var(--muted); border: 1px solid var(--border);
188
+ border-radius: 6px; padding: 4px 10px; font-size: 11px; cursor: pointer;
189
+ transition: all .12s;
317
190
  }
318
- const t = type.toLowerCase();
319
- let result;
320
- if (t === 'list') result = tools.list(path);
321
- else if (t === 'read') result = tools.read(path);
322
- else if (t === 'mkdir') result = tools.mkdir(path);
323
- else if (t === 'write') result = tools.write(path, content || '');
324
- else if (t === 'patch') {
325
- // Use indexOf to split into exactly 2 parts, preserving ||| in content
326
- const sepIdx = content?.indexOf('|||');
327
- let parts = null;
328
- if (sepIdx > -1) {
329
- parts = [content.substring(0, sepIdx), content.substring(sepIdx + 3)];
330
- }
331
- if (parts && parts.length === 2) result = tools.patch(path, parts[0], parts[1]);
332
- else result = 'Error: PATCH needs OLD_TEXT|||NEW_TEXT';
191
+ #bar button:hover { color: var(--accent); border-color: var(--accent); }
192
+ #bar button.toggle.on { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,.08); }
193
+
194
+ /* live system stats */
195
+ #stats { display: grid; grid-template-columns: auto auto auto; gap: 4px 10px;
196
+ align-items: center; padding: 4px 10px; margin: 0 8px;
197
+ background: var(--panel2); border: 1px solid var(--border); border-radius: 6px;
198
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 10px; line-height: 1; }
199
+ #stats .srow { display: contents; }
200
+ #stats .slbl { color: var(--dim); font-size: 9px; letter-spacing: .5px; min-width: 22px; }
201
+ #stats .sbar { width: 60px; height: 5px; background: var(--bg); border-radius: 3px;
202
+ overflow: hidden; }
203
+ #stats .sbar i { display: block; height: 100%; width: 0%;
204
+ background: linear-gradient(90deg, var(--green), var(--accent));
205
+ transition: width .35s ease, background .25s; }
206
+ #stats .sbar i.warn { background: linear-gradient(90deg, var(--yellow), #ff9b3f); }
207
+ #stats .sbar i.crit { background: linear-gradient(90deg, #ff7b72, var(--red)); }
208
+ #stats .sval { color: var(--fg); min-width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
209
+ @media (max-width: 1100px) {
210
+ #stats .sbar { width: 40px; }
211
+ #stats .sval { min-width: 48px; font-size: 9px; }
212
+ #bar .cwd { display: none; }
333
213
  }
334
- else if (t === 'search') result = await tools.search(path);
335
- else if (t === 'shell') result = await tools.shell(path);
336
- else result = `Unknown tool: ${type}`;
337
- return { result, blocked: false };
338
- }
339
-
340
- // ─── Chat Engine ───────────────────────────────────────────
341
-
342
- let abortFlag = false;
343
-
344
- async function* chatStream(messages, model, agentTools) {
345
- const MAX_TOOL_ROUNDS = 15;
346
- let rounds = 0;
347
- const patchFails = {};
348
-
349
- while (true) {
350
- if (abortFlag) { abortFlag = false; yield { type: 'system', data: 'Generation stopped' }; break; }
214
+ @media (max-width: 820px) { #stats { display: none; } }
351
215
 
352
- let fullMsg = '';
353
- const response = await ollama.chat({ model, messages, stream: true });
354
- for await (const chunk of response) {
355
- if (abortFlag) { abortFlag = false; yield { type: 'system', data: 'Generation stopped' }; messages.push({ role: 'assistant', content: fullMsg }); return; }
356
- const token = chunk.message?.content || '';
357
- fullMsg += token;
358
- yield { type: 'token', data: token };
359
- }
360
- messages.push({ role: 'assistant', content: fullMsg });
361
-
362
- const clean = fullMsg.replace(/```[\s\S]*?```/g, '');
363
- const toolMatches = [...clean.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
216
+ /* ─── Body layout ─── */
217
+ #body { flex: 1; min-height: 0; min-width: 0; display: flex; overflow: hidden; }
364
218
 
365
- if (toolMatches.length === 0) break;
366
-
367
- rounds++;
368
- if (rounds >= MAX_TOOL_ROUNDS) {
369
- messages.push({ role: 'user', content: 'STOP using tools. Provide your answer now with what you have.' });
370
- yield { type: 'system', data: `Tool limit reached (${MAX_TOOL_ROUNDS} rounds)` };
371
- continue;
372
- }
373
-
374
- for (const match of toolMatches) {
375
- if (abortFlag) break;
376
- const [, type, path, content] = match;
377
- yield { type: 'tool_start', data: { tool: type.toUpperCase(), path } };
378
-
379
- if (type.toLowerCase() === 'patch') {
380
- const key = path.trim();
381
- if ((patchFails[key] || 0) >= 3) {
382
- const err = `Error: PATCH failed 3 times on ${key}. Use READ + LINE:number mode or WRITE instead.`;
383
- messages.push({ role: 'user', content: `RESULT (${path}): ${err}` });
384
- yield { type: 'tool_result', data: { tool: 'PATCH', path, result: err, blocked: true } };
385
- continue;
386
- }
387
- }
388
-
389
- const { result, blocked } = await executeTool(type, path, content, agentTools);
390
-
391
- if (type.toLowerCase() === 'patch' && result.startsWith('Error:')) {
392
- const key = path.trim();
393
- patchFails[key] = (patchFails[key] || 0) + 1;
394
- }
219
+ /* ─── Sidebar ─── */
220
+ #side {
221
+ width: 280px; flex-shrink: 0; display: flex; flex-direction: column;
222
+ background: var(--panel); border-right: 1px solid var(--border);
223
+ min-width: 0; overflow: hidden;
224
+ }
225
+ #side.hidden { display: none; }
226
+ .tabs { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
227
+ .tabs button {
228
+ flex: 1; padding: 8px 4px; background: none; border: none;
229
+ border-bottom: 2px solid transparent; color: var(--dim); font-size: 11px;
230
+ font-weight: 600; cursor: pointer; text-transform: uppercase; letter-spacing: .5px;
231
+ }
232
+ .tabs button:hover { color: var(--muted); }
233
+ .tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
234
+ .pane { flex: 1; min-height: 0; min-width: 0; overflow-x: hidden; overflow-y: auto; display: none; padding: 6px 0; }
235
+ .pane.active { display: block; }
236
+ .pane::-webkit-scrollbar { width: 6px; }
237
+ .pane::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
238
+
239
+ /* Files tree */
240
+ .files-toolbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
241
+ border-bottom: 1px solid var(--border); background: var(--panel); }
242
+ .files-toolbar .ftb-spacer { flex: 1; }
243
+ .files-toolbar .ftb { background: transparent; color: var(--muted); border: 1px solid transparent;
244
+ border-radius: 4px; padding: 3px 7px; font-size: 13px; cursor: pointer; line-height: 1;
245
+ position: relative; }
246
+ .files-toolbar .ftb sup { font-size: 9px; color: var(--green); margin-left: 1px; }
247
+ .files-toolbar .ftb:hover { background: rgba(255,255,255,.05); color: var(--fg); border-color: var(--border); }
248
+ .tree { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; padding-bottom: 12px; }
249
+ .row { display: flex; align-items: center; gap: 4px; padding: 3px 8px; cursor: pointer; color: var(--muted);
250
+ white-space: nowrap; user-select: none; position: relative; }
251
+ .row:hover { background: rgba(255,255,255,.04); color: var(--fg); }
252
+ .row.active { background: rgba(88,166,255,.12); color: var(--accent); }
253
+ .row .chev { width: 12px; display: inline-block; color: var(--dim); font-size: 9px; flex-shrink: 0; text-align: center; }
254
+ .row .ico { width: 14px; flex-shrink: 0; }
255
+ .row .name { overflow: hidden; text-overflow: ellipsis; }
256
+ .row .badge { margin-left: auto; font-size: 9px; color: var(--yellow); opacity: 0; transition: opacity .2s; }
257
+ .row.changed .badge { opacity: 1; }
258
+ .row .rmenu { margin-left: auto; color: var(--dim); font-size: 14px; padding: 0 4px;
259
+ opacity: 0; flex-shrink: 0; line-height: 1; border-radius: 3px; }
260
+ .row.changed .rmenu { margin-left: 4px; }
261
+ .row:hover .rmenu, .row .rmenu.open { opacity: 1; }
262
+ .row .rmenu:hover { color: var(--fg); background: rgba(255,255,255,.08); }
263
+
264
+ /* Context menu */
265
+ .ctx-menu { position: fixed; z-index: 9999; min-width: 180px;
266
+ background: var(--panel2); border: 1px solid var(--border); border-radius: 6px;
267
+ padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,.5); font-size: 12px;
268
+ color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
269
+ .ctx-menu .ci { padding: 6px 14px; cursor: pointer; display: flex; align-items: center;
270
+ gap: 10px; color: var(--muted); }
271
+ .ctx-menu .ci:hover { background: rgba(88,166,255,.12); color: var(--accent); }
272
+ .ctx-menu .ci.danger:hover { background: rgba(248,81,73,.15); color: var(--red); }
273
+ .ctx-menu .ci .k { margin-left: auto; color: var(--dim); font-size: 10px; }
274
+ .ctx-menu .sep { height: 1px; background: var(--border); margin: 4px 0; }
275
+
276
+ /* Modal */
277
+ .modal-bd { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 10000;
278
+ display: flex; align-items: center; justify-content: center; }
279
+ .modal { background: var(--panel2); border: 1px solid var(--border); border-radius: 8px;
280
+ padding: 18px 18px 14px; width: 460px; max-width: 92vw;
281
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
282
+ .modal h3 { margin: 0 0 12px; font-size: 14px; color: var(--fg); font-weight: 600; }
283
+ .modal label { display: block; font-size: 11px; color: var(--dim); margin: 8px 0 4px;
284
+ text-transform: uppercase; letter-spacing: .5px; }
285
+ .modal input[type=text] { width: 100%; box-sizing: border-box; background: var(--bg);
286
+ color: var(--fg); border: 1px solid var(--border); border-radius: 4px;
287
+ padding: 7px 9px; font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; outline: none; }
288
+ .modal input[type=text]:focus { border-color: var(--accent); }
289
+ .modal .hint { font-size: 11px; color: var(--dim); margin-top: 4px; }
290
+ .modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
291
+ .modal .actions button { background: transparent; color: var(--muted); border: 1px solid var(--border);
292
+ border-radius: 5px; padding: 6px 14px; font-size: 12px; cursor: pointer; }
293
+ .modal .actions button:hover { color: var(--fg); border-color: var(--accent); }
294
+ .modal .actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
295
+ .modal .actions button.primary:hover { filter: brightness(1.1); }
296
+ .modal .actions button.danger { background: var(--red); color: #fff; border-color: var(--red); }
297
+
298
+ /* Config / Agents / Skills lists */
299
+ .pane-section { padding: 10px 14px; }
300
+ .pane-section h4 { font-size: 11px; color: var(--dim); text-transform: uppercase;
301
+ letter-spacing: .5px; margin: 0 0 8px; font-weight: 600; }
302
+ .pane-section p { font-size: 12px; color: var(--muted); margin: 4px 0 12px; line-height: 1.4; }
303
+ .pane-section label { display: block; font-size: 11px; color: var(--muted); margin: 8px 0 4px; font-weight: 500; }
304
+ .pane-section input[type=text], .pane-section input[type=number], .pane-section select {
305
+ width: 100%; background: var(--panel2); border: 1px solid var(--border2); border-radius: 5px;
306
+ padding: 5px 8px; color: var(--fg); font-size: 12px; outline: none; font-family: inherit;
307
+ }
308
+ .pane-section input:focus, .pane-section select:focus { border-color: var(--accent); }
309
+ .pane-section .toggle-row { display: flex; align-items: center; justify-content: space-between;
310
+ padding: 6px 0; border-bottom: 1px solid var(--border); }
311
+ .pane-section .toggle-row span { font-size: 12px; color: var(--muted); }
312
+ .switch { position: relative; width: 30px; height: 16px; background: var(--border2); border-radius: 8px;
313
+ cursor: pointer; transition: background .15s; flex-shrink: 0; }
314
+ .switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
315
+ background: var(--muted); border-radius: 50%; transition: all .15s; }
316
+ .switch.on { background: var(--accent); }
317
+ .switch.on::after { background: white; left: 16px; }
318
+
319
+ .json-edit {
320
+ width: 100%; max-width: 100%; height: 320px; background: var(--bg);
321
+ border: 1px solid var(--border2); border-radius: 6px; padding: 8px 10px; color: var(--fg);
322
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; line-height: 1.45;
323
+ resize: vertical; outline: none; display: block;
324
+ white-space: pre; overflow: auto;
325
+ }
326
+ .json-edit:focus { border-color: var(--accent); }
327
+ .row-btns { display: flex; gap: 6px; margin-top: 8px; }
328
+ .row-btns button {
329
+ flex: 1; padding: 6px 10px; border-radius: 5px; border: 1px solid var(--border2);
330
+ background: var(--panel2); color: var(--muted); font-size: 11px; cursor: pointer;
331
+ transition: all .12s;
332
+ }
333
+ .row-btns button:hover { color: var(--accent); border-color: var(--accent); }
334
+ .row-btns button.primary { background: var(--accent); color: white; border-color: var(--accent); }
335
+ .row-btns button.primary:hover { background: var(--accent2); }
336
+ .row-btns button.danger:hover { color: var(--red); border-color: var(--red); }
337
+
338
+ .item { padding: 8px 14px; cursor: pointer; border-left: 2px solid transparent; }
339
+ .item:hover { background: rgba(255,255,255,.03); border-left-color: var(--border2); }
340
+ .item .ti { font-size: 13px; color: var(--fg); display: flex; align-items: center; gap: 6px; }
341
+ .item .ti .b { background: var(--accent); color: white; font-size: 9px; padding: 1px 5px;
342
+ border-radius: 8px; text-transform: uppercase; letter-spacing: .3px; }
343
+ .item .ds { font-size: 11px; color: var(--dim); margin-top: 2px; line-height: 1.35; }
344
+
345
+ /* ─── Terminal area ─── */
346
+ #center { flex: 1; min-width: 0; min-height: 0; display: flex;
347
+ flex-direction: column; background: var(--bg); overflow: hidden; }
348
+ #term-wrap { flex: 1; min-height: 0; min-width: 0; padding: 6px 0 0 10px;
349
+ overflow: hidden; position: relative; }
350
+ #term-wrap .terminal, #term-wrap .xterm { height: 100% !important; width: 100% !important; }
351
+ .xterm-screen, .xterm-viewport { max-width: 100% !important; }
352
+ .xterm .xterm-viewport { background-color: var(--bg) !important; }
353
+ .xterm-viewport::-webkit-scrollbar { width: 8px; }
354
+ .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
355
+ .xterm-viewport::-webkit-scrollbar-track { background: transparent; }
356
+
357
+ /* ─── Preview panel ─── */
358
+ #preview {
359
+ width: 480px; flex-shrink: 0; display: flex; flex-direction: column;
360
+ background: var(--panel); border-left: 1px solid var(--border);
361
+ min-width: 0; overflow: hidden;
362
+ }
363
+ #preview.hidden { display: none; }
364
+ #preview .ph {
365
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
366
+ background: var(--panel2); border-bottom: 1px solid var(--border); flex-shrink: 0;
367
+ }
368
+ #preview .ph .pp { flex: 1; font-family: ui-monospace, 'SF Mono', monospace;
369
+ font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
370
+ #preview .ph button {
371
+ background: transparent; color: var(--muted); border: 1px solid var(--border2);
372
+ border-radius: 5px; padding: 3px 9px; font-size: 11px; cursor: pointer;
373
+ }
374
+ #preview .ph button:hover { color: var(--accent); border-color: var(--accent); }
375
+ #preview .ph button.primary { background: var(--accent); color: white; border-color: var(--accent); }
376
+ #preview .ph button.primary:hover { background: var(--accent2); }
395
377
 
396
- messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
397
- yield { type: 'tool_result', data: { tool: type.toUpperCase(), path, result: result.substring(0, 3000), blocked } };
398
- }
378
+ #preview .ind {
379
+ display: none; padding: 4px 12px; background: rgba(210,153,34,.12);
380
+ color: var(--yellow); font-size: 11px; border-bottom: 1px solid rgba(210,153,34,.3);
399
381
  }
400
- }
382
+ #preview .ind.show { display: block; }
383
+
384
+ #pview { flex: 1; min-height: 0; overflow: auto; padding: 14px 18px; font-size: 13.5px; line-height: 1.6; }
385
+ #pview::-webkit-scrollbar { width: 8px; }
386
+ #pview::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
387
+ #pview pre { background: var(--bg); border: 1px solid var(--border);
388
+ border-radius: 6px; padding: 10px 12px; overflow-x: auto;
389
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; line-height: 1.5; }
390
+ #pview code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; }
391
+ #pview :not(pre) > code { background: var(--panel2); padding: 1px 5px; border-radius: 3px; }
392
+ #pview h1, #pview h2, #pview h3 { color: var(--accent); margin-top: 1.2em; }
393
+ #pview h1 { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: .3em; }
394
+ #pview h2 { font-size: 18px; }
395
+ #pview h3 { font-size: 15px; }
396
+ #pview a { color: var(--accent); }
397
+ #pview blockquote { border-left: 3px solid var(--accent); padding-left: 12px; color: var(--muted); margin: 8px 0; }
398
+ #pview table { border-collapse: collapse; margin: 8px 0; }
399
+ #pview th, #pview td { border: 1px solid var(--border); padding: 5px 8px; }
400
+ #pview hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
401
+ #pview img { max-width: 100%; border-radius: 4px; }
402
+ #pview iframe.html-preview { width: 100%; height: 100%; border: 0; background: #fff;
403
+ border-radius: 4px; display: block; }
404
+
405
+ #pview.code { padding: 0; }
406
+ #pview.code pre { margin: 0; border: none; border-radius: 0; min-height: 100%; }
407
+
408
+ #pedit {
409
+ flex: 1; min-height: 0; width: 100%; padding: 12px 14px;
410
+ background: var(--bg); border: none; color: var(--fg);
411
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 12.5px; line-height: 1.5;
412
+ resize: none; outline: none; display: none;
413
+ }
414
+ #pedit.show { display: block; }
415
+ #pview.hide { display: none; }
416
+
417
+ #empty { padding: 40px 20px; text-align: center; color: var(--dim); font-size: 13px; }
418
+ #empty .lg { font-size: 36px; margin-bottom: 8px; }
419
+
420
+ /* Toast for fs events */
421
+ #toast { position: fixed; bottom: 14px; right: 14px; z-index: 100;
422
+ display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
423
+ .tmsg { background: rgba(13,17,23,.95); color: var(--fg); border: 1px solid var(--border2);
424
+ border-radius: 6px; padding: 8px 12px; font-size: 12px; pointer-events: auto;
425
+ animation: slideIn .2s ease; max-width: 360px; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
426
+ .tmsg.warn { border-color: rgba(210,153,34,.5); }
427
+ .tmsg.err { border-color: var(--red); }
428
+ @keyframes slideIn { from { transform: translateX(10px); opacity: 0; } to { transform: none; opacity: 1; } }
429
+ </style>
430
+ </head>
431
+ <body>
432
+ <div id="app">
433
+ <div id="bar">
434
+ <span class="dot" id="dot"></span>
435
+ <span class="title">&#9889; <span>Sapper</span></span>
436
+ <span class="cwd" id="cwd"></span>
437
+ <div id="stats" title="Live system stats">
438
+ <div class="srow"><span class="slbl">CPU</span><div class="sbar"><i id="bCpu"></i></div><span class="sval" id="vCpu">—</span></div>
439
+ <div class="srow"><span class="slbl">RAM</span><div class="sbar"><i id="bRam"></i></div><span class="sval" id="vRam">—</span></div>
440
+ <div class="srow"><span class="slbl">GPU</span><div class="sbar"><i id="bGpu"></i></div><span class="sval" id="vGpu">—</span></div>
441
+ </div>
442
+ <span class="spacer"></span>
443
+ <button id="btnSide" class="toggle on" onclick="toggleSide()">Sidebar</button>
444
+ <button id="btnPrev" class="toggle" onclick="togglePreview()">Preview</button>
445
+ <button onclick="sendCmd('/help')">/help</button>
446
+ <button onclick="sendCmd('/agents')">agents</button>
447
+ <button onclick="sendCmd('/model')">model</button>
448
+ <button onclick="sendCmd('/clear')">clear</button>
449
+ <button onclick="restartSapper()">restart</button>
450
+ </div>
401
451
 
402
- // ─── Session Management ────────────────────────────────────
452
+ <div id="body">
453
+ <!-- Sidebar -->
454
+ <aside id="side">
455
+ <div class="tabs">
456
+ <button class="active" data-tab="files" onclick="switchTab('files')">Files</button>
457
+ <button data-tab="config" onclick="switchTab('config')">Config</button>
458
+ <button data-tab="agents" onclick="switchTab('agents')">Agents</button>
459
+ <button data-tab="skills" onclick="switchTab('skills')">Skills</button>
460
+ </div>
461
+ <div class="pane active" id="pane-files">
462
+ <div class="files-toolbar">
463
+ <button class="ftb" title="New file" onclick="newItemPrompt('file','')">&#128462;<sup>+</sup></button>
464
+ <button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">&#128193;<sup>+</sup></button>
465
+ <span class="ftb-spacer"></span>
466
+ <button class="ftb" title="Refresh tree" onclick="loadTree()">&#8634;</button>
467
+ <button class="ftb" title="Collapse all" onclick="collapseAll()">&#8676;</button>
468
+ </div>
469
+ <div class="tree" id="tree"></div>
470
+ </div>
471
+ <div class="pane" id="pane-config">
472
+ <div class="pane-section" id="cfgQuick">
473
+ <h4>Quick settings</h4>
474
+ <div id="cfgQuickBody"></div>
475
+ </div>
476
+ <div class="pane-section">
477
+ <h4>Raw config.json</h4>
478
+ <p>Full <code>.sapper/config.json</code> — every Sapper option lives here.</p>
479
+ <textarea class="json-edit" id="cfgJson" spellcheck="false"></textarea>
480
+ <div class="row-btns">
481
+ <button onclick="reloadConfig()">Reload</button>
482
+ <button class="primary" onclick="saveConfig()">Save</button>
483
+ </div>
484
+ </div>
485
+ </div>
486
+ <div class="pane" id="pane-agents">
487
+ <div class="pane-section">
488
+ <h4>Available agents</h4>
489
+ <p>Click any agent to open its <code>.md</code> file in preview.</p>
490
+ </div>
491
+ <div id="agentsList"></div>
492
+ </div>
493
+ <div class="pane" id="pane-skills">
494
+ <div class="pane-section">
495
+ <h4>Available skills</h4>
496
+ <p>Click a skill to open it. Use <code>/use name</code> in the terminal to load.</p>
497
+ </div>
498
+ <div id="skillsList"></div>
499
+ </div>
500
+ </aside>
501
+
502
+ <!-- Center: terminal -->
503
+ <main id="center">
504
+ <div id="term-wrap"></div>
505
+ </main>
506
+
507
+ <!-- Right: preview -->
508
+ <aside id="preview" class="hidden">
509
+ <div class="ph">
510
+ <span class="pp" id="pPath">No file open</span>
511
+ <button id="pEdit" onclick="startEdit()" style="display:none">Edit</button>
512
+ <button id="pSave" onclick="saveEdit()" class="primary" style="display:none">Save</button>
513
+ <button id="pCancel" onclick="cancelEdit()" style="display:none">Cancel</button>
514
+ <button id="pSrc" onclick="toggleSource()" style="display:none">Source</button>
515
+ <button id="pReload" onclick="reloadPreview()" style="display:none">Reload</button>
516
+ <button onclick="closePreview()">&times;</button>
517
+ </div>
518
+ <div class="ind" id="pInd">File changed on disk — reload to view latest.</div>
519
+ <div id="pview"><div id="empty"><div class="lg">&#128196;</div>Open a file from the sidebar.</div></div>
520
+ <textarea id="pedit" spellcheck="false"></textarea>
521
+ </aside>
522
+ </div>
523
+ </div>
524
+ <div id="toast"></div>
403
525
 
404
- function listSessions() {
405
- ensureDir(SESSIONS_DIR);
406
- try {
407
- return fs.readdirSync(SESSIONS_DIR)
408
- .filter(f => f.endsWith('.json'))
409
- .map(f => {
410
- try {
411
- const data = JSON.parse(fs.readFileSync(join(SESSIONS_DIR, f), 'utf8'));
412
- return { id: f.replace('.json', ''), name: data.name || 'Unnamed', created: data.created, msgCount: (data.messages || []).filter(m => m.role === 'user').length };
413
- } catch { return null; }
414
- })
415
- .filter(Boolean)
416
- .sort((a, b) => new Date(b.created) - new Date(a.created));
417
- } catch { return []; }
418
- }
526
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
527
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
528
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
529
+ <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
530
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
531
+ <script>
532
+ /* ─────────────────────────────────────────────────────────────── */
533
+ /* Sapper Web frontend */
534
+ /* ─────────────────────────────────────────────────────────────── */
419
535
 
420
- function saveSession(id, name, messages, model, agentKey) {
421
- ensureDir(SESSIONS_DIR);
422
- const data = { name, created: new Date().toISOString(), model, agent: agentKey, messages };
423
- fs.writeFileSync(join(SESSIONS_DIR, `${id}.json`), JSON.stringify(data));
424
- }
536
+ var BT = String.fromCharCode(96);
425
537
 
426
- function loadSessionData(id) {
427
- try { return JSON.parse(fs.readFileSync(join(SESSIONS_DIR, `${id}.json`), 'utf8')); }
428
- catch { return null; }
429
- }
538
+ // ─── State ────────────────────────────────────────────────────
539
+ var state = {
540
+ cwd: '',
541
+ currentFile: null, // workspace-relative path currently in preview
542
+ fileOnDisk: '', // last loaded content from server
543
+ editing: false,
544
+ expanded: { '': true },
545
+ fsWS: null,
546
+ };
430
547
 
431
- function deleteSessionFile(id) {
432
- try { fs.unlinkSync(join(SESSIONS_DIR, `${id}.json`)); return true; }
433
- catch { return false; }
548
+ function esc(s) {
549
+ if (s == null) return '';
550
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
551
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
434
552
  }
435
553
 
436
- function renameSession(id, newName) {
554
+ function showToast(msg, kind) {
555
+ var el = document.createElement('div');
556
+ el.className = 'tmsg' + (kind ? ' ' + kind : '');
557
+ el.textContent = msg;
558
+ document.getElementById('toast').appendChild(el);
559
+ setTimeout(function(){ el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 2200);
560
+ setTimeout(function(){ el.remove(); }, 2700);
561
+ }
562
+
563
+ // ─── Terminal & pty WS ───────────────────────────────────────
564
+ var term = new Terminal({
565
+ fontFamily: '"SF Mono","Fira Code","JetBrains Mono",Menlo,ui-monospace,monospace',
566
+ fontSize: 13, lineHeight: 1.25, cursorBlink: true, cursorStyle: 'bar',
567
+ scrollback: 10000, allowProposedApi: true, macOptionIsMeta: true,
568
+ theme: {
569
+ background:'#0a0e14', foreground:'#e6edf3', cursor:'#58a6ff', cursorAccent:'#0a0e14',
570
+ selectionBackground:'rgba(88,166,255,0.35)',
571
+ black:'#484f58', red:'#ff7b72', green:'#3fb950', yellow:'#d29922',
572
+ blue:'#58a6ff', magenta:'#bc8cff', cyan:'#39c5cf', white:'#e6edf3',
573
+ brightBlack:'#6e7681', brightRed:'#ffa198', brightGreen:'#56d364',
574
+ brightYellow:'#e3b341', brightBlue:'#79c0ff', brightMagenta:'#d2a8ff',
575
+ brightCyan:'#56d4dd', brightWhite:'#f0f6fc'
576
+ }
577
+ });
578
+ var fit = new FitAddon.FitAddon();
579
+ term.loadAddon(fit);
580
+ try { term.loadAddon(new WebLinksAddon.WebLinksAddon()); } catch(e){}
581
+ term.open(document.getElementById('term-wrap'));
582
+ setTimeout(function(){ try { fit.fit(); } catch(e){} }, 30);
583
+
584
+ var ws = null, reconnectTimer = null;
585
+
586
+ function connectPty() {
587
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
588
+ ws = new WebSocket(proto + '//' + location.host + '/ws');
589
+ ws.binaryType = 'arraybuffer';
590
+ ws.onopen = function() {
591
+ document.getElementById('dot').className = 'dot on';
592
+ var d = fit.proposeDimensions() || { cols: 100, rows: 30 };
593
+ ws.send(JSON.stringify({ type:'init', cols:d.cols, rows:d.rows }));
594
+ term.focus();
595
+ };
596
+ ws.onmessage = function(ev) {
597
+ if (typeof ev.data === 'string') {
598
+ try {
599
+ var m = JSON.parse(ev.data);
600
+ if (m.type === 'cwd') { state.cwd = m.path; document.getElementById('cwd').textContent = m.path; }
601
+ else if (m.type === 'exit') {
602
+ term.writeln('\\r\\n\\x1b[33m[sapper exited — click "restart" to relaunch]\\x1b[0m');
603
+ document.getElementById('dot').className = 'dot err';
604
+ }
605
+ } catch(e){}
606
+ } else {
607
+ term.write(new Uint8Array(ev.data));
608
+ }
609
+ };
610
+ ws.onclose = function() {
611
+ document.getElementById('dot').className = 'dot err';
612
+ clearTimeout(reconnectTimer);
613
+ reconnectTimer = setTimeout(connectPty, 1200);
614
+ };
615
+ ws.onerror = function(){};
616
+ }
617
+ term.onData(function(d){ if (ws && ws.readyState === 1) ws.send(d); });
618
+ function doFit() {
437
619
  try {
438
- const data = JSON.parse(fs.readFileSync(join(SESSIONS_DIR, `${id}.json`), 'utf8'));
439
- data.name = newName;
440
- fs.writeFileSync(join(SESSIONS_DIR, `${id}.json`), JSON.stringify(data));
441
- return true;
442
- } catch { return false; }
443
- }
444
-
445
- // ─── Agent/Skill CRUD ─────────────────────────────────────
446
-
447
- function createAgentFile(name, description, agentTools, content) {
448
- ensureDir(AGENTS_DIR);
449
- const filename = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '.md';
450
- let fm = '---\n';
451
- fm += `name: "${name}"\n`;
452
- fm += `description: "${description}"\n`;
453
- if (agentTools && agentTools.length > 0) {
454
- fm += `tools: [${agentTools.map(t => '"' + t + '"').join(', ')}]\n`;
620
+ fit.fit();
621
+ var d = fit.proposeDimensions();
622
+ if (d && ws && ws.readyState === 1) ws.send(JSON.stringify({ type:'resize', cols:d.cols, rows:d.rows }));
623
+ } catch(e){}
624
+ }
625
+ var rTimer = null;
626
+ window.addEventListener('resize', function(){ clearTimeout(rTimer); rTimer = setTimeout(doFit, 80); });
627
+
628
+ window.sendCmd = function(cmd) { if (ws && ws.readyState === 1) ws.send(cmd + '\\r'); term.focus(); };
629
+ window.restartSapper = function() { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type:'restart' })); };
630
+ document.getElementById('term-wrap').addEventListener('click', function(){ term.focus(); });
631
+
632
+ // ─── FS events WS ────────────────────────────────────────────
633
+ function connectEvents() {
634
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
635
+ state.fsWS = new WebSocket(proto + '//' + location.host + '/events');
636
+ state.fsWS.onmessage = function(ev) {
637
+ var msg = null;
638
+ try { msg = JSON.parse(ev.data); } catch(e) { return; }
639
+ if (msg.type === 'stats') { handleStats(msg); return; }
640
+ handleFsEvent(msg);
641
+ };
642
+ state.fsWS.onclose = function() { setTimeout(connectEvents, 2000); };
643
+ }
644
+
645
+ function fmtBytes(b) {
646
+ if (b == null) return '—';
647
+ var u = ['B','KB','MB','GB','TB']; var i = 0;
648
+ while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
649
+ return (i >= 3 ? b.toFixed(1) : Math.round(b)) + ' ' + u[i];
650
+ }
651
+
652
+ function setBar(id, pct) {
653
+ var el = document.getElementById(id);
654
+ if (!el) return;
655
+ pct = Math.max(0, Math.min(100, pct || 0));
656
+ el.style.width = pct + '%';
657
+ el.classList.remove('warn', 'crit');
658
+ if (pct >= 85) el.classList.add('crit');
659
+ else if (pct >= 65) el.classList.add('warn');
660
+ }
661
+
662
+ function handleStats(msg) {
663
+ if (msg.cpu) {
664
+ setBar('bCpu', msg.cpu.percent);
665
+ document.getElementById('vCpu').textContent = msg.cpu.percent + '%';
666
+ }
667
+ if (msg.mem) {
668
+ setBar('bRam', msg.mem.percent);
669
+ document.getElementById('vRam').textContent = fmtBytes(msg.mem.used) + '/' + fmtBytes(msg.mem.total);
670
+ } else if (msg.totalMem) {
671
+ document.getElementById('vRam').textContent = fmtBytes(msg.totalMem);
672
+ }
673
+ if (msg.gpu) {
674
+ setBar('bGpu', msg.gpu.percent);
675
+ document.getElementById('vGpu').textContent = msg.gpu.percent + '%';
676
+ } else {
677
+ document.getElementById('vGpu').textContent = 'n/a';
455
678
  }
456
- fm += '---\n\n';
457
- fs.writeFileSync(join(AGENTS_DIR, filename), fm + content);
458
- return filename;
459
- }
460
-
461
- function deleteAgentFile(key) {
462
- try { fs.unlinkSync(join(AGENTS_DIR, key + '.md')); return true; }
463
- catch { return false; }
464
- }
465
-
466
- function createSkillFile(name, description, content) {
467
- ensureDir(SKILLS_DIR);
468
- const filename = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '.md';
469
- let fm = '---\n';
470
- fm += `name: "${name}"\n`;
471
- fm += `description: "${description}"\n`;
472
- fm += '---\n\n';
473
- fs.writeFileSync(join(SKILLS_DIR, filename), fm + content);
474
- return filename;
475
- }
476
-
477
- function deleteSkillFile(key) {
478
- try { fs.unlinkSync(join(SKILLS_DIR, key + '.md')); return true; }
479
- catch { return false; }
480
679
  }
481
680
 
482
- // ─── Directory Tree ────────────────────────────────────────
483
-
484
- function getTreeEntries(dirPath) {
485
- const safe = safePath(dirPath);
486
- if (!safe) return [];
487
- try {
488
- const entries = fs.readdirSync(safe);
489
- return entries
490
- .filter(e => !shouldIgnore(e) && !e.startsWith('.'))
491
- .map(e => {
492
- try {
493
- const stat = fs.statSync(join(safe, e));
494
- return { name: e, isDir: stat.isDirectory(), size: stat.size, modified: stat.mtime.toISOString() };
495
- } catch { return { name: e, isDir: false, size: 0 }; }
496
- })
497
- .sort((a, b) => {
498
- if (a.isDir && !b.isDir) return -1;
499
- if (!a.isDir && b.isDir) return 1;
500
- return a.name.localeCompare(b.name);
501
- });
502
- } catch { return []; }
681
+ function handleFsEvent(msg) {
682
+ if (!msg || !msg.path) return;
683
+ // Flag the file in the tree
684
+ var row = document.querySelector('.row[data-path="' + cssEscape(msg.path) + '"]');
685
+ if (row) {
686
+ row.classList.add('changed');
687
+ setTimeout(function(){ row.classList.remove('changed'); }, 4000);
688
+ }
689
+ // Refresh tree (parent dir) if a file was added/removed
690
+ if (msg.event === 'rename') {
691
+ var parent = msg.path.split('/').slice(0, -1).join('/');
692
+ refreshDir(parent);
693
+ }
694
+ // If the current preview file changed, auto-refresh (or show indicator if editing)
695
+ if (state.currentFile === msg.path) {
696
+ if (state.editing) {
697
+ document.getElementById('pInd').classList.add('show');
698
+ } else {
699
+ openFile(msg.path, true);
700
+ }
701
+ }
503
702
  }
504
703
 
505
- // ─── Server State ──────────────────────────────────────────
506
-
507
- let serverMessages = [];
508
- let serverModel = '';
509
- let serverAgent = null;
510
- let serverAgentKey = null;
511
- let serverAgentTools = null;
512
- let currentSessionId = null;
513
-
514
- function resetChat() {
515
- const skills = loadSkills();
516
- const skillContents = Object.values(skills).map(s => s.content);
517
- serverMessages = [{
518
- role: 'system',
519
- content: buildSystemPrompt(serverAgent?.content || null, serverAgentTools, skillContents)
520
- }];
521
- }
704
+ function cssEscape(s) { return s.replace(/(["\\\\])/g, '\\\\$1'); }
522
705
 
523
- function getVersion() {
524
- try { return JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8')).version; }
525
- catch { return '0.0.0'; }
526
- }
706
+ // ─── Sidebar tabs ────────────────────────────────────────────
707
+ window.switchTab = function(name) {
708
+ document.querySelectorAll('.tabs button').forEach(function(b){
709
+ b.classList.toggle('active', b.dataset.tab === name);
710
+ });
711
+ document.querySelectorAll('.pane').forEach(function(p){
712
+ p.classList.toggle('active', p.id === 'pane-' + name);
713
+ });
714
+ if (name === 'config' && !document.getElementById('cfgJson').value) reloadConfig();
715
+ if (name === 'agents') loadAgents();
716
+ if (name === 'skills') loadSkills();
717
+ };
718
+ window.toggleSide = function() {
719
+ var s = document.getElementById('side');
720
+ s.classList.toggle('hidden');
721
+ document.getElementById('btnSide').classList.toggle('on', !s.classList.contains('hidden'));
722
+ setTimeout(doFit, 50);
723
+ };
724
+ window.togglePreview = function() {
725
+ var p = document.getElementById('preview');
726
+ p.classList.toggle('hidden');
727
+ document.getElementById('btnPrev').classList.toggle('on', !p.classList.contains('hidden'));
728
+ setTimeout(doFit, 50);
729
+ };
527
730
 
528
- function json(res, data, status = 200) {
529
- res.writeHead(status, { 'Content-Type': 'application/json' });
530
- res.end(JSON.stringify(data));
731
+ // ─── File tree ───────────────────────────────────────────────
732
+ function fileIcon(name, isDir) {
733
+ if (isDir) return '&#128193;';
734
+ var ext = name.split('.').pop().toLowerCase();
735
+ if (['md','markdown'].indexOf(ext) >= 0) return '&#128221;';
736
+ if (['png','jpg','jpeg','gif','svg','webp'].indexOf(ext) >= 0) return '&#128247;';
737
+ if (['json','yml','yaml','toml'].indexOf(ext) >= 0) return '&#9881;';
738
+ return '&#128196;';
739
+ }
740
+
741
+ function loadTree() {
742
+ fetch('/api/tree?path=').then(function(r){return r.json();}).then(function(d){
743
+ var root = document.getElementById('tree');
744
+ root.innerHTML = '';
745
+ renderDir(root, '', d.entries, 0);
746
+ }).catch(function(e){ showToast('Tree error: ' + e.message, 'err'); });
747
+ }
748
+
749
+ function refreshDir(path) {
750
+ // Re-fetch the directory contents and re-render in place if expanded
751
+ var key = path || '';
752
+ if (!state.expanded[key]) return;
753
+ fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
754
+ // Find parent row, then rebuild its children
755
+ if (key === '') { loadTree(); return; }
756
+ var parentRow = document.querySelector('.row[data-path="' + cssEscape(key) + '"]');
757
+ if (!parentRow) return;
758
+ var depth = parseInt(parentRow.dataset.depth || '0', 10);
759
+ var next = parentRow.nextSibling;
760
+ while (next && parseInt(next.dataset.depth || '-1', 10) > depth) {
761
+ var rem = next; next = next.nextSibling; rem.remove();
762
+ }
763
+ // Re-insert children
764
+ var container = document.createDocumentFragment();
765
+ renderEntries(container, key, d.entries, depth + 1);
766
+ parentRow.parentNode.insertBefore(container, parentRow.nextSibling);
767
+ });
531
768
  }
532
769
 
533
- function readBody(req) {
534
- const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5MB limit
535
- return new Promise((resolve, reject) => {
536
- let body = '';
537
- let size = 0;
538
- req.on('data', c => {
539
- size += c.length;
540
- if (size > MAX_BODY_SIZE) {
541
- req.destroy();
542
- resolve({ _error: 'Request body too large' });
770
+ function renderDir(container, basePath, entries, depth) {
771
+ renderEntries(container, basePath, entries, depth);
772
+ }
773
+
774
+ function renderEntries(container, basePath, entries, depth) {
775
+ entries.forEach(function(entry){
776
+ var path = basePath ? (basePath + '/' + entry.name) : entry.name;
777
+ var row = document.createElement('div');
778
+ row.className = 'row';
779
+ row.dataset.path = path;
780
+ row.dataset.depth = depth;
781
+ row.dataset.isdir = entry.isDir ? '1' : '0';
782
+ row.style.paddingLeft = (8 + depth * 14) + 'px';
783
+ var chev = entry.isDir ? (state.expanded[path] ? '&#9662;' : '&#9656;') : '';
784
+ row.innerHTML =
785
+ '<span class="chev">' + chev + '</span>' +
786
+ '<span class="ico">' + fileIcon(entry.name, entry.isDir) + '</span>' +
787
+ '<span class="name">' + esc(entry.name) + '</span>' +
788
+ '<span class="badge">&#9679;</span>' +
789
+ '<span class="rmenu" title="Options">&#8943;</span>';
790
+ row.addEventListener('click', function(ev){
791
+ if (ev.target && ev.target.classList && ev.target.classList.contains('rmenu')) {
792
+ ev.stopPropagation();
793
+ openRowMenu(ev.target, path, entry.isDir);
543
794
  return;
544
795
  }
545
- body += c;
796
+ if (entry.isDir) toggleDir(row, path);
797
+ else openFile(path);
546
798
  });
547
- req.on('error', () => resolve({}));
548
- req.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve({}); } });
799
+ row.addEventListener('contextmenu', function(ev){
800
+ ev.preventDefault();
801
+ openRowMenu({ getBoundingClientRect: function(){ return { left: ev.clientX, bottom: ev.clientY, right: ev.clientX, top: ev.clientY }; } }, path, entry.isDir);
802
+ });
803
+ container.appendChild(row);
804
+ if (entry.isDir && state.expanded[path]) {
805
+ // Load children if not already loaded
806
+ fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
807
+ var frag = document.createDocumentFragment();
808
+ renderEntries(frag, path, d.entries, depth + 1);
809
+ row.parentNode.insertBefore(frag, row.nextSibling);
810
+ });
811
+ }
549
812
  });
550
813
  }
551
814
 
552
- // ─── HTML Builder ──────────────────────────────────────────
553
-
554
- function buildHTML() {
555
- return `<!DOCTYPE html>
556
- <html lang="en">
557
- <head>
558
- <meta charset="UTF-8">
559
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
560
- <title>Sapper</title>
561
- <style>
562
- :root {
563
- --bg0: #0a0e14; --bg1: #0d1117; --bg2: #161b22; --bg3: #21262d; --bg4: #30363d; --bg5: #3d444d;
564
- --fg0: #f0f6fc; --fg1: #e6edf3; --fg2: #8b949e; --fg3: #484f58;
565
- --accent: #58a6ff; --accent2: #1f6feb; --green: #3fb950; --red: #f85149;
566
- --orange: #d29922; --purple: #bc8cff; --cyan: #39d2c0; --pink: #f778ba;
567
- --radius: 10px; --radius-sm: 6px;
815
+ function toggleDir(row, path) {
816
+ var depth = parseInt(row.dataset.depth, 10);
817
+ var isExpanded = !!state.expanded[path];
818
+ if (isExpanded) {
819
+ // Collapse: remove all following rows with greater depth
820
+ var next = row.nextSibling;
821
+ while (next && parseInt(next.dataset.depth || '-1', 10) > depth) {
822
+ var rem = next; next = next.nextSibling; rem.remove();
823
+ }
824
+ delete state.expanded[path];
825
+ row.querySelector('.chev').innerHTML = '&#9656;';
826
+ } else {
827
+ state.expanded[path] = true;
828
+ row.querySelector('.chev').innerHTML = '&#9662;';
829
+ fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
830
+ var frag = document.createDocumentFragment();
831
+ renderEntries(frag, path, d.entries, depth + 1);
832
+ row.parentNode.insertBefore(frag, row.nextSibling);
833
+ });
834
+ }
568
835
  }
569
- * { margin: 0; padding: 0; box-sizing: border-box; }
570
- 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; }
571
-
572
- /* ── Sidebar ── */
573
- .sidebar { width: 270px; background: var(--bg2); border-right: 1px solid var(--bg4); display: flex; flex-direction: column; flex-shrink: 0; }
574
- .sidebar-header { padding: 16px 18px; border-bottom: 1px solid var(--bg4); display: flex; align-items: center; gap: 10px; }
575
- .sidebar-header h1 { font-size: 18px; font-weight: 700; }
576
- .sidebar-header h1 span { color: var(--accent); }
577
- .sidebar-tabs { display: flex; border-bottom: 1px solid var(--bg4); }
578
- .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; }
579
- .sidebar-tabs button:hover { color: var(--fg2); }
580
- .sidebar-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
581
- .tab-panel { display: none; flex: 1; overflow-y: auto; padding: 8px; }
582
- .tab-panel.active { display: flex; flex-direction: column; }
583
- .tab-panel::-webkit-scrollbar { width: 5px; }
584
- .tab-panel::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
585
- .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; }
586
- .tab-create-btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.08); }
587
- .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; }
588
- .s-item:hover { background: var(--bg3); color: var(--fg1); }
589
- .s-item.active { background: rgba(88,166,255,.1); color: var(--accent); border-color: rgba(88,166,255,.2); }
590
- .s-item .s-icon { width: 18px; text-align: center; flex-shrink: 0; font-size: 14px; }
591
- .s-item .s-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
592
- .s-item .s-meta { font-size: 10px; color: var(--fg3); }
593
- .s-item .s-del { display: none; background: none; border: none; color: var(--fg3); cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; }
594
- .s-item:hover .s-del { display: block; }
595
- .s-item .s-del:hover { color: var(--red); background: rgba(248,81,73,.15); }
596
-
597
- /* ── Quick Actions ── */
598
- .qa-section { padding: 4px 8px; margin-top: auto; border-top: 1px solid var(--bg4); }
599
- .qa-title { font-size: 10px; font-weight: 600; color: var(--fg3); text-transform: uppercase; letter-spacing: .8px; padding: 8px 8px 4px; }
600
- .qa-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; padding: 4px; }
601
- .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; }
602
- .qa-btn:hover { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.06); }
603
- .qa-btn .qa-icon { font-size: 12px; flex-shrink: 0; }
604
-
605
- /* ── Sidebar Footer ── */
606
- .sidebar-footer { padding: 12px; border-top: 1px solid var(--bg4); }
607
- .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; }
608
- .sidebar-footer select:focus { border-color: var(--accent); }
609
- .sidebar-footer .sf-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 11px; color: var(--fg3); }
610
- .sidebar-footer .sf-model { background: var(--bg3); padding: 3px 8px; border-radius: 12px; font-size: 10px; color: var(--accent); }
611
-
612
- /* ── Main ── */
613
- .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
614
-
615
- /* ── Topbar ── */
616
- .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; }
617
- .topbar .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
618
- .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; }
619
- .topbar .session-name { color: var(--fg2); font-size: 12px; cursor: pointer; padding: 2px 6px; border-radius: 4px; }
620
- .topbar .session-name:hover { background: var(--bg3); }
621
- .topbar .spacer { flex: 1; }
622
- .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; }
623
- .topbar .tb-btn:hover { border-color: var(--accent); color: var(--accent); }
624
- .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; }
625
-
626
- /* ── Chat ── */
627
- .chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 6px; scroll-behavior: smooth; }
628
- .chat::-webkit-scrollbar { width: 6px; }
629
- .chat::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
630
- .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; }
631
- @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
632
- .msg.user { background: var(--accent2); color: #fff; align-self: flex-end; border-bottom-right-radius: 3px; }
633
- .msg.ai { background: var(--bg2); border: 1px solid var(--bg4); align-self: flex-start; border-bottom-left-radius: 3px; }
634
- .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; }
635
- .msg.ai code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; }
636
- .msg.ai :not(pre) > code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-size: 12px; }
637
- .msg.ai h1, .msg.ai h2, .msg.ai h3, .msg.ai h4 { margin: 10px 0 4px; color: var(--accent); }
638
- .msg.ai ul, .msg.ai ol { padding-left: 20px; margin: 4px 0; }
639
- .msg.ai li { margin: 2px 0; }
640
- .msg.ai blockquote { border-left: 3px solid var(--accent); padding-left: 12px; color: var(--fg2); margin: 6px 0; }
641
- .msg.ai a { color: var(--accent); }
642
- .msg.ai p { margin: 4px 0; }
643
- .msg.ai hr { border: none; border-top: 1px solid var(--bg4); margin: 8px 0; }
644
- .msg.ai table { border-collapse: collapse; margin: 8px 0; width: 100%; }
645
- .msg.ai th, .msg.ai td { border: 1px solid var(--bg4); padding: 5px 8px; text-align: left; font-size: 13px; }
646
- .msg.ai th { background: var(--bg3); }
647
- .msg.system { background: transparent; color: var(--fg3); font-size: 12px; align-self: center; padding: 3px 10px; }
648
-
649
- /* ── Thinking Block ── */
650
- .think-block { background: rgba(188,140,255,.07); border: 1px solid rgba(188,140,255,.18); border-radius: 8px; margin: 8px 0; overflow: hidden; }
651
- .think-header { padding: 7px 12px; cursor: pointer; display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--purple); user-select: none; }
652
- .think-header:hover { background: rgba(188,140,255,.05); }
653
- .think-chevron { font-size: 10px; transition: transform .15s; }
654
- .think-block.open .think-chevron { transform: rotate(90deg); }
655
- .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; }
656
- .think-block.open .think-content { display: block; }
657
- .think-block.streaming .think-header .think-label { animation: pulse 1.5s infinite; }
658
- @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
659
-
660
- /* ── AI Exchange Container ── */
661
- .ai-exchange { display: flex; flex-direction: column; gap: 4px; width: 100%; }
662
-
663
- /* ── Tool Card ── */
664
- .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%; }
665
- .tool-card.done .tc-spinner { display: none; }
666
- .tool-card-header { padding: 8px 12px; display: flex; align-items: center; gap: 8px; cursor: pointer; transition: background .12s; }
667
- .tool-card-header:hover { background: var(--bg4); }
668
- .tc-icon { font-size: 13px; flex-shrink: 0; }
669
- .tc-name { color: var(--orange); font-weight: 600; font-family: 'SF Mono', monospace; font-size: 12px; }
670
- .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; }
671
- .tc-status { margin-left: auto; font-size: 12px; flex-shrink: 0; }
672
- .tc-chevron { font-size: 10px; color: var(--fg3); transition: transform .15s; flex-shrink: 0; }
673
- .tool-card.expanded .tc-chevron { transform: rotate(90deg); }
674
- .tool-card-body { display: none; padding: 8px 12px; border-top: 1px solid var(--bg4); }
675
- .tool-card.expanded .tool-card-body { display: block; }
676
- .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); }
677
- .tool-card.error { border-color: rgba(248,81,73,.3); }
678
- .tool-card.error .tc-name { color: var(--red); }
679
-
680
- /* ── Thinking Dots ── */
681
- .thinking-dots { align-self: flex-start; display: flex; gap: 4px; padding: 14px 16px; }
682
- .thinking-dots span { width: 7px; height: 7px; background: var(--fg3); border-radius: 50%; animation: bounce .6s ease-in-out infinite; }
683
- .thinking-dots span:nth-child(2) { animation-delay: .1s; }
684
- .thinking-dots span:nth-child(3) { animation-delay: .2s; }
685
- @keyframes bounce { 0%,80%,100% { transform: scale(.7); opacity: .4; } 40% { transform: scale(1); opacity: 1; } }
686
-
687
- /* ── Input ── */
688
- .input-area { background: var(--bg2); border-top: 1px solid var(--bg4); padding: 12px 16px; flex-shrink: 0; }
689
- .input-row { display: flex; gap: 8px; align-items: flex-end; }
690
- .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; }
691
- .input-row textarea:focus { border-color: var(--accent); }
692
- .input-row textarea::placeholder { color: var(--fg3); }
693
- .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; }
694
- .in-btn.send { background: var(--accent2); }
695
- .in-btn.send:hover { background: var(--accent); }
696
- .in-btn.send:disabled { opacity: .3; cursor: default; }
697
- .in-btn.stop { background: var(--red); display: none; }
698
- .in-btn.stop:hover { background: #da3633; }
699
- .in-btn.stop.visible { display: flex; }
700
- .input-hint { display: flex; gap: 14px; margin-top: 6px; font-size: 10px; color: var(--fg3); }
701
- .input-hint kbd { background: var(--bg3); padding: 1px 4px; border-radius: 3px; font-family: monospace; font-size: 9px; }
702
-
703
- /* ── Welcome ── */
704
- .welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--fg3); gap: 10px; padding: 40px; text-align: center; }
705
- .welcome .logo { font-size: 44px; margin-bottom: 6px; }
706
- .welcome h2 { color: var(--fg1); font-size: 20px; }
707
- .welcome p { max-width: 380px; line-height: 1.5; font-size: 14px; }
708
- .welcome .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; justify-content: center; }
709
- .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; }
710
- .welcome .chip:hover { border-color: var(--accent); color: var(--accent); }
711
-
712
- /* ── Right Panel ── */
713
- .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; }
714
- .right-panel.open { width: 380px; }
715
- .rp-header { padding: 12px 16px; border-bottom: 1px solid var(--bg4); display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
716
- .rp-header h3 { font-size: 13px; flex: 1; }
717
- .rp-close { background: none; border: none; color: var(--fg2); cursor: pointer; font-size: 16px; padding: 2px 6px; border-radius: 4px; }
718
- .rp-close:hover { background: var(--bg3); color: var(--fg1); }
719
- .rp-tabs { display: flex; border-bottom: 1px solid var(--bg4); flex-shrink: 0; }
720
- .rp-tabs button { flex: 1; padding: 8px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--fg3); font-size: 11px; cursor: pointer; }
721
- .rp-tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
722
- .file-tree { flex: 1; overflow-y: auto; padding: 6px; font-size: 13px; }
723
- .file-tree::-webkit-scrollbar { width: 5px; }
724
- .file-tree::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
725
- .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; }
726
- .ft-item:hover { background: var(--bg3); color: var(--fg1); }
727
- .ft-item.dir { color: var(--accent); }
728
- .ft-item.active { background: rgba(88,166,255,.1); color: var(--accent); }
729
- .ft-indent { padding-left: 16px; }
730
- .ft-icon { flex-shrink: 0; font-size: 13px; }
731
- .ft-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
732
-
733
- /* ── File Editor ── */
734
- .file-editor { display: none; flex-direction: column; border-top: 1px solid var(--bg4); }
735
- .file-editor.open { display: flex; flex: 1; min-height: 200px; }
736
- .fe-header { padding: 8px 12px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid var(--bg4); background: var(--bg3); flex-shrink: 0; }
737
- .fe-path { font-family: 'SF Mono', monospace; font-size: 11px; color: var(--fg2); flex: 1; overflow: hidden; text-overflow: ellipsis; }
738
- .fe-btn { padding: 4px 10px; border: 1px solid var(--bg4); border-radius: 4px; background: var(--bg3); color: var(--fg2); font-size: 11px; cursor: pointer; }
739
- .fe-btn:hover { border-color: var(--accent); color: var(--accent); }
740
- .fe-btn.save { background: var(--accent2); border-color: var(--accent2); color: #fff; }
741
- .fe-btn.save:hover { background: var(--accent); }
742
- .fe-content { flex: 1; overflow: auto; }
743
- .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%; }
744
- .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; }
745
-
746
- /* ── Modal ── */
747
- .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.6); z-index: 200; align-items: center; justify-content: center; }
748
- .modal-overlay.visible { display: flex; }
749
- .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); }
750
- .modal-header { padding: 16px 20px; border-bottom: 1px solid var(--bg4); display: flex; align-items: center; }
751
- .modal-header h3 { flex: 1; font-size: 15px; }
752
- .modal-close { background: none; border: none; color: var(--fg2); cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 4px; }
753
- .modal-close:hover { background: var(--bg3); }
754
- .modal-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
755
- .modal-footer { padding: 12px 20px; border-top: 1px solid var(--bg4); display: flex; justify-content: flex-end; gap: 8px; }
756
- .m-field { margin-bottom: 14px; }
757
- .m-field label { display: block; font-size: 12px; color: var(--fg2); margin-bottom: 5px; font-weight: 500; }
758
- .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; }
759
- .m-field input:focus, .m-field textarea:focus { border-color: var(--accent); }
760
- .m-field textarea { min-height: 120px; resize: vertical; font-family: 'SF Mono', monospace; font-size: 12px; }
761
- .m-field .checkbox-group { display: flex; flex-wrap: wrap; gap: 8px; }
762
- .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); }
763
- .m-field .checkbox-group label:has(input:checked) { background: rgba(88,166,255,.12); border-color: var(--accent); color: var(--accent); }
764
- .m-field .checkbox-group input { display: none; }
765
- .m-btn { padding: 8px 18px; border-radius: var(--radius-sm); border: 1px solid var(--bg4); font-size: 13px; cursor: pointer; font-weight: 500; }
766
- .m-btn.primary { background: var(--accent2); border-color: var(--accent2); color: #fff; }
767
- .m-btn.primary:hover { background: var(--accent); }
768
- .m-btn.secondary { background: var(--bg3); color: var(--fg2); }
769
- .m-btn.secondary:hover { background: var(--bg4); color: var(--fg1); }
770
- </style>
771
- </head>
772
- <body>
773
-
774
- <!-- Sidebar -->
775
- <div class="sidebar">
776
- <div class="sidebar-header">
777
- <h1>&#9889; <span>Sapper</span></h1>
778
- </div>
779
- <div class="sidebar-tabs">
780
- <button class="active" onclick="switchTab('sessions')">Sessions</button>
781
- <button onclick="switchTab('agents')">Agents</button>
782
- <button onclick="switchTab('skills')">Skills</button>
783
- </div>
784
- <div class="tab-panel active" id="sessionsPanel">
785
- <div class="tab-create-btn" onclick="createNewChat()">&#10010; New Chat</div>
786
- <div id="sessionList"></div>
787
- </div>
788
- <div class="tab-panel" id="agentsPanel">
789
- <div class="tab-create-btn" onclick="openCreateAgent()">&#10010; Create Agent</div>
790
- <div id="agentList"></div>
791
- </div>
792
- <div class="tab-panel" id="skillsPanel">
793
- <div class="tab-create-btn" onclick="openCreateSkill()">&#10010; Create Skill</div>
794
- <div id="skillList"></div>
795
- </div>
796
- <div class="qa-section">
797
- <div class="qa-title">Quick Actions</div>
798
- <div class="qa-grid">
799
- <div class="qa-btn" onclick="qaAction('list')"><span class="qa-icon">&#128194;</span> Browse Dir</div>
800
- <div class="qa-btn" onclick="qaAction('read')"><span class="qa-icon">&#128196;</span> Read File</div>
801
- <div class="qa-btn" onclick="qaAction('write')"><span class="qa-icon">&#9998;</span> Create File</div>
802
- <div class="qa-btn" onclick="qaAction('search')"><span class="qa-icon">&#128269;</span> Search</div>
803
- <div class="qa-btn" onclick="qaAction('shell')"><span class="qa-icon">&#9654;</span> Terminal</div>
804
- <div class="qa-btn" onclick="qaAction('mkdir')"><span class="qa-icon">&#128193;</span> New Dir</div>
805
- <div class="qa-btn" onclick="qaAction('review')"><span class="qa-icon">&#128270;</span> Review</div>
806
- <div class="qa-btn" onclick="qaAction('scan')"><span class="qa-icon">&#128202;</span> Scan</div>
807
- </div>
808
- </div>
809
- <div class="sidebar-footer">
810
- <div class="sf-row"><span>Model</span><span class="sf-model" id="modelTag">loading...</span></div>
811
- <select id="modelSelect" onchange="selectModel()"></select>
812
- </div>
813
- </div>
814
836
 
815
- <!-- Main -->
816
- <div class="main">
817
- <div class="topbar">
818
- <div class="status-dot" id="statusDot"></div>
819
- <div class="agent-badge" id="agentBadge">Sapper</div>
820
- <div class="session-name" id="sessionName" onclick="renameCurrentSession()" title="Click to rename">New Chat</div>
821
- <div class="spacer"></div>
822
- <div class="cwd" id="cwdDisplay"></div>
823
- <button class="tb-btn" onclick="toggleFilePanel()">&#128194; Files</button>
824
- </div>
825
- <div class="chat" id="chat">
826
- <div class="welcome" id="welcome">
827
- <div class="logo">&#9889;</div>
828
- <h2>Sapper</h2>
829
- <p>AI assistant with full filesystem access. Ask anything &mdash; code, write, analyze, build.</p>
830
- <div class="chips">
831
- <div class="chip" onclick="sendQuick('What files are in this project?')">&#128193; Explore project</div>
832
- <div class="chip" onclick="sendQuick('Help me fix bugs in the codebase')">&#128027; Find bugs</div>
833
- <div class="chip" onclick="sendQuick('Write a README for this project')">&#128221; Write docs</div>
834
- <div class="chip" onclick="sendQuick('What are my tasks for today?')">&#128203; Today tasks</div>
835
- </div>
836
- </div>
837
- </div>
838
- <div class="input-area">
839
- <div class="input-row">
840
- <textarea id="input" placeholder="Message Sapper..." rows="1" onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
841
- <button class="in-btn send" id="sendBtn" onclick="send()" title="Send">
842
- <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>
843
- </button>
844
- <button class="in-btn stop" id="stopBtn" onclick="stopGeneration()" title="Stop">
845
- <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
846
- </button>
847
- </div>
848
- <div class="input-hint">
849
- <span><kbd>Enter</kbd> send</span>
850
- <span><kbd>Shift+Enter</kbd> new line</span>
851
- </div>
852
- </div>
853
- </div>
837
+ window.collapseAll = function() {
838
+ state.expanded = {};
839
+ loadTree();
840
+ };
854
841
 
855
- <!-- Right Panel -->
856
- <div class="right-panel" id="rightPanel">
857
- <div class="rp-header">
858
- <h3>&#128194; Files</h3>
859
- <button class="rp-close" onclick="toggleFilePanel()">&#10005;</button>
860
- </div>
861
- <div class="file-tree" id="fileTree"></div>
862
- <div class="file-editor" id="fileEditor">
863
- <div class="fe-header">
864
- <span class="fe-path" id="fePath"></span>
865
- <button class="fe-btn" id="feEditBtn" onclick="startEditing()">Edit</button>
866
- <button class="fe-btn save" id="feSaveBtn" onclick="saveFileEdit()" style="display:none">Save</button>
867
- <button class="fe-btn" id="feCancelBtn" onclick="cancelEdit()" style="display:none">Cancel</button>
868
- </div>
869
- <div class="fe-content" id="feContent"></div>
870
- </div>
871
- </div>
842
+ // ─── Context menu + file actions ─────────────────────────────
843
+ function closeCtxMenu() {
844
+ var m = document.getElementById('ctxMenu');
845
+ if (m) m.remove();
846
+ document.querySelectorAll('.rmenu.open').forEach(function(e){ e.classList.remove('open'); });
847
+ }
872
848
 
873
- <!-- Modal -->
874
- <div class="modal-overlay" id="modalOverlay">
875
- <div class="modal">
876
- <div class="modal-header">
877
- <h3 id="modalTitle">Modal</h3>
878
- <button class="modal-close" onclick="closeModal()">&#10005;</button>
879
- </div>
880
- <div class="modal-body" id="modalBody"></div>
881
- <div class="modal-footer" id="modalFooter"></div>
882
- </div>
883
- </div>
849
+ document.addEventListener('click', function(e){
850
+ if (e.target.closest && e.target.closest('#ctxMenu')) return;
851
+ closeCtxMenu();
852
+ });
853
+ document.addEventListener('keydown', function(e){
854
+ if (e.key === 'Escape') closeCtxMenu();
855
+ });
884
856
 
885
- <script>
886
- // ─── State ─────────────────────────────────────────────────
887
- var currentModel = '';
888
- var currentAgent = null;
889
- var currentAgentKey = null;
890
- var currentSessionId = null;
891
- var currentSessionName = 'New Chat';
892
- var isStreaming = false;
893
- var editingFilePath = null;
894
- var editMode = false;
895
- var BT = String.fromCharCode(96);
896
- var NL = String.fromCharCode(10);
897
- var chatEl = document.getElementById('chat');
898
- var inputEl = document.getElementById('input');
899
- var welcomeEl = document.getElementById('welcome');
900
-
901
- // ─── Init ──────────────────────────────────────────────────
902
- function init() {
903
- Promise.all([
904
- fetch('/api/models').then(function(r){return r.json();}),
905
- fetch('/api/agents').then(function(r){return r.json();}),
906
- fetch('/api/info').then(function(r){return r.json();}),
907
- fetch('/api/sessions').then(function(r){return r.json();}),
908
- fetch('/api/skills').then(function(r){return r.json();})
909
- ]).then(function(results) {
910
- var modelsRes = results[0], agentsRes = results[1], infoRes = results[2], sessionsRes = results[3], skillsRes = results[4];
911
- var sel = document.getElementById('modelSelect');
912
- sel.innerHTML = '';
913
- for (var i = 0; i < modelsRes.models.length; i++) {
914
- var opt = document.createElement('option');
915
- opt.value = modelsRes.models[i]; opt.textContent = modelsRes.models[i];
916
- sel.appendChild(opt);
917
- }
918
- if (modelsRes.models.length > 0) {
919
- currentModel = modelsRes.models[0];
920
- document.getElementById('modelTag').textContent = shortModel(currentModel);
921
- }
922
- renderAgentList(agentsRes.agents);
923
- renderSkillList(skillsRes.skills);
924
- renderSessionList(sessionsRes.sessions);
925
- document.getElementById('cwdDisplay').textContent = infoRes.cwd;
926
- inputEl.focus();
857
+ function openRowMenu(anchor, path, isDir) {
858
+ closeCtxMenu();
859
+ var rect = anchor.getBoundingClientRect();
860
+ if (anchor.classList) anchor.classList.add('open');
861
+ var menu = document.createElement('div');
862
+ menu.id = 'ctxMenu';
863
+ menu.className = 'ctx-menu';
864
+ var items = [];
865
+ if (isDir) {
866
+ items.push({ label: '&#128462; New file inside', fn: function(){ newItemPrompt('file', path); } });
867
+ items.push({ label: '&#128193; New folder inside', fn: function(){ newItemPrompt('folder', path); } });
868
+ items.push({ sep: true });
869
+ items.push({ label: 'Expand / Collapse', fn: function(){
870
+ var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
871
+ if (row) toggleDir(row, path);
872
+ }});
873
+ } else {
874
+ items.push({ label: 'Open', fn: function(){ openFile(path); } });
875
+ }
876
+ items.push({ sep: true });
877
+ items.push({ label: 'Rename\u2026', fn: function(){ renamePrompt(path); } });
878
+ items.push({ label: 'Duplicate', fn: function(){ duplicateItem(path); } });
879
+ items.push({ label: 'Copy path', fn: function(){ copyText(path); showToast('Path copied'); } });
880
+ items.push({ label: 'Copy name', fn: function(){ copyText(path.split('/').pop()); showToast('Name copied'); } });
881
+ items.push({ sep: true });
882
+ items.push({ label: 'Reveal in Finder', fn: function(){
883
+ fetch('/api/fs/reveal', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ path: path }) });
884
+ }});
885
+ if (!isDir) {
886
+ items.push({ label: 'Send path to terminal', fn: function(){ sendCmd(path); } });
887
+ items.push({ label: 'Use as preview', fn: function(){ openFile(path); } });
888
+ }
889
+ items.push({ sep: true });
890
+ items.push({ label: 'Delete (move to .sapper/.trash)', danger: true, fn: function(){ deleteItem(path, false); } });
891
+
892
+ items.forEach(function(it){
893
+ if (it.sep) { var s = document.createElement('div'); s.className = 'sep'; menu.appendChild(s); return; }
894
+ var el = document.createElement('div');
895
+ el.className = 'ci' + (it.danger ? ' danger' : '');
896
+ el.innerHTML = '<span>' + it.label + '</span>';
897
+ el.addEventListener('click', function(e){ e.stopPropagation(); closeCtxMenu(); it.fn(); });
898
+ menu.appendChild(el);
927
899
  });
928
- }
929
900
 
930
- function shortModel(m) { return m.split(':')[0].substring(0, 18); }
931
-
932
- // ─── Tab Switching ─────────────────────────────────────────
933
- function switchTab(name) {
934
- var tabs = document.querySelectorAll('.sidebar-tabs button');
935
- var panels = document.querySelectorAll('.tab-panel');
936
- for (var i = 0; i < tabs.length; i++) { tabs[i].classList.remove('active'); }
937
- for (var i = 0; i < panels.length; i++) { panels[i].classList.remove('active'); }
938
- document.getElementById(name + 'Panel').classList.add('active');
939
- var tabNames = ['sessions', 'agents', 'skills'];
940
- for (var i = 0; i < tabNames.length; i++) {
941
- if (tabNames[i] === name) tabs[i].classList.add('active');
901
+ document.body.appendChild(menu);
902
+ // Position
903
+ var mw = menu.offsetWidth, mh = menu.offsetHeight;
904
+ var x = rect.right + 4, y = rect.top;
905
+ if (x + mw > window.innerWidth - 8) x = Math.max(8, rect.left - mw - 4);
906
+ if (y + mh > window.innerHeight - 8) y = Math.max(8, window.innerHeight - mh - 8);
907
+ menu.style.left = x + 'px';
908
+ menu.style.top = y + 'px';
909
+ }
910
+
911
+ function copyText(t) {
912
+ try { navigator.clipboard.writeText(t); }
913
+ catch(e) {
914
+ var ta = document.createElement('textarea');
915
+ ta.value = t; document.body.appendChild(ta); ta.select();
916
+ try { document.execCommand('copy'); } catch(_){}
917
+ ta.remove();
942
918
  }
943
- if (name === 'sessions') refreshSessions();
944
- if (name === 'agents') refreshAgents();
945
- if (name === 'skills') refreshSkills();
946
919
  }
947
920
 
948
- // ─── Sessions ──────────────────────────────────────────────
949
- function renderSessionList(sessions) {
950
- var el = document.getElementById('sessionList');
951
- el.innerHTML = '';
952
- for (var i = 0; i < sessions.length; i++) {
953
- var s = sessions[i];
954
- var item = document.createElement('div');
955
- item.className = 's-item' + (s.id === currentSessionId ? ' active' : '');
956
- item.innerHTML = '<span class="s-icon">&#128172;</span>' +
957
- '<span class="s-label">' + esc(s.name) + '</span>' +
958
- '<span class="s-meta">' + s.msgCount + '</span>' +
959
- '<button class="s-del" onclick="event.stopPropagation(); deleteSession(&apos;' + esc(s.id) + '&apos;)" title="Delete">&#10005;</button>';
960
- item.setAttribute('data-id', s.id);
961
- item.onclick = (function(sid) { return function() { loadSessionById(sid); }; })(s.id);
962
- el.appendChild(item);
963
- }
964
- }
965
- function refreshSessions() {
966
- fetch('/api/sessions').then(function(r){return r.json();}).then(function(d){renderSessionList(d.sessions);});
967
- }
968
- function createNewChat() {
969
- currentSessionId = null;
970
- currentSessionName = 'New Chat';
971
- document.getElementById('sessionName').textContent = 'New Chat';
972
- chatEl.innerHTML = '';
973
- chatEl.appendChild(welcomeEl);
974
- welcomeEl.style.display = 'flex';
975
- fetch('/api/clear', {method: 'POST'});
976
- refreshSessions();
977
- }
978
- function loadSessionById(id) {
979
- fetch('/api/sessions/load', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:id})})
980
- .then(function(r){return r.json();}).then(function(d) {
981
- if (!d.session) return;
982
- currentSessionId = id;
983
- currentSessionName = d.session.name || 'Chat';
984
- document.getElementById('sessionName').textContent = currentSessionName;
985
- if (d.session.model) {
986
- currentModel = d.session.model;
987
- document.getElementById('modelSelect').value = currentModel;
988
- document.getElementById('modelTag').textContent = shortModel(currentModel);
921
+ // ─── Modal prompt ─────────────────────────────────────────────
922
+ function showModal(opts) {
923
+ return new Promise(function(resolve){
924
+ var bd = document.createElement('div'); bd.className = 'modal-bd';
925
+ var html = '<div class="modal"><h3>' + esc(opts.title) + '</h3>';
926
+ if (opts.label) html += '<label>' + esc(opts.label) + '</label>';
927
+ if (opts.input !== false) {
928
+ html += '<input type="text" id="mdInput" value="' + esc(opts.value || '') + '" placeholder="' + esc(opts.placeholder || '') + '">';
989
929
  }
990
- chatEl.innerHTML = '';
991
- chatEl.appendChild(welcomeEl);
992
- welcomeEl.style.display = 'none';
993
- var msgs = d.session.messages || [];
994
- for (var i = 0; i < msgs.length; i++) {
995
- var m = msgs[i];
996
- if (m.role === 'system') continue;
997
- if (m.role === 'user' && m.content.indexOf('RESULT (') === 0) continue;
998
- if (m.role === 'user' && m.content === 'STOP using tools. Provide your answer now with what you have.') continue;
999
- if (m.role === 'user') addMsg('user', m.content);
1000
- else if (m.role === 'assistant') {
1001
- var el = addMsg('ai', '');
1002
- el.innerHTML = renderFullMessage(stripToolSyntax(m.content));
930
+ if (opts.hint) html += '<div class="hint">' + esc(opts.hint) + '</div>';
931
+ html += '<div class="actions">' +
932
+ '<button id="mdCancel">' + (opts.cancelLabel || 'Cancel') + '</button>' +
933
+ '<button id="mdOk" class="' + (opts.danger ? 'danger' : 'primary') + '">' + (opts.okLabel || 'OK') + '</button>' +
934
+ '</div></div>';
935
+ bd.innerHTML = html;
936
+ document.body.appendChild(bd);
937
+ var input = bd.querySelector('#mdInput');
938
+ var ok = function(){ var v = input ? input.value : ''; bd.remove(); resolve(v); };
939
+ var cancel = function(){ bd.remove(); resolve(null); };
940
+ bd.querySelector('#mdOk').addEventListener('click', ok);
941
+ bd.querySelector('#mdCancel').addEventListener('click', cancel);
942
+ bd.addEventListener('click', function(e){ if (e.target === bd) cancel(); });
943
+ if (input) {
944
+ input.focus();
945
+ // Select stem (before last dot) for renames
946
+ if (opts.selectStem && input.value) {
947
+ var dot = input.value.lastIndexOf('.');
948
+ if (dot > 0) input.setSelectionRange(0, dot);
949
+ else input.select();
950
+ } else if (input.value) {
951
+ input.select();
1003
952
  }
953
+ input.addEventListener('keydown', function(e){
954
+ if (e.key === 'Enter') ok();
955
+ if (e.key === 'Escape') cancel();
956
+ });
1004
957
  }
1005
- scrollDown();
1006
- refreshSessions();
1007
958
  });
1008
959
  }
1009
- function deleteSession(id) {
1010
- fetch('/api/sessions/delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id:id})})
1011
- .then(function() {
1012
- if (currentSessionId === id) createNewChat();
1013
- refreshSessions();
960
+
961
+ window.newItemPrompt = async function(kind, parentDir) {
962
+ var defPath = parentDir ? (parentDir + '/') : '';
963
+ var v = await showModal({
964
+ title: kind === 'folder' ? 'New folder' : 'New file',
965
+ label: 'Path (relative to workspace)',
966
+ value: defPath,
967
+ placeholder: kind === 'folder' ? 'src/utils' : 'src/index.ts',
968
+ hint: 'Use "/" for subdirectories. Intermediate folders are created automatically.',
969
+ okLabel: 'Create',
1014
970
  });
1015
- }
1016
- function autoSaveSession() {
1017
- if (!currentSessionId) currentSessionId = 'session_' + Date.now();
1018
- if (currentSessionName === 'New Chat') {
1019
- var firstUser = null;
1020
- var msgs = chatEl.querySelectorAll('.msg.user');
1021
- if (msgs.length > 0) firstUser = msgs[0].textContent;
1022
- if (firstUser) currentSessionName = firstUser.substring(0, 50);
1023
- document.getElementById('sessionName').textContent = currentSessionName;
1024
- }
1025
- fetch('/api/sessions/save', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: currentSessionId, name: currentSessionName})});
1026
- }
1027
- function renameCurrentSession() {
1028
- if (!currentSessionId) return;
1029
- showPromptModal('Rename Session', 'Session name', currentSessionName, function(val) {
1030
- if (!val) return;
1031
- currentSessionName = val;
1032
- document.getElementById('sessionName').textContent = val;
1033
- fetch('/api/sessions/rename', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id: currentSessionId, name: val})})
1034
- .then(function() { refreshSessions(); });
971
+ if (v == null) return;
972
+ v = v.replace(/^[\\/]+/, '').trim();
973
+ if (!v) return;
974
+ var r = await fetch('/api/fs/new', {
975
+ method: 'POST', headers: {'Content-Type':'application/json'},
976
+ body: JSON.stringify({ path: v, kind: kind })
1035
977
  });
1036
- }
978
+ var d = await r.json();
979
+ if (d.error) { showToast('Create failed: ' + d.error, 'err'); return; }
980
+ showToast(kind === 'folder' ? 'Folder created' : 'File created');
981
+ // expand parent + refresh
982
+ if (parentDir) state.expanded[parentDir] = true;
983
+ loadTree();
984
+ if (kind === 'file') setTimeout(function(){ openFile(v); }, 200);
985
+ };
1037
986
 
1038
- // ─── Agents ────────────────────────────────────────────────
1039
- function renderAgentList(agents) {
1040
- var el = document.getElementById('agentList');
1041
- el.innerHTML = '';
1042
- var defItem = document.createElement('div');
1043
- defItem.className = 's-item' + (!currentAgentKey ? ' active' : '');
1044
- defItem.innerHTML = '<span class="s-icon">&#9889;</span><span class="s-label">Sapper</span><span class="s-meta">default</span>';
1045
- defItem.onclick = function() { switchAgent(null, 'Sapper'); };
1046
- el.appendChild(defItem);
1047
- var keys = Object.keys(agents);
1048
- for (var i = 0; i < keys.length; i++) {
1049
- var k = keys[i], a = agents[k];
1050
- var item = document.createElement('div');
1051
- item.className = 's-item' + (currentAgentKey === k ? ' active' : '');
1052
- item.innerHTML = '<span class="s-icon">&#129302;</span>' +
1053
- '<span class="s-label">' + esc(a.name) + '</span>' +
1054
- '<button class="s-del" onclick="event.stopPropagation(); deleteAgent(&apos;' + esc(k) + '&apos;)" title="Delete">&#10005;</button>';
1055
- item.title = a.description || '';
1056
- item.onclick = (function(key, name) { return function() { switchAgent(key, name); }; })(k, a.name);
1057
- el.appendChild(item);
1058
- }
1059
- }
1060
- function refreshAgents() {
1061
- fetch('/api/agents').then(function(r){return r.json();}).then(function(d){renderAgentList(d.agents);});
1062
- }
1063
- function switchAgent(key, name) {
1064
- currentAgentKey = key;
1065
- currentAgent = key;
1066
- document.getElementById('agentBadge').textContent = name;
1067
- fetch('/api/agent', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({agent: key})});
1068
- addSystem('Switched to ' + name);
1069
- refreshAgents();
1070
- }
1071
- function deleteAgent(key) {
1072
- if (!confirm('Delete agent "' + key + '"?')) return;
1073
- fetch('/api/agents/delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({key: key})})
1074
- .then(function() {
1075
- if (currentAgentKey === key) switchAgent(null, 'Sapper');
1076
- refreshAgents();
987
+ async function renamePrompt(path) {
988
+ var name = path.split('/').pop();
989
+ var parent = path.split('/').slice(0, -1).join('/');
990
+ var v = await showModal({
991
+ title: 'Rename',
992
+ label: 'New name',
993
+ value: name,
994
+ selectStem: true,
995
+ okLabel: 'Rename',
1077
996
  });
997
+ if (v == null) return;
998
+ v = v.trim();
999
+ if (!v || v === name) return;
1000
+ var to = parent ? (parent + '/' + v) : v;
1001
+ var r = await fetch('/api/fs/rename', {
1002
+ method: 'POST', headers: {'Content-Type':'application/json'},
1003
+ body: JSON.stringify({ from: path, to: to })
1004
+ });
1005
+ var d = await r.json();
1006
+ if (d.error) { showToast('Rename failed: ' + d.error, 'err'); return; }
1007
+ showToast('Renamed');
1008
+ if (state.currentFile === path) state.currentFile = to;
1009
+ loadTree();
1078
1010
  }
1079
- function openCreateAgent() {
1080
- var allTools = ['read', 'write', 'patch', 'list', 'search', 'shell', 'mkdir'];
1081
- var body = '<div class="m-field"><label>Agent Name</label><input id="maName" placeholder="e.g. Code Reviewer"></div>' +
1082
- '<div class="m-field"><label>Description</label><input id="maDesc" placeholder="What does this agent do?"></div>' +
1083
- '<div class="m-field"><label>Allowed Tools</label><div class="checkbox-group" id="maTools">';
1084
- for (var i = 0; i < allTools.length; i++) {
1085
- body += '<label><input type="checkbox" value="' + allTools[i] + '" checked> ' + allTools[i].toUpperCase() + '</label>';
1086
- }
1087
- body += '</div></div>' +
1088
- '<div class="m-field"><label>Agent Instructions (Markdown)</label><textarea id="maContent" placeholder="# Agent Name' + NL + NL + 'Instructions for the agent..."></textarea></div>';
1089
- showModal('Create Agent', body,
1090
- '<button class="m-btn secondary" onclick="closeModal()">Cancel</button>' +
1091
- '<button class="m-btn primary" onclick="submitCreateAgent()">Create</button>');
1092
- }
1093
- function submitCreateAgent() {
1094
- var name = document.getElementById('maName').value.trim();
1095
- var desc = document.getElementById('maDesc').value.trim();
1096
- var content = document.getElementById('maContent').value;
1097
- if (!name) { alert('Name is required'); return; }
1098
- var checkboxes = document.querySelectorAll('#maTools input:checked');
1099
- var tools = [];
1100
- for (var i = 0; i < checkboxes.length; i++) tools.push(checkboxes[i].value);
1101
- fetch('/api/agents/create', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:name, description:desc, tools:tools, content:content})})
1102
- .then(function(r){return r.json();}).then(function() { closeModal(); refreshAgents(); });
1103
- }
1104
-
1105
- // ─── Skills ────────────────────────────────────────────────
1106
- function renderSkillList(skills) {
1107
- var el = document.getElementById('skillList');
1108
- el.innerHTML = '';
1109
- var keys = Object.keys(skills);
1110
- if (keys.length === 0) {
1111
- el.innerHTML = '<div style="padding:12px;color:var(--fg3);font-size:12px;">No skills yet</div>';
1112
- return;
1113
- }
1114
- for (var i = 0; i < keys.length; i++) {
1115
- var k = keys[i], s = skills[k];
1116
- var item = document.createElement('div');
1117
- item.className = 's-item';
1118
- item.innerHTML = '<span class="s-icon">&#128218;</span>' +
1119
- '<span class="s-label">' + esc(s.name) + '</span>' +
1120
- '<button class="s-del" onclick="event.stopPropagation(); deleteSkill(&apos;' + esc(k) + '&apos;)" title="Delete">&#10005;</button>';
1121
- item.title = s.description || '';
1122
- el.appendChild(item);
1123
- }
1124
- }
1125
- function refreshSkills() {
1126
- fetch('/api/skills').then(function(r){return r.json();}).then(function(d){renderSkillList(d.skills);});
1127
- }
1128
- function deleteSkill(key) {
1129
- if (!confirm('Delete skill "' + key + '"?')) return;
1130
- fetch('/api/skills/delete', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({key: key})})
1131
- .then(function() { refreshSkills(); });
1132
- }
1133
- function openCreateSkill() {
1134
- var body = '<div class="m-field"><label>Skill Name</label><input id="msName" placeholder="e.g. Testing"></div>' +
1135
- '<div class="m-field"><label>Description</label><input id="msDesc" placeholder="What does this skill teach?"></div>' +
1136
- '<div class="m-field"><label>Skill Content (Markdown)</label><textarea id="msContent" placeholder="# Skill Name' + NL + NL + 'Knowledge and instructions..."></textarea></div>';
1137
- showModal('Create Skill', body,
1138
- '<button class="m-btn secondary" onclick="closeModal()">Cancel</button>' +
1139
- '<button class="m-btn primary" onclick="submitCreateSkill()">Create</button>');
1140
- }
1141
- function submitCreateSkill() {
1142
- var name = document.getElementById('msName').value.trim();
1143
- var desc = document.getElementById('msDesc').value.trim();
1144
- var content = document.getElementById('msContent').value;
1145
- if (!name) { alert('Name is required'); return; }
1146
- fetch('/api/skills/create', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:name, description:desc, content:content})})
1147
- .then(function(r){return r.json();}).then(function() { closeModal(); refreshSkills(); });
1148
- }
1149
-
1150
- // ─── Model ─────────────────────────────────────────────────
1151
- function selectModel() {
1152
- currentModel = document.getElementById('modelSelect').value;
1153
- document.getElementById('modelTag').textContent = shortModel(currentModel);
1154
- fetch('/api/model', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({model: currentModel})});
1155
- addSystem('Model: ' + currentModel);
1156
- }
1157
-
1158
- // ─── Chat ──────────────────────────────────────────────────
1159
- function handleKey(e) {
1160
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
1161
- }
1162
- function autoResize(el) {
1163
- el.style.height = 'auto';
1164
- el.style.height = Math.min(el.scrollHeight, 150) + 'px';
1165
- }
1166
- function send(text) {
1167
- var msg = text || inputEl.value.trim();
1168
- if (!msg || isStreaming) return;
1169
- inputEl.value = ''; inputEl.style.height = 'auto';
1170
- welcomeEl.style.display = 'none';
1171
- addMsg('user', msg);
1172
- isStreaming = true;
1173
- document.getElementById('sendBtn').disabled = true;
1174
- document.getElementById('stopBtn').classList.add('visible');
1175
- document.getElementById('statusDot').style.background = 'var(--orange)';
1176
-
1177
- // Container holds all AI rounds + tool cards for this exchange
1178
- var container = document.createElement('div');
1179
- container.className = 'ai-exchange';
1180
- chatEl.appendChild(container);
1181
-
1182
- var dots = document.createElement('div');
1183
- dots.className = 'thinking-dots';
1184
- dots.innerHTML = '<span></span><span></span><span></span>';
1185
- container.appendChild(dots);
1186
-
1187
- fetch('/api/chat', {
1188
- method: 'POST',
1189
- headers: {'Content-Type': 'application/json'},
1190
- body: JSON.stringify({message: msg})
1191
- }).then(function(res) {
1192
- var reader = res.body.getReader();
1193
- var decoder = new TextDecoder();
1194
- var buffer = '';
1195
- var removedDots = false;
1196
-
1197
- // Track current AI text segment
1198
- var currentTextEl = null;
1199
- var currentText = '';
1200
- var renderTimer = null;
1201
- var thinkEl = null;
1202
- var thinkTextEl = null;
1203
- var inThink = false;
1204
- var thinkContent = '';
1205
- var doneThinkContent = '';
1206
- var lastRenderTime = 0;
1207
-
1208
- function ensureTextEl() {
1209
- if (!currentTextEl) {
1210
- currentTextEl = document.createElement('div');
1211
- currentTextEl.className = 'msg ai';
1212
- container.appendChild(currentTextEl);
1213
- currentText = '';
1214
- }
1215
- }
1216
1011
 
1217
- function renderCurrentText() {
1218
- if (!currentTextEl) return;
1219
- var cleaned = stripToolSyntax(doneThinkContent + currentText);
1220
- if (cleaned) currentTextEl.innerHTML = renderMarkdown(cleaned);
1221
- lastRenderTime = Date.now();
1012
+ async function duplicateItem(path) {
1013
+ var r = await fetch('/api/fs/duplicate', {
1014
+ method: 'POST', headers: {'Content-Type':'application/json'},
1015
+ body: JSON.stringify({ from: path })
1016
+ });
1017
+ var d = await r.json();
1018
+ if (d.error) { showToast('Duplicate failed: ' + d.error, 'err'); return; }
1019
+ showToast('Duplicated to ' + d.path);
1020
+ loadTree();
1021
+ }
1022
+
1023
+ async function deleteItem(path, hard) {
1024
+ var v = await showModal({
1025
+ title: 'Delete?',
1026
+ label: path,
1027
+ input: false,
1028
+ hint: hard ? 'This permanently removes the item. This cannot be undone.'
1029
+ : 'Item will be moved to .sapper/.trash/ so you can restore it manually.',
1030
+ okLabel: 'Delete',
1031
+ danger: true,
1032
+ });
1033
+ if (v == null) return;
1034
+ var r = await fetch('/api/fs/delete', {
1035
+ method: 'POST', headers: {'Content-Type':'application/json'},
1036
+ body: JSON.stringify({ path: path, hard: !!hard })
1037
+ });
1038
+ var d = await r.json();
1039
+ if (d.error) { showToast('Delete failed: ' + d.error, 'err'); return; }
1040
+ showToast('Deleted');
1041
+ if (state.currentFile === path) closePreview();
1042
+ loadTree();
1043
+ }
1044
+
1045
+ // ─── Preview / editor ────────────────────────────────────────
1046
+ function isTextLikeExt(ext) {
1047
+ return /^(md|markdown|txt|json|jsonc|yml|yaml|toml|js|mjs|cjs|ts|tsx|jsx|css|scss|html|htm|xml|py|rb|go|rs|java|c|cpp|h|hpp|sh|bash|zsh|fish|env|gitignore|conf|ini|sql|graphql|svelte|vue|astro|lock|log)$/i.test(ext);
1048
+ }
1049
+
1050
+ window.openFile = function(path, isReload) {
1051
+ // Ensure preview is open
1052
+ var prev = document.getElementById('preview');
1053
+ if (prev.classList.contains('hidden')) togglePreview();
1054
+ // Mark active row
1055
+ document.querySelectorAll('.row.active').forEach(function(r){ r.classList.remove('active'); });
1056
+ var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
1057
+ if (row) row.classList.add('active');
1058
+
1059
+ state.currentFile = path;
1060
+ state.editing = false;
1061
+ state.showSource = false;
1062
+ document.getElementById('pPath').textContent = path;
1063
+ document.getElementById('pInd').classList.remove('show');
1064
+ document.getElementById('pEdit').style.display = 'none';
1065
+ document.getElementById('pSave').style.display = 'none';
1066
+ document.getElementById('pCancel').style.display = 'none';
1067
+ document.getElementById('pSrc').style.display = 'none';
1068
+ document.getElementById('pReload').style.display = 'inline-block';
1069
+ document.getElementById('pedit').classList.remove('show');
1070
+ document.getElementById('pview').classList.remove('hide');
1071
+
1072
+ fetch('/api/file?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
1073
+ if (d.error) {
1074
+ document.getElementById('pview').innerHTML = '<div id="empty"><div class="lg">&#9888;</div>' + esc(d.error) + '</div>';
1075
+ document.getElementById('pview').className = '';
1076
+ return;
1222
1077
  }
1223
-
1224
- function scheduleRender() {
1225
- var now = Date.now();
1226
- if (now - lastRenderTime > 80) {
1227
- renderCurrentText();
1228
- } else if (!renderTimer) {
1229
- renderTimer = setTimeout(function() { renderTimer = null; renderCurrentText(); }, 80);
1078
+ state.fileOnDisk = d.content || '';
1079
+ var ext = (path.split('.').pop() || '').toLowerCase();
1080
+ var view = document.getElementById('pview');
1081
+ if (d.binary) {
1082
+ view.className = '';
1083
+ if (/^(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(ext)) {
1084
+ view.innerHTML = '<img src="/api/file/raw?path=' + encodeURIComponent(path) + '" alt="' + esc(path) + '">';
1085
+ } else {
1086
+ view.innerHTML = '<div id="empty"><div class="lg">&#128190;</div>Binary file (' + d.size + ' bytes)</div>';
1087
+ }
1088
+ document.getElementById('pEdit').style.display = 'none';
1089
+ } else if (ext === 'md' || ext === 'markdown') {
1090
+ view.className = '';
1091
+ try {
1092
+ marked.setOptions({ breaks: false, gfm: true });
1093
+ view.innerHTML = marked.parse(d.content || '');
1094
+ view.querySelectorAll('pre code').forEach(function(b){ try { hljs.highlightElement(b); } catch(e){} });
1095
+ } catch(e) {
1096
+ view.innerHTML = '<pre>' + esc(d.content || '') + '</pre>';
1230
1097
  }
1098
+ document.getElementById('pEdit').style.display = 'inline-block';
1099
+ } else if (ext === 'html' || ext === 'htm') {
1100
+ renderHtmlPreview(d.content || '');
1101
+ document.getElementById('pEdit').style.display = 'inline-block';
1102
+ document.getElementById('pSrc').style.display = 'inline-block';
1103
+ document.getElementById('pSrc').textContent = 'Source';
1104
+ } else if (isTextLikeExt(ext) || d.text) {
1105
+ view.className = 'code';
1106
+ var langClass = ext ? ' class="language-' + esc(ext) + '"' : '';
1107
+ view.innerHTML = '<pre><code' + langClass + '>' + esc(d.content || '') + '</code></pre>';
1108
+ try { hljs.highlightElement(view.querySelector('code')); } catch(e){}
1109
+ document.getElementById('pEdit').style.display = 'inline-block';
1110
+ } else {
1111
+ view.className = '';
1112
+ view.innerHTML = '<pre>' + esc(d.content || '') + '</pre>';
1113
+ document.getElementById('pEdit').style.display = 'inline-block';
1231
1114
  }
1115
+ if (isReload) showToast('Reloaded ' + path);
1116
+ }).catch(function(e){ showToast('Read failed: ' + e.message, 'err'); });
1117
+ };
1232
1118
 
1233
- function startThinkBlock() {
1234
- if (thinkEl) return;
1235
- inThink = true;
1236
- thinkContent = '';
1237
- thinkEl = document.createElement('div');
1238
- thinkEl.className = 'think-block open streaming';
1239
- thinkEl.innerHTML = '<div class="think-header" onclick="this.parentElement.classList.toggle(&apos;open&apos;)">' +
1240
- '<span class="think-chevron">&#9654;</span>' +
1241
- '<span class="think-label">&#129504; Thinking...</span></div>' +
1242
- '<div class="think-content"></div>';
1243
- container.appendChild(thinkEl);
1244
- thinkTextEl = thinkEl.querySelector('.think-content');
1245
- }
1119
+ function renderHtmlPreview(content) {
1120
+ var view = document.getElementById('pview');
1121
+ view.className = '';
1122
+ view.innerHTML = '';
1123
+ var iframe = document.createElement('iframe');
1124
+ iframe.className = 'html-preview';
1125
+ iframe.setAttribute('sandbox', 'allow-same-origin allow-popups');
1126
+ iframe.srcdoc = content;
1127
+ view.appendChild(iframe);
1128
+ }
1129
+
1130
+ function renderHtmlSource(content) {
1131
+ var view = document.getElementById('pview');
1132
+ view.className = 'code';
1133
+ view.innerHTML = '<pre><code class="language-html">' + esc(content) + '</code></pre>';
1134
+ try { hljs.highlightElement(view.querySelector('code')); } catch(e){}
1135
+ }
1136
+
1137
+ window.toggleSource = function() {
1138
+ if (!state.currentFile) return;
1139
+ state.showSource = !state.showSource;
1140
+ var btn = document.getElementById('pSrc');
1141
+ if (state.showSource) {
1142
+ renderHtmlSource(state.fileOnDisk || '');
1143
+ btn.textContent = 'Rendered';
1144
+ } else {
1145
+ renderHtmlPreview(state.fileOnDisk || '');
1146
+ btn.textContent = 'Source';
1147
+ }
1148
+ };
1246
1149
 
1247
- function updateThinkBlock(text) {
1248
- if (thinkTextEl) thinkTextEl.innerHTML = renderMarkdown(text);
1249
- }
1150
+ window.reloadPreview = function() { if (state.currentFile) openFile(state.currentFile, true); };
1151
+ window.closePreview = function() {
1152
+ state.currentFile = null; state.editing = false;
1153
+ document.getElementById('pview').innerHTML = '<div id="empty"><div class="lg">&#128196;</div>Open a file from the sidebar.</div>';
1154
+ document.getElementById('pview').className = '';
1155
+ document.getElementById('pPath').textContent = 'No file open';
1156
+ document.getElementById('pEdit').style.display = 'none';
1157
+ document.getElementById('pSave').style.display = 'none';
1158
+ document.getElementById('pCancel').style.display = 'none';
1159
+ document.getElementById('pSrc').style.display = 'none';
1160
+ document.getElementById('pReload').style.display = 'none';
1161
+ document.getElementById('pedit').classList.remove('show');
1162
+ document.getElementById('pview').classList.remove('hide');
1163
+ };
1164
+ window.startEdit = function() {
1165
+ if (!state.currentFile) return;
1166
+ state.editing = true;
1167
+ document.getElementById('pedit').value = state.fileOnDisk;
1168
+ document.getElementById('pedit').classList.add('show');
1169
+ document.getElementById('pview').classList.add('hide');
1170
+ document.getElementById('pEdit').style.display = 'none';
1171
+ document.getElementById('pSave').style.display = 'inline-block';
1172
+ document.getElementById('pCancel').style.display = 'inline-block';
1173
+ };
1174
+ window.cancelEdit = function() {
1175
+ state.editing = false;
1176
+ document.getElementById('pedit').classList.remove('show');
1177
+ document.getElementById('pview').classList.remove('hide');
1178
+ document.getElementById('pEdit').style.display = 'inline-block';
1179
+ document.getElementById('pSave').style.display = 'none';
1180
+ document.getElementById('pCancel').style.display = 'none';
1181
+ document.getElementById('pInd').classList.remove('show');
1182
+ };
1183
+ window.saveEdit = function() {
1184
+ if (!state.currentFile) return;
1185
+ var content = document.getElementById('pedit').value;
1186
+ fetch('/api/file', {
1187
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1188
+ body: JSON.stringify({ path: state.currentFile, content: content })
1189
+ }).then(function(r){return r.json();}).then(function(d){
1190
+ if (d.error) { showToast('Save failed: ' + d.error, 'err'); return; }
1191
+ showToast('Saved ' + state.currentFile);
1192
+ state.editing = false;
1193
+ openFile(state.currentFile, false);
1194
+ }).catch(function(e){ showToast('Save failed: ' + e.message, 'err'); });
1195
+ };
1250
1196
 
1251
- function endThinkBlock() {
1252
- if (thinkEl) {
1253
- thinkEl.classList.remove('streaming', 'open');
1254
- thinkEl.querySelector('.think-label').innerHTML = '&#129504; Thinking (done)';
1255
- updateThinkBlock(thinkContent);
1256
- }
1257
- inThink = false;
1258
- thinkEl = null;
1259
- thinkTextEl = null;
1260
- }
1197
+ // ─── Config tab ──────────────────────────────────────────────
1198
+ window.reloadConfig = function() {
1199
+ fetch('/api/config').then(function(r){return r.json();}).then(function(d){
1200
+ document.getElementById('cfgJson').value = JSON.stringify(d.config || {}, null, 2);
1201
+ renderQuickConfig(d.config || {});
1202
+ }).catch(function(e){ showToast('Config read failed: ' + e.message, 'err'); });
1203
+ };
1204
+ window.saveConfig = function() {
1205
+ var raw = document.getElementById('cfgJson').value;
1206
+ var parsed;
1207
+ try { parsed = JSON.parse(raw); }
1208
+ catch(e) { showToast('Invalid JSON: ' + e.message, 'err'); return; }
1209
+ fetch('/api/config', {
1210
+ method: 'POST', headers: {'Content-Type':'application/json'},
1211
+ body: JSON.stringify({ config: parsed })
1212
+ }).then(function(r){return r.json();}).then(function(d){
1213
+ if (d.error) { showToast('Save failed: ' + d.error, 'err'); return; }
1214
+ showToast('Config saved');
1215
+ renderQuickConfig(parsed);
1216
+ });
1217
+ };
1261
1218
 
1262
- function processToken(token) {
1263
- // Handle <think> and </think> tags that may come split across tokens
1264
- var remaining = token;
1265
- while (remaining.length > 0) {
1266
- if (inThink) {
1267
- var closeIdx = remaining.indexOf('</think>');
1268
- if (closeIdx !== -1) {
1269
- thinkContent += remaining.substring(0, closeIdx);
1270
- endThinkBlock();
1271
- remaining = remaining.substring(closeIdx + 8);
1272
- // Reset text element for content after thinking
1273
- currentTextEl = null;
1274
- } else {
1275
- // Check for partial </think> at end
1276
- var partial = false;
1277
- for (var pLen = 1; pLen < 8 && pLen <= remaining.length; pLen++) {
1278
- if ('</think>'.indexOf(remaining.substring(remaining.length - pLen)) === 0) {
1279
- thinkContent += remaining.substring(0, remaining.length - pLen);
1280
- remaining = remaining.substring(remaining.length - pLen);
1281
- partial = true;
1282
- break;
1283
- }
1284
- }
1285
- if (!partial) {
1286
- thinkContent += remaining;
1287
- remaining = '';
1288
- } else {
1289
- // Buffer partial tag, will resolve on next token
1290
- thinkContent += remaining;
1291
- remaining = '';
1292
- }
1293
- if (thinkContent.length > 0 && Date.now() - lastRenderTime > 120) {
1294
- updateThinkBlock(thinkContent);
1295
- lastRenderTime = Date.now();
1296
- }
1297
- }
1298
- } else {
1299
- var openIdx = remaining.indexOf('<think>');
1300
- if (openIdx !== -1) {
1301
- var before = remaining.substring(0, openIdx);
1302
- if (before.length > 0) {
1303
- ensureTextEl();
1304
- currentText += before;
1305
- scheduleRender();
1306
- }
1307
- // Save what we have so far as done text
1308
- if (currentText.length > 0) {
1309
- renderCurrentText();
1310
- doneThinkContent += currentText;
1311
- currentText = '';
1312
- currentTextEl = null;
1313
- }
1314
- startThinkBlock();
1315
- remaining = remaining.substring(openIdx + 7);
1316
- } else {
1317
- ensureTextEl();
1318
- currentText += remaining;
1319
- scheduleRender();
1320
- remaining = '';
1321
- }
1322
- }
1323
- }
1324
- }
1219
+ function renderQuickConfig(cfg) {
1220
+ var host = document.getElementById('cfgQuickBody');
1221
+ host.innerHTML = '';
1222
+ function add(html) { host.insertAdjacentHTML('beforeend', html); }
1223
+ add('<label>Default model</label><input type="text" id="qDefMod" placeholder="auto" value="' + esc(cfg.defaultModel || '') + '">');
1224
+ add('<label>Default agent</label><input type="text" id="qDefAgent" placeholder="(none)" value="' + esc(cfg.defaultAgent || '') + '">');
1225
+ add('<label>Context limit (tokens, blank = model default)</label><input type="number" id="qCtxLim" value="' + esc(cfg.contextLimit == null ? '' : cfg.contextLimit) + '">');
1226
+ add('<label>Tool round limit</label><input type="number" id="qToolRnd" value="' + esc(cfg.toolRoundLimit != null ? cfg.toolRoundLimit : 40) + '">');
1227
+ add('<div class="toggle-row"><span>Summary phases</span><div class="switch ' + (cfg.summaryPhases ? 'on' : '') + '" id="qSumPh"></div></div>');
1228
+ add('<label>Summary trigger %</label><input type="number" id="qSumTr" value="' + esc(cfg.summarizeTriggerPercent != null ? cfg.summarizeTriggerPercent : 65) + '">');
1229
+ add('<div class="toggle-row"><span>Debug mode</span><div class="switch ' + (cfg.debug ? 'on' : '') + '" id="qDebug"></div></div>');
1230
+ add('<div class="toggle-row"><span>Auto-attach files</span><div class="switch ' + (cfg.autoAttach !== false ? 'on' : '') + '" id="qAutoAtt"></div></div>');
1231
+ add('<div class="row-btns"><button class="primary" onclick="saveQuickConfig()">Apply quick changes</button></div>');
1232
+
1233
+ function bindSwitch(id) {
1234
+ var el = document.getElementById(id);
1235
+ if (el) el.addEventListener('click', function(){ el.classList.toggle('on'); });
1236
+ }
1237
+ bindSwitch('qSumPh'); bindSwitch('qDebug'); bindSwitch('qAutoAtt');
1238
+ }
1239
+
1240
+ window.saveQuickConfig = function() {
1241
+ var current;
1242
+ try { current = JSON.parse(document.getElementById('cfgJson').value || '{}'); }
1243
+ catch(e) { current = {}; }
1244
+ function val(id) { var el = document.getElementById(id); return el ? el.value : ''; }
1245
+ function on(id) { var el = document.getElementById(id); return el && el.classList.contains('on'); }
1246
+ var v;
1247
+ v = val('qDefMod').trim(); current.defaultModel = v || null;
1248
+ v = val('qDefAgent').trim(); current.defaultAgent = v || null;
1249
+ v = val('qCtxLim').trim(); current.contextLimit = v === '' ? null : parseInt(v, 10);
1250
+ v = val('qToolRnd').trim(); current.toolRoundLimit = v === '' ? 40 : parseInt(v, 10);
1251
+ v = val('qSumTr').trim(); current.summarizeTriggerPercent = v === '' ? 65 : parseInt(v, 10);
1252
+ current.summaryPhases = on('qSumPh');
1253
+ current.debug = on('qDebug');
1254
+ current.autoAttach = on('qAutoAtt');
1255
+ document.getElementById('cfgJson').value = JSON.stringify(current, null, 2);
1256
+ saveConfig();
1257
+ };
1325
1258
 
1326
- function processStream() {
1327
- reader.read().then(function(result) {
1328
- if (result.done) { finishStream(); return; }
1329
- buffer += decoder.decode(result.value, {stream: true});
1330
- var lines = buffer.split(NL);
1331
- buffer = lines.pop();
1332
- for (var i = 0; i < lines.length; i++) {
1333
- var line = lines[i];
1334
- if (line.indexOf('data: ') !== 0) continue;
1335
- var raw = line.slice(6);
1336
- if (raw === '[DONE]') continue;
1337
- var evt;
1338
- try { evt = JSON.parse(raw); } catch(e) { continue; }
1339
- if (!removedDots) { dots.remove(); removedDots = true; }
1340
- if (evt.type === 'token') {
1341
- processToken(evt.data);
1342
- scrollDown();
1343
- } else if (evt.type === 'tool_start') {
1344
- // Finish current text segment before showing tool
1345
- if (currentText) { renderCurrentText(); doneThinkContent += currentText; currentText = ''; }
1346
- currentTextEl = null;
1347
- if (inThink) endThinkBlock();
1348
-
1349
- var card = document.createElement('div');
1350
- card.className = 'tool-card';
1351
- card.setAttribute('data-tool', evt.data.tool);
1352
- card.setAttribute('data-path', evt.data.path);
1353
- card.innerHTML = '<div class="tool-card-header" onclick="this.parentElement.classList.toggle(&apos;expanded&apos;)">' +
1354
- '<span class="tc-icon">&#9881;</span>' +
1355
- '<span class="tc-name">' + esc(evt.data.tool) + '</span>' +
1356
- '<span class="tc-path">' + esc(evt.data.path) + '</span>' +
1357
- '<span class="tc-status tc-spinner">&#8987;</span>' +
1358
- '<span class="tc-chevron">&#9654;</span></div>' +
1359
- '<div class="tool-card-body"><pre>Running...</pre></div>';
1360
- container.appendChild(card);
1361
- scrollDown();
1362
- } else if (evt.type === 'tool_result') {
1363
- // Try to update the last pending tool card
1364
- var pendingCards = container.querySelectorAll('.tool-card:not(.done)');
1365
- var isErr = (evt.data.result && evt.data.result.indexOf('Error') !== -1) || evt.data.blocked;
1366
- if (pendingCards.length > 0) {
1367
- var lastCard = pendingCards[pendingCards.length - 1];
1368
- lastCard.classList.add('done');
1369
- if (isErr) lastCard.classList.add('error');
1370
- var hdr = lastCard.querySelector('.tool-card-header');
1371
- var statusSpan = lastCard.querySelector('.tc-status');
1372
- if (statusSpan) statusSpan.innerHTML = isErr ? '&#10060;' : '&#9989;';
1373
- var body = lastCard.querySelector('.tool-card-body pre');
1374
- if (body) body.textContent = evt.data.result || '(no output)';
1375
- } else {
1376
- // Fallback: create result card
1377
- var card = document.createElement('div');
1378
- card.className = 'tool-card done' + (isErr ? ' error' : '');
1379
- card.innerHTML = '<div class="tool-card-header" onclick="this.parentElement.classList.toggle(&apos;expanded&apos;)">' +
1380
- '<span class="tc-icon">' + (isErr ? '&#10060;' : '&#9989;') + '</span>' +
1381
- '<span class="tc-name">' + esc(evt.data.tool) + '</span>' +
1382
- '<span class="tc-path">' + esc(evt.data.path) + '</span>' +
1383
- '<span class="tc-chevron">&#9654;</span></div>' +
1384
- '<div class="tool-card-body"><pre>' + esc(evt.data.result || '') + '</pre></div>';
1385
- container.appendChild(card);
1386
- }
1387
- scrollDown();
1388
- } else if (evt.type === 'system') {
1389
- addSystem(evt.data);
1390
- }
1391
- }
1392
- processStream();
1393
- }).catch(function(e) {
1394
- if (!removedDots) dots.remove();
1395
- ensureTextEl();
1396
- currentTextEl.innerHTML = '<span style="color:var(--red)">Stream error: ' + esc(e.message) + '</span>';
1397
- finishStream();
1398
- });
1259
+ // ─── Agents & Skills tabs ────────────────────────────────────
1260
+ function loadAgents() {
1261
+ fetch('/api/agents').then(function(r){return r.json();}).then(function(d){
1262
+ var host = document.getElementById('agentsList');
1263
+ host.innerHTML = '';
1264
+ if (!d.agents || d.agents.length === 0) {
1265
+ host.innerHTML = '<div class="pane-section"><p>No agents yet. Create one with <code>/newagent</code>.</p></div>';
1266
+ return;
1399
1267
  }
1268
+ d.agents.forEach(function(a){
1269
+ var div = document.createElement('div');
1270
+ div.className = 'item';
1271
+ div.innerHTML = '<div class="ti">' + esc(a.name) + '</div>' +
1272
+ (a.description ? '<div class="ds">' + esc(a.description) + '</div>' : '');
1273
+ div.addEventListener('click', function(){
1274
+ openFile(a.path);
1275
+ sendCmd('/' + a.key);
1276
+ });
1277
+ host.appendChild(div);
1278
+ });
1279
+ });
1280
+ }
1400
1281
 
1401
- function finishStream() {
1402
- if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
1403
- if (inThink) endThinkBlock();
1404
- if (currentText) {
1405
- renderCurrentText();
1406
- }
1407
- if (!removedDots) { dots.remove(); }
1408
- // If nothing was rendered at all
1409
- if (!container.querySelector('.msg.ai') && !container.querySelector('.tool-card') && !container.querySelector('.think-block')) {
1410
- var empty = document.createElement('div');
1411
- empty.className = 'msg ai';
1412
- empty.innerHTML = '<em style="color:var(--fg3)">No response</em>';
1413
- container.appendChild(empty);
1414
- }
1415
- isStreaming = false;
1416
- document.getElementById('sendBtn').disabled = false;
1417
- document.getElementById('stopBtn').classList.remove('visible');
1418
- document.getElementById('statusDot').style.background = 'var(--green)';
1419
- scrollDown();
1420
- inputEl.focus();
1421
- autoSaveSession();
1282
+ function loadSkills() {
1283
+ fetch('/api/skills').then(function(r){return r.json();}).then(function(d){
1284
+ var host = document.getElementById('skillsList');
1285
+ host.innerHTML = '';
1286
+ if (!d.skills || d.skills.length === 0) {
1287
+ host.innerHTML = '<div class="pane-section"><p>No skills yet. Create one with <code>/newskill</code>.</p></div>';
1288
+ return;
1422
1289
  }
1423
-
1424
- processStream();
1425
- }).catch(function(e) {
1426
- if (dots.parentNode) dots.remove();
1427
- var errEl = document.createElement('div');
1428
- errEl.className = 'msg ai';
1429
- errEl.innerHTML = '<span style="color:var(--red)">Error: ' + esc(e.message) + '</span>';
1430
- container.appendChild(errEl);
1431
- isStreaming = false;
1432
- document.getElementById('sendBtn').disabled = false;
1433
- document.getElementById('stopBtn').classList.remove('visible');
1434
- document.getElementById('statusDot').style.background = 'var(--green)';
1290
+ d.skills.forEach(function(s){
1291
+ var div = document.createElement('div');
1292
+ div.className = 'item';
1293
+ div.innerHTML = '<div class="ti">' + esc(s.name) + '</div>' +
1294
+ (s.description ? '<div class="ds">' + esc(s.description) + '</div>' : '');
1295
+ div.addEventListener('click', function(){ openFile(s.path); });
1296
+ host.appendChild(div);
1297
+ });
1435
1298
  });
1436
1299
  }
1437
- function sendQuick(text) { inputEl.value = text; send(); }
1438
- function stopGeneration() {
1439
- fetch('/api/stop', {method: 'POST'});
1300
+
1301
+ // ─── Boot ────────────────────────────────────────────────────
1302
+ connectPty();
1303
+ connectEvents();
1304
+ loadTree();
1305
+ </script>
1306
+ </body>
1307
+ </html>`;
1440
1308
  }
1441
- function addMsg(role, text) {
1442
- welcomeEl.style.display = 'none';
1443
- var el = document.createElement('div');
1444
- el.className = 'msg ' + role;
1445
- if (role === 'user') el.textContent = text;
1446
- else el.innerHTML = renderFullMessage(text);
1447
- chatEl.appendChild(el);
1448
- scrollDown();
1449
- return el;
1309
+
1310
+ // ─── HTTP routes ─────────────────────────────────────────────────
1311
+
1312
+ function json(res, data, code = 200) {
1313
+ res.writeHead(code, { 'Content-Type': 'application/json' });
1314
+ res.end(JSON.stringify(data));
1450
1315
  }
1451
- function addSystem(text) {
1452
- var el = document.createElement('div');
1453
- el.className = 'msg system';
1454
- el.textContent = text;
1455
- chatEl.appendChild(el);
1456
- scrollDown();
1316
+
1317
+ function readReqJSON(req) {
1318
+ return new Promise((resolve) => {
1319
+ let body = ''; let size = 0;
1320
+ req.on('data', (c) => { size += c.length; if (size > 5 * 1024 * 1024) { req.destroy(); resolve({ _err: 'too large' }); return; } body += c; });
1321
+ req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
1322
+ req.on('error', () => resolve({}));
1323
+ });
1457
1324
  }
1458
- function scrollDown() { chatEl.scrollTop = chatEl.scrollHeight; }
1459
-
1460
- // ─── Rendering ─────────────────────────────────────────────
1461
- function stripToolSyntax(text) {
1462
- var result = text;
1463
- while (true) {
1464
- var start = result.indexOf('[TOOL:');
1465
- if (start === -1) break;
1466
- var end = result.indexOf('[/TOOL]', start);
1467
- if (end === -1) break;
1468
- result = result.substring(0, start) + result.substring(end + 7);
1469
- }
1470
- return result.trim();
1325
+
1326
+ function listEntries(dirPath) {
1327
+ try {
1328
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1329
+ return entries
1330
+ .filter(e => !IGNORE_NAMES.has(e.name))
1331
+ .map(e => ({ name: e.name, isDir: e.isDirectory() }))
1332
+ .sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1)));
1333
+ } catch { return []; }
1471
1334
  }
1472
1335
 
1473
- function renderFullMessage(text) {
1474
- if (!text) return '';
1475
- var parts = parseThinkBlocks(text);
1476
- var html = '';
1477
- for (var i = 0; i < parts.length; i++) {
1478
- var p = parts[i];
1479
- if (p.type === 'thinking') {
1480
- var cls = p.done ? 'think-block' : 'think-block open streaming';
1481
- html += '<div class="' + cls + '">';
1482
- html += '<div class="think-header" onclick="this.parentElement.classList.toggle(&apos;open&apos;)">';
1483
- html += '<span class="think-chevron">&#9654;</span>';
1484
- html += '<span class="think-label">&#129504; Thinking' + (p.done ? '' : '...') + '</span></div>';
1485
- html += '<div class="think-content">' + renderMarkdown(p.content) + '</div></div>';
1486
- } else {
1487
- html += renderMarkdown(p.content);
1488
- }
1336
+ function looksBinary(buf) {
1337
+ const len = Math.min(buf.length, 4096);
1338
+ let nonText = 0;
1339
+ for (let i = 0; i < len; i++) {
1340
+ const c = buf[i];
1341
+ if (c === 0) return true;
1342
+ if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c >= 127) nonText++;
1489
1343
  }
1490
- return html;
1344
+ return nonText / Math.max(len, 1) > 0.3;
1491
1345
  }
1492
1346
 
1493
- function parseThinkBlocks(text) {
1494
- var parts = [];
1495
- var remaining = text;
1496
- while (true) {
1497
- var s = remaining.indexOf('<think>');
1498
- if (s === -1) {
1499
- if (remaining.length > 0) parts.push({type: 'text', content: remaining});
1500
- break;
1347
+ const server = http.createServer(async (req, res) => {
1348
+ try {
1349
+ const url = new URL(req.url, 'http://localhost');
1350
+ const path = url.pathname;
1351
+
1352
+ // Pages
1353
+ if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
1354
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1355
+ res.end(buildHTML());
1356
+ return;
1501
1357
  }
1502
- if (s > 0) parts.push({type: 'text', content: remaining.substring(0, s)});
1503
- var e = remaining.indexOf('</think>', s + 7);
1504
- if (e === -1) {
1505
- parts.push({type: 'thinking', content: remaining.substring(s + 7), done: false});
1506
- break;
1358
+ if (req.method === 'GET' && path === '/health') return json(res, { ok: true, cwd: workingDir });
1359
+
1360
+ // ── Tree
1361
+ if (req.method === 'GET' && path === '/api/tree') {
1362
+ const rel = url.searchParams.get('path') || '';
1363
+ const abs = safePath(rel);
1364
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1365
+ return json(res, { path: rel, entries: listEntries(abs) });
1507
1366
  }
1508
- parts.push({type: 'thinking', content: remaining.substring(s + 7, e), done: true});
1509
- remaining = remaining.substring(e + 8);
1510
- }
1511
- return parts;
1512
- }
1513
1367
 
1514
- function renderMarkdown(text) {
1515
- if (!text) return '';
1516
- var lines = text.split(NL);
1517
- var html = '';
1518
- var inCode = false;
1519
- var codeLang = '';
1520
- var codeLines = [];
1521
- var inList = false;
1522
-
1523
- for (var i = 0; i < lines.length; i++) {
1524
- var line = lines[i];
1525
- var trimmed = line.trim();
1526
-
1527
- if (trimmed.indexOf(BT+BT+BT) === 0) {
1528
- if (!inCode) {
1529
- if (inList) { html += '</ul>'; inList = false; }
1530
- inCode = true;
1531
- codeLang = trimmed.slice(3).trim();
1532
- codeLines = [];
1533
- } else {
1534
- html += '<pre><code class="lang-' + esc(codeLang) + '">' + esc(codeLines.join(NL)) + '</code></pre>';
1535
- inCode = false;
1536
- }
1537
- continue;
1368
+ // ── File read (text)
1369
+ if (req.method === 'GET' && path === '/api/file') {
1370
+ const rel = url.searchParams.get('path') || '';
1371
+ const abs = safePath(rel);
1372
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1373
+ try {
1374
+ const stat = fs.statSync(abs);
1375
+ if (stat.isDirectory()) return json(res, { error: 'is a directory' }, 400);
1376
+ if (stat.size > 2 * 1024 * 1024) return json(res, { error: 'file too large (>2MB)', size: stat.size, binary: true }, 200);
1377
+ const buf = fs.readFileSync(abs);
1378
+ if (looksBinary(buf)) return json(res, { binary: true, size: stat.size });
1379
+ return json(res, { content: buf.toString('utf8'), size: stat.size });
1380
+ } catch (e) { return json(res, { error: e.message }, 500); }
1538
1381
  }
1539
- if (inCode) { codeLines.push(line); continue; }
1540
1382
 
1541
- if (trimmed === '') {
1542
- if (inList) { html += '</ul>'; inList = false; }
1543
- html += '<br>';
1544
- continue;
1383
+ // ── File raw (images)
1384
+ if (req.method === 'GET' && path === '/api/file/raw') {
1385
+ const rel = url.searchParams.get('path') || '';
1386
+ const abs = safePath(rel);
1387
+ if (!abs) { res.writeHead(400); return res.end('invalid path'); }
1388
+ try {
1389
+ const ext = (rel.split('.').pop() || '').toLowerCase();
1390
+ const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon' }[ext] || 'application/octet-stream';
1391
+ const buf = fs.readFileSync(abs);
1392
+ res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-store' });
1393
+ return res.end(buf);
1394
+ } catch (e) { res.writeHead(500); return res.end(e.message); }
1545
1395
  }
1546
1396
 
1547
- var isLi = trimmed.length > 2 && (trimmed.charAt(0) === '-' || trimmed.charAt(0) === '*') && trimmed.charAt(1) === ' ';
1548
- var isOl = false;
1549
- var olMatch = trimmed.match(/^(\d+)\.\s/);
1550
- if (olMatch) isOl = true;
1551
-
1552
- if (!isLi && !isOl && inList) { html += '</ul>'; inList = false; }
1553
-
1554
- if (trimmed.indexOf('### ') === 0) { html += '<h3>' + inlineFmt(esc(trimmed.slice(4))) + '</h3>'; continue; }
1555
- if (trimmed.indexOf('## ') === 0) { html += '<h2>' + inlineFmt(esc(trimmed.slice(3))) + '</h2>'; continue; }
1556
- if (trimmed.indexOf('# ') === 0) { html += '<h1>' + inlineFmt(esc(trimmed.slice(2))) + '</h1>'; continue; }
1557
- if (trimmed.indexOf('> ') === 0) { html += '<blockquote>' + inlineFmt(esc(trimmed.slice(2))) + '</blockquote>'; continue; }
1558
- if (trimmed === '---' || trimmed === '***' || trimmed === '___') { html += '<hr>'; continue; }
1559
-
1560
- if (isLi) {
1561
- if (!inList) { html += '<ul>'; inList = true; }
1562
- html += '<li>' + inlineFmt(esc(trimmed.slice(2))) + '</li>';
1563
- continue;
1397
+ // ── File write
1398
+ if (req.method === 'POST' && path === '/api/file') {
1399
+ const body = await readReqJSON(req);
1400
+ const abs = safePath(body.path);
1401
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1402
+ try {
1403
+ ensureDir(dirname(abs));
1404
+ fs.writeFileSync(abs, body.content == null ? '' : String(body.content));
1405
+ return json(res, { ok: true });
1406
+ } catch (e) { return json(res, { error: e.message }, 500); }
1564
1407
  }
1565
- if (isOl) {
1566
- if (!inList) { html += '<ul>'; inList = true; }
1567
- var olText = trimmed.replace(/^\d+\.\s/, '');
1568
- html += '<li>' + inlineFmt(esc(olText)) + '</li>';
1569
- continue;
1408
+
1409
+ // ── Create file or folder
1410
+ if (req.method === 'POST' && path === '/api/fs/new') {
1411
+ const body = await readReqJSON(req);
1412
+ const abs = safePath(body.path);
1413
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1414
+ try {
1415
+ if (fs.existsSync(abs)) return json(res, { error: 'already exists' }, 409);
1416
+ if (body.kind === 'folder') {
1417
+ fs.mkdirSync(abs, { recursive: true });
1418
+ } else {
1419
+ ensureDir(dirname(abs));
1420
+ fs.writeFileSync(abs, body.content == null ? '' : String(body.content));
1421
+ }
1422
+ return json(res, { ok: true, path: body.path });
1423
+ } catch (e) { return json(res, { error: e.message }, 500); }
1570
1424
  }
1571
1425
 
1572
- html += '<p>' + inlineFmt(esc(line)) + '</p>';
1573
- }
1574
- if (inCode) html += '<pre><code>' + esc(codeLines.join(NL)) + '</code></pre>';
1575
- if (inList) html += '</ul>';
1576
- return html;
1577
- }
1426
+ // ── Rename / move
1427
+ if (req.method === 'POST' && path === '/api/fs/rename') {
1428
+ const body = await readReqJSON(req);
1429
+ const from = safePath(body.from);
1430
+ const to = safePath(body.to);
1431
+ if (!from || !to) return json(res, { error: 'invalid path' }, 400);
1432
+ try {
1433
+ if (!fs.existsSync(from)) return json(res, { error: 'source not found' }, 404);
1434
+ if (fs.existsSync(to)) return json(res, { error: 'destination exists' }, 409);
1435
+ ensureDir(dirname(to));
1436
+ fs.renameSync(from, to);
1437
+ return json(res, { ok: true });
1438
+ } catch (e) { return json(res, { error: e.message }, 500); }
1439
+ }
1578
1440
 
1579
- function inlineFmt(text) {
1580
- var parts = text.split(BT);
1581
- var result = '';
1582
- for (var i = 0; i < parts.length; i++) {
1583
- if (i % 2 === 1) { result += '<code>' + parts[i] + '</code>'; }
1584
- else {
1585
- var s = parts[i];
1586
- s = replacePairs(s, '**', '<strong>', '</strong>');
1587
- s = replacePairs(s, '*', '<em>', '</em>');
1588
- result += s;
1441
+ // ── Duplicate
1442
+ if (req.method === 'POST' && path === '/api/fs/duplicate') {
1443
+ const body = await readReqJSON(req);
1444
+ const from = safePath(body.from);
1445
+ if (!from) return json(res, { error: 'invalid path' }, 400);
1446
+ try {
1447
+ if (!fs.existsSync(from)) return json(res, { error: 'source not found' }, 404);
1448
+ // build target name: foo.txt -> foo copy.txt, foo copy.txt -> foo copy 2.txt
1449
+ const dir = dirname(from);
1450
+ const base = from.slice(dir.length + 1);
1451
+ const dot = base.lastIndexOf('.');
1452
+ const stem = (dot > 0) ? base.slice(0, dot) : base;
1453
+ const ext = (dot > 0) ? base.slice(dot) : '';
1454
+ let target = '';
1455
+ for (let i = 0; i < 1000; i++) {
1456
+ const suffix = (i === 0) ? ' copy' : (' copy ' + (i + 1));
1457
+ const candidate = dir + '/' + stem + suffix + ext;
1458
+ if (!fs.existsSync(candidate)) { target = candidate; break; }
1459
+ }
1460
+ if (!target) return json(res, { error: 'too many copies' }, 500);
1461
+ fs.cpSync(from, target, { recursive: true });
1462
+ return json(res, { ok: true, path: relative(workingDir, target).split(sep).join('/') });
1463
+ } catch (e) { return json(res, { error: e.message }, 500); }
1589
1464
  }
1590
- }
1591
- return result;
1592
- }
1593
1465
 
1594
- function replacePairs(text, marker, open, close) {
1595
- var r = '';
1596
- var idx = 0;
1597
- while (idx < text.length) {
1598
- var s = text.indexOf(marker, idx);
1599
- if (s === -1) { r += text.substring(idx); break; }
1600
- var e = text.indexOf(marker, s + marker.length);
1601
- if (e === -1) { r += text.substring(idx); break; }
1602
- r += text.substring(idx, s) + open + text.substring(s + marker.length, e) + close;
1603
- idx = e + marker.length;
1604
- }
1605
- return r;
1606
- }
1466
+ // ── Delete (move to .sapper/.trash for safety)
1467
+ if (req.method === 'POST' && path === '/api/fs/delete') {
1468
+ const body = await readReqJSON(req);
1469
+ const abs = safePath(body.path);
1470
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1471
+ if (abs === workingDir) return json(res, { error: 'cannot delete workspace root' }, 400);
1472
+ try {
1473
+ if (!fs.existsSync(abs)) return json(res, { error: 'not found' }, 404);
1474
+ if (body.hard) {
1475
+ fs.rmSync(abs, { recursive: true, force: true });
1476
+ } else {
1477
+ const trashDir = join(SAPPER_DIR, '.trash');
1478
+ ensureDir(trashDir);
1479
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1480
+ const name = body.path.split('/').pop() || 'item';
1481
+ const target = join(trashDir, stamp + '__' + name);
1482
+ fs.renameSync(abs, target);
1483
+ }
1484
+ return json(res, { ok: true });
1485
+ } catch (e) { return json(res, { error: e.message }, 500); }
1486
+ }
1607
1487
 
1608
- function esc(s) {
1609
- if (!s) return '';
1610
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1611
- }
1488
+ // ── Reveal in OS file manager
1489
+ if (req.method === 'POST' && path === '/api/fs/reveal') {
1490
+ const body = await readReqJSON(req);
1491
+ const abs = safePath(body.path);
1492
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1493
+ try {
1494
+ if (process.platform === 'darwin') spawn('open', ['-R', abs]);
1495
+ else if (process.platform === 'win32') spawn('explorer', ['/select,' + abs]);
1496
+ else spawn('xdg-open', [dirname(abs)]);
1497
+ return json(res, { ok: true });
1498
+ } catch (e) { return json(res, { error: e.message }, 500); }
1499
+ }
1612
1500
 
1613
- // ─── File Panel ────────────────────────────────────────────
1614
- function toggleFilePanel() {
1615
- var rp = document.getElementById('rightPanel');
1616
- if (rp.classList.contains('open')) { rp.classList.remove('open'); }
1617
- else { rp.classList.add('open'); loadFileTree('.'); }
1618
- }
1619
- function closeFilePanel() { document.getElementById('rightPanel').classList.remove('open'); }
1620
-
1621
- function loadFileTree(dirPath) {
1622
- fetch('/api/tree?path=' + encodeURIComponent(dirPath)).then(function(r){return r.json();}).then(function(d) {
1623
- var el = document.getElementById('fileTree');
1624
- el.innerHTML = '';
1625
- if (dirPath !== '.') {
1626
- var up = document.createElement('div');
1627
- up.className = 'ft-item';
1628
- up.innerHTML = '<span class="ft-icon">&#11013;</span><span class="ft-name">..</span>';
1629
- var parent = dirPath.split('/').slice(0, -1).join('/') || '.';
1630
- up.onclick = function() { loadFileTree(parent); };
1631
- el.appendChild(up);
1501
+ // ── Config read/write
1502
+ if (req.method === 'GET' && path === '/api/config') {
1503
+ return json(res, { config: readJSON(CONFIG_FILE, {}), path: relative(workingDir, CONFIG_FILE) });
1632
1504
  }
1633
- for (var i = 0; i < d.entries.length; i++) {
1634
- var entry = d.entries[i];
1635
- var item = document.createElement('div');
1636
- item.className = 'ft-item' + (entry.isDir ? ' dir' : '');
1637
- var icon = entry.isDir ? '&#128194;' : '&#128196;';
1638
- item.innerHTML = '<span class="ft-icon">' + icon + '</span><span class="ft-name">' + esc(entry.name) + '</span>';
1639
- if (entry.isDir) {
1640
- var subPath = (dirPath === '.' ? '' : dirPath + '/') + entry.name;
1641
- item.onclick = (function(p) { return function() { loadFileTree(p); }; })(subPath);
1642
- } else {
1643
- var filePath = (dirPath === '.' ? '' : dirPath + '/') + entry.name;
1644
- item.onclick = (function(p) { return function() { viewFile(p); }; })(filePath);
1645
- }
1646
- el.appendChild(item);
1505
+ if (req.method === 'POST' && path === '/api/config') {
1506
+ const body = await readReqJSON(req);
1507
+ try {
1508
+ ensureDir(SAPPER_DIR);
1509
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(body.config || {}, null, 2));
1510
+ return json(res, { ok: true });
1511
+ } catch (e) { return json(res, { error: e.message }, 500); }
1647
1512
  }
1648
- });
1649
- }
1650
-
1651
- function viewFile(path) {
1652
- editMode = false;
1653
- editingFilePath = path;
1654
- fetch('/api/file/read?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d) {
1655
- var editor = document.getElementById('fileEditor');
1656
- editor.classList.add('open');
1657
- document.getElementById('fePath').textContent = path;
1658
- var content = document.getElementById('feContent');
1659
- content.innerHTML = '<pre>' + esc(d.content || '') + '</pre>';
1660
- document.getElementById('feEditBtn').style.display = '';
1661
- document.getElementById('feSaveBtn').style.display = 'none';
1662
- document.getElementById('feCancelBtn').style.display = 'none';
1663
- });
1664
- }
1665
-
1666
- function startEditing() {
1667
- if (!editingFilePath) return;
1668
- editMode = true;
1669
- var content = document.getElementById('feContent');
1670
- var pre = content.querySelector('pre');
1671
- var text = pre ? pre.textContent : '';
1672
- content.innerHTML = '';
1673
- var ta = document.createElement('textarea');
1674
- ta.value = text;
1675
- ta.id = 'feTextarea';
1676
- content.appendChild(ta);
1677
- document.getElementById('feEditBtn').style.display = 'none';
1678
- document.getElementById('feSaveBtn').style.display = '';
1679
- document.getElementById('feCancelBtn').style.display = '';
1680
- }
1681
-
1682
- function saveFileEdit() {
1683
- var ta = document.getElementById('feTextarea');
1684
- if (!ta || !editingFilePath) return;
1685
- fetch('/api/file/write', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({path: editingFilePath, content: ta.value})})
1686
- .then(function(r){return r.json();}).then(function(d) {
1687
- if (d.ok) { viewFile(editingFilePath); addSystem('Saved ' + editingFilePath); }
1688
- else addSystem('Error saving: ' + (d.error || 'unknown'));
1689
- });
1690
- }
1691
1513
 
1692
- function cancelEdit() { if (editingFilePath) viewFile(editingFilePath); }
1514
+ // ── Agents / Skills
1515
+ if (req.method === 'GET' && path === '/api/agents') return json(res, { agents: listMdDir(AGENTS_DIR) });
1516
+ if (req.method === 'GET' && path === '/api/skills') return json(res, { skills: listMdDir(SKILLS_DIR) });
1693
1517
 
1694
- // ─── Quick Actions ─────────────────────────────────────────
1695
- function qaAction(type) {
1696
- if (type === 'list') {
1697
- showPromptModal('Browse Directory', 'Directory path (e.g. src)', '.', function(v) {
1698
- sendQuick('List the contents of the directory: ' + v);
1699
- });
1700
- } else if (type === 'read') {
1701
- showPromptModal('Read File', 'File path (e.g. src/index.js)', '', function(v) {
1702
- if (v) sendQuick('Read and show me the file: ' + v);
1703
- });
1704
- } else if (type === 'write') {
1705
- showPromptModal('Create File', 'File path to create', '', function(v) {
1706
- if (v) sendQuick('Create a new file at ' + v + ' with appropriate starter content');
1707
- });
1708
- } else if (type === 'search') {
1709
- showPromptModal('Search Files', 'Search pattern', '', function(v) {
1710
- if (v) sendQuick('Search the codebase for: ' + v);
1711
- });
1712
- } else if (type === 'shell') {
1713
- showPromptModal('Run Command', 'Shell command', '', function(v) {
1714
- if (v) sendQuick('Run this command: ' + v);
1715
- });
1716
- } else if (type === 'mkdir') {
1717
- showPromptModal('Create Directory', 'Directory path', '', function(v) {
1718
- if (v) sendQuick('Create directory: ' + v);
1719
- });
1720
- } else if (type === 'review') {
1721
- sendQuick('Review the codebase for bugs, issues, and improvements');
1722
- } else if (type === 'scan') {
1723
- sendQuick('List the project files and give me a complete overview of the project structure and purpose');
1518
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1519
+ res.end('Not found');
1520
+ } catch (e) {
1521
+ json(res, { error: e.message }, 500);
1724
1522
  }
1725
- }
1726
-
1727
- // ─── Modal ─────────────────────────────────────────────────
1728
- function showModal(title, bodyHtml, footerHtml) {
1729
- document.getElementById('modalTitle').textContent = title;
1730
- document.getElementById('modalBody').innerHTML = bodyHtml;
1731
- document.getElementById('modalFooter').innerHTML = footerHtml;
1732
- document.getElementById('modalOverlay').classList.add('visible');
1733
- }
1734
- function closeModal() {
1735
- document.getElementById('modalOverlay').classList.remove('visible');
1736
- }
1737
- function showPromptModal(title, placeholder, defaultVal, callback) {
1738
- var body = '<div class="m-field"><label>' + esc(placeholder) + '</label><input id="promptInput" value="' + esc(defaultVal || '') + '"></div>';
1739
- showModal(title, body,
1740
- '<button class="m-btn secondary" onclick="closeModal()">Cancel</button>' +
1741
- '<button class="m-btn primary" id="promptOk">OK</button>');
1742
- var inp = document.getElementById('promptInput');
1743
- inp.focus();
1744
- inp.select();
1745
- document.getElementById('promptOk').onclick = function() { closeModal(); callback(inp.value); };
1746
- inp.onkeydown = function(e) { if (e.key === 'Enter') { closeModal(); callback(inp.value); } };
1747
- }
1748
-
1749
- // ─── Escape modal on Escape key ────────────────────────────
1750
- document.addEventListener('keydown', function(e) {
1751
- if (e.key === 'Escape') closeModal();
1752
1523
  });
1753
1524
 
1754
- init();
1755
- </script>
1756
- </body>
1757
- </html>`;
1758
- }
1525
+ // ─── WebSockets: /ws (pty) and /events (fs watcher) ──────────────
1759
1526
 
1760
- // ─── HTTP Server ───────────────────────────────────────────
1527
+ const wssPty = new WebSocketServer({ noServer: true });
1528
+ const wssEvents = new WebSocketServer({ noServer: true });
1761
1529
 
1762
- const server = http.createServer(async (req, res) => {
1763
- req.socket.setNoDelay(true);
1764
- const url = new URL(req.url, `http://localhost:${PORT}`);
1765
-
1766
- // ── Serve frontend ──
1767
- if (req.method === 'GET' && url.pathname === '/') {
1768
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1769
- res.end(buildHTML());
1770
- return;
1771
- }
1772
-
1773
- // ── API: models ──
1774
- if (req.method === 'GET' && url.pathname === '/api/models') {
1775
- try {
1776
- const list = await ollama.list();
1777
- const names = (list.models || []).map(m => m.name);
1778
- if (names.length > 0 && !serverModel) serverModel = names[0];
1779
- json(res, { models: names });
1780
- } catch (e) { json(res, { models: [], error: e.message }, 500); }
1781
- return;
1782
- }
1783
-
1784
- // ── API: agents ──
1785
- if (req.method === 'GET' && url.pathname === '/api/agents') {
1786
- json(res, { agents: loadAgents() });
1787
- return;
1788
- }
1789
-
1790
- // ── API: skills ──
1791
- if (req.method === 'GET' && url.pathname === '/api/skills') {
1792
- json(res, { skills: loadSkills() });
1793
- return;
1530
+ server.on('upgrade', (req, sock, head) => {
1531
+ const url = new URL(req.url, 'http://localhost');
1532
+ if (url.pathname === '/ws') {
1533
+ wssPty.handleUpgrade(req, sock, head, (ws) => wssPty.emit('connection', ws, req));
1534
+ } else if (url.pathname === '/events') {
1535
+ wssEvents.handleUpgrade(req, sock, head, (ws) => wssEvents.emit('connection', ws, req));
1536
+ } else {
1537
+ sock.destroy();
1794
1538
  }
1539
+ });
1795
1540
 
1796
- // ── API: info ──
1797
- if (req.method === 'GET' && url.pathname === '/api/info') {
1798
- json(res, { cwd: workingDir, version: getVersion() });
1799
- return;
1800
- }
1541
+ function spawnSapper(cols, rows) {
1542
+ return ptySpawn(process.execPath, [SAPPER_BIN], {
1543
+ name: 'xterm-256color',
1544
+ cols: cols || 100, rows: rows || 30,
1545
+ cwd: workingDir,
1546
+ env: { ...process.env, TERM: 'xterm-256color', FORCE_COLOR: '1', COLORTERM: 'truecolor' },
1547
+ });
1548
+ }
1801
1549
 
1802
- // ── API: sessions list ──
1803
- if (req.method === 'GET' && url.pathname === '/api/sessions') {
1804
- json(res, { sessions: listSessions() });
1805
- return;
1806
- }
1550
+ wssPty.on('connection', (ws) => {
1551
+ dbg('pty client connected');
1552
+ let pty = null; let initialized = false;
1807
1553
 
1808
- // ── API: session save ──
1809
- if (req.method === 'POST' && url.pathname === '/api/sessions/save') {
1810
- const body = await readBody(req);
1811
- if (body.id) {
1812
- currentSessionId = body.id;
1813
- saveSession(body.id, body.name || 'Chat', serverMessages, serverModel, serverAgentKey);
1554
+ function start(cols, rows) {
1555
+ if (pty) { try { pty.kill(); } catch {} }
1556
+ try { pty = spawnSapper(cols, rows); }
1557
+ catch (e) {
1558
+ console.error('[ui] spawn failed:', e.message);
1559
+ try { ws.send(Buffer.from('\x1b[31mFailed to spawn sapper: ' + e.message + '\x1b[0m\r\n', 'utf8')); } catch {}
1560
+ return;
1814
1561
  }
1815
- json(res, { ok: true });
1816
- return;
1562
+ dbg('pty pid=' + pty.pid + ' ' + cols + 'x' + rows);
1563
+ pty.onData((d) => { if (ws.readyState === ws.OPEN) ws.send(Buffer.from(d, 'utf8')); });
1564
+ pty.onExit(({ exitCode, signal }) => {
1565
+ dbg('pty exit code=' + exitCode);
1566
+ if (ws.readyState === ws.OPEN) { try { ws.send(JSON.stringify({ type: 'exit', code: exitCode, signal })); } catch {} }
1567
+ });
1568
+ try { ws.send(JSON.stringify({ type: 'cwd', path: workingDir })); } catch {}
1817
1569
  }
1818
1570
 
1819
- // ── API: session load ──
1820
- if (req.method === 'POST' && url.pathname === '/api/sessions/load') {
1821
- const body = await readBody(req);
1822
- const session = loadSessionData(body.id);
1823
- if (session) {
1824
- serverMessages = session.messages || [];
1825
- if (session.model) serverModel = session.model;
1826
- currentSessionId = body.id;
1827
- if (session.agent) {
1828
- const agents = loadAgents();
1829
- if (agents[session.agent]) {
1830
- serverAgent = agents[session.agent];
1831
- serverAgentKey = session.agent;
1832
- }
1833
- }
1571
+ ws.on('message', (raw, isBinary) => {
1572
+ const str = raw.toString('utf8');
1573
+ if (!isBinary && str.startsWith('{')) {
1574
+ try {
1575
+ const m = JSON.parse(str);
1576
+ if (m.type === 'init') { if (!initialized) { initialized = true; start(m.cols, m.rows); } return; }
1577
+ if (m.type === 'resize' && pty) { try { pty.resize(m.cols || 100, m.rows || 30); } catch {} return; }
1578
+ if (m.type === 'restart') { initialized = true; start(100, 30); return; }
1579
+ } catch {}
1834
1580
  }
1835
- json(res, { session });
1836
- return;
1837
- }
1581
+ if (pty) pty.write(str);
1582
+ });
1838
1583
 
1839
- // ── API: session delete ──
1840
- if (req.method === 'POST' && url.pathname === '/api/sessions/delete') {
1841
- const body = await readBody(req);
1842
- deleteSessionFile(body.id);
1843
- json(res, { ok: true });
1844
- return;
1845
- }
1584
+ ws.on('close', () => { if (pty) { try { pty.kill(); } catch {} pty = null; } });
1585
+ });
1846
1586
 
1847
- // ── API: session rename ──
1848
- if (req.method === 'POST' && url.pathname === '/api/sessions/rename') {
1849
- const body = await readBody(req);
1850
- renameSession(body.id, body.name);
1851
- json(res, { ok: true });
1852
- return;
1853
- }
1587
+ // ── FS watcher: broadcast to all /events clients ─────────────────
1854
1588
 
1855
- // ── API: create agent ──
1856
- if (req.method === 'POST' && url.pathname === '/api/agents/create') {
1857
- const body = await readBody(req);
1858
- const file = createAgentFile(body.name, body.description, body.tools, body.content || '');
1859
- json(res, { ok: true, file });
1860
- return;
1861
- }
1589
+ let watcher = null;
1590
+ const eventsClients = new Set();
1591
+ const recentEvents = new Map(); // path -> timestamp (dedupe burst events)
1862
1592
 
1863
- // ── API: delete agent ──
1864
- if (req.method === 'POST' && url.pathname === '/api/agents/delete') {
1865
- const body = await readBody(req);
1866
- deleteAgentFile(body.key);
1867
- json(res, { ok: true });
1868
- return;
1593
+ function startWatcher() {
1594
+ try {
1595
+ watcher = fs.watch(workingDir, { recursive: true }, (event, filename) => {
1596
+ if (!filename) return;
1597
+ // Normalize to forward slashes, skip ignored
1598
+ const rel = filename.split(sep).join('/');
1599
+ const top = rel.split('/')[0];
1600
+ if (IGNORE_NAMES.has(top)) return;
1601
+ const now = Date.now();
1602
+ const last = recentEvents.get(rel) || 0;
1603
+ if (now - last < 250) return; // dedupe
1604
+ recentEvents.set(rel, now);
1605
+ if (recentEvents.size > 500) { // bounded
1606
+ const cutoff = now - 10000;
1607
+ for (const [k, t] of recentEvents) if (t < cutoff) recentEvents.delete(k);
1608
+ }
1609
+ const payload = JSON.stringify({ event, path: rel, ts: now });
1610
+ for (const c of eventsClients) {
1611
+ if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
1612
+ }
1613
+ });
1614
+ dbg('fs.watch started on', workingDir);
1615
+ } catch (e) {
1616
+ console.error('[ui] fs.watch failed:', e.message);
1869
1617
  }
1618
+ }
1870
1619
 
1871
- // ── API: create skill ──
1872
- if (req.method === 'POST' && url.pathname === '/api/skills/create') {
1873
- const body = await readBody(req);
1874
- const file = createSkillFile(body.name, body.description, body.content || '');
1875
- json(res, { ok: true, file });
1876
- return;
1877
- }
1620
+ wssEvents.on('connection', (ws) => {
1621
+ eventsClients.add(ws);
1622
+ dbg('events client connected (total=' + eventsClients.size + ')');
1623
+ if (lastStats) { try { ws.send(lastStats); } catch {} }
1624
+ ws.on('close', () => { eventsClients.delete(ws); });
1625
+ });
1878
1626
 
1879
- // ── API: delete skill ──
1880
- if (req.method === 'POST' && url.pathname === '/api/skills/delete') {
1881
- const body = await readBody(req);
1882
- deleteSkillFile(body.key);
1883
- json(res, { ok: true });
1884
- return;
1885
- }
1627
+ // ── System stats poller (RAM + GPU on macOS) ─────────────────────
1886
1628
 
1887
- // ── API: file tree ──
1888
- if (req.method === 'GET' && url.pathname === '/api/tree') {
1889
- const dirPath = url.searchParams.get('path') || '.';
1890
- json(res, { entries: getTreeEntries(dirPath) });
1891
- return;
1892
- }
1629
+ let lastStats = null;
1630
+ let statsTimer = null;
1631
+ let lastCpuSample = null;
1893
1632
 
1894
- // ── API: file read ──
1895
- if (req.method === 'GET' && url.pathname === '/api/file/read') {
1896
- const filePath = url.searchParams.get('path');
1897
- const safe = safePath(filePath);
1898
- if (!safe) { json(res, { error: 'Invalid path' }, 400); return; }
1899
- try {
1900
- const content = fs.readFileSync(safe, 'utf8');
1901
- json(res, { content, path: filePath });
1902
- } catch (e) { json(res, { error: e.message }, 500); }
1903
- return;
1633
+ function broadcastEvents(payload) {
1634
+ for (const c of eventsClients) {
1635
+ if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
1904
1636
  }
1637
+ }
1905
1638
 
1906
- // ── API: file write ──
1907
- if (req.method === 'POST' && url.pathname === '/api/file/write') {
1908
- const body = await readBody(req);
1909
- const safe = safePath(body.path);
1910
- if (!safe) { json(res, { error: 'Invalid path' }, 400); return; }
1639
+ function runCmd(cmd, args, timeoutMs = 1500) {
1640
+ return new Promise((resolve) => {
1641
+ let out = ''; let done = false;
1911
1642
  try {
1912
- fs.mkdirSync(dirname(safe), { recursive: true });
1913
- fs.writeFileSync(safe, body.content);
1914
- json(res, { ok: true });
1915
- } catch (e) { json(res, { error: e.message }, 500); }
1916
- return;
1917
- }
1918
-
1919
- // ── API: select model ──
1920
- if (req.method === 'POST' && url.pathname === '/api/model') {
1921
- const body = await readBody(req);
1922
- serverModel = body.model || serverModel;
1923
- json(res, { ok: true, model: serverModel });
1924
- return;
1925
- }
1926
-
1927
- // ── API: select agent ──
1928
- if (req.method === 'POST' && url.pathname === '/api/agent') {
1929
- const body = await readBody(req);
1930
- const agentKey = body.agent;
1931
- if (agentKey === null) {
1932
- serverAgent = null;
1933
- serverAgentKey = null;
1934
- serverAgentTools = null;
1935
- } else {
1936
- const agents = loadAgents();
1937
- if (agents[agentKey]) {
1938
- serverAgent = agents[agentKey];
1939
- serverAgentKey = agentKey;
1940
- const toolMap = { read: 'READ', write: 'WRITE', edit: 'PATCH', patch: 'PATCH', list: 'LIST', search: 'SEARCH', shell: 'SHELL', mkdir: 'MKDIR' };
1941
- serverAgentTools = serverAgent.tools
1942
- ? (Array.isArray(serverAgent.tools) ? serverAgent.tools : [serverAgent.tools])
1943
- .map(t => toolMap[t.toLowerCase()] || t.toUpperCase()).filter(Boolean)
1944
- : null;
1945
- }
1946
- }
1947
- resetChat();
1948
- json(res, { ok: true });
1949
- return;
1950
- }
1643
+ const p = spawn(cmd, args);
1644
+ const t = setTimeout(() => { if (!done) { done = true; try { p.kill(); } catch {} resolve(''); } }, timeoutMs);
1645
+ p.stdout.on('data', (d) => { out += d.toString(); });
1646
+ p.on('error', () => { if (!done) { done = true; clearTimeout(t); resolve(''); } });
1647
+ p.on('close', () => { if (!done) { done = true; clearTimeout(t); resolve(out); } });
1648
+ } catch { resolve(''); }
1649
+ });
1650
+ }
1951
1651
 
1952
- // ── API: clear chat ──
1953
- if (req.method === 'POST' && url.pathname === '/api/clear') {
1954
- resetChat();
1955
- currentSessionId = null;
1956
- json(res, { ok: true });
1957
- return;
1652
+ async function readMemMac() {
1653
+ // Parse vm_stat. On Apple Silicon page size = 16384, on Intel = 4096.
1654
+ const out = await runCmd('vm_stat', []);
1655
+ if (!out) return null;
1656
+ const pageMatch = out.match(/page size of (\d+)/);
1657
+ const pageSize = pageMatch ? parseInt(pageMatch[1], 10) : 4096;
1658
+ function pages(name) {
1659
+ const m = out.match(new RegExp(name + '[^\\d]+(\\d+)'));
1660
+ return m ? parseInt(m[1], 10) : 0;
1958
1661
  }
1959
-
1960
- // ── API: stop generation ──
1961
- if (req.method === 'POST' && url.pathname === '/api/stop') {
1962
- abortFlag = true;
1963
- json(res, { ok: true });
1964
- return;
1965
- }
1966
-
1967
- // ── API: chat (SSE streaming) ──
1968
- if (req.method === 'POST' && url.pathname === '/api/chat') {
1969
- const body = await readBody(req);
1970
- const userMsg = body.message;
1971
- if (!userMsg) { json(res, { error: 'No message' }, 400); return; }
1972
- if (!serverModel) { json(res, { error: 'No model selected' }, 400); return; }
1973
-
1974
- if (serverMessages.length === 0) resetChat();
1975
- serverMessages.push({ role: 'user', content: userMsg });
1976
-
1977
- res.writeHead(200, {
1978
- 'Content-Type': 'text/event-stream',
1979
- 'Cache-Control': 'no-cache, no-transform',
1980
- 'Connection': 'keep-alive',
1981
- 'X-Accel-Buffering': 'no',
1982
- });
1983
- res.flushHeaders();
1984
-
1985
- try {
1986
- const stream = chatStream(serverMessages, serverModel, serverAgentTools);
1987
- let clientClosed = false;
1988
- res.on('close', () => { clientClosed = true; });
1989
- for await (const evt of stream) {
1990
- if (clientClosed) break;
1991
- const ok = res.write(`data: ${JSON.stringify(evt)}\n\n`);
1992
- if (!ok && !clientClosed) await new Promise(r => {
1993
- res.once('drain', r);
1994
- res.once('close', r);
1995
- });
1996
- }
1997
- } catch (e) {
1998
- try { res.write(`data: ${JSON.stringify({ type: 'system', data: 'Error: ' + e.message })}\n\n`); } catch {}
1999
- }
2000
- res.write('data: [DONE]\n\n');
2001
- res.end();
2002
- return;
1662
+ const wired = pages('Pages wired down');
1663
+ const active = pages('Pages active');
1664
+ const compressed = pages('Pages occupied by compressor');
1665
+ const used = (wired + active + compressed) * pageSize;
1666
+ const total = os.totalmem();
1667
+ return { used, total, percent: total > 0 ? Math.round((used / total) * 100) : 0 };
1668
+ }
1669
+
1670
+ async function readGPUMac() {
1671
+ // ioreg dumps GPU performance stats including "Device Utilization %"
1672
+ const out = await runCmd('ioreg', ['-r', '-c', 'IOAccelerator', '-d', '1', '-w', '0']);
1673
+ if (!out) return null;
1674
+ const m = out.match(/"Device Utilization %"\s*=\s*(\d+)/);
1675
+ if (!m) return null;
1676
+ const memMatch = out.match(/"In use system memory"\s*=\s*(\d+)/);
1677
+ return {
1678
+ percent: parseInt(m[1], 10),
1679
+ memBytes: memMatch ? parseInt(memMatch[1], 10) : null,
1680
+ };
1681
+ }
1682
+
1683
+ function readCPU() {
1684
+ const cpus = os.cpus();
1685
+ let idle = 0; let total = 0;
1686
+ for (const c of cpus) {
1687
+ for (const k of Object.keys(c.times)) total += c.times[k];
1688
+ idle += c.times.idle;
2003
1689
  }
1690
+ if (!lastCpuSample) { lastCpuSample = { idle, total }; return null; }
1691
+ const di = idle - lastCpuSample.idle;
1692
+ const dt = total - lastCpuSample.total;
1693
+ lastCpuSample = { idle, total };
1694
+ if (dt <= 0) return null;
1695
+ return { percent: Math.max(0, Math.min(100, Math.round((1 - di / dt) * 100))), cores: cpus.length };
1696
+ }
1697
+
1698
+ async function pollStats() {
1699
+ const isMac = process.platform === 'darwin';
1700
+ const [mem, gpu] = await Promise.all([
1701
+ isMac ? readMemMac() : null,
1702
+ isMac ? readGPUMac() : null,
1703
+ ]);
1704
+ const cpu = readCPU();
1705
+ const payload = JSON.stringify({
1706
+ type: 'stats', ts: Date.now(),
1707
+ platform: process.platform,
1708
+ cpu, mem, gpu,
1709
+ totalMem: os.totalmem(),
1710
+ });
1711
+ lastStats = payload;
1712
+ if (eventsClients.size > 0) broadcastEvents(payload);
1713
+ }
2004
1714
 
2005
- // ── 404 ──
2006
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
2007
- res.end('Not found');
2008
- });
1715
+ function startStatsPoll() {
1716
+ pollStats(); // first sample primes cpu delta
1717
+ statsTimer = setInterval(pollStats, 1500);
1718
+ }
2009
1719
 
2010
- // ─── Launch ────────────────────────────────────────────────
1720
+ // ─── Launch ──────────────────────────────────────────────────────
2011
1721
 
2012
1722
  server.listen(PORT, () => {
2013
1723
  const url = `http://localhost:${PORT}`;
2014
- console.log(`\n ⚡ Sapper UI running at ${url}\n`);
1724
+ console.log(`\n \x1b[36m⚡ Sapper Web\x1b[0m running at \x1b[1m${url}\x1b[0m`);
1725
+ console.log(` Working dir: ${workingDir}\n`);
1726
+ startWatcher();
1727
+ startStatsPoll();
1728
+
1729
+ if (process.env.SAPPER_UI_NO_OPEN) return;
2015
1730
 
2016
1731
  const browsers = [
2017
1732
  ['open', ['-na', 'Google Chrome', '--args', `--app=${url}`, '--new-window']],
@@ -2019,16 +1734,15 @@ server.listen(PORT, () => {
2019
1734
  ['open', ['-na', 'Brave Browser', '--args', `--app=${url}`]],
2020
1735
  ['open', [url]],
2021
1736
  ];
2022
-
2023
- let opened = false;
2024
1737
  for (const [cmd, args] of browsers) {
2025
- if (opened) break;
2026
1738
  try {
2027
- const proc = spawn(cmd, args, { stdio: 'ignore', detached: true });
2028
- proc.unref();
2029
- proc.on('error', () => {});
2030
- opened = true;
1739
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
1740
+ p.unref();
1741
+ p.on('error', () => {});
1742
+ break;
2031
1743
  } catch {}
2032
1744
  }
2033
- if (!opened) console.log(` Open manually: ${url}`);
2034
1745
  });
1746
+
1747
+ process.on('SIGINT', () => { console.log('\nShutting down…'); try { watcher && watcher.close(); } catch {} process.exit(0); });
1748
+ process.on('SIGTERM', () => process.exit(0));