@leeandrew94/ccm 0.1.12 → 0.2.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.
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
 
@@ -104,7 +143,7 @@ Profile config file `~/.ccm/profiles.json` (managed via `ccm add` / `ccm edit`):
104
143
 
105
144
  ## Star History
106
145
 
107
- [![Star History Chart](https://api.star-history.com/svg?repos=leeandrew94/ccm-cli&type=Date)](https://star-history.com/#leeandrew94/ccm-cli&Date)
146
+ [![Star History Chart](https://api.star-history.com/chart?repos=leeandrew94/ccm-cli&type=date&legend=top-left)](https://www.star-history.com/?type=date&repos=leeandrew94%2Fccm-cli)
108
147
 
109
148
  ## License
110
149
 
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
 
@@ -104,7 +143,7 @@ Profile 配置文件 `~/.ccm/profiles.json`(通过 `ccm add` / `ccm edit` 管
104
143
 
105
144
  ## Star History
106
145
 
107
- [![Star History Chart](https://api.star-history.com/svg?repos=leeandrew94/ccm-cli&type=Date)](https://star-history.com/#leeandrew94/ccm-cli&Date)
146
+ [![Star History Chart](https://api.star-history.com/chart?repos=leeandrew94/ccm-cli&type=date&legend=top-left)](https://www.star-history.com/?type=date&repos=leeandrew94%2Fccm-cli)
108
147
 
109
148
  ## License
110
149
 
Binary file
package/bin/ccm.js CHANGED
@@ -254,7 +254,7 @@ function cmdLaunch(args) {
254
254
  writeRun(process.pid, args.name, "");
255
255
  info(`Launching claude with profile '${args.name}'...`);
256
256
  console.log();
257
- const child = spawn("claude", ["--settings", settingsPath], {
257
+ const child = spawn("claude", ["--settings", settingsPath, ...args.extraArgs || []], {
258
258
  stdio: "inherit"
259
259
  });
260
260
  child.on("exit", (code) => {
@@ -806,6 +806,1095 @@ 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; }
1507
+ .message.user { flex-direction: row-reverse; justify-content: flex-start; }
1508
+ .message .avatar {
1509
+ width: 32px; height: 32px; border-radius: 50%;
1510
+ display: flex; align-items: center; justify-content: center;
1511
+ font-size: 13px; font-weight: 600; flex-shrink: 0;
1512
+ }
1513
+ .message.user .avatar {
1514
+ background: var(--td-brand-color); color: #fff;
1515
+ }
1516
+ .message.ai .avatar {
1517
+ background: #F6FFF9; color: var(--td-success-color);
1518
+ border: 1px solid #A3DFC5;
1519
+ }
1520
+ .message .bubble {
1521
+ max-width: 85%; padding: 12px 16px;
1522
+ border-radius: var(--td-radius-large);
1523
+ line-height: 1.7; font-size: 14px;
1524
+ }
1525
+ .message.user .bubble { background: var(--td-brand-color-light); color: var(--td-text-primary); }
1526
+ .message.ai .bubble { background-color: var(--ai-bubble, #f5f7fa);}
1527
+ .message .time {
1528
+ font-size: 11px; color: var(--td-text-placeholder); margin-top: 6px;
1529
+ font-variant-numeric: tabular-nums;
1530
+ }
1531
+ .message.user .time { text-align: right; }
1532
+ .message .model-tag {
1533
+ font-size: 11px; color: var(--td-brand-color); margin-bottom: 6px;
1534
+ font-weight: 500;
1535
+ }
1536
+ .message-count {
1537
+ text-align: center; color: var(--td-text-placeholder);
1538
+ font-size: 12px; padding: 20px;
1539
+ }
1540
+
1541
+ /* \u2500\u2500\u2500 Bubble content \u2500\u2500\u2500 */
1542
+ .bubble h1, .bubble h2, .bubble h3 { margin: 16px 0 8px; font-weight: 600; color: var(--td-text-primary); }
1543
+ .bubble h1 { font-size: 17px; }
1544
+ .bubble h2 { font-size: 15px; }
1545
+ .bubble h3 { font-size: 14px; }
1546
+ .bubble p { margin-bottom: 10px; }
1547
+ .bubble p:last-child { margin-bottom: 0; }
1548
+ .bubble ul, .bubble ol { margin: 10px 0; padding-left: 22px; }
1549
+ .bubble li { margin-bottom: 5px; line-height: 1.6; }
1550
+ .bubble code {
1551
+ background: rgba(175,184,193,.25); color: #C7254E;
1552
+ padding: 2px 5px; border-radius: 3px;
1553
+ font-family: "JetBrains Mono", "Fira Code", Consolas, monospace; font-size: .86em;
1554
+ }
1555
+ .bubble pre {
1556
+ background: #F6F8FA; border: 1px solid #E1E4E8;
1557
+ padding: 16px 18px; border-radius: var(--td-radius-default);
1558
+ overflow-x: auto; margin: 12px 0;
1559
+ font-size: 13px; line-height: 1.7;
1560
+ }
1561
+ .bubble pre code {
1562
+ background: none; padding: 0; color: #24292E; font-size: 12.5px;
1563
+ border-radius: 0;
1564
+ }
1565
+ .bubble blockquote {
1566
+ border-left: 4px solid var(--td-brand-color);
1567
+ background: var(--td-brand-color-light);
1568
+ padding: 12px 16px; margin: 12px 0;
1569
+ border-radius: 0 var(--td-radius-default) var(--td-radius-default) 0;
1570
+ color: var(--td-text-secondary); font-size: 13.5px;
1571
+ }
1572
+ .bubble table { border-collapse: collapse; margin: 12px 0; width: 100%; font-size: 13px; }
1573
+ .bubble th, .bubble td { border: 1px solid var(--td-border-level-1); padding: 8px 12px; text-align: left; }
1574
+ .bubble th { background: var(--td-gray-1); font-weight: 600; color: var(--td-text-primary); }
1575
+ .bubble td { color: var(--td-text-secondary); }
1576
+ .bubble a { color: var(--td-brand-color); text-decoration: none; }
1577
+ .bubble a:hover { text-decoration: underline; }
1578
+ .bubble strong { font-weight: 600; color: var(--td-text-primary); }
1579
+ .bubble em { font-style: italic; color: var(--td-brand-color); }
1580
+ .bubble hr { border: none; border-top: 1px solid var(--td-border-level-1); margin: 16px 0; }
1581
+
1582
+ /* GitHub Light syntax colors */
1583
+ .bubble pre .kw { color: #D73A49; font-weight: 600; }
1584
+ .bubble pre .str { color: #032F62; }
1585
+ .bubble pre .cm { color: #6A737D; font-style: italic; }
1586
+ .bubble pre .fn { color: #6F42C1; }
1587
+ .bubble pre .num { color: #005CC5; }
1588
+ .bubble pre .keyword { color: #D73A49; font-weight: 600; }
1589
+ .bubble pre .string { color: #032F62; }
1590
+ .bubble pre .comment { color: #6A737D; font-style: italic; }
1591
+ .bubble pre .number { color: #005CC5; }
1592
+
1593
+ /* \u2500\u2500\u2500 Index panel \u2500\u2500\u2500 */
1594
+ .index {
1595
+ background: var(--td-bg-card);
1596
+ border-left: 1px solid var(--td-border-level-1);
1597
+ display: flex; flex-direction: column; overflow: hidden;
1598
+ }
1599
+ .index-header {
1600
+ padding: 14px 16px; border-bottom: 1px solid var(--td-border-level-1);
1601
+ font-size: 16px; font-weight: 700; color: var(--td-text-primary);
1602
+ }
1603
+ .index-list { flex: 1; overflow-y: auto; padding: 8px; }
1604
+ .index-item {
1605
+ padding: 8px 10px; font-size: 12px; cursor: pointer;
1606
+ border-radius: var(--td-radius-small);
1607
+ color: var(--td-text-secondary); line-height: 1.5;
1608
+ transition: background .15s, color .15s;
1609
+ display: flex; gap: 8px;
1610
+ }
1611
+ .index-item:hover { background: var(--td-brand-color-light); color: var(--td-text-primary); }
1612
+ .index-item.active {
1613
+ background: var(--td-brand-color-light);
1614
+ color: var(--td-brand-color); font-weight: 500;
1615
+ }
1616
+ .index-item .num {
1617
+ color: var(--td-text-placeholder); flex-shrink: 0; min-width: 18px;
1618
+ font-variant-numeric: tabular-nums;
1619
+ }
1620
+
1621
+ /* \u2500\u2500\u2500 Empty state \u2500\u2500\u2500 */
1622
+ .empty {
1623
+ display: flex; align-items: center; justify-content: center;
1624
+ height: 100%; color: var(--td-text-placeholder); font-size: 15px;
1625
+ }
1626
+ .welcome { text-align: center; }
1627
+ .welcome h2 {
1628
+ font-size: 20px; margin-bottom: 8px; color: var(--td-text-primary);
1629
+ font-weight: 600;
1630
+ }
1631
+ .welcome p { font-size: 14px; color: var(--td-text-placeholder); }
1632
+
1633
+ /* \u2500\u2500\u2500 Toast \u2500\u2500\u2500 */
1634
+ .toast {
1635
+ position: fixed; bottom: 40px; left: 50%;
1636
+ transform: translateX(-50%) translateY(8px);
1637
+ background: var(--td-gray-10); color: #fff;
1638
+ padding: 10px 24px; border-radius: var(--td-radius-large);
1639
+ font-size: 13px; opacity: 0;
1640
+ transition: opacity .25s, transform .25s;
1641
+ pointer-events: none; z-index: 100;
1642
+ box-shadow: var(--td-shadow-2);
1643
+ }
1644
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
1645
+ </style>
1646
+ </head>
1647
+ <body>
1648
+ <div class="progress-bar" id="progressBar"></div>
1649
+ <div class="app">
1650
+ <div class="sidebar">
1651
+ <div class="sidebar-header">
1652
+ <h2>ccm sessions</h2>
1653
+ <input type="text" id="search" placeholder="\u641C\u7D22\u4F1A\u8BDD...">
1654
+ </div>
1655
+ <div class="session-list" id="sessionList"></div>
1656
+ </div>
1657
+ <div class="main" id="main">
1658
+ <div class="empty" id="emptyState">
1659
+ <div class="welcome">
1660
+ <h2>ccm sessions</h2>
1661
+ <p>\u9009\u62E9\u5DE6\u4FA7\u4F1A\u8BDD\u67E5\u770B\u5BF9\u8BDD\u8BB0\u5F55</p>
1662
+ </div>
1663
+ </div>
1664
+ </div>
1665
+ <div class="index" id="indexPanel" style="display:none">
1666
+ <div class="index-header">\u95EE\u9898\u7D22\u5F15</div>
1667
+ <div class="index-list" id="indexList"></div>
1668
+ </div>
1669
+ </div>
1670
+ <div class="toast" id="toast"></div>
1671
+ <script>${jsCode}</script>
1672
+ </body>
1673
+ </html>`;
1674
+ }
1675
+
1676
+ // src/commands/sessions.ts
1677
+ var PAGE_SIZE = 10;
1678
+ function formatTime(ts) {
1679
+ const d = new Date(ts);
1680
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
1681
+ const dd = String(d.getDate()).padStart(2, "0");
1682
+ const hh = String(d.getHours()).padStart(2, "0");
1683
+ const mi = String(d.getMinutes()).padStart(2, "0");
1684
+ return `${mm}-${dd} ${hh}:${mi}`;
1685
+ }
1686
+ function truncate(str, len) {
1687
+ if (str.length <= len) return str;
1688
+ return str.slice(0, len) + "...";
1689
+ }
1690
+ function printTable(sessions, page, selectedIndex) {
1691
+ const start = page * PAGE_SIZE;
1692
+ const end = Math.min(start + PAGE_SIZE, sessions.length);
1693
+ const pageSessions = sessions.slice(start, end);
1694
+ console.clear();
1695
+ console.log();
1696
+ 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)");
1697
+ console.log();
1698
+ if (sessions.length === 0) {
1699
+ console.log(" \u6682\u65E0\u4F1A\u8BDD\u8BB0\u5F55");
1700
+ console.log();
1701
+ return;
1702
+ }
1703
+ const totalW = process.stdout.columns || 80;
1704
+ const timeW = 12;
1705
+ const gap = 2;
1706
+ const prefixW = 4;
1707
+ const availW = totalW - prefixW - timeW - gap * 2;
1708
+ let maxProjLen = 4;
1709
+ for (const s of pageSessions) {
1710
+ if (s.projectName.length > maxProjLen) maxProjLen = s.projectName.length;
1711
+ }
1712
+ const projW = maxProjLen + 2;
1713
+ const questionW = Math.max(10, availW - projW);
1714
+ console.log(
1715
+ " ".repeat(prefixW) + "\x1B[2m" + "\u65F6\u95F4".padEnd(timeW) + " ".repeat(gap) + "\u9879\u76EE".padEnd(projW) + " ".repeat(gap) + "\u9996\u6761\u63D0\u95EE\x1B[0m"
1716
+ );
1717
+ console.log(" ".repeat(prefixW) + "\x1B[2m" + "\u2500".repeat(Math.min(totalW - prefixW, 80)) + "\x1B[0m");
1718
+ for (let i = 0; i < pageSessions.length; i++) {
1719
+ const s = pageSessions[i];
1720
+ const globalIdx = start + i;
1721
+ const isSelected = globalIdx === selectedIndex;
1722
+ const prefix = isSelected ? " \x1B[36m\u25B6 " : " ";
1723
+ const suffix = isSelected ? "\x1B[0m" : "";
1724
+ const time = formatTime(s.timestamp);
1725
+ const proj = truncate(s.projectName, projW);
1726
+ const question = truncate(s.firstQuestion, questionW);
1727
+ console.log(
1728
+ prefix + time.padEnd(timeW) + " ".repeat(gap) + proj.padEnd(projW) + " ".repeat(gap) + question + suffix
1729
+ );
1730
+ }
1731
+ const totalPages = Math.ceil(sessions.length / PAGE_SIZE);
1732
+ if (totalPages > 1) {
1733
+ console.log();
1734
+ console.log(
1735
+ `\x1B[2m \u7B2C ${page + 1}/${totalPages} \u9875 (\u5171 ${sessions.length} \u4E2A\u4F1A\u8BDD, \u2190 \u2192 \u7FFB\u9875)\x1B[0m`
1736
+ );
1737
+ }
1738
+ }
1739
+ async function confirmPrompt(msg) {
1740
+ process.stdout.write(`
1741
+ ${msg} (y/N) `);
1742
+ return new Promise((resolve) => {
1743
+ process.stdin.once("data", (data) => {
1744
+ resolve(data.toString().trim().toLowerCase() === "y");
1745
+ });
1746
+ });
1747
+ }
1748
+ async function cmdSessions(args) {
1749
+ cleanupOldTrash();
1750
+ if (args.purge) {
1751
+ const trash = getTrashSessions();
1752
+ if (trash.length === 0) {
1753
+ console.log("\n \u56DE\u6536\u7AD9\u4E3A\u7A7A\n");
1754
+ return;
1755
+ }
1756
+ console.log(`
1757
+ \u56DE\u6536\u7AD9\u4E2D\u6709 ${trash.length} \u4E2A\u4F1A\u8BDD`);
1758
+ const confirmed = await confirmPrompt("\x1B[31m\u786E\u8BA4\u6E05\u7A7A\u56DE\u6536\u7AD9\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\uFF01\x1B[0m");
1759
+ if (confirmed) {
1760
+ purgeTrash();
1761
+ ok("\u56DE\u6536\u7AD9\u5DF2\u6E05\u7A7A");
1762
+ }
1763
+ console.log();
1764
+ return;
1765
+ }
1766
+ if (args.restore !== void 0) {
1767
+ if (args.restore === "") {
1768
+ const trash = getTrashSessions();
1769
+ if (trash.length === 0) {
1770
+ console.log("\n \u56DE\u6536\u7AD9\u4E3A\u7A7A\n");
1771
+ return;
1772
+ }
1773
+ const latest = trash[0];
1774
+ const confirmed = await confirmPrompt(`\u6062\u590D\u4F1A\u8BDD "${latest.sessionId.slice(0, 8)}..."\uFF1F`);
1775
+ if (confirmed) {
1776
+ restoreSession(latest.sessionId);
1777
+ ok("\u4F1A\u8BDD\u5DF2\u6062\u590D");
1778
+ }
1779
+ } else {
1780
+ const success = restoreSession(args.restore);
1781
+ if (success) {
1782
+ ok("\u4F1A\u8BDD\u5DF2\u6062\u590D");
1783
+ } else {
1784
+ err(`\u672A\u627E\u5230\u4F1A\u8BDD ${args.restore}`);
1785
+ }
1786
+ }
1787
+ console.log();
1788
+ return;
1789
+ }
1790
+ if (args.web) {
1791
+ await startSessionServer();
1792
+ return;
1793
+ }
1794
+ const sessions = getSessionsList();
1795
+ if (sessions.length === 0) {
1796
+ console.log("\n \u6682\u65E0\u4F1A\u8BDD\u8BB0\u5F55\n");
1797
+ return;
1798
+ }
1799
+ let selectedIndex = 0;
1800
+ let page = 0;
1801
+ const totalPages = Math.ceil(sessions.length / PAGE_SIZE);
1802
+ process.stdin.setRawMode?.(true);
1803
+ process.stdin.resume();
1804
+ process.stdin.setEncoding("utf-8");
1805
+ const cleanup = () => {
1806
+ process.stdin.setRawMode?.(false);
1807
+ process.stdin.pause();
1808
+ };
1809
+ printTable(sessions, page, selectedIndex);
1810
+ return new Promise((resolve) => {
1811
+ process.stdin.on("data", async (key) => {
1812
+ if (key === "" || key === "q") {
1813
+ cleanup();
1814
+ console.log();
1815
+ resolve();
1816
+ return;
1817
+ }
1818
+ if (key === "\x1B[A") {
1819
+ if (selectedIndex > 0) {
1820
+ selectedIndex--;
1821
+ if (selectedIndex < page * PAGE_SIZE) {
1822
+ page = Math.floor(selectedIndex / PAGE_SIZE);
1823
+ }
1824
+ printTable(sessions, page, selectedIndex);
1825
+ }
1826
+ return;
1827
+ }
1828
+ if (key === "\x1B[B") {
1829
+ if (selectedIndex < sessions.length - 1) {
1830
+ selectedIndex++;
1831
+ if (selectedIndex >= (page + 1) * PAGE_SIZE) {
1832
+ page = Math.floor(selectedIndex / PAGE_SIZE);
1833
+ }
1834
+ printTable(sessions, page, selectedIndex);
1835
+ }
1836
+ return;
1837
+ }
1838
+ if (key === "\x1B[D") {
1839
+ if (page > 0) {
1840
+ page--;
1841
+ selectedIndex = page * PAGE_SIZE;
1842
+ printTable(sessions, page, selectedIndex);
1843
+ }
1844
+ return;
1845
+ }
1846
+ if (key === "\x1B[C") {
1847
+ if (page < totalPages - 1) {
1848
+ page++;
1849
+ selectedIndex = page * PAGE_SIZE;
1850
+ printTable(sessions, page, selectedIndex);
1851
+ }
1852
+ return;
1853
+ }
1854
+ if (key === "\r" || key === "\n") {
1855
+ cleanup();
1856
+ const session = sessions[selectedIndex];
1857
+ if (session) {
1858
+ console.log();
1859
+ await startSessionServer();
1860
+ }
1861
+ resolve();
1862
+ return;
1863
+ }
1864
+ if (key === "d") {
1865
+ cleanup();
1866
+ const session = sessions[selectedIndex];
1867
+ if (session) {
1868
+ const confirmed = await confirmPrompt(`\u786E\u8BA4\u5220\u9664 "${truncate(session.firstQuestion, 30)}"\uFF1F(\u79FB\u5165\u56DE\u6536\u7AD9)`);
1869
+ if (confirmed) {
1870
+ deleteSession(session.sessionId);
1871
+ sessions.splice(selectedIndex, 1);
1872
+ if (selectedIndex >= sessions.length) selectedIndex = Math.max(0, sessions.length - 1);
1873
+ ok("\u5DF2\u79FB\u5165\u56DE\u6536\u7AD9 (ccm sessions --restore \u6062\u590D)");
1874
+ }
1875
+ }
1876
+ process.stdin.setRawMode?.(true);
1877
+ process.stdin.resume();
1878
+ printTable(sessions, page, selectedIndex);
1879
+ return;
1880
+ }
1881
+ if (key === "D") {
1882
+ cleanup();
1883
+ 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");
1884
+ if (confirmed) {
1885
+ deleteAllSessions();
1886
+ sessions.length = 0;
1887
+ ok("\u5168\u90E8\u4F1A\u8BDD\u5DF2\u79FB\u5165\u56DE\u6536\u7AD9");
1888
+ }
1889
+ process.stdin.setRawMode?.(true);
1890
+ process.stdin.resume();
1891
+ printTable(sessions, page, selectedIndex);
1892
+ return;
1893
+ }
1894
+ });
1895
+ });
1896
+ }
1897
+
809
1898
  // src/cli.ts
810
1899
  var pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
811
1900
  var VERSION = pkg.version;
@@ -824,7 +1913,8 @@ var KNOWN_COMMANDS = /* @__PURE__ */ new Set([
824
1913
  "balance",
825
1914
  "bal",
826
1915
  "config",
827
- "completions"
1916
+ "completions",
1917
+ "sessions"
828
1918
  ]);
829
1919
  function printHelp() {
830
1920
  console.log(`
@@ -843,6 +1933,10 @@ Commands:
843
1933
  balance [name] Query model balance/credits
844
1934
  config <name> Show profile environment variables
845
1935
  completions Print shell completion script
1936
+ sessions Browse and manage session history
1937
+ sessions --web Open session viewer in browser
1938
+ sessions --restore [id] Restore session from trash
1939
+ sessions --purge Empty the trash permanently
846
1940
 
847
1941
  Options:
848
1942
  -v, --version Show version
@@ -853,13 +1947,14 @@ Shortcuts:
853
1947
  `);
854
1948
  }
855
1949
  function parseArgs(argv) {
856
- const args = { _: [] };
1950
+ const args = { _: [], extra: [] };
857
1951
  let i = 0;
858
1952
  while (i < argv.length) {
859
1953
  const arg = argv[i];
860
1954
  if (arg === "--all") {
861
1955
  args.all = true;
862
1956
  } else if (arg.startsWith("-")) {
1957
+ args.extra.push(arg);
863
1958
  } else {
864
1959
  args._.push(arg);
865
1960
  }
@@ -895,14 +1990,14 @@ async function main() {
895
1990
  const firstArg = rawArgs[0];
896
1991
  if (firstArg && !KNOWN_COMMANDS.has(firstArg) && !firstArg.startsWith("-")) {
897
1992
  if (profileExists(firstArg)) {
898
- cmdLaunch({ name: firstArg });
1993
+ cmdLaunch({ name: firstArg, extraArgs: rawArgs.slice(1) });
899
1994
  return;
900
1995
  }
901
1996
  }
902
1997
  const { command, args } = parseArgs(rawArgs);
903
1998
  switch (command) {
904
1999
  case "_launch":
905
- cmdLaunch({ name: args.name });
2000
+ cmdLaunch({ name: args.name, extraArgs: args.extra || [] });
906
2001
  break;
907
2002
  case "_register":
908
2003
  cmdRegister({ name: args.name, pid: args.pid, tty: args.tty });
@@ -942,6 +2037,16 @@ async function main() {
942
2037
  case "completions":
943
2038
  cmdCompletions({ shell: args.shell });
944
2039
  break;
2040
+ case "sessions": {
2041
+ const restoreIdx = rawArgs.indexOf("--restore");
2042
+ const restoreId = restoreIdx >= 0 ? rawArgs[restoreIdx + 1] || "" : void 0;
2043
+ await cmdSessions({
2044
+ web: rawArgs.includes("--web"),
2045
+ restore: restoreIdx >= 0 ? restoreId : void 0,
2046
+ purge: rawArgs.includes("--purge")
2047
+ });
2048
+ break;
2049
+ }
945
2050
  default:
946
2051
  console.error(`Unknown command: ${command}`);
947
2052
  printHelp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeandrew94/ccm",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
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"