@vitorcen/context-resume 1.0.3 → 1.0.5
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 +10 -6
- package/dist/adapters/index.js +307 -8
- package/dist/index.js +66 -0
- package/dist/ui/app.js +97 -22
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# Context Resume CLI
|
|
2
2
|
|
|
3
|
-
Context Resume lets you quickly resume work across Claude Code (`~/.claude`)
|
|
3
|
+
Context Resume lets you quickly resume work across Claude Code (`~/.claude`), Codex CLI (`~/.codex`), Cursor (`~/.cursor`), and Gemini (`~/.gemini`) by listing recent sessions for the current working directory, showing a detailed prompt history preview, and printing a ready-to-use bilingual (English/Chinese) resume prompt.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
|
-
- **
|
|
7
|
-
- **Detailed Preview**: Shows a list of user prompts from the selected session (truncated
|
|
6
|
+
- **Quad-Panel View**: Grid view for Claude, Codex, Cursor, and Gemini sessions.
|
|
7
|
+
- **Detailed Preview**: Shows a list of user prompts from the selected session (truncated) at the top.
|
|
8
8
|
- **Configurable Limit**: Use `-n <count>` to control how many sessions to load per source.
|
|
9
9
|
- **Bilingual Output**: Prints both English and Chinese prompts pointing to the session file.
|
|
10
10
|
- **Privacy First**: Works entirely locally; no network calls.
|
|
11
11
|
|
|
12
12
|
## Requirements
|
|
13
13
|
- Node.js 18+ (tested with ESM build).
|
|
14
|
+
- `sqlite3` command-line tool (required for reading Cursor sessions).
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
16
17
|
```bash
|
|
@@ -25,20 +26,23 @@ From any project directory you want to resume:
|
|
|
25
26
|
context # Load default 10 sessions per source
|
|
26
27
|
context -n 20 # Load 20 sessions per source
|
|
27
28
|
```
|
|
28
|
-
- Use **TAB** to switch between
|
|
29
|
-
- Use **Arrow Keys** to select
|
|
29
|
+
- Use **TAB** to switch between panels.
|
|
30
|
+
- Use **Arrow Keys** to navigate grid (Left/Right) and select sessions (Up/Down).
|
|
30
31
|
- Preview at the top shows the sequence of user prompts.
|
|
31
32
|
- Press **Enter** to print the resume prompts (with absolute paths) and exit.
|
|
32
33
|
|
|
33
34
|
## How it works
|
|
34
35
|
- **Claude**: Reads `~/.claude/projects/<encoded-path>/*.jsonl`.
|
|
35
36
|
- **Codex**: Scans `~/.codex/sessions/**/*.jsonl`, filters by `cwd` metadata.
|
|
37
|
+
- **Cursor**: Scans `~/.cursor/chats/<md5-hash>/*/store.db`, extracts prompts from SQLite `blobs`.
|
|
38
|
+
- **Gemini**: Scans `~/.gemini/tmp/<sha256-hash>/chats/*.json`.
|
|
36
39
|
- **Prompt Extraction**: Parses the session files to extract user inputs for the preview, giving you a quick summary of "what was I doing?".
|
|
37
40
|
|
|
38
41
|
## Project layout
|
|
39
42
|
- `src/index.tsx` – Entry point, handles CLI arguments.
|
|
40
|
-
- `src/ui/app.tsx` – Ink UI with
|
|
43
|
+
- `src/ui/app.tsx` – Ink UI with grid panels and preview.
|
|
41
44
|
- `src/adapters/index.ts` – File parsers and prompt extractors.
|
|
42
45
|
|
|
43
46
|
## Limitations
|
|
44
47
|
- Codex scanning involves globbing which might be slow on very large histories.
|
|
48
|
+
- Cursor support requires `sqlite3` installed in the system.
|
package/dist/adapters/index.js
CHANGED
|
@@ -2,7 +2,20 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { glob } from 'glob';
|
|
4
4
|
import os from 'os';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
// --- Helper Functions ---
|
|
8
|
+
function createPreview(content, maxLength = 500) {
|
|
9
|
+
if (content.length <= maxLength)
|
|
10
|
+
return content;
|
|
11
|
+
const start = content.slice(0, maxLength / 2);
|
|
12
|
+
const end = content.slice(content.length - (maxLength / 2));
|
|
13
|
+
return `${start}\n\n... [${content.length - maxLength} characters omitted] ...\n\n${end}`;
|
|
14
|
+
}
|
|
5
15
|
// --- Claude Adapter ---
|
|
16
|
+
function normalizeCwd(cwd) {
|
|
17
|
+
return path.resolve(cwd);
|
|
18
|
+
}
|
|
6
19
|
function getClaudeEncodedPath(projectPath) {
|
|
7
20
|
// Claude encodes paths by replacing /, ., and _ with -
|
|
8
21
|
// e.g. /home/user/project -> -home-user-project
|
|
@@ -11,8 +24,9 @@ function getClaudeEncodedPath(projectPath) {
|
|
|
11
24
|
return projectPath.replace(/[\/\._]/g, '-');
|
|
12
25
|
}
|
|
13
26
|
export async function getClaudeSessions(cwd, limit = 10) {
|
|
27
|
+
const resolvedCwd = normalizeCwd(cwd);
|
|
14
28
|
const homeDir = os.homedir();
|
|
15
|
-
const encodedPath = getClaudeEncodedPath(
|
|
29
|
+
const encodedPath = getClaudeEncodedPath(resolvedCwd);
|
|
16
30
|
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
|
17
31
|
if (!fs.existsSync(projectDir)) {
|
|
18
32
|
return [];
|
|
@@ -83,6 +97,7 @@ export async function getClaudeSessions(cwd, limit = 10) {
|
|
|
83
97
|
// --- Codex Adapter ---
|
|
84
98
|
export async function getCodexSessions(cwd, limit = 10) {
|
|
85
99
|
const homeDir = os.homedir();
|
|
100
|
+
const resolvedCwd = normalizeCwd(cwd);
|
|
86
101
|
const sessionsDir = path.join(homeDir, '.codex', 'sessions');
|
|
87
102
|
if (!fs.existsSync(sessionsDir)) {
|
|
88
103
|
return [];
|
|
@@ -109,7 +124,8 @@ export async function getCodexSessions(cwd, limit = 10) {
|
|
|
109
124
|
const firstLine = chunk.split('\n')[0];
|
|
110
125
|
if (firstLine) {
|
|
111
126
|
const meta = JSON.parse(firstLine);
|
|
112
|
-
|
|
127
|
+
const metaCwd = typeof meta.payload?.cwd === 'string' ? normalizeCwd(meta.payload.cwd) : '';
|
|
128
|
+
if (meta.type === 'session_meta' && metaCwd === resolvedCwd) {
|
|
113
129
|
const stats = fs.statSync(filePath);
|
|
114
130
|
relevantFiles.push({ filePath, mtime: stats.mtimeMs });
|
|
115
131
|
}
|
|
@@ -162,10 +178,293 @@ export async function getCodexSessions(cwd, limit = 10) {
|
|
|
162
178
|
}
|
|
163
179
|
return sessions;
|
|
164
180
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
// --- Cursor Adapter ---
|
|
182
|
+
function getCursorMD5Path(projectPath) {
|
|
183
|
+
return crypto.createHash('md5').update(projectPath).digest('hex');
|
|
184
|
+
}
|
|
185
|
+
function decodeMaybeHexJson(hexStr) {
|
|
186
|
+
if (!hexStr)
|
|
187
|
+
return null;
|
|
188
|
+
try {
|
|
189
|
+
const first = Buffer.from(hexStr.trim(), 'hex').toString('utf-8').trim();
|
|
190
|
+
// Cursor meta can be hex-encoded JSON string, then hex-encoded again.
|
|
191
|
+
const isHex = /^[0-9a-fA-F]+$/.test(first);
|
|
192
|
+
const jsonText = isHex ? Buffer.from(first, 'hex').toString('utf-8') : first;
|
|
193
|
+
return JSON.parse(jsonText);
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function sanitizeCursorText(text) {
|
|
200
|
+
return text.replace(/[\u0000-\u001F\u007F-\u009F]/g, '').trim();
|
|
201
|
+
}
|
|
202
|
+
function isLikelyText(text) {
|
|
203
|
+
if (!text)
|
|
204
|
+
return false;
|
|
205
|
+
const replacementCount = (text.match(/\uFFFD/g) || []).length;
|
|
206
|
+
if (replacementCount > 0)
|
|
207
|
+
return false;
|
|
208
|
+
const total = text.length;
|
|
209
|
+
let ok = 0;
|
|
210
|
+
for (const ch of text) {
|
|
211
|
+
if (/[\p{L}\p{N}\p{P}\p{Zs}]/u.test(ch)) {
|
|
212
|
+
ok++;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return total > 0 && ok / total >= 0.7;
|
|
216
|
+
}
|
|
217
|
+
function findCursorProjectRoot(cwd, chatsBaseDir) {
|
|
218
|
+
const resolved = path.resolve(cwd);
|
|
219
|
+
const hash = getCursorMD5Path(resolved);
|
|
220
|
+
const candidate = path.join(chatsBaseDir, hash);
|
|
221
|
+
return fs.existsSync(candidate) ? resolved : null;
|
|
222
|
+
}
|
|
223
|
+
export function getCursorDebugInfo(cwd) {
|
|
224
|
+
const homeDir = os.homedir();
|
|
225
|
+
const chatsBaseDir = path.join(homeDir, '.cursor', 'chats');
|
|
226
|
+
const resolvedCwd = path.resolve(cwd);
|
|
227
|
+
const projectRoot = findCursorProjectRoot(resolvedCwd, chatsBaseDir);
|
|
228
|
+
if (!projectRoot) {
|
|
229
|
+
return {
|
|
230
|
+
cwd,
|
|
231
|
+
resolvedCwd,
|
|
232
|
+
projectRoot: null,
|
|
233
|
+
projectHash: null,
|
|
234
|
+
chatsDir: null,
|
|
235
|
+
dbFiles: []
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const projectHash = getCursorMD5Path(projectRoot);
|
|
239
|
+
const chatsDir = path.join(chatsBaseDir, projectHash);
|
|
240
|
+
const dbFiles = fs.existsSync(chatsDir)
|
|
241
|
+
? glob.sync('*/store.db', { cwd: chatsDir, absolute: true })
|
|
242
|
+
: [];
|
|
243
|
+
return {
|
|
244
|
+
cwd,
|
|
245
|
+
resolvedCwd,
|
|
246
|
+
projectRoot,
|
|
247
|
+
projectHash,
|
|
248
|
+
chatsDir,
|
|
249
|
+
dbFiles
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export async function getCursorSessions(cwd, limit = 10) {
|
|
253
|
+
const homeDir = os.homedir();
|
|
254
|
+
const chatsBaseDir = path.join(homeDir, '.cursor', 'chats');
|
|
255
|
+
const projectRoot = findCursorProjectRoot(normalizeCwd(cwd), chatsBaseDir);
|
|
256
|
+
if (!projectRoot) {
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const projectHash = getCursorMD5Path(projectRoot);
|
|
260
|
+
// Path: ~/.cursor/chats/<hash>/<session_uuid>/store.db
|
|
261
|
+
const chatsDir = path.join(chatsBaseDir, projectHash);
|
|
262
|
+
if (!fs.existsSync(chatsDir)) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
// Glob for store.db files
|
|
266
|
+
const dbFiles = glob.sync('*/store.db', { cwd: chatsDir, absolute: true });
|
|
267
|
+
// Sort by modification time
|
|
268
|
+
const sortedFiles = dbFiles.map(filePath => ({
|
|
269
|
+
filePath,
|
|
270
|
+
mtime: fs.statSync(filePath).mtimeMs
|
|
271
|
+
}))
|
|
272
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
273
|
+
.slice(0, limit);
|
|
274
|
+
const sessions = [];
|
|
275
|
+
for (const { filePath, mtime } of sortedFiles) {
|
|
276
|
+
try {
|
|
277
|
+
// We need to read the sqlite DB. Assuming sqlite3 CLI is available.
|
|
278
|
+
// If not, we might fail.
|
|
279
|
+
// Query 1: Get Metadata (created_at, name)
|
|
280
|
+
// Table: meta, Key: "0" -> value is JSON hex encoded? No, in my test it was hex string of JSON.
|
|
281
|
+
// Wait, "select * from meta" showed: 0|<hex string>
|
|
282
|
+
// So we select hex(value) where key='0'.
|
|
283
|
+
// Query 2: Get Blobs (messages)
|
|
284
|
+
// Table: blobs, Column: data (BLOB)
|
|
285
|
+
// We select hex(data) to parse in JS.
|
|
286
|
+
// Use .separator to make parsing easier if needed, but hex is continuous.
|
|
287
|
+
// We run two commands or one?
|
|
288
|
+
// Let's run one command to get everything or just iterate.
|
|
289
|
+
// Command: sqlite3 <db> "select hex(value) from meta where key='0'; select '---SPLIT---'; select hex(data) from blobs;"
|
|
290
|
+
// Avoid dumping huge DBs to stdout (ENOBUFS). Limit blobs to a recent slice.
|
|
291
|
+
const cmd = `sqlite3 "${filePath}" "select hex(value) from meta where key='0'; select '---SPLIT---'; select hex(data) from blobs order by rowid desc limit 100;"`;
|
|
292
|
+
const output = execSync(cmd, {
|
|
293
|
+
encoding: 'utf-8',
|
|
294
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
295
|
+
maxBuffer: 10 * 1024 * 1024
|
|
296
|
+
});
|
|
297
|
+
const [metaHex, blobsHex] = output.split('---SPLIT---\n');
|
|
298
|
+
// Parse Meta
|
|
299
|
+
let title = 'New Session';
|
|
300
|
+
let timestamp = mtime;
|
|
301
|
+
if (metaHex && metaHex.trim()) {
|
|
302
|
+
const meta = decodeMaybeHexJson(metaHex);
|
|
303
|
+
if (meta?.name)
|
|
304
|
+
title = meta.name;
|
|
305
|
+
if (meta?.createdAt)
|
|
306
|
+
timestamp = meta.createdAt;
|
|
307
|
+
}
|
|
308
|
+
// Parse Blobs
|
|
309
|
+
const userPrompts = [];
|
|
310
|
+
const blobLines = (blobsHex || '').split('\n').filter(l => l.trim()).reverse();
|
|
311
|
+
for (const hex of blobLines) {
|
|
312
|
+
try {
|
|
313
|
+
const buffer = Buffer.from(hex.trim(), 'hex');
|
|
314
|
+
const str = buffer.toString('utf-8');
|
|
315
|
+
// Check if JSON
|
|
316
|
+
if (str.startsWith('{')) {
|
|
317
|
+
try {
|
|
318
|
+
const json = JSON.parse(str);
|
|
319
|
+
if (json.role === 'user' && json.content) {
|
|
320
|
+
let text = '';
|
|
321
|
+
if (typeof json.content === 'string') {
|
|
322
|
+
text = json.content;
|
|
323
|
+
}
|
|
324
|
+
else if (Array.isArray(json.content)) {
|
|
325
|
+
text = json.content
|
|
326
|
+
.filter((c) => c.type === 'text' && c.text)
|
|
327
|
+
.map((c) => c.text)
|
|
328
|
+
.join('\n');
|
|
329
|
+
}
|
|
330
|
+
// Filter out system/context injections if identifiable
|
|
331
|
+
// Cursor sometimes injects <user_info> etc.
|
|
332
|
+
if (text && !text.includes('<user_info>')) {
|
|
333
|
+
// Strip <user_query> tags which Cursor adds
|
|
334
|
+
text = sanitizeCursorText(text.replace(/<\/?user_query>/g, ''));
|
|
335
|
+
if (text && isLikelyText(text)) {
|
|
336
|
+
userPrompts.push(text);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (e) {
|
|
342
|
+
// not json
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// Binary format - extract printable strings
|
|
347
|
+
// Heuristic: Extract strings > 4 chars
|
|
348
|
+
// And filter out common noise.
|
|
349
|
+
// The user prompt I saw earlier was: "0A DA 01 ... User Text ... 12 24 UUID"
|
|
350
|
+
// Simplest approach: "strings" equivalent
|
|
351
|
+
// Filter for CJK characters or long English sentences.
|
|
352
|
+
// Let's just strip control chars and see what's left.
|
|
353
|
+
// But binary data has a lot of noise.
|
|
354
|
+
// If we assume the format found earlier: 0A [Varint Len] [Text]
|
|
355
|
+
if (buffer[0] === 0x0A) {
|
|
356
|
+
// Protobuf field 1
|
|
357
|
+
let offset = 1;
|
|
358
|
+
// Parse varint length
|
|
359
|
+
let len = 0;
|
|
360
|
+
let shift = 0;
|
|
361
|
+
while (offset < buffer.length) {
|
|
362
|
+
const b = buffer[offset];
|
|
363
|
+
len |= (b & 0x7F) << shift;
|
|
364
|
+
offset++;
|
|
365
|
+
shift += 7;
|
|
366
|
+
if ((b & 0x80) === 0)
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
if (len > 0 && offset + len <= buffer.length) {
|
|
370
|
+
const text = sanitizeCursorText(buffer.subarray(offset, offset + len).toString('utf-8'));
|
|
371
|
+
// Verify it looks like text (not random binary)
|
|
372
|
+
// If it contains many control chars, ignore.
|
|
373
|
+
if (text && !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(text) && isLikelyText(text)) {
|
|
374
|
+
userPrompts.push(text);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
// ignore
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Refine title if default
|
|
385
|
+
if (title === 'New Agent' || title === 'New Session') {
|
|
386
|
+
if (userPrompts.length > 0) {
|
|
387
|
+
title = userPrompts[0];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const preview = userPrompts.join('\n\n');
|
|
391
|
+
sessions.push({
|
|
392
|
+
id: path.basename(path.dirname(filePath)), // session UUID is parent dir name
|
|
393
|
+
title: title.slice(0, 50) + (title.length > 50 ? '...' : ''),
|
|
394
|
+
preview: createPreview(preview),
|
|
395
|
+
userPrompts,
|
|
396
|
+
timestamp,
|
|
397
|
+
source: 'cursor',
|
|
398
|
+
path: filePath
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
// ignore sqlite errors or missing files
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return sessions;
|
|
406
|
+
}
|
|
407
|
+
// --- Gemini Adapter ---
|
|
408
|
+
function getGeminiSHA256Path(projectPath) {
|
|
409
|
+
return crypto.createHash('sha256').update(projectPath).digest('hex');
|
|
410
|
+
}
|
|
411
|
+
export async function getGeminiSessions(cwd, limit = 10) {
|
|
412
|
+
const homeDir = os.homedir();
|
|
413
|
+
const projectHash = getGeminiSHA256Path(normalizeCwd(cwd));
|
|
414
|
+
// Path: ~/.gemini/tmp/<hash>/chats/*.json
|
|
415
|
+
const chatsDir = path.join(homeDir, '.gemini', 'tmp', projectHash, 'chats');
|
|
416
|
+
if (!fs.existsSync(chatsDir)) {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
const files = glob.sync('*.json', { cwd: chatsDir, absolute: true });
|
|
420
|
+
// Sort by modification time
|
|
421
|
+
const sortedFiles = files.map(filePath => ({
|
|
422
|
+
filePath,
|
|
423
|
+
mtime: fs.statSync(filePath).mtimeMs
|
|
424
|
+
}))
|
|
425
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
426
|
+
.slice(0, limit);
|
|
427
|
+
const sessions = [];
|
|
428
|
+
for (const { filePath, mtime } of sortedFiles) {
|
|
429
|
+
try {
|
|
430
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
431
|
+
const data = JSON.parse(content);
|
|
432
|
+
// Data format: { messages: [ { type: 'user', content: '...' }, ... ] }
|
|
433
|
+
const userPrompts = [];
|
|
434
|
+
let title = 'New Session';
|
|
435
|
+
if (data.messages && Array.isArray(data.messages)) {
|
|
436
|
+
for (const msg of data.messages) {
|
|
437
|
+
if (msg.type === 'user' && msg.content) {
|
|
438
|
+
let text = '';
|
|
439
|
+
if (typeof msg.content === 'string') {
|
|
440
|
+
text = msg.content;
|
|
441
|
+
}
|
|
442
|
+
else if (Array.isArray(msg.content)) {
|
|
443
|
+
text = msg.content.map((c) => typeof c === 'string' ? c : (c.text || '')).join(' ');
|
|
444
|
+
}
|
|
445
|
+
if (text) {
|
|
446
|
+
userPrompts.push(text);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (userPrompts.length > 0) {
|
|
452
|
+
title = userPrompts[0];
|
|
453
|
+
}
|
|
454
|
+
const preview = userPrompts.join('\n\n');
|
|
455
|
+
sessions.push({
|
|
456
|
+
id: path.basename(filePath, '.json'),
|
|
457
|
+
title: title.slice(0, 50) + (title.length > 50 ? '...' : ''),
|
|
458
|
+
preview: createPreview(preview),
|
|
459
|
+
userPrompts,
|
|
460
|
+
timestamp: data.lastUpdated ? new Date(data.lastUpdated).getTime() : mtime,
|
|
461
|
+
source: 'gemini',
|
|
462
|
+
path: filePath
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
// ignore
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return sessions;
|
|
171
470
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,10 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { Command } from 'commander';
|
|
4
4
|
import { render } from 'ink';
|
|
5
5
|
import App from './ui/app.js';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
6
8
|
import { createRequire } from 'module';
|
|
9
|
+
import { getClaudeSessions, getCodexSessions, getCursorDebugInfo, getCursorSessions } from './adapters/index.js';
|
|
7
10
|
const require = createRequire(import.meta.url);
|
|
8
11
|
const { version } = require('../package.json');
|
|
9
12
|
const program = new Command();
|
|
@@ -26,4 +29,67 @@ program
|
|
|
26
29
|
process.stdout.write(selectionOutput);
|
|
27
30
|
}
|
|
28
31
|
});
|
|
32
|
+
const expandHome = (inputPath) => {
|
|
33
|
+
if (inputPath.startsWith('~')) {
|
|
34
|
+
return path.join(os.homedir(), inputPath.slice(1));
|
|
35
|
+
}
|
|
36
|
+
return inputPath;
|
|
37
|
+
};
|
|
38
|
+
const printSessions = (label, sessions) => {
|
|
39
|
+
console.log(`${label}: ${sessions.length}`);
|
|
40
|
+
sessions.forEach((session, i) => {
|
|
41
|
+
console.log(`${i + 1}. ${session.title} | ${session.path}`);
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
program
|
|
45
|
+
.command('codex')
|
|
46
|
+
.argument('[path]', 'Workspace path', process.cwd())
|
|
47
|
+
.option('-n, --number <count>', 'Number of sessions to show', '10')
|
|
48
|
+
.description('Debug Codex sessions for a path')
|
|
49
|
+
.action(async (targetPath, options) => {
|
|
50
|
+
const resolvedPath = expandHome(targetPath);
|
|
51
|
+
const limit = parseInt(options.number, 10) || 10;
|
|
52
|
+
const sessions = await getCodexSessions(resolvedPath, limit);
|
|
53
|
+
console.log(`Codex debug`);
|
|
54
|
+
console.log(`cwd: ${resolvedPath}`);
|
|
55
|
+
printSessions('sessions', sessions);
|
|
56
|
+
});
|
|
57
|
+
program
|
|
58
|
+
.command('claude')
|
|
59
|
+
.argument('[path]', 'Workspace path', process.cwd())
|
|
60
|
+
.option('-n, --number <count>', 'Number of sessions to show', '10')
|
|
61
|
+
.description('Debug Claude sessions for a path')
|
|
62
|
+
.action(async (targetPath, options) => {
|
|
63
|
+
const resolvedPath = expandHome(targetPath);
|
|
64
|
+
const limit = parseInt(options.number, 10) || 10;
|
|
65
|
+
const sessions = await getClaudeSessions(resolvedPath, limit);
|
|
66
|
+
console.log(`Claude debug`);
|
|
67
|
+
console.log(`cwd: ${resolvedPath}`);
|
|
68
|
+
printSessions('sessions', sessions);
|
|
69
|
+
});
|
|
70
|
+
program
|
|
71
|
+
.command('cursor')
|
|
72
|
+
.argument('[path]', 'Workspace path', process.cwd())
|
|
73
|
+
.option('-n, --number <count>', 'Number of sessions to show', '10')
|
|
74
|
+
.description('Debug Cursor sessions for a path')
|
|
75
|
+
.action(async (targetPath, options) => {
|
|
76
|
+
const resolvedPath = expandHome(targetPath);
|
|
77
|
+
const limit = parseInt(options.number, 10) || 10;
|
|
78
|
+
const debug = getCursorDebugInfo(resolvedPath);
|
|
79
|
+
console.log('Cursor debug');
|
|
80
|
+
console.log(`cwd: ${debug.cwd}`);
|
|
81
|
+
console.log(`resolvedCwd: ${debug.resolvedCwd}`);
|
|
82
|
+
console.log(`projectRoot: ${debug.projectRoot ?? 'null'}`);
|
|
83
|
+
console.log(`projectHash: ${debug.projectHash ?? 'null'}`);
|
|
84
|
+
console.log(`chatsDir: ${debug.chatsDir ?? 'null'}`);
|
|
85
|
+
console.log(`dbFiles: ${debug.dbFiles.length}`);
|
|
86
|
+
debug.dbFiles.forEach((file, i) => {
|
|
87
|
+
console.log(` ${i + 1}. ${file}`);
|
|
88
|
+
});
|
|
89
|
+
if (!debug.projectRoot) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const sessions = await getCursorSessions(resolvedPath, limit);
|
|
93
|
+
printSessions('sessions', sessions);
|
|
94
|
+
});
|
|
29
95
|
program.parse(process.argv);
|
package/dist/ui/app.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useApp } from 'ink';
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
|
-
import { getClaudeSessions, getCodexSessions } from '../adapters/index.js';
|
|
5
|
+
import { getClaudeSessions, getCodexSessions, getCursorSessions, getGeminiSessions } from '../adapters/index.js';
|
|
6
6
|
import stringWidth from 'string-width';
|
|
7
7
|
// Truncate by display width (handles CJK characters correctly)
|
|
8
8
|
const truncateByWidth = (str, maxWidth) => {
|
|
@@ -22,20 +22,64 @@ const truncateByWidth = (str, maxWidth) => {
|
|
|
22
22
|
const App = ({ cwd, limit = 10, onSubmit }) => {
|
|
23
23
|
const [claudeItems, setClaudeItems] = useState([]);
|
|
24
24
|
const [codexItems, setCodexItems] = useState([]);
|
|
25
|
+
const [cursorItems, setCursorItems] = useState([]);
|
|
26
|
+
const [geminiItems, setGeminiItems] = useState([]);
|
|
25
27
|
const [activePanel, setActivePanel] = useState('claude');
|
|
26
28
|
const [activeClaudeItem, setActiveClaudeItem] = useState(null);
|
|
27
29
|
const [activeCodexItem, setActiveCodexItem] = useState(null);
|
|
30
|
+
const [activeCursorItem, setActiveCursorItem] = useState(null);
|
|
31
|
+
const [activeGeminiItem, setActiveGeminiItem] = useState(null);
|
|
28
32
|
const { exit } = useApp();
|
|
29
33
|
// Tab switching and Arrow Navigation
|
|
30
34
|
useInput((input, key) => {
|
|
31
35
|
if (key.tab) {
|
|
32
|
-
|
|
36
|
+
const panels = ['claude', 'codex', 'cursor', 'gemini'];
|
|
37
|
+
const currentIndex = panels.indexOf(activePanel);
|
|
38
|
+
const nextIndex = (currentIndex + 1) % panels.length;
|
|
39
|
+
setActivePanel(panels[nextIndex]);
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
// Helper to get current list state
|
|
42
|
+
const getCurrentState = () => {
|
|
43
|
+
switch (activePanel) {
|
|
44
|
+
case 'claude': return { items: claudeItems, active: activeClaudeItem };
|
|
45
|
+
case 'codex': return { items: codexItems, active: activeCodexItem };
|
|
46
|
+
case 'cursor': return { items: cursorItems, active: activeCursorItem };
|
|
47
|
+
case 'gemini': return { items: geminiItems, active: activeGeminiItem };
|
|
48
|
+
default: return { items: [], active: null };
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const { items, active } = getCurrentState();
|
|
52
|
+
const currentIndex = items.findIndex(i => i.value === active?.value);
|
|
53
|
+
// Arrow Navigation Logic
|
|
54
|
+
if (key.leftArrow) {
|
|
55
|
+
if (activePanel === 'codex')
|
|
56
|
+
setActivePanel('claude');
|
|
57
|
+
if (activePanel === 'gemini')
|
|
58
|
+
setActivePanel('cursor');
|
|
59
|
+
}
|
|
60
|
+
if (key.rightArrow) {
|
|
61
|
+
if (activePanel === 'claude')
|
|
62
|
+
setActivePanel('codex');
|
|
63
|
+
if (activePanel === 'cursor')
|
|
64
|
+
setActivePanel('gemini');
|
|
36
65
|
}
|
|
37
|
-
if (key.
|
|
38
|
-
|
|
66
|
+
if (key.upArrow) {
|
|
67
|
+
// If at top of list (index 0) or empty list, switch to panel above
|
|
68
|
+
if (currentIndex <= 0) {
|
|
69
|
+
if (activePanel === 'cursor')
|
|
70
|
+
setActivePanel('claude');
|
|
71
|
+
if (activePanel === 'gemini')
|
|
72
|
+
setActivePanel('codex');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (key.downArrow) {
|
|
76
|
+
// If at bottom of list or empty list, switch to panel below
|
|
77
|
+
if (items.length === 0 || currentIndex === items.length - 1) {
|
|
78
|
+
if (activePanel === 'claude')
|
|
79
|
+
setActivePanel('cursor');
|
|
80
|
+
if (activePanel === 'codex')
|
|
81
|
+
setActivePanel('gemini');
|
|
82
|
+
}
|
|
39
83
|
}
|
|
40
84
|
if (key.escape) {
|
|
41
85
|
exit();
|
|
@@ -43,36 +87,39 @@ const App = ({ cwd, limit = 10, onSubmit }) => {
|
|
|
43
87
|
});
|
|
44
88
|
useEffect(() => {
|
|
45
89
|
const loadSessions = async () => {
|
|
46
|
-
const [claudeSessions, codexSessions] = await Promise.all([
|
|
90
|
+
const [claudeSessions, codexSessions, cursorSessions, geminiSessions] = await Promise.all([
|
|
47
91
|
getClaudeSessions(cwd, limit),
|
|
48
|
-
getCodexSessions(cwd, limit)
|
|
92
|
+
getCodexSessions(cwd, limit),
|
|
93
|
+
getCursorSessions(cwd, limit),
|
|
94
|
+
getGeminiSessions(cwd, limit)
|
|
49
95
|
]);
|
|
50
96
|
// Sort by timestamp desc
|
|
51
97
|
const sortFn = (a, b) => b.timestamp - a.timestamp;
|
|
52
|
-
const
|
|
53
|
-
const title = s.title.replace(/\n/g, ' ').trim();
|
|
54
|
-
const truncatedTitle = truncateByWidth(title, 45);
|
|
55
|
-
return {
|
|
56
|
-
label: `${truncatedTitle} (${new Date(s.timestamp).toLocaleDateString()})`,
|
|
57
|
-
value: s.id,
|
|
58
|
-
session: s
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
const cxItems = codexSessions.sort(sortFn).map(s => {
|
|
98
|
+
const createItems = (sessions) => sessions.sort(sortFn).map(s => {
|
|
62
99
|
const title = s.title.replace(/\n/g, ' ').trim();
|
|
63
|
-
const truncatedTitle = truncateByWidth(title,
|
|
100
|
+
const truncatedTitle = truncateByWidth(title, 30); // Shorter title for grid
|
|
64
101
|
return {
|
|
65
102
|
label: `${truncatedTitle} (${new Date(s.timestamp).toLocaleDateString()})`,
|
|
66
103
|
value: s.id,
|
|
67
104
|
session: s
|
|
68
105
|
};
|
|
69
106
|
});
|
|
107
|
+
const cItems = createItems(claudeSessions);
|
|
108
|
+
const cxItems = createItems(codexSessions);
|
|
109
|
+
const curItems = createItems(cursorSessions);
|
|
110
|
+
const gemItems = createItems(geminiSessions);
|
|
70
111
|
setClaudeItems(cItems);
|
|
71
112
|
setCodexItems(cxItems);
|
|
113
|
+
setCursorItems(curItems);
|
|
114
|
+
setGeminiItems(gemItems);
|
|
72
115
|
if (cItems.length > 0)
|
|
73
116
|
setActiveClaudeItem(cItems[0]);
|
|
74
117
|
if (cxItems.length > 0)
|
|
75
118
|
setActiveCodexItem(cxItems[0]);
|
|
119
|
+
if (curItems.length > 0)
|
|
120
|
+
setActiveCursorItem(curItems[0]);
|
|
121
|
+
if (gemItems.length > 0)
|
|
122
|
+
setActiveGeminiItem(gemItems[0]);
|
|
76
123
|
};
|
|
77
124
|
loadSessions();
|
|
78
125
|
}, [cwd, limit]);
|
|
@@ -80,14 +127,41 @@ const App = ({ cwd, limit = 10, onSubmit }) => {
|
|
|
80
127
|
const selectedItem = item;
|
|
81
128
|
const englishPrompt = `Here's a context file ${selectedItem.session.path} from the user's previous operations. Analyze what the user was doing. Then use TodoWrite to list what might be incomplete, and what needs to be done next (if mentioned in the context), otherwise wait for user instructions.`;
|
|
82
129
|
const chinesePrompt = `这里有份上下文 ${selectedItem.session.path} ,是用户曾经的操作。你分析下用户在做什么。然后用TodoWrite列出可能没做完的事情,和接下来要的事情(如果上下文中有提到),如果没有就等待用户指令。`;
|
|
83
|
-
const
|
|
130
|
+
const source = selectedItem.session.source;
|
|
131
|
+
const getIndexIn = (items) => items.findIndex(i => i.value === selectedItem.value);
|
|
132
|
+
let resumeCommand = '';
|
|
133
|
+
if (source === 'claude') {
|
|
134
|
+
resumeCommand = `claude --resume ${selectedItem.session.id}`;
|
|
135
|
+
}
|
|
136
|
+
else if (source === 'codex') {
|
|
137
|
+
resumeCommand = `codex resume ${selectedItem.session.id}`;
|
|
138
|
+
}
|
|
139
|
+
else if (source === 'cursor') {
|
|
140
|
+
resumeCommand = `cursor-agent --resume=${selectedItem.session.id}`;
|
|
141
|
+
}
|
|
142
|
+
else if (source === 'gemini') {
|
|
143
|
+
const index = getIndexIn(geminiItems);
|
|
144
|
+
const resumeArg = index >= 0 ? String(index + 1) : 'latest';
|
|
145
|
+
resumeCommand = `gemini --resume ${resumeArg}`;
|
|
146
|
+
}
|
|
147
|
+
const output = `\n\n${englishPrompt}\n\n${chinesePrompt}\n\n${resumeCommand}\n\n`;
|
|
84
148
|
onSubmit?.(output);
|
|
85
149
|
exit();
|
|
86
150
|
};
|
|
87
151
|
const handleHighlightClaude = (item) => setActiveClaudeItem(item);
|
|
88
152
|
const handleHighlightCodex = (item) => setActiveCodexItem(item);
|
|
153
|
+
const handleHighlightCursor = (item) => setActiveCursorItem(item);
|
|
154
|
+
const handleHighlightGemini = (item) => setActiveGeminiItem(item);
|
|
89
155
|
// Get current preview prompts
|
|
90
|
-
|
|
156
|
+
let currentItem = null;
|
|
157
|
+
if (activePanel === 'claude')
|
|
158
|
+
currentItem = activeClaudeItem;
|
|
159
|
+
else if (activePanel === 'codex')
|
|
160
|
+
currentItem = activeCodexItem;
|
|
161
|
+
else if (activePanel === 'cursor')
|
|
162
|
+
currentItem = activeCursorItem;
|
|
163
|
+
else if (activePanel === 'gemini')
|
|
164
|
+
currentItem = activeGeminiItem;
|
|
91
165
|
// Filter out empty prompts
|
|
92
166
|
const prompts = (currentItem?.session.userPrompts || []).filter(p => p && p.trim().length > 0);
|
|
93
167
|
// Truncate prompts logic (~60 display width: ~120 ASCII chars or ~60 CJK chars)
|
|
@@ -96,6 +170,7 @@ const App = ({ cwd, limit = 10, onSubmit }) => {
|
|
|
96
170
|
const truncated = truncateByWidth(clean, 120);
|
|
97
171
|
return `${i + 1}. ${truncated}`;
|
|
98
172
|
}).join('\n');
|
|
99
|
-
|
|
173
|
+
const renderPanel = (title, panelId, items, onHighlight) => (_jsxs(Box, { width: "50%", borderStyle: activePanel === panelId ? 'double' : 'single', flexDirection: "column", borderColor: activePanel === panelId ? 'green' : 'white', paddingX: 1, children: [_jsx(Text, { bold: true, underline: true, color: activePanel === panelId ? 'green' : 'white', children: title }), items.length === 0 ? (_jsx(Text, { children: "No sessions found." })) : (_jsx(SelectInput, { items: items, onSelect: handleSelect, onHighlight: onHighlight, isFocused: activePanel === panelId, limit: limit }))] }));
|
|
174
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", flexDirection: "column", paddingX: 1, minHeight: 5, children: [_jsx(Text, { bold: true, underline: true, children: "Preview (User Prompts)" }), _jsx(Text, { children: previewText || 'Select a session to view prompts' })] }), _jsxs(Box, { flexDirection: "row", children: [renderPanel('Claude Sessions', 'claude', claudeItems, handleHighlightClaude), renderPanel('Codex Sessions', 'codex', codexItems, handleHighlightCodex)] }), _jsxs(Box, { flexDirection: "row", children: [renderPanel('Cursor Sessions', 'cursor', cursorItems, handleHighlightCursor), renderPanel('Gemini Sessions', 'gemini', geminiItems, handleHighlightGemini)] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { dimColor: true, children: "TAB: Switch Panel | Arrows: Nav | ENTER: Select | ESC: Exit" }) })] }));
|
|
100
175
|
};
|
|
101
176
|
export default App;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitorcen/context-resume",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Context Resume CLI - Browse and mutually restore the conversation history of Claude Code and Codex",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"ink": "^6.5.1",
|
|
41
41
|
"ink-box": "^1.0.0",
|
|
42
42
|
"ink-select-input": "^6.2.0",
|
|
43
|
-
"react": "^19.2.1"
|
|
43
|
+
"react": "^19.2.1",
|
|
44
|
+
"string-width": "^8.1.0"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/glob": "^8.1.0",
|