cc-viewer 1.6.0 → 1.6.2

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/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-D0IRVteu.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BawBkbaU.css">
9
+ <script type="module" crossorigin src="/assets/index-CzJcswn8.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-7ty6PCA6.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/interceptor.js CHANGED
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { dirname, join, basename } from 'node:path';
13
13
  import { LOG_DIR } from './findcc.js';
14
14
  import { assembleStreamMessage, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, migrateConversationContext } from './lib/interceptor-core.js';
15
- import { updateContextWindowFromResponse } from './lib/context-watcher.js';
15
+
16
16
 
17
17
 
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -434,10 +434,8 @@ export function setupInterceptor() {
434
434
  // 直接使用组装后的 message 对象作为 response.body
435
435
  // 如果组装失败(例如非标准 SSE),则使用原始流内容
436
436
  requestEntry.response.body = assembledMessage || streamedContent;
437
- // 非独占提取 context window 使用率
438
- if (assembledMessage && requestEntry.mainAgent) {
439
- updateContextWindowFromResponse(assembledMessage, requestEntry.body, requestEntry.body?.model);
440
- }
437
+
438
+
441
439
  // 移除在途请求标记,保持原始报文
442
440
  delete requestEntry.inProgress;
443
441
  delete requestEntry.requestId;
@@ -497,10 +495,8 @@ export function setupInterceptor() {
497
495
  headers: Object.fromEntries(response.headers.entries()),
498
496
  body: responseData
499
497
  };
500
- // 非独占提取 context window 使用率
501
- if (responseData && typeof responseData === 'object' && requestEntry.mainAgent) {
502
- updateContextWindowFromResponse(responseData, requestEntry.body, requestEntry.body?.model);
503
- }
498
+
499
+
504
500
  delete requestEntry.inProgress;
505
501
  delete requestEntry.requestId;
506
502
 
@@ -1,101 +1,81 @@
1
- import { readFileSync, writeFileSync, existsSync, watchFile } from 'node:fs';
1
+ import { readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  export const CONTEXT_WINDOW_FILE = join(homedir(), '.claude', 'context-window.json');
6
6
  export const CLAUDE_SETTINGS_FILE = join(homedir(), '.claude', 'settings.json');
7
7
 
8
- let _lastContextPct = -1;
9
- let _lastContextSize = -1;
8
+ // Startup cache: read once, never re-read unless model changes
9
+ let _startupModelBase = null; // e.g. 'opus-4-6'
10
+ let _startupContextSize = null; // e.g. 1000000
10
11
 
11
12
  /**
12
- * Watch context-window.json and push context usage to SSE clients.
13
- * @param {Array} clients - SSE client array (shared reference with server.js)
13
+ * Read context-window.json once at startup and cache model→size mapping.
14
+ * Extracts model base name (e.g. 'opus-4-6') and context size from model.id (e.g. 'claude-opus-4-6[1m]').
15
+ * @returns {{ modelId: string|null, contextSize: number }}
14
16
  */
15
- export function watchContextWindow(clients) {
16
- if (!existsSync(CONTEXT_WINDOW_FILE)) {
17
- setTimeout(() => watchContextWindow(clients), 5000);
18
- return;
19
- }
20
- watchFile(CONTEXT_WINDOW_FILE, { interval: 2000 }, () => {
21
- try {
22
- const raw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
23
- const data = JSON.parse(raw);
24
- const pct = data?.context_window?.used_percentage ?? -1;
25
- const size = data?.context_window?.context_window_size ?? -1;
26
- if (pct !== _lastContextPct || size !== _lastContextSize) {
27
- _lastContextPct = pct;
28
- _lastContextSize = size;
29
- clients.forEach(client => {
30
- try {
31
- client.write(`event: context_window\ndata: ${JSON.stringify(data.context_window)}\n\n`);
32
- } catch { }
33
- });
17
+ export function readModelContextSize() {
18
+ try {
19
+ if (!existsSync(CONTEXT_WINDOW_FILE)) return { modelId: null, contextSize: 200000 };
20
+ const raw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
21
+ const data = JSON.parse(raw);
22
+ const modelId = data?.model?.id || null;
23
+ let contextSize = 200000;
24
+ if (modelId) {
25
+ const lower = modelId.toLowerCase();
26
+ const sizeMatch = lower.match(/\[(\d+)([km])\]/);
27
+ if (sizeMatch) {
28
+ const num = parseInt(sizeMatch[1], 10);
29
+ contextSize = sizeMatch[2] === 'm' ? num * 1000000 : num * 1000;
34
30
  }
35
- } catch { }
36
- });
31
+ // Cache the base name → size mapping
32
+ const base = lower.replace(/^claude-/i, '').replace(/\[.*\]/, '').trim();
33
+ _startupModelBase = base;
34
+ _startupContextSize = contextSize;
35
+ }
36
+ return { modelId, contextSize };
37
+ } catch {
38
+ return { modelId: null, contextSize: 200000 };
39
+ }
37
40
  }
38
41
 
39
- // 已知模型的上下文窗口大小(tokens)
40
- const MODEL_CONTEXT_SIZES = {
41
- 'opus': 200000,
42
- 'sonnet': 200000,
43
- 'haiku': 200000,
44
- };
45
-
46
- // 从模型名中推断上下文窗口大小
47
- function inferContextWindowSize(model) {
48
- if (!model) return 200000;
49
- const lower = model.toLowerCase();
50
- // 检查是否带有显式大小标注,如 claude-opus-4-6[1m]
51
- const sizeMatch = lower.match(/\[(\d+)([km])\]/);
52
- if (sizeMatch) {
53
- const num = parseInt(sizeMatch[1], 10);
54
- return sizeMatch[2] === 'm' ? num * 1000000 : num * 1000;
55
- }
56
- for (const [key, size] of Object.entries(MODEL_CONTEXT_SIZES)) {
57
- if (lower.includes(key)) return size;
42
+ /**
43
+ * Get context size for a given API model name (e.g. 'claude-opus-4-6-20250514').
44
+ * Uses startup cache to avoid re-reading the file.
45
+ * @param {string} apiModelName - model name from req.body.model
46
+ * @returns {number} context window size in tokens
47
+ */
48
+ export function getContextSizeForModel(apiModelName) {
49
+ if (!apiModelName) return _startupContextSize || 200000;
50
+ const lower = apiModelName.toLowerCase();
51
+ // Extract base: 'claude-opus-4-6-20250514' → 'opus-4-6'
52
+ const base = lower.replace(/^claude-/i, '').replace(/-\d{8}$/, '').trim();
53
+ // Match against startup cache
54
+ if (_startupModelBase && base === _startupModelBase) {
55
+ return _startupContextSize;
58
56
  }
59
- return 200000; // 默认 200k
57
+ // Unknown model (e.g. after /model switch to a different model) → default 200K
58
+ return 200000;
60
59
  }
61
60
 
62
61
  /**
63
- * API 响应中提取 usage 信息,写入 context-window.json
64
- * 非独占方式:不修改用户的 statusLine 配置
62
+ * Build a context_window SSE event payload from API usage data.
63
+ * @param {object} usage - API response usage object
64
+ * @param {number} contextSize - total context window size in tokens
65
+ * @returns {object|null} context_window event data, or null if usage missing
65
66
  */
66
- export function updateContextWindowFromResponse(responseBody, requestBody, model) {
67
- try {
68
- const usage = responseBody?.usage;
69
- if (!usage) return;
70
-
71
- const inputTokens = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
72
- const outputTokens = usage.output_tokens || 0;
73
- const totalTokens = inputTokens + outputTokens;
74
- const contextWindowSize = inferContextWindowSize(model);
75
- const usedPct = Math.round((totalTokens / contextWindowSize) * 100);
76
-
77
- // 读取现有文件(如果存在),保留 Claude Code 写入的其他字段
78
- let existing = {};
79
- try {
80
- if (existsSync(CONTEXT_WINDOW_FILE)) {
81
- existing = JSON.parse(readFileSync(CONTEXT_WINDOW_FILE, 'utf-8'));
82
- }
83
- } catch { }
84
-
85
- // 仅更新 context_window 字段(如果 Claude Code 的 statusLine 也在写,它的数据更准确会覆盖我们的)
86
- // 保留 Claude Code 写入的 context_window_size(如 1M),仅在不存在时才用推断值
87
- const existingSize = existing.context_window?.context_window_size;
88
- const finalSize = (existingSize && existingSize > contextWindowSize) ? existingSize : contextWindowSize;
89
- const finalPct = Math.round(((inputTokens + outputTokens) / finalSize) * 100);
90
- existing.context_window = {
91
- total_input_tokens: inputTokens,
92
- total_output_tokens: outputTokens,
93
- context_window_size: finalSize,
94
- current_usage: usage,
95
- used_percentage: finalPct,
96
- remaining_percentage: 100 - finalPct,
97
- };
98
-
99
- writeFileSync(CONTEXT_WINDOW_FILE, JSON.stringify(existing) + '\n');
100
- } catch { }
67
+ export function buildContextWindowEvent(usage, contextSize) {
68
+ if (!usage) return null;
69
+ const inputTokens = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
70
+ const outputTokens = usage.output_tokens || 0;
71
+ const totalTokens = inputTokens + outputTokens;
72
+ const usedPct = Math.round((totalTokens / contextSize) * 100);
73
+ return {
74
+ total_input_tokens: inputTokens,
75
+ total_output_tokens: outputTokens,
76
+ context_window_size: contextSize,
77
+ current_usage: usage,
78
+ used_percentage: usedPct,
79
+ remaining_percentage: 100 - usedPct,
80
+ };
101
81
  }
@@ -1,5 +1,6 @@
1
- import { readFileSync, existsSync, watchFile } from 'node:fs';
1
+ import { readFileSync, existsSync, watchFile, openSync, readSync, closeSync, statSync } from 'node:fs';
2
2
  import { isMainAgentEntry, extractCachedContent } from './kv-cache-analyzer.js';
3
+ import { buildContextWindowEvent, getContextSizeForModel } from './context-watcher.js';
3
4
 
4
5
  // 跟踪所有被 watch 的日志文件
5
6
  const watchedFiles = new Map();
@@ -79,25 +80,64 @@ export function sendEventToClients(clients, eventName, data) {
79
80
  export function watchLogFile(opts) {
80
81
  const { logFile, clients, getClaudePid, runParallelHook, notifyStatsWorker, getLogFile } = opts;
81
82
  if (watchedFiles.has(logFile)) return;
82
- let lastSize = 0;
83
+
84
+ // Track byte offset instead of string length — avoids full-file re-read on every poll
85
+ let lastByteOffset = 0;
86
+ let pendingTail = ''; // incomplete entry carried across polls
83
87
  try {
84
88
  if (existsSync(logFile)) {
85
- lastSize = readFileSync(logFile, 'utf-8').length;
89
+ lastByteOffset = statSync(logFile).size;
86
90
  }
87
91
  } catch {}
92
+
88
93
  watchedFiles.set(logFile, true);
89
94
  watchFile(logFile, { interval: 500 }, () => {
90
95
  try {
91
- const content = readFileSync(logFile, 'utf-8');
92
- const newContent = content.slice(lastSize);
93
- lastSize = content.length;
96
+ const currentSize = statSync(logFile).size;
97
+
98
+ // File truncated (rotation or clear) — reset offset
99
+ if (currentSize < lastByteOffset) {
100
+ lastByteOffset = 0;
101
+ pendingTail = '';
102
+ }
94
103
 
95
- if (newContent.trim()) {
96
- const entries = newContent.split('\n---\n').filter(line => line.trim());
97
- entries.forEach(entry => {
104
+ if (currentSize <= lastByteOffset) return;
105
+
106
+ // Read only the new bytes
107
+ const bytesToRead = currentSize - lastByteOffset;
108
+ const buf = Buffer.alloc(bytesToRead);
109
+ const fd = openSync(logFile, 'r');
110
+ try {
111
+ readSync(fd, buf, 0, bytesToRead, lastByteOffset);
112
+ } finally {
113
+ closeSync(fd);
114
+ }
115
+ lastByteOffset = currentSize;
116
+
117
+ const raw = pendingTail + buf.toString('utf-8');
118
+ const parts = raw.split('\n---\n');
119
+
120
+ // Last part may be incomplete — keep it for next poll
121
+ pendingTail = parts.pop() || '';
122
+
123
+ // If there's only the tail and no complete entries, check if tail is a complete entry
124
+ // (happens when the file ends without a trailing \n---\n)
125
+ if (parts.length === 0 && pendingTail.trim()) {
126
+ try {
127
+ JSON.parse(pendingTail);
128
+ // Valid JSON — treat as complete entry
129
+ parts.push(pendingTail);
130
+ pendingTail = '';
131
+ } catch {
132
+ // Incomplete — keep in pendingTail for next poll
133
+ }
134
+ }
135
+
136
+ const validParts = parts.filter(p => p.trim());
137
+ if (validParts.length > 0) {
138
+ validParts.forEach(entry => {
98
139
  try {
99
140
  const parsed = JSON.parse(entry);
100
- // 注入 Claude 进程 PID:CLI 模式从 PTY 获取,非 CLI 模式使用当前进程 PID
101
141
  if (!parsed.pid) {
102
142
  parsed.pid = getClaudePid();
103
143
  }
@@ -108,19 +148,25 @@ export function watchLogFile(opts) {
108
148
  if (cached) {
109
149
  sendEventToClients(clients, 'kv_cache_content', cached);
110
150
  }
151
+ const usage = parsed.response?.body?.usage;
152
+ if (usage) {
153
+ const contextSize = getContextSizeForModel(parsed.body?.model);
154
+ const cwData = buildContextWindowEvent(usage, contextSize);
155
+ if (cwData) {
156
+ sendEventToClients(clients, 'context_window', cwData);
157
+ }
158
+ }
111
159
  }
112
160
  } catch (err) {
113
161
  // Skip invalid entries
114
162
  }
115
163
  });
116
- // 通知 stats worker 更新统计
117
164
  notifyStatsWorker(logFile);
118
165
  }
119
166
 
120
167
  // 检测日志文件是否已轮转到新文件
121
168
  const currentLogFile = getLogFile();
122
169
  if (currentLogFile !== logFile && !watchedFiles.has(currentLogFile)) {
123
- // 轮转发生,发送 full_reload 让客户端重新加载新文件
124
170
  const newEntries = readLogFile(currentLogFile);
125
171
  clients.forEach(client => {
126
172
  try {
@@ -143,13 +189,11 @@ export function watchLogFile(opts) {
143
189
  * @param {Function} opts.getClaudePid
144
190
  * @param {Function} opts.runParallelHook
145
191
  * @param {Function} opts.notifyStatsWorker
146
- * @param {Function} opts.watchContextWindow
147
192
  * @param {Function} opts.getLogFile
148
193
  */
149
194
  export function startWatching(opts) {
150
- const { watchContextWindow, clients, ...watchOpts } = opts;
195
+ const { clients, ...watchOpts } = opts;
151
196
  watchLogFile({ ...watchOpts, clients });
152
- watchContextWindow(clients);
153
197
  }
154
198
 
155
199
  /** Get the watchedFiles Map (for cleanup in stopViewer). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/pty-manager.js CHANGED
@@ -184,7 +184,57 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
184
184
  export function writeToPty(data) {
185
185
  if (ptyProcess) {
186
186
  ptyProcess.write(data);
187
+ return true;
187
188
  }
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Send chunks sequentially to PTY, waiting for PTY output between each.
194
+ * Designed for programmatic input (multi-select, paste, etc.) where
195
+ * the target application (e.g. inquirer) needs time to process each chunk.
196
+ * @param {string[]} chunks - array of input strings to send in order
197
+ * @param {Function} [onComplete] - called when all chunks are sent or on error
198
+ * @param {object} [opts] - { timeoutMs: per-chunk timeout (default 4000), settleMs: delay after ACK (default 150) }
199
+ */
200
+ export function writeToPtySequential(chunks, onComplete, opts = {}) {
201
+ const timeoutMs = opts.timeoutMs || 4000;
202
+ const settleMs = opts.settleMs || 150;
203
+
204
+ if (!ptyProcess || !chunks || chunks.length === 0) {
205
+ if (onComplete) onComplete(false);
206
+ return;
207
+ }
208
+
209
+ let idx = 0;
210
+ let dataListener = null;
211
+
212
+ const cleanup = () => {
213
+ if (dataListener) {
214
+ dataListeners = dataListeners.filter(l => l !== dataListener);
215
+ dataListener = null;
216
+ }
217
+ };
218
+
219
+ const sendNext = () => {
220
+ if (idx >= chunks.length || !ptyProcess) {
221
+ cleanup();
222
+ if (onComplete) onComplete(true);
223
+ return;
224
+ }
225
+
226
+ const chunk = chunks[idx];
227
+ idx++;
228
+
229
+ ptyProcess.write(chunk);
230
+
231
+ // Space, Enter, and Right arrow (→ for submit) need more time for inquirer
232
+ const isToggleOrSubmit = chunk === ' ' || chunk === '\r' || chunk === '\x1b[C';
233
+ const delay = isToggleOrSubmit ? settleMs : 80;
234
+ setTimeout(sendNext, delay);
235
+ };
236
+
237
+ sendNext();
188
238
  }
189
239
 
190
240
  /**
package/server.js CHANGED
@@ -40,7 +40,7 @@ import { checkAndUpdate } from './lib/updater.js';
40
40
  import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, PLUGINS_DIR } from './lib/plugin-loader.js';
41
41
  import { getUserProfile } from './lib/user-profile.js';
42
42
  import { getGitDiffs } from './lib/git-diff.js';
43
- import { watchContextWindow, CONTEXT_WINDOW_FILE } from './lib/context-watcher.js';
43
+ import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel } from './lib/context-watcher.js';
44
44
  import { readLogFile, watchLogFile, startWatching, getWatchedFiles } from './lib/log-watcher.js';
45
45
  import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
46
46
 
@@ -685,16 +685,45 @@ async function handleRequest(req, res) {
685
685
  res.write(`event: full_reload\ndata: ${JSON.stringify(entriesToSend)}\n\n`);
686
686
  }
687
687
 
688
- // Compute KV-Cache content for latest MainAgent
688
+ // Compute KV-Cache content + context_window for latest MainAgent
689
+ let pushedContextWindow = false;
689
690
  for (let i = entries.length - 1; i >= 0; i--) {
690
691
  if (isMainAgentEntry(entries[i])) {
691
692
  const cached = extractCachedContent(entries[i]);
692
693
  if (cached) {
693
694
  res.write(`event: kv_cache_content\ndata: ${JSON.stringify(cached)}\n\n`);
694
695
  }
696
+ // Push initial context_window from latest MainAgent usage
697
+ const usage = entries[i].response?.body?.usage;
698
+ if (usage) {
699
+ const contextSize = getContextSizeForModel(entries[i].body?.model);
700
+ const cwData = buildContextWindowEvent(usage, contextSize);
701
+ if (cwData) {
702
+ res.write(`event: context_window\ndata: ${JSON.stringify(cwData)}\n\n`);
703
+ pushedContextWindow = true;
704
+ }
705
+ }
695
706
  break;
696
707
  }
697
708
  }
709
+ // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
710
+ if (!pushedContextWindow) {
711
+ try {
712
+ const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
713
+ const cwFile = JSON.parse(cwRaw);
714
+ if (cwFile?.context_window) {
715
+ // Recalculate with correct context size from model.id
716
+ const { contextSize } = readModelContextSize();
717
+ const cw = cwFile.context_window;
718
+ const inputTokens = cw.total_input_tokens || 0;
719
+ const outputTokens = cw.total_output_tokens || 0;
720
+ const totalTokens = inputTokens + outputTokens;
721
+ const usedPct = contextSize > 0 ? Math.round((totalTokens / contextSize) * 100) : 0;
722
+ const data = { ...cw, context_window_size: contextSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
723
+ res.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
724
+ }
725
+ } catch { }
726
+ }
698
727
 
699
728
  req.on('close', () => {
700
729
  const idx = clients.indexOf(res);
@@ -1716,7 +1745,8 @@ export async function startViewer() {
1716
1745
  } catch { }
1717
1746
  // 工作区模式下延迟到选择工作区后再启动监听
1718
1747
  if (!isWorkspaceMode) {
1719
- startWatching({ ..._logWatcherOpts(LOG_FILE), watchContextWindow });
1748
+ readModelContextSize(); // Cache model→size mapping at startup
1749
+ startWatching(_logWatcherOpts(LOG_FILE));
1720
1750
  startStatsWorker();
1721
1751
  }
1722
1752
  // CLI 模式下启动 WebSocket 服务
@@ -1746,7 +1776,7 @@ export async function startViewer() {
1746
1776
  async function setupTerminalWebSocket(httpServer) {
1747
1777
  try {
1748
1778
  const { WebSocketServer } = await import('ws');
1749
- const { writeToPty, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
1779
+ const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getOutputBuffer, getCurrentWorkspace, spawnShell } = await import('./pty-manager.js');
1750
1780
  const wss = new WebSocketServer({ noServer: true });
1751
1781
  terminalWss = wss;
1752
1782
 
@@ -1833,6 +1863,20 @@ async function setupTerminalWebSocket(httpServer) {
1833
1863
  }
1834
1864
  }
1835
1865
  writeToPty(msg.data);
1866
+ } else if (msg.type === 'input-sequential') {
1867
+ // Programmatic sequential input: send chunks one by one, waiting for PTY ACK
1868
+ const state = getPtyState();
1869
+ if (!state.running) {
1870
+ try { await spawnShell(); } catch {}
1871
+ }
1872
+ const chunks = msg.chunks;
1873
+ if (Array.isArray(chunks) && chunks.length > 0) {
1874
+ writeToPtySequential(chunks, (ok) => {
1875
+ try {
1876
+ ws.send(JSON.stringify({ type: 'input-sequential-done', ok }));
1877
+ } catch {}
1878
+ }, { settleMs: msg.settleMs || 150 });
1879
+ }
1836
1880
  } else if (msg.type === 'resize') {
1837
1881
  // 存储该客户端的尺寸
1838
1882
  clientSizes.set(ws, { cols: msg.cols, rows: msg.rows });