sapper-iq 1.1.40 → 1.2.1

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 +2047 -1836
  4. package/sapper.mjs +1 -1
package/sapper-ui.mjs CHANGED
@@ -1,2017 +1,2229 @@
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
- }
214
+ @media (max-width: 820px) { #stats { display: none; } }
339
215
 
340
- // ─── Chat Engine ───────────────────────────────────────────
216
+ /* ─── Body layout ─── */
217
+ #body { flex: 1; min-height: 0; min-width: 0; display: flex; overflow: hidden; }
341
218
 
342
- let abortFlag = false;
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
+ .files-toolbar .ftb.on { color: var(--accent); border-color: var(--accent); background: rgba(88,166,255,.08); }
249
+
250
+ /* Activity feed */
251
+ #activityPanel { display: none; border-bottom: 1px solid var(--border);
252
+ background: var(--panel2); max-height: 180px; overflow-y: auto;
253
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; }
254
+ #activityPanel.on { display: block; }
255
+ #activityPanel .ah { display: flex; align-items: center; padding: 5px 10px;
256
+ border-bottom: 1px solid var(--border); color: var(--dim); font-size: 10px;
257
+ text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0;
258
+ background: var(--panel2); z-index: 1; }
259
+ #activityPanel .ah .acl { margin-left: auto; color: var(--accent); cursor: pointer;
260
+ text-transform: none; letter-spacing: 0; font-size: 10px; }
261
+ #activityPanel .ai { display: flex; align-items: center; gap: 6px; padding: 4px 10px;
262
+ color: var(--muted); cursor: pointer; border-left: 2px solid transparent; }
263
+ #activityPanel .ai:hover { background: rgba(255,255,255,.04); color: var(--fg); }
264
+ #activityPanel .ai .ak { font-size: 9px; text-transform: uppercase; letter-spacing: .5px;
265
+ width: 56px; flex-shrink: 0; font-weight: 600; }
266
+ #activityPanel .ai .ap { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
267
+ #activityPanel .ai .at { color: var(--dim); font-size: 9px; flex-shrink: 0; }
268
+ #activityPanel .ai.kind-created { border-left-color: var(--green); }
269
+ #activityPanel .ai.kind-modified { border-left-color: var(--yellow); }
270
+ #activityPanel .ai.kind-deleted { border-left-color: var(--red); }
271
+ #activityPanel .ai.kind-created .ak { color: var(--green); }
272
+ #activityPanel .ai.kind-modified .ak { color: var(--yellow); }
273
+ #activityPanel .ai.kind-deleted .ak { color: var(--red); }
274
+ #activityPanel .empty { padding: 12px; color: var(--dim); text-align: center; font-size: 11px; }
275
+ .tree { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; padding-bottom: 12px; }
276
+ .row { display: flex; align-items: center; gap: 4px; padding: 3px 8px; cursor: pointer; color: var(--muted);
277
+ white-space: nowrap; user-select: none; position: relative; }
278
+ .row:hover { background: rgba(255,255,255,.04); color: var(--fg); }
279
+ .row.active { background: rgba(88,166,255,.12); color: var(--accent); }
280
+ .row .chev { width: 12px; display: inline-block; color: var(--dim); font-size: 9px; flex-shrink: 0; text-align: center; }
281
+ .row .ico { width: 14px; flex-shrink: 0; }
282
+ .row .name { overflow: hidden; text-overflow: ellipsis; }
283
+ .row .badge { margin-left: auto; font-size: 9px; color: var(--yellow); opacity: 0; transition: opacity .2s; }
284
+ .row.changed .badge { opacity: 1; }
285
+ .row .actdot { display: none; width: 7px; height: 7px; border-radius: 50%;
286
+ margin-left: 4px; flex-shrink: 0; box-shadow: 0 0 6px currentColor; }
287
+ .row.act-created .actdot { display: inline-block; background: var(--green); color: var(--green); }
288
+ .row.act-modified .actdot { display: inline-block; background: var(--yellow); color: var(--yellow); }
289
+ .row.act-deleted .actdot { display: inline-block; background: var(--red); color: var(--red); }
290
+ .row.act-fresh .actdot { animation: pulse 1.4s ease-out 2; }
291
+ .row.act-created .name { color: #56d364; }
292
+ .row.act-modified .name { color: #e3b341; }
293
+ .row.act-deleted .name { color: #ffa198; text-decoration: line-through; opacity: .7; }
294
+ @keyframes pulse { 0%{transform:scale(1);} 50%{transform:scale(1.6);} 100%{transform:scale(1);} }
295
+ .row .actcount { display: none; font-size: 9px; color: var(--dim);
296
+ font-family: ui-monospace, monospace; margin-left: 2px; }
297
+ .row.act-multi .actcount { display: inline-block; }
298
+ .row .rmenu { margin-left: auto; color: var(--dim); font-size: 14px; padding: 0 4px;
299
+ opacity: 0; flex-shrink: 0; line-height: 1; border-radius: 3px; }
300
+ .row.changed .rmenu { margin-left: 4px; }
301
+ .row:hover .rmenu, .row .rmenu.open { opacity: 1; }
302
+ .row .rmenu:hover { color: var(--fg); background: rgba(255,255,255,.08); }
303
+
304
+ /* Context menu */
305
+ .ctx-menu { position: fixed; z-index: 9999; min-width: 180px;
306
+ background: var(--panel2); border: 1px solid var(--border); border-radius: 6px;
307
+ padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,.5); font-size: 12px;
308
+ color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
309
+ .ctx-menu .ci { padding: 6px 14px; cursor: pointer; display: flex; align-items: center;
310
+ gap: 10px; color: var(--muted); }
311
+ .ctx-menu .ci:hover { background: rgba(88,166,255,.12); color: var(--accent); }
312
+ .ctx-menu .ci.danger:hover { background: rgba(248,81,73,.15); color: var(--red); }
313
+ .ctx-menu .ci .k { margin-left: auto; color: var(--dim); font-size: 10px; }
314
+ .ctx-menu .sep { height: 1px; background: var(--border); margin: 4px 0; }
315
+
316
+ /* Modal */
317
+ .modal-bd { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 10000;
318
+ display: flex; align-items: center; justify-content: center; }
319
+ .modal { background: var(--panel2); border: 1px solid var(--border); border-radius: 8px;
320
+ padding: 18px 18px 14px; width: 460px; max-width: 92vw;
321
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
322
+ .modal h3 { margin: 0 0 12px; font-size: 14px; color: var(--fg); font-weight: 600; }
323
+ .modal label { display: block; font-size: 11px; color: var(--dim); margin: 8px 0 4px;
324
+ text-transform: uppercase; letter-spacing: .5px; }
325
+ .modal input[type=text] { width: 100%; box-sizing: border-box; background: var(--bg);
326
+ color: var(--fg); border: 1px solid var(--border); border-radius: 4px;
327
+ padding: 7px 9px; font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; outline: none; }
328
+ .modal input[type=text]:focus { border-color: var(--accent); }
329
+ .modal .hint { font-size: 11px; color: var(--dim); margin-top: 4px; }
330
+ .modal .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
331
+ .modal .actions button { background: transparent; color: var(--muted); border: 1px solid var(--border);
332
+ border-radius: 5px; padding: 6px 14px; font-size: 12px; cursor: pointer; }
333
+ .modal .actions button:hover { color: var(--fg); border-color: var(--accent); }
334
+ .modal .actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
335
+ .modal .actions button.primary:hover { filter: brightness(1.1); }
336
+ .modal .actions button.danger { background: var(--red); color: #fff; border-color: var(--red); }
337
+
338
+ /* Config / Agents / Skills lists */
339
+ .pane-section { padding: 10px 14px; }
340
+ .pane-section h4 { font-size: 11px; color: var(--dim); text-transform: uppercase;
341
+ letter-spacing: .5px; margin: 0 0 8px; font-weight: 600; }
342
+ .pane-section p { font-size: 12px; color: var(--muted); margin: 4px 0 12px; line-height: 1.4; }
343
+ .pane-section label { display: block; font-size: 11px; color: var(--muted); margin: 8px 0 4px; font-weight: 500; }
344
+ .pane-section input[type=text], .pane-section input[type=number], .pane-section select {
345
+ width: 100%; background: var(--panel2); border: 1px solid var(--border2); border-radius: 5px;
346
+ padding: 5px 8px; color: var(--fg); font-size: 12px; outline: none; font-family: inherit;
347
+ }
348
+ .pane-section input:focus, .pane-section select:focus { border-color: var(--accent); }
349
+ .pane-section .toggle-row { display: flex; align-items: center; justify-content: space-between;
350
+ padding: 6px 0; border-bottom: 1px solid var(--border); }
351
+ .pane-section .toggle-row span { font-size: 12px; color: var(--muted); }
352
+ .switch { position: relative; width: 30px; height: 16px; background: var(--border2); border-radius: 8px;
353
+ cursor: pointer; transition: background .15s; flex-shrink: 0; }
354
+ .switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 12px; height: 12px;
355
+ background: var(--muted); border-radius: 50%; transition: all .15s; }
356
+ .switch.on { background: var(--accent); }
357
+ .switch.on::after { background: white; left: 16px; }
358
+
359
+ .json-edit {
360
+ width: 100%; max-width: 100%; height: 320px; background: var(--bg);
361
+ border: 1px solid var(--border2); border-radius: 6px; padding: 8px 10px; color: var(--fg);
362
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 11px; line-height: 1.45;
363
+ resize: vertical; outline: none; display: block;
364
+ white-space: pre; overflow: auto;
365
+ }
366
+ .json-edit:focus { border-color: var(--accent); }
367
+ .row-btns { display: flex; gap: 6px; margin-top: 8px; }
368
+ .row-btns button {
369
+ flex: 1; padding: 6px 10px; border-radius: 5px; border: 1px solid var(--border2);
370
+ background: var(--panel2); color: var(--muted); font-size: 11px; cursor: pointer;
371
+ transition: all .12s;
372
+ }
373
+ .row-btns button:hover { color: var(--accent); border-color: var(--accent); }
374
+ .row-btns button.primary { background: var(--accent); color: white; border-color: var(--accent); }
375
+ .row-btns button.primary:hover { background: var(--accent2); }
376
+ .row-btns button.danger:hover { color: var(--red); border-color: var(--red); }
377
+
378
+ .item { padding: 8px 14px; cursor: pointer; border-left: 2px solid transparent; }
379
+ .item:hover { background: rgba(255,255,255,.03); border-left-color: var(--border2); }
380
+ .item .ti { font-size: 13px; color: var(--fg); display: flex; align-items: center; gap: 6px; }
381
+ .item .ti .b { background: var(--accent); color: white; font-size: 9px; padding: 1px 5px;
382
+ border-radius: 8px; text-transform: uppercase; letter-spacing: .3px; }
383
+ .item .ds { font-size: 11px; color: var(--dim); margin-top: 2px; line-height: 1.35; }
384
+
385
+ /* ─── Terminal area ─── */
386
+ #center { flex: 1; min-width: 0; min-height: 0; display: flex;
387
+ flex-direction: column; background: var(--bg); overflow: hidden; position: relative; }
388
+ #qa { display: flex; align-items: center; gap: 6px; padding: 6px 10px;
389
+ background: var(--panel2); border-bottom: 1px solid var(--border); flex-shrink: 0; }
390
+ #qa .qabtn { background: transparent; color: var(--muted); border: 1px solid var(--border);
391
+ border-radius: 5px; padding: 4px 10px; font-size: 11px; cursor: pointer;
392
+ display: inline-flex; align-items: center; gap: 5px; font-family: inherit; line-height: 1; }
393
+ #qa .qabtn:hover { color: var(--accent); border-color: var(--accent); }
394
+ #qa .qabtn .qaico { font-size: 13px; }
395
+ #qa .qabtn.rec.on { color: var(--red); border-color: var(--red); background: rgba(248,81,73,.08); }
396
+ #qa .qa-sp { flex: 1; }
397
+ #qa .rec-dot { display: none; width: 8px; height: 8px; border-radius: 50%;
398
+ background: var(--red); animation: blink 1s infinite; }
399
+ #qa .rec-dot.on { display: inline-block; }
400
+ #qa .rec-time { display: none; font-family: ui-monospace, 'SF Mono', monospace;
401
+ font-size: 11px; color: var(--red); font-variant-numeric: tabular-nums; }
402
+ #qa .rec-time.on { display: inline-block; }
403
+ @keyframes blink { 0%,100%{opacity:1;} 50%{opacity:.3;} }
404
+
405
+ #term-wrap { flex: 1; min-height: 0; min-width: 0; padding: 6px 0 0 10px;
406
+ overflow: hidden; position: relative; }
407
+ #term-wrap .terminal, #term-wrap .xterm { height: 100% !important; width: 100% !important; }
408
+ .xterm-screen, .xterm-viewport { max-width: 100% !important; }
409
+ .xterm .xterm-viewport { background-color: var(--bg) !important; }
410
+ .xterm-viewport::-webkit-scrollbar { width: 8px; }
411
+ .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
412
+ .xterm-viewport::-webkit-scrollbar-track { background: transparent; }
413
+
414
+ /* drag-drop overlay */
415
+ #dropOverlay { position: absolute; inset: 0; display: none; z-index: 200;
416
+ background: rgba(10,14,20,.85); align-items: center; justify-content: center;
417
+ border: 2px dashed var(--accent); pointer-events: none; }
418
+ #dropOverlay.on { display: flex; }
419
+ #dropOverlay .drop-card { text-align: center; }
420
+ #dropOverlay .drop-icon { font-size: 48px; margin-bottom: 8px; }
421
+ #dropOverlay .drop-text { color: var(--accent); font-size: 16px; font-weight: 600; }
422
+ #dropOverlay .drop-text span { color: var(--muted); font-weight: 400; font-size: 12px; }
423
+
424
+ /* ─── Preview panel ─── */
425
+ #preview {
426
+ width: 480px; flex-shrink: 0; display: flex; flex-direction: column;
427
+ background: var(--panel); border-left: 1px solid var(--border);
428
+ min-width: 0; overflow: hidden;
429
+ }
430
+ #preview.hidden { display: none; }
431
+ #preview .ph {
432
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
433
+ background: var(--panel2); border-bottom: 1px solid var(--border); flex-shrink: 0;
434
+ }
435
+ #preview .ph .pp { flex: 1; font-family: ui-monospace, 'SF Mono', monospace;
436
+ font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
437
+ #preview .ph button {
438
+ background: transparent; color: var(--muted); border: 1px solid var(--border2);
439
+ border-radius: 5px; padding: 3px 9px; font-size: 11px; cursor: pointer;
440
+ }
441
+ #preview .ph button:hover { color: var(--accent); border-color: var(--accent); }
442
+ #preview .ph button.primary { background: var(--accent); color: white; border-color: var(--accent); }
443
+ #preview .ph button.primary:hover { background: var(--accent2); }
343
444
 
344
- async function* chatStream(messages, model, agentTools) {
345
- const MAX_TOOL_ROUNDS = 15;
346
- let rounds = 0;
347
- const patchFails = {};
445
+ #preview .ind {
446
+ display: none; padding: 4px 12px; background: rgba(210,153,34,.12);
447
+ color: var(--yellow); font-size: 11px; border-bottom: 1px solid rgba(210,153,34,.3);
448
+ }
449
+ #preview .ind.show { display: block; }
450
+
451
+ #pview { flex: 1; min-height: 0; overflow: auto; padding: 14px 18px; font-size: 13.5px; line-height: 1.6; }
452
+ #pview::-webkit-scrollbar { width: 8px; }
453
+ #pview::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 4px; }
454
+ #pview pre { background: var(--bg); border: 1px solid var(--border);
455
+ border-radius: 6px; padding: 10px 12px; overflow-x: auto;
456
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; line-height: 1.5; }
457
+ #pview code { font-family: ui-monospace, 'SF Mono', monospace; font-size: 12px; }
458
+ #pview :not(pre) > code { background: var(--panel2); padding: 1px 5px; border-radius: 3px; }
459
+ #pview h1, #pview h2, #pview h3 { color: var(--accent); margin-top: 1.2em; }
460
+ #pview h1 { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: .3em; }
461
+ #pview h2 { font-size: 18px; }
462
+ #pview h3 { font-size: 15px; }
463
+ #pview a { color: var(--accent); }
464
+ #pview blockquote { border-left: 3px solid var(--accent); padding-left: 12px; color: var(--muted); margin: 8px 0; }
465
+ #pview table { border-collapse: collapse; margin: 8px 0; }
466
+ #pview th, #pview td { border: 1px solid var(--border); padding: 5px 8px; }
467
+ #pview hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
468
+ #pview img { max-width: 100%; border-radius: 4px; }
469
+ #pview iframe.html-preview { width: 100%; height: 100%; border: 0; background: #fff;
470
+ border-radius: 4px; display: block; }
471
+
472
+ #pview.code { padding: 0; }
473
+ #pview.code pre { margin: 0; border: none; border-radius: 0; min-height: 100%; }
474
+
475
+ #pedit {
476
+ flex: 1; min-height: 0; width: 100%; padding: 12px 14px;
477
+ background: var(--bg); border: none; color: var(--fg);
478
+ font-family: ui-monospace, 'SF Mono', monospace; font-size: 12.5px; line-height: 1.5;
479
+ resize: none; outline: none; display: none;
480
+ }
481
+ #pedit.show { display: block; }
482
+ #pview.hide { display: none; }
483
+
484
+ #empty { padding: 40px 20px; text-align: center; color: var(--dim); font-size: 13px; }
485
+ #empty .lg { font-size: 36px; margin-bottom: 8px; }
486
+
487
+ /* Toast for fs events */
488
+ #toast { position: fixed; bottom: 14px; right: 14px; z-index: 100;
489
+ display: flex; flex-direction: column; gap: 6px; pointer-events: none; }
490
+ .tmsg { background: rgba(13,17,23,.95); color: var(--fg); border: 1px solid var(--border2);
491
+ border-radius: 6px; padding: 8px 12px; font-size: 12px; pointer-events: auto;
492
+ animation: slideIn .2s ease; max-width: 360px; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
493
+ .tmsg.warn { border-color: rgba(210,153,34,.5); }
494
+ .tmsg.err { border-color: var(--red); }
495
+ @keyframes slideIn { from { transform: translateX(10px); opacity: 0; } to { transform: none; opacity: 1; } }
496
+ </style>
497
+ </head>
498
+ <body>
499
+ <div id="app">
500
+ <div id="bar">
501
+ <span class="dot" id="dot"></span>
502
+ <span class="title">&#9889; <span>Sapper</span></span>
503
+ <span class="cwd" id="cwd"></span>
504
+ <div id="stats" title="Live system stats">
505
+ <div class="srow"><span class="slbl">CPU</span><div class="sbar"><i id="bCpu"></i></div><span class="sval" id="vCpu">—</span></div>
506
+ <div class="srow"><span class="slbl">RAM</span><div class="sbar"><i id="bRam"></i></div><span class="sval" id="vRam">—</span></div>
507
+ <div class="srow"><span class="slbl">GPU</span><div class="sbar"><i id="bGpu"></i></div><span class="sval" id="vGpu">—</span></div>
508
+ </div>
509
+ <span class="spacer"></span>
510
+ <button id="btnSide" class="toggle on" onclick="toggleSide()">Sidebar</button>
511
+ <button id="btnPrev" class="toggle" onclick="togglePreview()">Preview</button>
512
+ <button onclick="sendCmd('/help')">/help</button>
513
+ <button onclick="sendCmd('/agents')">agents</button>
514
+ <button onclick="sendCmd('/model')">model</button>
515
+ <button onclick="sendCmd('/clear')">clear</button>
516
+ <button onclick="restartSapper()">restart</button>
517
+ </div>
348
518
 
349
- while (true) {
350
- if (abortFlag) { abortFlag = false; yield { type: 'system', data: 'Generation stopped' }; break; }
519
+ <div id="body">
520
+ <!-- Sidebar -->
521
+ <aside id="side">
522
+ <div class="tabs">
523
+ <button class="active" data-tab="files" onclick="switchTab('files')">Files</button>
524
+ <button data-tab="config" onclick="switchTab('config')">Config</button>
525
+ <button data-tab="agents" onclick="switchTab('agents')">Agents</button>
526
+ <button data-tab="skills" onclick="switchTab('skills')">Skills</button>
527
+ </div>
528
+ <div class="pane active" id="pane-files">
529
+ <div class="files-toolbar">
530
+ <button class="ftb" title="New file" onclick="newItemPrompt('file','')">&#128462;<sup>+</sup></button>
531
+ <button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">&#128193;<sup>+</sup></button>
532
+ <button class="ftb" id="ftbAct" title="Show activity log" onclick="toggleActivity()">&#9737;</button>
533
+ <span class="ftb-spacer"></span>
534
+ <button class="ftb" title="Clear change marks" onclick="clearAllMarks()">&#10005;</button>
535
+ <button class="ftb" title="Refresh tree" onclick="loadTree()">&#8634;</button>
536
+ <button class="ftb" title="Collapse all" onclick="collapseAll()">&#8676;</button>
537
+ </div>
538
+ <div id="activityPanel">
539
+ <div class="ah">Recent activity<span class="acl" onclick="clearActivity()">clear</span></div>
540
+ <div id="activityList"></div>
541
+ </div>
542
+ <div class="tree" id="tree"></div>
543
+ </div>
544
+ <div class="pane" id="pane-config">
545
+ <div class="pane-section" id="cfgQuick">
546
+ <h4>Quick settings</h4>
547
+ <div id="cfgQuickBody"></div>
548
+ </div>
549
+ <div class="pane-section">
550
+ <h4>Raw config.json</h4>
551
+ <p>Full <code>.sapper/config.json</code> — every Sapper option lives here.</p>
552
+ <textarea class="json-edit" id="cfgJson" spellcheck="false"></textarea>
553
+ <div class="row-btns">
554
+ <button onclick="reloadConfig()">Reload</button>
555
+ <button class="primary" onclick="saveConfig()">Save</button>
556
+ </div>
557
+ </div>
558
+ </div>
559
+ <div class="pane" id="pane-agents">
560
+ <div class="pane-section">
561
+ <h4>Available agents</h4>
562
+ <p>Click any agent to open its <code>.md</code> file in preview.</p>
563
+ </div>
564
+ <div id="agentsList"></div>
565
+ </div>
566
+ <div class="pane" id="pane-skills">
567
+ <div class="pane-section">
568
+ <h4>Available skills</h4>
569
+ <p>Click a skill to open it. Use <code>/use name</code> in the terminal to load.</p>
570
+ </div>
571
+ <div id="skillsList"></div>
572
+ </div>
573
+ </aside>
574
+
575
+ <!-- Center: terminal -->
576
+ <main id="center">
577
+ <div id="qa">
578
+ <button class="qabtn" title="Attach files (sends @path to Sapper)" onclick="pickAndUpload()">
579
+ <span class="qaico">&#128206;</span><span class="qalbl">Attach</span>
580
+ </button>
581
+ <button class="qabtn rec" title="Record voice (auto-transcribed by Sapper)" onclick="toggleRecord()" id="qaRec">
582
+ <span class="qaico">&#127908;</span><span class="qalbl">Record</span>
583
+ </button>
584
+ <span id="recDot" class="rec-dot"></span>
585
+ <span id="recTime" class="rec-time"></span>
586
+ <span class="qa-sp"></span>
587
+ <button class="qabtn" title="Send /attach (interactive)" onclick="sendCmd('/attach')">/attach</button>
588
+ <button class="qabtn" title="Open file by path" onclick="sendOpenPrompt()">/open</button>
589
+ <button class="qabtn" title="Compact context" onclick="sendCmd('/summary')">/summary</button>
590
+ <input type="file" id="qaFile" multiple style="display:none">
591
+ </div>
592
+ <div id="term-wrap"></div>
593
+ <div id="dropOverlay">
594
+ <div class="drop-card">
595
+ <div class="drop-icon">&#128229;</div>
596
+ <div class="drop-text">Drop files to upload<br><span>They will be attached to Sapper with <code>@path</code></span></div>
597
+ </div>
598
+ </div>
599
+ </main>
600
+
601
+ <!-- Right: preview -->
602
+ <aside id="preview" class="hidden">
603
+ <div class="ph">
604
+ <span class="pp" id="pPath">No file open</span>
605
+ <button id="pEdit" onclick="startEdit()" style="display:none">Edit</button>
606
+ <button id="pSave" onclick="saveEdit()" class="primary" style="display:none">Save</button>
607
+ <button id="pCancel" onclick="cancelEdit()" style="display:none">Cancel</button>
608
+ <button id="pSrc" onclick="toggleSource()" style="display:none">Source</button>
609
+ <button id="pReload" onclick="reloadPreview()" style="display:none">Reload</button>
610
+ <button onclick="closePreview()">&times;</button>
611
+ </div>
612
+ <div class="ind" id="pInd">File changed on disk — reload to view latest.</div>
613
+ <div id="pview"><div id="empty"><div class="lg">&#128196;</div>Open a file from the sidebar.</div></div>
614
+ <textarea id="pedit" spellcheck="false"></textarea>
615
+ </aside>
616
+ </div>
617
+ </div>
618
+ <div id="toast"></div>
351
619
 
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 });
620
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
621
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
622
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
623
+ <script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
624
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
625
+ <script>
626
+ /* ─────────────────────────────────────────────────────────────── */
627
+ /* Sapper Web — frontend */
628
+ /* ─────────────────────────────────────────────────────────────── */
361
629
 
362
- const clean = fullMsg.replace(/```[\s\S]*?```/g, '');
363
- const toolMatches = [...clean.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
630
+ var BT = String.fromCharCode(96);
364
631
 
365
- if (toolMatches.length === 0) break;
632
+ // ─── State ────────────────────────────────────────────────────
633
+ var state = {
634
+ cwd: '',
635
+ currentFile: null, // workspace-relative path currently in preview
636
+ fileOnDisk: '', // last loaded content from server
637
+ editing: false,
638
+ expanded: { '': true },
639
+ fsWS: null,
640
+ marks: {}, // path -> { kind, count, ts }
641
+ activity: [], // ordered list of {kind, path, isDir, ts}
642
+ activityOpen: false,
643
+ };
366
644
 
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
- }
645
+ function esc(s) {
646
+ if (s == null) return '';
647
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
648
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
649
+ }
373
650
 
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;
651
+ function showToast(msg, kind) {
652
+ var el = document.createElement('div');
653
+ el.className = 'tmsg' + (kind ? ' ' + kind : '');
654
+ el.textContent = msg;
655
+ document.getElementById('toast').appendChild(el);
656
+ setTimeout(function(){ el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 2200);
657
+ setTimeout(function(){ el.remove(); }, 2700);
658
+ }
659
+
660
+ // ─── Terminal & pty WS ───────────────────────────────────────
661
+ var term = new Terminal({
662
+ fontFamily: '"SF Mono","Fira Code","JetBrains Mono",Menlo,ui-monospace,monospace',
663
+ fontSize: 13, lineHeight: 1.25, cursorBlink: true, cursorStyle: 'bar',
664
+ scrollback: 10000, allowProposedApi: true, macOptionIsMeta: true,
665
+ theme: {
666
+ background:'#0a0e14', foreground:'#e6edf3', cursor:'#58a6ff', cursorAccent:'#0a0e14',
667
+ selectionBackground:'rgba(88,166,255,0.35)',
668
+ black:'#484f58', red:'#ff7b72', green:'#3fb950', yellow:'#d29922',
669
+ blue:'#58a6ff', magenta:'#bc8cff', cyan:'#39c5cf', white:'#e6edf3',
670
+ brightBlack:'#6e7681', brightRed:'#ffa198', brightGreen:'#56d364',
671
+ brightYellow:'#e3b341', brightBlue:'#79c0ff', brightMagenta:'#d2a8ff',
672
+ brightCyan:'#56d4dd', brightWhite:'#f0f6fc'
673
+ }
674
+ });
675
+ var fit = new FitAddon.FitAddon();
676
+ term.loadAddon(fit);
677
+ try { term.loadAddon(new WebLinksAddon.WebLinksAddon()); } catch(e){}
678
+ term.open(document.getElementById('term-wrap'));
679
+ setTimeout(function(){ try { fit.fit(); } catch(e){} }, 30);
680
+
681
+ var ws = null, reconnectTimer = null;
682
+
683
+ function connectPty() {
684
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
685
+ ws = new WebSocket(proto + '//' + location.host + '/ws');
686
+ ws.binaryType = 'arraybuffer';
687
+ ws.onopen = function() {
688
+ document.getElementById('dot').className = 'dot on';
689
+ var d = fit.proposeDimensions() || { cols: 100, rows: 30 };
690
+ ws.send(JSON.stringify({ type:'init', cols:d.cols, rows:d.rows }));
691
+ term.focus();
692
+ };
693
+ ws.onmessage = function(ev) {
694
+ if (typeof ev.data === 'string') {
695
+ try {
696
+ var m = JSON.parse(ev.data);
697
+ if (m.type === 'cwd') { state.cwd = m.path; document.getElementById('cwd').textContent = m.path; }
698
+ else if (m.type === 'exit') {
699
+ term.writeln('\\r\\n\\x1b[33m[sapper exited — click "restart" to relaunch]\\x1b[0m');
700
+ document.getElementById('dot').className = 'dot err';
386
701
  }
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
- }
395
-
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 } };
702
+ } catch(e){}
703
+ } else {
704
+ term.write(new Uint8Array(ev.data));
398
705
  }
399
- }
400
- }
401
-
402
- // ─── Session Management ────────────────────────────────────
403
-
404
- function listSessions() {
405
- ensureDir(SESSIONS_DIR);
706
+ };
707
+ ws.onclose = function() {
708
+ document.getElementById('dot').className = 'dot err';
709
+ clearTimeout(reconnectTimer);
710
+ reconnectTimer = setTimeout(connectPty, 1200);
711
+ };
712
+ ws.onerror = function(){};
713
+ }
714
+ term.onData(function(d){ if (ws && ws.readyState === 1) ws.send(d); });
715
+ function doFit() {
406
716
  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
- }
419
-
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));
717
+ fit.fit();
718
+ var d = fit.proposeDimensions();
719
+ if (d && ws && ws.readyState === 1) ws.send(JSON.stringify({ type:'resize', cols:d.cols, rows:d.rows }));
720
+ } catch(e){}
721
+ }
722
+ var rTimer = null;
723
+ window.addEventListener('resize', function(){ clearTimeout(rTimer); rTimer = setTimeout(doFit, 80); });
724
+
725
+ window.sendCmd = function(cmd) { if (ws && ws.readyState === 1) ws.send(cmd + '\\r'); term.focus(); };
726
+ window.restartSapper = function() { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type:'restart' })); };
727
+ document.getElementById('term-wrap').addEventListener('click', function(){ term.focus(); });
728
+
729
+ // ─── FS events WS ────────────────────────────────────────────
730
+ function connectEvents() {
731
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
732
+ state.fsWS = new WebSocket(proto + '//' + location.host + '/events');
733
+ state.fsWS.onmessage = function(ev) {
734
+ var msg = null;
735
+ try { msg = JSON.parse(ev.data); } catch(e) { return; }
736
+ if (msg.type === 'stats') { handleStats(msg); return; }
737
+ handleFsEvent(msg);
738
+ };
739
+ state.fsWS.onclose = function() { setTimeout(connectEvents, 2000); };
740
+ }
741
+
742
+ function fmtBytes(b) {
743
+ if (b == null) return '—';
744
+ var u = ['B','KB','MB','GB','TB']; var i = 0;
745
+ while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
746
+ return (i >= 3 ? b.toFixed(1) : Math.round(b)) + ' ' + u[i];
747
+ }
748
+
749
+ function setBar(id, pct) {
750
+ var el = document.getElementById(id);
751
+ if (!el) return;
752
+ pct = Math.max(0, Math.min(100, pct || 0));
753
+ el.style.width = pct + '%';
754
+ el.classList.remove('warn', 'crit');
755
+ if (pct >= 85) el.classList.add('crit');
756
+ else if (pct >= 65) el.classList.add('warn');
757
+ }
758
+
759
+ function handleStats(msg) {
760
+ if (msg.cpu) {
761
+ setBar('bCpu', msg.cpu.percent);
762
+ document.getElementById('vCpu').textContent = msg.cpu.percent + '%';
763
+ }
764
+ if (msg.mem) {
765
+ setBar('bRam', msg.mem.percent);
766
+ document.getElementById('vRam').textContent = fmtBytes(msg.mem.used) + '/' + fmtBytes(msg.mem.total);
767
+ } else if (msg.totalMem) {
768
+ document.getElementById('vRam').textContent = fmtBytes(msg.totalMem);
769
+ }
770
+ if (msg.gpu) {
771
+ setBar('bGpu', msg.gpu.percent);
772
+ document.getElementById('vGpu').textContent = msg.gpu.percent + '%';
773
+ } else {
774
+ document.getElementById('vGpu').textContent = 'n/a';
775
+ }
424
776
  }
425
777
 
426
- function loadSessionData(id) {
427
- try { return JSON.parse(fs.readFileSync(join(SESSIONS_DIR, `${id}.json`), 'utf8')); }
428
- catch { return null; }
778
+ function handleFsEvent(msg) {
779
+ if (!msg) return;
780
+ if (msg.type === 'activity-replay') {
781
+ if (Array.isArray(msg.items)) msg.items.forEach(applyActivityItem);
782
+ return;
783
+ }
784
+ if (!msg.path) return;
785
+ applyActivityItem(msg);
786
+ // Re-fetch tree (parent dir) for create/delete so the new/removed file appears
787
+ if (msg.kind === 'created' || msg.kind === 'deleted') {
788
+ var parent = msg.path.split('/').slice(0, -1).join('/');
789
+ refreshDir(parent);
790
+ }
791
+ // If the current preview file changed, auto-refresh (or show indicator if editing)
792
+ if (state.currentFile === msg.path) {
793
+ if (msg.kind === 'deleted') return; // file gone; leave preview state
794
+ if (state.editing) {
795
+ document.getElementById('pInd').classList.add('show');
796
+ } else {
797
+ openFile(msg.path, true);
798
+ }
799
+ }
429
800
  }
430
801
 
431
- function deleteSessionFile(id) {
432
- try { fs.unlinkSync(join(SESSIONS_DIR, `${id}.json`)); return true; }
433
- catch { return false; }
802
+ function applyActivityItem(item) {
803
+ // bump persistent mark
804
+ var prev = state.marks[item.path];
805
+ var count = prev ? (prev.count + 1) : 1;
806
+ state.marks[item.path] = { kind: item.kind, count: count, ts: item.ts };
807
+ var row = document.querySelector('.row[data-path="' + cssEscape(item.path) + '"]');
808
+ if (row) applyMark(row, state.marks[item.path]);
809
+ // push into activity log (dedupe consecutive entries for same path)
810
+ var last = state.activity[state.activity.length - 1];
811
+ if (last && last.path === item.path && last.kind === item.kind && (item.ts - last.ts) < 1500) {
812
+ last.count = (last.count || 1) + 1;
813
+ last.ts = item.ts;
814
+ } else {
815
+ state.activity.push({ kind: item.kind, path: item.path, isDir: item.isDir, ts: item.ts, count: 1 });
816
+ if (state.activity.length > 100) state.activity.shift();
817
+ }
818
+ renderActivity();
819
+ // Highlight parent dirs subtly so user notices nested change even when collapsed
820
+ var parts = item.path.split('/');
821
+ for (var i = 1; i < parts.length; i++) {
822
+ var dirPath = parts.slice(0, i).join('/');
823
+ var dirRow = document.querySelector('.row[data-path="' + cssEscape(dirPath) + '"]');
824
+ if (dirRow && !dirRow.classList.contains('act-created') && !dirRow.classList.contains('act-modified') && !dirRow.classList.contains('act-deleted')) {
825
+ dirRow.classList.add('act-modified', 'act-fresh');
826
+ setTimeout((function(r){ return function(){ r.classList.remove('act-fresh'); }; })(dirRow), 1500);
827
+ }
828
+ }
434
829
  }
435
830
 
436
- function renameSession(id, newName) {
437
- 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; }
831
+ function applyMark(row, mark) {
832
+ row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
833
+ row.classList.add('act-' + mark.kind, 'act-fresh');
834
+ if (mark.count > 1) {
835
+ row.classList.add('act-multi');
836
+ var cnt = row.querySelector('.actcount');
837
+ if (cnt) cnt.textContent = String(mark.count);
838
+ }
839
+ setTimeout(function(){ row.classList.remove('act-fresh'); }, 1500);
443
840
  }
444
841
 
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`;
842
+ function renderActivity() {
843
+ var host = document.getElementById('activityList');
844
+ if (!host) return;
845
+ if (!state.activity.length) {
846
+ host.innerHTML = '<div class="empty">No changes yet. Ask Sapper to edit something.</div>';
847
+ return;
455
848
  }
456
- fm += '---\n\n';
457
- fs.writeFileSync(join(AGENTS_DIR, filename), fm + content);
458
- return filename;
849
+ var items = state.activity.slice(-30).reverse();
850
+ host.innerHTML = items.map(function(a){
851
+ var rel = relTime(a.ts);
852
+ var ct = a.count > 1 ? ' &times;' + a.count : '';
853
+ return '<div class="ai kind-' + a.kind + '" data-path="' + esc(a.path) + '">' +
854
+ '<span class="ak">' + a.kind + ct + '</span>' +
855
+ '<span class="ap">' + esc(a.path) + '</span>' +
856
+ '<span class="at">' + rel + '</span></div>';
857
+ }).join('');
858
+ Array.from(host.querySelectorAll('.ai')).forEach(function(el){
859
+ el.addEventListener('click', function(){
860
+ var p = el.dataset.path;
861
+ var mark = state.marks[p];
862
+ if (mark && mark.kind === 'deleted') { showToast(p + ' (deleted)'); return; }
863
+ // expand ancestor dirs then open
864
+ var parts = p.split('/');
865
+ var soFar = '';
866
+ for (var i = 0; i < parts.length - 1; i++) {
867
+ soFar = soFar ? soFar + '/' + parts[i] : parts[i];
868
+ state.expanded[soFar] = true;
869
+ }
870
+ loadTree();
871
+ setTimeout(function(){ openFile(p); }, 80);
872
+ // clear that file's mark since the user has acknowledged it
873
+ clearMark(p);
874
+ });
875
+ });
459
876
  }
460
877
 
461
- function deleteAgentFile(key) {
462
- try { fs.unlinkSync(join(AGENTS_DIR, key + '.md')); return true; }
463
- catch { return false; }
878
+ function relTime(ts) {
879
+ var s = Math.floor((Date.now() - ts) / 1000);
880
+ if (s < 5) return 'now';
881
+ if (s < 60) return s + 's';
882
+ if (s < 3600) return Math.floor(s / 60) + 'm';
883
+ return Math.floor(s / 3600) + 'h';
464
884
  }
465
885
 
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;
886
+ function clearMark(path) {
887
+ delete state.marks[path];
888
+ var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
889
+ if (row) row.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
475
890
  }
476
891
 
477
- function deleteSkillFile(key) {
478
- try { fs.unlinkSync(join(SKILLS_DIR, key + '.md')); return true; }
479
- catch { return false; }
480
- }
892
+ window.toggleActivity = function() {
893
+ state.activityOpen = !state.activityOpen;
894
+ document.getElementById('activityPanel').classList.toggle('on', state.activityOpen);
895
+ document.getElementById('ftbAct').classList.toggle('on', state.activityOpen);
896
+ if (state.activityOpen) renderActivity();
897
+ };
481
898
 
482
- // ─── Directory Tree ────────────────────────────────────────
899
+ window.clearActivity = function() {
900
+ state.activity = [];
901
+ renderActivity();
902
+ };
483
903
 
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 []; }
503
- }
904
+ window.clearAllMarks = function() {
905
+ state.marks = {};
906
+ document.querySelectorAll('.row').forEach(function(r){
907
+ r.classList.remove('act-created', 'act-modified', 'act-deleted', 'act-multi', 'act-fresh');
908
+ });
909
+ showToast('Cleared change marks');
910
+ };
504
911
 
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
- }
912
+ // Periodically refresh "rel time" labels in the activity panel
913
+ setInterval(function(){ if (state.activityOpen) renderActivity(); }, 30000);
522
914
 
523
- function getVersion() {
524
- try { return JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8')).version; }
525
- catch { return '0.0.0'; }
526
- }
915
+ function cssEscape(s) { return s.replace(/(["\\\\])/g, '\\\\$1'); }
527
916
 
528
- function json(res, data, status = 200) {
529
- res.writeHead(status, { 'Content-Type': 'application/json' });
530
- res.end(JSON.stringify(data));
917
+ // ─── Sidebar tabs ────────────────────────────────────────────
918
+ window.switchTab = function(name) {
919
+ document.querySelectorAll('.tabs button').forEach(function(b){
920
+ b.classList.toggle('active', b.dataset.tab === name);
921
+ });
922
+ document.querySelectorAll('.pane').forEach(function(p){
923
+ p.classList.toggle('active', p.id === 'pane-' + name);
924
+ });
925
+ if (name === 'config' && !document.getElementById('cfgJson').value) reloadConfig();
926
+ if (name === 'agents') loadAgents();
927
+ if (name === 'skills') loadSkills();
928
+ };
929
+ window.toggleSide = function() {
930
+ var s = document.getElementById('side');
931
+ s.classList.toggle('hidden');
932
+ document.getElementById('btnSide').classList.toggle('on', !s.classList.contains('hidden'));
933
+ setTimeout(doFit, 50);
934
+ };
935
+ window.togglePreview = function() {
936
+ var p = document.getElementById('preview');
937
+ p.classList.toggle('hidden');
938
+ document.getElementById('btnPrev').classList.toggle('on', !p.classList.contains('hidden'));
939
+ setTimeout(doFit, 50);
940
+ };
941
+
942
+ // ─── File tree ───────────────────────────────────────────────
943
+ function fileIcon(name, isDir) {
944
+ if (isDir) return '&#128193;';
945
+ var ext = name.split('.').pop().toLowerCase();
946
+ if (['md','markdown'].indexOf(ext) >= 0) return '&#128221;';
947
+ if (['png','jpg','jpeg','gif','svg','webp'].indexOf(ext) >= 0) return '&#128247;';
948
+ if (['json','yml','yaml','toml'].indexOf(ext) >= 0) return '&#9881;';
949
+ return '&#128196;';
950
+ }
951
+
952
+ function loadTree() {
953
+ fetch('/api/tree?path=').then(function(r){return r.json();}).then(function(d){
954
+ var root = document.getElementById('tree');
955
+ root.innerHTML = '';
956
+ renderDir(root, '', d.entries, 0);
957
+ }).catch(function(e){ showToast('Tree error: ' + e.message, 'err'); });
958
+ }
959
+
960
+ function refreshDir(path) {
961
+ // Re-fetch the directory contents and re-render in place if expanded
962
+ var key = path || '';
963
+ if (!state.expanded[key]) return;
964
+ fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
965
+ // Find parent row, then rebuild its children
966
+ if (key === '') { loadTree(); return; }
967
+ var parentRow = document.querySelector('.row[data-path="' + cssEscape(key) + '"]');
968
+ if (!parentRow) return;
969
+ var depth = parseInt(parentRow.dataset.depth || '0', 10);
970
+ var next = parentRow.nextSibling;
971
+ while (next && parseInt(next.dataset.depth || '-1', 10) > depth) {
972
+ var rem = next; next = next.nextSibling; rem.remove();
973
+ }
974
+ // Re-insert children
975
+ var container = document.createDocumentFragment();
976
+ renderEntries(container, key, d.entries, depth + 1);
977
+ parentRow.parentNode.insertBefore(container, parentRow.nextSibling);
978
+ });
531
979
  }
532
980
 
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' });
981
+ function renderDir(container, basePath, entries, depth) {
982
+ renderEntries(container, basePath, entries, depth);
983
+ }
984
+
985
+ function renderEntries(container, basePath, entries, depth) {
986
+ entries.forEach(function(entry){
987
+ var path = basePath ? (basePath + '/' + entry.name) : entry.name;
988
+ var row = document.createElement('div');
989
+ row.className = 'row';
990
+ row.dataset.path = path;
991
+ row.dataset.depth = depth;
992
+ row.dataset.isdir = entry.isDir ? '1' : '0';
993
+ row.style.paddingLeft = (8 + depth * 14) + 'px';
994
+ var chev = entry.isDir ? (state.expanded[path] ? '&#9662;' : '&#9656;') : '';
995
+ row.innerHTML =
996
+ '<span class="chev">' + chev + '</span>' +
997
+ '<span class="ico">' + fileIcon(entry.name, entry.isDir) + '</span>' +
998
+ '<span class="name">' + esc(entry.name) + '</span>' +
999
+ '<span class="actdot"></span>' +
1000
+ '<span class="actcount"></span>' +
1001
+ '<span class="badge">&#9679;</span>' +
1002
+ '<span class="rmenu" title="Options">&#8943;</span>';
1003
+ row.addEventListener('click', function(ev){
1004
+ if (ev.target && ev.target.classList && ev.target.classList.contains('rmenu')) {
1005
+ ev.stopPropagation();
1006
+ openRowMenu(ev.target, path, entry.isDir);
543
1007
  return;
544
1008
  }
545
- body += c;
1009
+ if (entry.isDir) toggleDir(row, path);
1010
+ else openFile(path);
546
1011
  });
547
- req.on('error', () => resolve({}));
548
- req.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve({}); } });
1012
+ row.addEventListener('contextmenu', function(ev){
1013
+ ev.preventDefault();
1014
+ openRowMenu({ getBoundingClientRect: function(){ return { left: ev.clientX, bottom: ev.clientY, right: ev.clientX, top: ev.clientY }; } }, path, entry.isDir);
1015
+ });
1016
+ container.appendChild(row);
1017
+ // Re-apply any persistent activity mark for this path
1018
+ var m = state.marks[path];
1019
+ if (m) applyMark(row, m);
1020
+ if (entry.isDir && state.expanded[path]) {
1021
+ // Load children if not already loaded
1022
+ fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
1023
+ var frag = document.createDocumentFragment();
1024
+ renderEntries(frag, path, d.entries, depth + 1);
1025
+ row.parentNode.insertBefore(frag, row.nextSibling);
1026
+ });
1027
+ }
549
1028
  });
550
1029
  }
551
1030
 
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;
1031
+ function toggleDir(row, path) {
1032
+ var depth = parseInt(row.dataset.depth, 10);
1033
+ var isExpanded = !!state.expanded[path];
1034
+ if (isExpanded) {
1035
+ // Collapse: remove all following rows with greater depth
1036
+ var next = row.nextSibling;
1037
+ while (next && parseInt(next.dataset.depth || '-1', 10) > depth) {
1038
+ var rem = next; next = next.nextSibling; rem.remove();
1039
+ }
1040
+ delete state.expanded[path];
1041
+ row.querySelector('.chev').innerHTML = '&#9656;';
1042
+ } else {
1043
+ state.expanded[path] = true;
1044
+ row.querySelector('.chev').innerHTML = '&#9662;';
1045
+ fetch('/api/tree?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
1046
+ var frag = document.createDocumentFragment();
1047
+ renderEntries(frag, path, d.entries, depth + 1);
1048
+ row.parentNode.insertBefore(frag, row.nextSibling);
1049
+ });
1050
+ }
568
1051
  }
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
1052
 
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
-
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>
1053
+ window.collapseAll = function() {
1054
+ state.expanded = {};
1055
+ loadTree();
1056
+ };
854
1057
 
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>
1058
+ // ─── Context menu + file actions ─────────────────────────────
1059
+ function closeCtxMenu() {
1060
+ var m = document.getElementById('ctxMenu');
1061
+ if (m) m.remove();
1062
+ document.querySelectorAll('.rmenu.open').forEach(function(e){ e.classList.remove('open'); });
1063
+ }
872
1064
 
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>
1065
+ document.addEventListener('click', function(e){
1066
+ if (e.target.closest && e.target.closest('#ctxMenu')) return;
1067
+ closeCtxMenu();
1068
+ });
1069
+ document.addEventListener('keydown', function(e){
1070
+ if (e.key === 'Escape') closeCtxMenu();
1071
+ });
884
1072
 
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();
1073
+ function openRowMenu(anchor, path, isDir) {
1074
+ closeCtxMenu();
1075
+ var rect = anchor.getBoundingClientRect();
1076
+ if (anchor.classList) anchor.classList.add('open');
1077
+ var menu = document.createElement('div');
1078
+ menu.id = 'ctxMenu';
1079
+ menu.className = 'ctx-menu';
1080
+ var items = [];
1081
+ if (isDir) {
1082
+ items.push({ label: '&#128462; New file inside', fn: function(){ newItemPrompt('file', path); } });
1083
+ items.push({ label: '&#128193; New folder inside', fn: function(){ newItemPrompt('folder', path); } });
1084
+ items.push({ sep: true });
1085
+ items.push({ label: 'Expand / Collapse', fn: function(){
1086
+ var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
1087
+ if (row) toggleDir(row, path);
1088
+ }});
1089
+ } else {
1090
+ items.push({ label: 'Open', fn: function(){ openFile(path); } });
1091
+ }
1092
+ items.push({ sep: true });
1093
+ items.push({ label: 'Rename\u2026', fn: function(){ renamePrompt(path); } });
1094
+ items.push({ label: 'Duplicate', fn: function(){ duplicateItem(path); } });
1095
+ items.push({ label: 'Copy path', fn: function(){ copyText(path); showToast('Path copied'); } });
1096
+ items.push({ label: 'Copy name', fn: function(){ copyText(path.split('/').pop()); showToast('Name copied'); } });
1097
+ items.push({ sep: true });
1098
+ items.push({ label: 'Reveal in Finder', fn: function(){
1099
+ fetch('/api/fs/reveal', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ path: path }) });
1100
+ }});
1101
+ if (!isDir) {
1102
+ items.push({ label: 'Send path to terminal', fn: function(){ sendCmd(path); } });
1103
+ items.push({ label: 'Use as preview', fn: function(){ openFile(path); } });
1104
+ }
1105
+ items.push({ sep: true });
1106
+ items.push({ label: 'Delete (move to .sapper/.trash)', danger: true, fn: function(){ deleteItem(path, false); } });
1107
+
1108
+ items.forEach(function(it){
1109
+ if (it.sep) { var s = document.createElement('div'); s.className = 'sep'; menu.appendChild(s); return; }
1110
+ var el = document.createElement('div');
1111
+ el.className = 'ci' + (it.danger ? ' danger' : '');
1112
+ el.innerHTML = '<span>' + it.label + '</span>';
1113
+ el.addEventListener('click', function(e){ e.stopPropagation(); closeCtxMenu(); it.fn(); });
1114
+ menu.appendChild(el);
927
1115
  });
928
- }
929
1116
 
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');
1117
+ document.body.appendChild(menu);
1118
+ // Position
1119
+ var mw = menu.offsetWidth, mh = menu.offsetHeight;
1120
+ var x = rect.right + 4, y = rect.top;
1121
+ if (x + mw > window.innerWidth - 8) x = Math.max(8, rect.left - mw - 4);
1122
+ if (y + mh > window.innerHeight - 8) y = Math.max(8, window.innerHeight - mh - 8);
1123
+ menu.style.left = x + 'px';
1124
+ menu.style.top = y + 'px';
1125
+ }
1126
+
1127
+ function copyText(t) {
1128
+ try { navigator.clipboard.writeText(t); }
1129
+ catch(e) {
1130
+ var ta = document.createElement('textarea');
1131
+ ta.value = t; document.body.appendChild(ta); ta.select();
1132
+ try { document.execCommand('copy'); } catch(_){}
1133
+ ta.remove();
942
1134
  }
943
- if (name === 'sessions') refreshSessions();
944
- if (name === 'agents') refreshAgents();
945
- if (name === 'skills') refreshSkills();
946
1135
  }
947
1136
 
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);
1137
+ // ─── Modal prompt ─────────────────────────────────────────────
1138
+ function showModal(opts) {
1139
+ return new Promise(function(resolve){
1140
+ var bd = document.createElement('div'); bd.className = 'modal-bd';
1141
+ var html = '<div class="modal"><h3>' + esc(opts.title) + '</h3>';
1142
+ if (opts.label) html += '<label>' + esc(opts.label) + '</label>';
1143
+ if (opts.input !== false) {
1144
+ html += '<input type="text" id="mdInput" value="' + esc(opts.value || '') + '" placeholder="' + esc(opts.placeholder || '') + '">';
989
1145
  }
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));
1146
+ if (opts.hint) html += '<div class="hint">' + esc(opts.hint) + '</div>';
1147
+ html += '<div class="actions">' +
1148
+ '<button id="mdCancel">' + (opts.cancelLabel || 'Cancel') + '</button>' +
1149
+ '<button id="mdOk" class="' + (opts.danger ? 'danger' : 'primary') + '">' + (opts.okLabel || 'OK') + '</button>' +
1150
+ '</div></div>';
1151
+ bd.innerHTML = html;
1152
+ document.body.appendChild(bd);
1153
+ var input = bd.querySelector('#mdInput');
1154
+ var ok = function(){ var v = input ? input.value : ''; bd.remove(); resolve(v); };
1155
+ var cancel = function(){ bd.remove(); resolve(null); };
1156
+ bd.querySelector('#mdOk').addEventListener('click', ok);
1157
+ bd.querySelector('#mdCancel').addEventListener('click', cancel);
1158
+ bd.addEventListener('click', function(e){ if (e.target === bd) cancel(); });
1159
+ if (input) {
1160
+ input.focus();
1161
+ // Select stem (before last dot) for renames
1162
+ if (opts.selectStem && input.value) {
1163
+ var dot = input.value.lastIndexOf('.');
1164
+ if (dot > 0) input.setSelectionRange(0, dot);
1165
+ else input.select();
1166
+ } else if (input.value) {
1167
+ input.select();
1003
1168
  }
1169
+ input.addEventListener('keydown', function(e){
1170
+ if (e.key === 'Enter') ok();
1171
+ if (e.key === 'Escape') cancel();
1172
+ });
1004
1173
  }
1005
- scrollDown();
1006
- refreshSessions();
1007
1174
  });
1008
1175
  }
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();
1176
+
1177
+ window.newItemPrompt = async function(kind, parentDir) {
1178
+ var defPath = parentDir ? (parentDir + '/') : '';
1179
+ var v = await showModal({
1180
+ title: kind === 'folder' ? 'New folder' : 'New file',
1181
+ label: 'Path (relative to workspace)',
1182
+ value: defPath,
1183
+ placeholder: kind === 'folder' ? 'src/utils' : 'src/index.ts',
1184
+ hint: 'Use "/" for subdirectories. Intermediate folders are created automatically.',
1185
+ okLabel: 'Create',
1014
1186
  });
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(); });
1187
+ if (v == null) return;
1188
+ v = v.replace(/^[\\/]+/, '').trim();
1189
+ if (!v) return;
1190
+ var r = await fetch('/api/fs/new', {
1191
+ method: 'POST', headers: {'Content-Type':'application/json'},
1192
+ body: JSON.stringify({ path: v, kind: kind })
1035
1193
  });
1036
- }
1194
+ var d = await r.json();
1195
+ if (d.error) { showToast('Create failed: ' + d.error, 'err'); return; }
1196
+ showToast(kind === 'folder' ? 'Folder created' : 'File created');
1197
+ // expand parent + refresh
1198
+ if (parentDir) state.expanded[parentDir] = true;
1199
+ loadTree();
1200
+ if (kind === 'file') setTimeout(function(){ openFile(v); }, 200);
1201
+ };
1037
1202
 
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();
1203
+ async function renamePrompt(path) {
1204
+ var name = path.split('/').pop();
1205
+ var parent = path.split('/').slice(0, -1).join('/');
1206
+ var v = await showModal({
1207
+ title: 'Rename',
1208
+ label: 'New name',
1209
+ value: name,
1210
+ selectStem: true,
1211
+ okLabel: 'Rename',
1077
1212
  });
1213
+ if (v == null) return;
1214
+ v = v.trim();
1215
+ if (!v || v === name) return;
1216
+ var to = parent ? (parent + '/' + v) : v;
1217
+ var r = await fetch('/api/fs/rename', {
1218
+ method: 'POST', headers: {'Content-Type':'application/json'},
1219
+ body: JSON.stringify({ from: path, to: to })
1220
+ });
1221
+ var d = await r.json();
1222
+ if (d.error) { showToast('Rename failed: ' + d.error, 'err'); return; }
1223
+ showToast('Renamed');
1224
+ if (state.currentFile === path) state.currentFile = to;
1225
+ loadTree();
1078
1226
  }
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
1227
 
1217
- function renderCurrentText() {
1218
- if (!currentTextEl) return;
1219
- var cleaned = stripToolSyntax(doneThinkContent + currentText);
1220
- if (cleaned) currentTextEl.innerHTML = renderMarkdown(cleaned);
1221
- lastRenderTime = Date.now();
1228
+ async function duplicateItem(path) {
1229
+ var r = await fetch('/api/fs/duplicate', {
1230
+ method: 'POST', headers: {'Content-Type':'application/json'},
1231
+ body: JSON.stringify({ from: path })
1232
+ });
1233
+ var d = await r.json();
1234
+ if (d.error) { showToast('Duplicate failed: ' + d.error, 'err'); return; }
1235
+ showToast('Duplicated to ' + d.path);
1236
+ loadTree();
1237
+ }
1238
+
1239
+ async function deleteItem(path, hard) {
1240
+ var v = await showModal({
1241
+ title: 'Delete?',
1242
+ label: path,
1243
+ input: false,
1244
+ hint: hard ? 'This permanently removes the item. This cannot be undone.'
1245
+ : 'Item will be moved to .sapper/.trash/ so you can restore it manually.',
1246
+ okLabel: 'Delete',
1247
+ danger: true,
1248
+ });
1249
+ if (v == null) return;
1250
+ var r = await fetch('/api/fs/delete', {
1251
+ method: 'POST', headers: {'Content-Type':'application/json'},
1252
+ body: JSON.stringify({ path: path, hard: !!hard })
1253
+ });
1254
+ var d = await r.json();
1255
+ if (d.error) { showToast('Delete failed: ' + d.error, 'err'); return; }
1256
+ showToast('Deleted');
1257
+ if (state.currentFile === path) closePreview();
1258
+ loadTree();
1259
+ }
1260
+
1261
+ // ─── Preview / editor ────────────────────────────────────────
1262
+ function isTextLikeExt(ext) {
1263
+ 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);
1264
+ }
1265
+
1266
+ window.openFile = function(path, isReload) {
1267
+ // Ensure preview is open
1268
+ var prev = document.getElementById('preview');
1269
+ if (prev.classList.contains('hidden')) togglePreview();
1270
+ // Mark active row
1271
+ document.querySelectorAll('.row.active').forEach(function(r){ r.classList.remove('active'); });
1272
+ var row = document.querySelector('.row[data-path="' + cssEscape(path) + '"]');
1273
+ if (row) row.classList.add('active');
1274
+ // Clear any pending change-mark for this file since the user is acknowledging it
1275
+ if (!isReload && state.marks[path]) clearMark(path);
1276
+
1277
+ state.currentFile = path;
1278
+ state.editing = false;
1279
+ state.showSource = false;
1280
+ document.getElementById('pPath').textContent = path;
1281
+ document.getElementById('pInd').classList.remove('show');
1282
+ document.getElementById('pEdit').style.display = 'none';
1283
+ document.getElementById('pSave').style.display = 'none';
1284
+ document.getElementById('pCancel').style.display = 'none';
1285
+ document.getElementById('pSrc').style.display = 'none';
1286
+ document.getElementById('pReload').style.display = 'inline-block';
1287
+ document.getElementById('pedit').classList.remove('show');
1288
+ document.getElementById('pview').classList.remove('hide');
1289
+
1290
+ fetch('/api/file?path=' + encodeURIComponent(path)).then(function(r){return r.json();}).then(function(d){
1291
+ if (d.error) {
1292
+ document.getElementById('pview').innerHTML = '<div id="empty"><div class="lg">&#9888;</div>' + esc(d.error) + '</div>';
1293
+ document.getElementById('pview').className = '';
1294
+ return;
1222
1295
  }
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);
1296
+ state.fileOnDisk = d.content || '';
1297
+ var ext = (path.split('.').pop() || '').toLowerCase();
1298
+ var view = document.getElementById('pview');
1299
+ if (d.binary) {
1300
+ view.className = '';
1301
+ if (/^(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(ext)) {
1302
+ view.innerHTML = '<img src="/api/file/raw?path=' + encodeURIComponent(path) + '" alt="' + esc(path) + '">';
1303
+ } else {
1304
+ view.innerHTML = '<div id="empty"><div class="lg">&#128190;</div>Binary file (' + d.size + ' bytes)</div>';
1305
+ }
1306
+ document.getElementById('pEdit').style.display = 'none';
1307
+ } else if (ext === 'md' || ext === 'markdown') {
1308
+ view.className = '';
1309
+ try {
1310
+ marked.setOptions({ breaks: false, gfm: true });
1311
+ view.innerHTML = marked.parse(d.content || '');
1312
+ view.querySelectorAll('pre code').forEach(function(b){ try { hljs.highlightElement(b); } catch(e){} });
1313
+ } catch(e) {
1314
+ view.innerHTML = '<pre>' + esc(d.content || '') + '</pre>';
1230
1315
  }
1316
+ document.getElementById('pEdit').style.display = 'inline-block';
1317
+ } else if (ext === 'html' || ext === 'htm') {
1318
+ renderHtmlPreview(d.content || '');
1319
+ document.getElementById('pEdit').style.display = 'inline-block';
1320
+ document.getElementById('pSrc').style.display = 'inline-block';
1321
+ document.getElementById('pSrc').textContent = 'Source';
1322
+ } else if (isTextLikeExt(ext) || d.text) {
1323
+ view.className = 'code';
1324
+ var langClass = ext ? ' class="language-' + esc(ext) + '"' : '';
1325
+ view.innerHTML = '<pre><code' + langClass + '>' + esc(d.content || '') + '</code></pre>';
1326
+ try { hljs.highlightElement(view.querySelector('code')); } catch(e){}
1327
+ document.getElementById('pEdit').style.display = 'inline-block';
1328
+ } else {
1329
+ view.className = '';
1330
+ view.innerHTML = '<pre>' + esc(d.content || '') + '</pre>';
1331
+ document.getElementById('pEdit').style.display = 'inline-block';
1231
1332
  }
1333
+ if (isReload) showToast('Reloaded ' + path);
1334
+ }).catch(function(e){ showToast('Read failed: ' + e.message, 'err'); });
1335
+ };
1232
1336
 
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
- }
1337
+ function renderHtmlPreview(content) {
1338
+ var view = document.getElementById('pview');
1339
+ view.className = '';
1340
+ view.innerHTML = '';
1341
+ var iframe = document.createElement('iframe');
1342
+ iframe.className = 'html-preview';
1343
+ iframe.setAttribute('sandbox', 'allow-same-origin allow-popups');
1344
+ iframe.srcdoc = content;
1345
+ view.appendChild(iframe);
1346
+ }
1347
+
1348
+ function renderHtmlSource(content) {
1349
+ var view = document.getElementById('pview');
1350
+ view.className = 'code';
1351
+ view.innerHTML = '<pre><code class="language-html">' + esc(content) + '</code></pre>';
1352
+ try { hljs.highlightElement(view.querySelector('code')); } catch(e){}
1353
+ }
1354
+
1355
+ window.toggleSource = function() {
1356
+ if (!state.currentFile) return;
1357
+ state.showSource = !state.showSource;
1358
+ var btn = document.getElementById('pSrc');
1359
+ if (state.showSource) {
1360
+ renderHtmlSource(state.fileOnDisk || '');
1361
+ btn.textContent = 'Rendered';
1362
+ } else {
1363
+ renderHtmlPreview(state.fileOnDisk || '');
1364
+ btn.textContent = 'Source';
1365
+ }
1366
+ };
1246
1367
 
1247
- function updateThinkBlock(text) {
1248
- if (thinkTextEl) thinkTextEl.innerHTML = renderMarkdown(text);
1249
- }
1368
+ window.reloadPreview = function() { if (state.currentFile) openFile(state.currentFile, true); };
1369
+ window.closePreview = function() {
1370
+ state.currentFile = null; state.editing = false;
1371
+ document.getElementById('pview').innerHTML = '<div id="empty"><div class="lg">&#128196;</div>Open a file from the sidebar.</div>';
1372
+ document.getElementById('pview').className = '';
1373
+ document.getElementById('pPath').textContent = 'No file open';
1374
+ document.getElementById('pEdit').style.display = 'none';
1375
+ document.getElementById('pSave').style.display = 'none';
1376
+ document.getElementById('pCancel').style.display = 'none';
1377
+ document.getElementById('pSrc').style.display = 'none';
1378
+ document.getElementById('pReload').style.display = 'none';
1379
+ document.getElementById('pedit').classList.remove('show');
1380
+ document.getElementById('pview').classList.remove('hide');
1381
+ };
1382
+ window.startEdit = function() {
1383
+ if (!state.currentFile) return;
1384
+ state.editing = true;
1385
+ document.getElementById('pedit').value = state.fileOnDisk;
1386
+ document.getElementById('pedit').classList.add('show');
1387
+ document.getElementById('pview').classList.add('hide');
1388
+ document.getElementById('pEdit').style.display = 'none';
1389
+ document.getElementById('pSave').style.display = 'inline-block';
1390
+ document.getElementById('pCancel').style.display = 'inline-block';
1391
+ };
1392
+ window.cancelEdit = function() {
1393
+ state.editing = false;
1394
+ document.getElementById('pedit').classList.remove('show');
1395
+ document.getElementById('pview').classList.remove('hide');
1396
+ document.getElementById('pEdit').style.display = 'inline-block';
1397
+ document.getElementById('pSave').style.display = 'none';
1398
+ document.getElementById('pCancel').style.display = 'none';
1399
+ document.getElementById('pInd').classList.remove('show');
1400
+ };
1401
+ window.saveEdit = function() {
1402
+ if (!state.currentFile) return;
1403
+ var content = document.getElementById('pedit').value;
1404
+ fetch('/api/file', {
1405
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1406
+ body: JSON.stringify({ path: state.currentFile, content: content })
1407
+ }).then(function(r){return r.json();}).then(function(d){
1408
+ if (d.error) { showToast('Save failed: ' + d.error, 'err'); return; }
1409
+ showToast('Saved ' + state.currentFile);
1410
+ state.editing = false;
1411
+ openFile(state.currentFile, false);
1412
+ }).catch(function(e){ showToast('Save failed: ' + e.message, 'err'); });
1413
+ };
1250
1414
 
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
- }
1415
+ // ─── Config tab ──────────────────────────────────────────────
1416
+ window.reloadConfig = function() {
1417
+ fetch('/api/config').then(function(r){return r.json();}).then(function(d){
1418
+ document.getElementById('cfgJson').value = JSON.stringify(d.config || {}, null, 2);
1419
+ renderQuickConfig(d.config || {});
1420
+ }).catch(function(e){ showToast('Config read failed: ' + e.message, 'err'); });
1421
+ };
1422
+ window.saveConfig = function() {
1423
+ var raw = document.getElementById('cfgJson').value;
1424
+ var parsed;
1425
+ try { parsed = JSON.parse(raw); }
1426
+ catch(e) { showToast('Invalid JSON: ' + e.message, 'err'); return; }
1427
+ fetch('/api/config', {
1428
+ method: 'POST', headers: {'Content-Type':'application/json'},
1429
+ body: JSON.stringify({ config: parsed })
1430
+ }).then(function(r){return r.json();}).then(function(d){
1431
+ if (d.error) { showToast('Save failed: ' + d.error, 'err'); return; }
1432
+ showToast('Config saved');
1433
+ renderQuickConfig(parsed);
1434
+ });
1435
+ };
1261
1436
 
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
- }
1437
+ function renderQuickConfig(cfg) {
1438
+ var host = document.getElementById('cfgQuickBody');
1439
+ host.innerHTML = '';
1440
+ function add(html) { host.insertAdjacentHTML('beforeend', html); }
1441
+ add('<label>Default model</label><input type="text" id="qDefMod" placeholder="auto" value="' + esc(cfg.defaultModel || '') + '">');
1442
+ add('<label>Default agent</label><input type="text" id="qDefAgent" placeholder="(none)" value="' + esc(cfg.defaultAgent || '') + '">');
1443
+ add('<label>Context limit (tokens, blank = model default)</label><input type="number" id="qCtxLim" value="' + esc(cfg.contextLimit == null ? '' : cfg.contextLimit) + '">');
1444
+ add('<label>Tool round limit</label><input type="number" id="qToolRnd" value="' + esc(cfg.toolRoundLimit != null ? cfg.toolRoundLimit : 40) + '">');
1445
+ add('<div class="toggle-row"><span>Summary phases</span><div class="switch ' + (cfg.summaryPhases ? 'on' : '') + '" id="qSumPh"></div></div>');
1446
+ add('<label>Summary trigger %</label><input type="number" id="qSumTr" value="' + esc(cfg.summarizeTriggerPercent != null ? cfg.summarizeTriggerPercent : 65) + '">');
1447
+ add('<div class="toggle-row"><span>Debug mode</span><div class="switch ' + (cfg.debug ? 'on' : '') + '" id="qDebug"></div></div>');
1448
+ add('<div class="toggle-row"><span>Auto-attach files</span><div class="switch ' + (cfg.autoAttach !== false ? 'on' : '') + '" id="qAutoAtt"></div></div>');
1449
+ add('<div class="row-btns"><button class="primary" onclick="saveQuickConfig()">Apply quick changes</button></div>');
1450
+
1451
+ function bindSwitch(id) {
1452
+ var el = document.getElementById(id);
1453
+ if (el) el.addEventListener('click', function(){ el.classList.toggle('on'); });
1454
+ }
1455
+ bindSwitch('qSumPh'); bindSwitch('qDebug'); bindSwitch('qAutoAtt');
1456
+ }
1457
+
1458
+ window.saveQuickConfig = function() {
1459
+ var current;
1460
+ try { current = JSON.parse(document.getElementById('cfgJson').value || '{}'); }
1461
+ catch(e) { current = {}; }
1462
+ function val(id) { var el = document.getElementById(id); return el ? el.value : ''; }
1463
+ function on(id) { var el = document.getElementById(id); return el && el.classList.contains('on'); }
1464
+ var v;
1465
+ v = val('qDefMod').trim(); current.defaultModel = v || null;
1466
+ v = val('qDefAgent').trim(); current.defaultAgent = v || null;
1467
+ v = val('qCtxLim').trim(); current.contextLimit = v === '' ? null : parseInt(v, 10);
1468
+ v = val('qToolRnd').trim(); current.toolRoundLimit = v === '' ? 40 : parseInt(v, 10);
1469
+ v = val('qSumTr').trim(); current.summarizeTriggerPercent = v === '' ? 65 : parseInt(v, 10);
1470
+ current.summaryPhases = on('qSumPh');
1471
+ current.debug = on('qDebug');
1472
+ current.autoAttach = on('qAutoAtt');
1473
+ document.getElementById('cfgJson').value = JSON.stringify(current, null, 2);
1474
+ saveConfig();
1475
+ };
1325
1476
 
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
- });
1477
+ // ─── Agents & Skills tabs ────────────────────────────────────
1478
+ function loadAgents() {
1479
+ fetch('/api/agents').then(function(r){return r.json();}).then(function(d){
1480
+ var host = document.getElementById('agentsList');
1481
+ host.innerHTML = '';
1482
+ if (!d.agents || d.agents.length === 0) {
1483
+ host.innerHTML = '<div class="pane-section"><p>No agents yet. Create one with <code>/newagent</code>.</p></div>';
1484
+ return;
1399
1485
  }
1486
+ d.agents.forEach(function(a){
1487
+ var div = document.createElement('div');
1488
+ div.className = 'item';
1489
+ div.innerHTML = '<div class="ti">' + esc(a.name) + '</div>' +
1490
+ (a.description ? '<div class="ds">' + esc(a.description) + '</div>' : '');
1491
+ div.addEventListener('click', function(){
1492
+ openFile(a.path);
1493
+ sendCmd('/' + a.key);
1494
+ });
1495
+ host.appendChild(div);
1496
+ });
1497
+ });
1498
+ }
1400
1499
 
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();
1500
+ function loadSkills() {
1501
+ fetch('/api/skills').then(function(r){return r.json();}).then(function(d){
1502
+ var host = document.getElementById('skillsList');
1503
+ host.innerHTML = '';
1504
+ if (!d.skills || d.skills.length === 0) {
1505
+ host.innerHTML = '<div class="pane-section"><p>No skills yet. Create one with <code>/newskill</code>.</p></div>';
1506
+ return;
1422
1507
  }
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)';
1508
+ d.skills.forEach(function(s){
1509
+ var div = document.createElement('div');
1510
+ div.className = 'item';
1511
+ div.innerHTML = '<div class="ti">' + esc(s.name) + '</div>' +
1512
+ (s.description ? '<div class="ds">' + esc(s.description) + '</div>' : '');
1513
+ div.addEventListener('click', function(){ openFile(s.path); });
1514
+ host.appendChild(div);
1515
+ });
1435
1516
  });
1436
1517
  }
1437
- function sendQuick(text) { inputEl.value = text; send(); }
1438
- function stopGeneration() {
1439
- fetch('/api/stop', {method: 'POST'});
1440
- }
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;
1450
- }
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();
1457
- }
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();
1471
- }
1472
1518
 
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
- }
1489
- }
1490
- return html;
1519
+ // ─── Quick actions: upload + voice record ────────────────────
1520
+
1521
+ function uploadBlob(blob, filename, targetDir) {
1522
+ return fetch('/api/upload', {
1523
+ method: 'POST',
1524
+ headers: {
1525
+ 'Content-Type': blob.type || 'application/octet-stream',
1526
+ 'X-Filename': encodeURIComponent(filename),
1527
+ 'X-Target-Dir': encodeURIComponent(targetDir || 'uploads'),
1528
+ },
1529
+ body: blob,
1530
+ }).then(function(r){ return r.json(); }).then(function(d){
1531
+ if (d.error) throw new Error(d.error);
1532
+ return d.path;
1533
+ });
1491
1534
  }
1492
1535
 
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;
1536
+ window.pickAndUpload = function() {
1537
+ var inp = document.getElementById('qaFile');
1538
+ inp.value = '';
1539
+ inp.onchange = function() {
1540
+ var files = Array.from(inp.files || []);
1541
+ if (!files.length) return;
1542
+ uploadFileList(files, 'uploads');
1543
+ };
1544
+ inp.click();
1545
+ };
1546
+
1547
+ async function uploadFileList(files, targetDir) {
1548
+ var paths = [];
1549
+ for (var i = 0; i < files.length; i++) {
1550
+ var f = files[i];
1551
+ showToast('Uploading ' + f.name + '…');
1552
+ try {
1553
+ var p = await uploadBlob(f, f.name, targetDir);
1554
+ paths.push(p);
1555
+ } catch (e) {
1556
+ showToast('Upload failed: ' + e.message, 'err');
1501
1557
  }
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;
1558
+ }
1559
+ if (paths.length) {
1560
+ loadTree();
1561
+ // Send "@path1 @path2 " to terminal so user can keep typing
1562
+ if (ws && ws.readyState === 1) {
1563
+ ws.send(paths.map(function(p){ return '@' + p; }).join(' ') + ' ');
1507
1564
  }
1508
- parts.push({type: 'thinking', content: remaining.substring(s + 7, e), done: true});
1509
- remaining = remaining.substring(e + 8);
1565
+ showToast(paths.length + ' file' + (paths.length > 1 ? 's' : '') + ' attached');
1566
+ term.focus();
1510
1567
  }
1511
- return parts;
1512
1568
  }
1513
1569
 
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;
1538
- }
1539
- if (inCode) { codeLines.push(line); continue; }
1540
-
1541
- if (trimmed === '') {
1542
- if (inList) { html += '</ul>'; inList = false; }
1543
- html += '<br>';
1544
- continue;
1545
- }
1546
-
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; }
1570
+ // ─── Drag-drop on terminal area ──────────────────────────────
1571
+ (function setupDropZone(){
1572
+ var center = document.getElementById('center');
1573
+ var ov = document.getElementById('dropOverlay');
1574
+ var depth = 0;
1575
+ function show(){ ov.classList.add('on'); }
1576
+ function hide(){ ov.classList.remove('on'); depth = 0; }
1577
+ center.addEventListener('dragenter', function(e){
1578
+ if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
1579
+ e.preventDefault(); depth++; show();
1580
+ });
1581
+ center.addEventListener('dragover', function(e){
1582
+ if (!e.dataTransfer || !e.dataTransfer.types || e.dataTransfer.types.indexOf('Files') < 0) return;
1583
+ e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
1584
+ });
1585
+ center.addEventListener('dragleave', function(e){
1586
+ depth--; if (depth <= 0) hide();
1587
+ });
1588
+ center.addEventListener('drop', function(e){
1589
+ if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) { hide(); return; }
1590
+ e.preventDefault(); hide();
1591
+ uploadFileList(Array.from(e.dataTransfer.files), 'uploads');
1592
+ });
1593
+ })();
1553
1594
 
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; }
1595
+ // ─── Audio recording (16 kHz mono WAV for Whisper) ───────────
1596
+ var recState = null;
1559
1597
 
1560
- if (isLi) {
1561
- if (!inList) { html += '<ul>'; inList = true; }
1562
- html += '<li>' + inlineFmt(esc(trimmed.slice(2))) + '</li>';
1563
- continue;
1564
- }
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;
1570
- }
1598
+ window.toggleRecord = async function() {
1599
+ if (recState) return stopRecording();
1600
+ await startRecording();
1601
+ };
1571
1602
 
1572
- html += '<p>' + inlineFmt(esc(line)) + '</p>';
1603
+ async function startRecording() {
1604
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
1605
+ showToast('Microphone API not available (use HTTPS or localhost)', 'err');
1606
+ return;
1573
1607
  }
1574
- if (inCode) html += '<pre><code>' + esc(codeLines.join(NL)) + '</code></pre>';
1575
- if (inList) html += '</ul>';
1576
- return html;
1577
- }
1578
-
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;
1589
- }
1608
+ try {
1609
+ var stream = await navigator.mediaDevices.getUserMedia({
1610
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
1611
+ });
1612
+ var Ctx = window.AudioContext || window.webkitAudioContext;
1613
+ var ctx = new Ctx({ sampleRate: 16000 });
1614
+ // Resume in case of autoplay policy
1615
+ if (ctx.state === 'suspended') { try { await ctx.resume(); } catch(_){} }
1616
+ var src = ctx.createMediaStreamSource(stream);
1617
+ var proc = ctx.createScriptProcessor(4096, 1, 1);
1618
+ var chunks = [];
1619
+ proc.onaudioprocess = function(e) {
1620
+ var d = e.inputBuffer.getChannelData(0);
1621
+ chunks.push(new Float32Array(d));
1622
+ };
1623
+ src.connect(proc);
1624
+ proc.connect(ctx.destination);
1625
+ var startedAt = Date.now();
1626
+ recState = { stream: stream, ctx: ctx, src: src, proc: proc, chunks: chunks, sr: ctx.sampleRate, startedAt: startedAt };
1627
+ document.getElementById('qaRec').classList.add('on');
1628
+ document.getElementById('recDot').classList.add('on');
1629
+ document.getElementById('recTime').classList.add('on');
1630
+ recState.timer = setInterval(function(){
1631
+ var sec = Math.floor((Date.now() - startedAt) / 1000);
1632
+ var m = Math.floor(sec / 60), s = sec % 60;
1633
+ document.getElementById('recTime').textContent = m + ':' + (s < 10 ? '0' : '') + s;
1634
+ }, 250);
1635
+ showToast('Recording… click again to stop');
1636
+ } catch (e) {
1637
+ showToast('Mic permission: ' + e.message, 'err');
1590
1638
  }
1591
- return result;
1592
1639
  }
1593
1640
 
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;
1641
+ async function stopRecording() {
1642
+ var r = recState; if (!r) return;
1643
+ recState = null;
1644
+ document.getElementById('qaRec').classList.remove('on');
1645
+ document.getElementById('recDot').classList.remove('on');
1646
+ document.getElementById('recTime').classList.remove('on');
1647
+ document.getElementById('recTime').textContent = '';
1648
+ clearInterval(r.timer);
1649
+ try { r.proc.disconnect(); } catch(_){}
1650
+ try { r.src.disconnect(); } catch(_){}
1651
+ try { r.stream.getTracks().forEach(function(t){ t.stop(); }); } catch(_){}
1652
+ try { await r.ctx.close(); } catch(_){}
1653
+
1654
+ var len = 0; for (var i = 0; i < r.chunks.length; i++) len += r.chunks[i].length;
1655
+ if (len < r.sr / 4) { showToast('Too short (< 250 ms)', 'warn'); return; }
1656
+ var merged = new Float32Array(len);
1657
+ var off = 0;
1658
+ for (var j = 0; j < r.chunks.length; j++) { merged.set(r.chunks[j], off); off += r.chunks[j].length; }
1659
+ var wav = encodeWAV(merged, r.sr);
1660
+ var stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
1661
+ showToast('Uploading recording…');
1662
+ try {
1663
+ var rel = await uploadBlob(new Blob([wav], { type: 'audio/wav' }),
1664
+ 'rec-' + stamp + '.wav',
1665
+ '.sapper/voice/incoming');
1666
+ loadTree();
1667
+ sendCmd('/voice file ' + rel);
1668
+ showToast('Sent to Sapper for transcription');
1669
+ } catch (e) {
1670
+ showToast('Upload failed: ' + e.message, 'err');
1604
1671
  }
1605
- return r;
1606
1672
  }
1607
1673
 
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;');
1674
+ function encodeWAV(samples, sampleRate) {
1675
+ var bytesPerSample = 2;
1676
+ var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
1677
+ var view = new DataView(buffer);
1678
+ function writeStr(o, s) { for (var i = 0; i < s.length; i++) view.setUint8(o + i, s.charCodeAt(i)); }
1679
+ writeStr(0, 'RIFF');
1680
+ view.setUint32(4, 36 + samples.length * bytesPerSample, true);
1681
+ writeStr(8, 'WAVE');
1682
+ writeStr(12, 'fmt ');
1683
+ view.setUint32(16, 16, true);
1684
+ view.setUint16(20, 1, true); // PCM
1685
+ view.setUint16(22, 1, true); // mono
1686
+ view.setUint32(24, sampleRate, true);
1687
+ view.setUint32(28, sampleRate * bytesPerSample, true);
1688
+ view.setUint16(32, bytesPerSample, true);
1689
+ view.setUint16(34, 16, true);
1690
+ writeStr(36, 'data');
1691
+ view.setUint32(40, samples.length * bytesPerSample, true);
1692
+ var o = 44;
1693
+ for (var i = 0; i < samples.length; i++) {
1694
+ var s = Math.max(-1, Math.min(1, samples[i]));
1695
+ view.setInt16(o, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
1696
+ o += 2;
1697
+ }
1698
+ return buffer;
1611
1699
  }
1612
1700
 
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);
1632
- }
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);
1647
- }
1701
+ window.sendOpenPrompt = async function() {
1702
+ var v = await showModal({
1703
+ title: 'Open file in Sapper',
1704
+ label: 'Path',
1705
+ placeholder: 'src/index.ts',
1706
+ okLabel: 'Open',
1648
1707
  });
1649
- }
1708
+ if (v == null || !v.trim()) return;
1709
+ sendCmd('/open ' + v.trim());
1710
+ };
1650
1711
 
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
- });
1712
+ // ─── Boot ────────────────────────────────────────────────────
1713
+ connectPty();
1714
+ connectEvents();
1715
+ loadTree();
1716
+ </script>
1717
+ </body>
1718
+ </html>`;
1664
1719
  }
1665
1720
 
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
- }
1721
+ // ─── HTTP routes ─────────────────────────────────────────────────
1681
1722
 
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
- });
1723
+ function json(res, data, code = 200) {
1724
+ res.writeHead(code, { 'Content-Type': 'application/json' });
1725
+ res.end(JSON.stringify(data));
1690
1726
  }
1691
1727
 
1692
- function cancelEdit() { if (editingFilePath) viewFile(editingFilePath); }
1693
-
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');
1724
- }
1728
+ function readReqJSON(req) {
1729
+ return new Promise((resolve) => {
1730
+ let body = ''; let size = 0;
1731
+ req.on('data', (c) => { size += c.length; if (size > 5 * 1024 * 1024) { req.destroy(); resolve({ _err: 'too large' }); return; } body += c; });
1732
+ req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
1733
+ req.on('error', () => resolve({}));
1734
+ });
1725
1735
  }
1726
1736
 
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); } };
1737
+ function listEntries(dirPath) {
1738
+ try {
1739
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1740
+ return entries
1741
+ .filter(e => !IGNORE_NAMES.has(e.name))
1742
+ .map(e => ({ name: e.name, isDir: e.isDirectory() }))
1743
+ .sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1)));
1744
+ } catch { return []; }
1747
1745
  }
1748
1746
 
1749
- // ─── Escape modal on Escape key ────────────────────────────
1750
- document.addEventListener('keydown', function(e) {
1751
- if (e.key === 'Escape') closeModal();
1752
- });
1753
-
1754
- init();
1755
- </script>
1756
- </body>
1757
- </html>`;
1747
+ function looksBinary(buf) {
1748
+ const len = Math.min(buf.length, 4096);
1749
+ let nonText = 0;
1750
+ for (let i = 0; i < len; i++) {
1751
+ const c = buf[i];
1752
+ if (c === 0) return true;
1753
+ if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c >= 127) nonText++;
1754
+ }
1755
+ return nonText / Math.max(len, 1) > 0.3;
1758
1756
  }
1759
1757
 
1760
- // ─── HTTP Server ───────────────────────────────────────────
1761
-
1762
1758
  const server = http.createServer(async (req, res) => {
1763
- req.socket.setNoDelay(true);
1764
- const url = new URL(req.url, `http://localhost:${PORT}`);
1759
+ try {
1760
+ const url = new URL(req.url, 'http://localhost');
1761
+ const path = url.pathname;
1762
+
1763
+ // Pages
1764
+ if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
1765
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1766
+ res.end(buildHTML());
1767
+ return;
1768
+ }
1769
+ if (req.method === 'GET' && path === '/health') return json(res, { ok: true, cwd: workingDir });
1770
+
1771
+ // ── Tree
1772
+ if (req.method === 'GET' && path === '/api/tree') {
1773
+ const rel = url.searchParams.get('path') || '';
1774
+ const abs = safePath(rel);
1775
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1776
+ return json(res, { path: rel, entries: listEntries(abs) });
1777
+ }
1765
1778
 
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
- }
1779
+ // ── File read (text)
1780
+ if (req.method === 'GET' && path === '/api/file') {
1781
+ const rel = url.searchParams.get('path') || '';
1782
+ const abs = safePath(rel);
1783
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1784
+ try {
1785
+ const stat = fs.statSync(abs);
1786
+ if (stat.isDirectory()) return json(res, { error: 'is a directory' }, 400);
1787
+ if (stat.size > 2 * 1024 * 1024) return json(res, { error: 'file too large (>2MB)', size: stat.size, binary: true }, 200);
1788
+ const buf = fs.readFileSync(abs);
1789
+ if (looksBinary(buf)) return json(res, { binary: true, size: stat.size });
1790
+ return json(res, { content: buf.toString('utf8'), size: stat.size });
1791
+ } catch (e) { return json(res, { error: e.message }, 500); }
1792
+ }
1772
1793
 
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
- }
1794
+ // ── File raw (images)
1795
+ if (req.method === 'GET' && path === '/api/file/raw') {
1796
+ const rel = url.searchParams.get('path') || '';
1797
+ const abs = safePath(rel);
1798
+ if (!abs) { res.writeHead(400); return res.end('invalid path'); }
1799
+ try {
1800
+ const ext = (rel.split('.').pop() || '').toLowerCase();
1801
+ 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';
1802
+ const buf = fs.readFileSync(abs);
1803
+ res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'no-store' });
1804
+ return res.end(buf);
1805
+ } catch (e) { res.writeHead(500); return res.end(e.message); }
1806
+ }
1783
1807
 
1784
- // ── API: agents ──
1785
- if (req.method === 'GET' && url.pathname === '/api/agents') {
1786
- json(res, { agents: loadAgents() });
1787
- return;
1788
- }
1808
+ // ── File write
1809
+ if (req.method === 'POST' && path === '/api/file') {
1810
+ const body = await readReqJSON(req);
1811
+ const abs = safePath(body.path);
1812
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1813
+ try {
1814
+ ensureDir(dirname(abs));
1815
+ fs.writeFileSync(abs, body.content == null ? '' : String(body.content));
1816
+ return json(res, { ok: true });
1817
+ } catch (e) { return json(res, { error: e.message }, 500); }
1818
+ }
1789
1819
 
1790
- // ── API: skills ──
1791
- if (req.method === 'GET' && url.pathname === '/api/skills') {
1792
- json(res, { skills: loadSkills() });
1793
- return;
1794
- }
1820
+ // ── Create file or folder
1821
+ if (req.method === 'POST' && path === '/api/fs/new') {
1822
+ const body = await readReqJSON(req);
1823
+ const abs = safePath(body.path);
1824
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1825
+ try {
1826
+ if (fs.existsSync(abs)) return json(res, { error: 'already exists' }, 409);
1827
+ if (body.kind === 'folder') {
1828
+ fs.mkdirSync(abs, { recursive: true });
1829
+ } else {
1830
+ ensureDir(dirname(abs));
1831
+ fs.writeFileSync(abs, body.content == null ? '' : String(body.content));
1832
+ }
1833
+ return json(res, { ok: true, path: body.path });
1834
+ } catch (e) { return json(res, { error: e.message }, 500); }
1835
+ }
1795
1836
 
1796
- // ── API: info ──
1797
- if (req.method === 'GET' && url.pathname === '/api/info') {
1798
- json(res, { cwd: workingDir, version: getVersion() });
1799
- return;
1800
- }
1837
+ // ── Rename / move
1838
+ if (req.method === 'POST' && path === '/api/fs/rename') {
1839
+ const body = await readReqJSON(req);
1840
+ const from = safePath(body.from);
1841
+ const to = safePath(body.to);
1842
+ if (!from || !to) return json(res, { error: 'invalid path' }, 400);
1843
+ try {
1844
+ if (!fs.existsSync(from)) return json(res, { error: 'source not found' }, 404);
1845
+ if (fs.existsSync(to)) return json(res, { error: 'destination exists' }, 409);
1846
+ ensureDir(dirname(to));
1847
+ fs.renameSync(from, to);
1848
+ return json(res, { ok: true });
1849
+ } catch (e) { return json(res, { error: e.message }, 500); }
1850
+ }
1801
1851
 
1802
- // ── API: sessions list ──
1803
- if (req.method === 'GET' && url.pathname === '/api/sessions') {
1804
- json(res, { sessions: listSessions() });
1805
- return;
1806
- }
1852
+ // ── Duplicate
1853
+ if (req.method === 'POST' && path === '/api/fs/duplicate') {
1854
+ const body = await readReqJSON(req);
1855
+ const from = safePath(body.from);
1856
+ if (!from) return json(res, { error: 'invalid path' }, 400);
1857
+ try {
1858
+ if (!fs.existsSync(from)) return json(res, { error: 'source not found' }, 404);
1859
+ // build target name: foo.txt -> foo copy.txt, foo copy.txt -> foo copy 2.txt
1860
+ const dir = dirname(from);
1861
+ const base = from.slice(dir.length + 1);
1862
+ const dot = base.lastIndexOf('.');
1863
+ const stem = (dot > 0) ? base.slice(0, dot) : base;
1864
+ const ext = (dot > 0) ? base.slice(dot) : '';
1865
+ let target = '';
1866
+ for (let i = 0; i < 1000; i++) {
1867
+ const suffix = (i === 0) ? ' copy' : (' copy ' + (i + 1));
1868
+ const candidate = dir + '/' + stem + suffix + ext;
1869
+ if (!fs.existsSync(candidate)) { target = candidate; break; }
1870
+ }
1871
+ if (!target) return json(res, { error: 'too many copies' }, 500);
1872
+ fs.cpSync(from, target, { recursive: true });
1873
+ return json(res, { ok: true, path: relative(workingDir, target).split(sep).join('/') });
1874
+ } catch (e) { return json(res, { error: e.message }, 500); }
1875
+ }
1807
1876
 
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);
1877
+ // ── Delete (move to .sapper/.trash for safety)
1878
+ if (req.method === 'POST' && path === '/api/fs/delete') {
1879
+ const body = await readReqJSON(req);
1880
+ const abs = safePath(body.path);
1881
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1882
+ if (abs === workingDir) return json(res, { error: 'cannot delete workspace root' }, 400);
1883
+ try {
1884
+ if (!fs.existsSync(abs)) return json(res, { error: 'not found' }, 404);
1885
+ if (body.hard) {
1886
+ fs.rmSync(abs, { recursive: true, force: true });
1887
+ } else {
1888
+ const trashDir = join(SAPPER_DIR, '.trash');
1889
+ ensureDir(trashDir);
1890
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1891
+ const name = body.path.split('/').pop() || 'item';
1892
+ const target = join(trashDir, stamp + '__' + name);
1893
+ fs.renameSync(abs, target);
1894
+ }
1895
+ return json(res, { ok: true });
1896
+ } catch (e) { return json(res, { error: e.message }, 500); }
1814
1897
  }
1815
- json(res, { ok: true });
1816
- return;
1817
- }
1818
1898
 
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;
1899
+ // ── Reveal in OS file manager
1900
+ if (req.method === 'POST' && path === '/api/fs/reveal') {
1901
+ const body = await readReqJSON(req);
1902
+ const abs = safePath(body.path);
1903
+ if (!abs) return json(res, { error: 'invalid path' }, 400);
1904
+ try {
1905
+ if (process.platform === 'darwin') spawn('open', ['-R', abs]);
1906
+ else if (process.platform === 'win32') spawn('explorer', ['/select,' + abs]);
1907
+ else spawn('xdg-open', [dirname(abs)]);
1908
+ return json(res, { ok: true });
1909
+ } catch (e) { return json(res, { error: e.message }, 500); }
1910
+ }
1911
+
1912
+ // ── Upload (raw body; headers carry filename + target dir)
1913
+ if (req.method === 'POST' && path === '/api/upload') {
1914
+ try {
1915
+ let name = decodeURIComponent(req.headers['x-filename'] || 'upload.bin');
1916
+ let dir = decodeURIComponent(req.headers['x-target-dir'] || 'uploads');
1917
+ // sanitize filename (strip slashes), keep extension
1918
+ name = name.replace(/[\\/:*?"<>|]/g, '_').slice(0, 200) || 'upload.bin';
1919
+ dir = dir.replace(/^[\\/]+/, '');
1920
+ const absDir = safePath(dir);
1921
+ if (!absDir) return json(res, { error: 'invalid target dir' }, 400);
1922
+ ensureDir(absDir);
1923
+ let target = join(absDir, name);
1924
+ // de-dupe if exists
1925
+ if (fs.existsSync(target)) {
1926
+ const dot = name.lastIndexOf('.');
1927
+ const stem = dot > 0 ? name.slice(0, dot) : name;
1928
+ const ext = dot > 0 ? name.slice(dot) : '';
1929
+ for (let i = 1; i < 1000; i++) {
1930
+ const cand = join(absDir, stem + '-' + i + ext);
1931
+ if (!fs.existsSync(cand)) { target = cand; break; }
1932
+ }
1832
1933
  }
1833
- }
1934
+ const ws = fs.createWriteStream(target);
1935
+ let size = 0; let aborted = false;
1936
+ const MAX = 50 * 1024 * 1024;
1937
+ req.on('data', (c) => {
1938
+ size += c.length;
1939
+ if (size > MAX && !aborted) {
1940
+ aborted = true;
1941
+ ws.destroy();
1942
+ try { fs.unlinkSync(target); } catch {}
1943
+ json(res, { error: 'upload too large (>50MB)' }, 413);
1944
+ req.destroy();
1945
+ }
1946
+ });
1947
+ req.pipe(ws);
1948
+ await new Promise((resolve, reject) => {
1949
+ ws.on('finish', resolve);
1950
+ ws.on('error', reject);
1951
+ req.on('error', reject);
1952
+ });
1953
+ if (aborted) return;
1954
+ const rel = relative(workingDir, target).split(sep).join('/');
1955
+ return json(res, { ok: true, path: rel, size });
1956
+ } catch (e) { return json(res, { error: e.message }, 500); }
1834
1957
  }
1835
- json(res, { session });
1836
- return;
1837
- }
1838
1958
 
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
- }
1959
+ // ── Config read/write
1960
+ if (req.method === 'GET' && path === '/api/config') {
1961
+ return json(res, { config: readJSON(CONFIG_FILE, {}), path: relative(workingDir, CONFIG_FILE) });
1962
+ }
1963
+ if (req.method === 'POST' && path === '/api/config') {
1964
+ const body = await readReqJSON(req);
1965
+ try {
1966
+ ensureDir(SAPPER_DIR);
1967
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(body.config || {}, null, 2));
1968
+ return json(res, { ok: true });
1969
+ } catch (e) { return json(res, { error: e.message }, 500); }
1970
+ }
1846
1971
 
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
- }
1972
+ // ── Agents / Skills
1973
+ if (req.method === 'GET' && path === '/api/agents') return json(res, { agents: listMdDir(AGENTS_DIR) });
1974
+ if (req.method === 'GET' && path === '/api/skills') return json(res, { skills: listMdDir(SKILLS_DIR) });
1854
1975
 
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;
1976
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
1977
+ res.end('Not found');
1978
+ } catch (e) {
1979
+ json(res, { error: e.message }, 500);
1861
1980
  }
1981
+ });
1862
1982
 
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;
1869
- }
1983
+ // ─── WebSockets: /ws (pty) and /events (fs watcher) ──────────────
1870
1984
 
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
- }
1985
+ const wssPty = new WebSocketServer({ noServer: true });
1986
+ const wssEvents = new WebSocketServer({ noServer: true });
1878
1987
 
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;
1988
+ server.on('upgrade', (req, sock, head) => {
1989
+ const url = new URL(req.url, 'http://localhost');
1990
+ if (url.pathname === '/ws') {
1991
+ wssPty.handleUpgrade(req, sock, head, (ws) => wssPty.emit('connection', ws, req));
1992
+ } else if (url.pathname === '/events') {
1993
+ wssEvents.handleUpgrade(req, sock, head, (ws) => wssEvents.emit('connection', ws, req));
1994
+ } else {
1995
+ sock.destroy();
1885
1996
  }
1997
+ });
1886
1998
 
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
- }
1999
+ function spawnSapper(cols, rows) {
2000
+ return ptySpawn(process.execPath, [SAPPER_BIN], {
2001
+ name: 'xterm-256color',
2002
+ cols: cols || 100, rows: rows || 30,
2003
+ cwd: workingDir,
2004
+ env: { ...process.env, TERM: 'xterm-256color', FORCE_COLOR: '1', COLORTERM: 'truecolor' },
2005
+ });
2006
+ }
1893
2007
 
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;
1904
- }
2008
+ wssPty.on('connection', (ws) => {
2009
+ dbg('pty client connected');
2010
+ let pty = null; let initialized = false;
1905
2011
 
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; }
1911
- 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;
2012
+ function start(cols, rows) {
2013
+ if (pty) { try { pty.kill(); } catch {} }
2014
+ try { pty = spawnSapper(cols, rows); }
2015
+ catch (e) {
2016
+ console.error('[ui] spawn failed:', e.message);
2017
+ try { ws.send(Buffer.from('\x1b[31mFailed to spawn sapper: ' + e.message + '\x1b[0m\r\n', 'utf8')); } catch {}
2018
+ return;
2019
+ }
2020
+ dbg('pty pid=' + pty.pid + ' ' + cols + 'x' + rows);
2021
+ pty.onData((d) => { if (ws.readyState === ws.OPEN) ws.send(Buffer.from(d, 'utf8')); });
2022
+ pty.onExit(({ exitCode, signal }) => {
2023
+ dbg('pty exit code=' + exitCode);
2024
+ if (ws.readyState === ws.OPEN) { try { ws.send(JSON.stringify({ type: 'exit', code: exitCode, signal })); } catch {} }
2025
+ });
2026
+ try { ws.send(JSON.stringify({ type: 'cwd', path: workingDir })); } catch {}
1917
2027
  }
1918
2028
 
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
- }
2029
+ ws.on('message', (raw, isBinary) => {
2030
+ const str = raw.toString('utf8');
2031
+ if (!isBinary && str.startsWith('{')) {
2032
+ try {
2033
+ const m = JSON.parse(str);
2034
+ if (m.type === 'init') { if (!initialized) { initialized = true; start(m.cols, m.rows); } return; }
2035
+ if (m.type === 'resize' && pty) { try { pty.resize(m.cols || 100, m.rows || 30); } catch {} return; }
2036
+ if (m.type === 'restart') { initialized = true; start(100, 30); return; }
2037
+ } catch {}
2038
+ }
2039
+ if (pty) pty.write(str);
2040
+ });
1926
2041
 
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;
2042
+ ws.on('close', () => { if (pty) { try { pty.kill(); } catch {} pty = null; } });
2043
+ });
2044
+
2045
+ // ── FS watcher: broadcast to all /events clients ─────────────────
2046
+
2047
+ let watcher = null;
2048
+ const eventsClients = new Set();
2049
+ const recentEvents = new Map(); // path -> timestamp (dedupe burst events)
2050
+ const knownPaths = new Set(); // paths we have seen exist (for create vs delete detection)
2051
+ const recentActivity = []; // last N classified events for late-joining clients
2052
+
2053
+ function classifyEvent(rawEvent, rel, abs) {
2054
+ // fs.watch only gives 'rename' or 'change'
2055
+ const exists = fs.existsSync(abs);
2056
+ if (rawEvent === 'change') return exists ? 'modified' : 'deleted';
2057
+ // 'rename' = created, deleted, or moved-in/out
2058
+ if (!exists) return 'deleted';
2059
+ return knownPaths.has(rel) ? 'modified' : 'created';
2060
+ }
2061
+
2062
+ function seedKnownPaths(dir, rel = '') {
2063
+ try {
2064
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
2065
+ if (IGNORE_NAMES.has(ent.name)) continue;
2066
+ const sub = rel ? rel + '/' + ent.name : ent.name;
2067
+ knownPaths.add(sub);
2068
+ if (ent.isDirectory() && knownPaths.size < 20000) {
2069
+ seedKnownPaths(join(dir, ent.name), sub);
1945
2070
  }
1946
2071
  }
1947
- resetChat();
1948
- json(res, { ok: true });
1949
- return;
1950
- }
2072
+ } catch {}
2073
+ }
1951
2074
 
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;
2075
+ function startWatcher() {
2076
+ seedKnownPaths(workingDir);
2077
+ try {
2078
+ watcher = fs.watch(workingDir, { recursive: true }, (event, filename) => {
2079
+ if (!filename) return;
2080
+ // Normalize to forward slashes, skip ignored
2081
+ const rel = filename.split(sep).join('/');
2082
+ const top = rel.split('/')[0];
2083
+ if (IGNORE_NAMES.has(top)) return;
2084
+ const now = Date.now();
2085
+ const last = recentEvents.get(rel) || 0;
2086
+ if (now - last < 250) return; // dedupe
2087
+ recentEvents.set(rel, now);
2088
+ if (recentEvents.size > 500) { // bounded
2089
+ const cutoff = now - 10000;
2090
+ for (const [k, t] of recentEvents) if (t < cutoff) recentEvents.delete(k);
2091
+ }
2092
+ const abs = pathResolve(workingDir, rel);
2093
+ const kind = classifyEvent(event, rel, abs);
2094
+ if (kind === 'deleted') knownPaths.delete(rel);
2095
+ else knownPaths.add(rel);
2096
+ let isDir = false;
2097
+ try { isDir = fs.statSync(abs).isDirectory(); } catch {}
2098
+ const enriched = { event, kind, path: rel, isDir, ts: now };
2099
+ // remember for new clients (cap at 50)
2100
+ recentActivity.push(enriched);
2101
+ if (recentActivity.length > 50) recentActivity.shift();
2102
+ const payload = JSON.stringify(enriched);
2103
+ for (const c of eventsClients) {
2104
+ if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
2105
+ }
2106
+ });
2107
+ dbg('fs.watch started on', workingDir);
2108
+ } catch (e) {
2109
+ console.error('[ui] fs.watch failed:', e.message);
1958
2110
  }
2111
+ }
1959
2112
 
1960
- // ── API: stop generation ──
1961
- if (req.method === 'POST' && url.pathname === '/api/stop') {
1962
- abortFlag = true;
1963
- json(res, { ok: true });
1964
- return;
2113
+ wssEvents.on('connection', (ws) => {
2114
+ eventsClients.add(ws);
2115
+ dbg('events client connected (total=' + eventsClients.size + ')');
2116
+ // Replay last activity so the new tab sees recent changes
2117
+ if (recentActivity.length) {
2118
+ try { ws.send(JSON.stringify({ type: 'activity-replay', items: recentActivity.slice(-25) })); } catch {}
1965
2119
  }
2120
+ if (lastStats) { try { ws.send(lastStats); } catch {} }
2121
+ ws.on('close', () => { eventsClients.delete(ws); });
2122
+ });
1966
2123
 
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();
2124
+ // ── System stats poller (RAM + GPU on macOS) ─────────────────────
1984
2125
 
2126
+ let lastStats = null;
2127
+ let statsTimer = null;
2128
+ let lastCpuSample = null;
2129
+
2130
+ function broadcastEvents(payload) {
2131
+ for (const c of eventsClients) {
2132
+ if (c.readyState === c.OPEN) { try { c.send(payload); } catch {} }
2133
+ }
2134
+ }
2135
+
2136
+ function runCmd(cmd, args, timeoutMs = 1500) {
2137
+ return new Promise((resolve) => {
2138
+ let out = ''; let done = false;
1985
2139
  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;
2140
+ const p = spawn(cmd, args);
2141
+ const t = setTimeout(() => { if (!done) { done = true; try { p.kill(); } catch {} resolve(''); } }, timeoutMs);
2142
+ p.stdout.on('data', (d) => { out += d.toString(); });
2143
+ p.on('error', () => { if (!done) { done = true; clearTimeout(t); resolve(''); } });
2144
+ p.on('close', () => { if (!done) { done = true; clearTimeout(t); resolve(out); } });
2145
+ } catch { resolve(''); }
2146
+ });
2147
+ }
2148
+
2149
+ async function readMemMac() {
2150
+ // Parse vm_stat. On Apple Silicon page size = 16384, on Intel = 4096.
2151
+ const out = await runCmd('vm_stat', []);
2152
+ if (!out) return null;
2153
+ const pageMatch = out.match(/page size of (\d+)/);
2154
+ const pageSize = pageMatch ? parseInt(pageMatch[1], 10) : 4096;
2155
+ function pages(name) {
2156
+ const m = out.match(new RegExp(name + '[^\\d]+(\\d+)'));
2157
+ return m ? parseInt(m[1], 10) : 0;
2158
+ }
2159
+ const wired = pages('Pages wired down');
2160
+ const active = pages('Pages active');
2161
+ const compressed = pages('Pages occupied by compressor');
2162
+ const used = (wired + active + compressed) * pageSize;
2163
+ const total = os.totalmem();
2164
+ return { used, total, percent: total > 0 ? Math.round((used / total) * 100) : 0 };
2165
+ }
2166
+
2167
+ async function readGPUMac() {
2168
+ // ioreg dumps GPU performance stats including "Device Utilization %"
2169
+ const out = await runCmd('ioreg', ['-r', '-c', 'IOAccelerator', '-d', '1', '-w', '0']);
2170
+ if (!out) return null;
2171
+ const m = out.match(/"Device Utilization %"\s*=\s*(\d+)/);
2172
+ if (!m) return null;
2173
+ const memMatch = out.match(/"In use system memory"\s*=\s*(\d+)/);
2174
+ return {
2175
+ percent: parseInt(m[1], 10),
2176
+ memBytes: memMatch ? parseInt(memMatch[1], 10) : null,
2177
+ };
2178
+ }
2179
+
2180
+ function readCPU() {
2181
+ const cpus = os.cpus();
2182
+ let idle = 0; let total = 0;
2183
+ for (const c of cpus) {
2184
+ for (const k of Object.keys(c.times)) total += c.times[k];
2185
+ idle += c.times.idle;
2003
2186
  }
2187
+ if (!lastCpuSample) { lastCpuSample = { idle, total }; return null; }
2188
+ const di = idle - lastCpuSample.idle;
2189
+ const dt = total - lastCpuSample.total;
2190
+ lastCpuSample = { idle, total };
2191
+ if (dt <= 0) return null;
2192
+ return { percent: Math.max(0, Math.min(100, Math.round((1 - di / dt) * 100))), cores: cpus.length };
2193
+ }
2194
+
2195
+ async function pollStats() {
2196
+ const isMac = process.platform === 'darwin';
2197
+ const [mem, gpu] = await Promise.all([
2198
+ isMac ? readMemMac() : null,
2199
+ isMac ? readGPUMac() : null,
2200
+ ]);
2201
+ const cpu = readCPU();
2202
+ const payload = JSON.stringify({
2203
+ type: 'stats', ts: Date.now(),
2204
+ platform: process.platform,
2205
+ cpu, mem, gpu,
2206
+ totalMem: os.totalmem(),
2207
+ });
2208
+ lastStats = payload;
2209
+ if (eventsClients.size > 0) broadcastEvents(payload);
2210
+ }
2004
2211
 
2005
- // ── 404 ──
2006
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
2007
- res.end('Not found');
2008
- });
2212
+ function startStatsPoll() {
2213
+ pollStats(); // first sample primes cpu delta
2214
+ statsTimer = setInterval(pollStats, 1500);
2215
+ }
2009
2216
 
2010
- // ─── Launch ────────────────────────────────────────────────
2217
+ // ─── Launch ──────────────────────────────────────────────────────
2011
2218
 
2012
2219
  server.listen(PORT, () => {
2013
2220
  const url = `http://localhost:${PORT}`;
2014
- console.log(`\n ⚡ Sapper UI running at ${url}\n`);
2221
+ console.log(`\n \x1b[36m⚡ Sapper Web\x1b[0m running at \x1b[1m${url}\x1b[0m`);
2222
+ console.log(` Working dir: ${workingDir}\n`);
2223
+ startWatcher();
2224
+ startStatsPoll();
2225
+
2226
+ if (process.env.SAPPER_UI_NO_OPEN) return;
2015
2227
 
2016
2228
  const browsers = [
2017
2229
  ['open', ['-na', 'Google Chrome', '--args', `--app=${url}`, '--new-window']],
@@ -2019,16 +2231,15 @@ server.listen(PORT, () => {
2019
2231
  ['open', ['-na', 'Brave Browser', '--args', `--app=${url}`]],
2020
2232
  ['open', [url]],
2021
2233
  ];
2022
-
2023
- let opened = false;
2024
2234
  for (const [cmd, args] of browsers) {
2025
- if (opened) break;
2026
2235
  try {
2027
- const proc = spawn(cmd, args, { stdio: 'ignore', detached: true });
2028
- proc.unref();
2029
- proc.on('error', () => {});
2030
- opened = true;
2236
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
2237
+ p.unref();
2238
+ p.on('error', () => {});
2239
+ break;
2031
2240
  } catch {}
2032
2241
  }
2033
- if (!opened) console.log(` Open manually: ${url}`);
2034
2242
  });
2243
+
2244
+ process.on('SIGINT', () => { console.log('\nShutting down…'); try { watcher && watcher.close(); } catch {} process.exit(0); });
2245
+ process.on('SIGTERM', () => process.exit(0));