claude-opencode-viewer 2.3.1 → 2.4.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.
@@ -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.0",
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,9 @@ process.title = 'claude-opencode-viewer';
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const PORT = 7008;
16
17
 
18
+ // OpenCode 数据库路径
19
+ const OPENCODE_DB_PATH = join(homedir(), '.local/share/opencode/opencode.db');
20
+
17
21
  const MAX_BUFFER = 200000;
18
22
 
19
23
  let ptyModule = null;
@@ -93,7 +97,7 @@ function findCommand(cmd) {
93
97
  return cmd;
94
98
  }
95
99
 
96
- async function spawnProcess(mode) {
100
+ async function spawnProcess(mode, sessionId = null) {
97
101
  const pty = await getPty();
98
102
  fixSpawnHelperPermissions();
99
103
 
@@ -108,6 +112,11 @@ async function spawnProcess(mode) {
108
112
  }
109
113
  } else {
110
114
  command = findCommand('opencode');
115
+ // 如果提供了 sessionId,添加 --session 参数
116
+ if (sessionId) {
117
+ args = ['--session', sessionId];
118
+ console.log(`[opencode] 恢复会话: ${sessionId}`);
119
+ }
111
120
  }
112
121
 
113
122
  const proc = pty.spawn(command, args, {
@@ -126,11 +135,18 @@ async function spawnProcess(mode) {
126
135
  dataListeners.forEach(cb => cb(data));
127
136
  });
128
137
 
129
- proc.onExit(() => {
138
+ proc.onExit(({ exitCode }) => {
139
+ console.log(`[onExit] 进程退出, PID: ${proc.pid}, exitCode: ${exitCode}`);
130
140
  if (currentProcess === proc) {
131
141
  currentProcess = null;
132
- exitListeners.forEach(cb => cb(0));
133
142
  }
143
+ if (claudeProcess === proc) {
144
+ claudeProcess = null;
145
+ }
146
+ if (opencodeProcess === proc) {
147
+ opencodeProcess = null;
148
+ }
149
+ exitListeners.forEach(cb => cb(exitCode || 0));
134
150
  });
135
151
 
136
152
  // 只在初始化时杀死旧进程,switchMode 已经处理了切换时的进程清理
@@ -214,6 +230,155 @@ function resizePty(cols, rows) {
214
230
  });
215
231
  }
216
232
 
233
+ // 数据库访问函数
234
+ function getOpenCodeSessions() {
235
+ try {
236
+ if (!existsSync(OPENCODE_DB_PATH)) {
237
+ console.log('[DB] OpenCode 数据库不存在:', OPENCODE_DB_PATH);
238
+ return [];
239
+ }
240
+
241
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
242
+
243
+ const sessions = db.prepare(`
244
+ SELECT
245
+ s.id,
246
+ s.title,
247
+ s.directory,
248
+ s.time_created,
249
+ s.time_updated,
250
+ p.name as project_name
251
+ FROM session s
252
+ LEFT JOIN project p ON s.project_id = p.id
253
+ WHERE s.parent_id IS NULL
254
+ AND s.time_archived IS NULL
255
+ ORDER BY s.time_updated DESC
256
+ LIMIT 50
257
+ `).all();
258
+
259
+ // 为每个会话获取第一条用户消息作为预览
260
+ const result = sessions.map(session => {
261
+ // 获取第一条用户消息
262
+ const firstMessage = db.prepare(`
263
+ SELECT id
264
+ FROM message
265
+ WHERE session_id = ?
266
+ AND json_extract(data, '$.role') = 'user'
267
+ ORDER BY time_created ASC
268
+ LIMIT 1
269
+ `).get(session.id);
270
+
271
+ let preview = '';
272
+ if (firstMessage) {
273
+ // 获取该消息的文本 part
274
+ const textPart = db.prepare(`
275
+ SELECT data
276
+ FROM part
277
+ WHERE message_id = ?
278
+ AND json_extract(data, '$.type') = 'text'
279
+ LIMIT 1
280
+ `).get(firstMessage.id);
281
+
282
+ if (textPart) {
283
+ try {
284
+ const partData = JSON.parse(textPart.data);
285
+ if (partData.text) {
286
+ // 截取前80个字符作为预览
287
+ preview = partData.text.length > 80
288
+ ? partData.text.substring(0, 80) + '...'
289
+ : partData.text;
290
+ }
291
+ } catch (e) {
292
+ console.error('[DB] 解析 part 失败:', e.message);
293
+ }
294
+ }
295
+ }
296
+
297
+ return {
298
+ ...session,
299
+ preview: preview || session.title
300
+ };
301
+ });
302
+
303
+ db.close();
304
+ return result;
305
+ } catch (err) {
306
+ console.error('[DB] 读取会话失败:', err.message);
307
+ return [];
308
+ }
309
+ }
310
+
311
+ function getSessionMessages(sessionId) {
312
+ try {
313
+ if (!existsSync(OPENCODE_DB_PATH)) {
314
+ return [];
315
+ }
316
+
317
+ const db = new Database(OPENCODE_DB_PATH, { readonly: true });
318
+
319
+ // 获取消息列表
320
+ const messages = db.prepare(`
321
+ SELECT
322
+ id,
323
+ time_created,
324
+ data
325
+ FROM message
326
+ WHERE session_id = ?
327
+ ORDER BY time_created ASC
328
+ `).all(sessionId);
329
+
330
+ // 为每个消息获取其 parts
331
+ const result = messages.map(msg => {
332
+ const msgData = JSON.parse(msg.data);
333
+
334
+ // 获取该消息的所有 parts
335
+ const parts = db.prepare(`
336
+ SELECT data
337
+ FROM part
338
+ WHERE message_id = ?
339
+ ORDER BY time_created ASC
340
+ `).all(msg.id);
341
+
342
+ const parsedParts = parts.map(p => JSON.parse(p.data));
343
+
344
+ // 提取文本内容
345
+ let text = '';
346
+ let reasoning = '';
347
+ let toolCalls = [];
348
+ let toolResults = [];
349
+
350
+ for (const part of parsedParts) {
351
+ if (part.type === 'text' && part.text) {
352
+ text += part.text;
353
+ } else if (part.type === 'reasoning' && part.text) {
354
+ reasoning += part.text;
355
+ } else if (part.type === 'tool-call' || part.type === 'tool_call') {
356
+ toolCalls.push(part);
357
+ } else if (part.type === 'tool-result' || part.type === 'tool_result') {
358
+ toolResults.push(part);
359
+ }
360
+ }
361
+
362
+ return {
363
+ id: msg.id,
364
+ time_created: msg.time_created,
365
+ role: msgData.role,
366
+ text: text || undefined,
367
+ reasoning: reasoning || undefined,
368
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
369
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
370
+ metadata: msgData
371
+ };
372
+ });
373
+
374
+ db.close();
375
+ return result;
376
+ } catch (err) {
377
+ console.error('[DB] 读取消息失败:', err.message);
378
+ return [];
379
+ }
380
+ }
381
+
217
382
  const server = createServer((req, res) => {
218
383
  if (req.url === '/' || req.url === '/index.html') {
219
384
  res.writeHead(200, {
@@ -223,6 +388,30 @@ const server = createServer((req, res) => {
223
388
  createReadStream(join(__dirname, 'index.html')).pipe(res);
224
389
  return;
225
390
  }
391
+
392
+ // API: 获取会话列表
393
+ if (req.url === '/api/sessions') {
394
+ res.writeHead(200, {
395
+ 'Content-Type': 'application/json',
396
+ 'Access-Control-Allow-Origin': '*',
397
+ });
398
+ const sessions = getOpenCodeSessions();
399
+ res.end(JSON.stringify(sessions));
400
+ return;
401
+ }
402
+
403
+ // API: 获取会话消息
404
+ if (req.url?.startsWith('/api/session/')) {
405
+ const sessionId = req.url.split('/').pop();
406
+ res.writeHead(200, {
407
+ 'Content-Type': 'application/json',
408
+ 'Access-Control-Allow-Origin': '*',
409
+ });
410
+ const messages = getSessionMessages(sessionId);
411
+ res.end(JSON.stringify(messages));
412
+ return;
413
+ }
414
+
226
415
  res.writeHead(404, { 'Content-Type': 'text/plain' });
227
416
  res.end('Not Found');
228
417
  });
@@ -260,8 +449,20 @@ wss.on('connection', (ws, req) => {
260
449
  ws.on('message', async (raw) => {
261
450
  try {
262
451
  const msg = JSON.parse(raw);
263
-
452
+ console.log(`[WS msg] type=${msg.type}, currentProcess=${!!currentProcess}, currentMode=${currentMode}`);
453
+
264
454
  if (msg.type === 'input') {
455
+ // 进程已退出时,自动重新启动(参考 cc-viewer 逻辑)
456
+ if (!currentProcess) {
457
+ try {
458
+ console.log(`[respawn] 进程已退出,自动重新启动 ${currentMode}`);
459
+ outputBuffer = '';
460
+ await spawnProcess(currentMode);
461
+ console.log(`[respawn] 重新启动成功, currentProcess=${!!currentProcess}`);
462
+ } catch (e) {
463
+ console.log(`[respawn] 重新启动失败: ${e.message}`);
464
+ }
465
+ }
265
466
  if (activeWs !== ws) {
266
467
  activeWs = ws;
267
468
  const mSize = getMobileSize();
@@ -293,6 +494,38 @@ wss.on('connection', (ws, req) => {
293
494
  }
294
495
  }, 100);
295
496
  }
497
+ } else if (msg.type === 'restore') {
498
+ // 恢复会话
499
+ if (msg.sessionId && currentMode === 'opencode') {
500
+ console.log(`[restore] 恢复会话: ${msg.sessionId}`);
501
+
502
+ // 杀死当前 opencode 进程
503
+ if (opencodeProcess) {
504
+ try {
505
+ console.log(`[restore] 杀死当前进程 PID: ${opencodeProcess.pid}`);
506
+ opencodeProcess.kill();
507
+ } catch (e) {
508
+ console.log('[restore] 杀死进程失败:', e.message);
509
+ }
510
+ opencodeProcess = null;
511
+ currentProcess = null;
512
+ }
513
+
514
+ // 清空输出缓冲
515
+ outputBuffer = '';
516
+
517
+ // 等待进程完全退出
518
+ await new Promise(resolve => setTimeout(resolve, 200));
519
+
520
+ // 启动新的 opencode 进程,传入 session ID
521
+ try {
522
+ await spawnProcess('opencode', msg.sessionId);
523
+ ws.send(JSON.stringify({ type: 'restored', sessionId: msg.sessionId }));
524
+ } catch (e) {
525
+ console.error('[restore] 启动进程失败:', e.message);
526
+ ws.send(JSON.stringify({ type: 'restore-error', error: e.message }));
527
+ }
528
+ }
296
529
  }
297
530
  } catch (err) {
298
531
  console.error('[WS] Error:', err.message);