@vinh0809it/claude-sessions 0.1.0 → 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.
Files changed (3) hide show
  1. package/README.md +49 -36
  2. package/index.js +673 -289
  3. package/package.json +2 -2
package/index.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * claude-sessions --named-only Only /rename'd sessions
11
11
  * claude-sessions --json JSON output
12
12
  * claude-sessions -d <path> Scan a specific config dir
13
+ * claude-sessions --empty-trash Permanently remove deleted sessions
14
+ * claude-sessions --no-cache Ignore the metadata cache
13
15
  * claude-sessions --version Print version
14
16
  * claude-sessions -h Help
15
17
  *
@@ -18,16 +20,23 @@
18
20
  * PgUp PgDn Page up / down
19
21
  * g G Jump to top / bottom
20
22
  * / Search (shows [config] project inline)
21
- * Esc Clear search
22
- * Enter Open session in a new terminal tab
23
+ * Esc Clear search / marks
24
+ * Enter Open session (new tab if supported, else here)
25
+ * o Open session in this terminal
26
+ * p Toggle preview of recent messages
27
+ * r Rename session
28
+ * s Cycle sort: date / size / name
29
+ * Space Mark session (for bulk delete)
23
30
  * n New session
24
31
  * m Move session to another config / folder
25
- * d Delete session
32
+ * d Delete session(s) to trash
33
+ * ? Help
26
34
  * q Ctrl-C Quit
27
35
  *
28
36
  * Environment variables:
29
- * CLAUDE_SESSIONS_TERMINAL Force terminal: kitty | wezterm | tmux
37
+ * CLAUDE_SESSIONS_TERMINAL Force terminal: kitty | wezterm | tmux | iterm2 | apple-terminal | here
30
38
  * CLAUDE_SESSIONS_OPEN_CMD Custom open command (placeholders: %cmd% %title% %project%)
39
+ * CLAUDE_CONFIG_DIR Also scanned when set
31
40
  */
32
41
 
33
42
  const fs = require('fs');
@@ -35,124 +44,224 @@ const path = require('path');
35
44
  const os = require('os');
36
45
  const { spawnSync } = require('child_process');
37
46
 
38
- const VERSION = require('./package.json').version;
47
+ let VERSION = 'dev';
48
+ try { VERSION = require('./package.json').version; } catch {} // single-file installs have no package.json
49
+ const IS_WIN = process.platform === 'win32';
50
+ const TRASH = '.claude-sessions-trash';
51
+ const HEAD_BYTES = 256 * 1024;
52
+ const TAIL_BYTES = 256 * 1024;
39
53
 
40
54
  // ─── Config dir discovery ─────────────────────────────────────────────────────
41
55
 
56
+ function hasProjects(d) {
57
+ try { return fs.statSync(path.join(d, 'projects')).isDirectory(); } catch { return false; }
58
+ }
59
+
42
60
  function discoverConfigDirs() {
43
61
  const home = os.homedir();
62
+ let dirs = [];
44
63
  try {
45
- return fs.readdirSync(home)
64
+ dirs = fs.readdirSync(home)
46
65
  .filter(n => n === '.claude' || n.startsWith('.claude-'))
47
- .map(n => path.join(home, n))
48
- .filter(d => {
49
- try { return fs.statSync(d).isDirectory() && fs.existsSync(path.join(d, 'projects')); }
50
- catch { return false; }
51
- })
52
- .sort((a, b) => {
53
- const na = path.basename(a), nb = path.basename(b);
54
- if (na === '.claude') return 1;
55
- if (nb === '.claude') return -1;
56
- return na.localeCompare(nb);
57
- });
58
- } catch { return []; }
66
+ .map(n => path.join(home, n));
67
+ } catch {}
68
+ if (process.env.CLAUDE_CONFIG_DIR) dirs.push(path.resolve(process.env.CLAUDE_CONFIG_DIR));
69
+ return [...new Set(dirs)]
70
+ .filter(hasProjects)
71
+ .sort((a, b) => {
72
+ const na = path.basename(a), nb = path.basename(b);
73
+ if (na === '.claude') return 1;
74
+ if (nb === '.claude') return -1;
75
+ return na.localeCompare(nb);
76
+ });
59
77
  }
60
78
 
61
79
  // ─── Args ─────────────────────────────────────────────────────────────────────
62
80
 
63
- function parseArgs() {
64
- const args = process.argv.slice(2);
65
- const opts = { dir: null, namedOnly: false, json: false, list: false, help: false, version: false, filterPath: null };
66
- for (let i = 0; i < args.length; i++) {
67
- const a = args[i];
68
- if (a === '-d' || a === '--dir') opts.dir = args[++i];
81
+ function parseArgs(argv = process.argv.slice(2)) {
82
+ const opts = { dir: null, namedOnly: false, json: false, list: false, help: false, version: false,
83
+ filterPath: null, emptyTrash: false, noCache: false };
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const a = argv[i];
86
+ if (a === '-d' || a === '--dir') opts.dir = argv[++i];
69
87
  else if (a === '--named-only') opts.namedOnly = true;
70
88
  else if (a === '--json') opts.json = true;
71
89
  else if (a === '--list') opts.list = true;
90
+ else if (a === '--empty-trash') opts.emptyTrash = true;
91
+ else if (a === '--no-cache') opts.noCache = true;
72
92
  else if (a === '--version' || a === '-v') opts.version = true;
73
93
  else if (a === '-h' || a === '--help') opts.help = true;
74
- else if (!a.startsWith('-')) opts.filterPath = path.resolve(a);
94
+ else if (!a.startsWith('-')) opts.filterPath = path.resolve(a.replace(/^~(?=$|[\\/])/, os.homedir()));
75
95
  }
76
96
  return opts;
77
97
  }
78
98
 
79
99
  // ─── Session reading ──────────────────────────────────────────────────────────
80
100
 
101
+ // Reads the first `head` and last `tail` bytes, returning only complete lines.
102
+ function readEdges(file, head = HEAD_BYTES, tail = TAIL_BYTES) {
103
+ const fd = fs.openSync(file, 'r');
104
+ try {
105
+ const size = fs.fstatSync(fd).size;
106
+ if (size <= head + tail) {
107
+ const buf = Buffer.alloc(size);
108
+ fs.readSync(fd, buf, 0, size, 0);
109
+ const lines = buf.toString('utf8').split('\n');
110
+ return { headLines: lines, tailLines: lines, whole: true };
111
+ }
112
+ const hb = Buffer.alloc(head);
113
+ fs.readSync(fd, hb, 0, head, 0);
114
+ const tb = Buffer.alloc(tail);
115
+ fs.readSync(fd, tb, 0, tail, size - tail);
116
+ const headLines = hb.toString('utf8').split('\n').slice(0, -1); // drop partial last line
117
+ const tailLines = tb.toString('utf8').split('\n').slice(1); // drop partial first line
118
+ return { headLines, tailLines, whole: false };
119
+ } finally { fs.closeSync(fd); }
120
+ }
121
+
122
+ function parseLine(line) {
123
+ if (!line) return null;
124
+ try { return JSON.parse(line); } catch { return null; }
125
+ }
126
+
127
+ // Text Claude Code injects into user turns (reminders, slash-command echoes, hook output).
128
+ const WRAPPER_RE = /^<(system-reminder|command-|local-command-|user-prompt-submit-hook|bash-|task-notification|pasted_content)/;
129
+
130
+ // Human-typed text of a user/assistant record, or '' for tool results, meta and wrappers.
131
+ function messageText(r) {
132
+ if (!r || r.isMeta || !r.message) return '';
133
+ const c = r.message.content;
134
+ const parts = typeof c === 'string' ? [c]
135
+ : Array.isArray(c) ? c.filter(x => x?.type === 'text').map(x => x.text || '') : [];
136
+ return parts.map(t => t.trim()).filter(t => t && !WRAPPER_RE.test(t)).join(' ');
137
+ }
138
+
81
139
  function readSession(jsonlPath) {
82
140
  let title = null, firstUserMessage = null, cwd = null, timestamp = null;
83
- try {
84
- const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n');
85
- for (const line of lines) {
86
- if (!line) continue;
87
- try {
88
- const r = JSON.parse(line);
89
- if (r.type === 'custom-title' && r.customTitle) title = r.customTitle;
90
- if (!firstUserMessage && r.type === 'user' && r.message?.content) {
91
- const c = r.message.content;
92
- const text = typeof c === 'string' ? c : (c.find?.(x => x.type === 'text')?.text ?? '');
93
- if (text) firstUserMessage = text.trim().replace(/\s+/g, ' ').slice(0, 80);
94
- }
95
- if (!cwd && r.cwd) cwd = r.cwd;
96
- if (!timestamp && r.timestamp) timestamp = r.timestamp;
97
- } catch { /* malformed line — skip */ }
141
+ let edges;
142
+ try { edges = readEdges(jsonlPath); }
143
+ catch { return { title, firstUserMessage, cwd, timestamp }; }
144
+
145
+ for (const line of edges.headLines) {
146
+ const r = parseLine(line);
147
+ if (!r) continue;
148
+ if (r.type === 'custom-title' && r.customTitle) title = r.customTitle;
149
+ if (!firstUserMessage && r.type === 'user') {
150
+ const text = messageText(r);
151
+ if (text) firstUserMessage = text.replace(/\s+/g, ' ').slice(0, 80);
152
+ }
153
+ if (!cwd && r.cwd) cwd = r.cwd;
154
+ if (!timestamp && r.timestamp) timestamp = r.timestamp;
155
+ }
156
+ if (!edges.whole) {
157
+ // Claude Code re-emits custom-title throughout the file; the last one wins.
158
+ for (const line of edges.tailLines) {
159
+ if (!line.includes('"custom-title"')) continue;
160
+ const r = parseLine(line);
161
+ if (r?.type === 'custom-title' && r.customTitle) title = r.customTitle;
98
162
  }
99
- } catch { /* unreadable file — return nulls */ }
163
+ }
100
164
  return { title, firstUserMessage, cwd, timestamp };
101
165
  }
102
166
 
103
- function scanConfigDir(configDir, filterPath) {
104
- const projectsDir = path.join(configDir, 'projects');
105
- if (!fs.existsSync(projectsDir)) return [];
106
- const results = [];
167
+ // Last `n` human-readable messages, for the preview pane.
168
+ function readRecentMessages(jsonlPath, n = 8) {
169
+ let lines;
170
+ try { lines = readEdges(jsonlPath, 0, TAIL_BYTES).tailLines; } catch { return []; }
171
+ const out = [];
172
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
173
+ const r = parseLine(lines[i]);
174
+ if (!r || (r.type !== 'user' && r.type !== 'assistant')) continue;
175
+ const text = messageText(r);
176
+ if (text) out.push({ role: r.type, text: text.replace(/\s+/g, ' ') });
177
+ }
178
+ return out.reverse();
179
+ }
107
180
 
181
+ // ─── Metadata cache ───────────────────────────────────────────────────────────
182
+
183
+ const cache = {
184
+ file: path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'claude-sessions', 'index.json'),
185
+ data: null,
186
+ dirty: false,
187
+ enabled: true,
188
+ load() {
189
+ if (this.data) return;
190
+ this.data = {};
191
+ if (!this.enabled) return;
192
+ try { this.data = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch {}
193
+ },
194
+ get(p, st) {
195
+ this.load();
196
+ const e = this.data[p];
197
+ return e && e.mtimeMs === st.mtimeMs && e.size === st.size ? e.meta : null;
198
+ },
199
+ set(p, st, meta) {
200
+ this.data[p] = { mtimeMs: st.mtimeMs, size: st.size, meta };
201
+ this.dirty = true;
202
+ },
203
+ save(livePaths) {
204
+ if (!this.enabled || !this.dirty) return;
205
+ if (livePaths) for (const k of Object.keys(this.data)) if (!livePaths.has(k)) delete this.data[k];
206
+ try {
207
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
208
+ const tmp = `${this.file}.${process.pid}.tmp`;
209
+ fs.writeFileSync(tmp, JSON.stringify(this.data));
210
+ fs.renameSync(tmp, this.file);
211
+ this.dirty = false;
212
+ } catch {}
213
+ },
214
+ };
215
+
216
+ function readSessionCached(jsonlPath, st) {
217
+ const hit = cache.get(jsonlPath, st);
218
+ if (hit) return hit;
219
+ const meta = readSession(jsonlPath);
220
+ cache.set(jsonlPath, st, meta);
221
+ return meta;
222
+ }
223
+
224
+ // ─── Scanning ─────────────────────────────────────────────────────────────────
225
+
226
+ function scanConfigDir(configDir, filterPath, livePaths = new Set()) {
227
+ const projectsDir = path.join(configDir, 'projects');
108
228
  let projectEntries;
109
229
  try { projectEntries = fs.readdirSync(projectsDir); } catch { return []; }
230
+ const results = [];
110
231
 
111
232
  for (const pd of projectEntries) {
112
233
  const pdFull = path.join(projectsDir, pd);
113
- try { if (!fs.statSync(pdFull).isDirectory()) continue; } catch { continue; }
234
+ let names;
235
+ try { names = fs.readdirSync(pdFull).filter(f => f.endsWith('.jsonl')); } catch { continue; }
114
236
 
115
- let files;
116
- try {
117
- files = fs.readdirSync(pdFull)
118
- .filter(f => f.endsWith('.jsonl'))
119
- .map(f => {
120
- const p = path.join(pdFull, f);
121
- return { file: f, mtime: fs.statSync(p).mtime, jsonlPath: p };
122
- })
123
- .sort((a, b) => b.mtime - a.mtime);
124
- } catch { continue; }
125
-
126
- // .project-path hint written at move time takes highest priority
127
- let dirPathHint = null;
128
- try {
129
- const hint = fs.readFileSync(path.join(pdFull, '.project-path'), 'utf8').trim();
130
- if (hint) dirPathHint = hint;
131
- } catch {}
237
+ const files = [];
238
+ for (const f of names) {
239
+ const p = path.join(pdFull, f);
240
+ try {
241
+ const st = fs.statSync(p);
242
+ livePaths.add(p);
243
+ files.push({ file: f, jsonlPath: p, mtime: st.mtime, size: st.size, meta: readSessionCached(p, st) });
244
+ } catch {}
245
+ }
132
246
 
133
- for (const { file, mtime, jsonlPath } of files) {
134
- const { title, firstUserMessage, cwd, timestamp } = readSession(jsonlPath);
135
- let project = cwd || pd;
136
- if (dirPathHint) {
137
- project = dirPathHint;
138
- } else if (cwd && encodePathToDir(cwd) !== pd) {
139
- // JSONL cwd is stale; find a sibling whose cwd encodes to this dir name
140
- for (const sib of files) {
141
- if (sib.jsonlPath === jsonlPath) continue;
142
- const { cwd: sibCwd } = readSession(sib.jsonlPath);
143
- if (sibCwd && encodePathToDir(sibCwd) === pd) { project = sibCwd; break; }
144
- }
145
- }
146
- if (filterPath && !project.startsWith(filterPath)) continue;
247
+ // Folder's real path: .project-path hint (written at move time), else any session whose cwd encodes to it.
248
+ let dirPath = null;
249
+ try { dirPath = fs.readFileSync(path.join(pdFull, '.project-path'), 'utf8').trim() || null; } catch {}
250
+ if (!dirPath) dirPath = files.find(x => x.meta.cwd && encodePathToDir(x.meta.cwd) === pd)?.meta.cwd ?? null;
251
+
252
+ for (const { file, jsonlPath, mtime, size, meta } of files) {
253
+ const project = dirPath || meta.cwd || pd;
254
+ if (filterPath && !isWithin(project, filterPath)) continue;
147
255
  results.push({
148
256
  configDir: path.basename(configDir),
149
257
  configDirPath: configDir,
150
258
  project,
151
- sessionId: file.replace('.jsonl', ''),
152
- title,
153
- firstMessage: firstUserMessage,
259
+ sessionId: file.replace(/\.jsonl$/, ''),
260
+ title: meta.title,
261
+ firstMessage: meta.firstUserMessage,
154
262
  lastModified: mtime,
155
- timestamp,
263
+ timestamp: meta.timestamp,
264
+ size,
156
265
  jsonlPath,
157
266
  });
158
267
  }
@@ -160,38 +269,81 @@ function scanConfigDir(configDir, filterPath) {
160
269
  return results.sort((a, b) => b.lastModified - a.lastModified);
161
270
  }
162
271
 
272
+ function isWithin(p, root) {
273
+ const rel = path.relative(root, p);
274
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
275
+ }
276
+
163
277
  function loadSessions(opts) {
164
278
  const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
165
- if (dirs.length === 0) process.stderr.write('No Claude Code config directories found in ~\n');
166
- const all = dirs.flatMap(d => scanConfigDir(d, opts.filterPath));
279
+ if (dirs.length === 0) process.stderr.write('No Claude Code config directories found (looked for ~/.claude* and $CLAUDE_CONFIG_DIR)\n');
280
+ const live = new Set();
281
+ const all = dirs.flatMap(d => scanConfigDir(d, opts.filterPath, live));
282
+ cache.save(opts.filterPath || opts.dir ? null : live); // only prune on a full scan
167
283
  return opts.namedOnly ? all.filter(s => s.title) : all;
168
284
  }
169
285
 
170
286
  // ─── Session operations ───────────────────────────────────────────────────────
171
287
 
288
+ // rename(2) fails across filesystems (EXDEV); fall back to copy + remove.
289
+ function movePath(src, dest) {
290
+ try { fs.renameSync(src, dest); }
291
+ catch (e) {
292
+ if (e.code !== 'EXDEV') throw e;
293
+ fs.cpSync(src, dest, { recursive: true, errorOnExist: true, force: false });
294
+ fs.rmSync(src, { recursive: true, force: true });
295
+ }
296
+ }
297
+
298
+ function companionDir(jsonlPath) { return jsonlPath.replace(/\.jsonl$/, ''); }
299
+
300
+ // Moves a session into <configDir>/.claude-sessions-trash/ (same filesystem, so it's a cheap rename).
172
301
  function deleteSessionFiles(session) {
173
- try { fs.unlinkSync(session.jsonlPath); } catch {}
174
- const dir = session.jsonlPath.replace(/\.jsonl$/, '');
302
+ const projDirName = path.basename(path.dirname(session.jsonlPath));
303
+ const trashDir = path.join(session.configDirPath, TRASH, projDirName);
304
+ fs.mkdirSync(trashDir, { recursive: true });
305
+ let base = path.basename(session.jsonlPath, '.jsonl');
306
+ if (fs.existsSync(path.join(trashDir, base + '.jsonl'))) base += '-' + Date.now();
307
+ movePath(session.jsonlPath, path.join(trashDir, base + '.jsonl'));
308
+ const dir = companionDir(session.jsonlPath);
309
+ try { if (fs.statSync(dir).isDirectory()) movePath(dir, path.join(trashDir, base)); } catch {}
310
+ return trashDir;
311
+ }
312
+
313
+ function dirSize(p) {
314
+ let total = 0;
175
315
  try {
176
- if (fs.statSync(dir).isDirectory()) fs.rmSync(dir, { recursive: true, force: true });
316
+ const st = fs.lstatSync(p);
317
+ if (!st.isDirectory()) return st.size;
318
+ for (const n of fs.readdirSync(p)) total += dirSize(path.join(p, n));
177
319
  } catch {}
320
+ return total;
321
+ }
322
+
323
+ function emptyTrash(configDirs) {
324
+ let freed = 0;
325
+ for (const d of configDirs) {
326
+ const t = path.join(d, TRASH);
327
+ if (!fs.existsSync(t)) continue;
328
+ freed += dirSize(t);
329
+ fs.rmSync(t, { recursive: true, force: true });
330
+ }
331
+ return freed;
178
332
  }
179
333
 
180
334
  function moveSession(session, targetConfigDirPath, targetProjectDirName, targetRawPath = null) {
181
335
  const srcProjectDirName = path.basename(path.dirname(session.jsonlPath));
182
336
  const destDirName = targetProjectDirName || srcProjectDirName;
183
337
  const targetProjectDir = path.join(targetConfigDirPath, 'projects', destDirName);
184
- fs.mkdirSync(targetProjectDir, { recursive: true });
338
+ const targetJsonl = path.join(targetProjectDir, path.basename(session.jsonlPath));
339
+ if (path.resolve(targetJsonl) === path.resolve(session.jsonlPath)) return;
340
+ if (fs.existsSync(targetJsonl)) throw new Error('a session with this id already exists there');
185
341
 
186
- const targetJsonl = path.join(targetProjectDir, path.basename(session.jsonlPath));
187
- fs.renameSync(session.jsonlPath, targetJsonl);
342
+ fs.mkdirSync(targetProjectDir, { recursive: true });
343
+ movePath(session.jsonlPath, targetJsonl);
188
344
 
189
- const srcDir = session.jsonlPath.replace(/\.jsonl$/, '');
190
- try {
191
- if (fs.statSync(srcDir).isDirectory()) {
192
- fs.renameSync(srcDir, targetJsonl.replace(/\.jsonl$/, ''));
193
- }
194
- } catch {}
345
+ const srcDir = companionDir(session.jsonlPath);
346
+ try { if (fs.statSync(srcDir).isDirectory()) movePath(srcDir, companionDir(targetJsonl)); } catch {}
195
347
 
196
348
  if (targetRawPath) {
197
349
  try { fs.writeFileSync(path.join(targetProjectDir, '.project-path'), targetRawPath, 'utf8'); } catch {}
@@ -202,35 +354,75 @@ function moveSession(session, targetConfigDirPath, targetProjectDirName, targetR
202
354
  session.jsonlPath = targetJsonl;
203
355
  }
204
356
 
357
+ // Appends a custom-title record — the same thing /rename writes.
358
+ function renameSession(session, title) {
359
+ title = title.trim();
360
+ if (!title) throw new Error('empty title');
361
+ const fd = fs.openSync(session.jsonlPath, 'r+');
362
+ try {
363
+ const size = fs.fstatSync(fd).size;
364
+ let prefix = '';
365
+ if (size > 0) {
366
+ const last = Buffer.alloc(1);
367
+ fs.readSync(fd, last, 0, 1, size - 1);
368
+ if (last[0] !== 0x0a) prefix = '\n';
369
+ }
370
+ const rec = JSON.stringify({ type: 'custom-title', customTitle: title, sessionId: session.sessionId });
371
+ fs.writeSync(fd, prefix + rec + '\n', size);
372
+ } finally { fs.closeSync(fd); }
373
+ session.title = title;
374
+ }
375
+
205
376
  function listProjectFolders(configDirPath) {
206
377
  const projectsDir = path.join(configDirPath, 'projects');
207
- if (!fs.existsSync(projectsDir)) return [];
208
378
  try {
209
379
  return fs.readdirSync(projectsDir)
210
380
  .filter(d => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } })
211
381
  .map(d => {
212
382
  let displayPath = d;
213
383
  try {
214
- const f = fs.readdirSync(path.join(projectsDir, d)).find(f => f.endsWith('.jsonl'));
215
- if (f) {
216
- const { cwd } = readSession(path.join(projectsDir, d, f));
217
- if (cwd) displayPath = shortenPath(cwd);
218
- }
219
- } catch {}
384
+ const hint = fs.readFileSync(path.join(projectsDir, d, '.project-path'), 'utf8').trim();
385
+ if (hint) displayPath = shortenPath(hint);
386
+ } catch {
387
+ try {
388
+ const f = fs.readdirSync(path.join(projectsDir, d)).find(f => f.endsWith('.jsonl'));
389
+ if (f) {
390
+ const { cwd } = readSession(path.join(projectsDir, d, f));
391
+ if (cwd) displayPath = shortenPath(cwd);
392
+ }
393
+ } catch {}
394
+ }
220
395
  return { projectDirName: d, displayPath };
221
396
  })
222
397
  .sort((a, b) => a.displayPath.localeCompare(b.displayPath));
223
398
  } catch { return []; }
224
399
  }
225
400
 
401
+ // Same scheme as Claude Code: every non-alphanumeric char becomes '-'.
402
+ const MAX_DIR_NAME = 200;
226
403
  function encodePathToDir(absPath) {
227
- return absPath.replace(/\//g, '-');
404
+ return absPath.replace(/[^a-zA-Z0-9]/g, '-');
228
405
  }
229
406
 
407
+ // Claude Code shortens very long names with a hash suffix we can't reproduce,
408
+ // so for those reuse an existing folder that shares the prefix.
409
+ function projectDirNameFor(configDirPath, absPath) {
410
+ const enc = encodePathToDir(absPath);
411
+ if (enc.length <= MAX_DIR_NAME) return enc;
412
+ const prefix = enc.slice(0, MAX_DIR_NAME);
413
+ try {
414
+ const hit = fs.readdirSync(path.join(configDirPath, 'projects')).find(d => d.startsWith(prefix));
415
+ if (hit) return hit;
416
+ } catch {}
417
+ throw new Error('path too long: start one Claude session in it first, then move');
418
+ }
419
+
420
+ function expandHome(p) { return p.replace(/^~(?=$|[\\/])/, os.homedir()); }
421
+
230
422
  function tabComplete(input) {
231
- const expanded = input.replace(/^~/, os.homedir());
232
- const endsWithSlash = expanded.endsWith('/');
233
- const baseDir = endsWithSlash ? (expanded.slice(0, -1) || '/') : path.dirname(expanded);
423
+ const expanded = expandHome(input);
424
+ const endsWithSlash = /[\\/]$/.test(expanded);
425
+ const baseDir = endsWithSlash ? (expanded.slice(0, -1) || path.sep) : path.dirname(expanded);
234
426
  const prefix = endsWithSlash ? '' : path.basename(expanded);
235
427
 
236
428
  let matches;
@@ -245,8 +437,7 @@ function tabComplete(input) {
245
437
  if (!matches.length) return { completed: input, matches: [] };
246
438
 
247
439
  if (matches.length === 1) {
248
- const completed = (path.join(baseDir, matches[0]) + '/').replace(os.homedir(), '~');
249
- return { completed, matches: [] };
440
+ return { completed: shortenPath(path.join(baseDir, matches[0]) + path.sep), matches: [] };
250
441
  }
251
442
 
252
443
  let common = matches[0];
@@ -257,29 +448,29 @@ function tabComplete(input) {
257
448
  }
258
449
 
259
450
  if (common.length > prefix.length) {
260
- const completed = path.join(baseDir, common).replace(os.homedir(), '~');
261
- return { completed, matches: matches.map(m => m + '/') };
451
+ return { completed: shortenPath(path.join(baseDir, common)), matches: matches.map(m => m + path.sep) };
262
452
  }
263
-
264
- return { completed: input, matches: matches.map(m => m + '/') };
453
+ return { completed: input, matches: matches.map(m => m + path.sep) };
265
454
  }
266
455
 
267
456
  // ─── Terminal detection & open ────────────────────────────────────────────────
268
457
 
269
- function detectTerminal() {
270
- const forced = process.env.CLAUDE_SESSIONS_TERMINAL;
271
- if (forced) return forced;
272
- if (process.env.KITTY_WINDOW_ID !== undefined) return 'kitty';
273
- if (process.env.WEZTERM_PANE !== undefined) return 'wezterm';
274
- if (process.env.TMUX) return 'tmux';
275
- if (process.env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
276
- return null;
458
+ function detectTerminal(env = process.env) {
459
+ if (env.CLAUDE_SESSIONS_TERMINAL) return env.CLAUDE_SESSIONS_TERMINAL;
460
+ if (env.KITTY_WINDOW_ID !== undefined) return 'kitty';
461
+ if (env.WEZTERM_PANE !== undefined) return 'wezterm';
462
+ if (env.TMUX) return 'tmux';
463
+ if (env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
464
+ if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
465
+ return 'here';
277
466
  }
278
467
 
279
468
  function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
469
+ function appleStr(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
280
470
 
281
471
  function trySpawn(cmd, args) {
282
472
  const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
473
+ if (r.error) return { ok: false, msg: `${cmd}: ${r.error.code === 'ENOENT' ? 'not found' : r.error.message}` };
283
474
  return r.status === 0
284
475
  ? { ok: true }
285
476
  : { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
@@ -287,12 +478,10 @@ function trySpawn(cmd, args) {
287
478
 
288
479
  function resolveProjectPath(session) {
289
480
  const sessionDir = path.dirname(session.jsonlPath);
290
- // Check hint file first (written at move time; survives restarts)
291
481
  try {
292
482
  const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
293
483
  if (hint && fs.existsSync(hint)) return hint;
294
484
  } catch {}
295
- // Fall back to a sibling session whose cwd resolves to an existing path
296
485
  try {
297
486
  const siblings = fs.readdirSync(sessionDir)
298
487
  .filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
@@ -304,49 +493,80 @@ function resolveProjectPath(session) {
304
493
  return fs.existsSync(session.project) ? session.project : os.homedir();
305
494
  }
306
495
 
307
- function launchInTerminal(shellCmd, title, projectPath) {
496
+ // A launch plan: where to run, which claude args, under which config dir.
497
+ function resumePlan(session) {
498
+ return {
499
+ cwd: resolveProjectPath(session),
500
+ args: ['-r', session.sessionId],
501
+ configDirPath: session.configDirPath,
502
+ title: session.title || session.sessionId.slice(0, 8),
503
+ };
504
+ }
505
+
506
+ function newPlan(configDirPath, projectPath) {
507
+ return { cwd: projectPath, args: [], configDirPath, title: path.basename(projectPath) };
508
+ }
509
+
510
+ function planShellCmd(plan) {
511
+ const claude = ['claude', ...plan.args].join(' ');
512
+ return `cd ${shellEsc(plan.cwd)} && CLAUDE_CONFIG_DIR=${shellEsc(plan.configDirPath)} ${claude}; exec "\${SHELL:-bash}"`;
513
+ }
514
+
515
+ // Returns { ok, msg } or { here: true } when the caller should run it in this terminal.
516
+ function launchInTab(plan) {
517
+ const shellCmd = planShellCmd(plan);
308
518
  const customTpl = process.env.CLAUDE_SESSIONS_OPEN_CMD;
309
519
  if (customTpl) {
310
520
  const expanded = customTpl
311
521
  .replace(/%cmd%/g, shellEsc(shellCmd))
312
- .replace(/%title%/g, shellEsc(title))
313
- .replace(/%project%/g, shellEsc(projectPath));
522
+ .replace(/%title%/g, shellEsc(plan.title))
523
+ .replace(/%project%/g, shellEsc(plan.cwd));
314
524
  const r = spawnSync('bash', ['-c', expanded], { stdio: 'ignore' });
315
525
  return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
316
526
  }
317
527
 
318
- const term = detectTerminal();
319
- switch (term) {
528
+ switch (detectTerminal()) {
320
529
  case 'kitty':
321
- return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${title}`, 'bash', '-i', '-c', shellCmd]);
530
+ return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${plan.title}`, 'bash', '-i', '-c', shellCmd]);
322
531
  case 'wezterm':
323
532
  return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
324
533
  case 'tmux':
325
- return trySpawn('tmux', ['new-window', '-n', title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
534
+ return trySpawn('tmux', ['new-window', '-n', plan.title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
535
+ case 'iterm2':
536
+ return trySpawn('osascript', ['-e',
537
+ `tell application "iTerm2" to tell current window to create tab with default profile command ${appleStr('bash -i -c ' + shellEsc(shellCmd))}`]);
538
+ case 'apple-terminal':
539
+ return trySpawn('osascript', ['-e', `tell application "Terminal" to do script ${appleStr(shellCmd)}`]);
326
540
  default:
327
- return { ok: false, msg: 'Terminal not supported. Set CLAUDE_SESSIONS_OPEN_CMD or CLAUDE_SESSIONS_TERMINAL.' };
541
+ return { here: true };
328
542
  }
329
543
  }
330
544
 
331
- function openSession(session) {
332
- const projectPath = resolveProjectPath(session);
333
- const title = session.title || session.sessionId.slice(0, 8);
334
- const resumeCmd = `CLAUDE_CONFIG_DIR=${shellEsc(session.configDirPath)} claude -r ${session.sessionId}`;
335
- const shellCmd = `cd ${shellEsc(projectPath)} && ${resumeCmd}; exec $SHELL`;
336
- return launchInTerminal(shellCmd, title, projectPath);
337
- }
338
-
339
- function openNewSession(configDirPath, projectPath) {
340
- const title = path.basename(projectPath);
341
- const cmd = `CLAUDE_CONFIG_DIR=${shellEsc(configDirPath)} claude`;
342
- const shellCmd = `cd ${shellEsc(projectPath)} && ${cmd}; exec $SHELL`;
343
- return launchInTerminal(shellCmd, title, projectPath);
545
+ // Runs claude in the current terminal and waits for it to exit.
546
+ function runHere(plan) {
547
+ const r = spawnSync('claude', plan.args, {
548
+ cwd: plan.cwd,
549
+ env: { ...process.env, CLAUDE_CONFIG_DIR: plan.configDirPath },
550
+ stdio: 'inherit',
551
+ shell: IS_WIN, // claude may be a .cmd shim on Windows
552
+ });
553
+ if (r.error) return { ok: false, msg: r.error.code === 'ENOENT' ? '`claude` not found on PATH' : r.error.message };
554
+ return { ok: true };
344
555
  }
345
556
 
346
557
  // ─── Utilities ────────────────────────────────────────────────────────────────
347
558
 
348
559
  function fmtDate(d) {
349
- return new Date(d).toLocaleString(undefined, { hour12: false });
560
+ const t = new Date(d), p = n => String(n).padStart(2, '0');
561
+ return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())}`;
562
+ }
563
+
564
+ function fmtSize(n) {
565
+ if (n < 1024) return `${n}B`;
566
+ const u = ['K', 'M', 'G'];
567
+ let i = -1;
568
+ do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
569
+ return `${n < 10 ? n.toFixed(1) : Math.round(n)}${u[i]}`;
350
570
  }
351
571
 
352
572
  function shortConfig(configDir) {
@@ -355,7 +575,19 @@ function shortConfig(configDir) {
355
575
 
356
576
  function shortenPath(p) {
357
577
  const home = os.homedir();
358
- return p.startsWith(home) ? '~' + p.slice(home.length) : p;
578
+ return p === home || p.startsWith(home + path.sep) || p.startsWith(home + '/') ? '~' + p.slice(home.length) : p;
579
+ }
580
+
581
+ const SORTS = ['date', 'size', 'name'];
582
+ function sortSessions(sessions, mode) {
583
+ const order = new Map();
584
+ for (const s of sessions) if (!order.has(s.configDir)) order.set(s.configDir, order.size);
585
+ const key = {
586
+ date: (a, b) => b.lastModified - a.lastModified,
587
+ size: (a, b) => b.size - a.size,
588
+ name: (a, b) => (a.title || a.firstMessage || '~').localeCompare(b.title || b.firstMessage || '~'),
589
+ }[mode];
590
+ return [...sessions].sort((a, b) => (order.get(a.configDir) - order.get(b.configDir)) || key(a, b));
359
591
  }
360
592
 
361
593
  // ─── Plain list output ────────────────────────────────────────────────────────
@@ -378,7 +610,7 @@ function printList(sessions) {
378
610
  const label = s.title
379
611
  ? ansi('1;32', s.title)
380
612
  : ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
381
- console.log(` ${label} ${ansi('2', fmtDate(s.lastModified))}`);
613
+ console.log(` ${label} ${ansi('2', `${fmtDate(s.lastModified)} ${fmtSize(s.size)}`)}`);
382
614
  console.log(ansi('2', ` id: ${s.sessionId}`));
383
615
  }
384
616
  }
@@ -402,6 +634,17 @@ const bgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
402
634
  const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
403
635
  const pad = (s, n) => s.padEnd(n).slice(0, n);
404
636
 
637
+ const HELP = [
638
+ ['↑↓ / j k', 'Navigate'], ['PgUp PgDn', 'Page up / down'],
639
+ ['g / G', 'Top / bottom'], ['/', 'Search titles, messages, paths'],
640
+ ['Esc', 'Clear search / marks'], ['Enter', 'Open (new tab if supported, else here)'],
641
+ ['o', 'Open in this terminal'], ['p', 'Toggle preview'],
642
+ ['r', 'Rename'], ['s', 'Sort: date / size / name'],
643
+ ['Space', 'Mark for bulk delete'], ['n', 'New session'],
644
+ ['m', 'Move to profile / folder'], ['d', 'Delete marked or selected (to trash)'],
645
+ ['q / Ctrl-C', 'Quit'],
646
+ ];
647
+
405
648
  // ─── Picker ───────────────────────────────────────────────────────────────────
406
649
  // Shared profile → folder → input flow used by both move and new modes.
407
650
  // step: 'profile' | 'folder' | 'input'
@@ -412,7 +655,7 @@ class Picker {
412
655
  this.items = [];
413
656
  this.selIdx = 0;
414
657
  this.scroll = 0;
415
- this.config = null; // config dir path chosen at the profile step
658
+ this.config = null;
416
659
  this.input = '';
417
660
  this.completions = [];
418
661
 
@@ -433,6 +676,8 @@ class Picker {
433
676
  this.config = null;
434
677
  this.input = '';
435
678
  this.completions = [];
679
+ // Skip the profile step when there is only one.
680
+ if (this.items.length === 1) this._enterFolderStep(this.items[0].configDirPath);
436
681
  }
437
682
 
438
683
  _enterFolderStep(configDirPath) {
@@ -443,7 +688,7 @@ class Picker {
443
688
  this.scroll = 0;
444
689
  }
445
690
 
446
- // Returns false when the picker should be dismissed (Esc at profile step).
691
+ // Returns false when the picker should be dismissed.
447
692
  handleKey(key) {
448
693
  if (this.step === 'input') {
449
694
  if (key === '\x1b') {
@@ -454,19 +699,15 @@ class Picker {
454
699
  const { completed, matches } = tabComplete(this.input || '~/');
455
700
  this.input = completed;
456
701
  this.completions = matches.length > 1 ? matches : [];
457
- } else if (key === '\x7f') {
702
+ } else if (key === '\x7f' || key === '\b') {
458
703
  this.input = this.input.slice(0, -1);
459
704
  this.completions = [];
460
705
  } else if (key === '\r') {
461
706
  const raw = this.input.trim();
462
707
  this.completions = [];
463
- if (raw) {
464
- const absPath = raw.replace(/^~/, os.homedir()).replace(/\/+$/, '');
465
- this._onConfirmCustom(absPath);
466
- } else {
467
- this.step = 'folder';
468
- }
469
- } else if (key.length === 1 && key >= ' ') {
708
+ if (raw) this._onConfirmCustom(path.resolve(expandHome(raw).replace(/[\\/]+$/, '') || path.sep));
709
+ else this.step = 'folder';
710
+ } else if (isPrintable(key)) {
470
711
  this.input += key;
471
712
  this.completions = [];
472
713
  }
@@ -474,15 +715,15 @@ class Picker {
474
715
  }
475
716
 
476
717
  if (key === '\x1b') {
477
- if (this.step === 'folder') {
718
+ const profiles = this._buildProfileItems();
719
+ if (this.step === 'folder' && profiles.length > 1) {
478
720
  this.step = 'profile';
479
- this.items = this._buildProfileItems();
721
+ this.items = profiles;
480
722
  this.selIdx = 0;
481
723
  this.scroll = 0;
482
- } else {
483
- return false; // cancel at profile step
724
+ return true;
484
725
  }
485
- return true;
726
+ return false;
486
727
  }
487
728
 
488
729
  if (key === '\x1b[A' || key === 'k') { this.selIdx = Math.max(0, this.selIdx - 1); return true; }
@@ -503,7 +744,7 @@ class Picker {
503
744
  out.push(mv(2, 1), clrLine, ` ${headerLabel}`);
504
745
 
505
746
  out.push(mv(3, 1), clrLine);
506
- const divider = dim('─'.repeat(Math.max(0, W - 2)));
747
+ const divider = dim('─'.repeat(Math.max(0, W - 30)));
507
748
  if (this.step === 'profile') {
508
749
  out.push(` ` + bold(this._profileLabel) + ` ` + divider);
509
750
  } else {
@@ -543,24 +784,61 @@ class Picker {
543
784
  : item.type === 'new' ? fgc('93', item.label)
544
785
  : item.type === 'profile' ? bold(fgc('96', item.label))
545
786
  : fgc('33', item.label);
546
- out.push(isSel ? inv(pad(` ${item.label}`, W)) : ` ${label}`);
787
+ out.push(isSel ? inv(pad(` ${trunc(item.label, W - 4)}`, W)) : ` ${label}`);
788
+ }
789
+ }
790
+ }
791
+
792
+ // Word-wraps text to `width` columns; words longer than a line are split.
793
+ function wrapWords(text, width) {
794
+ const lines = [];
795
+ let cur = '';
796
+ for (let word of text.split(' ')) {
797
+ while (word.length > width) {
798
+ if (cur) { lines.push(cur); cur = ''; }
799
+ lines.push(word.slice(0, width)); word = word.slice(width);
547
800
  }
801
+ if (!cur) cur = word;
802
+ else if (cur.length + 1 + word.length <= width) cur += ' ' + word;
803
+ else { lines.push(cur); cur = word; }
548
804
  }
805
+ if (cur || !lines.length) lines.push(cur);
806
+ return lines;
807
+ }
808
+
809
+ // A stdin chunk may hold several keys (fast typing, paste): split into single
810
+ // chars, keeping escape sequences like "\x1b[A" or "\x1b[5~" whole.
811
+ function splitKeys(chunk) {
812
+ const keys = [];
813
+ const re = /\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Za-z]|\x1b|[\s\S]/gu;
814
+ let m;
815
+ while ((m = re.exec(chunk))) keys.push(m[0]);
816
+ return keys;
817
+ }
818
+
819
+ function isPrintable(key) {
820
+ return key.length >= 1 && !key.startsWith('\x1b') && [...key].every(ch => ch >= ' ' && ch !== '\x7f');
549
821
  }
550
822
 
551
823
  // ─── TUI ─────────────────────────────────────────────────────────────────────
552
824
 
553
825
  class TUI {
554
- constructor(sessions, filterPath) {
826
+ constructor(sessions, opts = {}) {
555
827
  this.all = sessions;
556
- this.filterPath = filterPath || '';
828
+ this.opts = opts;
829
+ this.filterPath = opts.filterPath || '';
557
830
  this.search = '';
558
- this.mode = null; // null | 'search' | 'delete' | 'move' | 'new'
831
+ this.mode = null; // null | 'search' | 'delete' | 'move' | 'new' | 'rename' | 'help'
559
832
  this.statusMsg = '';
560
833
  this._statusTimer = null;
561
834
  this.selIdx = 0;
562
835
  this.scrollTop = 0;
563
836
  this.items = [];
837
+ this.sort = 'date';
838
+ this.marked = new Set(); // jsonlPath
839
+ this.preview = false;
840
+ this._previewCache = new Map();
841
+ this.renameInput = '';
564
842
  this.w = process.stdout.columns || 80;
565
843
  this.h = process.stdout.rows || 24;
566
844
 
@@ -569,7 +847,8 @@ class TUI {
569
847
  this.rebuild();
570
848
  }
571
849
 
572
- get listH() { return this.h - 2; }
850
+ get previewH() { return this.preview ? Math.max(4, Math.floor((this.h - 2) * 0.4)) : 0; }
851
+ get listH() { return this.h - 2 - this.previewH; }
573
852
 
574
853
  _createMovePicker() {
575
854
  return new Picker({
@@ -589,7 +868,7 @@ class TUI {
589
868
  }));
590
869
  },
591
870
  buildFolderItems: (configDirPath) => {
592
- const s = this.selectedSession();
871
+ const s = this.selectedSession();
593
872
  const isSameConfig = configDirPath === s?.configDirPath;
594
873
  const srcDirName = s ? path.basename(path.dirname(s.jsonlPath)) : null;
595
874
  const items = [];
@@ -603,7 +882,10 @@ class TUI {
603
882
  return items;
604
883
  },
605
884
  onConfirmItem: (item) => this._doMove(item.dirName),
606
- onConfirmCustom: (absPath) => this._doMove(encodePathToDir(absPath), absPath),
885
+ onConfirmCustom: (absPath) => {
886
+ try { this._doMove(projectDirNameFor(this.movePicker.config, absPath), absPath); }
887
+ catch (e) { this.mode = null; this.setStatus(`Move failed: ${e.message}`, 5000); }
888
+ },
607
889
  });
608
890
  }
609
891
 
@@ -618,7 +900,7 @@ class TUI {
618
900
  ...listProjectFolders(configDirPath).map(f => ({
619
901
  type: 'folder',
620
902
  label: f.displayPath,
621
- projectPath: f.displayPath.replace(/^~/, os.homedir()),
903
+ projectPath: expandHome(f.displayPath),
622
904
  })),
623
905
  { type: 'new', label: '[type a new path…]', projectPath: null },
624
906
  ],
@@ -640,7 +922,7 @@ class TUI {
640
922
 
641
923
  this.items = [];
642
924
  let lastConfig = null;
643
- for (const s of filtered) {
925
+ for (const s of sortSessions(filtered, this.sort)) {
644
926
  if (s.configDir !== lastConfig) {
645
927
  this.items.push({ type: 'config', label: s.configDir });
646
928
  lastConfig = s.configDir;
@@ -682,36 +964,89 @@ class TUI {
682
964
  }
683
965
 
684
966
  reloadAll() {
685
- this.all = discoverConfigDirs().flatMap(d => scanConfigDir(d, this.filterPath || null));
967
+ const keep = this.selectedSession()?.sessionId;
968
+ this.all = loadSessions(this.opts);
969
+ this._previewCache.clear();
970
+ this.rebuild();
971
+ this.selectById(keep);
972
+ }
973
+
974
+ selectById(sessionId) {
975
+ if (!sessionId) return;
976
+ const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
977
+ if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
686
978
  }
687
979
 
688
980
  _doMove(targetDirName, targetRawPath = null) {
689
981
  const s = this.selectedSession();
690
- if (!s) { this.mode = null; return; }
691
- const sessionId = s.sessionId;
692
- const targetConfig = this.movePicker.config;
982
+ this.mode = null;
983
+ if (!s) return;
984
+ const targetConfig = this.movePicker.config;
693
985
  try {
694
986
  moveSession(s, targetConfig, targetDirName, targetRawPath);
695
- this.mode = null;
696
987
  this.reloadAll();
697
- this.rebuild();
698
- const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
699
- if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
988
+ this.selectById(s.sessionId);
700
989
  this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
701
990
  } catch (e) {
702
- this.mode = null;
703
- this.setStatus(`Move failed: ${e.message}`, 4000);
991
+ this.setStatus(`Move failed: ${e.message}`, 5000);
704
992
  }
705
993
  }
706
994
 
995
+ _open(plan, forceHere = false) {
996
+ const r = forceHere ? { here: true } : launchInTab(plan);
997
+ if (r.here) {
998
+ if (!fs.existsSync(plan.cwd)) { this.setStatus(`Folder not found: ${plan.cwd}`, 5000); return; }
999
+ this.suspend();
1000
+ const res = runHere(plan);
1001
+ this.resume();
1002
+ if (!res.ok) this.setStatus(res.msg, 5000);
1003
+ return;
1004
+ }
1005
+ this.setStatus(r.ok ? `Opened "${plan.title}" in new tab` : r.msg, r.ok ? 2500 : 5000);
1006
+ }
1007
+
707
1008
  _doNew(projectPath) {
708
- if (!projectPath) return;
709
- const result = openNewSession(this.newPicker.config, projectPath);
710
1009
  this.mode = null;
711
- this.setStatus(
712
- result.ok ? `Opening new session in ${shortenPath(projectPath)}` : result.msg,
713
- result.ok ? 2500 : 5000
714
- );
1010
+ if (!projectPath) return;
1011
+ this._open(newPlan(this.newPicker.config, projectPath));
1012
+ }
1013
+
1014
+ _deleteTargets() {
1015
+ if (this.marked.size) return this.all.filter(s => this.marked.has(s.jsonlPath));
1016
+ const s = this.selectedSession();
1017
+ return s ? [s] : [];
1018
+ }
1019
+
1020
+ _doDelete() {
1021
+ const targets = this._deleteTargets();
1022
+ const failed = [];
1023
+ for (const s of targets) {
1024
+ try { deleteSessionFiles(s); } catch (e) { failed.push(e.message); }
1025
+ }
1026
+ const gone = new Set(targets.map(s => s.jsonlPath));
1027
+ this.all = this.all.filter(x => !gone.has(x.jsonlPath) || fs.existsSync(x.jsonlPath));
1028
+ this.marked.clear();
1029
+ this.rebuild();
1030
+ this.setStatus(failed.length
1031
+ ? `Delete failed for ${failed.length}: ${failed[0]}`
1032
+ : `Moved ${targets.length} session(s) to trash — run with --empty-trash to free space`, 4000);
1033
+ }
1034
+
1035
+ // ── Preview ─────────────────────────────────────────────────────────────────
1036
+
1037
+ _previewLines(s, W, H) {
1038
+ const key = s.jsonlPath + ':' + s.lastModified.valueOf();
1039
+ let msgs = this._previewCache.get(key);
1040
+ if (!msgs) { msgs = readRecentMessages(s.jsonlPath); this._previewCache.set(key, msgs); }
1041
+ const lines = [];
1042
+ for (const m of msgs) {
1043
+ const tag = m.role === 'user' ? fgc('92', 'you ') : fgc('96', 'cc ');
1044
+ const width = Math.max(10, W - 8);
1045
+ const chunks = wrapWords(m.text, width);
1046
+ chunks.slice(0, 3).forEach((c, i) => lines.push(` ${i === 0 ? tag : ' '} ${i === 2 && chunks.length > 3 ? trunc(c, width - 1) + '…' : c}`));
1047
+ }
1048
+ if (!lines.length) lines.push(dim(' (no messages)'));
1049
+ return lines.slice(-H);
715
1050
  }
716
1051
 
717
1052
  // ── Render ──────────────────────────────────────────────────────────────────
@@ -720,100 +1055,106 @@ class TUI {
720
1055
  const W = this.w, H = this.h;
721
1056
  const out = [hideCursor, altBuf, clrScreen, mv(1, 1)];
722
1057
 
723
- const filterInfo = this.filterPath ? ` path:${this.filterPath}` : '';
1058
+ const filterInfo = this.filterPath ? ` path:${shortenPath(this.filterPath)}` : '';
724
1059
  const searchInfo = this.search ? ` search:${this.search}` : '';
725
- out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}`, W), W)))));
1060
+ const sortInfo = this.sort !== 'date' ? ` sort:${this.sort}` : '';
1061
+ out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}${sortInfo}`, W), W)))));
726
1062
 
727
1063
  if (this.mode === 'move') {
728
1064
  this.movePicker.render(out, W, H);
729
1065
  } else if (this.mode === 'new') {
730
1066
  this.newPicker.render(out, W, H);
1067
+ } else if (this.mode === 'help') {
1068
+ HELP.forEach(([k, d], i) => out.push(mv(i + 3, 1), clrLine, ` ${fgc('93', pad(k, 14))}${d}`));
1069
+ out.push(mv(HELP.length + 4, 1), dim(' Env: CLAUDE_SESSIONS_TERMINAL, CLAUDE_SESSIONS_OPEN_CMD, CLAUDE_CONFIG_DIR'));
731
1070
  } else {
732
- // Session list
733
- const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
734
- let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
735
-
736
- for (let r = 0; r < this.listH; r++) {
737
- out.push(mv(r + 2, 1), clrLine);
738
- const item = visible[r];
739
- if (!item) continue;
740
-
741
- if (item.type === 'config') {
742
- out.push(bold(fgc('96', ` [${item.label}]`)));
743
- } else {
744
- const s = item.session;
745
- const isSel = si === this.selIdx;
746
- const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
747
- const project = shortenPath(s.project);
748
- const dateStr = fmtDate(s.lastModified);
749
- const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
750
- const dateW = dateStr.length;
751
- const avail = Math.max(12, W - 2 - cfgTag.length - 2 - 2 - dateW);
752
- const titleW = Math.floor(avail * 0.5);
753
- const projectW = avail - titleW;
754
-
755
- if (isSel) {
756
- const plain = ` ${cfgTag}${trunc(title, titleW).padEnd(titleW)} ${trunc(project, projectW).padEnd(projectW)} ${dateStr}`;
757
- out.push(inv(pad(plain, W)));
758
- } else {
759
- out.push(
760
- ` ${this.search ? fgc('96', cfgTag) : ''}` +
761
- (s.title ? fgc('92', pad(trunc(title, titleW), titleW)) : dim(pad(trunc(title, titleW), titleW))) +
762
- ` ${fgc('33', pad(trunc(project, projectW), projectW))}` +
763
- ` ${dim(dateStr)}`
764
- );
765
- }
766
- si++;
767
- }
768
- }
1071
+ this._renderList(out, W);
1072
+ if (this.preview) this._renderPreview(out, W);
769
1073
  }
770
1074
 
771
- // Footer
772
1075
  out.push(mv(H, 1));
773
- const sel = this.selectedSession();
774
- const total = this.sessionItems().length;
775
- let footer;
1076
+ out.push(bgc('40', fgc('37', pad(trunc(this._footer(W), W), W))));
1077
+ process.stdout.write(out.join(''));
1078
+ }
776
1079
 
777
- if (this.mode === 'delete') {
778
- const name = sel?.title || sel?.firstMessage?.slice(0, 35) || 'this session';
779
- footer = ` Delete "${trunc(name, 35)}"? y=yes n/Esc=cancel`;
780
- } else if (this.mode === 'move') {
781
- const step = this.movePicker.step;
782
- footer = step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
783
- : step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
784
- : ` ↑↓/jk=navigate Enter=select Esc=back to profiles`;
785
- } else if (this.mode === 'new') {
786
- const step = this.newPicker.step;
787
- footer = step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
788
- : step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
789
- : ` ↑↓/jk=navigate Enter=select folder Esc=back to profiles`;
790
- } else if (this.mode === 'search') {
791
- footer = ` SEARCH: ${this.search}█ Esc=cancel Enter=done`;
792
- } else if (this.statusMsg) {
793
- footer = ` ${this.statusMsg}`;
794
- } else {
795
- const ctx = sel ? ` ${dim(trunc(sel.project, W - 45))}` : '';
796
- footer = ` ↑↓/jk=nav /=search Enter=open n=new m=move d=del q=quit [${total}]${ctx}`;
1080
+ _renderList(out, W) {
1081
+ const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
1082
+ let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
1083
+
1084
+ for (let r = 0; r < this.listH; r++) {
1085
+ out.push(mv(r + 2, 1), clrLine);
1086
+ const item = visible[r];
1087
+ if (!item) continue;
1088
+
1089
+ if (item.type === 'config') {
1090
+ out.push(bold(fgc('96', ` [${item.label}]`)));
1091
+ continue;
1092
+ }
1093
+ const s = item.session;
1094
+ const isSel = si === this.selIdx;
1095
+ const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
1096
+ const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
1097
+ const project = shortenPath(s.project);
1098
+ const meta = `${fmtSize(s.size).padStart(5)} ${fmtDate(s.lastModified)}`;
1099
+ const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
1100
+ const avail = Math.max(12, W - 2 - cfgTag.length - 2 - 2 - meta.length);
1101
+ const titleW = Math.floor(avail * 0.55);
1102
+ const projectW = avail - titleW;
1103
+
1104
+ if (isSel) {
1105
+ out.push(inv(pad(`${mark} ${cfgTag}${pad(trunc(title, titleW), titleW)} ${pad(trunc(project, projectW), projectW)} ${meta}`, W)));
1106
+ } else {
1107
+ out.push(
1108
+ fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
1109
+ (s.title ? fgc('92', pad(trunc(title, titleW), titleW)) : dim(pad(trunc(title, titleW), titleW))) +
1110
+ ` ${fgc('33', pad(trunc(project, projectW), projectW))}` +
1111
+ ` ${dim(meta)}`
1112
+ );
1113
+ }
1114
+ si++;
797
1115
  }
1116
+ }
798
1117
 
799
- out.push(bgc('40', fgc('37', pad(trunc(footer, W), W))));
800
- process.stdout.write(out.join(''));
1118
+ _renderPreview(out, W) {
1119
+ const top = this.listH + 2;
1120
+ out.push(mv(top, 1), clrLine, dim('─'.repeat(W)));
1121
+ const s = this.selectedSession();
1122
+ const lines = s ? this._previewLines(s, W, this.previewH - 1) : [];
1123
+ for (let r = 0; r < this.previewH - 1; r++) out.push(mv(top + 1 + r, 1), clrLine, lines[r] || '');
1124
+ }
1125
+
1126
+ _footer(W) {
1127
+ const sel = this.selectedSession();
1128
+ const total = this.sessionItems().length;
1129
+ const pick = p => p.step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
1130
+ : p.step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
1131
+ : ` ↑↓/jk=navigate Enter=select Esc=back`;
1132
+ switch (this.mode) {
1133
+ case 'delete': {
1134
+ const t = this._deleteTargets();
1135
+ const name = t.length > 1 ? `${t.length} sessions` : `"${trunc(sel?.title || sel?.firstMessage || 'this session', 35)}"`;
1136
+ return ` Move ${name} to trash? y=yes n/Esc=cancel`;
1137
+ }
1138
+ case 'move': return pick(this.movePicker);
1139
+ case 'new': return pick(this.newPicker);
1140
+ case 'search': return ` SEARCH: ${this.search}█ Esc=cancel Enter=done`;
1141
+ case 'rename': return ` RENAME: ${this.renameInput}█ Enter=save Esc=cancel`;
1142
+ case 'help': return ` Press any key to close help`;
1143
+ }
1144
+ if (this.statusMsg) return ` ${this.statusMsg}`;
1145
+ const marks = this.marked.size ? ` ${this.marked.size} marked` : '';
1146
+ const ctx = sel ? ` ${dim(trunc(shortenPath(sel.project), Math.max(0, W - 80)))}` : '';
1147
+ return ` Enter=open o=here /=search p=preview r=rename m=move d=del ?=help q=quit [${total}]${marks}${ctx}`;
801
1148
  }
802
1149
 
803
1150
  // ── Input ───────────────────────────────────────────────────────────────────
804
1151
 
805
1152
  handleKey(key) {
1153
+ if (this.mode === 'help') { this.mode = null; return true; }
1154
+
806
1155
  if (this.mode === 'delete') {
807
1156
  this.mode = null;
808
- if (key === 'y' || key === 'Y') {
809
- const s = this.selectedSession();
810
- if (s) {
811
- deleteSessionFiles(s);
812
- this.all = this.all.filter(x => x.sessionId !== s.sessionId);
813
- this.setStatus(`Deleted: ${s.title || s.sessionId.slice(0, 8)}`);
814
- this.rebuild();
815
- }
816
- }
1157
+ if (key === 'y' || key === 'Y') this._doDelete();
817
1158
  return true;
818
1159
  }
819
1160
 
@@ -828,10 +1169,25 @@ class TUI {
828
1169
  }
829
1170
 
830
1171
  if (this.mode === 'search') {
831
- if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
832
- else if (key === '\r') { this.mode = null; }
833
- else if (key === '\x7f') { this.search = this.search.slice(0, -1); this.rebuild(); }
834
- else if (key.length === 1 && key >= ' ') { this.search += key; this.rebuild(); }
1172
+ if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
1173
+ else if (key === '\r') { this.mode = null; }
1174
+ else if (key === '\x7f' || key === '\b') { this.search = this.search.slice(0, -1); this.rebuild(); }
1175
+ else if (isPrintable(key)) { this.search += key; this.rebuild(); }
1176
+ return true;
1177
+ }
1178
+
1179
+ if (this.mode === 'rename') {
1180
+ if (key === '\x1b') { this.mode = null; }
1181
+ else if (key === '\x7f' || key === '\b') { this.renameInput = this.renameInput.slice(0, -1); }
1182
+ else if (key === '\r') {
1183
+ this.mode = null;
1184
+ const s = this.selectedSession();
1185
+ if (s && this.renameInput.trim()) {
1186
+ try { renameSession(s, this.renameInput); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
1187
+ catch (e) { this.setStatus(`Rename failed: ${e.message}`, 5000); }
1188
+ }
1189
+ }
1190
+ else if (isPrintable(key)) { this.renameInput += key; }
835
1191
  return true;
836
1192
  }
837
1193
 
@@ -841,25 +1197,25 @@ class TUI {
841
1197
  this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
842
1198
  this.ensureVisible();
843
1199
  };
1200
+ const s = this.selectedSession();
844
1201
 
845
1202
  if (key === 'q' || key === '\x03') return false;
846
1203
 
847
- if (key === '/') { this.mode = 'search'; }
848
- else if (key === '\x1b') { this.search = ''; this.rebuild(); }
849
- else if (key === '\r') {
850
- const s = this.selectedSession();
851
- if (s) {
852
- const result = openSession(s);
853
- this.setStatus(
854
- result.ok ? `Opened "${s.title || s.sessionId.slice(0, 8)}" in new tab` : result.msg,
855
- result.ok ? 2500 : 5000
856
- );
857
- }
1204
+ if (key === '/') { this.mode = 'search'; }
1205
+ else if (key === '?') { this.mode = 'help'; }
1206
+ else if (key === '\x1b') { this.search = ''; this.marked.clear(); this.rebuild(); }
1207
+ else if (key === '\r') { if (s) this._open(resumePlan(s)); }
1208
+ else if (key === 'o') { if (s) this._open(resumePlan(s), true); }
1209
+ else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
1210
+ else if (key === 's') { this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length]; this.rebuild(); this.selectById(s?.sessionId); }
1211
+ else if (key === 'r') { if (s) { this.renameInput = s.title || ''; this.mode = 'rename'; } }
1212
+ else if (key === ' ') {
1213
+ if (s) { this.marked.has(s.jsonlPath) ? this.marked.delete(s.jsonlPath) : this.marked.add(s.jsonlPath); move(+1); }
858
1214
  }
859
- else if (key === 'd') { if (this.selectedSession()) this.mode = 'delete'; }
860
- else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
1215
+ else if (key === 'd') { if (this._deleteTargets().length) this.mode = 'delete'; }
1216
+ else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
861
1217
  else if (key === 'm') {
862
- if (this.selectedSession()) { this.movePicker.enter(); this.mode = 'move'; }
1218
+ if (s) { this.movePicker.enter(); this.mode = 'move'; }
863
1219
  else this.setStatus('No session selected.');
864
1220
  }
865
1221
  else if (key === '\x1b[A' || key === 'k') move(-1);
@@ -872,6 +1228,21 @@ class TUI {
872
1228
  return true;
873
1229
  }
874
1230
 
1231
+ // Hand the terminal to a child process, then take it back.
1232
+ suspend() {
1233
+ process.stdin.removeListener('data', this._onData);
1234
+ process.stdin.setRawMode(false);
1235
+ process.stdin.pause();
1236
+ process.stdout.write(showCursor + normBuf);
1237
+ }
1238
+
1239
+ resume() {
1240
+ process.stdin.setRawMode(true);
1241
+ process.stdin.resume();
1242
+ process.stdin.on('data', this._onData);
1243
+ this.reloadAll();
1244
+ }
1245
+
875
1246
  cleanup() { process.stdout.write(showCursor + normBuf); }
876
1247
 
877
1248
  start() {
@@ -886,15 +1257,17 @@ class TUI {
886
1257
  this.render();
887
1258
  });
888
1259
 
889
- this.render();
890
-
891
- process.stdin.on('data', key => {
892
- if (this.handleKey(key) === false) {
893
- this.cleanup();
894
- process.exit(0);
1260
+ this._onData = chunk => {
1261
+ for (const key of splitKeys(chunk)) {
1262
+ if (this.handleKey(key) === false) {
1263
+ this.cleanup();
1264
+ process.exit(0);
1265
+ }
895
1266
  }
896
1267
  this.render();
897
- });
1268
+ };
1269
+ process.stdin.on('data', this._onData);
1270
+ this.render();
898
1271
 
899
1272
  process.on('exit', () => this.cleanup());
900
1273
  process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
@@ -913,21 +1286,32 @@ function main() {
913
1286
  const opts = parseArgs();
914
1287
  if (opts.version) { console.log(VERSION); return; }
915
1288
  if (opts.help) { printHelp(); return; }
1289
+ if (opts.noCache) cache.enabled = false;
1290
+
1291
+ if (opts.emptyTrash) {
1292
+ const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
1293
+ console.log(`Freed ${fmtSize(emptyTrash(dirs))}`);
1294
+ return;
1295
+ }
916
1296
 
917
1297
  const sessions = loadSessions(opts);
918
1298
 
919
1299
  if (opts.json) {
920
1300
  const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
921
1301
  console.log(JSON.stringify(out, null, 2));
922
- } else if (opts.list || !process.stdout.isTTY) {
1302
+ } else if (opts.list || !process.stdout.isTTY || !process.stdin.isTTY) {
923
1303
  printList(sessions);
924
1304
  } else {
925
- new TUI(sessions, opts.filterPath).start();
1305
+ new TUI(sessions, opts).start();
926
1306
  }
927
1307
  }
928
1308
 
929
1309
  if (require.main === module) {
930
1310
  main();
931
1311
  } else {
932
- module.exports = { encodePathToDir, tabComplete, resolveProjectPath, moveSession, readSession };
1312
+ module.exports = {
1313
+ encodePathToDir, projectDirNameFor, tabComplete, resolveProjectPath, moveSession, readSession,
1314
+ readRecentMessages, renameSession, deleteSessionFiles, emptyTrash, scanConfigDir, sortSessions,
1315
+ detectTerminal, parseArgs, fmtSize, splitKeys, wrapWords, cache,
1316
+ };
933
1317
  }