codexmate 0.0.34 → 0.0.37

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.
Files changed (34) hide show
  1. package/README.md +14 -5
  2. package/README.zh.md +14 -5
  3. package/cli.js +74 -61
  4. package/package.json +2 -1
  5. package/web-ui/app.js +32 -2
  6. package/web-ui/index.html +1 -1
  7. package/web-ui/logic.sessions.mjs +6 -5
  8. package/web-ui/modules/app.computed.dashboard.mjs +4 -0
  9. package/web-ui/modules/app.computed.session.mjs +147 -6
  10. package/web-ui/modules/app.methods.claude-config.mjs +4 -0
  11. package/web-ui/modules/app.methods.navigation.mjs +32 -16
  12. package/web-ui/modules/app.methods.session-browser.mjs +7 -0
  13. package/web-ui/modules/app.methods.session-trash.mjs +30 -0
  14. package/web-ui/modules/i18n.dict.mjs +5 -0
  15. package/web-ui/modules/sessions-filters-url.mjs +65 -12
  16. package/web-ui/modules/skills.methods.mjs +31 -0
  17. package/web-ui/partials/index/layout-header.html +20 -11
  18. package/web-ui/partials/index/panel-config-claude.html +5 -3
  19. package/web-ui/partials/index/panel-config-codex.html +1 -1
  20. package/web-ui/partials/index/panel-market.html +76 -149
  21. package/web-ui/partials/index/panel-sessions.html +2 -2
  22. package/web-ui/partials/index/panel-settings.html +4 -2
  23. package/web-ui/partials/index/panel-usage.html +111 -68
  24. package/web-ui/res/vue.runtime.global.prod.js +7 -0
  25. package/web-ui/res/web-ui-render.precompiled.js +7269 -0
  26. package/web-ui/session-helpers.mjs +15 -4
  27. package/web-ui/source-bundle.cjs +73 -1
  28. package/web-ui/styles/base-theme.css +10 -0
  29. package/web-ui/styles/layout-shell.css +65 -27
  30. package/web-ui/styles/navigation-panels.css +8 -0
  31. package/web-ui/styles/responsive.css +50 -9
  32. package/web-ui/styles/sessions-usage.css +501 -336
  33. package/web-ui/styles/skills-market.css +294 -0
  34. package/web-ui/styles/titles-cards.css +14 -0
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  [![Version](https://img.shields.io/npm/v/codexmate?style=flat-square&color=A179FF)](https://www.npmjs.com/package/codexmate)
16
16
  [![Build](https://img.shields.io/github/actions/workflow/status/SakuraByteCore/codexmate/release.yml?style=flat-square&color=44cc11)](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
17
17
  [![Downloads](https://img.shields.io/npm/dt/codexmate?style=flat-square)](https://www.npmjs.com/package/codexmate)
18
+ [![Install](https://img.shields.io/badge/install-brew%20%7C%20curl%20%7C%20npm-0A0?style=flat-square)](#install-via-homebrew-macos--linux)
18
19
  [![Platform](https://img.shields.io/badge/platform-Termux%20%7C%20Linux%20%7C%20macOS%20%7C%20Windows-555?style=flat-square)](#quick-start)
19
20
  [![Node](https://img.shields.io/node/v/codexmate?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)
20
21
  [![License](https://img.shields.io/npm/l/codexmate?style=flat-square)](LICENSE)
@@ -70,11 +71,19 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**:
70
71
 
71
72
  ## Quick Start
72
73
 
74
+ ### Install via Homebrew (macOS / Linux)
75
+
76
+ ```bash
77
+ brew tap SakuraByteCore/codexmate
78
+ brew install codexmate
79
+ ```
80
+
81
+ Requires [Node.js](https://nodejs.org/) (`brew install node` if not present).
82
+
73
83
  ### Install via npm
74
84
 
75
85
  ```bash
76
86
  npm install -g codexmate
77
- codexmate setup
78
87
  codexmate run
79
88
  ```
80
89
 
@@ -102,7 +111,7 @@ flowchart TD
102
111
  CLI[CLI]
103
112
  WebUI[Web UI]
104
113
  MCP[MCP Server]
105
-
114
+
106
115
  subgraph Mate [Codex Mate Core]
107
116
  API[HTTP API]
108
117
  Config[Config Engine]
@@ -110,7 +119,7 @@ flowchart TD
110
119
  Skills[Skills Market]
111
120
  Tasks[Task Runner]
112
121
  end
113
-
122
+
114
123
  subgraph Local [Local Filesystem]
115
124
  CodexDir[~/.codex]
116
125
  ClaudeDir[~/.claude]
@@ -120,9 +129,9 @@ flowchart TD
120
129
 
121
130
  User --> CLI & WebUI & MCP
122
131
  CLI & WebUI & MCP --> API
123
-
132
+
124
133
  API --> Config & Session & Skills & Tasks
125
-
134
+
126
135
  Config --> CodexDir & ClaudeDir & ClawDir
127
136
  Session --> State
128
137
  Skills --> Local
package/README.zh.md CHANGED
@@ -15,6 +15,7 @@
15
15
  [![Version](https://img.shields.io/npm/v/codexmate?style=flat-square&color=A179FF)](https://www.npmjs.com/package/codexmate)
16
16
  [![Build](https://img.shields.io/github/actions/workflow/status/SakuraByteCore/codexmate/release.yml?style=flat-square&color=44cc11)](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
17
17
  [![Downloads](https://img.shields.io/npm/dt/codexmate?style=flat-square)](https://www.npmjs.com/package/codexmate)
18
+ [![Install](https://img.shields.io/badge/install-brew%20%7C%20curl%20%7C%20npm-0A0?style=flat-square)](#homebrew-安装macos--linux)
18
19
  [![Platform](https://img.shields.io/badge/platform-Termux%20%7C%20Linux%20%7C%20macOS%20%7C%20Windows-555?style=flat-square)](#快速开始)
19
20
  [![Node](https://img.shields.io/node/v/codexmate?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)
20
21
  [![License](https://img.shields.io/npm/l/codexmate?style=flat-square)](LICENSE)
@@ -70,11 +71,19 @@
70
71
 
71
72
  ## 快速开始
72
73
 
74
+ ### Homebrew 安装(macOS / Linux)
75
+
76
+ ```bash
77
+ brew tap SakuraByteCore/codexmate
78
+ brew install codexmate
79
+ ```
80
+
81
+ 需要 [Node.js](https://nodejs.org/)(如未安装可执行 `brew install node`)。
82
+
73
83
  ### 通过 npm 安装
74
84
 
75
85
  ```bash
76
86
  npm install -g codexmate
77
- codexmate setup
78
87
  codexmate run
79
88
  ```
80
89
 
@@ -102,7 +111,7 @@ flowchart TD
102
111
  CLI[CLI 命令]
103
112
  WebUI[Web 界面]
104
113
  MCP[MCP 服务]
105
-
114
+
106
115
  subgraph Mate [Codex Mate 核心]
107
116
  API[HTTP API]
108
117
  Config[配置引擎]
@@ -110,7 +119,7 @@ flowchart TD
110
119
  Skills[Skills 市场]
111
120
  Tasks[任务运行器]
112
121
  end
113
-
122
+
114
123
  subgraph Local [本地文件系统]
115
124
  CodexDir[~/.codex]
116
125
  ClaudeDir[~/.claude]
@@ -120,9 +129,9 @@ flowchart TD
120
129
 
121
130
  User --> CLI & WebUI & MCP
122
131
  CLI & WebUI & MCP --> API
123
-
132
+
124
133
  API --> Config & Session & Skills & Tasks
125
-
134
+
126
135
  Config --> CodexDir & ClaudeDir & ClawDir
127
136
  Session --> State
128
137
  Skills --> Local
package/cli.js CHANGED
@@ -4879,27 +4879,29 @@ function listClaudeSessions(limit, options = {}) {
4879
4879
  }
4880
4880
  }
4881
4881
 
4882
- if (sessions.length === 0) {
4883
- const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
4884
- returnCount: scanCount,
4885
- maxFilesScanned,
4886
- ignoreSubPath: `${path.sep}subagents${path.sep}`
4882
+ // 补充扫描未索引的 .jsonl 文件(包括 sessions-index.json 中遗漏的会话)
4883
+ const seenFilePaths = new Set(sessions.map((item) => item.filePath).filter(Boolean));
4884
+ const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
4885
+ returnCount: scanCount,
4886
+ maxFilesScanned,
4887
+ ignoreSubPath: `${path.sep}subagents${path.sep}`
4888
+ });
4889
+ for (const filePath of fallbackFiles) {
4890
+ if (seenFilePaths.has(filePath)) continue;
4891
+ const summary = parseClaudeSessionSummary(filePath, {
4892
+ summaryReadBytes,
4893
+ titleReadBytes
4887
4894
  });
4888
- for (const filePath of fallbackFiles) {
4889
- const summary = parseClaudeSessionSummary(filePath, {
4890
- summaryReadBytes,
4891
- titleReadBytes
4892
- });
4893
- if (summary) {
4894
- sessions.push(attachSessionNativeStatus({
4895
- ...summary,
4896
- derived: isDerivedSessionFile(filePath)
4897
- }));
4898
- }
4895
+ if (summary) {
4896
+ sessions.push(attachSessionNativeStatus({
4897
+ ...summary,
4898
+ derived: isDerivedSessionFile(filePath)
4899
+ }));
4900
+ seenFilePaths.add(filePath);
4901
+ }
4899
4902
 
4900
- if (sessions.length >= targetCount) {
4901
- break;
4902
- }
4903
+ if (sessions.length >= targetCount) {
4904
+ break;
4903
4905
  }
4904
4906
  }
4905
4907
 
@@ -10058,9 +10060,10 @@ function watchPathsForRestart(targets, onChange) {
10058
10060
  }
10059
10061
  // #endregion watchPathsForRestart
10060
10062
 
10061
- function writeJsonResponse(res, statusCode, payload) {
10063
+ function writeJsonResponse(res, statusCode, payload, headers = {}) {
10062
10064
  const body = JSON.stringify(payload, null, 2);
10063
10065
  res.writeHead(statusCode, {
10066
+ ...headers,
10064
10067
  'Content-Type': 'application/json; charset=utf-8',
10065
10068
  'Content-Length': Buffer.byteLength(body, 'utf-8')
10066
10069
  });
@@ -10620,7 +10623,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10620
10623
  const securityHeaders = {
10621
10624
  'X-Content-Type-Options': 'nosniff',
10622
10625
  'X-Frame-Options': 'DENY',
10623
- 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
10626
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
10624
10627
  };
10625
10628
  const origWriteHead = res.writeHead.bind(res);
10626
10629
  res.writeHead = function (statusCode, headers) {
@@ -10649,34 +10652,15 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10649
10652
  || requestPath.startsWith('/download/')
10650
10653
  ) {
10651
10654
  const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
10652
- const isLoopback = !remoteAddr
10653
- || remoteAddr === '127.0.0.1'
10654
- || remoteAddr === '::1'
10655
- || remoteAddr === '::ffff:127.0.0.1';
10655
+ const isLoopback = !remoteAddr || isLoopbackRemoteAddress(remoteAddr);
10656
10656
  if (!isLoopback) {
10657
10657
  const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath;
10658
10658
  if (!checkRateLimit(rateLimitKey)) {
10659
- res.writeHead(429, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '60' });
10660
- res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
10659
+ writeJsonResponse(res, 429, { error: 'Rate limit exceeded' }, { 'Retry-After': '60' });
10661
10660
  return;
10662
10661
  }
10663
- const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
10664
- ? process.env.CODEXMATE_HTTP_TOKEN.trim()
10665
- : '';
10666
- if (!expected) {
10667
- sendJson(403, {
10668
- error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN or use --host 127.0.0.1)'
10669
- });
10670
- return;
10671
- }
10672
- const headers = req && req.headers && typeof req.headers === 'object' ? req.headers : {};
10673
- const rawAuth = typeof headers.authorization === 'string' ? headers.authorization.trim() : '';
10674
- const match = rawAuth ? rawAuth.match(/^bearer\s+(.+)$/i) : null;
10675
- const actual = match && match[1]
10676
- ? match[1].trim()
10677
- : (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
10678
- if (!actual || !safeTimingEqual(actual, expected)) {
10679
- sendJson(401, { error: 'Unauthorized' });
10662
+ const auth = assertRequestAuthorized(req, res);
10663
+ if (!auth.ok) {
10680
10664
  return;
10681
10665
  }
10682
10666
  }
@@ -11408,15 +11392,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11408
11392
  res.end(errorBody, 'utf-8');
11409
11393
  }
11410
11394
  });
11411
- } else if (requestPath === '/web-ui') {
11412
- try {
11413
- const html = readBundledWebUiHtml(htmlPath);
11414
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11415
- res.end(html);
11416
- } catch (error) {
11417
- writeWebUiAssetError(res, requestPath, error);
11418
- }
11395
+ } else if (requestPath === '/web-ui/index.html') {
11396
+ const rawUrl = typeof req.url === 'string' ? req.url : '';
11397
+ const queryIndex = rawUrl.indexOf('?');
11398
+ const query = queryIndex >= 0 ? rawUrl.slice(queryIndex) : '';
11399
+ res.writeHead(302, {
11400
+ 'Location': `/${query}`,
11401
+ 'Content-Type': 'text/plain; charset=utf-8',
11402
+ 'Cache-Control': 'no-store, max-age=0'
11403
+ });
11404
+ res.end('Found');
11419
11405
  } else if (requestPath.startsWith('/web-ui/')) {
11406
+ // Skip the /web-ui/ directory itself, which is handled above
11420
11407
  const normalized = path.normalize(requestPath).replace(/^([\\.\\/])+/, '');
11421
11408
  const filePath = path.join(__dirname, normalized);
11422
11409
  if (!isPathInside(filePath, webDir)) {
@@ -11425,11 +11412,22 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11425
11412
  return;
11426
11413
  }
11427
11414
  const relativePath = path.relative(webDir, filePath).replace(/\\/g, '/');
11415
+
11416
+ // Empty relativePath means direct /web-ui/ access - return 404
11417
+ if (relativePath === '' || relativePath === 'index.html') {
11418
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
11419
+ res.end('Not Found');
11420
+ return;
11421
+ }
11422
+
11428
11423
  const dynamicAsset = PUBLIC_WEB_UI_DYNAMIC_ASSETS.get(relativePath);
11429
11424
  if (dynamicAsset) {
11430
11425
  try {
11431
11426
  const assetBody = dynamicAsset.reader(filePath);
11432
- res.writeHead(200, { 'Content-Type': dynamicAsset.mime });
11427
+ res.writeHead(200, {
11428
+ 'Content-Type': dynamicAsset.mime,
11429
+ 'Cache-Control': 'no-store, max-age=0'
11430
+ });
11433
11431
  res.end(assetBody, 'utf-8');
11434
11432
  } catch (error) {
11435
11433
  writeWebUiAssetError(res, requestPath, error);
@@ -11456,7 +11454,10 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11456
11454
  : ext === '.json'
11457
11455
  ? 'application/json; charset=utf-8'
11458
11456
  : 'application/octet-stream';
11459
- res.writeHead(200, { 'Content-Type': mime });
11457
+ res.writeHead(200, {
11458
+ 'Content-Type': mime,
11459
+ 'Cache-Control': 'no-store, max-age=0'
11460
+ });
11460
11461
  fs.createReadStream(filePath).pipe(res);
11461
11462
  } else if (requestPath.startsWith('/download/')) {
11462
11463
  const fileName = requestPath.slice('/download/'.length);
@@ -11517,15 +11518,27 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11517
11518
  : ext === '.json'
11518
11519
  ? 'application/json; charset=utf-8'
11519
11520
  : 'application/octet-stream';
11520
- res.writeHead(200, { 'Content-Type': mime });
11521
+ res.writeHead(200, {
11522
+ 'Content-Type': mime,
11523
+ 'Cache-Control': 'no-store, max-age=0'
11524
+ });
11521
11525
  fs.createReadStream(filePath).pipe(res);
11522
11526
  } else {
11523
- try {
11524
- const html = readBundledWebUiHtml(htmlPath);
11525
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
11526
- res.end(html);
11527
- } catch (error) {
11528
- writeWebUiAssetError(res, requestPath, error);
11527
+ // Only serve HTML for root path; /web-ui returns 404.
11528
+ if (requestPath === '/') {
11529
+ try {
11530
+ const html = readBundledWebUiHtml(htmlPath);
11531
+ res.writeHead(200, {
11532
+ 'Content-Type': 'text/html; charset=utf-8',
11533
+ 'Cache-Control': 'no-store, max-age=0'
11534
+ });
11535
+ res.end(html);
11536
+ } catch (error) {
11537
+ writeWebUiAssetError(res, requestPath, error);
11538
+ }
11539
+ } else {
11540
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
11541
+ res.end('Not Found');
11529
11542
  }
11530
11543
  }
11531
11544
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.0.34",
3
+ "version": "0.0.37",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@iarna/toml": "^2.2.5",
49
+ "@vue/compiler-dom": "^3.5.30",
49
50
  "json5": "^2.2.3",
50
51
  "yauzl": "^3.2.1",
51
52
  "zip-lib": "^1.2.1"
package/web-ui/app.js CHANGED
@@ -7,8 +7,10 @@ import {
7
7
  import { createAppComputed } from './modules/app.computed.index.mjs';
8
8
  import { createAppMethods } from './modules/app.methods.index.mjs';
9
9
  import { loadConfigTemplateDiffConfirmEnabledFromStorage } from './modules/config-template-confirm-pref.mjs';
10
+ import { installWebUiUrlCanonicalization } from './modules/sessions-filters-url.mjs';
10
11
 
11
12
  document.addEventListener('DOMContentLoaded', () => {
13
+ installWebUiUrlCanonicalization();
12
14
  if (typeof Vue === 'undefined') {
13
15
  console.error('Vue 库未能在 DOMContentLoaded 触发前加载完成。');
14
16
  const fallbackTarget = document.querySelector('#app') || document.querySelector('[v-cloak]');
@@ -26,7 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
26
28
 
27
29
  const { createApp } = Vue;
28
30
 
29
- const app = createApp({
31
+ const appOptions = {
30
32
  data() {
31
33
  return {
32
34
  lang: 'zh',
@@ -230,6 +232,7 @@ document.addEventListener('DOMContentLoaded', () => {
230
232
  sessionPreviewHeaderEl: null,
231
233
  sessionPreviewHeaderResizeObserver: null,
232
234
  sessionListRenderEnabled: false,
235
+ preserveSessionRenderOnTabLeave: true,
233
236
  sessionListVisibleCount: 0,
234
237
  sessionListInitialBatchSize: 40,
235
238
  sessionListLoadStep: 80,
@@ -420,6 +423,27 @@ document.addEventListener('DOMContentLoaded', () => {
420
423
  },
421
424
 
422
425
  mounted() {
426
+ // URL 规范化:将 /web-ui/* 重定向到根路径 /
427
+ try {
428
+ const pathname = window.location.pathname;
429
+ if (pathname === '/web-ui' || pathname === '/web-ui/' || pathname === '/web-ui/index.html') {
430
+ const url = new URL(window.location.href);
431
+ url.pathname = '/';
432
+ // 移除查询参数和 hash,保持 URL 纯净
433
+ url.search = '';
434
+ url.hash = '';
435
+ window.location.replace(url.toString());
436
+ return;
437
+ }
438
+ // 清理任何查询参数和 hash,保持 URL 为 /
439
+ if (window.location.search || window.location.hash) {
440
+ const url = new URL(window.location.href);
441
+ url.search = '';
442
+ url.hash = '';
443
+ window.history.replaceState(null, '', url.toString());
444
+ }
445
+ } catch (_) {}
446
+
423
447
  if (typeof this.initI18n === 'function') {
424
448
  this.initI18n();
425
449
  }
@@ -648,7 +672,13 @@ document.addEventListener('DOMContentLoaded', () => {
648
672
 
649
673
  computed: createAppComputed(),
650
674
  methods: createAppMethods()
651
- });
675
+ };
676
+
677
+ if (typeof window.__CODEXMATE_WEB_UI_RENDER__ === 'function') {
678
+ appOptions.render = window.__CODEXMATE_WEB_UI_RENDER__;
679
+ }
680
+
681
+ const app = createApp(appOptions);
652
682
 
653
683
  app.mount('#app');
654
684
  });
package/web-ui/index.html CHANGED
@@ -6,7 +6,7 @@
6
6
  <title>Codex Mate</title>
7
7
  <link rel="icon" type="image/webp" href="/res/logo-pack.webp">
8
8
  <link rel="apple-touch-icon" href="/res/logo-pack.webp">
9
- <script src="/res/vue.global.prod.js"></script>
9
+ <script src="/res/vue.runtime.global.prod.js"></script>
10
10
  <script src="/res/json5.min.js"></script>
11
11
  <link rel="stylesheet" href="/web-ui/styles.css">
12
12
  </head>
@@ -180,12 +180,9 @@ export function formatSessionTimelineTimestamp(timestamp) {
180
180
  if (!value) return '';
181
181
 
182
182
  const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})(?::(\d{2}))?/);
183
- if (matched) {
184
- const second = matched[6] || '00';
185
- return `${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}:${second}`;
186
- }
183
+ if (!matched) return value;
187
184
 
188
- return value;
185
+ return `${matched[1]}-${matched[2]}-${matched[3]} ${matched[4]}:${matched[5]}`;
189
186
  }
190
187
 
191
188
  function normalizeUsageRange(range) {
@@ -549,11 +546,15 @@ export function buildUsageChartGroups(sessions = [], options = {}) {
549
546
  String(messageCount),
550
547
  String(sessionIndex)
551
548
  ].join(':'),
549
+ sessionId: session.sessionId || '',
550
+ filePath: session.filePath || '',
552
551
  title: normalizedTitle,
553
552
  source,
554
553
  sourceLabel,
555
554
  cwd,
556
555
  messageCount,
556
+ totalTokens: sessionTotalTokens,
557
+ contextWindow: sessionContextWindow,
557
558
  updatedAt: session.updatedAt || '',
558
559
  updatedAtMs,
559
560
  updatedAtLabel: formatSessionTimelineTimestamp(session.updatedAt || ''),
@@ -92,6 +92,10 @@ export function createDashboardComputed() {
92
92
  return list;
93
93
  },
94
94
 
95
+ isLocalProviderDisabled() {
96
+ return this.configMode === 'codex';
97
+ },
98
+
95
99
  displayProviderUrl() {
96
100
  return (provider) => {
97
101
  if (provider && provider.name === 'local') return '';
@@ -602,11 +602,10 @@ export function createSessionComputed() {
602
602
  const sessions = this.sessionUsageCharts && Array.isArray(this.sessionUsageCharts.filteredSessions)
603
603
  ? this.sessionUsageCharts.filteredSessions
604
604
  : this.sessionsUsageList;
605
- const compareEnabled = this.sessionsUsageCompareEnabled === true && this.sessionsUsageTimeRange !== 'all';
606
605
  const rangeDays = this.sessionsUsageTimeRange === '30d' ? 30 : 7;
607
606
  const dayMs = 24 * 60 * 60 * 1000;
608
607
  const baseMs = Date.parse(`${dayKey}T00:00:00.000Z`);
609
- const prevKey = compareEnabled && Number.isFinite(baseMs)
608
+ const prevKey = Number.isFinite(baseMs)
610
609
  ? new Date(baseMs - (rangeDays * dayMs)).toISOString().slice(0, 10)
611
610
  : '';
612
611
  let sessionCount = 0;
@@ -636,7 +635,7 @@ export function createSessionComputed() {
636
635
  } else if (isPrev) {
637
636
  prevTokenTotal += sessionTokens;
638
637
  }
639
- const model = typeof session.model === 'string' ? session.model.trim() : '';
638
+ const model = typeof session.model === 'string' ? session.model.trim() : '';
640
639
  if (isCurrent && model) {
641
640
  modelMap.set(model, (modelMap.get(model) || 0) + 1);
642
641
  }
@@ -667,20 +666,162 @@ export function createSessionComputed() {
667
666
  }));
668
667
  return {
669
668
  dayKey,
670
- compareEnabled,
671
669
  prevKey,
672
670
  sessionCount,
673
671
  messageCount,
674
672
  tokenTotal,
675
673
  tokenLabel: formatUsageSummaryNumber(tokenTotal),
676
674
  prevTokenTotal,
677
- prevTokenLabel: compareEnabled ? formatUsageSummaryNumber(prevTokenTotal) : '0',
678
- deltaTokenLabel: compareEnabled ? formatSignedUsageSummaryNumber(tokenTotal - prevTokenTotal) : '0',
675
+ prevTokenLabel: prevKey ? formatUsageSummaryNumber(prevTokenTotal) : null,
676
+ deltaTokenLabel: prevKey ? formatSignedUsageSummaryNumber(tokenTotal - prevTokenTotal) : null,
679
677
  topSessions,
680
678
  topModels
681
679
  };
682
680
  },
683
681
 
682
+ usageHeroMainValue() {
683
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
684
+ ? this.sessionUsageCharts.summary
685
+ : null;
686
+ if (!summary) return '0';
687
+ return formatCompactUsageSummaryNumber(summary.totalTokens || 0);
688
+ },
689
+
690
+ usageHeroSubLabel() {
691
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
692
+ ? this.sessionUsageCharts.summary
693
+ : null;
694
+ if (!summary) return '';
695
+ const t = typeof this.t === 'function' ? this.t : null;
696
+ const sessionCount = summary.totalSessions || 0;
697
+ const rangeLabel = this.sessionsUsageTimeRange === '30d' ? '30天' : (this.sessionsUsageTimeRange === 'all' ? '全部' : '7天');
698
+ const rangeText = t ? t('usage.range.' + this.sessionsUsageTimeRange) : rangeLabel;
699
+ return `${formatUsageSummaryNumber(sessionCount)} sessions · ${rangeText}`;
700
+ },
701
+
702
+ usageHeroDelta() {
703
+ const range = this.sessionsUsageTimeRange;
704
+ if (range === 'all') return null;
705
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
706
+ ? this.sessionUsageCharts.summary
707
+ : null;
708
+ if (!summary || !summary.totalTokens) return null;
709
+
710
+ const rangeDays = range === '30d' ? 30 : 7;
711
+ const dayMs = 24 * 60 * 60 * 1000;
712
+ const nowMs = Date.now();
713
+ const prevStartMs = nowMs - (rangeDays * 2 * dayMs);
714
+ const prevEndMs = nowMs - (rangeDays * dayMs);
715
+
716
+ let prevTokens = 0;
717
+ for (const session of (Array.isArray(this.sessionsUsageList) ? this.sessionsUsageList : [])) {
718
+ if (!session || typeof session !== 'object') continue;
719
+ const updatedAtMs = Date.parse(session.updatedAt || '');
720
+ if (!Number.isFinite(updatedAtMs)) continue;
721
+ if (updatedAtMs >= prevStartMs && updatedAtMs < prevEndMs) {
722
+ const sessionTokens = Number.isFinite(Number(session.totalTokens))
723
+ ? Math.max(0, Math.floor(Number(session.totalTokens)))
724
+ : 0;
725
+ prevTokens += sessionTokens;
726
+ }
727
+ }
728
+
729
+ if (prevTokens === 0) return null;
730
+ const currentTokens = summary.totalTokens;
731
+ const delta = currentTokens - prevTokens;
732
+ const deltaPercent = prevTokens > 0 ? Math.round((delta / prevTokens) * 100) : 0;
733
+ const arrow = delta > 0 ? '↑' : (delta < 0 ? '↓' : '–');
734
+ const sign = delta >= 0 ? '+' : '';
735
+ return `${arrow} ${sign}${deltaPercent}%`;
736
+ },
737
+
738
+ usageHeroDeltaClass() {
739
+ const summary = this.sessionUsageCharts && this.sessionUsageCharts.summary
740
+ ? this.sessionUsageCharts.summary
741
+ : null;
742
+ if (!summary || !summary.totalTokens) return '';
743
+
744
+ const range = this.sessionsUsageTimeRange;
745
+ if (range === 'all') return '';
746
+
747
+ const rangeDays = range === '30d' ? 30 : 7;
748
+ const dayMs = 24 * 60 * 60 * 1000;
749
+ const nowMs = Date.now();
750
+ const prevStartMs = nowMs - (rangeDays * 2 * dayMs);
751
+ const prevEndMs = nowMs - (rangeDays * dayMs);
752
+
753
+ let prevTokens = 0;
754
+ for (const session of (Array.isArray(this.sessionsUsageList) ? this.sessionsUsageList : [])) {
755
+ if (!session || typeof session !== 'object') continue;
756
+ const updatedAtMs = Date.parse(session.updatedAt || '');
757
+ if (!Number.isFinite(updatedAtMs)) continue;
758
+ if (updatedAtMs >= prevStartMs && updatedAtMs < prevEndMs) {
759
+ const sessionTokens = Number.isFinite(Number(session.totalTokens))
760
+ ? Math.max(0, Math.floor(Number(session.totalTokens)))
761
+ : 0;
762
+ prevTokens += sessionTokens;
763
+ }
764
+ }
765
+
766
+ if (prevTokens === 0) return '';
767
+ const currentTokens = summary.totalTokens;
768
+ return currentTokens >= prevTokens ? 'delta-up' : 'delta-down';
769
+ },
770
+
771
+ sessionsUsageSelectedDay() {
772
+ return this.sessionsUsageSelectedDayKey || '';
773
+ },
774
+
775
+ sessionUsageWave() {
776
+ const daily = this.sessionUsageDaily && typeof this.sessionUsageDaily === 'object'
777
+ ? this.sessionUsageDaily
778
+ : null;
779
+ if (!daily || !Array.isArray(daily.rows) || daily.rows.length === 0) {
780
+ return { points: [], labels: [], linePath: '', areaPath: '', width: 800, maxTokens: 0 };
781
+ }
782
+
783
+ const rows = daily.rows;
784
+ const maxTokens = daily.maxTokens || 1;
785
+ const width = 800;
786
+ const height = 140;
787
+ const padding = { top: 10, bottom: 30, left: 0, right: 0 };
788
+ const chartWidth = width - padding.left - padding.right;
789
+ const chartHeight = height - padding.top - padding.bottom;
790
+
791
+ const points = rows.map((row, index) => {
792
+ const x = padding.left + (index / (rows.length - 1 || 1)) * chartWidth;
793
+ const normalizedValue = maxTokens > 0 ? (row.tokenTotal / maxTokens) : 0;
794
+ const y = padding.top + chartHeight - (normalizedValue * chartHeight);
795
+ return { x, y, key: row.key, value: row.tokenTotal, label: row.label };
796
+ });
797
+
798
+ const linePath = points.length > 1
799
+ ? `M ${points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L ')}`
800
+ : '';
801
+
802
+ const areaPath = points.length > 1
803
+ ? `${linePath} L ${points[points.length - 1].x.toFixed(1)},${(padding.top + chartHeight).toFixed(1)} L ${points[0].x.toFixed(1)},${(padding.top + chartHeight).toFixed(1)} Z`
804
+ : '';
805
+
806
+ const selectedKey = this.sessionsUsageSelectedDayKey;
807
+ const selectedPoint = points.find(p => p.key === selectedKey) || points[points.length - 1] || null;
808
+
809
+ return {
810
+ points,
811
+ labels: rows.map((row, index) => ({
812
+ key: row.key,
813
+ text: row.label
814
+ })),
815
+ linePath,
816
+ areaPath,
817
+ width,
818
+ height,
819
+ maxTokens,
820
+ hoverX: selectedPoint ? selectedPoint.x : 0,
821
+ hoverY: selectedPoint ? selectedPoint.y : 0
822
+ };
823
+ },
824
+
684
825
  visibleSessionTrashItems() {
685
826
  const items = Array.isArray(this.sessionTrashItems) ? this.sessionTrashItems : [];
686
827
  const visibleCount = Number(this.sessionTrashVisibleCount);
@@ -266,6 +266,10 @@ export function createClaudeConfigMethods(options = {}) {
266
266
  return this.claudeLocalBridgeCandidateProviders().some(p => p.hasKey);
267
267
  },
268
268
 
269
+ isClaudeLocalBridgeDisabled() {
270
+ return this.configMode === 'claude';
271
+ },
272
+
269
273
  async applyClaudeLocalBridge() {
270
274
  this.currentClaudeConfig = 'claude-local';
271
275
  try { localStorage.setItem('currentClaudeConfig', 'claude-local'); } catch (_) {}