@siteboon/claude-code-ui 1.16.4 → 1.18.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.
@@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) {
384
384
  const config = await loadProjectConfig();
385
385
  const projects = [];
386
386
  const existingProjects = new Set();
387
+ const codexSessionsIndexRef = { sessionsByProject: null };
387
388
  let totalProjects = 0;
388
389
  let processedProjects = 0;
389
390
  let directories = [];
@@ -419,8 +420,6 @@ async function getProjects(progressCallback = null) {
419
420
  });
420
421
  }
421
422
 
422
- const projectPath = path.join(claudeDir, entry.name);
423
-
424
423
  // Extract actual project directory from JSONL sessions
425
424
  const actualProjectDir = await extractProjectDirectory(entry.name);
426
425
 
@@ -435,7 +434,11 @@ async function getProjects(progressCallback = null) {
435
434
  displayName: customName || autoDisplayName,
436
435
  fullPath: fullPath,
437
436
  isCustomName: !!customName,
438
- sessions: []
437
+ sessions: [],
438
+ sessionMeta: {
439
+ hasMore: false,
440
+ total: 0
441
+ }
439
442
  };
440
443
 
441
444
  // Try to get sessions for this project (just first 5 for performance)
@@ -448,6 +451,10 @@ async function getProjects(progressCallback = null) {
448
451
  };
449
452
  } catch (e) {
450
453
  console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
454
+ project.sessionMeta = {
455
+ hasMore: false,
456
+ total: 0
457
+ };
451
458
  }
452
459
 
453
460
  // Also fetch Cursor sessions for this project
@@ -460,7 +467,9 @@ async function getProjects(progressCallback = null) {
460
467
 
461
468
  // Also fetch Codex sessions for this project
462
469
  try {
463
- project.codexSessions = await getCodexSessions(actualProjectDir);
470
+ project.codexSessions = await getCodexSessions(actualProjectDir, {
471
+ indexRef: codexSessionsIndexRef,
472
+ });
464
473
  } catch (e) {
465
474
  console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
466
475
  project.codexSessions = [];
@@ -525,7 +534,7 @@ async function getProjects(progressCallback = null) {
525
534
  }
526
535
  }
527
536
 
528
- const project = {
537
+ const project = {
529
538
  name: projectName,
530
539
  path: actualProjectDir,
531
540
  displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
@@ -533,9 +542,13 @@ async function getProjects(progressCallback = null) {
533
542
  isCustomName: !!projectConfig.displayName,
534
543
  isManuallyAdded: true,
535
544
  sessions: [],
545
+ sessionMeta: {
546
+ hasMore: false,
547
+ total: 0
548
+ },
536
549
  cursorSessions: [],
537
550
  codexSessions: []
538
- };
551
+ };
539
552
 
540
553
  // Try to fetch Cursor sessions for manual projects too
541
554
  try {
@@ -546,7 +559,9 @@ async function getProjects(progressCallback = null) {
546
559
 
547
560
  // Try to fetch Codex sessions for manual projects too
548
561
  try {
549
- project.codexSessions = await getCodexSessions(actualProjectDir);
562
+ project.codexSessions = await getCodexSessions(actualProjectDir, {
563
+ indexRef: codexSessionsIndexRef,
564
+ });
550
565
  } catch (e) {
551
566
  console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
552
567
  }
@@ -1244,75 +1259,114 @@ async function getCursorSessions(projectPath) {
1244
1259
  }
1245
1260
 
1246
1261
 
1247
- // Fetch Codex sessions for a given project path
1248
- async function getCodexSessions(projectPath, options = {}) {
1249
- const { limit = 5 } = options;
1262
+ function normalizeComparablePath(inputPath) {
1263
+ if (!inputPath || typeof inputPath !== 'string') {
1264
+ return '';
1265
+ }
1266
+
1267
+ const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\')
1268
+ ? inputPath.slice(4)
1269
+ : inputPath;
1270
+ const normalized = path.normalize(withoutLongPathPrefix.trim());
1271
+
1272
+ if (!normalized) {
1273
+ return '';
1274
+ }
1275
+
1276
+ const resolved = path.resolve(normalized);
1277
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
1278
+ }
1279
+
1280
+ async function findCodexJsonlFiles(dir) {
1281
+ const files = [];
1282
+
1250
1283
  try {
1251
- const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1252
- const sessions = [];
1284
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1285
+ for (const entry of entries) {
1286
+ const fullPath = path.join(dir, entry.name);
1287
+ if (entry.isDirectory()) {
1288
+ files.push(...await findCodexJsonlFiles(fullPath));
1289
+ } else if (entry.name.endsWith('.jsonl')) {
1290
+ files.push(fullPath);
1291
+ }
1292
+ }
1293
+ } catch (error) {
1294
+ // Skip directories we can't read
1295
+ }
1253
1296
 
1254
- // Check if the directory exists
1297
+ return files;
1298
+ }
1299
+
1300
+ async function buildCodexSessionsIndex() {
1301
+ const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
1302
+ const sessionsByProject = new Map();
1303
+
1304
+ try {
1305
+ await fs.access(codexSessionsDir);
1306
+ } catch (error) {
1307
+ return sessionsByProject;
1308
+ }
1309
+
1310
+ const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
1311
+
1312
+ for (const filePath of jsonlFiles) {
1255
1313
  try {
1256
- await fs.access(codexSessionsDir);
1257
- } catch (error) {
1258
- // No Codex sessions directory
1259
- return [];
1260
- }
1314
+ const sessionData = await parseCodexSessionFile(filePath);
1315
+ if (!sessionData || !sessionData.id) {
1316
+ continue;
1317
+ }
1261
1318
 
1262
- // Recursively find all .jsonl files in the sessions directory
1263
- const findJsonlFiles = async (dir) => {
1264
- const files = [];
1265
- try {
1266
- const entries = await fs.readdir(dir, { withFileTypes: true });
1267
- for (const entry of entries) {
1268
- const fullPath = path.join(dir, entry.name);
1269
- if (entry.isDirectory()) {
1270
- files.push(...await findJsonlFiles(fullPath));
1271
- } else if (entry.name.endsWith('.jsonl')) {
1272
- files.push(fullPath);
1273
- }
1274
- }
1275
- } catch (error) {
1276
- // Skip directories we can't read
1319
+ const normalizedProjectPath = normalizeComparablePath(sessionData.cwd);
1320
+ if (!normalizedProjectPath) {
1321
+ continue;
1277
1322
  }
1278
- return files;
1279
- };
1280
1323
 
1281
- const jsonlFiles = await findJsonlFiles(codexSessionsDir);
1324
+ const session = {
1325
+ id: sessionData.id,
1326
+ summary: sessionData.summary || 'Codex Session',
1327
+ messageCount: sessionData.messageCount || 0,
1328
+ lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
1329
+ cwd: sessionData.cwd,
1330
+ model: sessionData.model,
1331
+ filePath,
1332
+ provider: 'codex',
1333
+ };
1282
1334
 
1283
- // Process each file to find sessions matching the project path
1284
- for (const filePath of jsonlFiles) {
1285
- try {
1286
- const sessionData = await parseCodexSessionFile(filePath);
1287
-
1288
- // Check if this session matches the project path
1289
- // Handle Windows long paths with \\?\ prefix
1290
- const sessionCwd = sessionData?.cwd || '';
1291
- const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd;
1292
- const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath;
1293
-
1294
- if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) {
1295
- sessions.push({
1296
- id: sessionData.id,
1297
- summary: sessionData.summary || 'Codex Session',
1298
- messageCount: sessionData.messageCount || 0,
1299
- lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
1300
- cwd: sessionData.cwd,
1301
- model: sessionData.model,
1302
- filePath: filePath,
1303
- provider: 'codex'
1304
- });
1305
- }
1306
- } catch (error) {
1307
- console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
1335
+ if (!sessionsByProject.has(normalizedProjectPath)) {
1336
+ sessionsByProject.set(normalizedProjectPath, []);
1308
1337
  }
1338
+
1339
+ sessionsByProject.get(normalizedProjectPath).push(session);
1340
+ } catch (error) {
1341
+ console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
1309
1342
  }
1343
+ }
1310
1344
 
1311
- // Sort sessions by last activity (newest first)
1345
+ for (const sessions of sessionsByProject.values()) {
1312
1346
  sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
1347
+ }
1348
+
1349
+ return sessionsByProject;
1350
+ }
1351
+
1352
+ // Fetch Codex sessions for a given project path
1353
+ async function getCodexSessions(projectPath, options = {}) {
1354
+ const { limit = 5, indexRef = null } = options;
1355
+ try {
1356
+ const normalizedProjectPath = normalizeComparablePath(projectPath);
1357
+ if (!normalizedProjectPath) {
1358
+ return [];
1359
+ }
1360
+
1361
+ if (indexRef && !indexRef.sessionsByProject) {
1362
+ indexRef.sessionsByProject = await buildCodexSessionsIndex();
1363
+ }
1364
+
1365
+ const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();
1366
+ const sessions = sessionsByProject.get(normalizedProjectPath) || [];
1313
1367
 
1314
1368
  // Return limited sessions for performance (0 = unlimited for deletion)
1315
- return limit > 0 ? sessions.slice(0, limit) : sessions;
1369
+ return limit > 0 ? sessions.slice(0, limit) : [...sessions];
1316
1370
 
1317
1371
  } catch (error) {
1318
1372
  console.error('Error fetching Codex sessions:', error);
@@ -209,6 +209,86 @@ Custom commands can be created in:
209
209
  };
210
210
  },
211
211
 
212
+ '/cost': async (args, context) => {
213
+ const tokenUsage = context?.tokenUsage || {};
214
+ const provider = context?.provider || 'claude';
215
+ const model =
216
+ context?.model ||
217
+ (provider === 'cursor'
218
+ ? CURSOR_MODELS.DEFAULT
219
+ : provider === 'codex'
220
+ ? CODEX_MODELS.DEFAULT
221
+ : CLAUDE_MODELS.DEFAULT);
222
+
223
+ const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
224
+ const total =
225
+ Number(
226
+ tokenUsage.total ??
227
+ tokenUsage.contextWindow ??
228
+ parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
229
+ ) || 160000;
230
+ const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
231
+
232
+ const inputTokensRaw =
233
+ Number(
234
+ tokenUsage.inputTokens ??
235
+ tokenUsage.input ??
236
+ tokenUsage.cumulativeInputTokens ??
237
+ tokenUsage.promptTokens ??
238
+ 0,
239
+ ) || 0;
240
+ const outputTokens =
241
+ Number(
242
+ tokenUsage.outputTokens ??
243
+ tokenUsage.output ??
244
+ tokenUsage.cumulativeOutputTokens ??
245
+ tokenUsage.completionTokens ??
246
+ 0,
247
+ ) || 0;
248
+ const cacheTokens =
249
+ Number(
250
+ tokenUsage.cacheReadTokens ??
251
+ tokenUsage.cacheCreationTokens ??
252
+ tokenUsage.cacheTokens ??
253
+ tokenUsage.cachedTokens ??
254
+ 0,
255
+ ) || 0;
256
+
257
+ // If we only have total used tokens, treat them as input for display/estimation.
258
+ const inputTokens =
259
+ inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
260
+
261
+ // Rough default rates by provider (USD / 1M tokens).
262
+ const pricingByProvider = {
263
+ claude: { input: 3, output: 15 },
264
+ cursor: { input: 3, output: 15 },
265
+ codex: { input: 1.5, output: 6 },
266
+ };
267
+ const rates = pricingByProvider[provider] || pricingByProvider.claude;
268
+
269
+ const inputCost = (inputTokens / 1_000_000) * rates.input;
270
+ const outputCost = (outputTokens / 1_000_000) * rates.output;
271
+ const totalCost = inputCost + outputCost;
272
+
273
+ return {
274
+ type: 'builtin',
275
+ action: 'cost',
276
+ data: {
277
+ tokenUsage: {
278
+ used,
279
+ total,
280
+ percentage,
281
+ },
282
+ cost: {
283
+ input: inputCost.toFixed(4),
284
+ output: outputCost.toFixed(4),
285
+ total: totalCost.toFixed(4),
286
+ },
287
+ model,
288
+ },
289
+ };
290
+ },
291
+
212
292
  '/status': async (args, context) => {
213
293
  // Read version from package.json
214
294
  const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
@@ -1,5 +1,5 @@
1
1
  import express from 'express';
2
- import { exec } from 'child_process';
2
+ import { exec, spawn } from 'child_process';
3
3
  import { promisify } from 'util';
4
4
  import path from 'path';
5
5
  import { promises as fs } from 'fs';
@@ -10,6 +10,43 @@ import { spawnCursor } from '../cursor-cli.js';
10
10
  const router = express.Router();
11
11
  const execAsync = promisify(exec);
12
12
 
13
+ function spawnAsync(command, args, options = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(command, args, {
16
+ ...options,
17
+ shell: false,
18
+ });
19
+
20
+ let stdout = '';
21
+ let stderr = '';
22
+
23
+ child.stdout.on('data', (data) => {
24
+ stdout += data.toString();
25
+ });
26
+
27
+ child.stderr.on('data', (data) => {
28
+ stderr += data.toString();
29
+ });
30
+
31
+ child.on('error', (error) => {
32
+ reject(error);
33
+ });
34
+
35
+ child.on('close', (code) => {
36
+ if (code === 0) {
37
+ resolve({ stdout, stderr });
38
+ return;
39
+ }
40
+
41
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
42
+ error.code = code;
43
+ error.stdout = stdout;
44
+ error.stderr = stderr;
45
+ reject(error);
46
+ });
47
+ });
48
+ }
49
+
13
50
  // Helper function to get the actual project path from the encoded project name
14
51
  async function getActualProjectPath(projectName) {
15
52
  try {
@@ -60,19 +97,16 @@ async function validateGitRepository(projectPath) {
60
97
  }
61
98
 
62
99
  try {
63
- // Use --show-toplevel to get the root of the git repository
64
- const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
65
- const normalizedGitRoot = path.resolve(gitRoot.trim());
66
- const normalizedProjectPath = path.resolve(projectPath);
67
-
68
- // Ensure the git root matches our project path (prevent using parent git repos)
69
- if (normalizedGitRoot !== normalizedProjectPath) {
70
- throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
71
- }
72
- } catch (error) {
73
- if (error.message.includes('Project directory is not a git repository')) {
74
- throw error;
100
+ // Allow any directory that is inside a work tree (repo root or nested folder).
101
+ const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
102
+ const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
103
+ if (!isInsideWorkTree) {
104
+ throw new Error('Not inside a git work tree');
75
105
  }
106
+
107
+ // Ensure git can resolve the repository root for this directory.
108
+ await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
109
+ } catch {
76
110
  throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
77
111
  }
78
112
  }
@@ -445,11 +479,17 @@ router.get('/commits', async (req, res) => {
445
479
 
446
480
  try {
447
481
  const projectPath = await getActualProjectPath(project);
482
+ await validateGitRepository(projectPath);
483
+ const parsedLimit = Number.parseInt(String(limit), 10);
484
+ const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
485
+ ? Math.min(parsedLimit, 100)
486
+ : 10;
448
487
 
449
488
  // Get commit log with stats
450
- const { stdout } = await execAsync(
451
- `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`,
452
- { cwd: projectPath }
489
+ const { stdout } = await spawnAsync(
490
+ 'git',
491
+ ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
492
+ { cwd: projectPath },
453
493
  );
454
494
 
455
495
  const commits = stdout
@@ -1125,4 +1165,4 @@ router.post('/delete-untracked', async (req, res) => {
1125
1165
  }
1126
1166
  });
1127
1167
 
1128
- export default router;
1168
+ export default router;