claude-opencode-viewer 2.3.1 → 2.4.1

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.
@@ -5,7 +5,8 @@
5
5
  "Bash(kill:*)",
6
6
  "Bash(pkill:*)",
7
7
  "Bash(node:*)",
8
- "Bash(lsof -ti:7008)"
8
+ "Bash(lsof -ti:7008)",
9
+ "Bash(sqlite3:*)"
9
10
  ]
10
11
  }
11
12
  }
package/index.html CHANGED
@@ -29,6 +29,366 @@
29
29
  justify-content: space-between;
30
30
  flex-shrink: 0;
31
31
  height: 40px;
32
+ gap: 12px;
33
+ }
34
+
35
+ #session-history-bar {
36
+ display: none;
37
+ position: absolute;
38
+ top: 0;
39
+ left: 0;
40
+ right: 0;
41
+ bottom: 0;
42
+ background: #0a0a0a;
43
+ z-index: 1000;
44
+ flex-direction: column;
45
+ }
46
+
47
+ #session-history-bar.visible {
48
+ display: flex;
49
+ }
50
+
51
+ #session-list-view {
52
+ display: flex;
53
+ flex-direction: column;
54
+ flex: 1;
55
+ min-height: 0;
56
+ }
57
+
58
+ #session-list-view.hidden {
59
+ display: none;
60
+ }
61
+
62
+ #session-history-header {
63
+ display: flex;
64
+ align-items: center;
65
+ justify-content: space-between;
66
+ padding: 12px 16px;
67
+ background: #111;
68
+ border-bottom: 1px solid #222;
69
+ flex-shrink: 0;
70
+ }
71
+
72
+ #session-history-title {
73
+ font-size: 14px;
74
+ color: #ddd;
75
+ font-weight: 600;
76
+ }
77
+
78
+ #session-history-actions {
79
+ display: flex;
80
+ gap: 8px;
81
+ }
82
+
83
+ #session-list-container {
84
+ flex: 1;
85
+ overflow-y: auto;
86
+ padding: 12px;
87
+ -webkit-overflow-scrolling: touch;
88
+ }
89
+
90
+ #session-detail-view {
91
+ display: none;
92
+ flex-direction: column;
93
+ flex: 1;
94
+ min-height: 0;
95
+ }
96
+
97
+ #session-detail-view.visible {
98
+ display: flex;
99
+ }
100
+
101
+ #session-detail-header {
102
+ padding: 12px 16px;
103
+ background: #0d0d0d;
104
+ border-bottom: 1px solid #222;
105
+ flex-shrink: 0;
106
+ }
107
+
108
+ #session-detail-title {
109
+ font-size: 14px;
110
+ color: #ddd;
111
+ font-weight: 500;
112
+ margin-bottom: 4px;
113
+ }
114
+
115
+ #session-detail-meta {
116
+ font-size: 11px;
117
+ color: #888;
118
+ display: flex;
119
+ gap: 12px;
120
+ }
121
+
122
+ #session-detail-content {
123
+ flex: 1;
124
+ overflow-y: auto;
125
+ padding: 16px;
126
+ -webkit-overflow-scrolling: touch;
127
+ }
128
+
129
+ .message-item {
130
+ margin-bottom: 20px;
131
+ display: flex;
132
+ gap: 12px;
133
+ align-items: flex-start;
134
+ }
135
+
136
+ /* User 消息:左对齐 */
137
+ .message-user {
138
+ justify-content: flex-start;
139
+ }
140
+
141
+ /* Assistant 消息:右对齐 */
142
+ .message-assistant {
143
+ justify-content: flex-end;
144
+ flex-direction: row-reverse;
145
+ }
146
+
147
+ .message-avatar {
148
+ flex-shrink: 0;
149
+ width: 32px;
150
+ height: 32px;
151
+ border-radius: 50%;
152
+ background: #2a4a7c;
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ font-size: 14px;
157
+ }
158
+
159
+ .message-assistant .message-avatar {
160
+ background: #1a5a3a;
161
+ }
162
+
163
+ .message-content {
164
+ flex: 1;
165
+ min-width: 0;
166
+ max-width: 80%;
167
+ }
168
+
169
+ .message-header {
170
+ display: flex;
171
+ align-items: center;
172
+ gap: 8px;
173
+ margin-bottom: 6px;
174
+ }
175
+
176
+ /* User 的标题左对齐 */
177
+ .message-user .message-header {
178
+ justify-content: flex-start;
179
+ }
180
+
181
+ /* Assistant 的标题右对齐 */
182
+ .message-assistant .message-header {
183
+ justify-content: flex-end;
184
+ flex-direction: row-reverse;
185
+ }
186
+
187
+ .message-role {
188
+ font-size: 11px;
189
+ color: #888;
190
+ font-weight: 600;
191
+ text-transform: uppercase;
192
+ letter-spacing: 0.5px;
193
+ }
194
+
195
+ .message-text {
196
+ color: #ddd;
197
+ font-size: 13px;
198
+ line-height: 1.6;
199
+ white-space: pre-wrap;
200
+ word-break: break-word;
201
+ background: #141414;
202
+ padding: 12px;
203
+ border-radius: 8px;
204
+ border: 1px solid #222;
205
+ }
206
+
207
+ /* User 消息气泡 */
208
+ .message-user .message-text {
209
+ background: #1a2332;
210
+ border-color: #2a4a7c;
211
+ }
212
+
213
+ /* Assistant 消息气泡 */
214
+ .message-assistant .message-text {
215
+ background: #1a2e1a;
216
+ border-color: #2a5a3a;
217
+ }
218
+
219
+ .message-tool-call {
220
+ margin-top: 8px;
221
+ padding: 8px 12px;
222
+ background: #1a1a0a;
223
+ border: 1px solid #333;
224
+ border-radius: 6px;
225
+ font-size: 12px;
226
+ }
227
+
228
+ .message-tool-name {
229
+ color: #f0ad4e;
230
+ font-weight: 600;
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 6px;
234
+ }
235
+
236
+ .message-tool-result {
237
+ margin-top: 6px;
238
+ padding: 8px;
239
+ background: #0a0a0a;
240
+ border-radius: 4px;
241
+ font-size: 11px;
242
+ color: #999;
243
+ max-height: 100px;
244
+ overflow-y: auto;
245
+ }
246
+
247
+ .message-empty {
248
+ text-align: center;
249
+ padding: 40px 20px;
250
+ color: #666;
251
+ font-size: 13px;
252
+ }
253
+
254
+ #session-list {
255
+ display: flex;
256
+ flex-direction: column;
257
+ gap: 6px;
258
+ }
259
+
260
+ .session-item {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: 8px;
264
+ padding: 8px 12px;
265
+ background: #1a1a1a;
266
+ border: 1px solid #333;
267
+ border-radius: 6px;
268
+ cursor: pointer;
269
+ transition: all 0.15s;
270
+ min-width: 0;
271
+ }
272
+
273
+ .session-item:hover {
274
+ background: #252525;
275
+ border-color: #555;
276
+ }
277
+
278
+ .session-item.active {
279
+ background: #2a4a7c;
280
+ border-color: #4a8cff;
281
+ }
282
+
283
+ .session-icon {
284
+ flex-shrink: 0;
285
+ width: 8px;
286
+ height: 8px;
287
+ border-radius: 50%;
288
+ background: #666;
289
+ }
290
+
291
+ .session-item.active .session-icon {
292
+ background: #4a8cff;
293
+ }
294
+
295
+ .session-info {
296
+ flex: 1;
297
+ min-width: 0;
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 2px;
301
+ }
302
+
303
+ .session-title {
304
+ font-size: 13px;
305
+ color: #ddd;
306
+ font-weight: 500;
307
+ line-height: 1.4;
308
+ display: -webkit-box;
309
+ -webkit-line-clamp: 2;
310
+ -webkit-box-orient: vertical;
311
+ overflow: hidden;
312
+ text-overflow: ellipsis;
313
+ word-break: break-word;
314
+ }
315
+
316
+ .session-item.active .session-title {
317
+ color: #fff;
318
+ }
319
+
320
+ .session-meta {
321
+ font-size: 11px;
322
+ color: #888;
323
+ display: flex;
324
+ gap: 8px;
325
+ }
326
+
327
+ .session-time {
328
+ flex-shrink: 0;
329
+ }
330
+
331
+ .session-dir {
332
+ overflow: hidden;
333
+ text-overflow: ellipsis;
334
+ white-space: nowrap;
335
+ }
336
+
337
+ .history-toggle-btn {
338
+ background: none;
339
+ border: 1px solid #333;
340
+ color: #aaa;
341
+ padding: 4px 10px;
342
+ font-size: 12px;
343
+ cursor: pointer;
344
+ border-radius: 4px;
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 4px;
348
+ flex-shrink: 0;
349
+ }
350
+
351
+ .history-toggle-btn:hover {
352
+ background: #2a2a2a;
353
+ color: #ddd;
354
+ border-color: #555;
355
+ }
356
+
357
+ .history-toggle-btn svg {
358
+ width: 14px;
359
+ height: 14px;
360
+ }
361
+
362
+ .session-loading {
363
+ text-align: center;
364
+ padding: 16px;
365
+ color: #888;
366
+ font-size: 12px;
367
+ }
368
+
369
+ .session-empty {
370
+ text-align: center;
371
+ padding: 16px;
372
+ color: #666;
373
+ font-size: 12px;
374
+ }
375
+
376
+ .session-restore-hint {
377
+ margin-top: 8px;
378
+ padding: 8px 12px;
379
+ background: #1a1a1a;
380
+ border: 1px solid #333;
381
+ border-radius: 6px;
382
+ font-size: 11px;
383
+ color: #888;
384
+ text-align: center;
385
+ }
386
+
387
+ .session-restore-hint code {
388
+ color: #4a8cff;
389
+ background: #0a0a0a;
390
+ padding: 2px 6px;
391
+ border-radius: 3px;
32
392
  }
33
393
 
34
394
  #mode-switcher {
@@ -134,6 +494,13 @@
134
494
  <div id="layout">
135
495
  <div id="header">
136
496
  <div style="font-size: 12px; color: #aaa;">Claude OpenCode Viewer</div>
497
+ <button class="history-toggle-btn" id="history-toggle">
498
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
499
+ <circle cx="12" cy="12" r="10"></circle>
500
+ <polyline points="12 6 12 12 16 14"></polyline>
501
+ </svg>
502
+ <span>历史</span>
503
+ </button>
137
504
  <div id="mode-switcher">
138
505
  <span id="mode-label">Mode:</span>
139
506
  <select id="mode-select">
@@ -143,6 +510,70 @@
143
510
  </div>
144
511
  </div>
145
512
 
513
+ <!-- 会话历史栏 -->
514
+ <div id="session-history-bar">
515
+ <!-- 会话列表视图 -->
516
+ <div id="session-list-view">
517
+ <div id="session-history-header">
518
+ <div id="session-history-title">历史会话</div>
519
+ <div id="session-history-actions">
520
+ <button class="history-toggle-btn" id="refresh-sessions">
521
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
522
+ <polyline points="23 4 23 10 17 10"></polyline>
523
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
524
+ </svg>
525
+ <span>刷新</span>
526
+ </button>
527
+ <button class="history-toggle-btn" id="close-history">
528
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
529
+ <line x1="18" y1="6" x2="6" y2="18"></line>
530
+ <line x1="6" y1="6" x2="18" y2="18"></line>
531
+ </svg>
532
+ <span>返回</span>
533
+ </button>
534
+ </div>
535
+ </div>
536
+ <div id="session-list-container">
537
+ <div id="session-list">
538
+ <div class="session-loading">加载历史会话...</div>
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ <!-- 会话详情视图 -->
544
+ <div id="session-detail-view">
545
+ <div id="session-history-header">
546
+ <div id="session-history-title">会话详情</div>
547
+ <div id="session-history-actions">
548
+ <button class="history-toggle-btn" id="restore-session" style="background: #1a5a3a; border-color: #2a7a4a;">
549
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
550
+ <polyline points="1 4 1 10 7 10"></polyline>
551
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
552
+ </svg>
553
+ <span>恢复会话</span>
554
+ </button>
555
+ <button class="history-toggle-btn" id="back-to-list">
556
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
557
+ <line x1="19" y1="12" x2="5" y2="12"></line>
558
+ <polyline points="12 19 5 12 12 5"></polyline>
559
+ </svg>
560
+ <span>返回列表</span>
561
+ </button>
562
+ </div>
563
+ </div>
564
+ <div id="session-detail-header">
565
+ <div id="session-detail-title">会话标题</div>
566
+ <div id="session-detail-meta">
567
+ <span id="session-detail-time">时间</span>
568
+ <span id="session-detail-dir">目录</span>
569
+ </div>
570
+ </div>
571
+ <div id="session-detail-content">
572
+ <div class="message-empty">加载中...</div>
573
+ </div>
574
+ </div>
575
+ </div>
576
+
146
577
  <div id="content">
147
578
  <div id="terminal-container">
148
579
  <div id="terminal"></div>
@@ -208,6 +639,12 @@
208
639
  var CACHE_KEY = 'claude_opencode_input_cache';
209
640
  var cacheRestored = false; // 防止重复恢复
210
641
 
642
+ // 会话历史相关
643
+ var sessions = [];
644
+ var currentSessionId = null;
645
+ var currentSessionData = null;
646
+ var historyBarVisible = false;
647
+
211
648
  function saveInputCache() {
212
649
  if (currentInputBuffer) {
213
650
  localStorage.setItem(CACHE_KEY, currentInputBuffer);
@@ -559,6 +996,42 @@
559
996
  // 设置初始选中项
560
997
  modeSelect.value = currentMode;
561
998
 
999
+ // 只绑定一次 term.onData,避免重连时重复绑定
1000
+ term.onData(function(d) {
1001
+ if (ws && ws.readyState === 1) {
1002
+ ws.send(JSON.stringify({ type: 'input', data: d }));
1003
+
1004
+ // 缓存管理:跟踪当前行的输入(仅在 opencode 模式且未在恢复中)
1005
+ if (currentMode === 'opencode' && cacheRestored) {
1006
+ if (d === '\r' || d === '\n' || d === '\r\n') {
1007
+ // 回车:命令已发送,清除缓存
1008
+ clearInputCache();
1009
+ } else if (d === '\x7f' || d === '\b') {
1010
+ // 退格:删除最后一个字符
1011
+ if (currentInputBuffer.length > 0) {
1012
+ currentInputBuffer = currentInputBuffer.slice(0, -1);
1013
+ saveInputCache();
1014
+ }
1015
+ } else if (d === '\x03') {
1016
+ // Ctrl+C:中断,清除缓存
1017
+ clearInputCache();
1018
+ } else if (d === '\x15') {
1019
+ // Ctrl+U:清空整行
1020
+ clearInputCache();
1021
+ } else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
1022
+ // 可打印 ASCII 字符:添加到缓冲区
1023
+ currentInputBuffer += d;
1024
+ saveInputCache();
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ // 点击终端输入时,滚动到底部
1030
+ setTimeout(function() {
1031
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
1032
+ }, 50);
1033
+ });
1034
+
562
1035
  function connect() {
563
1036
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
564
1037
  ws = new WebSocket(proto + '//' + location.host + '/ws');
@@ -576,7 +1049,10 @@
576
1049
  try {
577
1050
  var msg = JSON.parse(e.data);
578
1051
  if (msg.type === 'data') throttledWrite(msg.data);
579
- else if (msg.type === 'exit') throttledWrite('\r\n[进程已退出: ' + msg.exitCode + ']\r\n');
1052
+ else if (msg.type === 'exit') {
1053
+ throttledWrite('\r\n\x1b[33m[进程已退出: ' + msg.exitCode + ']\x1b[0m\r\n');
1054
+ throttledWrite('\x1b[90m按 Enter 键重新启动 ' + currentMode + '...\x1b[0m\r\n');
1055
+ }
580
1056
  else if (msg.type === 'mode') {
581
1057
  endTransition(msg.mode);
582
1058
  // 模式切换完成后,重新绑定触摸事件
@@ -594,44 +1070,19 @@
594
1070
  modeIndicator.textContent = msg.mode === 'claude' ? 'Claude' : 'OpenCode';
595
1071
  }
596
1072
  }
1073
+ else if (msg.type === 'restored') {
1074
+ // 会话恢复成功
1075
+ term.write('\x1b[32m✓ 会话已恢复: ' + msg.sessionId + '\x1b[0m\r\n');
1076
+ term.write('\x1b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m\r\n');
1077
+ term.write('\r\n');
1078
+ }
1079
+ else if (msg.type === 'restore-error') {
1080
+ // 恢复失败
1081
+ term.write('\x1b[31m✗ 恢复失败: ' + msg.error + '\x1b[0m\r\n');
1082
+ }
597
1083
  } catch(err) {}
598
1084
  };
599
1085
 
600
- term.onData(function(d) {
601
- if (ws && ws.readyState === 1) {
602
- ws.send(JSON.stringify({ type: 'input', data: d }));
603
-
604
- // 缓存管理:跟踪当前行的输入(仅在 opencode 模式且未在恢复中)
605
- if (currentMode === 'opencode' && cacheRestored) {
606
- if (d === '\r' || d === '\n' || d === '\r\n') {
607
- // 回车:命令已发送,清除缓存
608
- clearInputCache();
609
- } else if (d === '\x7f' || d === '\b') {
610
- // 退格:删除最后一个字符
611
- if (currentInputBuffer.length > 0) {
612
- currentInputBuffer = currentInputBuffer.slice(0, -1);
613
- saveInputCache();
614
- }
615
- } else if (d === '\x03') {
616
- // Ctrl+C:中断,清除缓存
617
- clearInputCache();
618
- } else if (d === '\x15') {
619
- // Ctrl+U:清空整行
620
- clearInputCache();
621
- } else if (d.length === 1 && d.charCodeAt(0) >= 32 && d.charCodeAt(0) < 127) {
622
- // 可打印 ASCII 字符:添加到缓冲区
623
- currentInputBuffer += d;
624
- saveInputCache();
625
- }
626
- }
627
- }
628
-
629
- // 点击终端输入时,滚动到底部
630
- setTimeout(function() {
631
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
632
- }, 50);
633
- });
634
-
635
1086
  // 页面加载后尝试恢复缓存的输入(仅在 opencode 模式)
636
1087
  setTimeout(function() {
637
1088
  if (currentMode === 'opencode') {
@@ -798,6 +1249,283 @@
798
1249
  });
799
1250
  }
800
1251
 
1252
+ // 会话历史功能
1253
+ function formatTime(timestamp) {
1254
+ var date = new Date(timestamp);
1255
+ var now = new Date();
1256
+ var diff = now - date;
1257
+ var minutes = Math.floor(diff / 60000);
1258
+ var hours = Math.floor(diff / 3600000);
1259
+ var days = Math.floor(diff / 86400000);
1260
+
1261
+ if (minutes < 1) return '刚刚';
1262
+ if (minutes < 60) return minutes + '分钟前';
1263
+ if (hours < 24) return hours + '小时前';
1264
+ if (days < 7) return days + '天前';
1265
+
1266
+ return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
1267
+ }
1268
+
1269
+ function loadSessions() {
1270
+ var sessionList = document.getElementById('session-list');
1271
+ sessionList.innerHTML = '<div class="session-loading">加载历史会话...</div>';
1272
+
1273
+ fetch('/api/sessions')
1274
+ .then(function(response) { return response.json(); })
1275
+ .then(function(data) {
1276
+ sessions = data;
1277
+ renderSessions();
1278
+ })
1279
+ .catch(function(err) {
1280
+ console.error('[sessions] 加载失败:', err);
1281
+ sessionList.innerHTML = '<div class="session-empty">无法加载历史会话</div>';
1282
+ });
1283
+ }
1284
+
1285
+ function renderSessions() {
1286
+ var sessionList = document.getElementById('session-list');
1287
+
1288
+ if (sessions.length === 0) {
1289
+ sessionList.innerHTML = '<div class="session-empty">暂无历史会话</div>';
1290
+ return;
1291
+ }
1292
+
1293
+ sessionList.innerHTML = '';
1294
+
1295
+ sessions.forEach(function(session) {
1296
+ var item = document.createElement('div');
1297
+ item.className = 'session-item';
1298
+ if (session.id === currentSessionId) {
1299
+ item.classList.add('active');
1300
+ }
1301
+
1302
+ var icon = document.createElement('div');
1303
+ icon.className = 'session-icon';
1304
+
1305
+ var info = document.createElement('div');
1306
+ info.className = 'session-info';
1307
+
1308
+ var title = document.createElement('div');
1309
+ title.className = 'session-title';
1310
+ // 使用预览文本,如果没有预览则使用标题
1311
+ title.textContent = session.preview || session.title;
1312
+
1313
+ var meta = document.createElement('div');
1314
+ meta.className = 'session-meta';
1315
+
1316
+ var time = document.createElement('span');
1317
+ time.className = 'session-time';
1318
+ time.textContent = formatTime(session.time_updated);
1319
+
1320
+ var dir = document.createElement('span');
1321
+ dir.className = 'session-dir';
1322
+ dir.textContent = session.directory.replace(/^\/Users\/[^\/]+/, '~');
1323
+
1324
+ meta.appendChild(time);
1325
+ meta.appendChild(dir);
1326
+ info.appendChild(title);
1327
+ info.appendChild(meta);
1328
+
1329
+ item.appendChild(icon);
1330
+ item.appendChild(info);
1331
+
1332
+ item.addEventListener('click', function() {
1333
+ loadSession(session);
1334
+ });
1335
+
1336
+ sessionList.appendChild(item);
1337
+ });
1338
+ }
1339
+
1340
+ function showSessionDetail() {
1341
+ document.getElementById('session-list-view').classList.add('hidden');
1342
+ document.getElementById('session-detail-view').classList.add('visible');
1343
+ }
1344
+
1345
+ function showSessionList() {
1346
+ document.getElementById('session-list-view').classList.remove('hidden');
1347
+ document.getElementById('session-detail-view').classList.remove('visible');
1348
+ }
1349
+
1350
+ function loadSession(session) {
1351
+ console.log('[session] 加载会话:', session.title);
1352
+
1353
+ // 保存当前会话数据
1354
+ currentSessionData = session;
1355
+
1356
+ // 切换到详情视图
1357
+ showSessionDetail();
1358
+
1359
+ // 更新详情页标题和元信息
1360
+ document.getElementById('session-detail-title').textContent = session.preview || session.title;
1361
+ document.getElementById('session-detail-time').textContent = formatTime(session.time_updated);
1362
+ document.getElementById('session-detail-dir').textContent = session.directory.replace(/^\/Users\/[^\/]+/, '~');
1363
+
1364
+ var contentDiv = document.getElementById('session-detail-content');
1365
+ contentDiv.innerHTML = '<div class="message-empty">加载消息中...</div>';
1366
+
1367
+ // 获取会话的所有消息
1368
+ fetch('/api/session/' + session.id)
1369
+ .then(function(response) { return response.json(); })
1370
+ .then(function(messages) {
1371
+ console.log('[session] 收到', messages.length, '条消息');
1372
+
1373
+ if (messages.length === 0) {
1374
+ contentDiv.innerHTML = '<div class="message-empty">该会话暂无消息</div>';
1375
+ return;
1376
+ }
1377
+
1378
+ // 渲染消息列表
1379
+ var html = '';
1380
+ messages.forEach(function(msg) {
1381
+ console.log('[render] 消息:', msg);
1382
+
1383
+ var roleClass = msg.role === 'assistant' ? 'message-assistant' : 'message-user';
1384
+ var avatar = msg.role === 'user' ? '👤' : '🤖';
1385
+ var roleName = msg.role === 'user' ? 'User' : 'Assistant';
1386
+
1387
+ html += '<div class="message-item ' + roleClass + '">';
1388
+ html += '<div class="message-avatar">' + avatar + '</div>';
1389
+ html += '<div class="message-content">';
1390
+ html += '<div class="message-header">';
1391
+ html += '<div class="message-role">' + roleName + '</div>';
1392
+ html += '</div>';
1393
+
1394
+ // 显示推理过程(仅 assistant)
1395
+ if (msg.reasoning) {
1396
+ html += '<div class="message-text" style="background: #1a1a0a; border-color: #333;">';
1397
+ html += '<div style="color: #f0ad4e; font-size: 11px; font-weight: 600; margin-bottom: 6px;">💭 思考过程</div>';
1398
+ html += '<div style="color: #bbb;">' + escapeHtml(msg.reasoning) + '</div>';
1399
+ html += '</div>';
1400
+ }
1401
+
1402
+ // 显示文本内容
1403
+ if (msg.text) {
1404
+ html += '<div class="message-text">' + escapeHtml(msg.text) + '</div>';
1405
+ } else if (!msg.reasoning && !msg.toolCalls && !msg.toolResults) {
1406
+ html += '<div class="message-text" style="color: #666; font-style: italic;">(无文本内容)</div>';
1407
+ }
1408
+
1409
+ // 显示工具调用
1410
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
1411
+ msg.toolCalls.forEach(function(tool) {
1412
+ html += '<div class="message-tool-call">';
1413
+ html += '<div class="message-tool-name">🔧 工具调用: ' + escapeHtml(tool.name || '未知工具') + '</div>';
1414
+ html += '</div>';
1415
+ });
1416
+ }
1417
+
1418
+ // 显示工具结果
1419
+ if (msg.toolResults && msg.toolResults.length > 0) {
1420
+ msg.toolResults.forEach(function(result) {
1421
+ if (result.text) {
1422
+ var resultText = result.text.length > 200 ? result.text.substring(0, 200) + '...' : result.text;
1423
+ html += '<div class="message-tool-call">';
1424
+ html += '<div class="message-tool-name">📝 工具结果</div>';
1425
+ html += '<div class="message-tool-result">' + escapeHtml(resultText) + '</div>';
1426
+ html += '</div>';
1427
+ }
1428
+ });
1429
+ }
1430
+
1431
+ html += '</div>';
1432
+ html += '</div>';
1433
+ });
1434
+
1435
+ contentDiv.innerHTML = html;
1436
+
1437
+ // 滚动到底部
1438
+ contentDiv.scrollTop = contentDiv.scrollHeight;
1439
+ })
1440
+ .catch(function(err) {
1441
+ console.error('[session] 加载消息失败:', err);
1442
+ contentDiv.innerHTML = '<div class="message-empty">加载失败: ' + escapeHtml(err.message) + '</div>';
1443
+ });
1444
+ }
1445
+
1446
+ function escapeHtml(text) {
1447
+ var div = document.createElement('div');
1448
+ div.textContent = text;
1449
+ return div.innerHTML;
1450
+ }
1451
+
1452
+ function toggleHistoryBar() {
1453
+ historyBarVisible = !historyBarVisible;
1454
+ var historyBar = document.getElementById('session-history-bar');
1455
+
1456
+ if (historyBarVisible) {
1457
+ historyBar.classList.add('visible');
1458
+ loadSessions();
1459
+ } else {
1460
+ historyBar.classList.remove('visible');
1461
+ }
1462
+ }
1463
+
1464
+ // 绑定历史按钮
1465
+ document.getElementById('history-toggle').addEventListener('click', function() {
1466
+ toggleHistoryBar();
1467
+ });
1468
+
1469
+ // 刷新会话列表
1470
+ document.getElementById('refresh-sessions').addEventListener('click', function(e) {
1471
+ e.stopPropagation();
1472
+ loadSessions();
1473
+ });
1474
+
1475
+ // 关闭历史栏
1476
+ document.getElementById('close-history').addEventListener('click', function(e) {
1477
+ e.stopPropagation();
1478
+ toggleHistoryBar();
1479
+ });
1480
+
1481
+ // 返回到会话列表
1482
+ document.getElementById('back-to-list').addEventListener('click', function(e) {
1483
+ e.stopPropagation();
1484
+ showSessionList();
1485
+ });
1486
+
1487
+ // 恢复会话
1488
+ document.getElementById('restore-session').addEventListener('click', function(e) {
1489
+ e.stopPropagation();
1490
+ if (!currentSessionData) {
1491
+ console.error('[restore] 没有当前会话数据');
1492
+ return;
1493
+ }
1494
+
1495
+ console.log('[restore] 恢复会话:', currentSessionData.id);
1496
+
1497
+ // 关闭历史栏
1498
+ toggleHistoryBar();
1499
+
1500
+ // 清空终端
1501
+ term.clear();
1502
+
1503
+ // 显示正在恢复的提示
1504
+ term.write('\x1b[1;36m╔══════════════════════════════════════════════════════════════╗\x1b[0m\r\n');
1505
+ term.write('\x1b[1;36m║ \x1b[1;37m正在恢复会话... \x1b[1;36m║\x1b[0m\r\n');
1506
+ term.write('\x1b[1;36m╚══════════════════════════════════════════════════════════════╝\x1b[0m\r\n');
1507
+ term.write('\r\n');
1508
+
1509
+ // 发送恢复会话的请求到服务端
1510
+ if (ws && ws.readyState === 1) {
1511
+ if (currentMode !== 'opencode') {
1512
+ term.write('\x1b[31m错误: 请先切换到 OpenCode 模式\x1b[0m\r\n');
1513
+ return;
1514
+ }
1515
+
1516
+ term.write('\x1b[33m正在重启 OpenCode 并恢复会话: ' + currentSessionData.id + '\x1b[0m\r\n');
1517
+ term.write('\r\n');
1518
+
1519
+ // 发送恢复请求
1520
+ ws.send(JSON.stringify({
1521
+ type: 'restore',
1522
+ sessionId: currentSessionData.id
1523
+ }));
1524
+ } else {
1525
+ term.write('\x1b[31m错误: WebSocket 未连接\x1b[0m\r\n');
1526
+ }
1527
+ });
1528
+
801
1529
  // 初始化虚拟按键事件
802
1530
  setupVirtualKeyEvents();
803
1531
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-opencode-viewer",
3
- "version": "2.3.1",
3
+ "version": "2.4.1",
4
4
  "description": "A unified terminal viewer for Claude Code and OpenCode with seamless switching",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "homepage": "https://github.com/ChrisJason121238/claude-opencode-viewer#readme",
35
35
  "dependencies": {
36
+ "better-sqlite3": "^11.8.1",
36
37
  "node-pty": "^1.1.0",
37
38
  "ws": "^8.19.0"
38
39
  },
package/server.js CHANGED
@@ -3,10 +3,11 @@ import { createServer } from 'node:http';
3
3
  import { existsSync, createReadStream } from 'node:fs';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { networkInterfaces, platform, arch } from 'node:os';
6
+ import { networkInterfaces, platform, arch, homedir } from 'node:os';
7
7
  import { chmodSync, statSync } from 'node:fs';
8
8
  import { execSync } from 'child_process';
9
9
  import { WebSocketServer } from 'ws';
10
+ import Database from 'better-sqlite3';
10
11
 
11
12
  // 设置进程名为 claude-opencode-viewer
12
13
  process.title = 'claude-opencode-viewer';
@@ -14,6 +15,12 @@ process.title = 'claude-opencode-viewer';
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const PORT = 7008;
16
17
 
18
+ // OpenCode 数据库路径(支持环境变量覆盖,自动检测 /halo 环境)
19
+ const OPENCODE_DB_PATH = process.env.OPENCODE_DB_PATH || join(
20
+ existsSync('/halo') ? '/halo/.local/share' : (process.env.XDG_DATA_HOME || join(homedir(), '.local/share')),
21
+ 'opencode/opencode.db'
22
+ );
23
+
17
24
  const MAX_BUFFER = 200000;
18
25
 
19
26
  let ptyModule = null;
@@ -93,7 +100,7 @@ function findCommand(cmd) {
93
100
  return cmd;
94
101
  }
95
102
 
96
- async function spawnProcess(mode) {
103
+ async function spawnProcess(mode, sessionId = null) {
97
104
  const pty = await getPty();
98
105
  fixSpawnHelperPermissions();
99
106
 
@@ -108,6 +115,17 @@ async function spawnProcess(mode) {
108
115
  }
109
116
  } else {
110
117
  command = findCommand('opencode');
118
+ // 如果提供了 sessionId,添加 --session 参数
119
+ if (sessionId) {
120
+ args = ['--session', sessionId];
121
+ console.log(`[opencode] 恢复会话: ${sessionId}`);
122
+ }
123
+ }
124
+
125
+ const spawnEnv = { ...process.env };
126
+ // 如果 /halo 存在,将 opencode 数据持久化到 NAS
127
+ if (mode === 'opencode' && existsSync('/halo')) {
128
+ spawnEnv.XDG_DATA_HOME = '/halo/.local/share';
111
129
  }
112
130
 
113
131
  const proc = pty.spawn(command, args, {
@@ -115,7 +133,7 @@ async function spawnProcess(mode) {
115
133
  cols: lastPtyCols,
116
134
  rows: lastPtyRows,
117
135
  cwd: process.cwd(),
118
- env: { ...process.env },
136
+ env: spawnEnv,
119
137
  });
120
138
 
121
139
  proc.onData((data) => {
@@ -126,11 +144,18 @@ async function spawnProcess(mode) {
126
144
  dataListeners.forEach(cb => cb(data));
127
145
  });
128
146
 
129
- proc.onExit(() => {
147
+ proc.onExit(({ exitCode }) => {
148
+ console.log(`[onExit] 进程退出, PID: ${proc.pid}, exitCode: ${exitCode}`);
130
149
  if (currentProcess === proc) {
131
150
  currentProcess = null;
132
- exitListeners.forEach(cb => cb(0));
133
151
  }
152
+ if (claudeProcess === proc) {
153
+ claudeProcess = null;
154
+ }
155
+ if (opencodeProcess === proc) {
156
+ opencodeProcess = null;
157
+ }
158
+ exitListeners.forEach(cb => cb(exitCode || 0));
134
159
  });
135
160
 
136
161
  // 只在初始化时杀死旧进程,switchMode 已经处理了切换时的进程清理
@@ -214,6 +239,155 @@ function resizePty(cols, rows) {
214
239
  });
215
240
  }
216
241
 
242
+ // 数据库访问函数
243
+ function getOpenCodeSessions() {
244
+ try {
245
+ if (!existsSync(OPENCODE_DB_PATH)) {
246
+ console.log('[DB] OpenCode 数据库不存在:', OPENCODE_DB_PATH);
247
+ return [];
248
+ }
249
+
250
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
251
+
252
+ const sessions = db.prepare(`
253
+ SELECT
254
+ s.id,
255
+ s.title,
256
+ s.directory,
257
+ s.time_created,
258
+ s.time_updated,
259
+ p.name as project_name
260
+ FROM session s
261
+ LEFT JOIN project p ON s.project_id = p.id
262
+ WHERE s.parent_id IS NULL
263
+ AND s.time_archived IS NULL
264
+ ORDER BY s.time_updated DESC
265
+ LIMIT 50
266
+ `).all();
267
+
268
+ // 为每个会话获取第一条用户消息作为预览
269
+ const result = sessions.map(session => {
270
+ // 获取第一条用户消息
271
+ const firstMessage = db.prepare(`
272
+ SELECT id
273
+ FROM message
274
+ WHERE session_id = ?
275
+ AND json_extract(data, '$.role') = 'user'
276
+ ORDER BY time_created ASC
277
+ LIMIT 1
278
+ `).get(session.id);
279
+
280
+ let preview = '';
281
+ if (firstMessage) {
282
+ // 获取该消息的文本 part
283
+ const textPart = db.prepare(`
284
+ SELECT data
285
+ FROM part
286
+ WHERE message_id = ?
287
+ AND json_extract(data, '$.type') = 'text'
288
+ LIMIT 1
289
+ `).get(firstMessage.id);
290
+
291
+ if (textPart) {
292
+ try {
293
+ const partData = JSON.parse(textPart.data);
294
+ if (partData.text) {
295
+ // 截取前80个字符作为预览
296
+ preview = partData.text.length > 80
297
+ ? partData.text.substring(0, 80) + '...'
298
+ : partData.text;
299
+ }
300
+ } catch (e) {
301
+ console.error('[DB] 解析 part 失败:', e.message);
302
+ }
303
+ }
304
+ }
305
+
306
+ return {
307
+ ...session,
308
+ preview: preview || session.title
309
+ };
310
+ });
311
+
312
+ db.close();
313
+ return result;
314
+ } catch (err) {
315
+ console.error('[DB] 读取会话失败:', err.message);
316
+ return [];
317
+ }
318
+ }
319
+
320
+ function getSessionMessages(sessionId) {
321
+ try {
322
+ if (!existsSync(OPENCODE_DB_PATH)) {
323
+ return [];
324
+ }
325
+
326
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
327
+
328
+ // 获取消息列表
329
+ const messages = db.prepare(`
330
+ SELECT
331
+ id,
332
+ time_created,
333
+ data
334
+ FROM message
335
+ WHERE session_id = ?
336
+ ORDER BY time_created ASC
337
+ `).all(sessionId);
338
+
339
+ // 为每个消息获取其 parts
340
+ const result = messages.map(msg => {
341
+ const msgData = JSON.parse(msg.data);
342
+
343
+ // 获取该消息的所有 parts
344
+ const parts = db.prepare(`
345
+ SELECT data
346
+ FROM part
347
+ WHERE message_id = ?
348
+ ORDER BY time_created ASC
349
+ `).all(msg.id);
350
+
351
+ const parsedParts = parts.map(p => JSON.parse(p.data));
352
+
353
+ // 提取文本内容
354
+ let text = '';
355
+ let reasoning = '';
356
+ let toolCalls = [];
357
+ let toolResults = [];
358
+
359
+ for (const part of parsedParts) {
360
+ if (part.type === 'text' && part.text) {
361
+ text += part.text;
362
+ } else if (part.type === 'reasoning' && part.text) {
363
+ reasoning += part.text;
364
+ } else if (part.type === 'tool-call' || part.type === 'tool_call') {
365
+ toolCalls.push(part);
366
+ } else if (part.type === 'tool-result' || part.type === 'tool_result') {
367
+ toolResults.push(part);
368
+ }
369
+ }
370
+
371
+ return {
372
+ id: msg.id,
373
+ time_created: msg.time_created,
374
+ role: msgData.role,
375
+ text: text || undefined,
376
+ reasoning: reasoning || undefined,
377
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
378
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
379
+ metadata: msgData
380
+ };
381
+ });
382
+
383
+ db.close();
384
+ return result;
385
+ } catch (err) {
386
+ console.error('[DB] 读取消息失败:', err.message);
387
+ return [];
388
+ }
389
+ }
390
+
217
391
  const server = createServer((req, res) => {
218
392
  if (req.url === '/' || req.url === '/index.html') {
219
393
  res.writeHead(200, {
@@ -223,6 +397,30 @@ const server = createServer((req, res) => {
223
397
  createReadStream(join(__dirname, 'index.html')).pipe(res);
224
398
  return;
225
399
  }
400
+
401
+ // API: 获取会话列表
402
+ if (req.url === '/api/sessions') {
403
+ res.writeHead(200, {
404
+ 'Content-Type': 'application/json',
405
+ 'Access-Control-Allow-Origin': '*',
406
+ });
407
+ const sessions = getOpenCodeSessions();
408
+ res.end(JSON.stringify(sessions));
409
+ return;
410
+ }
411
+
412
+ // API: 获取会话消息
413
+ if (req.url?.startsWith('/api/session/')) {
414
+ const sessionId = req.url.split('/').pop();
415
+ res.writeHead(200, {
416
+ 'Content-Type': 'application/json',
417
+ 'Access-Control-Allow-Origin': '*',
418
+ });
419
+ const messages = getSessionMessages(sessionId);
420
+ res.end(JSON.stringify(messages));
421
+ return;
422
+ }
423
+
226
424
  res.writeHead(404, { 'Content-Type': 'text/plain' });
227
425
  res.end('Not Found');
228
426
  });
@@ -260,8 +458,20 @@ wss.on('connection', (ws, req) => {
260
458
  ws.on('message', async (raw) => {
261
459
  try {
262
460
  const msg = JSON.parse(raw);
263
-
461
+ console.log(`[WS msg] type=${msg.type}, currentProcess=${!!currentProcess}, currentMode=${currentMode}`);
462
+
264
463
  if (msg.type === 'input') {
464
+ // 进程已退出时,自动重新启动(参考 cc-viewer 逻辑)
465
+ if (!currentProcess) {
466
+ try {
467
+ console.log(`[respawn] 进程已退出,自动重新启动 ${currentMode}`);
468
+ outputBuffer = '';
469
+ await spawnProcess(currentMode);
470
+ console.log(`[respawn] 重新启动成功, currentProcess=${!!currentProcess}`);
471
+ } catch (e) {
472
+ console.log(`[respawn] 重新启动失败: ${e.message}`);
473
+ }
474
+ }
265
475
  if (activeWs !== ws) {
266
476
  activeWs = ws;
267
477
  const mSize = getMobileSize();
@@ -293,6 +503,38 @@ wss.on('connection', (ws, req) => {
293
503
  }
294
504
  }, 100);
295
505
  }
506
+ } else if (msg.type === 'restore') {
507
+ // 恢复会话
508
+ if (msg.sessionId && currentMode === 'opencode') {
509
+ console.log(`[restore] 恢复会话: ${msg.sessionId}`);
510
+
511
+ // 杀死当前 opencode 进程
512
+ if (opencodeProcess) {
513
+ try {
514
+ console.log(`[restore] 杀死当前进程 PID: ${opencodeProcess.pid}`);
515
+ opencodeProcess.kill();
516
+ } catch (e) {
517
+ console.log('[restore] 杀死进程失败:', e.message);
518
+ }
519
+ opencodeProcess = null;
520
+ currentProcess = null;
521
+ }
522
+
523
+ // 清空输出缓冲
524
+ outputBuffer = '';
525
+
526
+ // 等待进程完全退出
527
+ await new Promise(resolve => setTimeout(resolve, 200));
528
+
529
+ // 启动新的 opencode 进程,传入 session ID
530
+ try {
531
+ await spawnProcess('opencode', msg.sessionId);
532
+ ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
533
+ } catch (e) {
534
+ console.error('[restore] 启动进程失败:', e.message);
535
+ ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
536
+ }
537
+ }
296
538
  }
297
539
  } catch (err) {
298
540
  console.error('[WS] Error:', err.message);