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.
- package/README.md +224 -158
- package/package.json +7 -3
- package/sapper-ui.mjs +2047 -1836
- package/sapper.mjs +1 -1
package/sapper-ui.mjs
CHANGED
|
@@ -1,2017 +1,2229 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Sapper
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
14
|
+
import os from 'os';
|
|
11
15
|
import { fileURLToPath } from 'url';
|
|
12
|
-
import { dirname, join, resolve,
|
|
13
|
-
import
|
|
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
|
|
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
|
-
|
|
32
|
+
const IGNORE_NAMES = new Set([
|
|
33
|
+
'.git', 'node_modules', '.next', 'dist', 'build', '.cache',
|
|
34
|
+
'.DS_Store', '__pycache__', '.venv', 'venv',
|
|
35
|
+
]);
|
|
25
36
|
|
|
26
|
-
|
|
37
|
+
const DEBUG = !!process.env.SAPPER_UI_DEBUG;
|
|
38
|
+
const dbg = (...a) => { if (DEBUG) console.log('[ui]', ...a); };
|
|
27
39
|
|
|
28
|
-
|
|
29
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
30
|
-
}
|
|
40
|
+
// ─── Path safety ─────────────────────────────────────────────────
|
|
31
41
|
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
75
|
+
out += c; i++;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
function
|
|
75
|
-
ensureDir(SKILLS_DIR);
|
|
76
|
-
const skills = {};
|
|
80
|
+
function readJSON(file, fallback = null) {
|
|
77
81
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
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
|
|
86
|
-
return skills;
|
|
88
|
+
} catch { return fallback; }
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
function ensureDir(d) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
|
|
90
92
|
|
|
91
|
-
// ───
|
|
92
|
-
const SAPPERIGNORE_FILE = '.sapperignore';
|
|
93
|
+
// ─── Markdown frontmatter (for agents/skills) ────────────────────
|
|
93
94
|
|
|
94
|
-
function
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
131
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
143
132
|
}
|
|
144
133
|
|
|
145
|
-
|
|
146
|
-
const resolved = resolve(workingDir, p || '.');
|
|
147
|
-
if (!resolved.startsWith(workingDir)) return null;
|
|
148
|
-
return resolved;
|
|
149
|
-
}
|
|
134
|
+
// ─── HTML page ───────────────────────────────────────────────────
|
|
150
135
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
/* ─── Body layout ─── */
|
|
217
|
+
#body { flex: 1; min-height: 0; min-width: 0; display: flex; overflow: hidden; }
|
|
341
218
|
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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">⚡ <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
|
-
|
|
350
|
-
|
|
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','')">🗎<sup>+</sup></button>
|
|
531
|
+
<button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">📁<sup>+</sup></button>
|
|
532
|
+
<button class="ftb" id="ftbAct" title="Show activity log" onclick="toggleActivity()">☉</button>
|
|
533
|
+
<span class="ftb-spacer"></span>
|
|
534
|
+
<button class="ftb" title="Clear change marks" onclick="clearAllMarks()">✕</button>
|
|
535
|
+
<button class="ftb" title="Refresh tree" onclick="loadTree()">↺</button>
|
|
536
|
+
<button class="ftb" title="Collapse all" onclick="collapseAll()">⇤</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">📎</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">🎤</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">📥</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()">×</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">📄</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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
const toolMatches = [...clean.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
|
|
630
|
+
var BT = String.fromCharCode(96);
|
|
364
631
|
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
645
|
+
function esc(s) {
|
|
646
|
+
if (s == null) return '';
|
|
647
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
648
|
+
.replace(/"/g,'"').replace(/'/g,''');
|
|
649
|
+
}
|
|
373
650
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
function
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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 ? ' ×' + 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
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
899
|
+
window.clearActivity = function() {
|
|
900
|
+
state.activity = [];
|
|
901
|
+
renderActivity();
|
|
902
|
+
};
|
|
483
903
|
|
|
484
|
-
function
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 '📁';
|
|
945
|
+
var ext = name.split('.').pop().toLowerCase();
|
|
946
|
+
if (['md','markdown'].indexOf(ext) >= 0) return '📝';
|
|
947
|
+
if (['png','jpg','jpeg','gif','svg','webp'].indexOf(ext) >= 0) return '📷';
|
|
948
|
+
if (['json','yml','yaml','toml'].indexOf(ext) >= 0) return '⚙';
|
|
949
|
+
return '📄';
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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] ? '▾' : '▸') : '';
|
|
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">●</span>' +
|
|
1002
|
+
'<span class="rmenu" title="Options">⋯</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
|
-
|
|
1009
|
+
if (entry.isDir) toggleDir(row, path);
|
|
1010
|
+
else openFile(path);
|
|
546
1011
|
});
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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 = '▸';
|
|
1042
|
+
} else {
|
|
1043
|
+
state.expanded[path] = true;
|
|
1044
|
+
row.querySelector('.chev').innerHTML = '▾';
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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()">✚ 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()">✚ 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()">✚ 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">📂</span> Browse Dir</div>
|
|
800
|
-
<div class="qa-btn" onclick="qaAction('read')"><span class="qa-icon">📄</span> Read File</div>
|
|
801
|
-
<div class="qa-btn" onclick="qaAction('write')"><span class="qa-icon">✎</span> Create File</div>
|
|
802
|
-
<div class="qa-btn" onclick="qaAction('search')"><span class="qa-icon">🔍</span> Search</div>
|
|
803
|
-
<div class="qa-btn" onclick="qaAction('shell')"><span class="qa-icon">▶</span> Terminal</div>
|
|
804
|
-
<div class="qa-btn" onclick="qaAction('mkdir')"><span class="qa-icon">📁</span> New Dir</div>
|
|
805
|
-
<div class="qa-btn" onclick="qaAction('review')"><span class="qa-icon">🔎</span> Review</div>
|
|
806
|
-
<div class="qa-btn" onclick="qaAction('scan')"><span class="qa-icon">📊</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()">📂 Files</button>
|
|
824
|
-
</div>
|
|
825
|
-
<div class="chat" id="chat">
|
|
826
|
-
<div class="welcome" id="welcome">
|
|
827
|
-
<div class="logo">⚡</div>
|
|
828
|
-
<h2>Sapper</h2>
|
|
829
|
-
<p>AI assistant with full filesystem access. Ask anything — code, write, analyze, build.</p>
|
|
830
|
-
<div class="chips">
|
|
831
|
-
<div class="chip" onclick="sendQuick('What files are in this project?')">📁 Explore project</div>
|
|
832
|
-
<div class="chip" onclick="sendQuick('Help me fix bugs in the codebase')">🐛 Find bugs</div>
|
|
833
|
-
<div class="chip" onclick="sendQuick('Write a README for this project')">📝 Write docs</div>
|
|
834
|
-
<div class="chip" onclick="sendQuick('What are my tasks for today?')">📋 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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
var
|
|
888
|
-
|
|
889
|
-
var
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
var
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
var
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
function
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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: '🗎 New file inside', fn: function(){ newItemPrompt('file', path); } });
|
|
1083
|
+
items.push({ label: '📁 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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
// ───
|
|
949
|
-
function
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
item.innerHTML = '<span class="s-icon">💬</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('' + esc(s.id) + '')" title="Delete">✕</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
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1017
|
-
if (!
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1039
|
-
|
|
1040
|
-
var
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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">🤖</span>' +
|
|
1053
|
-
'<span class="s-label">' + esc(a.name) + '</span>' +
|
|
1054
|
-
'<button class="s-del" onclick="event.stopPropagation(); deleteAgent('' + esc(k) + '')" title="Delete">✕</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">📚</span>' +
|
|
1119
|
-
'<span class="s-label">' + esc(s.name) + '</span>' +
|
|
1120
|
-
'<button class="s-del" onclick="event.stopPropagation(); deleteSkill('' + esc(k) + '')" title="Delete">✕</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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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">⚠</div>' + esc(d.error) + '</div>';
|
|
1293
|
+
document.getElementById('pview').className = '';
|
|
1294
|
+
return;
|
|
1222
1295
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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">💾</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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
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">📄</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
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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('expanded')">' +
|
|
1354
|
-
'<span class="tc-icon">⚙</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">⌛</span>' +
|
|
1358
|
-
'<span class="tc-chevron">▶</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 ? '❌' : '✅';
|
|
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('expanded')">' +
|
|
1380
|
-
'<span class="tc-icon">' + (isErr ? '❌' : '✅') + '</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">▶</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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
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
|
|
1494
|
-
var
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
var
|
|
1498
|
-
if (
|
|
1499
|
-
|
|
1500
|
-
|
|
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
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
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
|
-
|
|
1509
|
-
|
|
1565
|
+
showToast(paths.length + ' file' + (paths.length > 1 ? 's' : '') + ' attached');
|
|
1566
|
+
term.focus();
|
|
1510
1567
|
}
|
|
1511
|
-
return parts;
|
|
1512
1568
|
}
|
|
1513
1569
|
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
var
|
|
1517
|
-
var
|
|
1518
|
-
var
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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
|
-
|
|
1555
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
|
1595
|
-
var r =
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
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
|
|
1609
|
-
|
|
1610
|
-
|
|
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
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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">⬅</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 ? '📂' : '📄';
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
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
|
-
|
|
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
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
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
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
-
|
|
1764
|
-
|
|
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
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
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
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
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
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1872
|
-
|
|
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
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
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
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
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
|
-
|
|
1948
|
-
|
|
1949
|
-
return;
|
|
1950
|
-
}
|
|
2072
|
+
} catch {}
|
|
2073
|
+
}
|
|
1951
2074
|
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
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
|
-
|
|
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
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
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
|
|
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
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
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));
|