@vitorcen/context-resume 1.0.2 → 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 CHANGED
@@ -1,16 +1,17 @@
1
1
  # Context Resume CLI
2
2
 
3
- Context Resume lets you quickly resume work across Claude Code (`~/.claude`) and Codex CLI (`~/.codex`) 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.
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
- - **Dual-Panel View**: Split view for Claude and Codex sessions; use `TAB` to switch.
7
- - **Detailed Preview**: Shows a list of user prompts from the selected session (truncated to 50 chars) at the top.
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 Claude and Codex panels.
29
- - Use **Arrow Keys** to select a session.
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 split panels and preview.
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.
@@ -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(cwd);
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
- if (meta.type === 'session_meta' && meta.payload?.cwd === cwd) {
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
- function createPreview(content, maxLength = 500) {
166
- if (content.length <= maxLength)
167
- return content;
168
- const start = content.slice(0, maxLength / 2);
169
- const end = content.slice(content.length - (maxLength / 2));
170
- return `${start}\n\n... [${content.length - maxLength} characters omitted] ...\n\n${end}`;
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,24 +2,84 @@ 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
+ import stringWidth from 'string-width';
7
+ // Truncate by display width (handles CJK characters correctly)
8
+ const truncateByWidth = (str, maxWidth) => {
9
+ if (stringWidth(str) <= maxWidth)
10
+ return str;
11
+ let result = '';
12
+ let width = 0;
13
+ for (const char of str) {
14
+ const charWidth = stringWidth(char);
15
+ if (width + charWidth > maxWidth)
16
+ break;
17
+ result += char;
18
+ width += charWidth;
19
+ }
20
+ return result + '...';
21
+ };
6
22
  const App = ({ cwd, limit = 10, onSubmit }) => {
7
23
  const [claudeItems, setClaudeItems] = useState([]);
8
24
  const [codexItems, setCodexItems] = useState([]);
25
+ const [cursorItems, setCursorItems] = useState([]);
26
+ const [geminiItems, setGeminiItems] = useState([]);
9
27
  const [activePanel, setActivePanel] = useState('claude');
10
28
  const [activeClaudeItem, setActiveClaudeItem] = useState(null);
11
29
  const [activeCodexItem, setActiveCodexItem] = useState(null);
30
+ const [activeCursorItem, setActiveCursorItem] = useState(null);
31
+ const [activeGeminiItem, setActiveGeminiItem] = useState(null);
12
32
  const { exit } = useApp();
13
33
  // Tab switching and Arrow Navigation
14
34
  useInput((input, key) => {
15
35
  if (key.tab) {
16
- setActivePanel(prev => prev === 'claude' ? 'codex' : 'claude');
36
+ const panels = ['claude', 'codex', 'cursor', 'gemini'];
37
+ const currentIndex = panels.indexOf(activePanel);
38
+ const nextIndex = (currentIndex + 1) % panels.length;
39
+ setActivePanel(panels[nextIndex]);
40
+ }
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');
17
65
  }
18
- if (key.leftArrow && activePanel === 'codex') {
19
- setActivePanel('claude');
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
+ }
20
74
  }
21
- if (key.rightArrow && activePanel === 'claude') {
22
- setActivePanel('codex');
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
+ }
23
83
  }
24
84
  if (key.escape) {
25
85
  exit();
@@ -27,28 +87,39 @@ const App = ({ cwd, limit = 10, onSubmit }) => {
27
87
  });
28
88
  useEffect(() => {
29
89
  const loadSessions = async () => {
30
- const [claudeSessions, codexSessions] = await Promise.all([
90
+ const [claudeSessions, codexSessions, cursorSessions, geminiSessions] = await Promise.all([
31
91
  getClaudeSessions(cwd, limit),
32
- getCodexSessions(cwd, limit)
92
+ getCodexSessions(cwd, limit),
93
+ getCursorSessions(cwd, limit),
94
+ getGeminiSessions(cwd, limit)
33
95
  ]);
34
96
  // Sort by timestamp desc
35
97
  const sortFn = (a, b) => b.timestamp - a.timestamp;
36
- const cItems = claudeSessions.sort(sortFn).map(s => ({
37
- label: `${s.title} (${new Date(s.timestamp).toLocaleDateString()})`,
38
- value: s.id,
39
- session: s
40
- }));
41
- const cxItems = codexSessions.sort(sortFn).map(s => ({
42
- label: `${s.title} (${new Date(s.timestamp).toLocaleDateString()})`,
43
- value: s.id,
44
- session: s
45
- }));
98
+ const createItems = (sessions) => sessions.sort(sortFn).map(s => {
99
+ const title = s.title.replace(/\n/g, ' ').trim();
100
+ const truncatedTitle = truncateByWidth(title, 30); // Shorter title for grid
101
+ return {
102
+ label: `${truncatedTitle} (${new Date(s.timestamp).toLocaleDateString()})`,
103
+ value: s.id,
104
+ session: s
105
+ };
106
+ });
107
+ const cItems = createItems(claudeSessions);
108
+ const cxItems = createItems(codexSessions);
109
+ const curItems = createItems(cursorSessions);
110
+ const gemItems = createItems(geminiSessions);
46
111
  setClaudeItems(cItems);
47
112
  setCodexItems(cxItems);
113
+ setCursorItems(curItems);
114
+ setGeminiItems(gemItems);
48
115
  if (cItems.length > 0)
49
116
  setActiveClaudeItem(cItems[0]);
50
117
  if (cxItems.length > 0)
51
118
  setActiveCodexItem(cxItems[0]);
119
+ if (curItems.length > 0)
120
+ setActiveCursorItem(curItems[0]);
121
+ if (gemItems.length > 0)
122
+ setActiveGeminiItem(gemItems[0]);
52
123
  };
53
124
  loadSessions();
54
125
  }, [cwd, limit]);
@@ -56,22 +127,50 @@ const App = ({ cwd, limit = 10, onSubmit }) => {
56
127
  const selectedItem = item;
57
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.`;
58
129
  const chinesePrompt = `这里有份上下文 ${selectedItem.session.path} ,是用户曾经的操作。你分析下用户在做什么。然后用TodoWrite列出可能没做完的事情,和接下来要的事情(如果上下文中有提到),如果没有就等待用户指令。`;
59
- const output = `\n\n${englishPrompt}\n\n${chinesePrompt}\n\n`;
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`;
60
148
  onSubmit?.(output);
61
149
  exit();
62
150
  };
63
151
  const handleHighlightClaude = (item) => setActiveClaudeItem(item);
64
152
  const handleHighlightCodex = (item) => setActiveCodexItem(item);
153
+ const handleHighlightCursor = (item) => setActiveCursorItem(item);
154
+ const handleHighlightGemini = (item) => setActiveGeminiItem(item);
65
155
  // Get current preview prompts
66
- const currentItem = activePanel === 'claude' ? activeClaudeItem : activeCodexItem;
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;
67
165
  // Filter out empty prompts
68
166
  const prompts = (currentItem?.session.userPrompts || []).filter(p => p && p.trim().length > 0);
69
- // Truncate prompts logic
167
+ // Truncate prompts logic (~60 display width: ~120 ASCII chars or ~60 CJK chars)
70
168
  const previewText = prompts.map((p, i) => {
71
169
  const clean = p.replace(/\n/g, ' ').trim();
72
- const truncated = clean.length > 50 ? clean.slice(0, 50) + '...' : clean;
170
+ const truncated = truncateByWidth(clean, 120);
73
171
  return `${i + 1}. ${truncated}`;
74
172
  }).join('\n');
75
- return (_jsxs(Box, { flexDirection: "column", height: 35, children: [_jsxs(Box, { height: 12, borderStyle: "single", flexDirection: "column", paddingX: 1, 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", height: 20, children: [_jsxs(Box, { width: "50%", borderStyle: activePanel === 'claude' ? 'double' : 'single', flexDirection: "column", borderColor: activePanel === 'claude' ? 'green' : 'white', children: [_jsx(Text, { bold: true, underline: true, color: activePanel === 'claude' ? 'green' : 'white', children: "Claude Sessions" }), claudeItems.length === 0 ? (_jsx(Text, { children: "No sessions found." })) : (_jsx(SelectInput, { items: claudeItems, onSelect: handleSelect, onHighlight: handleHighlightClaude, isFocused: activePanel === 'claude' }))] }), _jsxs(Box, { width: "50%", borderStyle: activePanel === 'codex' ? 'double' : 'single', flexDirection: "column", borderColor: activePanel === 'codex' ? 'green' : 'white', children: [_jsx(Text, { bold: true, underline: true, color: activePanel === 'codex' ? 'green' : 'white', children: "Codex Sessions" }), codexItems.length === 0 ? (_jsx(Text, { children: "No sessions found." })) : (_jsx(SelectInput, { items: codexItems, onSelect: handleSelect, onHighlight: handleHighlightCodex, isFocused: activePanel === 'codex' }))] })] }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { dimColor: true, children: "TAB/Arrows: Switch Panel | ENTER: Select | ESC: Exit" }) })] }));
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" }) })] }));
76
175
  };
77
176
  export default App;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitorcen/context-resume",
3
- "version": "1.0.2",
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",