codex-lens 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AdonisZeng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # Codex-Lens
2
+ 一个可视化的 Codex 任务管理与代码审查工具
package/build.js ADDED
@@ -0,0 +1,67 @@
1
+ import { build } from 'esbuild';
2
+ import { rmSync, existsSync, mkdirSync, cpSync } from 'fs';
3
+ import { resolve, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const outDir = resolve(__dirname, 'dist');
8
+
9
+ if (existsSync(outDir)) {
10
+ rmSync(outDir, { recursive: true });
11
+ }
12
+
13
+ mkdirSync(outDir);
14
+
15
+ async function buildAll() {
16
+ const backendFiles = [
17
+ 'src/cli.js',
18
+ 'src/proxy.js',
19
+ 'src/aggregator.js',
20
+ 'src/watcher.js',
21
+ 'src/lib/sse-parser.js',
22
+ 'src/lib/diff-builder.js',
23
+ 'src/lib/log-manager.js',
24
+ ];
25
+
26
+ for (const file of backendFiles) {
27
+ try {
28
+ await build({
29
+ entryPoints: [resolve(__dirname, file)],
30
+ outfile: resolve(__dirname, file.replace('src/', 'dist/')),
31
+ bundle: false,
32
+ platform: 'node',
33
+ target: 'node18',
34
+ format: 'esm',
35
+ banner: {
36
+ js: '#!/usr/bin/env node',
37
+ },
38
+ });
39
+ console.log(`Built: ${file}`);
40
+ } catch (e) {
41
+ console.error(`Failed to build ${file}:`, e.message);
42
+ }
43
+ }
44
+
45
+ await build({
46
+ entryPoints: [resolve(__dirname, 'src/main.jsx')],
47
+ outfile: resolve(__dirname, 'dist/main.js'),
48
+ bundle: true,
49
+ platform: 'browser',
50
+ target: 'es2020',
51
+ format: 'esm',
52
+ jsx: 'automatic',
53
+ loader: {
54
+ '.js': 'jsx',
55
+ '.jsx': 'jsx',
56
+ },
57
+ external: [],
58
+ });
59
+ console.log('Built: src/main.jsx -> dist/main.js');
60
+
61
+ if (existsSync(resolve(__dirname, 'public'))) {
62
+ cpSync(resolve(__dirname, 'public'), resolve(outDir, 'public'), { recursive: true });
63
+ console.log('Copied: public/ -> dist/public/');
64
+ }
65
+ }
66
+
67
+ buildAll().catch(console.error);
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "http";
3
+ import { createHash, createHash as cryptoCreateHash } from "crypto";
4
+ import { WebSocketServer } from "ws";
5
+ import express from "express";
6
+ import { createProxyServer } from "./proxy.js";
7
+ import { FileWatcher, scanDirectory } from "./watcher.js";
8
+ import { createLogger } from "./lib/logger.js";
9
+ import { spawnCodex, writeToPty, resizePty, killPty, onPtyData, onPtyExit, getPtyState } from "./pty-manager.js";
10
+ import { SnapshotManager } from "./snapshot-manager.js";
11
+ import path from "path";
12
+ import { fileURLToPath } from "url";
13
+ import { readFileSync, existsSync } from "fs";
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const HTTP_PORT = 5174;
16
+ const NPM_PACKAGE_NAME = "codex-lens";
17
+ const logger = createLogger("Aggregator");
18
+ let latestVersion = null;
19
+ let currentVersion = null;
20
+ async function fetchLatestVersion() {
21
+ try {
22
+ const response = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`);
23
+ if (response.ok) {
24
+ const data = await response.json();
25
+ latestVersion = data.version;
26
+ logger.info(`Latest ${NPM_PACKAGE_NAME} version: ${latestVersion}`);
27
+ }
28
+ } catch (error) {
29
+ logger.warn(`Failed to fetch latest version: ${error.message}`);
30
+ }
31
+ }
32
+ function getCurrentVersion() {
33
+ if (currentVersion) return currentVersion;
34
+ try {
35
+ const packageJson = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
36
+ currentVersion = packageJson.version;
37
+ } catch {
38
+ currentVersion = "0.0.0";
39
+ }
40
+ return currentVersion;
41
+ }
42
+ class Aggregator {
43
+ constructor(codexBinary, projectRoot) {
44
+ this.codexBinary = codexBinary;
45
+ this.projectRoot = projectRoot;
46
+ this.clients = /* @__PURE__ */ new Set();
47
+ this.terminalClients = /* @__PURE__ */ new Set();
48
+ this.httpServer = null;
49
+ this.wss = null;
50
+ this.proxyServer = null;
51
+ this.fileWatcher = null;
52
+ this.ptyProcess = null;
53
+ this.snapshotManager = new SnapshotManager();
54
+ this.currentTaskId = null;
55
+ this.taskStatus = "idle";
56
+ }
57
+ async start(proxyPort) {
58
+ await fetchLatestVersion();
59
+ const app = express();
60
+ const publicPath = path.join(__dirname, "public");
61
+ app.use(express.static(publicPath));
62
+ app.use((req, res, next) => {
63
+ if (req.url === "/" || req.url === "/index.html") {
64
+ res.send(this.getIndexHtml());
65
+ } else if (req.url === "/api/status") {
66
+ const current = getCurrentVersion();
67
+ res.json({
68
+ status: "running",
69
+ clients: this.clients.size,
70
+ codexRunning: !!this.ptyProcess,
71
+ version: current,
72
+ latestVersion,
73
+ hasUpdate: latestVersion && latestVersion !== current
74
+ });
75
+ } else {
76
+ next();
77
+ }
78
+ });
79
+ await new Promise((resolve) => {
80
+ this.httpServer = createServer(app);
81
+ this.httpServer.on("upgrade", (req, socket, head) => {
82
+ const pathname = new URL(req.url, `http://${req.headers.host}`).pathname;
83
+ logger.info(`Upgrade: ${pathname}`);
84
+ if (pathname === "/ws/terminal") {
85
+ this.handleTerminalUpgrade(req, socket, head);
86
+ } else if (pathname === "/ws") {
87
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
88
+ this.wss.emit("connection", ws, req);
89
+ });
90
+ } else {
91
+ socket.destroy();
92
+ }
93
+ });
94
+ this.httpServer.listen(HTTP_PORT, () => {
95
+ logger.info(`HTTP server started on port ${HTTP_PORT}`);
96
+ resolve();
97
+ });
98
+ });
99
+ this.wss = new WebSocketServer({ noServer: true });
100
+ this.setupMainWebSocket();
101
+ this.proxyServer = createProxyServer((event) => this.broadcast(event), proxyPort);
102
+ await this.proxyServer.start();
103
+ this.fileWatcher = new FileWatcher(this.projectRoot, (event) => this.broadcast(event));
104
+ await this.fileWatcher.start();
105
+ await this.startCodex(proxyPort);
106
+ return { httpPort: HTTP_PORT, proxyPort };
107
+ }
108
+ setupMainWebSocket() {
109
+ this.wss.on("connection", (ws) => {
110
+ logger.info("API client connected");
111
+ this.clients.add(ws);
112
+ const fileTree = scanDirectory(this.projectRoot);
113
+ logger.info(`Sending file_tree with ${fileTree.length} top-level items`);
114
+ ws.send(JSON.stringify({ type: "file_tree", data: fileTree }));
115
+ ws.on("close", () => {
116
+ logger.info("API client disconnected");
117
+ this.clients.delete(ws);
118
+ });
119
+ ws.on("message", (message) => {
120
+ try {
121
+ const data = JSON.parse(message.toString());
122
+ this.handleClientMessage(data, ws);
123
+ } catch (e) {
124
+ logger.error(`Invalid message: ${e.message}`);
125
+ }
126
+ });
127
+ });
128
+ }
129
+ async startCodex(proxyPort) {
130
+ this.ptyProcess = await spawnCodex(this.codexBinary, this.projectRoot, proxyPort);
131
+ logger.info(`Codex PTY spawned with PID: ${this.ptyProcess.pid}`);
132
+ onPtyData((data) => {
133
+ this.broadcastToTerminal({ type: "data", data });
134
+ });
135
+ onPtyExit((exitCode) => {
136
+ logger.info(`Codex PTY exited with code: ${exitCode}`);
137
+ this.broadcastToTerminal({ type: "exit", exitCode });
138
+ });
139
+ }
140
+ handleTerminalUpgrade(req, socket, head) {
141
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
142
+ logger.info("Terminal WebSocket connected");
143
+ this.handleTerminalConnection(ws);
144
+ });
145
+ }
146
+ handleTerminalConnection(ws) {
147
+ this.terminalClients.add(ws);
148
+ logger.info(`Terminal client connected. Total: ${this.terminalClients.size}`);
149
+ ws.on("message", (raw) => {
150
+ try {
151
+ const msg = JSON.parse(raw.toString());
152
+ if (msg.type === "input") {
153
+ writeToPty(msg.data);
154
+ } else if (msg.type === "resize") {
155
+ resizePty(msg.cols || 120, msg.rows || 30);
156
+ }
157
+ } catch (e) {
158
+ logger.error(`Terminal message error: ${e.message}`);
159
+ }
160
+ });
161
+ ws.on("close", () => {
162
+ this.terminalClients.delete(ws);
163
+ logger.info(`Terminal client disconnected. Total: ${this.terminalClients.size}`);
164
+ });
165
+ const state = getPtyState();
166
+ ws.send(JSON.stringify({ type: "state", ...state }));
167
+ }
168
+ broadcastToTerminal(event) {
169
+ const message = JSON.stringify(event);
170
+ for (const client of this.terminalClients) {
171
+ if (client.readyState === WebSocket.OPEN) {
172
+ client.send(message);
173
+ }
174
+ }
175
+ }
176
+ handleClientMessage(data, ws) {
177
+ if (data.type === "user_message") {
178
+ writeToPty(data.data + "\r");
179
+ } else if (data.type === "open_file") {
180
+ const result = this.fileWatcher.readFile(data.data);
181
+ if (result.error) {
182
+ ws.send(JSON.stringify({ type: "error", message: result.error }));
183
+ } else {
184
+ const extension = path.extname(result.path);
185
+ ws.send(JSON.stringify({
186
+ type: "file_content",
187
+ data: {
188
+ path: result.path,
189
+ content: result.content,
190
+ extension
191
+ }
192
+ }));
193
+ }
194
+ } else if (data.type === "start_task") {
195
+ this.handleStartTask(ws);
196
+ } else if (data.type === "rollback_task") {
197
+ this.handleRollbackTask(ws);
198
+ } else if (data.type === "complete_task") {
199
+ this.handleCompleteTask(ws);
200
+ } else if (data.type === "get_task_status") {
201
+ ws.send(JSON.stringify({
202
+ type: "task_status",
203
+ data: {
204
+ status: this.taskStatus,
205
+ taskId: this.currentTaskId
206
+ }
207
+ }));
208
+ }
209
+ }
210
+ async handleStartTask(ws) {
211
+ if (this.taskStatus === "running") {
212
+ ws.send(JSON.stringify({ type: "error", message: "Task already running" }));
213
+ return;
214
+ }
215
+ this.currentTaskId = Date.now().toString();
216
+ this.taskStatus = "running";
217
+ const result = await this.snapshotManager.createSnapshot(this.projectRoot, this.currentTaskId);
218
+ if (result.success) {
219
+ logger.info(`Task started: ${this.currentTaskId}, ${result.filesCount} files snapshotted`);
220
+ this.broadcast({
221
+ type: "task_status",
222
+ data: {
223
+ status: this.taskStatus,
224
+ taskId: this.currentTaskId,
225
+ filesCount: result.filesCount
226
+ }
227
+ });
228
+ ws.send(JSON.stringify({
229
+ type: "task_started",
230
+ data: {
231
+ taskId: this.currentTaskId,
232
+ filesCount: result.filesCount
233
+ }
234
+ }));
235
+ } else {
236
+ this.taskStatus = "idle";
237
+ this.currentTaskId = null;
238
+ ws.send(JSON.stringify({ type: "error", message: "Failed to create snapshot: " + result.error }));
239
+ }
240
+ }
241
+ async handleRollbackTask(ws) {
242
+ if (!this.currentTaskId || this.taskStatus !== "running") {
243
+ ws.send(JSON.stringify({ type: "error", message: "No active task to rollback" }));
244
+ return;
245
+ }
246
+ const taskId = this.currentTaskId;
247
+ const result = await this.snapshotManager.restoreSnapshot(taskId);
248
+ if (result.success) {
249
+ await this.snapshotManager.deleteSnapshot(taskId);
250
+ logger.info(`Task rolled back: ${taskId}, ${result.restoredCount} files restored`);
251
+ this.taskStatus = "idle";
252
+ this.currentTaskId = null;
253
+ this.broadcast({
254
+ type: "task_status",
255
+ data: {
256
+ status: this.taskStatus,
257
+ taskId: null
258
+ }
259
+ });
260
+ ws.send(JSON.stringify({
261
+ type: "task_rolled_back",
262
+ data: {
263
+ restoredCount: result.restoredCount
264
+ }
265
+ }));
266
+ } else {
267
+ ws.send(JSON.stringify({ type: "error", message: "Failed to rollback: " + result.error }));
268
+ }
269
+ }
270
+ async handleCompleteTask(ws) {
271
+ if (!this.currentTaskId || this.taskStatus !== "running") {
272
+ ws.send(JSON.stringify({ type: "error", message: "No active task to complete" }));
273
+ return;
274
+ }
275
+ const taskId = this.currentTaskId;
276
+ await this.snapshotManager.deleteSnapshot(taskId);
277
+ logger.info(`Task completed: ${taskId}`);
278
+ this.taskStatus = "idle";
279
+ this.currentTaskId = null;
280
+ this.broadcast({
281
+ type: "task_status",
282
+ data: {
283
+ status: this.taskStatus,
284
+ taskId: null
285
+ }
286
+ });
287
+ ws.send(JSON.stringify({
288
+ type: "task_completed",
289
+ data: {}
290
+ }));
291
+ }
292
+ broadcast(event) {
293
+ const message = JSON.stringify(event);
294
+ for (const client of this.clients) {
295
+ if (client.readyState === WebSocket.OPEN) {
296
+ client.send(message);
297
+ }
298
+ }
299
+ }
300
+ getIndexHtml() {
301
+ return `<!DOCTYPE html>
302
+ <html lang="zh-CN">
303
+ <head>
304
+ <meta charset="UTF-8">
305
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
306
+ <title>Codex Viewer</title>
307
+ <link rel="stylesheet" href="/lib/xterm/xterm.css">
308
+ <style>
309
+ :root {
310
+ --bg-primary: #1e1e1e;
311
+ --bg-secondary: #252526;
312
+ --bg-tertiary: #2d2d30;
313
+ --text-primary: #cccccc;
314
+ --text-secondary: #858585;
315
+ --border-color: #3c3c3c;
316
+ --accent-color: #007acc;
317
+ }
318
+ * { margin: 0; padding: 0; box-sizing: border-box; }
319
+ html, body, #root { height: 100%; width: 100%; overflow: hidden; }
320
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg-primary); color: var(--text-primary); }
321
+ .app { display: flex; height: 100vh; }
322
+ .panel { display: flex; flex-direction: column; border-right: 1px solid var(--border-color); overflow: hidden; }
323
+ .panel:last-child { border-right: none; }
324
+ .panel-header { padding: 8px 12px; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); font-weight: 600; font-size: 12px; text-transform: uppercase; color: var(--text-secondary); }
325
+ .panel-content { flex: 1; overflow: auto; padding: 8px; }
326
+ .file-tree .item { padding: 4px 8px; cursor: pointer; border-radius: 3px; display: flex; align-items: center; gap: 6px; }
327
+ .file-tree .item:hover { background: var(--bg-tertiary); }
328
+ .diff-line { font-family: 'SF Mono', Consolas, monospace; font-size: 13px; padding: 2px 12px; white-space: pre; }
329
+ .diff-line.added { background: #2d4a2d; color: #89d185; }
330
+ .diff-line.removed { background: #5a2d2d; color: #f48771; }
331
+ .diff-line::before { display: inline-block; width: 16px; margin-right: 8px; text-align: center; font-weight: bold; }
332
+ .diff-line.added::before { content: '+'; }
333
+ .diff-line.removed::before { content: '-'; }
334
+ .terminal-wrapper { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
335
+ .terminal-container { flex: 1; padding: 8px; overflow: hidden; background: #1e1e1e; }
336
+ .terminal-toolbar { display: flex; gap: 4px; padding: 8px; background: #252526; border-top: 1px solid var(--border-color); flex-wrap: wrap; }
337
+ .terminal-btn { padding: 4px 8px; background: #2d2d30; border: 1px solid #3c3c3c; border-radius: 3px; color: #cccccc; cursor: pointer; font-size: 12px; }
338
+ .terminal-btn:hover { background: #3c3c3c; }
339
+ .left-panel { width: 250px; min-width: 200px; }
340
+ .middle-panel { flex: 1; min-width: 400px; }
341
+ .right-panel { width: 45%; min-width: 350px; }
342
+ .loading { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary); }
343
+ </style>
344
+ </head>
345
+ <body>
346
+ <div id="root">
347
+ <div class="app">
348
+ <div class="panel left-panel">
349
+ <div class="panel-header">File Explorer</div>
350
+ <div class="panel-content" id="fileTree">
351
+ <div class="loading">Waiting for file changes...</div>
352
+ </div>
353
+ </div>
354
+ <div class="panel middle-panel">
355
+ <div class="panel-header" id="fileHeader">File Content</div>
356
+ <div class="panel-content" id="codeContent">
357
+ <div class="loading">Select a file to view diff...</div>
358
+ </div>
359
+ </div>
360
+ <div class="panel right-panel">
361
+ <div class="panel-header">Terminal</div>
362
+ <div class="terminal-wrapper">
363
+ <div class="terminal-container" id="terminalContainer"></div>
364
+ <div class="terminal-toolbar">
365
+ <button class="terminal-btn" onclick="sendKey('\\x1b[A')">Up</button>
366
+ <button class="terminal-btn" onclick="sendKey('\\x1b[B')">Down</button>
367
+ <button class="terminal-btn" onclick="sendKey('\\x1b[D')">Left</button>
368
+ <button class="terminal-btn" onclick="sendKey('\\x1b[C')">Right</button>
369
+ <button class="terminal-btn" onclick="sendKey('\\r')">Enter</button>
370
+ <button class="terminal-btn" onclick="sendKey('\\t')">Tab</button>
371
+ <button class="terminal-btn" onclick="sendKey('\\x03')">Ctrl+C</button>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </div>
376
+ </div>
377
+ <script src="/lib/xterm/xterm.js"></script>
378
+ <script src="/lib/xterm/addon-fit.js"></script>
379
+ <script src="/lib/xterm/addon-web-links.js"></script>
380
+ <script>
381
+ const term = new Terminal({
382
+ cursorBlink: true,
383
+ fontSize: 14,
384
+ fontFamily: '"SF Mono", "Fira Code", Consolas, monospace',
385
+ theme: {
386
+ background: '#1e1e1e',
387
+ foreground: '#cccccc',
388
+ cursor: '#ffffff',
389
+ selectionBackground: '#264f78',
390
+ },
391
+ scrollback: 10000,
392
+ });
393
+
394
+ const fitAddon = new FitAddon();
395
+ term.loadAddon(fitAddon);
396
+ term.loadAddon(new WebLinksAddon());
397
+ term.open(document.getElementById('terminalContainer'));
398
+ term.write('Connecting to Codex...\r
399
+ ');
400
+
401
+ const wsHost = window.location.hostname;
402
+ let terminalWs = null;
403
+
404
+ function connectTerminal() {
405
+ const port = window.location.port === '5173' ? '5174' : window.location.port;
406
+ terminalWs = new WebSocket('ws://' + wsHost + ':' + port + '/ws/terminal');
407
+
408
+ terminalWs.onopen = () => {
409
+ term.write('\r
410
+ \x1B[32mConnected to Codex!\x1B[0m\r
411
+ ');
412
+ };
413
+
414
+ terminalWs.onclose = () => {
415
+ term.write('\r
416
+ \x1B[33mDisconnected, reconnecting...\x1B[0m\r
417
+ ');
418
+ setTimeout(connectTerminal, 3000);
419
+ };
420
+
421
+ terminalWs.onerror = (e) => {
422
+ term.write('\r
423
+ \x1B[31mWebSocket Error\x1B[0m\r
424
+ ');
425
+ };
426
+
427
+ terminalWs.onmessage = (event) => {
428
+ const msg = JSON.parse(event.data);
429
+ if (msg.type === 'data') {
430
+ term.write(msg.data);
431
+ } else if (msg.type === 'exit') {
432
+ term.write('\r
433
+ \x1B[31m[Process exited with code ' + msg.exitCode + ']\x1B[0m\r
434
+ ');
435
+ }
436
+ };
437
+ }
438
+
439
+ connectTerminal();
440
+
441
+ term.onData((data) => {
442
+ if (terminalWs && terminalWs.readyState === WebSocket.OPEN) {
443
+ terminalWs.send(JSON.stringify({ type: 'input', data }));
444
+ }
445
+ });
446
+
447
+ window.sendKey = function(seq) {
448
+ if (terminalWs && terminalWs.readyState === WebSocket.OPEN) {
449
+ terminalWs.send(JSON.stringify({ type: 'input', data: seq }));
450
+ }
451
+ term.write(seq);
452
+ };
453
+
454
+ const resizeObserver = new ResizeObserver(() => {
455
+ try {
456
+ fitAddon.fit();
457
+ const dims = fitAddon.proposeDimensions();
458
+ if (dims && terminalWs && terminalWs.readyState === WebSocket.OPEN) {
459
+ terminalWs.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
460
+ }
461
+ } catch (e) {}
462
+ });
463
+ resizeObserver.observe(document.getElementById('terminalContainer'));
464
+
465
+ let apiWs = null;
466
+ const fileTree = document.getElementById('fileTree');
467
+ const codeContent = document.getElementById('codeContent');
468
+ let recentChanges = [];
469
+
470
+ const port = window.location.port === '5173' ? '5174' : window.location.port;
471
+ apiWs = new WebSocket('ws://' + wsHost + ':' + port + '/ws');
472
+
473
+ apiWs.onopen = () => console.log('[API] Connected');
474
+ apiWs.onclose = () => {
475
+ setTimeout(() => { apiWs = new WebSocket('ws://' + wsHost + ':' + port + '/ws'); }, 3000);
476
+ };
477
+
478
+ apiWs.onmessage = (event) => {
479
+ const msg = JSON.parse(event.data);
480
+ if (msg.type === 'file_change') {
481
+ recentChanges.unshift({ path: msg.data.path, time: Date.now(), diff: msg.data.diff, content: msg.data.newContent });
482
+ if (recentChanges.length > 10) recentChanges.pop();
483
+ renderRecentChanges();
484
+ }
485
+ };
486
+
487
+ function renderRecentChanges() {
488
+ let html = '<div style="padding:8px;font-size:11px;color:var(--text-secondary);border-bottom:1px solid var(--border-color)">RECENT CHANGES</div>';
489
+ recentChanges.forEach((c, i) => {
490
+ const fileName = c.path.split(/[/\\\\]/).pop();
491
+ const time = new Date(c.time).toLocaleTimeString();
492
+ html += '<div class="item" onclick="openFile(' + i + ')">' +
493
+ '<span>\u{1F4DD}</span><span style="flex:1">' + fileName + '</span><span style="font-size:10px;color:#858585">' + time + '</span>' +
494
+ '</div>';
495
+ });
496
+ if (recentChanges.length === 0) {
497
+ html += '<div style="padding:20px;color:#858585;text-align:center">No changes yet</div>';
498
+ }
499
+ fileTree.innerHTML = html;
500
+ }
501
+
502
+ window.openFile = function(index) {
503
+ const change = recentChanges[index];
504
+ if (change.diff && change.diff.some(d => d.added || d.removed)) {
505
+ codeContent.innerHTML = change.diff.map(line =>
506
+ '<div class="diff-line ' + (line.added ? 'added' : line.removed ? 'removed' : '') + '">' +
507
+ escapeHtml(line.content) + '</div>'
508
+ ).join('');
509
+ } else {
510
+ codeContent.innerHTML = '<pre style="padding:8px;white-space:pre-wrap">' + escapeHtml(change.content) + '</pre>';
511
+ }
512
+ };
513
+
514
+ function escapeHtml(str) {
515
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
516
+ }
517
+
518
+ renderRecentChanges();
519
+ </script>
520
+ </body>
521
+ </html>`;
522
+ }
523
+ stop() {
524
+ if (this.fileWatcher) this.fileWatcher.stop();
525
+ if (this.proxyServer) this.proxyServer.stop();
526
+ killPty();
527
+ if (this.wss) this.wss.close();
528
+ if (this.httpServer) this.httpServer.close();
529
+ logger.info("All services stopped");
530
+ }
531
+ }
532
+ function createAggregator(codexBinary, projectRoot) {
533
+ return new Aggregator(codexBinary, projectRoot);
534
+ }
535
+ export {
536
+ createAggregator
537
+ };