codex-map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,1371 @@
1
+ const State = {
2
+ isLoading: true,
3
+ mode: 'global',
4
+ projectPath: '',
5
+ currentTab: 'overview',
6
+ statsRange: null,
7
+ theme: 'light',
8
+ scan: null,
9
+ analysis: null,
10
+ history: [],
11
+ sessions: [],
12
+ sessionDetail: null,
13
+ activeSessionId: null,
14
+ toolStats: null,
15
+ usageStats: null,
16
+ pluginData: null,
17
+ pinnedProjects: [],
18
+ projectStatuses: {},
19
+ rawSelectedPath: null,
20
+ rawFile: null,
21
+ rawEditMode: false,
22
+ rawDraft: '',
23
+ configDraft: '',
24
+ browserPath: null,
25
+ skillDraft: null
26
+ };
27
+
28
+ const TABS_GLOBAL = ['overview', 'stats', 'skills', 'sessions', 'config', 'plugins', 'raw'];
29
+ const TABS_PROJECT = ['map', 'overview', 'stats', 'skills', 'sessions', 'config', 'raw'];
30
+
31
+ const API = {
32
+ async get(url) {
33
+ const res = await fetch(url);
34
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
35
+ return res.json();
36
+ },
37
+ async send(url, method, body) {
38
+ const res = await fetch(url, {
39
+ method,
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify(body)
42
+ });
43
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
44
+ return res.json();
45
+ }
46
+ };
47
+
48
+ function escapeHtml(value) {
49
+ return String(value || '')
50
+ .replace(/&/g, '&')
51
+ .replace(/</g, '&lt;')
52
+ .replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;')
54
+ .replace(/'/g, '&#39;');
55
+ }
56
+
57
+ function showToast(title, body = '', tone = 'success') {
58
+ const stack = document.getElementById('toast-stack');
59
+ if (!stack) return;
60
+ const toast = document.createElement('div');
61
+ toast.className = `toast ${tone}`;
62
+ toast.innerHTML = `
63
+ <strong class="toast-title">${escapeHtml(title)}</strong>
64
+ ${body ? `<div class="toast-body">${escapeHtml(body)}</div>` : ''}
65
+ `;
66
+ stack.appendChild(toast);
67
+ requestAnimationFrame(() => toast.classList.add('visible'));
68
+ window.setTimeout(() => {
69
+ toast.classList.remove('visible');
70
+ window.setTimeout(() => toast.remove(), 180);
71
+ }, 2600);
72
+ }
73
+
74
+ async function runOperation(work, successTitle, successBody = '', errorTitle = 'Operation failed') {
75
+ try {
76
+ const result = await work();
77
+ if (successTitle) showToast(successTitle, successBody, 'success');
78
+ return result;
79
+ } catch (error) {
80
+ showToast(errorTitle, error.message || 'Unknown error', 'error');
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ function jsQuote(value) {
86
+ return JSON.stringify(String(value || ''));
87
+ }
88
+
89
+ function formatDate(value) {
90
+ if (!value) return '—';
91
+ try {
92
+ return new Date(value).toLocaleString();
93
+ } catch {
94
+ return value;
95
+ }
96
+ }
97
+
98
+ function formatNumber(value) {
99
+ return Number(value || 0).toLocaleString();
100
+ }
101
+
102
+ function currentTabs() {
103
+ return State.mode === 'project' ? TABS_PROJECT : TABS_GLOBAL;
104
+ }
105
+
106
+ function statusLabel(status) {
107
+ return ({ full: 'Full', partial: 'Partial', none: 'Empty', missing: 'Missing' }[status] || status || '—');
108
+ }
109
+
110
+ function getCurrentTree() {
111
+ return State.mode === 'project' ? State.scan?.project?.fileTree : State.scan?.global?.fileTree;
112
+ }
113
+
114
+ function isoDateOnly(value) {
115
+ return new Date(value).toISOString().slice(0, 10);
116
+ }
117
+
118
+ function defaultStatsRange() {
119
+ const end = new Date();
120
+ const start = new Date(end.getTime() - (29 * 24 * 60 * 60 * 1000));
121
+ return {
122
+ from: isoDateOnly(start),
123
+ to: isoDateOnly(end)
124
+ };
125
+ }
126
+
127
+ function statsPresetRange(preset) {
128
+ const now = new Date();
129
+ const end = new Date(now);
130
+ let start = new Date(now);
131
+
132
+ if (preset === '7d') {
133
+ start = new Date(end.getTime() - (6 * 24 * 60 * 60 * 1000));
134
+ } else if (preset === '30d') {
135
+ start = new Date(end.getTime() - (29 * 24 * 60 * 60 * 1000));
136
+ } else if (preset === '90d') {
137
+ start = new Date(end.getTime() - (89 * 24 * 60 * 60 * 1000));
138
+ } else if (preset === 'month') {
139
+ start = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), 1));
140
+ } else if (preset === 'all') {
141
+ const first = State.usageStats?.daily?.find(row => row.prompts || row.sessions || row.tools)?.date;
142
+ return first ? { from: first, to: isoDateOnly(end) } : defaultStatsRange();
143
+ }
144
+
145
+ return {
146
+ from: isoDateOnly(start),
147
+ to: isoDateOnly(end)
148
+ };
149
+ }
150
+
151
+ async function init() {
152
+ setLoading(true);
153
+ State.statsRange = defaultStatsRange();
154
+ const savedTheme = localStorage.getItem('codex-map-theme') || 'light';
155
+ applyTheme(savedTheme);
156
+ await loadPinnedProjects();
157
+ await loadCurrentView();
158
+ connectEvents();
159
+ setLoading(false);
160
+ }
161
+
162
+ async function loadCurrentView() {
163
+ try {
164
+ const projectQuery = State.mode === 'project' ? `?project=${encodeURIComponent(State.projectPath)}` : '';
165
+ const statsParams = new URLSearchParams();
166
+ if (State.mode === 'project') statsParams.set('project', State.projectPath);
167
+ if (State.statsRange?.from) statsParams.set('from', State.statsRange.from);
168
+ if (State.statsRange?.to) statsParams.set('to', State.statsRange.to);
169
+ const statsQuery = statsParams.toString() ? `?${statsParams.toString()}` : '';
170
+ State.scan = await API.get(`/api/scan${projectQuery}`);
171
+ State.history = (await API.get(`/api/history${projectQuery}`)).entries || [];
172
+ State.toolStats = await API.get(`/api/stats/tools${statsQuery}`);
173
+ State.usageStats = await API.get(`/api/stats/usage${statsQuery}`);
174
+ State.pluginData = await API.get('/api/plugins');
175
+
176
+ if (State.mode === 'project') {
177
+ State.analysis = await API.get(`/api/analyze?project=${encodeURIComponent(State.projectPath)}`);
178
+ } else {
179
+ State.analysis = null;
180
+ }
181
+
182
+ const sessionRes = await API.get(`/api/sessions${projectQuery}`);
183
+ State.sessions = sessionRes.sessions || [];
184
+ if (!State.activeSessionId || !State.sessions.find(item => item.id === State.activeSessionId)) {
185
+ State.activeSessionId = State.sessions[0]?.id || null;
186
+ }
187
+ State.sessionDetail = State.activeSessionId
188
+ ? await API.get(`/api/sessions/${encodeURIComponent(State.activeSessionId)}`)
189
+ : null;
190
+
191
+ State.configDraft = State.scan?.global?.config?.raw || '';
192
+ render();
193
+ } finally {
194
+ setLoading(false);
195
+ }
196
+ }
197
+
198
+ async function loadPinnedProjects() {
199
+ const res = await API.get('/api/pinned-projects');
200
+ State.pinnedProjects = res.projects || [];
201
+ await refreshProjectStatuses();
202
+ renderProjectList();
203
+ }
204
+
205
+ async function refreshProjectStatuses() {
206
+ const statuses = await Promise.all(State.pinnedProjects.map(async projectPath => {
207
+ try {
208
+ const res = await API.get(`/api/project-status?path=${encodeURIComponent(projectPath)}`);
209
+ return [projectPath, res.status];
210
+ } catch {
211
+ return [projectPath, 'missing'];
212
+ }
213
+ }));
214
+ State.projectStatuses = Object.fromEntries(statuses);
215
+ }
216
+
217
+ function applyTheme(theme) {
218
+ State.theme = theme;
219
+ document.documentElement.dataset.theme = theme;
220
+ localStorage.setItem('codex-map-theme', theme);
221
+ document.getElementById('hljs-light').disabled = theme === 'dark';
222
+ document.getElementById('hljs-dark').disabled = theme !== 'dark';
223
+ }
224
+
225
+ function toggleTheme() {
226
+ applyTheme(State.theme === 'light' ? 'dark' : 'light');
227
+ }
228
+
229
+ function selectGlobal() {
230
+ State.mode = 'global';
231
+ State.projectPath = '';
232
+ State.currentTab = 'overview';
233
+ State.activeSessionId = null;
234
+ loadCurrentView();
235
+ }
236
+
237
+ function selectProject(projectPath, tab = 'map') {
238
+ State.mode = 'project';
239
+ State.projectPath = projectPath;
240
+ State.currentTab = tab;
241
+ State.activeSessionId = null;
242
+ loadCurrentView();
243
+ }
244
+
245
+ async function addPinnedProject(projectPath) {
246
+ await runOperation(
247
+ () => API.send('/api/pinned-projects', 'POST', { path: projectPath }),
248
+ 'Project added',
249
+ projectPath
250
+ );
251
+ await loadPinnedProjects();
252
+ }
253
+
254
+ async function removePinnedProject(projectPath) {
255
+ await runOperation(
256
+ () => API.send('/api/pinned-projects', 'DELETE', { path: projectPath }),
257
+ 'Project removed',
258
+ projectPath
259
+ );
260
+ if (State.projectPath === projectPath) {
261
+ selectGlobal();
262
+ } else {
263
+ await loadPinnedProjects();
264
+ }
265
+ }
266
+
267
+ async function addPath() {
268
+ const input = document.getElementById('path-input');
269
+ const value = input.value.trim();
270
+ if (!value) return;
271
+ await addPinnedProject(value);
272
+ input.value = '';
273
+ }
274
+
275
+ function render() {
276
+ renderProjectList();
277
+ renderHero();
278
+ renderTabs();
279
+ renderContent();
280
+ document.getElementById('scan-meta').textContent = State.scan?.meta ? `Scan ${State.scan.meta.scanDurationMs}ms` : '';
281
+ }
282
+
283
+ function renderProjectList() {
284
+ const list = document.getElementById('project-list');
285
+ list.innerHTML = State.pinnedProjects.length ? State.pinnedProjects.map(projectPath => `
286
+ <div class="project-item ${State.projectPath === projectPath ? 'active' : ''}">
287
+ <button class="project-main" onclick='selectProject(${jsQuote(projectPath)})' title="${escapeHtml(projectPath)}">
288
+ <span class="status-pill ${escapeHtml(State.projectStatuses[projectPath] || 'none')}">${escapeHtml(statusLabel(State.projectStatuses[projectPath]))}</span>
289
+ <span class="project-name">${escapeHtml(projectPath.split('/').pop() || projectPath)}</span>
290
+ </button>
291
+ <button class="ghost" onclick='removePinnedProject(${jsQuote(projectPath)})'>×</button>
292
+ </div>
293
+ `).join('') : '<p class="muted">No pinned projects yet.</p>';
294
+ }
295
+
296
+ function renderHero() {
297
+ const heroTitle = document.getElementById('hero-title');
298
+ const heroSubtitle = document.getElementById('hero-subtitle');
299
+ const metrics = document.getElementById('hero-metrics');
300
+
301
+ if (State.mode === 'project') {
302
+ const project = State.analysis?.project;
303
+ heroTitle.textContent = State.scan?.project?.projectName || 'Project';
304
+ heroSubtitle.textContent = 'Project-local instructions, skills, MCP, sessions, raw files, and trust state in one place.';
305
+ metrics.innerHTML = [
306
+ metricCard('Trust', project?.trustLevel || 'unlisted'),
307
+ metricCard('Sessions', formatNumber(project?.sessionCount || 0)),
308
+ metricCard('Skills', formatNumber(State.scan?.project?.localSkills?.length || 0)),
309
+ metricCard('Status', statusLabel(project?.status))
310
+ ].join('');
311
+ return;
312
+ }
313
+
314
+ heroTitle.textContent = 'Your Codex setup, finally visible.';
315
+ heroSubtitle.textContent = 'Inspect ~/.codex, edit config and MCP, manage skills and plugins, browse sessions, and save raw files without leaving the dashboard.';
316
+ metrics.innerHTML = [
317
+ metricCard('Projects', formatNumber(State.scan?.global?.projects?.length || 0)),
318
+ metricCard('Skills', formatNumber(State.scan?.global?.skills?.length || 0)),
319
+ metricCard('Sessions', formatNumber(State.scan?.global?.sessionSummary?.total || 0)),
320
+ metricCard('Plugins', formatNumber(State.pluginData?.plugins?.length || 0))
321
+ ].join('');
322
+ }
323
+
324
+ function setLoading(isLoading) {
325
+ State.isLoading = isLoading;
326
+ document.body.classList.toggle('loading', isLoading);
327
+ document.getElementById('app-loader')?.classList.toggle('hidden', !isLoading);
328
+ }
329
+
330
+ function metricCard(label, value) {
331
+ return `<article class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></article>`;
332
+ }
333
+
334
+ function renderTabs() {
335
+ document.getElementById('tabs').innerHTML = currentTabs().map(tab => `
336
+ <button class="tab ${State.currentTab === tab ? 'active' : ''}" aria-pressed="${State.currentTab === tab}" onclick='setTab(${jsQuote(tab)})'>${escapeHtml(tab)}</button>
337
+ `).join('');
338
+ }
339
+
340
+ function setTab(tab) {
341
+ State.currentTab = tab;
342
+ renderTabs();
343
+ renderContent();
344
+ }
345
+
346
+ function renderContent() {
347
+ const html = {
348
+ map: renderMap(),
349
+ overview: renderOverview(),
350
+ stats: renderStats(),
351
+ skills: renderSkills(),
352
+ sessions: renderSessions(),
353
+ config: renderConfig(),
354
+ plugins: renderPlugins(),
355
+ raw: renderRaw()
356
+ }[State.currentTab] || '<p class="muted">Nothing here yet.</p>';
357
+
358
+ document.getElementById('content').innerHTML = html;
359
+ highlightCode();
360
+ }
361
+
362
+ function panel(title, body, actions = '') {
363
+ return `
364
+ <section class="panel">
365
+ <div class="panel-head">
366
+ <h3>${escapeHtml(title)}</h3>
367
+ ${actions}
368
+ </div>
369
+ <div class="panel-body">${body}</div>
370
+ </section>
371
+ `;
372
+ }
373
+
374
+ function renderMap() {
375
+ const project = State.analysis?.project;
376
+ const connections = State.analysis?.connections;
377
+ if (!project) return '<p class="muted">Project map unavailable.</p>';
378
+
379
+ return `
380
+ <div class="grid two">
381
+ ${panel('Global', `
382
+ <div class="kv"><span>Skills</span><strong>${formatNumber(connections?.global?.skills?.count || 0)}</strong></div>
383
+ <div class="kv"><span>MCP Servers</span><strong>${formatNumber(connections?.global?.mcp?.count || 0)}</strong></div>
384
+ <div class="kv"><span>Plugins</span><strong>${formatNumber(connections?.global?.plugins?.count || 0)}</strong></div>
385
+ `)}
386
+ ${panel('Project', `
387
+ <div class="kv"><span>AGENTS.md</span><strong>${project.hasAgentsMd ? 'Present' : 'Missing'}</strong></div>
388
+ <div class="kv"><span>.codex/</span><strong>${project.hasCodexDir ? 'Present' : 'Missing'}</strong></div>
389
+ <div class="kv"><span>.mcp.json</span><strong>${project.hasMcpJson ? 'Present' : 'Missing'}</strong></div>
390
+ <div class="kv"><span>Trust</span><strong>${escapeHtml(project.trustLevel || 'unlisted')}</strong></div>
391
+ <div class="kv"><span>Sessions</span><strong>${formatNumber(project.sessionCount || 0)}</strong></div>
392
+ `)}
393
+ </div>
394
+ ${project.warnings?.length ? panel('Warnings', `<ul class="plain-list">${project.warnings.map(item => `<li>${escapeHtml(item.message)}</li>`).join('')}</ul>`) : ''}
395
+ `;
396
+ }
397
+
398
+ function renderOverview() {
399
+ return State.mode === 'project' ? renderProjectOverview() : renderGlobalOverview();
400
+ }
401
+
402
+ function renderGlobalOverview() {
403
+ const global = State.scan?.global || {};
404
+ const config = global.config || {};
405
+ return `
406
+ <div class="grid two">
407
+ ${panel('Codex Home', `
408
+ <div class="kv"><span>Path</span><strong>${escapeHtml(State.scan?.meta?.globalPath || '—')}</strong></div>
409
+ <div class="kv"><span>Approvals Reviewer</span><strong>${escapeHtml(config.approvalsReviewer || '—')}</strong></div>
410
+ <div class="kv"><span>Personality</span><strong>${escapeHtml(config.personality || '—')}</strong></div>
411
+ <div class="kv"><span>Profiles</span><strong>${formatNumber(config.profiles?.length || 0)}</strong></div>
412
+ `)}
413
+ ${panel('Configured Projects', (global.projects || []).length ? `
414
+ <div class="chart-list">
415
+ ${global.projects.map(project => `
416
+ <div class="project-row">
417
+ <span>${escapeHtml(project.name)}</span>
418
+ <span>${escapeHtml(project.trustLevel || 'unlisted')}</span>
419
+ <span>${formatNumber(project.sessionCount || 0)} sessions</span>
420
+ </div>
421
+ `).join('')}
422
+ </div>
423
+ ` : '<p class="muted">No trusted projects found in config.toml.</p>')}
424
+ </div>
425
+ ${global.agentsMd ? panel('Global AGENTS.md', markdownCard(global.agentsMd.raw, true)) : ''}
426
+ `;
427
+ }
428
+
429
+ function renderProjectOverview() {
430
+ const project = State.scan?.project || {};
431
+ const analysis = State.analysis?.project || {};
432
+ return `
433
+ <div class="grid two">
434
+ ${panel('Project Snapshot', `
435
+ <div class="kv"><span>Path</span><strong>${escapeHtml(project.path || '—')}</strong></div>
436
+ <div class="kv"><span>Trust</span><strong>${escapeHtml(project.trustLevel || 'unlisted')}</strong></div>
437
+ <div class="kv"><span>Sessions</span><strong>${formatNumber(analysis.sessionCount || 0)}</strong></div>
438
+ <div class="kv"><span>Skills</span><strong>${formatNumber(project.localSkills?.length || 0)}</strong></div>
439
+ `)}
440
+ ${panel('Project Config', `
441
+ <div class="kv"><span>AGENTS.md</span><strong>${project.agentsMd ? 'Present' : 'Missing'}</strong></div>
442
+ <div class="kv"><span>.codex/</span><strong>${project.hasCodexDir ? 'Present' : 'Missing'}</strong></div>
443
+ <div class="kv"><span>MCP Servers</span><strong>${formatNumber(project.mcpJson?.servers?.length || 0)}</strong></div>
444
+ `)}
445
+ </div>
446
+ ${project.agentsMd ? panel('Project AGENTS.md', markdownCard(project.agentsMd.raw, true)) : ''}
447
+ `;
448
+ }
449
+
450
+ function renderStats() {
451
+ const usage = State.usageStats || { daily: [], topTools: [] };
452
+ const summary = buildStatsSummary(usage);
453
+ return `
454
+ <div class="stats-dashboard">
455
+ ${panel('Date Range', `
456
+ <div class="stats-range">
457
+ <label class="stats-field">
458
+ <span>From</span>
459
+ <input id="stats-from" type="date" value="${escapeHtml(State.statsRange?.from || '')}">
460
+ </label>
461
+ <label class="stats-field">
462
+ <span>To</span>
463
+ <input id="stats-to" type="date" value="${escapeHtml(State.statsRange?.to || '')}">
464
+ </label>
465
+ <div class="stats-range-actions">
466
+ <button class="btn btn-small" onclick="applyStatsRange()">Apply</button>
467
+ <button class="ghost" onclick="resetStatsRange()">Last 30 days</button>
468
+ </div>
469
+ </div>
470
+ <div class="stats-presets">
471
+ <button class="ghost" onclick="applyStatsPreset('7d')">7D</button>
472
+ <button class="ghost" onclick="applyStatsPreset('30d')">30D</button>
473
+ <button class="ghost" onclick="applyStatsPreset('90d')">90D</button>
474
+ <button class="ghost" onclick="applyStatsPreset('month')">This Month</button>
475
+ <button class="ghost" onclick="applyStatsPreset('all')">All Time</button>
476
+ </div>
477
+ `)}
478
+ <div class="stats-summary">
479
+ ${summary.map((item, index) => `
480
+ <article class="stat-tile ${index === 0 ? 'emphasis' : ''}">
481
+ <div class="stat-value">${escapeHtml(item.value)}</div>
482
+ <div class="stat-label">${escapeHtml(item.label)}</div>
483
+ ${item.sub ? `<div class="stat-sub">${escapeHtml(item.sub)}</div>` : ''}
484
+ </article>
485
+ `).join('')}
486
+ </div>
487
+ ${panel('Total Usage', usage.daily?.length ? usageChart(usage.daily) : '<p class="muted">No recent usage data.</p>')}
488
+ ${panel('Top Tools', usage.topTools?.length ? toolChart(usage.topTools) : '<p class="muted">No tool usage data.</p>')}
489
+ </div>
490
+ `;
491
+ }
492
+
493
+ function buildStatsSummary(usage) {
494
+ const daily = usage.daily || [];
495
+ const totalMessages = daily.reduce((sum, row) => sum + (row.prompts || 0), 0);
496
+ const totalTools = daily.reduce((sum, row) => sum + (row.tools || 0), 0);
497
+ const totalSessions = daily.reduce((sum, row) => sum + (row.sessions || 0), 0);
498
+ const activeDays = daily.filter(row => row.prompts || row.sessions || row.tools).length;
499
+ const longestDay = daily.reduce((best, row) => ((row.prompts || 0) > (best?.prompts || 0) ? row : best), null);
500
+ const firstDay = daily.find(row => row.prompts || row.sessions || row.tools);
501
+
502
+ return [
503
+ { label: 'Messages', value: formatNumber(totalMessages), sub: 'selected range' },
504
+ { label: 'Tool Calls', value: formatNumber(totalTools), sub: 'selected range' },
505
+ { label: 'Sessions', value: formatNumber(totalSessions), sub: 'selected range' },
506
+ { label: 'Active Days', value: formatNumber(activeDays), sub: usage.period || '' },
507
+ { label: 'Peak Day', value: formatNumber(longestDay?.prompts || 0), sub: longestDay?.date || '—' },
508
+ { label: 'First Active', value: firstDay?.date || '—', sub: firstDay ? 'within range' : '' }
509
+ ];
510
+ }
511
+
512
+ async function applyStatsRange() {
513
+ const from = document.getElementById('stats-from')?.value;
514
+ const to = document.getElementById('stats-to')?.value;
515
+ if (!from || !to) {
516
+ showToast('Invalid date range', 'Both start and end dates are required.', 'error');
517
+ return;
518
+ }
519
+ if (from > to) {
520
+ showToast('Invalid date range', 'Start date must be before end date.', 'error');
521
+ return;
522
+ }
523
+ State.statsRange = { from, to };
524
+ setLoading(true);
525
+ await runOperation(
526
+ () => loadCurrentView(),
527
+ 'Stats updated',
528
+ `${from} → ${to}`
529
+ );
530
+ }
531
+
532
+ async function resetStatsRange() {
533
+ State.statsRange = defaultStatsRange();
534
+ setLoading(true);
535
+ await runOperation(
536
+ () => loadCurrentView(),
537
+ 'Stats reset',
538
+ 'Showing last 30 days'
539
+ );
540
+ }
541
+
542
+ async function applyStatsPreset(preset) {
543
+ State.statsRange = statsPresetRange(preset);
544
+ setLoading(true);
545
+ await runOperation(
546
+ () => loadCurrentView(),
547
+ 'Stats updated',
548
+ `${State.statsRange.from} → ${State.statsRange.to}`
549
+ );
550
+ }
551
+
552
+ function usageChart(rows) {
553
+ const width = 980;
554
+ const height = 320;
555
+ const left = 56;
556
+ const right = 18;
557
+ const top = 18;
558
+ const bottom = 34;
559
+ const innerWidth = width - left - right;
560
+ const innerHeight = height - top - bottom;
561
+ const max = Math.max(...rows.map(row => Math.max(row.prompts, row.sessions, row.tools, 1)), 1);
562
+ const gridLines = 4;
563
+ const xStep = rows.length > 1 ? innerWidth / (rows.length - 1) : innerWidth;
564
+ const yFor = value => top + innerHeight - ((value / max) * innerHeight);
565
+ const makePath = key => rows.map((row, index) => {
566
+ const x = left + (index * xStep);
567
+ const y = yFor(row[key] || 0);
568
+ return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
569
+ }).join(' ');
570
+ const promptPath = makePath('prompts');
571
+ const sessionPath = makePath('sessions');
572
+ const toolPath = makePath('tools');
573
+ const areaPath = `${promptPath} L ${left + ((rows.length - 1) * xStep)} ${top + innerHeight} L ${left} ${top + innerHeight} Z`;
574
+ return `
575
+ <div class="line-chart">
576
+ <div class="chart-legend">
577
+ <span><i class="legend-dot prompts"></i>Messages</span>
578
+ <span><i class="legend-dot sessions"></i>Sessions</span>
579
+ <span><i class="legend-dot tools"></i>Tool Calls</span>
580
+ </div>
581
+ <div class="chart-surface">
582
+ <svg viewBox="0 0 ${width} ${height}" class="chart-svg" role="img" aria-label="Total usage over time">
583
+ ${Array.from({ length: gridLines + 1 }, (_, index) => {
584
+ const value = Math.round((max / gridLines) * (gridLines - index));
585
+ const y = top + ((innerHeight / gridLines) * index);
586
+ return `
587
+ <g>
588
+ <line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--panel-border)" stroke-width="1"></line>
589
+ <text x="${left - 10}" y="${y + 4}" text-anchor="end" fill="var(--muted)" font-size="11">${formatNumber(value)}</text>
590
+ </g>
591
+ `;
592
+ }).join('')}
593
+ <path d="${areaPath}" fill="rgba(102, 220, 194, 0.14)"></path>
594
+ <path d="${promptPath}" fill="none" stroke="#66dcc2" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"></path>
595
+ <path d="${sessionPath}" fill="none" stroke="#8f62d5" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"></path>
596
+ <path d="${toolPath}" fill="none" stroke="#dd9f68" stroke-width="2" stroke-dasharray="6 6" stroke-linejoin="round" stroke-linecap="round"></path>
597
+ ${rows.map((row, index) => {
598
+ const x = left + (index * xStep);
599
+ const promptY = yFor(row.prompts || 0);
600
+ const sessionY = yFor(row.sessions || 0);
601
+ return `
602
+ <g>
603
+ <circle cx="${x}" cy="${promptY}" r="3.5" fill="#66dcc2"></circle>
604
+ <circle cx="${x}" cy="${sessionY}" r="3" fill="#8f62d5"></circle>
605
+ <text x="${x}" y="${height - 10}" text-anchor="middle" fill="var(--muted)" font-size="11">${row.date.slice(5)}</text>
606
+ </g>
607
+ `;
608
+ }).join('')}
609
+ </svg>
610
+ </div>
611
+ <div class="chart-list">
612
+ ${rows.map(row => `<div class="kv"><span>${escapeHtml(row.date)}</span><strong>${formatNumber(row.prompts)} messages · ${formatNumber(row.sessions)} sessions · ${formatNumber(row.tools)} tools</strong></div>`).join('')}
613
+ </div>
614
+ </div>
615
+ `;
616
+ }
617
+
618
+ function toolChart(rows) {
619
+ const max = Math.max(...rows.map(row => row.count), 1);
620
+ return `
621
+ <div class="chart-list">
622
+ ${rows.map(row => `
623
+ <div class="tool-row">
624
+ <span>${escapeHtml(row.name)}</span>
625
+ <div class="tool-bar"><i style="width:${(row.count / max) * 100}%"></i></div>
626
+ <strong>${row.count}</strong>
627
+ </div>
628
+ `).join('')}
629
+ </div>
630
+ `;
631
+ }
632
+
633
+ function renderSkills() {
634
+ const globalSkills = State.scan?.global?.skills || [];
635
+ const localSkills = State.scan?.project?.localSkills || [];
636
+ const localSet = new Set(localSkills.map(skill => skill.name));
637
+
638
+ return `
639
+ <div class="grid two">
640
+ ${panel('Global Skills', globalSkills.length ? globalSkills.map(skill => skillCard(skill, localSet.has(skill.name) ? 'shared' : 'global', 'global')).join('') : '<p class="muted">No global skills found.</p>', `<button class="btn btn-small" onclick='newSkill("global")'>New global skill</button>`)}
641
+ ${panel('Project Skills', localSkills.length ? localSkills.map(skill => skillCard(skill, 'project', 'project')).join('') : '<p class="muted">No project-local skills found in .codex/skills.</p>', State.mode === 'project' ? `<button class="btn btn-small" onclick='newSkill("project")'>New project skill</button>` : '')}
642
+ </div>
643
+ `;
644
+ }
645
+
646
+ function skillCard(skill, tone, scope) {
647
+ const chips = [tone, ...(skill.meta?.allowedTools || []).slice(0, 3)]
648
+ .map(chip => `<span class="chip">${escapeHtml(chip)}</span>`).join('');
649
+
650
+ return `
651
+ <article class="skill-card">
652
+ <div class="skill-head">
653
+ <strong>${escapeHtml(skill.meta?.displayName || skill.name)}</strong>
654
+ <div class="chip-row">${chips}</div>
655
+ </div>
656
+ <p>${escapeHtml(skill.meta?.description || skill.excerpt || 'No description.')}</p>
657
+ <div class="chip-row">
658
+ <button class="btn btn-small" onclick='editSkill(${jsQuote(scope)}, ${jsQuote(skill.name)})'>Edit</button>
659
+ <button class="ghost" onclick='removeSkill(${jsQuote(scope)}, ${jsQuote(skill.name)})'>Delete</button>
660
+ </div>
661
+ ${skill.meta?.argumentHint ? `<small>Arguments: ${escapeHtml(skill.meta.argumentHint)}</small>` : ''}
662
+ </article>
663
+ `;
664
+ }
665
+
666
+ function renderSessions() {
667
+ const history = State.history || [];
668
+ return `
669
+ ${panel('Session Browser', State.sessions.length ? `
670
+ <div class="session-browser">
671
+ <div class="session-master">
672
+ ${State.sessions.map(session => `
673
+ <button class="session-card ${State.activeSessionId === session.id ? 'selected' : ''}" onclick='openSession(${jsQuote(session.id)})'>
674
+ <strong>${escapeHtml(session.title)}</strong>
675
+ <span>${escapeHtml(session.cwd || 'Unknown cwd')}</span>
676
+ <div class="chip-row">
677
+ <span class="chip">${formatNumber(session.messageCount)} msgs</span>
678
+ <span class="chip">${formatNumber(session.toolCallCount)} tools</span>
679
+ <span class="chip">${escapeHtml(formatDate(session.updatedAt))}</span>
680
+ </div>
681
+ </button>
682
+ `).join('')}
683
+ </div>
684
+ <div class="session-detail-wrap">
685
+ ${State.sessionDetail ? renderSessionDetail() : '<p class="muted">Select a session.</p>'}
686
+ </div>
687
+ </div>
688
+ ` : '<p class="muted">No sessions found for this scope.</p>', `<button class="btn btn-small" onclick='createSession()'>New session</button>`)}
689
+ ${panel('Prompt History', history.length ? `
690
+ <div class="history-list">
691
+ ${history.slice(0, 24).map(entry => `
692
+ <article class="history-item">
693
+ <small>${escapeHtml(formatDate(entry.timestamp))}</small>
694
+ <p>${escapeHtml(entry.text)}</p>
695
+ </article>
696
+ `).join('')}
697
+ </div>
698
+ ` : '<p class="muted">No prompt history found.</p>')}
699
+ `;
700
+ }
701
+
702
+ async function openSession(sessionId) {
703
+ State.activeSessionId = sessionId;
704
+ State.sessionDetail = await API.get(`/api/sessions/${encodeURIComponent(sessionId)}`);
705
+ renderContent();
706
+ }
707
+
708
+ function renderSessionDetail() {
709
+ const detail = State.sessionDetail;
710
+ const tools = Object.entries(detail.summary?.toolBreakdown || {})
711
+ .map(([name, count]) => `<span class="chip">${escapeHtml(name)} ${count}</span>`).join('');
712
+
713
+ return `
714
+ <div class="session-detail">
715
+ <div class="eyebrow">Session Detail</div>
716
+ <h2>${escapeHtml(detail.session.title || '(untitled session)')}</h2>
717
+ <div class="session-meta">
718
+ <div class="kv"><span>Session</span><strong>${escapeHtml(detail.session.id)}</strong></div>
719
+ <div class="kv"><span>Provider</span><strong>${escapeHtml(detail.session.modelProvider || '—')}</strong></div>
720
+ <div class="kv"><span>Path</span><strong>${escapeHtml(detail.session.cwd || '—')}</strong></div>
721
+ <div class="kv"><span>Updated</span><strong>${escapeHtml(formatDate(detail.session.updatedAt))}</strong></div>
722
+ </div>
723
+ <div class="panel-actions-inline">
724
+ <button class="btn btn-small" onclick='copyResumeCommand(${jsQuote(detail.session.id)})'>Copy resume command</button>
725
+ <button class="btn btn-small" onclick='renameSession(${jsQuote(detail.session.id)})'>Rename</button>
726
+ <button class="ghost" onclick='deleteSession(${jsQuote(detail.session.id)})'>Delete</button>
727
+ </div>
728
+ <div class="chip-row" style="margin:12px 0 16px;">
729
+ ${tools || '<span class="chip">No tool events</span>'}
730
+ </div>
731
+ <div class="timeline">
732
+ ${(detail.timeline || []).map(item => `
733
+ <article class="timeline-item ${item.kind}">
734
+ <small>${escapeHtml(formatDate(item.timestamp))}</small>
735
+ <strong>${escapeHtml(item.title)}</strong>
736
+ <pre>${escapeHtml(item.body || '')}</pre>
737
+ </article>
738
+ `).join('')}
739
+ </div>
740
+ </div>
741
+ `;
742
+ }
743
+
744
+ function copyResumeCommand(sessionId) {
745
+ const command = `codex resume ${sessionId}`;
746
+ const copyPromise = navigator.clipboard?.writeText
747
+ ? navigator.clipboard.writeText(command)
748
+ : Promise.reject(new Error('Clipboard API unavailable'));
749
+ runOperation(
750
+ () => copyPromise,
751
+ 'Resume command copied',
752
+ command,
753
+ 'Copy failed'
754
+ ).catch(() => {});
755
+ }
756
+
757
+ async function createSession() {
758
+ const title = window.prompt('Session title', 'New session');
759
+ if (!title) return;
760
+ const cwd = window.prompt('Working directory', State.projectPath || '/Volumes/Projects');
761
+ if (!cwd) return;
762
+ const detail = await runOperation(
763
+ () => API.send('/api/sessions', 'POST', { title, cwd }),
764
+ 'Session created',
765
+ title
766
+ );
767
+ State.activeSessionId = detail?.session?.id || null;
768
+ await loadCurrentView();
769
+ }
770
+
771
+ async function renameSession(sessionId) {
772
+ const current = State.sessionDetail?.session?.title || '';
773
+ const next = window.prompt('New session title', current);
774
+ if (!next || next === current) return;
775
+ await runOperation(
776
+ () => API.send(`/api/sessions/${encodeURIComponent(sessionId)}`, 'PUT', { title: next }),
777
+ 'Session renamed',
778
+ next
779
+ );
780
+ await loadCurrentView();
781
+ }
782
+
783
+ async function deleteSession(sessionId) {
784
+ if (!window.confirm('Delete this session and its local log file?')) return;
785
+ await runOperation(
786
+ async () => {
787
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
788
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
789
+ return res.json();
790
+ },
791
+ 'Session deleted',
792
+ sessionId
793
+ );
794
+ await loadCurrentView();
795
+ }
796
+
797
+ function renderConfig() {
798
+ return State.mode === 'project' ? renderProjectConfig() : renderGlobalConfig();
799
+ }
800
+
801
+ function renderGlobalConfig() {
802
+ const config = State.scan?.global?.config;
803
+ if (!config) return '<p class="muted">No ~/.codex/config.toml found.</p>';
804
+
805
+ return `
806
+ <div class="grid two">
807
+ ${panel('config.toml Summary', `
808
+ <div class="kv"><span>Approvals Reviewer</span><strong>${escapeHtml(config.approvalsReviewer || '—')}</strong></div>
809
+ <div class="kv"><span>Personality</span><strong>${escapeHtml(config.personality || '—')}</strong></div>
810
+ <div class="kv"><span>MCP Servers</span><strong>${formatNumber(config.mcpServers?.length || 0)}</strong></div>
811
+ <div class="kv"><span>Profiles</span><strong>${formatNumber(config.profiles?.length || 0)}</strong></div>
812
+ `, `<button class="btn btn-small" onclick='saveConfig()'>Save</button>`)}
813
+ ${panel('MCP Servers', config.mcpServers?.length ? config.mcpServers.map(server => `
814
+ <article class="mini-card">
815
+ <strong>${escapeHtml(server.name)}</strong>
816
+ <p>${escapeHtml(server.command || '—')} ${escapeHtml((server.args || []).join(' '))}</p>
817
+ <div class="chip-row">
818
+ <button class="btn btn-small" onclick='editMcpServer(${jsQuote(server.name)})'>Edit</button>
819
+ <button class="ghost" onclick='deleteMcpServer(${jsQuote(server.name)})'>Delete</button>
820
+ </div>
821
+ </article>
822
+ `).join('') : '<p class="muted">No MCP servers configured.</p>', `<button class="btn btn-small" onclick='createMcpServer()'>New MCP</button>`)}
823
+ </div>
824
+ ${panel('Raw config.toml', `<textarea id="config-editor" class="skill-textarea">${escapeHtml(State.configDraft || config.raw || '')}</textarea>`)}
825
+ `;
826
+ }
827
+
828
+ function renderProjectConfig() {
829
+ const project = State.scan?.project;
830
+ if (!project) return '<p class="muted">No project selected.</p>';
831
+
832
+ return `
833
+ <div class="grid two">
834
+ ${panel('Project Instructions', project.agentsMd ? markdownCard(project.agentsMd.raw, true) : '<p class="muted">No AGENTS.md found.</p>')}
835
+ ${panel('Project MCP', project.mcpJson ? codeBlock(project.mcpJson.raw, 'json') : '<p class="muted">No .mcp.json found.</p>')}
836
+ </div>
837
+ `;
838
+ }
839
+
840
+ async function saveConfig() {
841
+ const raw = document.getElementById('config-editor')?.value;
842
+ if (typeof raw !== 'string') return;
843
+ await runOperation(
844
+ () => API.send('/api/config', 'PUT', { raw }),
845
+ 'Config saved',
846
+ '~/.codex/config.toml'
847
+ );
848
+ await loadCurrentView();
849
+ }
850
+
851
+ async function createMcpServer() {
852
+ const name = window.prompt('MCP server name');
853
+ if (!name) return;
854
+ const command = window.prompt('Command', 'npx');
855
+ if (!command) return;
856
+ const argsText = window.prompt('Args (space-separated)', '') || '';
857
+ const cwd = window.prompt('Working directory (optional)', '') || '';
858
+ await runOperation(
859
+ () => API.send('/api/config/mcp', 'POST', {
860
+ name,
861
+ command,
862
+ args: argsText.split(/\s+/).filter(Boolean),
863
+ cwd: cwd || undefined
864
+ }),
865
+ 'MCP server created',
866
+ name
867
+ );
868
+ await loadCurrentView();
869
+ }
870
+
871
+ async function editMcpServer(name) {
872
+ const server = (State.scan?.global?.config?.mcpServers || []).find(item => item.name === name);
873
+ if (!server) return;
874
+ const newName = window.prompt('MCP server name', server.name);
875
+ if (!newName) return;
876
+ const command = window.prompt('Command', server.command || '');
877
+ if (!command) return;
878
+ const argsText = window.prompt('Args (space-separated)', (server.args || []).join(' ')) || '';
879
+ const cwd = window.prompt('Working directory (optional)', server.cwd || '') || '';
880
+ await runOperation(
881
+ () => API.send(`/api/config/mcp/${encodeURIComponent(name)}`, 'PUT', {
882
+ newName,
883
+ command,
884
+ args: argsText.split(/\s+/).filter(Boolean),
885
+ cwd: cwd || undefined
886
+ }),
887
+ 'MCP server updated',
888
+ newName
889
+ );
890
+ await loadCurrentView();
891
+ }
892
+
893
+ async function deleteMcpServer(name) {
894
+ if (!window.confirm(`Delete MCP server "${name}"?`)) return;
895
+ await runOperation(
896
+ async () => {
897
+ const res = await fetch(`/api/config/mcp/${encodeURIComponent(name)}`, { method: 'DELETE' });
898
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
899
+ return res.json();
900
+ },
901
+ 'MCP server deleted',
902
+ name
903
+ );
904
+ await loadCurrentView();
905
+ }
906
+
907
+ async function createConfigProject() {
908
+ const projectPath = window.prompt('Project path');
909
+ if (!projectPath) return;
910
+ const trustLevel = window.prompt('Trust level', 'trusted') || 'trusted';
911
+ await runOperation(
912
+ () => API.send('/api/config/projects', 'POST', { path: projectPath, trustLevel }),
913
+ 'Trusted project added',
914
+ projectPath
915
+ );
916
+ await loadCurrentView();
917
+ }
918
+
919
+ async function editConfigProject(projectPath) {
920
+ const project = (State.scan?.global?.config?.projects || []).find(item => item.path === projectPath);
921
+ if (!project) return;
922
+ const trustLevel = window.prompt('Trust level', project.trustLevel || 'trusted') || project.trustLevel || 'trusted';
923
+ await runOperation(
924
+ () => API.send('/api/config/projects', 'PUT', { path: projectPath, trustLevel }),
925
+ 'Trusted project updated',
926
+ projectPath
927
+ );
928
+ await loadCurrentView();
929
+ }
930
+
931
+ async function deleteConfigProject(projectPath) {
932
+ if (!window.confirm(`Delete trusted project "${projectPath}"?`)) return;
933
+ await runOperation(
934
+ async () => {
935
+ const res = await fetch('/api/config/projects', {
936
+ method: 'DELETE',
937
+ headers: { 'Content-Type': 'application/json' },
938
+ body: JSON.stringify({ path: projectPath })
939
+ });
940
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
941
+ return res.json();
942
+ },
943
+ 'Trusted project deleted',
944
+ projectPath
945
+ );
946
+ await loadCurrentView();
947
+ }
948
+
949
+ function renderPlugins() {
950
+ const plugins = State.pluginData?.plugins || [];
951
+ const activity = Object.entries(State.toolStats?.toolUsage || {})
952
+ .map(([name, count]) => `<div class="kv"><span>${escapeHtml(name)}</span><strong>${formatNumber(count)}</strong></div>`).join('');
953
+
954
+ return `
955
+ <div class="grid two">
956
+ ${panel('Installed Plugins', plugins.length ? `<div class="chart-list plugins-list-scroll">${plugins.map(plugin => `
957
+ <article class="mini-card">
958
+ <strong>${escapeHtml(plugin.displayName || plugin.name)}</strong>
959
+ <p>${escapeHtml(plugin.description || 'No description.')}</p>
960
+ <small>${escapeHtml(plugin.version || 'no version')} · ${plugin.tools} tools · ${plugin.prompts} prompts · ${escapeHtml(plugin.category || 'Custom')}</small>
961
+ <div class="chip-row">
962
+ <button class="btn btn-small" onclick='editPlugin(${jsQuote(plugin.name)})'>Edit</button>
963
+ <button class="ghost" onclick='deletePlugin(${jsQuote(plugin.name)})'>Delete</button>
964
+ </div>
965
+ </article>
966
+ `).join('')}</div>` : '<p class="muted">No plugin manifests detected.</p>', `<button class="btn btn-small" onclick='createPlugin()'>New plugin</button>`)}
967
+ ${panel('Tool Activity', activity ? `<div class="chart-list tool-activity-scroll">${activity}</div>` : '<p class="muted">No recent tool events.</p>')}
968
+ </div>
969
+ `;
970
+ }
971
+
972
+ async function createPlugin() {
973
+ const name = window.prompt('Plugin id/name');
974
+ if (!name) return;
975
+ const displayName = window.prompt('Display name', name) || name;
976
+ const description = window.prompt('Description', '') || '';
977
+ const category = window.prompt('Category', 'Custom') || 'Custom';
978
+ await runOperation(
979
+ () => API.send('/api/plugins', 'POST', {
980
+ name,
981
+ displayName,
982
+ description,
983
+ category,
984
+ capabilities: ['Interactive'],
985
+ defaultPrompt: ['Use this plugin from Codex Map']
986
+ }),
987
+ 'Plugin created',
988
+ displayName
989
+ );
990
+ await loadCurrentView();
991
+ }
992
+
993
+ async function editPlugin(name) {
994
+ const plugin = (State.pluginData?.plugins || []).find(item => item.name === name);
995
+ if (!plugin) return;
996
+ const newName = window.prompt('Plugin id/name', plugin.name);
997
+ if (!newName) return;
998
+ const displayName = window.prompt('Display name', plugin.displayName || plugin.name) || plugin.name;
999
+ const description = window.prompt('Description', plugin.description || '') || '';
1000
+ const category = window.prompt('Category', plugin.category || 'Custom') || 'Custom';
1001
+ await runOperation(
1002
+ () => API.send(`/api/plugins/${encodeURIComponent(name)}`, 'PUT', {
1003
+ name: newName,
1004
+ displayName,
1005
+ description,
1006
+ category,
1007
+ capabilities: ['Interactive'],
1008
+ defaultPrompt: ['Use this plugin from Codex Map'],
1009
+ version: plugin.version || '0.1.0'
1010
+ }),
1011
+ 'Plugin updated',
1012
+ displayName
1013
+ );
1014
+ await loadCurrentView();
1015
+ }
1016
+
1017
+ async function deletePlugin(name) {
1018
+ if (!window.confirm(`Delete plugin "${name}"?`)) return;
1019
+ await runOperation(
1020
+ async () => {
1021
+ const res = await fetch(`/api/plugins/${encodeURIComponent(name)}`, { method: 'DELETE' });
1022
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
1023
+ return res.json();
1024
+ },
1025
+ 'Plugin deleted',
1026
+ name
1027
+ );
1028
+ await loadCurrentView();
1029
+ }
1030
+
1031
+ function renderRaw() {
1032
+ const tree = getCurrentTree();
1033
+ return `
1034
+ <div class="grid two raw-grid">
1035
+ ${panel('File Tree', tree ? renderTree(tree) : '<p class="muted">No file tree available.</p>')}
1036
+ ${panel('File Viewer', State.rawFile ? renderRawEditor() : '<p class="muted">Choose a file to preview.</p>')}
1037
+ </div>
1038
+ `;
1039
+ }
1040
+
1041
+ function renderTree(node, depth = 0) {
1042
+ if (!node) return '';
1043
+ const indent = depth * 14;
1044
+ if (!node.isDir) {
1045
+ return `<button class="tree-file" style="padding-left:${indent}px" onclick='openFile(${jsQuote(node.path)})'>${escapeHtml(node.name)}</button>`;
1046
+ }
1047
+
1048
+ return `
1049
+ <div class="tree-dir" style="padding-left:${indent}px">${escapeHtml(node.name)}</div>
1050
+ ${(node.children || []).map(child => renderTree(child, depth + 1)).join('')}
1051
+ `;
1052
+ }
1053
+
1054
+ async function openFile(filePath) {
1055
+ State.rawSelectedPath = filePath;
1056
+ const projectQuery = State.mode === 'project' ? `&project=${encodeURIComponent(State.projectPath)}` : '';
1057
+ State.rawFile = await API.get(`/api/file?path=${encodeURIComponent(filePath)}${projectQuery}`);
1058
+ State.rawEditMode = false;
1059
+ State.rawDraft = State.rawFile.content || '';
1060
+ renderContent();
1061
+ }
1062
+
1063
+ function renderRawEditor() {
1064
+ return `
1065
+ <div class="panel-actions-inline">
1066
+ <button class="btn btn-small" onclick='toggleRawEdit()'>${State.rawEditMode ? 'Preview' : 'Edit'}</button>
1067
+ ${State.rawEditMode ? `<button class="btn btn-small" onclick='saveRawFile()'>Save</button>` : ''}
1068
+ <span class="chip">${escapeHtml(State.rawSelectedPath || '')}</span>
1069
+ </div>
1070
+ ${State.rawEditMode
1071
+ ? `<textarea id="raw-editor" class="skill-textarea">${escapeHtml(State.rawDraft || '')}</textarea>`
1072
+ : codeBlock(State.rawFile.content || '', detectLanguage(State.rawSelectedPath))}
1073
+ `;
1074
+ }
1075
+
1076
+ function toggleRawEdit() {
1077
+ if (!State.rawFile) return;
1078
+ if (State.rawEditMode) {
1079
+ State.rawDraft = document.getElementById('raw-editor')?.value || State.rawDraft;
1080
+ }
1081
+ State.rawEditMode = !State.rawEditMode;
1082
+ renderContent();
1083
+ }
1084
+
1085
+ async function saveRawFile() {
1086
+ const content = document.getElementById('raw-editor')?.value;
1087
+ if (typeof content !== 'string') return;
1088
+ await runOperation(
1089
+ () => API.send('/api/file', 'PUT', {
1090
+ path: State.rawSelectedPath,
1091
+ content,
1092
+ projectPath: State.mode === 'project' ? State.projectPath : null
1093
+ }),
1094
+ 'File saved',
1095
+ State.rawSelectedPath || ''
1096
+ );
1097
+ State.rawFile.content = content;
1098
+ State.rawDraft = content;
1099
+ State.rawEditMode = false;
1100
+ renderContent();
1101
+ }
1102
+
1103
+ function markdownCard(content, compact = false) {
1104
+ return `<div class="markdown-body ${compact ? 'markdown-compact' : ''}">${marked.parse(content || '')}</div>`;
1105
+ }
1106
+
1107
+ function codeBlock(content, language) {
1108
+ return `<pre><code class="language-${language}">${escapeHtml(content || '')}</code></pre>`;
1109
+ }
1110
+
1111
+ function detectLanguage(filePath) {
1112
+ if (!filePath) return 'plaintext';
1113
+ if (filePath.endsWith('.json')) return 'json';
1114
+ if (filePath.endsWith('.md')) return 'markdown';
1115
+ if (filePath.endsWith('.toml')) return 'ini';
1116
+ if (filePath.endsWith('.js')) return 'javascript';
1117
+ return 'plaintext';
1118
+ }
1119
+
1120
+ function highlightCode() {
1121
+ document.querySelectorAll('pre code').forEach(block => {
1122
+ try {
1123
+ hljs.highlightElement(block);
1124
+ } catch {}
1125
+ });
1126
+ }
1127
+
1128
+ function downloadExport() {
1129
+ const projectQuery = State.mode === 'project' ? `?project=${encodeURIComponent(State.projectPath)}` : '';
1130
+ window.open(`/api/export${projectQuery}`, '_blank');
1131
+ showToast('Export started', 'JSON download opened', 'success');
1132
+ }
1133
+
1134
+ async function downloadBundle() {
1135
+ const res = await fetch('/api/export/bundle', {
1136
+ method: 'POST',
1137
+ headers: { 'Content-Type': 'application/json' },
1138
+ body: JSON.stringify({
1139
+ scope: State.mode === 'project' ? 'project' : 'global',
1140
+ projectPath: State.projectPath || null
1141
+ })
1142
+ });
1143
+ const blob = await res.blob();
1144
+ const url = URL.createObjectURL(blob);
1145
+ const a = document.createElement('a');
1146
+ a.href = url;
1147
+ a.download = `codex-map-bundle-${new Date().toISOString().slice(0, 10)}.json`;
1148
+ a.click();
1149
+ URL.revokeObjectURL(url);
1150
+ showToast('Bundle ready', a.download, 'success');
1151
+ }
1152
+
1153
+ async function openBrowser(startPath) {
1154
+ State.browserPath = startPath || State.browserPath || '/Volumes/Projects';
1155
+ document.getElementById('browser-modal').classList.remove('hidden');
1156
+ await renderBrowser();
1157
+ }
1158
+
1159
+ function closeBrowser() {
1160
+ document.getElementById('browser-modal').classList.add('hidden');
1161
+ }
1162
+
1163
+ async function renderBrowser() {
1164
+ const data = await API.get(`/api/browse?path=${encodeURIComponent(State.browserPath)}`);
1165
+ document.getElementById('browser-crumbs').innerHTML = data.crumbs.map(crumb => `
1166
+ <button class="crumb" onclick='openBrowser(${jsQuote(crumb.path)})'>${escapeHtml(crumb.name)}</button>
1167
+ `).join('');
1168
+
1169
+ const bookmarks = await API.get('/api/browse/bookmarks');
1170
+ document.getElementById('browser-bookmarks').innerHTML = bookmarks.bookmarks.map(mark => `
1171
+ <button class="bookmark" onclick='openBrowser(${jsQuote(mark.path)})'>${escapeHtml(mark.name)}</button>
1172
+ `).join('');
1173
+
1174
+ document.getElementById('browser-list').innerHTML = `
1175
+ <p class="muted browser-note">${data.codexOnly ? 'Showing Codex-ready folders in this location.' : 'No Codex-ready folders found here yet. Browse deeper or select a folder manually.'}</p>
1176
+ ${data.trustedProjects?.length ? `
1177
+ <div class="browser-section">
1178
+ <strong>Trusted Projects</strong>
1179
+ ${data.trustedProjects.map(dir => `
1180
+ <div class="browser-row">
1181
+ <button class="browser-item" onclick='chooseBrowserPath(${jsQuote(dir.path)})' title="${escapeHtml(dir.path)}">
1182
+ <span>${escapeHtml(dir.name)}</span>
1183
+ <span class="status-pill ${escapeHtml(dir.status)}">Trusted</span>
1184
+ </button>
1185
+ </div>
1186
+ `).join('')}
1187
+ </div>
1188
+ ` : ''}
1189
+ ${data.discovered?.length ? `
1190
+ <div class="browser-section">
1191
+ <strong>Discovered Nested Projects</strong>
1192
+ ${data.discovered.map(dir => `
1193
+ <div class="browser-row">
1194
+ <button class="browser-item" onclick='chooseBrowserPath(${jsQuote(dir.path)})' title="${escapeHtml(dir.path)}">
1195
+ <span>${escapeHtml(dir.path.replace(`${State.browserPath}/`, ''))}</span>
1196
+ <span class="status-pill ${escapeHtml(dir.status)}">${escapeHtml(dir.status === 'full' ? 'Codex ready' : 'Partial')}</span>
1197
+ </button>
1198
+ </div>
1199
+ `).join('')}
1200
+ </div>
1201
+ ` : ''}
1202
+ ${data.parent ? `<button class="browser-item" onclick='openBrowser(${jsQuote(data.parent)})'>..</button>` : ''}
1203
+ ${data.dirs.map(dir => `
1204
+ <div class="browser-row">
1205
+ <button class="browser-item" onclick='${dir.isProject ? `chooseBrowserPath(${jsQuote(dir.path)})` : `openBrowser(${jsQuote(dir.path)})`}' title="${escapeHtml(dir.path)}">
1206
+ <span>${escapeHtml(dir.name)}</span>
1207
+ ${dir.isProject ? `<span class="status-pill ${escapeHtml(dir.status)}">${escapeHtml(dir.status === 'full' ? 'Codex ready' : 'Partial')}</span>` : ''}
1208
+ </button>
1209
+ ${dir.isProject ? '' : `<button class="btn btn-small" onclick='chooseBrowserPath(${jsQuote(dir.path)})'>Select</button>`}
1210
+ </div>
1211
+ `).join('')}
1212
+ `;
1213
+ }
1214
+
1215
+ async function chooseBrowserPath(projectPath, tab = 'map') {
1216
+ await addPinnedProject(projectPath);
1217
+ closeBrowser();
1218
+ selectProject(projectPath, tab);
1219
+ }
1220
+
1221
+ function openSkillModal() {
1222
+ document.getElementById('skill-modal').classList.remove('hidden');
1223
+ }
1224
+
1225
+ function closeSkillModal() {
1226
+ document.getElementById('skill-modal').classList.add('hidden');
1227
+ }
1228
+
1229
+ function currentSkillProjectPath(scope) {
1230
+ return scope === 'project' ? State.projectPath : null;
1231
+ }
1232
+
1233
+ function newSkill(scope) {
1234
+ if (scope === 'project' && State.mode !== 'project') return;
1235
+ State.skillDraft = { mode: 'create', scope, originalName: '' };
1236
+ document.getElementById('skill-modal-title').textContent = `New ${scope} skill`;
1237
+ document.getElementById('skill-name-input').value = '';
1238
+ document.getElementById('skill-content-input').value = '';
1239
+ document.getElementById('skill-scope-label').textContent = scope === 'project' ? `Project: ${State.projectPath}` : 'Global skill';
1240
+ document.getElementById('skill-delete-btn').style.display = 'none';
1241
+ openSkillModal();
1242
+ }
1243
+
1244
+ async function editSkill(scope, name) {
1245
+ const qs = new URLSearchParams({ scope });
1246
+ const projectPath = currentSkillProjectPath(scope);
1247
+ if (projectPath) qs.set('projectPath', projectPath);
1248
+ const skill = await API.get(`/api/skills/${encodeURIComponent(name)}?${qs.toString()}`);
1249
+ State.skillDraft = { mode: 'edit', scope, originalName: name };
1250
+ document.getElementById('skill-modal-title').textContent = `Edit ${name}`;
1251
+ document.getElementById('skill-name-input').value = name;
1252
+ document.getElementById('skill-content-input').value = skill.content || '';
1253
+ document.getElementById('skill-scope-label').textContent = scope === 'project' ? `Project: ${State.projectPath}` : 'Global skill';
1254
+ document.getElementById('skill-delete-btn').style.display = 'inline-block';
1255
+ openSkillModal();
1256
+ }
1257
+
1258
+ async function saveSkill() {
1259
+ const draft = State.skillDraft;
1260
+ if (!draft) return;
1261
+ const name = document.getElementById('skill-name-input').value.trim();
1262
+ const content = document.getElementById('skill-content-input').value;
1263
+ if (!name || !content) return;
1264
+
1265
+ const body = { name, content, scope: draft.scope, projectPath: currentSkillProjectPath(draft.scope) };
1266
+ if (draft.mode === 'create') {
1267
+ await runOperation(
1268
+ () => API.send('/api/skills', 'POST', body),
1269
+ 'Skill created',
1270
+ name
1271
+ );
1272
+ } else if (name === draft.originalName) {
1273
+ await runOperation(
1274
+ () => API.send(`/api/skills/${encodeURIComponent(draft.originalName)}`, 'PUT', body),
1275
+ 'Skill updated',
1276
+ name
1277
+ );
1278
+ } else {
1279
+ await runOperation(
1280
+ () => API.send('/api/skills', 'POST', body),
1281
+ 'Skill renamed',
1282
+ `${draft.originalName} → ${name}`
1283
+ );
1284
+ const qs = new URLSearchParams({ scope: draft.scope });
1285
+ const projectPath = currentSkillProjectPath(draft.scope);
1286
+ if (projectPath) qs.set('projectPath', projectPath);
1287
+ const res = await fetch(`/api/skills/${encodeURIComponent(draft.originalName)}?${qs.toString()}`, { method: 'DELETE' });
1288
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
1289
+ }
1290
+ closeSkillModal();
1291
+ await loadCurrentView();
1292
+ }
1293
+
1294
+ async function deleteSkill() {
1295
+ const draft = State.skillDraft;
1296
+ if (!draft) return;
1297
+ const qs = new URLSearchParams({ scope: draft.scope });
1298
+ const projectPath = currentSkillProjectPath(draft.scope);
1299
+ if (projectPath) qs.set('projectPath', projectPath);
1300
+ await runOperation(
1301
+ async () => {
1302
+ const res = await fetch(`/api/skills/${encodeURIComponent(draft.originalName)}?${qs.toString()}`, { method: 'DELETE' });
1303
+ if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`);
1304
+ return res.json();
1305
+ },
1306
+ 'Skill deleted',
1307
+ draft.originalName
1308
+ );
1309
+ closeSkillModal();
1310
+ await loadCurrentView();
1311
+ }
1312
+
1313
+ async function removeSkill(scope, name) {
1314
+ await editSkill(scope, name);
1315
+ }
1316
+
1317
+ function connectEvents() {
1318
+ const source = new EventSource('/api/events');
1319
+ source.addEventListener('connected', () => {
1320
+ document.getElementById('sse-status').textContent = 'Live sync on';
1321
+ });
1322
+ source.addEventListener('file-changed', () => {
1323
+ document.getElementById('sse-status').textContent = 'Syncing…';
1324
+ loadCurrentView().finally(() => {
1325
+ document.getElementById('sse-status').textContent = 'Live sync on';
1326
+ });
1327
+ });
1328
+ source.onerror = () => {
1329
+ document.getElementById('sse-status').textContent = 'Live sync off';
1330
+ };
1331
+ }
1332
+
1333
+ window.toggleTheme = toggleTheme;
1334
+ window.selectGlobal = selectGlobal;
1335
+ window.selectProject = selectProject;
1336
+ window.setTab = setTab;
1337
+ window.applyStatsRange = applyStatsRange;
1338
+ window.applyStatsPreset = applyStatsPreset;
1339
+ window.resetStatsRange = resetStatsRange;
1340
+ window.addPath = addPath;
1341
+ window.removePinnedProject = removePinnedProject;
1342
+ window.openSession = openSession;
1343
+ window.createSession = createSession;
1344
+ window.copyResumeCommand = copyResumeCommand;
1345
+ window.renameSession = renameSession;
1346
+ window.deleteSession = deleteSession;
1347
+ window.saveConfig = saveConfig;
1348
+ window.createMcpServer = createMcpServer;
1349
+ window.editMcpServer = editMcpServer;
1350
+ window.deleteMcpServer = deleteMcpServer;
1351
+ window.createConfigProject = createConfigProject;
1352
+ window.editConfigProject = editConfigProject;
1353
+ window.deleteConfigProject = deleteConfigProject;
1354
+ window.createPlugin = createPlugin;
1355
+ window.editPlugin = editPlugin;
1356
+ window.deletePlugin = deletePlugin;
1357
+ window.openFile = openFile;
1358
+ window.toggleRawEdit = toggleRawEdit;
1359
+ window.saveRawFile = saveRawFile;
1360
+ window.downloadExport = downloadExport;
1361
+ window.downloadBundle = downloadBundle;
1362
+ window.openBrowser = openBrowser;
1363
+ window.closeBrowser = closeBrowser;
1364
+ window.chooseBrowserPath = chooseBrowserPath;
1365
+ window.newSkill = newSkill;
1366
+ window.editSkill = editSkill;
1367
+ window.saveSkill = saveSkill;
1368
+ window.deleteSkill = deleteSkill;
1369
+ window.closeSkillModal = closeSkillModal;
1370
+
1371
+ init();