@vinh0809it/claude-sessions 0.1.0 → 0.3.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 +52 -36
  2. package/index.js +807 -310
  3. package/package.json +3 -3
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,31 @@ 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, platform = process.platform) {
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
+ // WSL also sees WT_SESSION, but there `wt` would open a Windows shell, not Linux.
466
+ if (env.WT_SESSION && platform === 'win32') return 'windows-terminal';
467
+ return 'here';
277
468
  }
278
469
 
279
470
  function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
471
+ function appleStr(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
280
472
 
281
473
  function trySpawn(cmd, args) {
282
474
  const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
475
+ if (r.error) return { ok: false, msg: `${cmd}: ${r.error.code === 'ENOENT' ? 'not found' : r.error.message}` };
283
476
  return r.status === 0
284
477
  ? { ok: true }
285
478
  : { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
@@ -287,12 +480,10 @@ function trySpawn(cmd, args) {
287
480
 
288
481
  function resolveProjectPath(session) {
289
482
  const sessionDir = path.dirname(session.jsonlPath);
290
- // Check hint file first (written at move time; survives restarts)
291
483
  try {
292
484
  const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
293
485
  if (hint && fs.existsSync(hint)) return hint;
294
486
  } catch {}
295
- // Fall back to a sibling session whose cwd resolves to an existing path
296
487
  try {
297
488
  const siblings = fs.readdirSync(sessionDir)
298
489
  .filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
@@ -304,49 +495,100 @@ function resolveProjectPath(session) {
304
495
  return fs.existsSync(session.project) ? session.project : os.homedir();
305
496
  }
306
497
 
307
- function launchInTerminal(shellCmd, title, projectPath) {
498
+ // A launch plan: where to run, which claude args, under which config dir.
499
+ function resumePlan(session) {
500
+ return {
501
+ cwd: resolveProjectPath(session),
502
+ args: ['-r', session.sessionId],
503
+ configDirPath: session.configDirPath,
504
+ title: session.title || session.sessionId.slice(0, 8),
505
+ };
506
+ }
507
+
508
+ function newPlan(configDirPath, projectPath) {
509
+ return { cwd: projectPath, args: [], configDirPath, title: path.basename(projectPath) };
510
+ }
511
+
512
+ function planShellCmd(plan) {
513
+ const claude = ['claude', ...plan.args].join(' ');
514
+ return `cd ${shellEsc(plan.cwd)} && CLAUDE_CONFIG_DIR=${shellEsc(plan.configDirPath)} ${claude}; exec "\${SHELL:-bash}"`;
515
+ }
516
+
517
+ // `wt new-tab` args. wt treats ';' as a command separator, so it is escaped.
518
+ // cmd's `set X=v&& …` takes everything up to && as the value (spaces allowed).
519
+ function wtArgs(plan) {
520
+ const semi = v => String(v).replace(/;/g, '\\;');
521
+ return ['-w', '0', 'nt', '--title', semi(plan.title), '-d', semi(plan.cwd),
522
+ 'cmd', '/k', semi(`set CLAUDE_CONFIG_DIR=${plan.configDirPath}&& claude ${plan.args.join(' ')}`.trim())];
523
+ }
524
+
525
+ // Returns { ok, msg } or { here: true } when the caller should run it in this terminal.
526
+ function launchInTab(plan) {
527
+ const shellCmd = planShellCmd(plan);
308
528
  const customTpl = process.env.CLAUDE_SESSIONS_OPEN_CMD;
309
529
  if (customTpl) {
310
530
  const expanded = customTpl
311
531
  .replace(/%cmd%/g, shellEsc(shellCmd))
312
- .replace(/%title%/g, shellEsc(title))
313
- .replace(/%project%/g, shellEsc(projectPath));
532
+ .replace(/%title%/g, shellEsc(plan.title))
533
+ .replace(/%project%/g, shellEsc(plan.cwd));
314
534
  const r = spawnSync('bash', ['-c', expanded], { stdio: 'ignore' });
315
535
  return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
316
536
  }
317
537
 
318
- const term = detectTerminal();
319
- switch (term) {
538
+ switch (detectTerminal()) {
320
539
  case 'kitty':
321
- return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${title}`, 'bash', '-i', '-c', shellCmd]);
540
+ return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${plan.title}`, 'bash', '-i', '-c', shellCmd]);
322
541
  case 'wezterm':
323
542
  return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
324
543
  case 'tmux':
325
- return trySpawn('tmux', ['new-window', '-n', title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
544
+ return trySpawn('tmux', ['new-window', '-n', plan.title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
545
+ case 'iterm2':
546
+ return trySpawn('osascript', ['-e',
547
+ `tell application "iTerm2" to tell current window to create tab with default profile command ${appleStr('bash -i -c ' + shellEsc(shellCmd))}`]);
548
+ case 'apple-terminal':
549
+ return trySpawn('osascript', ['-e', `tell application "Terminal" to do script ${appleStr(shellCmd)}`]);
550
+ case 'windows-terminal':
551
+ return trySpawn('wt', wtArgs(plan));
326
552
  default:
327
- return { ok: false, msg: 'Terminal not supported. Set CLAUDE_SESSIONS_OPEN_CMD or CLAUDE_SESSIONS_TERMINAL.' };
553
+ return { here: true };
328
554
  }
329
555
  }
330
556
 
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);
557
+ // Runs claude in the current terminal and waits for it to exit.
558
+ function runHere(plan) {
559
+ const r = spawnSync('claude', plan.args, {
560
+ cwd: plan.cwd,
561
+ env: { ...process.env, CLAUDE_CONFIG_DIR: plan.configDirPath },
562
+ stdio: 'inherit',
563
+ shell: IS_WIN, // claude may be a .cmd shim on Windows
564
+ });
565
+ if (r.error) return { ok: false, msg: r.error.code === 'ENOENT' ? '`claude` not found on PATH' : r.error.message };
566
+ return { ok: true };
344
567
  }
345
568
 
346
569
  // ─── Utilities ────────────────────────────────────────────────────────────────
347
570
 
348
571
  function fmtDate(d) {
349
- return new Date(d).toLocaleString(undefined, { hour12: false });
572
+ const t = new Date(d), p = n => String(n).padStart(2, '0');
573
+ return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())}`;
574
+ }
575
+
576
+ function fmtAgo(d, now = Date.now()) {
577
+ const m = Math.floor((now - new Date(d)) / 60000);
578
+ if (m < 1) return 'just now';
579
+ if (m < 60) return `${m}m ago`;
580
+ if (m < 1440) return `${Math.floor(m / 60)}h ago`;
581
+ if (m < 43200) return `${Math.floor(m / 1440)}d ago`;
582
+ if (m < 525600) return `${Math.floor(m / 43200)}mo ago`;
583
+ return `${Math.floor(m / 525600)}y ago`;
584
+ }
585
+
586
+ function fmtSize(n) {
587
+ if (n < 1024) return `${n}B`;
588
+ const u = ['K', 'M', 'G'];
589
+ let i = -1;
590
+ do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
591
+ return `${n < 10 ? n.toFixed(1) : Math.round(n)}${u[i]}`;
350
592
  }
351
593
 
352
594
  function shortConfig(configDir) {
@@ -355,12 +597,24 @@ function shortConfig(configDir) {
355
597
 
356
598
  function shortenPath(p) {
357
599
  const home = os.homedir();
358
- return p.startsWith(home) ? '~' + p.slice(home.length) : p;
600
+ return p === home || p.startsWith(home + path.sep) || p.startsWith(home + '/') ? '~' + p.slice(home.length) : p;
601
+ }
602
+
603
+ const SORTS = ['date', 'size', 'name'];
604
+ function sortSessions(sessions, mode) {
605
+ const order = new Map();
606
+ for (const s of sessions) if (!order.has(s.configDir)) order.set(s.configDir, order.size);
607
+ const key = {
608
+ date: (a, b) => b.lastModified - a.lastModified,
609
+ size: (a, b) => b.size - a.size,
610
+ name: (a, b) => (a.title || a.firstMessage || '~').localeCompare(b.title || b.firstMessage || '~'),
611
+ }[mode];
612
+ return [...sessions].sort((a, b) => (order.get(a.configDir) - order.get(b.configDir)) || key(a, b));
359
613
  }
360
614
 
361
615
  // ─── Plain list output ────────────────────────────────────────────────────────
362
616
 
363
- function ansi(code, s) { return process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s; }
617
+ function ansi(code, s) { return process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; }
364
618
 
365
619
  function printList(sessions) {
366
620
  if (!sessions.length) { console.log('No sessions found.'); return; }
@@ -376,9 +630,9 @@ function printList(sessions) {
376
630
  lastProject = s.project;
377
631
  }
378
632
  const label = s.title
379
- ? ansi('1;32', s.title)
633
+ ? groupTitle(s.title, '1;32', ansi)
380
634
  : ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
381
- console.log(` ${label} ${ansi('2', fmtDate(s.lastModified))}`);
635
+ console.log(` ${label} ${ansi('2', `${fmtDate(s.lastModified)} ${fmtSize(s.size)}`)}`);
382
636
  console.log(ansi('2', ` id: ${s.sessionId}`));
383
637
  }
384
638
  }
@@ -394,13 +648,89 @@ const altBuf = `${ESC}[?1049h`;
394
648
  const normBuf = `${ESC}[?1049l`;
395
649
 
396
650
  const mv = (r, c) => `${ESC}[${r};${c}H`;
397
- const bold = s => `${ESC}[1m${s}${ESC}[0m`;
398
- const dim = s => `${ESC}[2m${s}${ESC}[0m`;
399
- const inv = s => `${ESC}[7m${s}${ESC}[0m`;
400
- const fgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
401
- const bgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
651
+ // Each helper resets only its own attribute, so a row background survives nested
652
+ // colors. NO_COLOR (https://no-color.org) drops colors but keeps bold/dim/inverse.
653
+ const COLOR = !process.env.NO_COLOR;
654
+ const bold = s => `${ESC}[1m${s}${ESC}[22m`;
655
+ const dim = s => `${ESC}[2m${s}${ESC}[22m`;
656
+ const inv = s => `${ESC}[7m${s}${ESC}[27m`;
657
+ const fgc = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[22;39m` : s;
658
+ const bgc = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[49m` : s;
659
+ const SEL_BG = `${ESC}[48;5;236m`;
660
+ // Selected line: dark background + accent bar (inverse when colors are off).
661
+ // `styled` must color every visible part explicitly so it reads on light themes too.
662
+ const selLine = (styled, plain, W) => COLOR
663
+ ? `${SEL_BG}${fgc('96', '▌')}${styled}${ESC}[K${ESC}[0m`
664
+ : inv(pad(` ${plain}`, W));
665
+ const stripAnsi = s => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
666
+ // "Enter=open /=search" → key in bold, label dimmed.
667
+ const styleHints = s => s.replace(/(\S+)=(.+?)(?= |$)/g, (_, k, l) => `${bold(k)} ${dim(l)}`);
402
668
  const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
403
669
  const pad = (s, n) => s.padEnd(n).slice(0, n);
670
+ // "keycloak/fix-login" → group prefix in its own color so related sessions stand out
671
+ const groupTitle = (t, color, fmt = fgc) => {
672
+ const i = t.indexOf('/');
673
+ return i > 0 ? fmt('95', t.slice(0, i + 1)) + fmt(color, t.slice(i + 1)) : fmt(color, t);
674
+ };
675
+
676
+ const HELP = [
677
+ ['↑↓ / j k', 'Navigate'], ['PgUp PgDn', 'Page up / down'],
678
+ ['g / G', 'Top / bottom'], ['/', 'Search (↑↓ select, Enter open, Tab keep filter)'],
679
+ ['Esc', 'Clear search / marks'], ['Enter', 'Open (new tab if supported, else here)'],
680
+ ['o', 'Open in this terminal'], ['p', 'Toggle preview'],
681
+ ['r', 'Rename'], ['s', 'Sort: date / size / name'],
682
+ ['Space', 'Mark for bulk delete'], ['n', 'New session'],
683
+ ['m', 'Move to profile / folder'], ['d', 'Delete marked or selected (to trash)'],
684
+ ['q / Ctrl-C', 'Quit'],
685
+ ];
686
+
687
+ // ─── LineInput ───────────────────────────────────────────────────────────────
688
+ // One-line text field with a movable cursor: ←/→, Home/End, Ctrl-←/→ by word,
689
+ // Backspace/Delete at the cursor, Ctrl-U (clear to start), Ctrl-W (delete word).
690
+
691
+ const WORD_SEP = /[\s/\-_.]/;
692
+
693
+ class LineInput {
694
+ constructor(text = '') { this.set(text); }
695
+ set(text) { this.chars = [...text]; this.cur = this.chars.length; }
696
+ get text() { return this.chars.join(''); }
697
+ get before() { return this.chars.slice(0, this.cur).join(''); }
698
+
699
+ _wordLeft(i) {
700
+ while (i > 0 && WORD_SEP.test(this.chars[i - 1])) i--;
701
+ while (i > 0 && !WORD_SEP.test(this.chars[i - 1])) i--;
702
+ return i;
703
+ }
704
+ _wordRight(i) {
705
+ const n = this.chars.length;
706
+ while (i < n && WORD_SEP.test(this.chars[i])) i++;
707
+ while (i < n && !WORD_SEP.test(this.chars[i])) i++;
708
+ return i;
709
+ }
710
+
711
+ // Returns true when the key was an editing key.
712
+ handle(key) {
713
+ const c = this.chars;
714
+ switch (key) {
715
+ case '\x1b[D': case '\x02': this.cur = Math.max(0, this.cur - 1); break;
716
+ case '\x1b[C': case '\x06': this.cur = Math.min(c.length, this.cur + 1); break;
717
+ case '\x1b[H': case '\x1bOH': case '\x1b[1~': case '\x01': this.cur = 0; break;
718
+ case '\x1b[F': case '\x1bOF': case '\x1b[4~': case '\x05': this.cur = c.length; break;
719
+ case '\x1b[1;5D': case '\x1b[1;3D': this.cur = this._wordLeft(this.cur); break;
720
+ case '\x1b[1;5C': case '\x1b[1;3C': this.cur = this._wordRight(this.cur); break;
721
+ case '\x7f': case '\b': if (this.cur > 0) c.splice(--this.cur, 1); break;
722
+ case '\x1b[3~': c.splice(this.cur, 1); break;
723
+ case '\x15': c.splice(0, this.cur); this.cur = 0; break;
724
+ case '\x17': { const i = this._wordLeft(this.cur); c.splice(i, this.cur - i); this.cur = i; break; }
725
+ default:
726
+ if (!isPrintable(key)) return false;
727
+ const ins = [...key];
728
+ c.splice(this.cur, 0, ...ins);
729
+ this.cur += ins.length;
730
+ }
731
+ return true;
732
+ }
733
+ }
404
734
 
405
735
  // ─── Picker ───────────────────────────────────────────────────────────────────
406
736
  // Shared profile → folder → input flow used by both move and new modes.
@@ -412,8 +742,8 @@ class Picker {
412
742
  this.items = [];
413
743
  this.selIdx = 0;
414
744
  this.scroll = 0;
415
- this.config = null; // config dir path chosen at the profile step
416
- this.input = '';
745
+ this.config = null;
746
+ this.field = new LineInput();
417
747
  this.completions = [];
418
748
 
419
749
  this._headerLabel = headerLabel;
@@ -431,8 +761,10 @@ class Picker {
431
761
  this.selIdx = 0;
432
762
  this.scroll = 0;
433
763
  this.config = null;
434
- this.input = '';
764
+ this.field.set('');
435
765
  this.completions = [];
766
+ // Skip the profile step when there is only one.
767
+ if (this.items.length === 1) this._enterFolderStep(this.items[0].configDirPath);
436
768
  }
437
769
 
438
770
  _enterFolderStep(configDirPath) {
@@ -443,7 +775,7 @@ class Picker {
443
775
  this.scroll = 0;
444
776
  }
445
777
 
446
- // Returns false when the picker should be dismissed (Esc at profile step).
778
+ // Returns false when the picker should be dismissed.
447
779
  handleKey(key) {
448
780
  if (this.step === 'input') {
449
781
  if (key === '\x1b') {
@@ -451,38 +783,30 @@ class Picker {
451
783
  this.completions = [];
452
784
  this.items = this._buildFolderItems(this.config);
453
785
  } else if (key === '\t') {
454
- const { completed, matches } = tabComplete(this.input || '~/');
455
- this.input = completed;
786
+ const { completed, matches } = tabComplete(this.field.text || '~/');
787
+ this.field.set(completed);
456
788
  this.completions = matches.length > 1 ? matches : [];
457
- } else if (key === '\x7f') {
458
- this.input = this.input.slice(0, -1);
459
- this.completions = [];
460
789
  } else if (key === '\r') {
461
- const raw = this.input.trim();
790
+ const raw = this.field.text.trim();
462
791
  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 >= ' ') {
470
- this.input += key;
792
+ if (raw) this._onConfirmCustom(path.resolve(expandHome(raw).replace(/[\\/]+$/, '') || path.sep));
793
+ else this.step = 'folder';
794
+ } else if (this.field.handle(key)) {
471
795
  this.completions = [];
472
796
  }
473
797
  return true;
474
798
  }
475
799
 
476
800
  if (key === '\x1b') {
477
- if (this.step === 'folder') {
801
+ const profiles = this._buildProfileItems();
802
+ if (this.step === 'folder' && profiles.length > 1) {
478
803
  this.step = 'profile';
479
- this.items = this._buildProfileItems();
804
+ this.items = profiles;
480
805
  this.selIdx = 0;
481
806
  this.scroll = 0;
482
- } else {
483
- return false; // cancel at profile step
807
+ return true;
484
808
  }
485
- return true;
809
+ return false;
486
810
  }
487
811
 
488
812
  if (key === '\x1b[A' || key === 'k') { this.selIdx = Math.max(0, this.selIdx - 1); return true; }
@@ -492,7 +816,7 @@ class Picker {
492
816
  const item = this.items[this.selIdx];
493
817
  if (!item) return true;
494
818
  if (item.type === 'profile') { this._enterFolderStep(item.configDirPath); return true; }
495
- if (item.type === 'new') { this.step = 'input'; this.input = '~/'; this.completions = []; return true; }
819
+ if (item.type === 'new') { this.step = 'input'; this.field.set('~/'); this.completions = []; return true; }
496
820
  this._onConfirmItem(item);
497
821
  }
498
822
  return true;
@@ -503,7 +827,7 @@ class Picker {
503
827
  out.push(mv(2, 1), clrLine, ` ${headerLabel}`);
504
828
 
505
829
  out.push(mv(3, 1), clrLine);
506
- const divider = dim('─'.repeat(Math.max(0, W - 2)));
830
+ const divider = dim('─'.repeat(Math.max(0, W - 30)));
507
831
  if (this.step === 'profile') {
508
832
  out.push(` ` + bold(this._profileLabel) + ` ` + divider);
509
833
  } else {
@@ -512,7 +836,7 @@ class Picker {
512
836
 
513
837
  let listStart = 4;
514
838
  if (this.step === 'input') {
515
- out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.input}█`);
839
+ out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.field.text}`);
516
840
  out.push(mv(5, 1), clrLine);
517
841
  if (this.completions.length > 1) {
518
842
  const cols = Math.max(1, Math.floor((W - 2) / 22));
@@ -543,24 +867,62 @@ class Picker {
543
867
  : item.type === 'new' ? fgc('93', item.label)
544
868
  : item.type === 'profile' ? bold(fgc('96', item.label))
545
869
  : fgc('33', item.label);
546
- out.push(isSel ? inv(pad(` ${item.label}`, W)) : ` ${label}`);
870
+ out.push(isSel ? selLine(` ${COLOR ? label.replace(/\x1b\[2m/g, '') : label}`, ` ${trunc(item.label, W - 5)}`, W) : ` ${label}`);
547
871
  }
548
872
  }
549
873
  }
550
874
 
875
+ // Word-wraps text to `width` columns; words longer than a line are split.
876
+ function wrapWords(text, width) {
877
+ const lines = [];
878
+ let cur = '';
879
+ for (let word of text.split(' ')) {
880
+ while (word.length > width) {
881
+ if (cur) { lines.push(cur); cur = ''; }
882
+ lines.push(word.slice(0, width)); word = word.slice(width);
883
+ }
884
+ if (!cur) cur = word;
885
+ else if (cur.length + 1 + word.length <= width) cur += ' ' + word;
886
+ else { lines.push(cur); cur = word; }
887
+ }
888
+ if (cur || !lines.length) lines.push(cur);
889
+ return lines;
890
+ }
891
+
892
+ // A stdin chunk may hold several keys (fast typing, paste): split into single
893
+ // chars, keeping escape sequences like "\x1b[A" or "\x1b[5~" whole.
894
+ function splitKeys(chunk) {
895
+ const keys = [];
896
+ const re = /\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Za-z]|\x1b|[\s\S]/gu;
897
+ let m;
898
+ while ((m = re.exec(chunk))) keys.push(m[0]);
899
+ return keys;
900
+ }
901
+
902
+ function isPrintable(key) {
903
+ return key.length >= 1 && !key.startsWith('\x1b') && [...key].every(ch => ch >= ' ' && ch !== '\x7f');
904
+ }
905
+
551
906
  // ─── TUI ─────────────────────────────────────────────────────────────────────
552
907
 
553
908
  class TUI {
554
- constructor(sessions, filterPath) {
909
+ constructor(sessions, opts = {}) {
555
910
  this.all = sessions;
556
- this.filterPath = filterPath || '';
911
+ this.opts = opts;
912
+ this.filterPath = opts.filterPath || '';
557
913
  this.search = '';
558
- this.mode = null; // null | 'search' | 'delete' | 'move' | 'new'
914
+ this.mode = null; // null | 'search' | 'delete' | 'move' | 'new' | 'rename' | 'help'
559
915
  this.statusMsg = '';
560
916
  this._statusTimer = null;
561
917
  this.selIdx = 0;
562
918
  this.scrollTop = 0;
563
919
  this.items = [];
920
+ this.sort = 'date';
921
+ this.marked = new Set(); // jsonlPath
922
+ this.preview = false;
923
+ this._previewCache = new Map();
924
+ this.renameField = new LineInput();
925
+ this.searchField = new LineInput();
564
926
  this.w = process.stdout.columns || 80;
565
927
  this.h = process.stdout.rows || 24;
566
928
 
@@ -569,7 +931,8 @@ class TUI {
569
931
  this.rebuild();
570
932
  }
571
933
 
572
- get listH() { return this.h - 2; }
934
+ get previewH() { return this.preview ? Math.max(4, Math.floor((this.h - 2) * 0.4)) : 0; }
935
+ get listH() { return this.h - 2 - this.previewH; }
573
936
 
574
937
  _createMovePicker() {
575
938
  return new Picker({
@@ -589,7 +952,7 @@ class TUI {
589
952
  }));
590
953
  },
591
954
  buildFolderItems: (configDirPath) => {
592
- const s = this.selectedSession();
955
+ const s = this.selectedSession();
593
956
  const isSameConfig = configDirPath === s?.configDirPath;
594
957
  const srcDirName = s ? path.basename(path.dirname(s.jsonlPath)) : null;
595
958
  const items = [];
@@ -603,7 +966,10 @@ class TUI {
603
966
  return items;
604
967
  },
605
968
  onConfirmItem: (item) => this._doMove(item.dirName),
606
- onConfirmCustom: (absPath) => this._doMove(encodePathToDir(absPath), absPath),
969
+ onConfirmCustom: (absPath) => {
970
+ try { this._doMove(projectDirNameFor(this.movePicker.config, absPath), absPath); }
971
+ catch (e) { this.mode = null; this.setStatus(`Move failed: ${e.message}`, 5000); }
972
+ },
607
973
  });
608
974
  }
609
975
 
@@ -618,7 +984,7 @@ class TUI {
618
984
  ...listProjectFolders(configDirPath).map(f => ({
619
985
  type: 'folder',
620
986
  label: f.displayPath,
621
- projectPath: f.displayPath.replace(/^~/, os.homedir()),
987
+ projectPath: expandHome(f.displayPath),
622
988
  })),
623
989
  { type: 'new', label: '[type a new path…]', projectPath: null },
624
990
  ],
@@ -639,12 +1005,13 @@ class TUI {
639
1005
  : this.all;
640
1006
 
641
1007
  this.items = [];
642
- let lastConfig = null;
643
- for (const s of filtered) {
644
- if (s.configDir !== lastConfig) {
645
- this.items.push({ type: 'config', label: s.configDir });
646
- lastConfig = s.configDir;
1008
+ let group = null;
1009
+ for (const s of sortSessions(filtered, this.sort)) {
1010
+ if (s.configDir !== group?.label) {
1011
+ group = { type: 'config', label: s.configDir, count: 0 };
1012
+ this.items.push(group);
647
1013
  }
1014
+ group.count++;
648
1015
  this.items.push({ type: 'session', session: s });
649
1016
  }
650
1017
 
@@ -682,36 +1049,90 @@ class TUI {
682
1049
  }
683
1050
 
684
1051
  reloadAll() {
685
- this.all = discoverConfigDirs().flatMap(d => scanConfigDir(d, this.filterPath || null));
1052
+ const keep = this.selectedSession()?.sessionId;
1053
+ this.all = loadSessions(this.opts);
1054
+ this._previewCache.clear();
1055
+ this.rebuild();
1056
+ this.selectById(keep);
1057
+ }
1058
+
1059
+ selectById(sessionId) {
1060
+ if (!sessionId) return;
1061
+ const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
1062
+ if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
686
1063
  }
687
1064
 
688
1065
  _doMove(targetDirName, targetRawPath = null) {
689
1066
  const s = this.selectedSession();
690
- if (!s) { this.mode = null; return; }
691
- const sessionId = s.sessionId;
692
- const targetConfig = this.movePicker.config;
1067
+ this.mode = null;
1068
+ if (!s) return;
1069
+ const targetConfig = this.movePicker.config;
693
1070
  try {
694
1071
  moveSession(s, targetConfig, targetDirName, targetRawPath);
695
- this.mode = null;
696
1072
  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(); }
1073
+ this.selectById(s.sessionId);
700
1074
  this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
701
1075
  } catch (e) {
702
- this.mode = null;
703
- this.setStatus(`Move failed: ${e.message}`, 4000);
1076
+ this.setStatus(`Move failed: ${e.message}`, 5000);
704
1077
  }
705
1078
  }
706
1079
 
1080
+ _open(plan, forceHere = false) {
1081
+ const r = forceHere ? { here: true } : launchInTab(plan);
1082
+ if (r.here) {
1083
+ if (!fs.existsSync(plan.cwd)) { this.setStatus(`Folder not found: ${plan.cwd}`, 5000); return; }
1084
+ this.suspend();
1085
+ const res = runHere(plan);
1086
+ this.resume();
1087
+ if (!res.ok) this.setStatus(res.msg, 5000);
1088
+ return;
1089
+ }
1090
+ this.setStatus(r.ok ? `Opened "${plan.title}" in new tab` : r.msg, r.ok ? 2500 : 5000);
1091
+ }
1092
+
707
1093
  _doNew(projectPath) {
708
- if (!projectPath) return;
709
- const result = openNewSession(this.newPicker.config, projectPath);
710
1094
  this.mode = null;
711
- this.setStatus(
712
- result.ok ? `Opening new session in ${shortenPath(projectPath)}` : result.msg,
713
- result.ok ? 2500 : 5000
714
- );
1095
+ if (!projectPath) return;
1096
+ this._open(newPlan(this.newPicker.config, projectPath));
1097
+ }
1098
+
1099
+ _deleteTargets() {
1100
+ if (this.marked.size) return this.all.filter(s => this.marked.has(s.jsonlPath));
1101
+ const s = this.selectedSession();
1102
+ return s ? [s] : [];
1103
+ }
1104
+
1105
+ _doDelete() {
1106
+ const targets = this._deleteTargets();
1107
+ const failed = [];
1108
+ for (const s of targets) {
1109
+ try { deleteSessionFiles(s); } catch (e) { failed.push(e.message); }
1110
+ }
1111
+ const gone = new Set(targets.map(s => s.jsonlPath));
1112
+ this.all = this.all.filter(x => !gone.has(x.jsonlPath) || fs.existsSync(x.jsonlPath));
1113
+ this.marked.clear();
1114
+ this.rebuild();
1115
+ this.setStatus(failed.length
1116
+ ? `Delete failed for ${failed.length}: ${failed[0]}`
1117
+ : `Moved ${targets.length} session(s) to trash — run with --empty-trash to free space`, 4000);
1118
+ }
1119
+
1120
+ // ── Preview ─────────────────────────────────────────────────────────────────
1121
+
1122
+ _previewLines(s, W, H) {
1123
+ const key = s.jsonlPath + ':' + s.lastModified.valueOf();
1124
+ let msgs = this._previewCache.get(key);
1125
+ if (!msgs) { msgs = readRecentMessages(s.jsonlPath); this._previewCache.set(key, msgs); }
1126
+ const lines = [];
1127
+ for (const m of msgs) {
1128
+ const tag = m.role === 'user' ? fgc('92', 'you ') : fgc('96', 'cc ');
1129
+ const width = Math.max(10, W - 8);
1130
+ const chunks = wrapWords(m.text, width);
1131
+ chunks.slice(0, 3).forEach((c, i) => lines.push(` ${i === 0 ? tag : ' '} ${i === 2 && chunks.length > 3 ? trunc(c, width - 1) + '…' : c}`));
1132
+ }
1133
+ if (!lines.length) lines.push(dim(' (no messages)'));
1134
+ const head = dim(` ${fmtDate(s.lastModified)} · ${fmtSize(s.size)} · ${s.sessionId}`);
1135
+ return [head, ...lines.slice(-(H - 1))];
715
1136
  }
716
1137
 
717
1138
  // ── Render ──────────────────────────────────────────────────────────────────
@@ -720,100 +1141,125 @@ class TUI {
720
1141
  const W = this.w, H = this.h;
721
1142
  const out = [hideCursor, altBuf, clrScreen, mv(1, 1)];
722
1143
 
723
- const filterInfo = this.filterPath ? ` path:${this.filterPath}` : '';
1144
+ const filterInfo = this.filterPath ? ` path:${shortenPath(this.filterPath)}` : '';
724
1145
  const searchInfo = this.search ? ` search:${this.search}` : '';
725
- out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}`, W), W)))));
1146
+ const sortInfo = this.sort !== 'date' ? ` sort:${this.sort}` : '';
1147
+ out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}${sortInfo}`, W), W)))));
726
1148
 
727
1149
  if (this.mode === 'move') {
728
1150
  this.movePicker.render(out, W, H);
729
1151
  } else if (this.mode === 'new') {
730
1152
  this.newPicker.render(out, W, H);
1153
+ } else if (this.mode === 'help') {
1154
+ HELP.forEach(([k, d], i) => out.push(mv(i + 3, 1), clrLine, ` ${fgc('93', pad(k, 14))}${d}`));
1155
+ out.push(mv(HELP.length + 4, 1), dim(' Env: CLAUDE_SESSIONS_TERMINAL, CLAUDE_SESSIONS_OPEN_CMD, CLAUDE_CONFIG_DIR'));
731
1156
  } 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
- }
1157
+ this._renderList(out, W);
1158
+ if (this.preview) this._renderPreview(out, W);
769
1159
  }
770
1160
 
771
- // Footer
772
1161
  out.push(mv(H, 1));
773
- const sel = this.selectedSession();
774
- const total = this.sessionItems().length;
775
- let footer;
1162
+ const foot = trunc(this._footer(W), W);
1163
+ out.push(clrLine, this._footerIsStatus ? fgc('93', foot) : styleHints(foot)
1164
+ .replace(/^ (SEARCH|RENAME):/, (_, l) => ` ${bold(fgc('93', l + ':'))}`));
1165
+ const cur = this._cursorPos();
1166
+ if (cur) out.push(mv(cur[0], Math.min(W, cur[1])), showCursor);
1167
+ process.stdout.write(out.join(''));
1168
+ }
776
1169
 
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}`;
1170
+ _renderList(out, W) {
1171
+ const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
1172
+ let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
1173
+
1174
+ for (let r = 0; r < this.listH; r++) {
1175
+ out.push(mv(r + 2, 1), clrLine);
1176
+ const item = visible[r];
1177
+ if (!item) continue;
1178
+
1179
+ if (item.type === 'config') {
1180
+ const count = `· ${item.count} session${item.count === 1 ? '' : 's'}`;
1181
+ const rule = '─'.repeat(Math.max(0, W - item.label.length - count.length - 6));
1182
+ out.push(dim('── ') + bold(fgc('96', item.label)) + ' ' + dim(`${count} ${rule}`));
1183
+ continue;
1184
+ }
1185
+ const s = item.session;
1186
+ const isSel = si === this.selIdx;
1187
+ const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
1188
+ const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
1189
+ const project = shortenPath(s.project);
1190
+ const meta = `${fmtSize(s.size).padStart(5)} ${fmtAgo(s.lastModified).padStart(8)}`;
1191
+ const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
1192
+ const avail = Math.max(12, W - 3 - cfgTag.length - 4 - meta.length - 1);
1193
+ const titleW = Math.floor(avail * 0.55);
1194
+ const projectW = avail - titleW;
1195
+ const titleTxt = pad(trunc(title, titleW), titleW);
1196
+ const projTxt = pad(trunc(project, projectW), projectW);
1197
+ // On the selected line every part gets an explicit color (see selLine).
1198
+ const muted = t => isSel ? fgc('37', t) : dim(t);
1199
+ const styled = fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
1200
+ (s.title ? groupTitle(titleTxt, isSel ? '1;92' : '92') : muted(titleTxt)) +
1201
+ ` ${fgc('33', projTxt)} ${muted(meta)}`;
1202
+
1203
+ out.push(isSel ? selLine(styled, `${mark} ${cfgTag}${titleTxt} ${projTxt} ${meta}`, W) : ` ${styled}`);
1204
+ si++;
797
1205
  }
1206
+ }
798
1207
 
799
- out.push(bgc('40', fgc('37', pad(trunc(footer, W), W))));
800
- process.stdout.write(out.join(''));
1208
+ _renderPreview(out, W) {
1209
+ const top = this.listH + 2;
1210
+ out.push(mv(top, 1), clrLine, dim('─'.repeat(W)));
1211
+ const s = this.selectedSession();
1212
+ const lines = s ? this._previewLines(s, W, this.previewH - 1) : [];
1213
+ for (let r = 0; r < this.previewH - 1; r++) out.push(mv(top + 1 + r, 1), clrLine, lines[r] || '');
1214
+ }
1215
+
1216
+ // Where to show the terminal cursor when a text field is active, else null.
1217
+ _cursorPos() {
1218
+ const at = (row, prefix, f) => [row, prefix.length + [...f.before].length + 1];
1219
+ if (this.mode === 'search') return at(this.h, ' SEARCH: ', this.searchField);
1220
+ if (this.mode === 'rename') return at(this.h, ' RENAME: ', this.renameField);
1221
+ const p = this.mode === 'move' ? this.movePicker : this.mode === 'new' ? this.newPicker : null;
1222
+ if (p && p.step === 'input') return at(4, ` ${p._inputPrompt} `, p.field);
1223
+ return null;
1224
+ }
1225
+
1226
+ _footer(W) {
1227
+ this._footerIsStatus = false;
1228
+ const sel = this.selectedSession();
1229
+ const total = this.sessionItems().length;
1230
+ const pick = p => p.step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
1231
+ : p.step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
1232
+ : ` ↑↓/jk=navigate Enter=select Esc=back`;
1233
+ switch (this.mode) {
1234
+ case 'delete': {
1235
+ const t = this._deleteTargets();
1236
+ const name = t.length > 1 ? `${t.length} sessions` : `"${trunc(sel?.title || sel?.firstMessage || 'this session', 35)}"`;
1237
+ return ` Move ${name} to trash? y=yes n/Esc=cancel`;
1238
+ }
1239
+ case 'move': return pick(this.movePicker);
1240
+ case 'new': return pick(this.newPicker);
1241
+ case 'search': return ` SEARCH: ${this.searchField.text} ↑↓=select Enter=open Tab=keep filter Esc=cancel`;
1242
+ case 'rename': return ` RENAME: ${this.renameField.text} Enter=save Esc=cancel`;
1243
+ case 'help': return ` Press any key to close help`;
1244
+ }
1245
+ if (this.statusMsg) { this._footerIsStatus = true; return ` ${this.statusMsg}`; }
1246
+ const right = `${total} session${total === 1 ? '' : 's'}${this.marked.size ? ` · ${this.marked.size} marked` : ''} `;
1247
+ let left = '';
1248
+ for (const h of ['Enter=open', '/=search', 'p=preview', 'r=rename', 'm=move', 'd=delete', '?=more', 'q=quit']) {
1249
+ if (`${left} ${h}`.length + right.length + 2 > W) break;
1250
+ left += ` ${h}`;
1251
+ }
1252
+ return left + ' '.repeat(Math.max(2, W - left.length - right.length)) + right;
801
1253
  }
802
1254
 
803
1255
  // ── Input ───────────────────────────────────────────────────────────────────
804
1256
 
805
1257
  handleKey(key) {
1258
+ if (this.mode === 'help') { this.mode = null; return true; }
1259
+
806
1260
  if (this.mode === 'delete') {
807
1261
  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
- }
1262
+ if (key === 'y' || key === 'Y') this._doDelete();
817
1263
  return true;
818
1264
  }
819
1265
 
@@ -828,10 +1274,33 @@ class TUI {
828
1274
  }
829
1275
 
830
1276
  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(); }
1277
+ if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
1278
+ else if (key === '\t') { this.mode = null; }
1279
+ else if (key === '\r') { this.mode = null; const s = this.selectedSession(); if (s) this._open(resumePlan(s)); }
1280
+ else if (key === '\x1b[A' || key === '\x1b[B' || key === '\x1b[5~' || key === '\x1b[6~') {
1281
+ const total = this.sessionItems().length;
1282
+ const delta = { '\x1b[A': -1, '\x1b[B': 1, '\x1b[5~': -this.listH, '\x1b[6~': this.listH }[key];
1283
+ this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
1284
+ this.ensureVisible();
1285
+ }
1286
+ else if (this.searchField.handle(key) && this.searchField.text !== this.search) {
1287
+ this.search = this.searchField.text; this.rebuild();
1288
+ }
1289
+ return true;
1290
+ }
1291
+
1292
+ if (this.mode === 'rename') {
1293
+ if (key === '\x1b') { this.mode = null; }
1294
+ else if (key === '\r') {
1295
+ this.mode = null;
1296
+ const s = this.selectedSession();
1297
+ const name = this.renameField.text;
1298
+ if (s && name.trim()) {
1299
+ try { renameSession(s, name); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
1300
+ catch (e) { this.setStatus(`Rename failed: ${e.message}`, 5000); }
1301
+ }
1302
+ }
1303
+ else this.renameField.handle(key);
835
1304
  return true;
836
1305
  }
837
1306
 
@@ -841,25 +1310,25 @@ class TUI {
841
1310
  this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
842
1311
  this.ensureVisible();
843
1312
  };
1313
+ const s = this.selectedSession();
844
1314
 
845
1315
  if (key === 'q' || key === '\x03') return false;
846
1316
 
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
- }
1317
+ if (key === '/') { this.searchField.set(this.search); this.mode = 'search'; }
1318
+ else if (key === '?') { this.mode = 'help'; }
1319
+ else if (key === '\x1b') { this.search = ''; this.marked.clear(); this.rebuild(); }
1320
+ else if (key === '\r') { if (s) this._open(resumePlan(s)); }
1321
+ else if (key === 'o') { if (s) this._open(resumePlan(s), true); }
1322
+ else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
1323
+ else if (key === 's') { this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length]; this.rebuild(); this.selectById(s?.sessionId); }
1324
+ else if (key === 'r') { if (s) { this.renameField.set(s.title || ''); this.mode = 'rename'; } }
1325
+ else if (key === ' ') {
1326
+ if (s) { this.marked.has(s.jsonlPath) ? this.marked.delete(s.jsonlPath) : this.marked.add(s.jsonlPath); move(+1); }
858
1327
  }
859
- else if (key === 'd') { if (this.selectedSession()) this.mode = 'delete'; }
860
- else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
1328
+ else if (key === 'd') { if (this._deleteTargets().length) this.mode = 'delete'; }
1329
+ else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
861
1330
  else if (key === 'm') {
862
- if (this.selectedSession()) { this.movePicker.enter(); this.mode = 'move'; }
1331
+ if (s) { this.movePicker.enter(); this.mode = 'move'; }
863
1332
  else this.setStatus('No session selected.');
864
1333
  }
865
1334
  else if (key === '\x1b[A' || key === 'k') move(-1);
@@ -872,6 +1341,21 @@ class TUI {
872
1341
  return true;
873
1342
  }
874
1343
 
1344
+ // Hand the terminal to a child process, then take it back.
1345
+ suspend() {
1346
+ process.stdin.removeListener('data', this._onData);
1347
+ process.stdin.setRawMode(false);
1348
+ process.stdin.pause();
1349
+ process.stdout.write(showCursor + normBuf);
1350
+ }
1351
+
1352
+ resume() {
1353
+ process.stdin.setRawMode(true);
1354
+ process.stdin.resume();
1355
+ process.stdin.on('data', this._onData);
1356
+ this.reloadAll();
1357
+ }
1358
+
875
1359
  cleanup() { process.stdout.write(showCursor + normBuf); }
876
1360
 
877
1361
  start() {
@@ -886,15 +1370,17 @@ class TUI {
886
1370
  this.render();
887
1371
  });
888
1372
 
889
- this.render();
890
-
891
- process.stdin.on('data', key => {
892
- if (this.handleKey(key) === false) {
893
- this.cleanup();
894
- process.exit(0);
1373
+ this._onData = chunk => {
1374
+ for (const key of splitKeys(chunk)) {
1375
+ if (this.handleKey(key) === false) {
1376
+ this.cleanup();
1377
+ process.exit(0);
1378
+ }
895
1379
  }
896
1380
  this.render();
897
- });
1381
+ };
1382
+ process.stdin.on('data', this._onData);
1383
+ this.render();
898
1384
 
899
1385
  process.on('exit', () => this.cleanup());
900
1386
  process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
@@ -913,21 +1399,32 @@ function main() {
913
1399
  const opts = parseArgs();
914
1400
  if (opts.version) { console.log(VERSION); return; }
915
1401
  if (opts.help) { printHelp(); return; }
1402
+ if (opts.noCache) cache.enabled = false;
1403
+
1404
+ if (opts.emptyTrash) {
1405
+ const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
1406
+ console.log(`Freed ${fmtSize(emptyTrash(dirs))}`);
1407
+ return;
1408
+ }
916
1409
 
917
1410
  const sessions = loadSessions(opts);
918
1411
 
919
1412
  if (opts.json) {
920
1413
  const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
921
1414
  console.log(JSON.stringify(out, null, 2));
922
- } else if (opts.list || !process.stdout.isTTY) {
1415
+ } else if (opts.list || !process.stdout.isTTY || !process.stdin.isTTY) {
923
1416
  printList(sessions);
924
1417
  } else {
925
- new TUI(sessions, opts.filterPath).start();
1418
+ new TUI(sessions, opts).start();
926
1419
  }
927
1420
  }
928
1421
 
929
1422
  if (require.main === module) {
930
1423
  main();
931
1424
  } else {
932
- module.exports = { encodePathToDir, tabComplete, resolveProjectPath, moveSession, readSession };
1425
+ module.exports = {
1426
+ encodePathToDir, projectDirNameFor, tabComplete, resolveProjectPath, moveSession, readSession,
1427
+ readRecentMessages, renameSession, deleteSessionFiles, emptyTrash, scanConfigDir, sortSessions,
1428
+ detectTerminal, wtArgs, parseArgs, fmtAgo, fmtSize, splitKeys, wrapWords, cache, LineInput,
1429
+ };
933
1430
  }