ai-agent-session-center 1.0.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/README.md +618 -0
- package/bin/cli.js +20 -0
- package/hooks/dashboard-hook-codex.sh +67 -0
- package/hooks/dashboard-hook-gemini.sh +102 -0
- package/hooks/dashboard-hook.ps1 +147 -0
- package/hooks/dashboard-hook.sh +142 -0
- package/hooks/dashboard-hooks-backup.json +103 -0
- package/hooks/install-hooks.js +543 -0
- package/hooks/reset.js +357 -0
- package/hooks/setup-wizard.js +156 -0
- package/package.json +52 -0
- package/public/css/dashboard.css +10200 -0
- package/public/index.html +915 -0
- package/public/js/analyticsPanel.js +467 -0
- package/public/js/app.js +1148 -0
- package/public/js/browserDb.js +806 -0
- package/public/js/chartUtils.js +383 -0
- package/public/js/historyPanel.js +298 -0
- package/public/js/movementManager.js +155 -0
- package/public/js/navController.js +32 -0
- package/public/js/robotManager.js +526 -0
- package/public/js/sceneManager.js +7 -0
- package/public/js/sessionPanel.js +2477 -0
- package/public/js/settingsManager.js +924 -0
- package/public/js/soundManager.js +249 -0
- package/public/js/statsPanel.js +118 -0
- package/public/js/terminalManager.js +391 -0
- package/public/js/timelinePanel.js +278 -0
- package/public/js/wsClient.js +88 -0
- package/server/apiRouter.js +321 -0
- package/server/config.js +120 -0
- package/server/hookProcessor.js +55 -0
- package/server/hookRouter.js +18 -0
- package/server/hookStats.js +107 -0
- package/server/index.js +314 -0
- package/server/logger.js +67 -0
- package/server/mqReader.js +218 -0
- package/server/serverConfig.js +27 -0
- package/server/sessionStore.js +1049 -0
- package/server/sshManager.js +339 -0
- package/server/wsManager.js +83 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// terminalManager.js — Frontend terminal module using xterm.js
|
|
2
|
+
// Manages terminal lifecycle, I/O relay through WebSocket, and tab attachment.
|
|
3
|
+
// Uses canvas renderer, Unicode11, WebLinks, and FitAddon (same stack as AWS/Azure Cloud Shell).
|
|
4
|
+
|
|
5
|
+
let ws = null;
|
|
6
|
+
let activeTerminal = null; // { terminalId, term, fitAddon, resizeObserver }
|
|
7
|
+
let terminalSessions = {}; // terminalId -> sessionId mapping
|
|
8
|
+
let terminalThemes = {}; // terminalId -> theme name
|
|
9
|
+
let pendingOutput = {}; // terminalId -> [base64Data] — buffer output before terminal is ready
|
|
10
|
+
let isFullscreen = false;
|
|
11
|
+
|
|
12
|
+
const THEMES = {
|
|
13
|
+
default: {
|
|
14
|
+
background: '#0a0a1a', foreground: '#e0e0e0', cursor: '#0a0a1a', cursorAccent: '#0a0a1a',
|
|
15
|
+
selectionBackground: 'rgba(0,229,255,0.3)', selectionForeground: '#ffffff',
|
|
16
|
+
black: '#0a0a1a', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
|
|
17
|
+
blue: '#6272a4', magenta: '#ff79c6', cyan: '#00e5ff', white: '#e0e0e0',
|
|
18
|
+
brightBlack: '#555555', brightRed: '#ff6e6e', brightGreen: '#69ff94',
|
|
19
|
+
brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df',
|
|
20
|
+
brightCyan: '#a4ffff', brightWhite: '#ffffff',
|
|
21
|
+
},
|
|
22
|
+
dark: {
|
|
23
|
+
background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#1e1e1e', cursorAccent: '#1e1e1e',
|
|
24
|
+
selectionBackground: 'rgba(255,255,255,0.15)', selectionForeground: '#ffffff',
|
|
25
|
+
black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510',
|
|
26
|
+
blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5',
|
|
27
|
+
brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b',
|
|
28
|
+
brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6',
|
|
29
|
+
brightCyan: '#29b8db', brightWhite: '#ffffff',
|
|
30
|
+
},
|
|
31
|
+
monokai: {
|
|
32
|
+
background: '#272822', foreground: '#f8f8f2', cursor: '#272822', cursorAccent: '#272822',
|
|
33
|
+
selectionBackground: 'rgba(73,72,62,0.6)', selectionForeground: '#ffffff',
|
|
34
|
+
black: '#272822', red: '#f92672', green: '#a6e22e', yellow: '#f4bf75',
|
|
35
|
+
blue: '#66d9ef', magenta: '#ae81ff', cyan: '#a1efe4', white: '#f8f8f2',
|
|
36
|
+
brightBlack: '#75715e', brightRed: '#f92672', brightGreen: '#a6e22e',
|
|
37
|
+
brightYellow: '#f4bf75', brightBlue: '#66d9ef', brightMagenta: '#ae81ff',
|
|
38
|
+
brightCyan: '#a1efe4', brightWhite: '#f9f8f5',
|
|
39
|
+
},
|
|
40
|
+
dracula: {
|
|
41
|
+
background: '#282a36', foreground: '#f8f8f2', cursor: '#282a36', cursorAccent: '#282a36',
|
|
42
|
+
selectionBackground: 'rgba(68,71,90,0.6)', selectionForeground: '#ffffff',
|
|
43
|
+
black: '#21222c', red: '#ff5555', green: '#50fa7b', yellow: '#f1fa8c',
|
|
44
|
+
blue: '#bd93f9', magenta: '#ff79c6', cyan: '#8be9fd', white: '#f8f8f2',
|
|
45
|
+
brightBlack: '#6272a4', brightRed: '#ff6e6e', brightGreen: '#69ff94',
|
|
46
|
+
brightYellow: '#ffffa5', brightBlue: '#d6acff', brightMagenta: '#ff92df',
|
|
47
|
+
brightCyan: '#a4ffff', brightWhite: '#ffffff',
|
|
48
|
+
},
|
|
49
|
+
'solarized-dark': {
|
|
50
|
+
background: '#002b36', foreground: '#839496', cursor: '#002b36', cursorAccent: '#002b36',
|
|
51
|
+
selectionBackground: 'rgba(7,54,66,0.6)', selectionForeground: '#93a1a1',
|
|
52
|
+
black: '#073642', red: '#dc322f', green: '#859900', yellow: '#b58900',
|
|
53
|
+
blue: '#268bd2', magenta: '#d33682', cyan: '#2aa198', white: '#eee8d5',
|
|
54
|
+
brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#586e75',
|
|
55
|
+
brightYellow: '#657b83', brightBlue: '#839496', brightMagenta: '#6c71c4',
|
|
56
|
+
brightCyan: '#93a1a1', brightWhite: '#fdf6e3',
|
|
57
|
+
},
|
|
58
|
+
nord: {
|
|
59
|
+
background: '#2e3440', foreground: '#d8dee9', cursor: '#2e3440', cursorAccent: '#2e3440',
|
|
60
|
+
selectionBackground: 'rgba(67,76,94,0.6)', selectionForeground: '#eceff4',
|
|
61
|
+
black: '#3b4252', red: '#bf616a', green: '#a3be8c', yellow: '#ebcb8b',
|
|
62
|
+
blue: '#81a1c1', magenta: '#b48ead', cyan: '#88c0d0', white: '#e5e9f0',
|
|
63
|
+
brightBlack: '#4c566a', brightRed: '#bf616a', brightGreen: '#a3be8c',
|
|
64
|
+
brightYellow: '#ebcb8b', brightBlue: '#81a1c1', brightMagenta: '#b48ead',
|
|
65
|
+
brightCyan: '#8fbcbb', brightWhite: '#eceff4',
|
|
66
|
+
},
|
|
67
|
+
'github-dark': {
|
|
68
|
+
background: '#0d1117', foreground: '#c9d1d9', cursor: '#0d1117', cursorAccent: '#0d1117',
|
|
69
|
+
selectionBackground: 'rgba(56,139,253,0.25)', selectionForeground: '#ffffff',
|
|
70
|
+
black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922',
|
|
71
|
+
blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4',
|
|
72
|
+
brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364',
|
|
73
|
+
brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
|
|
74
|
+
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function setWs(websocket) {
|
|
79
|
+
ws = websocket;
|
|
80
|
+
// Re-subscribe active terminal after WS reconnect so output keeps flowing
|
|
81
|
+
if (activeTerminal && ws && ws.readyState === 1) {
|
|
82
|
+
ws.send(JSON.stringify({ type: 'terminal_subscribe', terminalId: activeTerminal.terminalId }));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function setTerminalTheme(terminalId, themeName) {
|
|
87
|
+
terminalThemes[terminalId] = themeName;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getTheme(terminalId) {
|
|
91
|
+
const name = terminalThemes[terminalId] || 'default';
|
|
92
|
+
return THEMES[name] || THEMES.default;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sendResize(terminalId, cols, rows) {
|
|
96
|
+
if (ws && ws.readyState === 1 && cols > 0 && rows > 0) {
|
|
97
|
+
ws.send(JSON.stringify({ type: 'terminal_resize', terminalId, cols, rows }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function initTerminal(terminalId) {
|
|
102
|
+
detachTerminal();
|
|
103
|
+
|
|
104
|
+
const container = document.getElementById('terminal-container');
|
|
105
|
+
if (!container) return;
|
|
106
|
+
container.innerHTML = '';
|
|
107
|
+
|
|
108
|
+
// Subscribe early so output is buffered while we wait for container dimensions
|
|
109
|
+
if (ws && ws.readyState === 1) {
|
|
110
|
+
ws.send(JSON.stringify({ type: 'terminal_subscribe', terminalId }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Defer setup until container has real dimensions so fitAddon
|
|
114
|
+
// can calculate correct cols/rows.
|
|
115
|
+
function setupWhenReady(retries) {
|
|
116
|
+
if (container.offsetWidth > 0 && container.offsetHeight > 0) {
|
|
117
|
+
doSetup();
|
|
118
|
+
} else if (retries > 0) {
|
|
119
|
+
requestAnimationFrame(() => setTimeout(() => setupWhenReady(retries - 1), 50));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function doSetup() {
|
|
124
|
+
const term = new Terminal({
|
|
125
|
+
cursorBlink: false,
|
|
126
|
+
cursorStyle: 'bar',
|
|
127
|
+
fontSize: 14,
|
|
128
|
+
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Menlo', monospace",
|
|
129
|
+
fontWeight: '400',
|
|
130
|
+
fontWeightBold: '700',
|
|
131
|
+
lineHeight: 1.15,
|
|
132
|
+
letterSpacing: 0,
|
|
133
|
+
theme: getTheme(terminalId),
|
|
134
|
+
allowProposedApi: true,
|
|
135
|
+
scrollback: 10000,
|
|
136
|
+
convertEol: false,
|
|
137
|
+
windowsMode: false,
|
|
138
|
+
drawBoldTextInBrightColors: true,
|
|
139
|
+
minimumContrastRatio: 1,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Load FitAddon
|
|
143
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
144
|
+
term.loadAddon(fitAddon);
|
|
145
|
+
|
|
146
|
+
// Load Unicode11 for proper wide character / emoji rendering
|
|
147
|
+
try {
|
|
148
|
+
const unicode11 = new Unicode11Addon.Unicode11Addon();
|
|
149
|
+
term.loadAddon(unicode11);
|
|
150
|
+
term.unicode.activeVersion = '11';
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.warn('[terminal] Unicode11 addon not available:', e.message);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Load WebLinks for clickable URLs
|
|
156
|
+
try {
|
|
157
|
+
const webLinks = new WebLinksAddon.WebLinksAddon();
|
|
158
|
+
term.loadAddon(webLinks);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.warn('[terminal] WebLinks addon not available:', e.message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
term.open(container);
|
|
164
|
+
|
|
165
|
+
// Canvas renderer (default) — same as AWS/Azure Cloud Shell.
|
|
166
|
+
// WebGL addon removed: it caused black screens, context loss on app switch,
|
|
167
|
+
// and required forced refresh hacks. Canvas is stable and performant enough.
|
|
168
|
+
|
|
169
|
+
// Container already has dimensions at this point — fit immediately
|
|
170
|
+
fitAddon.fit();
|
|
171
|
+
sendResize(terminalId, term.cols, term.rows);
|
|
172
|
+
|
|
173
|
+
// Send keystrokes to server
|
|
174
|
+
term.onData((data) => {
|
|
175
|
+
if (ws && ws.readyState === 1) {
|
|
176
|
+
ws.send(JSON.stringify({ type: 'terminal_input', terminalId, data }));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Also handle binary data (for special keys)
|
|
181
|
+
term.onBinary((data) => {
|
|
182
|
+
if (ws && ws.readyState === 1) {
|
|
183
|
+
ws.send(JSON.stringify({ type: 'terminal_input', terminalId, data }));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Handle resize — debounce to avoid flooding
|
|
188
|
+
let resizeTimer = null;
|
|
189
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
190
|
+
clearTimeout(resizeTimer);
|
|
191
|
+
resizeTimer = setTimeout(() => {
|
|
192
|
+
fitAddon.fit();
|
|
193
|
+
sendResize(terminalId, term.cols, term.rows);
|
|
194
|
+
}, 50);
|
|
195
|
+
});
|
|
196
|
+
resizeObserver.observe(container);
|
|
197
|
+
|
|
198
|
+
activeTerminal = { terminalId, term, fitAddon, resizeObserver };
|
|
199
|
+
|
|
200
|
+
// Flush any buffered output
|
|
201
|
+
if (pendingOutput[terminalId]) {
|
|
202
|
+
for (const data of pendingOutput[terminalId]) {
|
|
203
|
+
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
|
|
204
|
+
term.write(bytes);
|
|
205
|
+
}
|
|
206
|
+
delete pendingOutput[terminalId];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
updateStatus('Connected', 'connected');
|
|
210
|
+
term.focus();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Start polling — up to ~2s (40 × 50ms)
|
|
214
|
+
setupWhenReady(40);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function onTerminalOutput(terminalId, base64Data) {
|
|
218
|
+
if (activeTerminal && activeTerminal.terminalId === terminalId) {
|
|
219
|
+
const bytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
|
220
|
+
activeTerminal.term.write(bytes);
|
|
221
|
+
} else {
|
|
222
|
+
// Buffer output for when terminal attaches
|
|
223
|
+
if (!pendingOutput[terminalId]) pendingOutput[terminalId] = [];
|
|
224
|
+
pendingOutput[terminalId].push(base64Data);
|
|
225
|
+
// Limit buffer to last 500 chunks
|
|
226
|
+
if (pendingOutput[terminalId].length > 500) pendingOutput[terminalId].shift();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function onTerminalReady(terminalId) {
|
|
231
|
+
if (activeTerminal && activeTerminal.terminalId === terminalId) {
|
|
232
|
+
updateStatus('Terminal ready', 'connected');
|
|
233
|
+
// Re-fit and sync size now that server shell is ready
|
|
234
|
+
requestAnimationFrame(() => {
|
|
235
|
+
if (activeTerminal && activeTerminal.fitAddon) {
|
|
236
|
+
activeTerminal.fitAddon.fit();
|
|
237
|
+
sendResize(terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function onTerminalClosed(terminalId, reason) {
|
|
244
|
+
if (activeTerminal && activeTerminal.terminalId === terminalId) {
|
|
245
|
+
activeTerminal.term.write(`\r\n\x1b[31m--- Terminal ${reason || 'closed'} ---\x1b[0m\r\n`);
|
|
246
|
+
updateStatus(`Disconnected (${reason || 'closed'})`, 'disconnected');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function attachToSession(sessionId, terminalId) {
|
|
251
|
+
if (!terminalId) return;
|
|
252
|
+
terminalSessions[terminalId] = sessionId;
|
|
253
|
+
initTerminal(terminalId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function detachTerminal() {
|
|
257
|
+
if (isFullscreen) exitFullscreen();
|
|
258
|
+
if (activeTerminal) {
|
|
259
|
+
if (activeTerminal.resizeObserver) {
|
|
260
|
+
activeTerminal.resizeObserver.disconnect();
|
|
261
|
+
}
|
|
262
|
+
activeTerminal.term.dispose();
|
|
263
|
+
activeTerminal = null;
|
|
264
|
+
}
|
|
265
|
+
const container = document.getElementById('terminal-container');
|
|
266
|
+
if (container) container.innerHTML = '';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function getActiveTerminalId() {
|
|
270
|
+
return activeTerminal ? activeTerminal.terminalId : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function refitTerminal() {
|
|
274
|
+
if (activeTerminal && activeTerminal.fitAddon) {
|
|
275
|
+
activeTerminal.fitAddon.fit();
|
|
276
|
+
sendResize(activeTerminal.terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function getContainer() {
|
|
281
|
+
return isFullscreen
|
|
282
|
+
? document.getElementById('terminal-fullscreen-container')
|
|
283
|
+
: document.getElementById('terminal-container');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function toggleFullscreen() {
|
|
287
|
+
if (isFullscreen) {
|
|
288
|
+
exitFullscreen();
|
|
289
|
+
} else {
|
|
290
|
+
enterFullscreen();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function enterFullscreen() {
|
|
295
|
+
if (isFullscreen || !activeTerminal) return;
|
|
296
|
+
isFullscreen = true;
|
|
297
|
+
|
|
298
|
+
const overlay = document.getElementById('terminal-fullscreen-overlay');
|
|
299
|
+
const fsContainer = document.getElementById('terminal-fullscreen-container');
|
|
300
|
+
if (!overlay || !fsContainer) return;
|
|
301
|
+
|
|
302
|
+
// Move the .xterm element into fullscreen container
|
|
303
|
+
const xtermEl = activeTerminal.term.element;
|
|
304
|
+
if (xtermEl) {
|
|
305
|
+
fsContainer.appendChild(xtermEl);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
overlay.classList.remove('hidden');
|
|
309
|
+
|
|
310
|
+
// Observe fullscreen container for resize (e.g. window resize while fullscreen)
|
|
311
|
+
if (activeTerminal.resizeObserver) {
|
|
312
|
+
activeTerminal.resizeObserver.observe(fsContainer);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Refit after DOM move
|
|
316
|
+
requestAnimationFrame(() => {
|
|
317
|
+
if (activeTerminal && activeTerminal.fitAddon) {
|
|
318
|
+
activeTerminal.fitAddon.fit();
|
|
319
|
+
sendResize(activeTerminal.terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
|
|
320
|
+
activeTerminal.term.focus();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function exitFullscreen() {
|
|
326
|
+
if (!isFullscreen) return;
|
|
327
|
+
isFullscreen = false;
|
|
328
|
+
|
|
329
|
+
const overlay = document.getElementById('terminal-fullscreen-overlay');
|
|
330
|
+
const fsContainer = document.getElementById('terminal-fullscreen-container');
|
|
331
|
+
const inlineContainer = document.getElementById('terminal-container');
|
|
332
|
+
if (!overlay || !inlineContainer) return;
|
|
333
|
+
|
|
334
|
+
overlay.classList.add('hidden');
|
|
335
|
+
|
|
336
|
+
// Stop observing fullscreen container
|
|
337
|
+
if (activeTerminal && activeTerminal.resizeObserver && fsContainer) {
|
|
338
|
+
activeTerminal.resizeObserver.unobserve(fsContainer);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Move the .xterm element back to inline container
|
|
342
|
+
if (activeTerminal) {
|
|
343
|
+
const xtermEl = activeTerminal.term.element;
|
|
344
|
+
if (xtermEl) {
|
|
345
|
+
inlineContainer.appendChild(xtermEl);
|
|
346
|
+
}
|
|
347
|
+
// Refit after DOM move
|
|
348
|
+
requestAnimationFrame(() => {
|
|
349
|
+
if (activeTerminal && activeTerminal.fitAddon) {
|
|
350
|
+
activeTerminal.fitAddon.fit();
|
|
351
|
+
sendResize(activeTerminal.terminalId, activeTerminal.term.cols, activeTerminal.term.rows);
|
|
352
|
+
activeTerminal.term.focus();
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function updateStatus(text, className) {
|
|
359
|
+
const status = document.getElementById('terminal-status');
|
|
360
|
+
if (status) {
|
|
361
|
+
status.textContent = text;
|
|
362
|
+
status.className = `terminal-status ${className}`;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Refit terminal after tab/app switch
|
|
367
|
+
document.addEventListener('visibilitychange', () => {
|
|
368
|
+
if (document.visibilityState === 'visible' && activeTerminal) {
|
|
369
|
+
requestAnimationFrame(() => {
|
|
370
|
+
if (!activeTerminal) return;
|
|
371
|
+
activeTerminal.fitAddon.fit();
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// F11 toggles fullscreen (no Escape — it's a valid terminal key)
|
|
377
|
+
document.addEventListener('keydown', (e) => {
|
|
378
|
+
if (e.key === 'F11' && activeTerminal) {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
toggleFullscreen();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Wire up fullscreen buttons when DOM is ready
|
|
385
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
386
|
+
const fsBtn = document.getElementById('terminal-fullscreen-btn');
|
|
387
|
+
if (fsBtn) fsBtn.addEventListener('click', () => toggleFullscreen());
|
|
388
|
+
|
|
389
|
+
const exitBtn = document.getElementById('terminal-fullscreen-exit');
|
|
390
|
+
if (exitBtn) exitBtn.addEventListener('click', () => exitFullscreen());
|
|
391
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { formatNumber, showTooltip, hideTooltip } from './chartUtils.js';
|
|
2
|
+
import { getDistinctProjects, getTimeline } from './browserDb.js';
|
|
3
|
+
|
|
4
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
5
|
+
|
|
6
|
+
let initialized = false;
|
|
7
|
+
|
|
8
|
+
export async function init() {
|
|
9
|
+
if (initialized) return;
|
|
10
|
+
initialized = true;
|
|
11
|
+
|
|
12
|
+
// Populate project filter from IndexedDB
|
|
13
|
+
const projects = await getDistinctProjects();
|
|
14
|
+
const select = document.getElementById('timeline-project-filter');
|
|
15
|
+
projects.forEach(p => {
|
|
16
|
+
const opt = document.createElement('option');
|
|
17
|
+
opt.value = p.project_path;
|
|
18
|
+
opt.textContent = p.project_name;
|
|
19
|
+
select.appendChild(opt);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Set default date range (last 30 days)
|
|
23
|
+
const now = new Date();
|
|
24
|
+
const thirtyDaysAgo = new Date(now - 30 * 24 * 60 * 60 * 1000);
|
|
25
|
+
document.getElementById('timeline-date-from').value = thirtyDaysAgo.toISOString().split('T')[0];
|
|
26
|
+
document.getElementById('timeline-date-to').value = now.toISOString().split('T')[0];
|
|
27
|
+
|
|
28
|
+
// Wire controls
|
|
29
|
+
['timeline-granularity', 'timeline-project-filter', 'timeline-date-from', 'timeline-date-to'].forEach(id => {
|
|
30
|
+
document.getElementById(id).addEventListener('change', loadTimeline);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await loadTimeline();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function refresh() {
|
|
37
|
+
await init();
|
|
38
|
+
await loadTimeline();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadTimeline() {
|
|
42
|
+
const granularity = document.getElementById('timeline-granularity').value || 'day';
|
|
43
|
+
const project = document.getElementById('timeline-project-filter').value;
|
|
44
|
+
const dateFrom = document.getElementById('timeline-date-from').value;
|
|
45
|
+
const dateTo = document.getElementById('timeline-date-to').value;
|
|
46
|
+
|
|
47
|
+
const data = await getTimeline({
|
|
48
|
+
granularity,
|
|
49
|
+
project: project || undefined,
|
|
50
|
+
dateFrom: dateFrom ? new Date(dateFrom).getTime() : undefined,
|
|
51
|
+
dateTo: dateTo ? new Date(dateTo + 'T23:59:59').getTime() : undefined,
|
|
52
|
+
});
|
|
53
|
+
const buckets = data.buckets || [];
|
|
54
|
+
|
|
55
|
+
const container = document.getElementById('timeline-chart');
|
|
56
|
+
container.innerHTML = '';
|
|
57
|
+
|
|
58
|
+
if (buckets.length === 0) {
|
|
59
|
+
container.innerHTML = '<div class="tab-empty">No data for this period</div>';
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
renderTimelineChart(container, buckets, granularity);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatTimeLabel(bucket, granularity) {
|
|
67
|
+
const ts = bucket.period || bucket.timestamp || bucket.date || bucket.label;
|
|
68
|
+
if (!ts) return '';
|
|
69
|
+
|
|
70
|
+
// Week format: "2026-W06"
|
|
71
|
+
if (granularity === 'week') {
|
|
72
|
+
const weekMatch = String(ts).match(/^(\d{4})-W(\d{1,2})$/);
|
|
73
|
+
if (weekMatch) {
|
|
74
|
+
const year = parseInt(weekMatch[1], 10);
|
|
75
|
+
const week = parseInt(weekMatch[2], 10);
|
|
76
|
+
const jan1 = new Date(year, 0, 1);
|
|
77
|
+
const dayOffset = (week - 1) * 7 - jan1.getDay() + 1;
|
|
78
|
+
const weekStart = new Date(year, 0, 1 + dayOffset);
|
|
79
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
80
|
+
return months[weekStart.getMonth()] + ' ' + weekStart.getDate();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Hour format: "2026-02-10 14:00"
|
|
85
|
+
if (granularity === 'hour' && typeof ts === 'string') {
|
|
86
|
+
const hourMatch = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})$/);
|
|
87
|
+
if (hourMatch) {
|
|
88
|
+
const date = new Date(hourMatch[1] + 'T' + hourMatch[2] + ':' + hourMatch[3] + ':00');
|
|
89
|
+
if (!isNaN(date.getTime())) {
|
|
90
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
91
|
+
return months[date.getMonth()] + ' ' + date.getDate() + ' ' + date.getHours().toString().padStart(2, '0') + ':00';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Day format: "2026-02-10"
|
|
97
|
+
if (typeof ts === 'string') {
|
|
98
|
+
const date = new Date(ts + (ts.includes('T') ? '' : 'T00:00:00'));
|
|
99
|
+
if (!isNaN(date.getTime())) {
|
|
100
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
101
|
+
return months[date.getMonth()] + ' ' + date.getDate();
|
|
102
|
+
}
|
|
103
|
+
return ts;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Numeric timestamp
|
|
107
|
+
const date = new Date(ts);
|
|
108
|
+
if (!isNaN(date.getTime())) {
|
|
109
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
110
|
+
return months[date.getMonth()] + ' ' + date.getDate();
|
|
111
|
+
}
|
|
112
|
+
return String(ts);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderTimelineChart(container, buckets, granularity) {
|
|
116
|
+
const svgWidth = container.clientWidth || 700;
|
|
117
|
+
const paddingLeft = 50;
|
|
118
|
+
const paddingRight = 15;
|
|
119
|
+
const paddingTop = 15;
|
|
120
|
+
const paddingBottom = granularity === 'hour' ? 70 : 50;
|
|
121
|
+
const svgHeight = 300 + (granularity === 'hour' ? 20 : 0);
|
|
122
|
+
const chartW = svgWidth - paddingLeft - paddingRight;
|
|
123
|
+
const chartH = svgHeight - paddingTop - paddingBottom;
|
|
124
|
+
|
|
125
|
+
// Max across all individual bar values (grouped, not stacked)
|
|
126
|
+
const maxVal = Math.max(
|
|
127
|
+
...buckets.map(b => Math.max(
|
|
128
|
+
b.session_count || 0,
|
|
129
|
+
b.prompt_count || 0,
|
|
130
|
+
b.tool_call_count || 0
|
|
131
|
+
)),
|
|
132
|
+
1
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const svg = createEl('svg', {
|
|
136
|
+
width: '100%',
|
|
137
|
+
height: svgHeight,
|
|
138
|
+
viewBox: `0 0 ${svgWidth} ${svgHeight}`,
|
|
139
|
+
preserveAspectRatio: 'xMidYMid meet',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Y-axis grid lines and labels (5 ticks)
|
|
143
|
+
for (let i = 0; i <= 4; i++) {
|
|
144
|
+
const val = (maxVal / 4) * i;
|
|
145
|
+
const y = paddingTop + chartH - (i / 4) * chartH;
|
|
146
|
+
|
|
147
|
+
const text = createEl('text', {
|
|
148
|
+
x: paddingLeft - 8,
|
|
149
|
+
y: y + 4,
|
|
150
|
+
fill: '#8892b0',
|
|
151
|
+
'font-size': '10',
|
|
152
|
+
'text-anchor': 'end',
|
|
153
|
+
});
|
|
154
|
+
text.textContent = formatNumber(val);
|
|
155
|
+
svg.appendChild(text);
|
|
156
|
+
|
|
157
|
+
const line = createEl('line', {
|
|
158
|
+
x1: paddingLeft,
|
|
159
|
+
y1: y,
|
|
160
|
+
x2: svgWidth - paddingRight,
|
|
161
|
+
y2: y,
|
|
162
|
+
stroke: '#1e2a4a',
|
|
163
|
+
'stroke-width': 1,
|
|
164
|
+
});
|
|
165
|
+
svg.appendChild(line);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Grouped bars: 3 bars per bucket
|
|
169
|
+
const groupCount = buckets.length;
|
|
170
|
+
const groupWidth = chartW / groupCount;
|
|
171
|
+
const barGap = 1;
|
|
172
|
+
const barWidth = Math.max(2, (groupWidth - 4 * barGap) / 3);
|
|
173
|
+
|
|
174
|
+
const colors = {
|
|
175
|
+
session: '#00e5ff',
|
|
176
|
+
prompt: '#00ff88',
|
|
177
|
+
tool: '#ff9800',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
buckets.forEach((bucket, i) => {
|
|
181
|
+
const groupX = paddingLeft + i * groupWidth;
|
|
182
|
+
const sessions = bucket.session_count || 0;
|
|
183
|
+
const prompts = bucket.prompt_count || 0;
|
|
184
|
+
const tools = bucket.tool_call_count || 0;
|
|
185
|
+
|
|
186
|
+
const bars = [
|
|
187
|
+
{ value: sessions, color: colors.session, label: 'Sessions' },
|
|
188
|
+
{ value: prompts, color: colors.prompt, label: 'Prompts' },
|
|
189
|
+
{ value: tools, color: colors.tool, label: 'Tool Calls' },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const barsStartX = groupX + (groupWidth - 3 * barWidth - 2 * barGap) / 2;
|
|
193
|
+
|
|
194
|
+
bars.forEach((bar, j) => {
|
|
195
|
+
const barH = Math.max(0, (bar.value / maxVal) * chartH);
|
|
196
|
+
const x = barsStartX + j * (barWidth + barGap);
|
|
197
|
+
const y = paddingTop + chartH - barH;
|
|
198
|
+
|
|
199
|
+
if (barH > 0) {
|
|
200
|
+
const rect = createEl('rect', {
|
|
201
|
+
x, y,
|
|
202
|
+
width: barWidth,
|
|
203
|
+
height: barH,
|
|
204
|
+
rx: 2,
|
|
205
|
+
fill: bar.color,
|
|
206
|
+
opacity: 0.85,
|
|
207
|
+
});
|
|
208
|
+
rect.addEventListener('mouseenter', (e) => {
|
|
209
|
+
rect.setAttribute('opacity', '1');
|
|
210
|
+
showTooltip(
|
|
211
|
+
`${bar.label}: ${bar.value}\nSessions: ${sessions} | Prompts: ${prompts} | Tools: ${tools}`,
|
|
212
|
+
e.pageX, e.pageY
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
rect.addEventListener('mouseleave', () => {
|
|
216
|
+
rect.setAttribute('opacity', '0.85');
|
|
217
|
+
hideTooltip();
|
|
218
|
+
});
|
|
219
|
+
svg.appendChild(rect);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// X-axis label (show subset to avoid overlap)
|
|
224
|
+
const maxLabels = granularity === 'hour' ? 12 : granularity === 'week' ? 12 : 15;
|
|
225
|
+
const labelStep = Math.max(1, Math.ceil(groupCount / maxLabels));
|
|
226
|
+
if (i % labelStep === 0 || i === groupCount - 1) {
|
|
227
|
+
const label = formatTimeLabel(bucket, granularity);
|
|
228
|
+
const lx = groupX + groupWidth / 2;
|
|
229
|
+
const ly = paddingTop + chartH + 14;
|
|
230
|
+
// Rotate labels when there are many buckets to prevent overlap
|
|
231
|
+
const shouldRotate = groupCount > 10 || granularity === 'hour';
|
|
232
|
+
const text = createEl('text', {
|
|
233
|
+
x: lx,
|
|
234
|
+
y: ly,
|
|
235
|
+
fill: '#8892b0',
|
|
236
|
+
'font-size': '9',
|
|
237
|
+
'text-anchor': shouldRotate ? 'end' : 'middle',
|
|
238
|
+
...(shouldRotate ? { transform: `rotate(-40 ${lx} ${ly})` } : {}),
|
|
239
|
+
});
|
|
240
|
+
text.textContent = label;
|
|
241
|
+
svg.appendChild(text);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Legend
|
|
246
|
+
const legendY = svgHeight - 12;
|
|
247
|
+
const legendItems = [
|
|
248
|
+
{ label: 'Sessions', color: colors.session },
|
|
249
|
+
{ label: 'Prompts', color: colors.prompt },
|
|
250
|
+
{ label: 'Tool Calls', color: colors.tool },
|
|
251
|
+
];
|
|
252
|
+
legendItems.forEach((item, i) => {
|
|
253
|
+
const lx = paddingLeft + i * 100;
|
|
254
|
+
const rect = createEl('rect', {
|
|
255
|
+
x: lx, y: legendY - 8,
|
|
256
|
+
width: 8, height: 8,
|
|
257
|
+
rx: 1, fill: item.color,
|
|
258
|
+
opacity: 0.85,
|
|
259
|
+
});
|
|
260
|
+
svg.appendChild(rect);
|
|
261
|
+
const text = createEl('text', {
|
|
262
|
+
x: lx + 12, y: legendY,
|
|
263
|
+
fill: '#8892b0', 'font-size': '9',
|
|
264
|
+
});
|
|
265
|
+
text.textContent = item.label;
|
|
266
|
+
svg.appendChild(text);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
container.appendChild(svg);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function createEl(tag, attrs) {
|
|
273
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
274
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
275
|
+
el.setAttribute(k, String(v));
|
|
276
|
+
}
|
|
277
|
+
return el;
|
|
278
|
+
}
|