@leeandrew94/ccm 0.1.13 → 0.2.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.
package/README.md CHANGED
@@ -65,6 +65,45 @@ ccm ps
65
65
  | `ccm check` | Check if claude is installed |
66
66
  | `ccm test [name]` | Test API connection (omit name to test all) |
67
67
  | `ccm balance [name]` | Query model balance/credits (omit name to query all) |
68
+ | `ccm sessions` | Browse session history (interactive terminal list) |
69
+ | `ccm sessions --web` | Open session viewer in browser |
70
+ | `ccm sessions --restore [id]` | Restore a deleted session from trash |
71
+ | `ccm sessions --purge` | Permanently empty the trash |
72
+
73
+ ## Session Management
74
+
75
+ Browse, search, and restore your Claude Code conversation history.
76
+
77
+ <p align="center">
78
+ <img src="assets/sessions-web.png" alt="ccm sessions web" width="800" />
79
+ </p>
80
+
81
+ ```bash
82
+ # Interactive terminal list — navigate with arrow keys
83
+ ccm sessions
84
+
85
+ # Open full conversation viewer in browser
86
+ ccm sessions --web
87
+
88
+ # Restore the most recently deleted session
89
+ ccm sessions --restore
90
+
91
+ # Restore a specific session by ID
92
+ ccm sessions --restore <sessionId>
93
+
94
+ # Permanently delete all trashed sessions
95
+ ccm sessions --purge
96
+ ```
97
+
98
+ **Terminal list controls:**
99
+ | Key | Action |
100
+ |---|---|
101
+ | `↑↓` | Navigate |
102
+ | `Enter` | Open in browser |
103
+ | `d` | Delete session (moves to trash) |
104
+ | `D` | Delete all sessions (moves to trash) |
105
+ | `←→` | Previous/next page |
106
+ | `q` | Quit |
68
107
 
69
108
  ## Shell Completions
70
109
 
package/README.zh.md CHANGED
@@ -65,6 +65,45 @@ ccm ps
65
65
  | `ccm check` | 检查 claude 是否安装 |
66
66
  | `ccm test [name]` | 测试 API 连接(不指定则测试全部) |
67
67
  | `ccm balance [name]` | 查询模型余额(不指定则查询全部) |
68
+ | `ccm sessions` | 浏览会话历史(终端交互式列表) |
69
+ | `ccm sessions --web` | 在浏览器中打开会话查看器 |
70
+ | `ccm sessions --restore [id]` | 从回收站恢复已删除的会话 |
71
+ | `ccm sessions --purge` | 永久清空回收站 |
72
+
73
+ ## 会话管理
74
+
75
+ 浏览、搜索、恢复你的 Claude Code 对话历史。
76
+
77
+ <p align="center">
78
+ <img src="assets/sessions-web.png" alt="ccm sessions web" width="800" />
79
+ </p>
80
+
81
+ ```bash
82
+ # 终端交互式列表 — 方向键导航
83
+ ccm sessions
84
+
85
+ # 在浏览器中查看完整对话
86
+ ccm sessions --web
87
+
88
+ # 恢复最近一次删除的会话
89
+ ccm sessions --restore
90
+
91
+ # 恢复指定会话
92
+ ccm sessions --restore <sessionId>
93
+
94
+ # 永久清空回收站
95
+ ccm sessions --purge
96
+ ```
97
+
98
+ **终端列表操作:**
99
+ | 按键 | 功能 |
100
+ |---|---|
101
+ | `↑↓` | 上下选择 |
102
+ | `Enter` | 在浏览器中打开 |
103
+ | `d` | 删除会话(移入回收站) |
104
+ | `D` | 清空全部会话(移入回收站) |
105
+ | `←→` | 上一页/下一页 |
106
+ | `q` | 退出 |
68
107
 
69
108
  ## Shell 补全
70
109
 
Binary file
package/bin/ccm.js CHANGED
@@ -806,6 +806,1097 @@ var BASH_COMPLETION = `_ccm_completions() {
806
806
 
807
807
  complete -F _ccm_completions ccm`;
808
808
 
809
+ // src/session-data.ts
810
+ import fs4 from "fs";
811
+ import path4 from "path";
812
+ import os2 from "os";
813
+ var CLAUDE_DIR = path4.join(os2.homedir(), ".claude");
814
+ var HISTORY_FILE = path4.join(CLAUDE_DIR, "history.jsonl");
815
+ var PROJECTS_DIR = path4.join(CLAUDE_DIR, "projects");
816
+ var TRASH_DIR = path4.join(os2.homedir(), ".ccm", "session-trash");
817
+ var TRASH_EXPIRE_DAYS = 30;
818
+ function readJsonl(filePath) {
819
+ if (!fs4.existsSync(filePath)) return [];
820
+ const lines = fs4.readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
821
+ const results = [];
822
+ for (const line of lines) {
823
+ try {
824
+ results.push(JSON.parse(line));
825
+ } catch {
826
+ }
827
+ }
828
+ return results;
829
+ }
830
+ function atomicWriteJsonl(filePath, lines) {
831
+ const tmp = filePath + ".tmp";
832
+ fs4.writeFileSync(tmp, lines.join("\n"), "utf-8");
833
+ fs4.renameSync(tmp, filePath);
834
+ }
835
+ function getHistoryEntries() {
836
+ const entries = readJsonl(HISTORY_FILE);
837
+ return entries.filter((e) => e.sessionId && e.display && !e.display.startsWith("/") && e.display !== "exit").map((e) => ({
838
+ sessionId: e.sessionId,
839
+ display: e.display,
840
+ timestamp: e.timestamp,
841
+ project: e.project || ""
842
+ }));
843
+ }
844
+ function getSessionsList() {
845
+ const entries = getHistoryEntries();
846
+ const grouped = /* @__PURE__ */ new Map();
847
+ for (const entry of entries) {
848
+ const list = grouped.get(entry.sessionId) || [];
849
+ list.push(entry);
850
+ grouped.set(entry.sessionId, list);
851
+ }
852
+ const sessions = [];
853
+ for (const [sessionId, group] of grouped) {
854
+ group.sort((a, b) => a.timestamp - b.timestamp);
855
+ const first = group[0];
856
+ const projectPath = first.project;
857
+ const projectName = projectPath.split("/").pop() || projectPath;
858
+ sessions.push({
859
+ sessionId,
860
+ firstQuestion: first.display,
861
+ timestamp: first.timestamp,
862
+ project: projectPath,
863
+ projectName,
864
+ messageCount: group.length
865
+ });
866
+ }
867
+ sessions.sort((a, b) => b.timestamp - a.timestamp);
868
+ return sessions;
869
+ }
870
+ function findSessionJsonl(sessionId) {
871
+ if (!fs4.existsSync(PROJECTS_DIR)) return null;
872
+ const projectDirs = fs4.readdirSync(PROJECTS_DIR);
873
+ for (const dir of projectDirs) {
874
+ const filePath = path4.join(PROJECTS_DIR, dir, `${sessionId}.jsonl`);
875
+ if (fs4.existsSync(filePath)) return filePath;
876
+ }
877
+ return null;
878
+ }
879
+ function getSessionMessages(sessionId) {
880
+ const filePath = findSessionJsonl(sessionId);
881
+ if (!filePath) return [];
882
+ const entries = readJsonl(filePath);
883
+ const messages = [];
884
+ for (const entry of entries) {
885
+ if (entry.type === "user" && entry.message?.content && typeof entry.message.content === "string") {
886
+ messages.push({
887
+ role: "user",
888
+ content: entry.message.content,
889
+ timestamp: entry.timestamp || ""
890
+ });
891
+ } else if (entry.type === "assistant" && entry.message?.content) {
892
+ const textParts = [];
893
+ let model;
894
+ if (entry.message.model) model = entry.message.model;
895
+ if (Array.isArray(entry.message.content)) {
896
+ for (const block of entry.message.content) {
897
+ if (block.type === "text" && block.text) {
898
+ textParts.push(block.text);
899
+ }
900
+ }
901
+ }
902
+ if (textParts.length > 0) {
903
+ messages.push({
904
+ role: "assistant",
905
+ content: textParts.join("\n"),
906
+ timestamp: entry.timestamp || "",
907
+ model
908
+ });
909
+ }
910
+ }
911
+ }
912
+ return messages;
913
+ }
914
+ function getModelForSession(sessionId) {
915
+ const filePath = findSessionJsonl(sessionId);
916
+ if (!filePath) return null;
917
+ const entries = readJsonl(filePath);
918
+ for (const entry of entries) {
919
+ if (entry.type === "assistant" && entry.message?.model) {
920
+ return entry.message.model;
921
+ }
922
+ }
923
+ return null;
924
+ }
925
+ function resolveProfileName(model) {
926
+ const profiles = loadProfiles();
927
+ for (const [name, profile] of Object.entries(profiles)) {
928
+ if (profile.ANTHROPIC_MODEL === model) return name;
929
+ }
930
+ return null;
931
+ }
932
+ function getSessionTitle(sessionId) {
933
+ const filePath = findSessionJsonl(sessionId);
934
+ if (!filePath) return null;
935
+ const entries = readJsonl(filePath);
936
+ for (const entry of entries) {
937
+ if (entry.type === "ai-title" && entry.aiTitle) {
938
+ return entry.aiTitle;
939
+ }
940
+ }
941
+ return null;
942
+ }
943
+ function ensureTrashDir() {
944
+ fs4.mkdirSync(TRASH_DIR, { recursive: true });
945
+ }
946
+ function extractHistoryForSession(sessionId) {
947
+ if (!fs4.existsSync(HISTORY_FILE)) return [];
948
+ const lines = fs4.readFileSync(HISTORY_FILE, "utf-8").split("\n").filter(Boolean);
949
+ const matched = [];
950
+ for (const line of lines) {
951
+ try {
952
+ const entry = JSON.parse(line);
953
+ if (entry.sessionId === sessionId) matched.push(entry);
954
+ } catch {
955
+ }
956
+ }
957
+ return matched;
958
+ }
959
+ function filterHistoryBySession(sessionId) {
960
+ if (!fs4.existsSync(HISTORY_FILE)) return;
961
+ const lines = fs4.readFileSync(HISTORY_FILE, "utf-8").split("\n");
962
+ const filtered = lines.filter((line) => {
963
+ if (!line.trim()) return false;
964
+ try {
965
+ const entry = JSON.parse(line);
966
+ return entry.sessionId !== sessionId;
967
+ } catch {
968
+ return true;
969
+ }
970
+ });
971
+ atomicWriteJsonl(HISTORY_FILE, filtered);
972
+ }
973
+ function deleteSession(sessionId) {
974
+ ensureTrashDir();
975
+ const historyEntries = extractHistoryForSession(sessionId);
976
+ const filePath = findSessionJsonl(sessionId);
977
+ let originalProject = "";
978
+ if (filePath) {
979
+ originalProject = path4.basename(path4.dirname(filePath));
980
+ const destJsonl = path4.join(TRASH_DIR, `${sessionId}.jsonl`);
981
+ fs4.copyFileSync(filePath, destJsonl);
982
+ fs4.unlinkSync(filePath);
983
+ const sessionDir = path4.join(path4.dirname(filePath), sessionId);
984
+ if (fs4.existsSync(sessionDir)) {
985
+ const destDir = path4.join(TRASH_DIR, sessionId);
986
+ fs4.cpSync(sessionDir, destDir, { recursive: true });
987
+ fs4.rmSync(sessionDir, { recursive: true, force: true });
988
+ }
989
+ }
990
+ const meta = {
991
+ sessionId,
992
+ deletedAt: Date.now(),
993
+ originalProject,
994
+ historyEntries
995
+ };
996
+ fs4.writeFileSync(
997
+ path4.join(TRASH_DIR, `${sessionId}.meta.json`),
998
+ JSON.stringify(meta, null, 2),
999
+ "utf-8"
1000
+ );
1001
+ filterHistoryBySession(sessionId);
1002
+ }
1003
+ function deleteAllSessions() {
1004
+ ensureTrashDir();
1005
+ const sessions = getSessionsList();
1006
+ for (const session of sessions) {
1007
+ const historyEntries = extractHistoryForSession(session.sessionId);
1008
+ const filePath = findSessionJsonl(session.sessionId);
1009
+ if (filePath) {
1010
+ const destJsonl = path4.join(TRASH_DIR, `${session.sessionId}.jsonl`);
1011
+ fs4.copyFileSync(filePath, destJsonl);
1012
+ fs4.unlinkSync(filePath);
1013
+ const sessionDir = path4.join(path4.dirname(filePath), session.sessionId);
1014
+ if (fs4.existsSync(sessionDir)) {
1015
+ const destDir = path4.join(TRASH_DIR, session.sessionId);
1016
+ fs4.cpSync(sessionDir, destDir, { recursive: true });
1017
+ fs4.rmSync(sessionDir, { recursive: true, force: true });
1018
+ }
1019
+ }
1020
+ const meta = {
1021
+ sessionId: session.sessionId,
1022
+ deletedAt: Date.now(),
1023
+ originalProject: session.projectName,
1024
+ historyEntries
1025
+ };
1026
+ fs4.writeFileSync(
1027
+ path4.join(TRASH_DIR, `${session.sessionId}.meta.json`),
1028
+ JSON.stringify(meta, null, 2),
1029
+ "utf-8"
1030
+ );
1031
+ }
1032
+ if (fs4.existsSync(HISTORY_FILE)) {
1033
+ atomicWriteJsonl(HISTORY_FILE, []);
1034
+ }
1035
+ }
1036
+ function restoreSession(sessionId) {
1037
+ const metaPath = path4.join(TRASH_DIR, `${sessionId}.meta.json`);
1038
+ if (!fs4.existsSync(metaPath)) return false;
1039
+ const meta = JSON.parse(fs4.readFileSync(metaPath, "utf-8"));
1040
+ const destDir = findProjectDirForRestore(meta.originalProject);
1041
+ const trashJsonl = path4.join(TRASH_DIR, `${sessionId}.jsonl`);
1042
+ if (fs4.existsSync(trashJsonl) && destDir) {
1043
+ fs4.copyFileSync(trashJsonl, path4.join(destDir, `${sessionId}.jsonl`));
1044
+ fs4.unlinkSync(trashJsonl);
1045
+ }
1046
+ const trashSessionDir = path4.join(TRASH_DIR, sessionId);
1047
+ if (fs4.existsSync(trashSessionDir) && destDir) {
1048
+ const restoredDir = path4.join(destDir, sessionId);
1049
+ fs4.cpSync(trashSessionDir, restoredDir, { recursive: true });
1050
+ fs4.rmSync(trashSessionDir, { recursive: true, force: true });
1051
+ }
1052
+ if (meta.historyEntries && meta.historyEntries.length > 0) {
1053
+ const newLines = meta.historyEntries.map((e) => JSON.stringify(e));
1054
+ if (fs4.existsSync(HISTORY_FILE)) {
1055
+ const existing = fs4.readFileSync(HISTORY_FILE, "utf-8");
1056
+ const combined = existing.trimEnd() + "\n" + newLines.join("\n") + "\n";
1057
+ atomicWriteJsonl(HISTORY_FILE, combined.split("\n"));
1058
+ } else {
1059
+ atomicWriteJsonl(HISTORY_FILE, newLines);
1060
+ }
1061
+ }
1062
+ fs4.unlinkSync(metaPath);
1063
+ return true;
1064
+ }
1065
+ function findProjectDirForRestore(projectName) {
1066
+ if (!fs4.existsSync(PROJECTS_DIR)) return null;
1067
+ const dirs = fs4.readdirSync(PROJECTS_DIR);
1068
+ for (const dir of dirs) {
1069
+ if (dir.endsWith(projectName) || dir.includes(projectName)) {
1070
+ const fullPath = path4.join(PROJECTS_DIR, dir);
1071
+ if (fs4.statSync(fullPath).isDirectory()) return fullPath;
1072
+ }
1073
+ }
1074
+ if (dirs.length > 0) {
1075
+ const first = path4.join(PROJECTS_DIR, dirs[0]);
1076
+ if (fs4.statSync(first).isDirectory()) return first;
1077
+ }
1078
+ return null;
1079
+ }
1080
+ function getTrashSessions() {
1081
+ ensureTrashDir();
1082
+ const files = fs4.readdirSync(TRASH_DIR);
1083
+ const metas = [];
1084
+ for (const file of files) {
1085
+ if (file.endsWith(".meta.json")) {
1086
+ try {
1087
+ const meta = JSON.parse(fs4.readFileSync(path4.join(TRASH_DIR, file), "utf-8"));
1088
+ metas.push(meta);
1089
+ } catch {
1090
+ }
1091
+ }
1092
+ }
1093
+ metas.sort((a, b) => b.deletedAt - a.deletedAt);
1094
+ return metas;
1095
+ }
1096
+ function purgeTrash() {
1097
+ ensureTrashDir();
1098
+ const files = fs4.readdirSync(TRASH_DIR);
1099
+ for (const file of files) {
1100
+ const fullPath = path4.join(TRASH_DIR, file);
1101
+ if (fs4.statSync(fullPath).isDirectory()) {
1102
+ fs4.rmSync(fullPath, { recursive: true, force: true });
1103
+ } else {
1104
+ fs4.unlinkSync(fullPath);
1105
+ }
1106
+ }
1107
+ }
1108
+ function cleanupOldTrash() {
1109
+ ensureTrashDir();
1110
+ const cutoff = Date.now() - TRASH_EXPIRE_DAYS * 24 * 60 * 60 * 1e3;
1111
+ const metas = getTrashSessions();
1112
+ let cleaned = 0;
1113
+ for (const meta of metas) {
1114
+ if (meta.deletedAt < cutoff) {
1115
+ const jsonlPath = path4.join(TRASH_DIR, `${meta.sessionId}.jsonl`);
1116
+ if (fs4.existsSync(jsonlPath)) fs4.unlinkSync(jsonlPath);
1117
+ const sessionDir = path4.join(TRASH_DIR, meta.sessionId);
1118
+ if (fs4.existsSync(sessionDir)) fs4.rmSync(sessionDir, { recursive: true, force: true });
1119
+ const metaPath = path4.join(TRASH_DIR, `${meta.sessionId}.meta.json`);
1120
+ if (fs4.existsSync(metaPath)) fs4.unlinkSync(metaPath);
1121
+ cleaned++;
1122
+ }
1123
+ }
1124
+ return cleaned;
1125
+ }
1126
+
1127
+ // src/session-server.ts
1128
+ import { exec } from "child_process";
1129
+ import http2 from "http";
1130
+ function openBrowser(url) {
1131
+ const cmd = process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
1132
+ exec(cmd);
1133
+ }
1134
+ function jsonResponse(res, data, status = 200) {
1135
+ res.writeHead(status, { "Content-Type": "application/json" });
1136
+ res.end(JSON.stringify(data));
1137
+ }
1138
+ function startSessionServer(port = 13501) {
1139
+ return new Promise((resolve) => {
1140
+ const server = http2.createServer((req, res) => {
1141
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
1142
+ if (req.method === "GET" && (url.pathname === "/" || /^\/session\/[0-9a-f-]+$/.test(url.pathname))) {
1143
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1144
+ res.end(getHTML());
1145
+ return;
1146
+ }
1147
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
1148
+ const sessions = getSessionsList();
1149
+ jsonResponse(res, sessions);
1150
+ return;
1151
+ }
1152
+ const sessionMatch = url.pathname.match(/^\/api\/session\/([0-9a-f-]+)$/);
1153
+ if (sessionMatch) {
1154
+ const sessionId = sessionMatch[1];
1155
+ if (req.method === "GET") {
1156
+ const messages = getSessionMessages(sessionId);
1157
+ const model = getModelForSession(sessionId);
1158
+ const profile = model ? resolveProfileName(model) : null;
1159
+ const restoreCmd = profile ? `ccm ${profile} --resume ${sessionId}` : `ccm <model> --resume ${sessionId}`;
1160
+ const title = getSessionTitle(sessionId);
1161
+ jsonResponse(res, { messages, model, profile, restoreCmd, title });
1162
+ return;
1163
+ }
1164
+ if (req.method === "DELETE") {
1165
+ deleteSession(sessionId);
1166
+ jsonResponse(res, { ok: true });
1167
+ return;
1168
+ }
1169
+ }
1170
+ res.writeHead(404);
1171
+ res.end("Not Found");
1172
+ });
1173
+ server.listen(port, () => {
1174
+ const url = `http://localhost:${port}`;
1175
+ console.log(`
1176
+ ccm sessions web: ${url}
1177
+ `);
1178
+ openBrowser(url);
1179
+ resolve(server);
1180
+ });
1181
+ });
1182
+ }
1183
+ function getHTML() {
1184
+ const BT = String.fromCharCode(96);
1185
+ const jsCode = `
1186
+ let sessions = [];
1187
+ let currentSession = null;
1188
+
1189
+ async function loadSessions() {
1190
+ const res = await fetch('/api/sessions');
1191
+ sessions = await res.json();
1192
+ renderSessionList(sessions);
1193
+ const match = location.pathname.match(/\\/session\\/([0-9a-f-]+)/);
1194
+ if (match) selectSession(match[1], false);
1195
+ }
1196
+
1197
+ window.addEventListener('popstate', function(e) {
1198
+ if (e.state && e.state.sessionId) selectSession(e.state.sessionId, false);
1199
+ });
1200
+
1201
+ function renderSessionList(list) {
1202
+ const el = document.getElementById('sessionList');
1203
+ el.innerHTML = list.map((s, i) => {
1204
+ const date = new Date(s.timestamp);
1205
+ const time = date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
1206
+ const active = currentSession === s.sessionId ? ' active' : '';
1207
+ return '<div class="session-item' + active + '" data-id="' + s.sessionId + '" onclick="selectSession(\\'' + s.sessionId + '\\')">' +
1208
+ '<div class="time">' + time + '</div>' +
1209
+ '<div class="question">' + escHtml(s.firstQuestion) + '</div>' +
1210
+ '</div>';
1211
+ }).join('');
1212
+ }
1213
+
1214
+ async function selectSession(sessionId, pushState) {
1215
+ currentSession = sessionId;
1216
+ if (pushState !== false) history.pushState({ sessionId }, '', '/session/' + sessionId);
1217
+ renderSessionList(getFilteredList());
1218
+ const res = await fetch('/api/session/' + sessionId);
1219
+ const data = await res.json();
1220
+ const session = sessions.find(s => s.sessionId === sessionId);
1221
+ const date = session ? new Date(session.timestamp) : new Date();
1222
+ const dateStr = date.toLocaleString('zh-CN');
1223
+ const mainEl = document.getElementById('main');
1224
+ const questions = data.messages.filter(m => m.role === 'user');
1225
+ let html = '<div class="main-header">' +
1226
+ '<h1>' + escHtml(session?.firstQuestion || data.title || '\u65E0\u6807\u9898') + '</h1>' +
1227
+ '<div class="meta">' +
1228
+ '<span class="meta-date"><svg width="13" height="13" viewBox="0 0 13 13" fill="none"><rect x="1" y="3" width="11" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M4 1.5V3M9 1.5V3M1 6h11" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>' + dateStr + '</span>' +
1229
+ (data.model ? '<span><svg width="13" height="13" viewBox="0 0 13 13" fill="none"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.3"/><path d="M6.5 3.5V6.5L8.5 8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>' + escHtml(data.model) + '</span>' : '') +
1230
+ '</div>' +
1231
+ '<div class="restore-bar">' +
1232
+ '<code>' + escHtml(data.restoreCmd) + '</code>' +
1233
+ '<button class="copy-btn" onclick="copyCmd()">\u590D\u5236</button>' +
1234
+ '</div></div>' +
1235
+ '<div class="messages" id="messages">';
1236
+ data.messages.forEach((m, i) => {
1237
+ const time = m.timestamp ? new Date(m.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
1238
+ if (m.role === 'user') {
1239
+ html += '<div class="message user" id="msg-' + i + '"><div class="avatar">U</div><div><div class="bubble">' + renderMarkdown(m.content) + '</div><div class="time">' + time + '</div></div></div>';
1240
+ } else {
1241
+ html += '<div class="message ai" id="msg-' + i + '"><div class="avatar">AI</div><div>' + (m.model ? '<div class="model-tag">' + escHtml(m.model) + '</div>' : '') + '<div class="bubble">' + renderMarkdown(m.content) + '</div><div class="time">' + time + '</div></div></div>';
1242
+ }
1243
+ });
1244
+ html += '<div class="message-count">\u5171 ' + data.messages.length + ' \u6761\u6D88\u606F</div></div>';
1245
+ mainEl.innerHTML = html;
1246
+ document.getElementById('indexPanel').style.display = '';
1247
+ const indexEl = document.getElementById('indexList');
1248
+ let idx = 0;
1249
+ indexEl.innerHTML = questions.map((q, i) => {
1250
+ const msgIdx = data.messages.indexOf(q);
1251
+ const text = q.content.length > 30 ? q.content.slice(0, 30) + '...' : q.content;
1252
+ return '<div class="index-item" data-msg="' + msgIdx + '" onclick="scrollToMsg(' + msgIdx + ')"><span class="num">' + (++idx) + '</span><span>' + escHtml(text) + '</span></div>';
1253
+ }).join('');
1254
+ window._restoreCmd = data.restoreCmd;
1255
+ setTimeout(() => {
1256
+ const msgsEl = document.getElementById('messages');
1257
+ if (!msgsEl) return;
1258
+ msgsEl.addEventListener('scroll', function() {
1259
+ var bar = document.getElementById('progressBar');
1260
+ if (!bar) return;
1261
+ var pct = msgsEl.scrollHeight > msgsEl.clientHeight ? (msgsEl.scrollTop / (msgsEl.scrollHeight - msgsEl.clientHeight)) * 100 : 0;
1262
+ bar.style.width = pct + '%';
1263
+ });
1264
+ var observer = new IntersectionObserver(function(entries) {
1265
+ for (var j = 0; j < entries.length; j++) {
1266
+ if (entries[j].isIntersecting) {
1267
+ var id = entries[j].target.id;
1268
+ var msgIdx = parseInt(id.replace('msg-', ''));
1269
+ document.querySelectorAll('.index-item').forEach(function(el) { el.classList.toggle('active', parseInt(el.dataset.msg) === msgIdx); });
1270
+ }
1271
+ }
1272
+ }, { root: msgsEl, threshold: 0.5 });
1273
+ document.querySelectorAll('.message').forEach(function(el) { observer.observe(el); });
1274
+ }, 100);
1275
+ }
1276
+
1277
+ function scrollToMsg(idx) {
1278
+ var el = document.getElementById('msg-' + idx);
1279
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
1280
+ }
1281
+
1282
+ function copyCmd() {
1283
+ if (window._restoreCmd) { navigator.clipboard.writeText(window._restoreCmd); showToast('\u5DF2\u590D\u5236: ' + window._restoreCmd); }
1284
+ }
1285
+
1286
+ function showToast(msg) {
1287
+ var el = document.getElementById('toast');
1288
+ el.textContent = msg;
1289
+ el.classList.add('show');
1290
+ setTimeout(function() { el.classList.remove('show'); }, 2000);
1291
+ }
1292
+
1293
+ function getFilteredList() {
1294
+ var q = document.getElementById('search').value.toLowerCase();
1295
+ if (!q) return sessions;
1296
+ return sessions.filter(function(s) { return s.firstQuestion.toLowerCase().includes(q) || s.projectName.toLowerCase().includes(q); });
1297
+ }
1298
+
1299
+ document.getElementById('search').addEventListener('input', function() { renderSessionList(getFilteredList()); });
1300
+
1301
+ function escHtml(s) {
1302
+ if (!s) return '';
1303
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1304
+ }
1305
+
1306
+ function renderMarkdown(text) {
1307
+ if (!text) return '';
1308
+ var h = escHtml(text);
1309
+ var BT = String.fromCharCode(96);
1310
+ h = h.replace(new RegExp(BT + BT + BT + '(\\\\w*)\\\\n([\\\\s\\\\S]*?)' + BT + BT + BT, 'g'), function(m, lang, code) { return '<pre><code>' + highlightCode(code) + '</code></pre>'; });
1311
+ h = h.replace(new RegExp(BT + '([^\\\\n]+?)' + BT, 'g'), '<code>$1</code>');
1312
+ h = h.replace(/^### (.+)$/gm, '<h3>$1</h3>');
1313
+ h = h.replace(/^## (.+)$/gm, '<h2>$1</h2>');
1314
+ h = h.replace(/^# (.+)$/gm, '<h1>$1</h1>');
1315
+ h = h.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
1316
+ h = h.replace(/\\*(.+?)\\*/g, '<em>$1</em>');
1317
+ h = h.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" target="_blank">$1</a>');
1318
+ h = h.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
1319
+ h = h.replace(/^---$/gm, '<hr>');
1320
+ h = h.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
1321
+ h = h.replace(/(<li>.*<\\/li>)/s, '<ul>$1</ul>');
1322
+ h = h.replace(/^\\|(.+)\\|$/gm, function(m, content) {
1323
+ var cells = content.split('|').map(function(c) { return c.trim(); });
1324
+ if (cells.every(function(c) { return /^[-:]+$/.test(c); })) return '';
1325
+ return '<tr>' + cells.map(function(c) { return '<td>' + c + '</td>'; }).join('') + '</tr>';
1326
+ });
1327
+ h = h.replace(/(<tr>.*<\\/tr>)/gs, '<table>$1</table>');
1328
+ h = h.replace(/\\n\\n/g, '</p><p>');
1329
+ h = '<p>' + h + '</p>';
1330
+ h = h.replace(/<p><\\/p>/g, '');
1331
+ h = h.replace(/<p>(<h[123]>)/g, '$1');
1332
+ h = h.replace(/(<\\/h[123]>)<\\/p>/g, '$1');
1333
+ h = h.replace(/<p>(<pre>)/g, '$1');
1334
+ h = h.replace(/(<\\/pre>)<\\/p>/g, '$1');
1335
+ h = h.replace(/<p>(<table>)/g, '$1');
1336
+ h = h.replace(/(<\\/table>)<\\/p>/g, '$1');
1337
+ h = h.replace(/<p>(<ul>)/g, '$1');
1338
+ h = h.replace(/(<\\/ul>)<\\/p>/g, '$1');
1339
+ h = h.replace(/<p>(<blockquote>)/g, '$1');
1340
+ h = h.replace(/(<\\/blockquote>)<\\/p>/g, '$1');
1341
+ h = h.replace(/<p>(<hr>)/g, '$1');
1342
+ h = h.replace(/(<hr>)<\\/p>/g, '$1');
1343
+ return h;
1344
+ }
1345
+
1346
+ function highlightCode(code) {
1347
+ var h = code;
1348
+ h = h.replace(/(\\/\\/.*$)/gm, '<span class="cm">$1</span>');
1349
+ h = h.replace(/(#.*$)/gm, '<span class="cm">$1</span>');
1350
+ h = h.replace(/("(?:[^"\\\\\\\\]|\\\\\\\\.)*"|'(?:[^'\\\\\\\\]|\\\\\\\\.)*')/g, '<span class="str">$1</span>');
1351
+ h = h.replace(/\\b(import|export|from|const|let|var|function|return|if|else|for|while|class|extends|new|async|await|try|catch|throw|switch|case|break|default|typeof|instanceof|void|null|undefined|true|false|def|self|print|raise|with|as|in|not|and|or|is|lambda|yield|assert|del|global|nonlocal|pass|elif|except|finally)\\b/g, '<span class="kw">$1</span>');
1352
+ h = h.replace(/\\b(\\d+\\.?\\d*)\\b/g, '<span class="num">$1</span>');
1353
+ return h;
1354
+ }
1355
+
1356
+ loadSessions();`;
1357
+ return `<!DOCTYPE html>
1358
+ <html lang="zh-CN">
1359
+ <head>
1360
+ <meta charset="UTF-8">
1361
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1362
+ <title>ccm sessions</title>
1363
+ <style>
1364
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1365
+ html { scroll-behavior: smooth; }
1366
+ :root {
1367
+ --td-brand-color: #0052D9;
1368
+ --td-brand-color-hover: #266FE8;
1369
+ --td-brand-color-light: #ECF2FE;
1370
+ --td-success-color: #00A870;
1371
+ --td-warning-color: #ED7B2F;
1372
+ --td-error-color: #E34D59;
1373
+ --td-gray-1: #F3F3F3;
1374
+ --td-gray-2: #EEEEEE;
1375
+ --td-gray-3: #E7E7E7;
1376
+ --td-gray-4: #DCDCDC;
1377
+ --td-gray-6: #A6A6A6;
1378
+ --td-gray-8: #616161;
1379
+ --td-gray-10: #1A1A1A;
1380
+ --td-text-primary: #1A1A1A;
1381
+ --td-text-secondary: #4A4A4A;
1382
+ --td-text-placeholder: #A6A6A6;
1383
+ --td-bg-page: #F3F3F3;
1384
+ --td-bg-card: #FFFFFF;
1385
+ --td-border-level-1: #E7E7E7;
1386
+ --td-radius-small: 3px;
1387
+ --td-radius-default: 6px;
1388
+ --td-radius-large: 9px;
1389
+ --td-shadow-1: 0 1px 4px rgba(0,0,0,.08);
1390
+ --td-shadow-2: 0 4px 16px rgba(0,0,0,.10);
1391
+ }
1392
+ body {
1393
+ font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif;
1394
+ font-size: 14px; line-height: 1.6;
1395
+ color: var(--td-text-primary); background: var(--td-bg-page);
1396
+ height: 100vh; overflow: hidden;
1397
+ }
1398
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
1399
+ ::-webkit-scrollbar-track { background: transparent; }
1400
+ ::-webkit-scrollbar-thumb { background: var(--td-gray-4); border-radius: 3px; }
1401
+ ::-webkit-scrollbar-thumb:hover { background: var(--td-gray-6); }
1402
+
1403
+ .app { display: grid; grid-template-columns: 280px 1fr 220px; height: 100vh; }
1404
+
1405
+ /* \u2500\u2500\u2500 Sidebar \u2500\u2500\u2500 */
1406
+ .sidebar {
1407
+ background: var(--td-bg-card);
1408
+ border-right: 1px solid var(--td-border-level-1);
1409
+ display: flex; flex-direction: column; overflow: hidden;
1410
+ }
1411
+ .sidebar-header {
1412
+ padding: 16px; border-bottom: 1px solid var(--td-border-level-1);
1413
+ }
1414
+ .sidebar-header h2 {
1415
+ font-size: 15px; font-weight: 600; margin-bottom: 10px;
1416
+ color: var(--td-text-primary);
1417
+ }
1418
+ .sidebar-header input {
1419
+ width: 100%; padding: 8px 12px;
1420
+ border: 1px solid var(--td-border-level-1);
1421
+ border-radius: var(--td-radius-default);
1422
+ font-size: 13px; outline: none; color: var(--td-text-primary);
1423
+ transition: border-color .15s, box-shadow .15s;
1424
+ }
1425
+ .sidebar-header input::placeholder { color: var(--td-text-placeholder); }
1426
+ .sidebar-header input:focus {
1427
+ border-color: var(--td-brand-color);
1428
+ box-shadow: 0 0 0 2px rgba(0,82,217,.12);
1429
+ }
1430
+ .session-list { flex: 1; overflow-y: auto; padding: 8px 0; }
1431
+ .session-item {
1432
+ padding: 10px 16px; cursor: pointer;
1433
+ transition: background .15s;
1434
+ border-radius: 0;
1435
+ border-left: 3px solid transparent;
1436
+ }
1437
+ .session-item:hover { background: var(--td-brand-color-light); }
1438
+ .session-item.active {
1439
+ background: var(--td-brand-color-light);
1440
+ border-left-color: var(--td-brand-color);
1441
+ }
1442
+ .session-item .time {
1443
+ font-size: 12px; font-weight: 700; color: var(--td-brand-color);
1444
+ text-transform: uppercase; letter-spacing: .06em;
1445
+ font-variant-numeric: tabular-nums;
1446
+ }
1447
+ .session-item .question {
1448
+ font-size: 13px;
1449
+ margin-top: 4px;
1450
+ color: var(--td-text-primary);
1451
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1452
+ line-height: 1.5;
1453
+ }
1454
+
1455
+ /* \u2500\u2500\u2500 Progress bar \u2500\u2500\u2500 */
1456
+ .progress-bar {
1457
+ position: fixed; top: 0; left: 0; height: 3px; width: 0;
1458
+ background: linear-gradient(90deg, var(--td-brand-color), #66B2FF);
1459
+ z-index: 100; transition: width .1s;
1460
+ }
1461
+
1462
+ /* \u2500\u2500\u2500 Main \u2500\u2500\u2500 */
1463
+ .main { display: flex; flex-direction: column; overflow: hidden; }
1464
+ .main-header {
1465
+ padding: 20px 24px;
1466
+ border-bottom: 1px solid var(--td-border-level-1);
1467
+ background: var(--td-bg-card);
1468
+ }
1469
+ .main-header h1 {
1470
+ font-size: 16px; font-weight: 600; margin-bottom: 10px;
1471
+ line-height: 1.5; color: var(--td-text-primary);
1472
+ }
1473
+ .main-header .meta {
1474
+ display: flex; gap: 18px; font-size: 12px;
1475
+ color: var(--td-text-placeholder); flex-wrap: wrap;
1476
+ }
1477
+ .main-header .meta span {
1478
+ display: flex; align-items: center; gap: 5px;
1479
+ }
1480
+ .main-header .meta .meta-date {
1481
+ text-transform: uppercase; letter-spacing: .06em;
1482
+ }
1483
+ .main-header .meta svg { opacity: .7; }
1484
+ .restore-bar {
1485
+ margin-top: 14px; display: flex; align-items: center; gap: 10px;
1486
+ background: var(--td-gray-1); padding: 10px 14px;
1487
+ border-radius: var(--td-radius-default);
1488
+ border: 1px solid var(--td-border-level-1);
1489
+ font-family: "JetBrains Mono", "Fira Code", "SF Mono", Monaco, Consolas, monospace;
1490
+ font-size: 13px;
1491
+ }
1492
+ .restore-bar code { flex: 1; color: var(--td-text-secondary); }
1493
+ .copy-btn {
1494
+ padding: 5px 14px; background: var(--td-brand-color); color: #fff;
1495
+ border: none; border-radius: var(--td-radius-small);
1496
+ cursor: pointer; font-size: 12px; font-weight: 500; white-space: nowrap;
1497
+ transition: background .15s;
1498
+ }
1499
+ .copy-btn:hover { background: var(--td-brand-color-hover); }
1500
+
1501
+ /* \u2500\u2500\u2500 Messages \u2500\u2500\u2500 */
1502
+ .messages {
1503
+ flex: 1; overflow-y: auto; padding: 24px 16px;
1504
+ background: var(--td-bg-card);box-shadow: var(--td-shadow-1);
1505
+ }
1506
+ .message { margin-bottom: 20px; display: flex; gap: 10px; width: 100%; }
1507
+ .message.user { flex-direction: row-reverse; justify-content: flex-start; }
1508
+ .message.user > div:not(.avatar) { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-end; }
1509
+ .message.user .bubble { max-width: 85%; }
1510
+ .message .avatar {
1511
+ width: 32px; height: 32px; border-radius: 50%;
1512
+ display: flex; align-items: center; justify-content: center;
1513
+ font-size: 13px; font-weight: 600; flex-shrink: 0;
1514
+ }
1515
+ .message.user .avatar {
1516
+ background: var(--td-brand-color); color: #fff;
1517
+ }
1518
+ .message.ai .avatar {
1519
+ background: #F6FFF9; color: var(--td-success-color);
1520
+ border: 1px solid #A3DFC5;
1521
+ }
1522
+ .message .bubble {
1523
+ max-width: 85%; padding: 12px 16px;
1524
+ border-radius: var(--td-radius-large);
1525
+ line-height: 1.7; font-size: 14px;
1526
+ }
1527
+ .message.user .bubble { background: var(--td-brand-color-light); color: var(--td-text-primary); }
1528
+ .message.ai .bubble { background-color: var(--ai-bubble, #f5f7fa);}
1529
+ .message .time {
1530
+ font-size: 11px; color: var(--td-text-placeholder); margin-top: 6px;
1531
+ font-variant-numeric: tabular-nums;
1532
+ }
1533
+ .message.user .time { text-align: right; }
1534
+ .message .model-tag {
1535
+ font-size: 11px; color: var(--td-brand-color); margin-bottom: 6px;
1536
+ font-weight: 500;
1537
+ }
1538
+ .message-count {
1539
+ text-align: center; color: var(--td-text-placeholder);
1540
+ font-size: 12px; padding: 20px;
1541
+ }
1542
+
1543
+ /* \u2500\u2500\u2500 Bubble content \u2500\u2500\u2500 */
1544
+ .bubble h1, .bubble h2, .bubble h3 { margin: 16px 0 8px; font-weight: 600; color: var(--td-text-primary); }
1545
+ .bubble h1 { font-size: 17px; }
1546
+ .bubble h2 { font-size: 15px; }
1547
+ .bubble h3 { font-size: 14px; }
1548
+ .bubble p { margin-bottom: 10px; }
1549
+ .bubble p:last-child { margin-bottom: 0; }
1550
+ .bubble ul, .bubble ol { margin: 10px 0; padding-left: 22px; }
1551
+ .bubble li { margin-bottom: 5px; line-height: 1.6; }
1552
+ .bubble code {
1553
+ background: rgba(175,184,193,.25); color: #C7254E;
1554
+ padding: 2px 5px; border-radius: 3px;
1555
+ font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; font-size: .86em;
1556
+ }
1557
+ .bubble pre {
1558
+ background: #F6F8FA; border: 1px solid #E1E4E8;
1559
+ padding: 16px 18px; border-radius: var(--td-radius-default);
1560
+ overflow-x: auto; margin: 12px 0;
1561
+ font-size: 13px; line-height: 1.7;
1562
+ }
1563
+ .bubble pre code {
1564
+ background: none; padding: 0; color: #24292E; font-size: 12.5px;
1565
+ border-radius: 0;
1566
+ }
1567
+ .bubble blockquote {
1568
+ border-left: 4px solid var(--td-brand-color);
1569
+ background: var(--td-brand-color-light);
1570
+ padding: 12px 16px; margin: 12px 0;
1571
+ border-radius: 0 var(--td-radius-default) var(--td-radius-default) 0;
1572
+ color: var(--td-text-secondary); font-size: 13.5px;
1573
+ }
1574
+ .bubble table { border-collapse: collapse; margin: 12px 0; width: 100%; font-size: 13px; }
1575
+ .bubble th, .bubble td { border: 1px solid var(--td-border-level-1); padding: 8px 12px; text-align: left; }
1576
+ .bubble th { background: var(--td-gray-1); font-weight: 600; color: var(--td-text-primary); }
1577
+ .bubble td { color: var(--td-text-secondary); }
1578
+ .bubble a { color: var(--td-brand-color); text-decoration: none; }
1579
+ .bubble a:hover { text-decoration: underline; }
1580
+ .bubble strong { font-weight: 600; color: var(--td-text-primary); }
1581
+ .bubble em { font-style: italic; color: var(--td-brand-color); }
1582
+ .bubble hr { border: none; border-top: 1px solid var(--td-border-level-1); margin: 16px 0; }
1583
+
1584
+ /* GitHub Light syntax colors */
1585
+ .bubble pre .kw { color: #D73A49; font-weight: 600; }
1586
+ .bubble pre .str { color: #032F62; }
1587
+ .bubble pre .cm { color: #6A737D; font-style: italic; }
1588
+ .bubble pre .fn { color: #6F42C1; }
1589
+ .bubble pre .num { color: #005CC5; }
1590
+ .bubble pre .keyword { color: #D73A49; font-weight: 600; }
1591
+ .bubble pre .string { color: #032F62; }
1592
+ .bubble pre .comment { color: #6A737D; font-style: italic; }
1593
+ .bubble pre .number { color: #005CC5; }
1594
+
1595
+ /* \u2500\u2500\u2500 Index panel \u2500\u2500\u2500 */
1596
+ .index {
1597
+ background: var(--td-bg-card);
1598
+ border-left: 1px solid var(--td-border-level-1);
1599
+ display: flex; flex-direction: column; overflow: hidden;
1600
+ }
1601
+ .index-header {
1602
+ padding: 14px 16px; border-bottom: 1px solid var(--td-border-level-1);
1603
+ font-size: 16px; font-weight: 700; color: var(--td-text-primary);
1604
+ }
1605
+ .index-list { flex: 1; overflow-y: auto; padding: 8px; }
1606
+ .index-item {
1607
+ padding: 8px 10px; font-size: 12px; cursor: pointer;
1608
+ border-radius: var(--td-radius-small);
1609
+ color: var(--td-text-secondary); line-height: 1.5;
1610
+ transition: background .15s, color .15s;
1611
+ display: flex; gap: 8px;
1612
+ }
1613
+ .index-item:hover { background: var(--td-brand-color-light); color: var(--td-text-primary); }
1614
+ .index-item.active {
1615
+ background: var(--td-brand-color-light);
1616
+ color: var(--td-brand-color); font-weight: 500;
1617
+ }
1618
+ .index-item .num {
1619
+ color: var(--td-text-placeholder); flex-shrink: 0; min-width: 18px;
1620
+ font-variant-numeric: tabular-nums;
1621
+ }
1622
+
1623
+ /* \u2500\u2500\u2500 Empty state \u2500\u2500\u2500 */
1624
+ .empty {
1625
+ display: flex; align-items: center; justify-content: center;
1626
+ height: 100%; color: var(--td-text-placeholder); font-size: 15px;
1627
+ }
1628
+ .welcome { text-align: center; }
1629
+ .welcome h2 {
1630
+ font-size: 20px; margin-bottom: 8px; color: var(--td-text-primary);
1631
+ font-weight: 600;
1632
+ }
1633
+ .welcome p { font-size: 14px; color: var(--td-text-placeholder); }
1634
+
1635
+ /* \u2500\u2500\u2500 Toast \u2500\u2500\u2500 */
1636
+ .toast {
1637
+ position: fixed; bottom: 40px; left: 50%;
1638
+ transform: translateX(-50%) translateY(8px);
1639
+ background: var(--td-gray-10); color: #fff;
1640
+ padding: 10px 24px; border-radius: var(--td-radius-large);
1641
+ font-size: 13px; opacity: 0;
1642
+ transition: opacity .25s, transform .25s;
1643
+ pointer-events: none; z-index: 100;
1644
+ box-shadow: var(--td-shadow-2);
1645
+ }
1646
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
1647
+ </style>
1648
+ </head>
1649
+ <body>
1650
+ <div class="progress-bar" id="progressBar"></div>
1651
+ <div class="app">
1652
+ <div class="sidebar">
1653
+ <div class="sidebar-header">
1654
+ <h2>ccm sessions</h2>
1655
+ <input type="text" id="search" placeholder="\u641C\u7D22\u4F1A\u8BDD...">
1656
+ </div>
1657
+ <div class="session-list" id="sessionList"></div>
1658
+ </div>
1659
+ <div class="main" id="main">
1660
+ <div class="empty" id="emptyState">
1661
+ <div class="welcome">
1662
+ <h2>ccm sessions</h2>
1663
+ <p>\u9009\u62E9\u5DE6\u4FA7\u4F1A\u8BDD\u67E5\u770B\u5BF9\u8BDD\u8BB0\u5F55</p>
1664
+ </div>
1665
+ </div>
1666
+ </div>
1667
+ <div class="index" id="indexPanel" style="display:none">
1668
+ <div class="index-header">\u95EE\u9898\u7D22\u5F15</div>
1669
+ <div class="index-list" id="indexList"></div>
1670
+ </div>
1671
+ </div>
1672
+ <div class="toast" id="toast"></div>
1673
+ <script>${jsCode}</script>
1674
+ </body>
1675
+ </html>`;
1676
+ }
1677
+
1678
+ // src/commands/sessions.ts
1679
+ var PAGE_SIZE = 10;
1680
+ function formatTime(ts) {
1681
+ const d = new Date(ts);
1682
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
1683
+ const dd = String(d.getDate()).padStart(2, "0");
1684
+ const hh = String(d.getHours()).padStart(2, "0");
1685
+ const mi = String(d.getMinutes()).padStart(2, "0");
1686
+ return `${mm}-${dd} ${hh}:${mi}`;
1687
+ }
1688
+ function truncate(str, len) {
1689
+ if (str.length <= len) return str;
1690
+ return str.slice(0, len) + "...";
1691
+ }
1692
+ function printTable(sessions, page, selectedIndex) {
1693
+ const start = page * PAGE_SIZE;
1694
+ const end = Math.min(start + PAGE_SIZE, sessions.length);
1695
+ const pageSessions = sessions.slice(start, end);
1696
+ console.clear();
1697
+ console.log();
1698
+ console.log(" \x1B[1mccm sessions\x1B[0m (\u2191\u2193 \u9009\u62E9 | Enter Web\u67E5\u770B | d \u5220\u9664 | D \u6E05\u7A7A\u5168\u90E8 | q \u9000\u51FA)");
1699
+ console.log();
1700
+ if (sessions.length === 0) {
1701
+ console.log(" \u6682\u65E0\u4F1A\u8BDD\u8BB0\u5F55");
1702
+ console.log();
1703
+ return;
1704
+ }
1705
+ const totalW = process.stdout.columns || 80;
1706
+ const timeW = 12;
1707
+ const gap = 2;
1708
+ const prefixW = 4;
1709
+ const availW = totalW - prefixW - timeW - gap * 2;
1710
+ let maxProjLen = 4;
1711
+ for (const s of pageSessions) {
1712
+ if (s.projectName.length > maxProjLen) maxProjLen = s.projectName.length;
1713
+ }
1714
+ const projW = maxProjLen + 2;
1715
+ const questionW = Math.max(10, availW - projW);
1716
+ console.log(
1717
+ " ".repeat(prefixW) + "\x1B[2m" + "\u65F6\u95F4".padEnd(timeW) + " ".repeat(gap) + "\u9879\u76EE".padEnd(projW) + " ".repeat(gap) + "\u9996\u6761\u63D0\u95EE\x1B[0m"
1718
+ );
1719
+ console.log(" ".repeat(prefixW) + "\x1B[2m" + "\u2500".repeat(Math.min(totalW - prefixW, 80)) + "\x1B[0m");
1720
+ for (let i = 0; i < pageSessions.length; i++) {
1721
+ const s = pageSessions[i];
1722
+ const globalIdx = start + i;
1723
+ const isSelected = globalIdx === selectedIndex;
1724
+ const prefix = isSelected ? " \x1B[36m\u25B6 " : " ";
1725
+ const suffix = isSelected ? "\x1B[0m" : "";
1726
+ const time = formatTime(s.timestamp);
1727
+ const proj = truncate(s.projectName, projW);
1728
+ const question = truncate(s.firstQuestion, questionW);
1729
+ console.log(
1730
+ prefix + time.padEnd(timeW) + " ".repeat(gap) + proj.padEnd(projW) + " ".repeat(gap) + question + suffix
1731
+ );
1732
+ }
1733
+ const totalPages = Math.ceil(sessions.length / PAGE_SIZE);
1734
+ if (totalPages > 1) {
1735
+ console.log();
1736
+ console.log(
1737
+ `\x1B[2m \u7B2C ${page + 1}/${totalPages} \u9875 (\u5171 ${sessions.length} \u4E2A\u4F1A\u8BDD, \u2190 \u2192 \u7FFB\u9875)\x1B[0m`
1738
+ );
1739
+ }
1740
+ }
1741
+ async function confirmPrompt(msg) {
1742
+ process.stdout.write(`
1743
+ ${msg} (y/N) `);
1744
+ return new Promise((resolve) => {
1745
+ process.stdin.once("data", (data) => {
1746
+ resolve(data.toString().trim().toLowerCase() === "y");
1747
+ });
1748
+ });
1749
+ }
1750
+ async function cmdSessions(args) {
1751
+ cleanupOldTrash();
1752
+ if (args.purge) {
1753
+ const trash = getTrashSessions();
1754
+ if (trash.length === 0) {
1755
+ console.log("\n \u56DE\u6536\u7AD9\u4E3A\u7A7A\n");
1756
+ return;
1757
+ }
1758
+ console.log(`
1759
+ \u56DE\u6536\u7AD9\u4E2D\u6709 ${trash.length} \u4E2A\u4F1A\u8BDD`);
1760
+ const confirmed = await confirmPrompt("\x1B[31m\u786E\u8BA4\u6E05\u7A7A\u56DE\u6536\u7AD9\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\uFF01\x1B[0m");
1761
+ if (confirmed) {
1762
+ purgeTrash();
1763
+ ok("\u56DE\u6536\u7AD9\u5DF2\u6E05\u7A7A");
1764
+ }
1765
+ console.log();
1766
+ return;
1767
+ }
1768
+ if (args.restore !== void 0) {
1769
+ if (args.restore === "") {
1770
+ const trash = getTrashSessions();
1771
+ if (trash.length === 0) {
1772
+ console.log("\n \u56DE\u6536\u7AD9\u4E3A\u7A7A\n");
1773
+ return;
1774
+ }
1775
+ const latest = trash[0];
1776
+ const confirmed = await confirmPrompt(`\u6062\u590D\u4F1A\u8BDD "${latest.sessionId.slice(0, 8)}..."\uFF1F`);
1777
+ if (confirmed) {
1778
+ restoreSession(latest.sessionId);
1779
+ ok("\u4F1A\u8BDD\u5DF2\u6062\u590D");
1780
+ }
1781
+ } else {
1782
+ const success = restoreSession(args.restore);
1783
+ if (success) {
1784
+ ok("\u4F1A\u8BDD\u5DF2\u6062\u590D");
1785
+ } else {
1786
+ err(`\u672A\u627E\u5230\u4F1A\u8BDD ${args.restore}`);
1787
+ }
1788
+ }
1789
+ console.log();
1790
+ return;
1791
+ }
1792
+ if (args.web) {
1793
+ await startSessionServer();
1794
+ return;
1795
+ }
1796
+ const sessions = getSessionsList();
1797
+ if (sessions.length === 0) {
1798
+ console.log("\n \u6682\u65E0\u4F1A\u8BDD\u8BB0\u5F55\n");
1799
+ return;
1800
+ }
1801
+ let selectedIndex = 0;
1802
+ let page = 0;
1803
+ const totalPages = Math.ceil(sessions.length / PAGE_SIZE);
1804
+ process.stdin.setRawMode?.(true);
1805
+ process.stdin.resume();
1806
+ process.stdin.setEncoding("utf-8");
1807
+ const cleanup = () => {
1808
+ process.stdin.setRawMode?.(false);
1809
+ process.stdin.pause();
1810
+ };
1811
+ printTable(sessions, page, selectedIndex);
1812
+ return new Promise((resolve) => {
1813
+ process.stdin.on("data", async (key) => {
1814
+ if (key === "" || key === "q") {
1815
+ cleanup();
1816
+ console.log();
1817
+ resolve();
1818
+ return;
1819
+ }
1820
+ if (key === "\x1B[A") {
1821
+ if (selectedIndex > 0) {
1822
+ selectedIndex--;
1823
+ if (selectedIndex < page * PAGE_SIZE) {
1824
+ page = Math.floor(selectedIndex / PAGE_SIZE);
1825
+ }
1826
+ printTable(sessions, page, selectedIndex);
1827
+ }
1828
+ return;
1829
+ }
1830
+ if (key === "\x1B[B") {
1831
+ if (selectedIndex < sessions.length - 1) {
1832
+ selectedIndex++;
1833
+ if (selectedIndex >= (page + 1) * PAGE_SIZE) {
1834
+ page = Math.floor(selectedIndex / PAGE_SIZE);
1835
+ }
1836
+ printTable(sessions, page, selectedIndex);
1837
+ }
1838
+ return;
1839
+ }
1840
+ if (key === "\x1B[D") {
1841
+ if (page > 0) {
1842
+ page--;
1843
+ selectedIndex = page * PAGE_SIZE;
1844
+ printTable(sessions, page, selectedIndex);
1845
+ }
1846
+ return;
1847
+ }
1848
+ if (key === "\x1B[C") {
1849
+ if (page < totalPages - 1) {
1850
+ page++;
1851
+ selectedIndex = page * PAGE_SIZE;
1852
+ printTable(sessions, page, selectedIndex);
1853
+ }
1854
+ return;
1855
+ }
1856
+ if (key === "\r" || key === "\n") {
1857
+ cleanup();
1858
+ const session = sessions[selectedIndex];
1859
+ if (session) {
1860
+ console.log();
1861
+ await startSessionServer();
1862
+ }
1863
+ resolve();
1864
+ return;
1865
+ }
1866
+ if (key === "d") {
1867
+ cleanup();
1868
+ const session = sessions[selectedIndex];
1869
+ if (session) {
1870
+ const confirmed = await confirmPrompt(`\u786E\u8BA4\u5220\u9664 "${truncate(session.firstQuestion, 30)}"\uFF1F(\u79FB\u5165\u56DE\u6536\u7AD9)`);
1871
+ if (confirmed) {
1872
+ deleteSession(session.sessionId);
1873
+ sessions.splice(selectedIndex, 1);
1874
+ if (selectedIndex >= sessions.length) selectedIndex = Math.max(0, sessions.length - 1);
1875
+ ok("\u5DF2\u79FB\u5165\u56DE\u6536\u7AD9 (ccm sessions --restore \u6062\u590D)");
1876
+ }
1877
+ }
1878
+ process.stdin.setRawMode?.(true);
1879
+ process.stdin.resume();
1880
+ printTable(sessions, page, selectedIndex);
1881
+ return;
1882
+ }
1883
+ if (key === "D") {
1884
+ cleanup();
1885
+ const confirmed = await confirmPrompt("\x1B[33m\u786E\u8BA4\u6E05\u7A7A\u5168\u90E8\u4F1A\u8BDD\uFF1F(\u79FB\u5165\u56DE\u6536\u7AD9\uFF0C30\u5929\u540E\u81EA\u52A8\u6E05\u7406)\x1B[0m");
1886
+ if (confirmed) {
1887
+ deleteAllSessions();
1888
+ sessions.length = 0;
1889
+ ok("\u5168\u90E8\u4F1A\u8BDD\u5DF2\u79FB\u5165\u56DE\u6536\u7AD9");
1890
+ }
1891
+ process.stdin.setRawMode?.(true);
1892
+ process.stdin.resume();
1893
+ printTable(sessions, page, selectedIndex);
1894
+ return;
1895
+ }
1896
+ });
1897
+ });
1898
+ }
1899
+
809
1900
  // src/cli.ts
810
1901
  var pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
811
1902
  var VERSION = pkg.version;
@@ -824,7 +1915,8 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
824
1915
  "balance",
825
1916
  "bal",
826
1917
  "config",
827
- "completions"
1918
+ "completions",
1919
+ "sessions"
828
1920
  ]);
829
1921
  function printHelp() {
830
1922
  console.log(`
@@ -843,6 +1935,10 @@ Commands:
843
1935
  balance [name] Query model balance/credits
844
1936
  config <name> Show profile environment variables
845
1937
  completions Print shell completion script
1938
+ sessions Browse and manage session history
1939
+ sessions --web Open session viewer in browser
1940
+ sessions --restore [id] Restore session from trash
1941
+ sessions --purge Empty the trash permanently
846
1942
 
847
1943
  Options:
848
1944
  -v, --version Show version
@@ -943,6 +2039,16 @@ async function main() {
943
2039
  case "completions":
944
2040
  cmdCompletions({ shell: args.shell });
945
2041
  break;
2042
+ case "sessions": {
2043
+ const restoreIdx = rawArgs.indexOf("--restore");
2044
+ const restoreId = restoreIdx >= 0 ? rawArgs[restoreIdx + 1] || "" : void 0;
2045
+ await cmdSessions({
2046
+ web: rawArgs.includes("--web"),
2047
+ restore: restoreIdx >= 0 ? restoreId : void 0,
2048
+ purge: rawArgs.includes("--purge")
2049
+ });
2050
+ break;
2051
+ }
946
2052
  default:
947
2053
  console.error(`Unknown command: ${command}`);
948
2054
  printHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeandrew94/ccm",
3
- "version": "0.1.13",
3
+ "version": "0.2.1",
4
4
  "description": "Claude Code Model Manager - switch between AI models per terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "assets",
11
12
  "README.md",
12
13
  "README.zh.md",
13
14
  "LICENSE"