@vinh0809it/claude-sessions 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/bin/claude-sessions.js +4 -0
- package/lib/ansi.js +66 -0
- package/lib/cli.js +63 -0
- package/lib/config.js +93 -0
- package/lib/format.js +45 -0
- package/lib/launch.js +164 -0
- package/lib/operations.js +137 -0
- package/lib/paths.js +86 -0
- package/lib/sessions.js +236 -0
- package/lib/tui/app.js +525 -0
- package/lib/tui/keys.js +45 -0
- package/lib/tui/line-input.js +63 -0
- package/lib/tui/picker.js +166 -0
- package/package.json +5 -4
- package/index.js +0 -1415
package/index.js
DELETED
|
@@ -1,1415 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* claude-sessions — Terminal UI for Claude Code sessions
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* claude-sessions [folder] TUI, optionally filtered by folder path
|
|
9
|
-
* claude-sessions --list [folder] Plain list output
|
|
10
|
-
* claude-sessions --named-only Only /rename'd sessions
|
|
11
|
-
* claude-sessions --json JSON output
|
|
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
|
|
15
|
-
* claude-sessions --version Print version
|
|
16
|
-
* claude-sessions -h Help
|
|
17
|
-
*
|
|
18
|
-
* TUI keys:
|
|
19
|
-
* ↑ ↓ j k Navigate
|
|
20
|
-
* PgUp PgDn Page up / down
|
|
21
|
-
* g G Jump to top / bottom
|
|
22
|
-
* / Search (shows [config] project inline)
|
|
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)
|
|
30
|
-
* n New session
|
|
31
|
-
* m Move session to another config / folder
|
|
32
|
-
* d Delete session(s) to trash
|
|
33
|
-
* ? Help
|
|
34
|
-
* q Ctrl-C Quit
|
|
35
|
-
*
|
|
36
|
-
* Environment variables:
|
|
37
|
-
* CLAUDE_SESSIONS_TERMINAL Force terminal: kitty | wezterm | tmux | iterm2 | apple-terminal | here
|
|
38
|
-
* CLAUDE_SESSIONS_OPEN_CMD Custom open command (placeholders: %cmd% %title% %project%)
|
|
39
|
-
* CLAUDE_CONFIG_DIR Also scanned when set
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
const fs = require('fs');
|
|
43
|
-
const path = require('path');
|
|
44
|
-
const os = require('os');
|
|
45
|
-
const { spawnSync } = require('child_process');
|
|
46
|
-
|
|
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;
|
|
53
|
-
|
|
54
|
-
// ─── Config dir discovery ─────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
function hasProjects(d) {
|
|
57
|
-
try { return fs.statSync(path.join(d, 'projects')).isDirectory(); } catch { return false; }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function discoverConfigDirs() {
|
|
61
|
-
const home = os.homedir();
|
|
62
|
-
let dirs = [];
|
|
63
|
-
try {
|
|
64
|
-
dirs = fs.readdirSync(home)
|
|
65
|
-
.filter(n => n === '.claude' || n.startsWith('.claude-'))
|
|
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
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ─── Args ─────────────────────────────────────────────────────────────────────
|
|
80
|
-
|
|
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];
|
|
87
|
-
else if (a === '--named-only') opts.namedOnly = true;
|
|
88
|
-
else if (a === '--json') opts.json = true;
|
|
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;
|
|
92
|
-
else if (a === '--version' || a === '-v') opts.version = true;
|
|
93
|
-
else if (a === '-h' || a === '--help') opts.help = true;
|
|
94
|
-
else if (!a.startsWith('-')) opts.filterPath = path.resolve(a.replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
95
|
-
}
|
|
96
|
-
return opts;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ─── Session reading ──────────────────────────────────────────────────────────
|
|
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
|
-
|
|
139
|
-
function readSession(jsonlPath) {
|
|
140
|
-
let title = null, firstUserMessage = null, cwd = null, timestamp = null;
|
|
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;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return { title, firstUserMessage, cwd, timestamp };
|
|
165
|
-
}
|
|
166
|
-
|
|
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
|
-
}
|
|
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');
|
|
228
|
-
let projectEntries;
|
|
229
|
-
try { projectEntries = fs.readdirSync(projectsDir); } catch { return []; }
|
|
230
|
-
const results = [];
|
|
231
|
-
|
|
232
|
-
for (const pd of projectEntries) {
|
|
233
|
-
const pdFull = path.join(projectsDir, pd);
|
|
234
|
-
let names;
|
|
235
|
-
try { names = fs.readdirSync(pdFull).filter(f => f.endsWith('.jsonl')); } catch { continue; }
|
|
236
|
-
|
|
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
|
-
}
|
|
246
|
-
|
|
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;
|
|
255
|
-
results.push({
|
|
256
|
-
configDir: path.basename(configDir),
|
|
257
|
-
configDirPath: configDir,
|
|
258
|
-
project,
|
|
259
|
-
sessionId: file.replace(/\.jsonl$/, ''),
|
|
260
|
-
title: meta.title,
|
|
261
|
-
firstMessage: meta.firstUserMessage,
|
|
262
|
-
lastModified: mtime,
|
|
263
|
-
timestamp: meta.timestamp,
|
|
264
|
-
size,
|
|
265
|
-
jsonlPath,
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
return results.sort((a, b) => b.lastModified - a.lastModified);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function isWithin(p, root) {
|
|
273
|
-
const rel = path.relative(root, p);
|
|
274
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function loadSessions(opts) {
|
|
278
|
-
const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
|
|
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
|
|
283
|
-
return opts.namedOnly ? all.filter(s => s.title) : all;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// ─── Session operations ───────────────────────────────────────────────────────
|
|
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).
|
|
301
|
-
function deleteSessionFiles(session) {
|
|
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;
|
|
315
|
-
try {
|
|
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));
|
|
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;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function moveSession(session, targetConfigDirPath, targetProjectDirName, targetRawPath = null) {
|
|
335
|
-
const srcProjectDirName = path.basename(path.dirname(session.jsonlPath));
|
|
336
|
-
const destDirName = targetProjectDirName || srcProjectDirName;
|
|
337
|
-
const targetProjectDir = path.join(targetConfigDirPath, 'projects', destDirName);
|
|
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');
|
|
341
|
-
|
|
342
|
-
fs.mkdirSync(targetProjectDir, { recursive: true });
|
|
343
|
-
movePath(session.jsonlPath, targetJsonl);
|
|
344
|
-
|
|
345
|
-
const srcDir = companionDir(session.jsonlPath);
|
|
346
|
-
try { if (fs.statSync(srcDir).isDirectory()) movePath(srcDir, companionDir(targetJsonl)); } catch {}
|
|
347
|
-
|
|
348
|
-
if (targetRawPath) {
|
|
349
|
-
try { fs.writeFileSync(path.join(targetProjectDir, '.project-path'), targetRawPath, 'utf8'); } catch {}
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
session.configDir = path.basename(targetConfigDirPath);
|
|
353
|
-
session.configDirPath = targetConfigDirPath;
|
|
354
|
-
session.jsonlPath = targetJsonl;
|
|
355
|
-
}
|
|
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
|
-
|
|
376
|
-
function listProjectFolders(configDirPath) {
|
|
377
|
-
const projectsDir = path.join(configDirPath, 'projects');
|
|
378
|
-
try {
|
|
379
|
-
return fs.readdirSync(projectsDir)
|
|
380
|
-
.filter(d => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } })
|
|
381
|
-
.map(d => {
|
|
382
|
-
let displayPath = d;
|
|
383
|
-
try {
|
|
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
|
-
}
|
|
395
|
-
return { projectDirName: d, displayPath };
|
|
396
|
-
})
|
|
397
|
-
.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
|
|
398
|
-
} catch { return []; }
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Same scheme as Claude Code: every non-alphanumeric char becomes '-'.
|
|
402
|
-
const MAX_DIR_NAME = 200;
|
|
403
|
-
function encodePathToDir(absPath) {
|
|
404
|
-
return absPath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
405
|
-
}
|
|
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
|
-
|
|
422
|
-
function tabComplete(input) {
|
|
423
|
-
const expanded = expandHome(input);
|
|
424
|
-
const endsWithSlash = /[\\/]$/.test(expanded);
|
|
425
|
-
const baseDir = endsWithSlash ? (expanded.slice(0, -1) || path.sep) : path.dirname(expanded);
|
|
426
|
-
const prefix = endsWithSlash ? '' : path.basename(expanded);
|
|
427
|
-
|
|
428
|
-
let matches;
|
|
429
|
-
try {
|
|
430
|
-
matches = fs.readdirSync(baseDir)
|
|
431
|
-
.filter(n => n.startsWith(prefix) && (() => {
|
|
432
|
-
try { return fs.statSync(path.join(baseDir, n)).isDirectory(); } catch { return false; }
|
|
433
|
-
})())
|
|
434
|
-
.sort();
|
|
435
|
-
} catch { return { completed: input, matches: [] }; }
|
|
436
|
-
|
|
437
|
-
if (!matches.length) return { completed: input, matches: [] };
|
|
438
|
-
|
|
439
|
-
if (matches.length === 1) {
|
|
440
|
-
return { completed: shortenPath(path.join(baseDir, matches[0]) + path.sep), matches: [] };
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
let common = matches[0];
|
|
444
|
-
for (const m of matches.slice(1)) {
|
|
445
|
-
let i = 0;
|
|
446
|
-
while (i < common.length && i < m.length && common[i] === m[i]) i++;
|
|
447
|
-
common = common.slice(0, i);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (common.length > prefix.length) {
|
|
451
|
-
return { completed: shortenPath(path.join(baseDir, common)), matches: matches.map(m => m + path.sep) };
|
|
452
|
-
}
|
|
453
|
-
return { completed: input, matches: matches.map(m => m + path.sep) };
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// ─── Terminal detection & open ────────────────────────────────────────────────
|
|
457
|
-
|
|
458
|
-
function detectTerminal(env = process.env, platform = process.platform) {
|
|
459
|
-
if (env.CLAUDE_SESSIONS_TERMINAL) return env.CLAUDE_SESSIONS_TERMINAL;
|
|
460
|
-
// tmux first: it is always the innermost layer, and a tmux started from kitty or
|
|
461
|
-
// WezTerm inherits their variables even though their tab commands can't reach it.
|
|
462
|
-
if (env.TMUX) return 'tmux';
|
|
463
|
-
if (env.KITTY_WINDOW_ID !== undefined) return 'kitty';
|
|
464
|
-
if (env.WEZTERM_PANE !== undefined) return 'wezterm';
|
|
465
|
-
if (env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
|
|
466
|
-
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
|
|
467
|
-
// WSL also sees WT_SESSION, but there `wt` would open a Windows shell, not Linux.
|
|
468
|
-
if (env.WT_SESSION && platform === 'win32') return 'windows-terminal';
|
|
469
|
-
return 'here';
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
473
|
-
function appleStr(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
|
|
474
|
-
|
|
475
|
-
function trySpawn(cmd, args) {
|
|
476
|
-
// Tab commands return at once; the timeout keeps a stuck one (e.g. `kitty @`
|
|
477
|
-
// waiting for a reply that never comes) from freezing the UI.
|
|
478
|
-
const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'], timeout: 5000 });
|
|
479
|
-
if (r.error) return { ok: false, msg: `${cmd}: ${r.error.code === 'ENOENT' ? 'not found' : r.error.code === 'ETIMEDOUT' ? 'timed out' : r.error.message}` };
|
|
480
|
-
return r.status === 0
|
|
481
|
-
? { ok: true }
|
|
482
|
-
: { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function resolveProjectPath(session) {
|
|
486
|
-
const sessionDir = path.dirname(session.jsonlPath);
|
|
487
|
-
try {
|
|
488
|
-
const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
|
|
489
|
-
if (hint && fs.existsSync(hint)) return hint;
|
|
490
|
-
} catch {}
|
|
491
|
-
try {
|
|
492
|
-
const siblings = fs.readdirSync(sessionDir)
|
|
493
|
-
.filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
|
|
494
|
-
for (const sibling of siblings) {
|
|
495
|
-
const { cwd } = readSession(path.join(sessionDir, sibling));
|
|
496
|
-
if (cwd && fs.existsSync(cwd)) return cwd;
|
|
497
|
-
}
|
|
498
|
-
} catch {}
|
|
499
|
-
return fs.existsSync(session.project) ? session.project : os.homedir();
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// A launch plan: where to run, which claude args, under which config dir.
|
|
503
|
-
function resumePlan(session) {
|
|
504
|
-
return {
|
|
505
|
-
cwd: resolveProjectPath(session),
|
|
506
|
-
args: ['-r', session.sessionId],
|
|
507
|
-
configDirPath: session.configDirPath,
|
|
508
|
-
title: session.title || session.sessionId.slice(0, 8),
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function newPlan(configDirPath, projectPath) {
|
|
513
|
-
return { cwd: projectPath, args: [], configDirPath, title: path.basename(projectPath) };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
function planShellCmd(plan) {
|
|
517
|
-
const claude = ['claude', ...plan.args].join(' ');
|
|
518
|
-
return `cd ${shellEsc(plan.cwd)} && CLAUDE_CONFIG_DIR=${shellEsc(plan.configDirPath)} ${claude}; exec "\${SHELL:-bash}"`;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// `wt new-tab` args. wt treats ';' as a command separator, so it is escaped.
|
|
522
|
-
// cmd's `set X=v&& …` takes everything up to && as the value (spaces allowed).
|
|
523
|
-
function wtArgs(plan) {
|
|
524
|
-
const semi = v => String(v).replace(/;/g, '\\;');
|
|
525
|
-
return ['-w', '0', 'nt', '--title', semi(plan.title), '-d', semi(plan.cwd),
|
|
526
|
-
'cmd', '/k', semi(`set CLAUDE_CONFIG_DIR=${plan.configDirPath}&& claude ${plan.args.join(' ')}`.trim())];
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Returns { ok, msg } or { here: true } when the caller should run it in this terminal.
|
|
530
|
-
function launchInTab(plan) {
|
|
531
|
-
const shellCmd = planShellCmd(plan);
|
|
532
|
-
const customTpl = process.env.CLAUDE_SESSIONS_OPEN_CMD;
|
|
533
|
-
if (customTpl) {
|
|
534
|
-
const expanded = customTpl
|
|
535
|
-
.replace(/%cmd%/g, shellEsc(shellCmd))
|
|
536
|
-
.replace(/%title%/g, shellEsc(plan.title))
|
|
537
|
-
.replace(/%project%/g, shellEsc(plan.cwd));
|
|
538
|
-
const r = spawnSync('bash', ['-c', expanded], { stdio: 'ignore' });
|
|
539
|
-
return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
switch (detectTerminal()) {
|
|
543
|
-
case 'kitty':
|
|
544
|
-
return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${plan.title}`, 'bash', '-i', '-c', shellCmd]);
|
|
545
|
-
case 'wezterm':
|
|
546
|
-
return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
|
|
547
|
-
case 'tmux':
|
|
548
|
-
return trySpawn('tmux', ['new-window', '-n', plan.title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
|
|
549
|
-
case 'iterm2':
|
|
550
|
-
return trySpawn('osascript', ['-e',
|
|
551
|
-
`tell application "iTerm2" to tell current window to create tab with default profile command ${appleStr('bash -i -c ' + shellEsc(shellCmd))}`]);
|
|
552
|
-
case 'apple-terminal':
|
|
553
|
-
return trySpawn('osascript', ['-e', `tell application "Terminal" to do script ${appleStr(shellCmd)}`]);
|
|
554
|
-
case 'windows-terminal':
|
|
555
|
-
return trySpawn('wt', wtArgs(plan));
|
|
556
|
-
default:
|
|
557
|
-
return { here: true };
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Runs claude in the current terminal and waits for it to exit.
|
|
562
|
-
function runHere(plan) {
|
|
563
|
-
const r = spawnSync('claude', plan.args, {
|
|
564
|
-
cwd: plan.cwd,
|
|
565
|
-
env: { ...process.env, CLAUDE_CONFIG_DIR: plan.configDirPath },
|
|
566
|
-
stdio: 'inherit',
|
|
567
|
-
shell: IS_WIN, // claude may be a .cmd shim on Windows
|
|
568
|
-
});
|
|
569
|
-
if (r.error) return { ok: false, msg: r.error.code === 'ENOENT' ? '`claude` not found on PATH' : r.error.message };
|
|
570
|
-
return { ok: true };
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
574
|
-
|
|
575
|
-
function fmtDate(d) {
|
|
576
|
-
const t = new Date(d), p = n => String(n).padStart(2, '0');
|
|
577
|
-
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())}`;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function fmtAgo(d, now = Date.now()) {
|
|
581
|
-
const m = Math.floor((now - new Date(d)) / 60000);
|
|
582
|
-
if (m < 1) return 'just now';
|
|
583
|
-
if (m < 60) return `${m}m ago`;
|
|
584
|
-
if (m < 1440) return `${Math.floor(m / 60)}h ago`;
|
|
585
|
-
if (m < 43200) return `${Math.floor(m / 1440)}d ago`;
|
|
586
|
-
if (m < 525600) return `${Math.floor(m / 43200)}mo ago`;
|
|
587
|
-
return `${Math.floor(m / 525600)}y ago`;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function fmtSize(n) {
|
|
591
|
-
if (n < 1024) return `${n}B`;
|
|
592
|
-
const u = ['K', 'M', 'G'];
|
|
593
|
-
let i = -1;
|
|
594
|
-
do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
|
|
595
|
-
return `${n < 10 ? n.toFixed(1) : Math.round(n)}${u[i]}`;
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function shortConfig(configDir) {
|
|
599
|
-
return path.basename(configDir).replace(/^\.claude-?/, '') || 'default';
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function shortenPath(p) {
|
|
603
|
-
const home = os.homedir();
|
|
604
|
-
return p === home || p.startsWith(home + path.sep) || p.startsWith(home + '/') ? '~' + p.slice(home.length) : p;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
const SORTS = ['date', 'size', 'name'];
|
|
608
|
-
function sortSessions(sessions, mode) {
|
|
609
|
-
const order = new Map();
|
|
610
|
-
for (const s of sessions) if (!order.has(s.configDir)) order.set(s.configDir, order.size);
|
|
611
|
-
const key = {
|
|
612
|
-
date: (a, b) => b.lastModified - a.lastModified,
|
|
613
|
-
size: (a, b) => b.size - a.size,
|
|
614
|
-
name: (a, b) => (a.title || a.firstMessage || '~').localeCompare(b.title || b.firstMessage || '~'),
|
|
615
|
-
}[mode];
|
|
616
|
-
return [...sessions].sort((a, b) => (order.get(a.configDir) - order.get(b.configDir)) || key(a, b));
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
// ─── Plain list output ────────────────────────────────────────────────────────
|
|
620
|
-
|
|
621
|
-
function ansi(code, s) { return process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; }
|
|
622
|
-
|
|
623
|
-
function printList(sessions) {
|
|
624
|
-
if (!sessions.length) { console.log('No sessions found.'); return; }
|
|
625
|
-
let lastConfig = null, lastProject = null;
|
|
626
|
-
for (const s of sessions) {
|
|
627
|
-
if (s.configDir !== lastConfig) {
|
|
628
|
-
if (lastConfig) console.log();
|
|
629
|
-
console.log(ansi('1;36', `[${s.configDir}]`));
|
|
630
|
-
lastConfig = s.configDir; lastProject = null;
|
|
631
|
-
}
|
|
632
|
-
if (s.project !== lastProject) {
|
|
633
|
-
console.log(ansi('33', ` ${shortenPath(s.project)}`));
|
|
634
|
-
lastProject = s.project;
|
|
635
|
-
}
|
|
636
|
-
const label = s.title
|
|
637
|
-
? groupTitle(s.title, '1;32', ansi)
|
|
638
|
-
: ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
|
|
639
|
-
console.log(` ${label} ${ansi('2', `${fmtDate(s.lastModified)} ${fmtSize(s.size)}`)}`);
|
|
640
|
-
console.log(ansi('2', ` id: ${s.sessionId}`));
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// ─── TUI ──────────────────────────────────────────────────────────────────────
|
|
645
|
-
|
|
646
|
-
const ESC = '\x1b';
|
|
647
|
-
const clrLine = `${ESC}[2K`;
|
|
648
|
-
const clrScreen = `${ESC}[2J`;
|
|
649
|
-
const hideCursor = `${ESC}[?25l`;
|
|
650
|
-
const showCursor = `${ESC}[?25h`;
|
|
651
|
-
const altBuf = `${ESC}[?1049h`;
|
|
652
|
-
const normBuf = `${ESC}[?1049l`;
|
|
653
|
-
|
|
654
|
-
const mv = (r, c) => `${ESC}[${r};${c}H`;
|
|
655
|
-
// Each helper resets only its own attribute, so a row background survives nested
|
|
656
|
-
// colors. NO_COLOR (https://no-color.org) drops colors but keeps bold/dim/inverse.
|
|
657
|
-
const COLOR = !process.env.NO_COLOR;
|
|
658
|
-
const bold = s => `${ESC}[1m${s}${ESC}[22m`;
|
|
659
|
-
const dim = s => `${ESC}[2m${s}${ESC}[22m`;
|
|
660
|
-
const inv = s => `${ESC}[7m${s}${ESC}[27m`;
|
|
661
|
-
const fgc = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[22;39m` : s;
|
|
662
|
-
const bgc = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[49m` : s;
|
|
663
|
-
const SEL_BG = `${ESC}[48;5;236m`;
|
|
664
|
-
// Selected line: dark background + accent bar (inverse when colors are off).
|
|
665
|
-
// `styled` must color every visible part explicitly so it reads on light themes too.
|
|
666
|
-
const selLine = (styled, plain, W) => COLOR
|
|
667
|
-
? `${SEL_BG}${fgc('96', '▌')}${styled}${ESC}[K${ESC}[0m`
|
|
668
|
-
: inv(pad(` ${plain}`, W));
|
|
669
|
-
const stripAnsi = s => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
670
|
-
// "Enter=open /=search" → key in bold, label dimmed.
|
|
671
|
-
const styleHints = s => s.replace(/(\S+)=(.+?)(?= |$)/g, (_, k, l) => `${bold(k)} ${dim(l)}`);
|
|
672
|
-
const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
673
|
-
const pad = (s, n) => s.padEnd(n).slice(0, n);
|
|
674
|
-
// "keycloak/fix-login" → group prefix in its own color so related sessions stand out
|
|
675
|
-
const groupTitle = (t, color, fmt = fgc) => {
|
|
676
|
-
const i = t.indexOf('/');
|
|
677
|
-
return i > 0 ? fmt('95', t.slice(0, i + 1)) + fmt(color, t.slice(i + 1)) : fmt(color, t);
|
|
678
|
-
};
|
|
679
|
-
|
|
680
|
-
const HELP = [
|
|
681
|
-
['↑↓ / j k', 'Navigate'], ['PgUp PgDn', 'Page up / down'],
|
|
682
|
-
['g / G', 'Top / bottom'], ['/', 'Search (↑↓ select, Enter open, Tab keep filter)'],
|
|
683
|
-
['Esc', 'Clear search / marks'], ['Enter', 'Open (new tab if supported, else here)'],
|
|
684
|
-
['o', 'Open in this terminal'], ['p', 'Toggle preview'],
|
|
685
|
-
['r', 'Rename'], ['s', 'Sort: date / size / name'],
|
|
686
|
-
['Space', 'Mark for bulk delete'], ['n', 'New session'],
|
|
687
|
-
['m', 'Move to profile / folder'], ['d', 'Delete marked or selected (to trash)'],
|
|
688
|
-
['q / Ctrl-C', 'Quit'],
|
|
689
|
-
];
|
|
690
|
-
|
|
691
|
-
// ─── LineInput ───────────────────────────────────────────────────────────────
|
|
692
|
-
// One-line text field with a movable cursor: ←/→, Home/End, Ctrl-←/→ by word,
|
|
693
|
-
// Backspace/Delete at the cursor, Ctrl-U (clear to start), Ctrl-W (delete word).
|
|
694
|
-
|
|
695
|
-
const WORD_SEP = /[\s/\-_.]/;
|
|
696
|
-
|
|
697
|
-
class LineInput {
|
|
698
|
-
constructor(text = '') { this.set(text); }
|
|
699
|
-
set(text) { this.chars = [...text]; this.cur = this.chars.length; }
|
|
700
|
-
get text() { return this.chars.join(''); }
|
|
701
|
-
get before() { return this.chars.slice(0, this.cur).join(''); }
|
|
702
|
-
|
|
703
|
-
_wordLeft(i) {
|
|
704
|
-
while (i > 0 && WORD_SEP.test(this.chars[i - 1])) i--;
|
|
705
|
-
while (i > 0 && !WORD_SEP.test(this.chars[i - 1])) i--;
|
|
706
|
-
return i;
|
|
707
|
-
}
|
|
708
|
-
_wordRight(i) {
|
|
709
|
-
const n = this.chars.length;
|
|
710
|
-
while (i < n && WORD_SEP.test(this.chars[i])) i++;
|
|
711
|
-
while (i < n && !WORD_SEP.test(this.chars[i])) i++;
|
|
712
|
-
return i;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Returns true when the key was an editing key.
|
|
716
|
-
handle(key) {
|
|
717
|
-
const c = this.chars;
|
|
718
|
-
switch (key) {
|
|
719
|
-
case '\x1b[D': case '\x02': this.cur = Math.max(0, this.cur - 1); break;
|
|
720
|
-
case '\x1b[C': case '\x06': this.cur = Math.min(c.length, this.cur + 1); break;
|
|
721
|
-
case '\x1b[H': case '\x1bOH': case '\x1b[1~': case '\x01': this.cur = 0; break;
|
|
722
|
-
case '\x1b[F': case '\x1bOF': case '\x1b[4~': case '\x05': this.cur = c.length; break;
|
|
723
|
-
case '\x1b[1;5D': case '\x1b[1;3D': this.cur = this._wordLeft(this.cur); break;
|
|
724
|
-
case '\x1b[1;5C': case '\x1b[1;3C': this.cur = this._wordRight(this.cur); break;
|
|
725
|
-
case '\x7f': case '\b': if (this.cur > 0) c.splice(--this.cur, 1); break;
|
|
726
|
-
case '\x1b[3~': c.splice(this.cur, 1); break;
|
|
727
|
-
case '\x15': c.splice(0, this.cur); this.cur = 0; break;
|
|
728
|
-
case '\x17': { const i = this._wordLeft(this.cur); c.splice(i, this.cur - i); this.cur = i; break; }
|
|
729
|
-
default:
|
|
730
|
-
if (!isPrintable(key)) return false;
|
|
731
|
-
const ins = [...key];
|
|
732
|
-
c.splice(this.cur, 0, ...ins);
|
|
733
|
-
this.cur += ins.length;
|
|
734
|
-
}
|
|
735
|
-
return true;
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// ─── Picker ───────────────────────────────────────────────────────────────────
|
|
740
|
-
// Shared profile → folder → input flow used by both move and new modes.
|
|
741
|
-
// step: 'profile' | 'folder' | 'input'
|
|
742
|
-
|
|
743
|
-
class Picker {
|
|
744
|
-
constructor({ headerLabel, profileLabel = 'Select profile', inputPrompt, buildProfileItems, buildFolderItems, onConfirmItem, onConfirmCustom }) {
|
|
745
|
-
this.step = 'profile';
|
|
746
|
-
this.items = [];
|
|
747
|
-
this.selIdx = 0;
|
|
748
|
-
this.scroll = 0;
|
|
749
|
-
this.config = null;
|
|
750
|
-
this.field = new LineInput();
|
|
751
|
-
this.completions = [];
|
|
752
|
-
|
|
753
|
-
this._headerLabel = headerLabel;
|
|
754
|
-
this._profileLabel = profileLabel;
|
|
755
|
-
this._inputPrompt = inputPrompt;
|
|
756
|
-
this._buildProfileItems = buildProfileItems;
|
|
757
|
-
this._buildFolderItems = buildFolderItems;
|
|
758
|
-
this._onConfirmItem = onConfirmItem;
|
|
759
|
-
this._onConfirmCustom = onConfirmCustom;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
enter() {
|
|
763
|
-
this.step = 'profile';
|
|
764
|
-
this.items = this._buildProfileItems();
|
|
765
|
-
this.selIdx = 0;
|
|
766
|
-
this.scroll = 0;
|
|
767
|
-
this.config = null;
|
|
768
|
-
this.field.set('');
|
|
769
|
-
this.completions = [];
|
|
770
|
-
// Skip the profile step when there is only one.
|
|
771
|
-
if (this.items.length === 1) this._enterFolderStep(this.items[0].configDirPath);
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
_enterFolderStep(configDirPath) {
|
|
775
|
-
this.config = configDirPath;
|
|
776
|
-
this.step = 'folder';
|
|
777
|
-
this.items = this._buildFolderItems(configDirPath);
|
|
778
|
-
this.selIdx = 0;
|
|
779
|
-
this.scroll = 0;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// Returns false when the picker should be dismissed.
|
|
783
|
-
handleKey(key) {
|
|
784
|
-
if (this.step === 'input') {
|
|
785
|
-
if (key === '\x1b') {
|
|
786
|
-
this.step = 'folder';
|
|
787
|
-
this.completions = [];
|
|
788
|
-
this.items = this._buildFolderItems(this.config);
|
|
789
|
-
} else if (key === '\t') {
|
|
790
|
-
const { completed, matches } = tabComplete(this.field.text || '~/');
|
|
791
|
-
this.field.set(completed);
|
|
792
|
-
this.completions = matches.length > 1 ? matches : [];
|
|
793
|
-
} else if (key === '\r') {
|
|
794
|
-
const raw = this.field.text.trim();
|
|
795
|
-
this.completions = [];
|
|
796
|
-
if (raw) this._onConfirmCustom(path.resolve(expandHome(raw).replace(/[\\/]+$/, '') || path.sep));
|
|
797
|
-
else this.step = 'folder';
|
|
798
|
-
} else if (this.field.handle(key)) {
|
|
799
|
-
this.completions = [];
|
|
800
|
-
}
|
|
801
|
-
return true;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
if (key === '\x1b') {
|
|
805
|
-
const profiles = this._buildProfileItems();
|
|
806
|
-
if (this.step === 'folder' && profiles.length > 1) {
|
|
807
|
-
this.step = 'profile';
|
|
808
|
-
this.items = profiles;
|
|
809
|
-
this.selIdx = 0;
|
|
810
|
-
this.scroll = 0;
|
|
811
|
-
return true;
|
|
812
|
-
}
|
|
813
|
-
return false;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (key === '\x1b[A' || key === 'k') { this.selIdx = Math.max(0, this.selIdx - 1); return true; }
|
|
817
|
-
if (key === '\x1b[B' || key === 'j') { this.selIdx = Math.min(this.items.length - 1, this.selIdx + 1); return true; }
|
|
818
|
-
|
|
819
|
-
if (key === '\r') {
|
|
820
|
-
const item = this.items[this.selIdx];
|
|
821
|
-
if (!item) return true;
|
|
822
|
-
if (item.type === 'profile') { this._enterFolderStep(item.configDirPath); return true; }
|
|
823
|
-
if (item.type === 'new') { this.step = 'input'; this.field.set('~/'); this.completions = []; return true; }
|
|
824
|
-
this._onConfirmItem(item);
|
|
825
|
-
}
|
|
826
|
-
return true;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
render(out, W, H) {
|
|
830
|
-
const headerLabel = typeof this._headerLabel === 'function' ? this._headerLabel() : this._headerLabel;
|
|
831
|
-
out.push(mv(2, 1), clrLine, ` ${headerLabel}`);
|
|
832
|
-
|
|
833
|
-
out.push(mv(3, 1), clrLine);
|
|
834
|
-
const divider = dim('─'.repeat(Math.max(0, W - 30)));
|
|
835
|
-
if (this.step === 'profile') {
|
|
836
|
-
out.push(` ` + bold(this._profileLabel) + ` ` + divider);
|
|
837
|
-
} else {
|
|
838
|
-
out.push(` ` + bold('Select folder in ') + bold(fgc('96', `[${path.basename(this.config)}]`)) + ` ` + divider);
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
let listStart = 4;
|
|
842
|
-
if (this.step === 'input') {
|
|
843
|
-
out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.field.text}`);
|
|
844
|
-
out.push(mv(5, 1), clrLine);
|
|
845
|
-
if (this.completions.length > 1) {
|
|
846
|
-
const cols = Math.max(1, Math.floor((W - 2) / 22));
|
|
847
|
-
const rows = Math.ceil(this.completions.length / cols);
|
|
848
|
-
for (let r = 0; r < Math.min(rows, H - 8); r++) {
|
|
849
|
-
out.push(mv(r + 5, 1), clrLine);
|
|
850
|
-
out.push(` ` + dim(this.completions.slice(r * cols, r * cols + cols).map(n => pad(n, 22)).join(' ')));
|
|
851
|
-
}
|
|
852
|
-
listStart = H;
|
|
853
|
-
} else {
|
|
854
|
-
out.push(dim(` Tab to complete path`));
|
|
855
|
-
listStart = 6;
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
const items = this.step === 'input' ? [] : this.items;
|
|
860
|
-
const maxVis = Math.max(0, H - listStart - 1);
|
|
861
|
-
if (this.selIdx < this.scroll) this.scroll = this.selIdx;
|
|
862
|
-
if (this.selIdx >= this.scroll + maxVis) this.scroll = this.selIdx - maxVis + 1;
|
|
863
|
-
if (this.scroll < 0) this.scroll = 0;
|
|
864
|
-
|
|
865
|
-
for (let r = 0; r < maxVis; r++) {
|
|
866
|
-
out.push(mv(listStart + r, 1), clrLine);
|
|
867
|
-
const item = items[this.scroll + r];
|
|
868
|
-
if (!item) continue;
|
|
869
|
-
const isSel = (this.scroll + r) === this.selIdx;
|
|
870
|
-
const label = item.type === 'same' ? dim(item.label)
|
|
871
|
-
: item.type === 'new' ? fgc('93', item.label)
|
|
872
|
-
: item.type === 'profile' ? bold(fgc('96', item.label))
|
|
873
|
-
: fgc('33', item.label);
|
|
874
|
-
out.push(isSel ? selLine(` ${COLOR ? label.replace(/\x1b\[2m/g, '') : label}`, ` ${trunc(item.label, W - 5)}`, W) : ` ${label}`);
|
|
875
|
-
}
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
// A stdin chunk may hold several keys (fast typing, paste): split into single
|
|
880
|
-
// chars, keeping escape sequences like "\x1b[A" or "\x1b[5~" whole.
|
|
881
|
-
function splitKeys(chunk) {
|
|
882
|
-
const keys = [];
|
|
883
|
-
const re = /\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Za-z]|\x1b|[\s\S]/gu;
|
|
884
|
-
let m;
|
|
885
|
-
while ((m = re.exec(chunk))) keys.push(m[0]);
|
|
886
|
-
return keys;
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function isPrintable(key) {
|
|
890
|
-
return key.length >= 1 && !key.startsWith('\x1b') && [...key].every(ch => ch >= ' ' && ch !== '\x7f');
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// ─── TUI ─────────────────────────────────────────────────────────────────────
|
|
894
|
-
|
|
895
|
-
class TUI {
|
|
896
|
-
constructor(sessions, opts = {}) {
|
|
897
|
-
this.all = sessions;
|
|
898
|
-
this.opts = opts;
|
|
899
|
-
this.filterPath = opts.filterPath || '';
|
|
900
|
-
this.search = '';
|
|
901
|
-
this.mode = null; // null | 'search' | 'delete' | 'move' | 'new' | 'rename' | 'help'
|
|
902
|
-
this.statusMsg = '';
|
|
903
|
-
this._statusTimer = null;
|
|
904
|
-
this.selIdx = 0;
|
|
905
|
-
this.scrollTop = 0;
|
|
906
|
-
this.items = [];
|
|
907
|
-
this.sort = 'date';
|
|
908
|
-
this.marked = new Set(); // jsonlPath
|
|
909
|
-
this.preview = false;
|
|
910
|
-
this._previewCache = new Map();
|
|
911
|
-
this.renameField = new LineInput();
|
|
912
|
-
this.searchField = new LineInput();
|
|
913
|
-
this.w = process.stdout.columns || 80;
|
|
914
|
-
this.h = process.stdout.rows || 24;
|
|
915
|
-
|
|
916
|
-
this.movePicker = this._createMovePicker();
|
|
917
|
-
this.newPicker = this._createNewPicker();
|
|
918
|
-
this.rebuild();
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
get previewH() { return this.preview ? Math.max(4, Math.floor((this.h - 2) * 0.4)) : 0; }
|
|
922
|
-
get listH() { return this.h - 2 - this.previewH; }
|
|
923
|
-
|
|
924
|
-
_createMovePicker() {
|
|
925
|
-
return new Picker({
|
|
926
|
-
headerLabel: () => {
|
|
927
|
-
const s = this.selectedSession();
|
|
928
|
-
const label = s?.title || s?.firstMessage?.slice(0, 60) || s?.sessionId?.slice(0, 8) || '';
|
|
929
|
-
return `Moving: ` + (label ? fgc('92', `"${trunc(label, this.w - 12)}"`) : dim('(untitled)'));
|
|
930
|
-
},
|
|
931
|
-
profileLabel: 'Select target profile',
|
|
932
|
-
inputPrompt: 'New path:',
|
|
933
|
-
buildProfileItems: () => {
|
|
934
|
-
const s = this.selectedSession();
|
|
935
|
-
return discoverConfigDirs().map(d => ({
|
|
936
|
-
type: 'profile',
|
|
937
|
-
label: path.basename(d) + (d === s?.configDirPath ? ' (current)' : ''),
|
|
938
|
-
configDirPath: d,
|
|
939
|
-
}));
|
|
940
|
-
},
|
|
941
|
-
buildFolderItems: (configDirPath) => {
|
|
942
|
-
const s = this.selectedSession();
|
|
943
|
-
const isSameConfig = configDirPath === s?.configDirPath;
|
|
944
|
-
const srcDirName = s ? path.basename(path.dirname(s.jsonlPath)) : null;
|
|
945
|
-
const items = [];
|
|
946
|
-
if (!isSameConfig) {
|
|
947
|
-
items.push({ type: 'same', label: (s ? shortenPath(s.project) : '') + ' (keep same folder)', dirName: null });
|
|
948
|
-
}
|
|
949
|
-
listProjectFolders(configDirPath)
|
|
950
|
-
.filter(f => !(isSameConfig && f.projectDirName === srcDirName))
|
|
951
|
-
.forEach(f => items.push({ type: 'folder', label: f.displayPath, dirName: f.projectDirName }));
|
|
952
|
-
items.push({ type: 'new', label: '[type a new path…]', dirName: null });
|
|
953
|
-
return items;
|
|
954
|
-
},
|
|
955
|
-
onConfirmItem: (item) => this._doMove(item.dirName),
|
|
956
|
-
onConfirmCustom: (absPath) => {
|
|
957
|
-
try { this._doMove(projectDirNameFor(this.movePicker.config, absPath), absPath); }
|
|
958
|
-
catch (e) { this.mode = null; this.setStatus(`Move failed: ${e.message}`, 5000); }
|
|
959
|
-
},
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
_createNewPicker() {
|
|
964
|
-
return new Picker({
|
|
965
|
-
headerLabel: fgc('92', 'New session'),
|
|
966
|
-
inputPrompt: 'Path:',
|
|
967
|
-
buildProfileItems: () => discoverConfigDirs().map(d => ({
|
|
968
|
-
type: 'profile', label: path.basename(d), configDirPath: d,
|
|
969
|
-
})),
|
|
970
|
-
buildFolderItems: (configDirPath) => [
|
|
971
|
-
...listProjectFolders(configDirPath).map(f => ({
|
|
972
|
-
type: 'folder',
|
|
973
|
-
label: f.displayPath,
|
|
974
|
-
projectPath: expandHome(f.displayPath),
|
|
975
|
-
})),
|
|
976
|
-
{ type: 'new', label: '[type a new path…]', projectPath: null },
|
|
977
|
-
],
|
|
978
|
-
onConfirmItem: (item) => { if (item.projectPath) this._doNew(item.projectPath); },
|
|
979
|
-
onConfirmCustom: (absPath) => this._doNew(absPath),
|
|
980
|
-
});
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// ── Item list ───────────────────────────────────────────────────────────────
|
|
984
|
-
|
|
985
|
-
rebuild() {
|
|
986
|
-
const q = this.search.toLowerCase();
|
|
987
|
-
const filtered = q
|
|
988
|
-
? this.all.filter(s =>
|
|
989
|
-
(s.title || '').toLowerCase().includes(q) ||
|
|
990
|
-
(s.firstMessage || '').toLowerCase().includes(q) ||
|
|
991
|
-
s.project.toLowerCase().includes(q))
|
|
992
|
-
: this.all;
|
|
993
|
-
|
|
994
|
-
this.items = [];
|
|
995
|
-
let group = null;
|
|
996
|
-
for (const s of sortSessions(filtered, this.sort)) {
|
|
997
|
-
if (s.configDir !== group?.label) {
|
|
998
|
-
group = { type: 'config', label: s.configDir, count: 0 };
|
|
999
|
-
this.items.push(group);
|
|
1000
|
-
}
|
|
1001
|
-
group.count++;
|
|
1002
|
-
this.items.push({ type: 'session', session: s });
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
const total = this.sessionItems().length;
|
|
1006
|
-
this.selIdx = Math.min(this.selIdx, Math.max(0, total - 1));
|
|
1007
|
-
this.ensureVisible();
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
sessionItems() { return this.items.filter(i => i.type === 'session'); }
|
|
1011
|
-
selectedSession() { return this.sessionItems()[this.selIdx]?.session ?? null; }
|
|
1012
|
-
|
|
1013
|
-
selItemRow() {
|
|
1014
|
-
let si = 0;
|
|
1015
|
-
for (let i = 0; i < this.items.length; i++) {
|
|
1016
|
-
if (this.items[i].type === 'session') {
|
|
1017
|
-
if (si === this.selIdx) return i;
|
|
1018
|
-
si++;
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
return -1;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
ensureVisible() {
|
|
1025
|
-
const r = this.selItemRow();
|
|
1026
|
-
if (r === -1) return;
|
|
1027
|
-
if (r < this.scrollTop) this.scrollTop = r;
|
|
1028
|
-
if (r >= this.scrollTop + this.listH) this.scrollTop = r - this.listH + 1;
|
|
1029
|
-
if (this.scrollTop < 0) this.scrollTop = 0;
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
setStatus(msg, ms = 2500) {
|
|
1033
|
-
this.statusMsg = msg;
|
|
1034
|
-
clearTimeout(this._statusTimer);
|
|
1035
|
-
this._statusTimer = setTimeout(() => { this.statusMsg = ''; this.render(); }, ms);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
reloadAll() {
|
|
1039
|
-
const keep = this.selectedSession()?.sessionId;
|
|
1040
|
-
this.all = loadSessions(this.opts);
|
|
1041
|
-
this._previewCache.clear();
|
|
1042
|
-
this.rebuild();
|
|
1043
|
-
this.selectById(keep);
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
selectById(sessionId) {
|
|
1047
|
-
if (!sessionId) return;
|
|
1048
|
-
const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
|
|
1049
|
-
if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
_doMove(targetDirName, targetRawPath = null) {
|
|
1053
|
-
const s = this.selectedSession();
|
|
1054
|
-
this.mode = null;
|
|
1055
|
-
if (!s) return;
|
|
1056
|
-
const targetConfig = this.movePicker.config;
|
|
1057
|
-
try {
|
|
1058
|
-
moveSession(s, targetConfig, targetDirName, targetRawPath);
|
|
1059
|
-
this.reloadAll();
|
|
1060
|
-
this.selectById(s.sessionId);
|
|
1061
|
-
this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
|
|
1062
|
-
} catch (e) {
|
|
1063
|
-
this.setStatus(`Move failed: ${e.message}`, 5000);
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
_open(plan, forceHere = false) {
|
|
1068
|
-
const r = forceHere ? { here: true } : launchInTab(plan);
|
|
1069
|
-
if (r.here) {
|
|
1070
|
-
if (!fs.existsSync(plan.cwd)) { this.setStatus(`Folder not found: ${plan.cwd}`, 5000); return; }
|
|
1071
|
-
this.suspend();
|
|
1072
|
-
const res = runHere(plan);
|
|
1073
|
-
this.resume();
|
|
1074
|
-
if (!res.ok) this.setStatus(res.msg, 5000);
|
|
1075
|
-
return;
|
|
1076
|
-
}
|
|
1077
|
-
this.setStatus(r.ok ? `Opened "${plan.title}" in new tab` : r.msg, r.ok ? 2500 : 5000);
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
_doNew(projectPath) {
|
|
1081
|
-
this.mode = null;
|
|
1082
|
-
if (!projectPath) return;
|
|
1083
|
-
this._open(newPlan(this.newPicker.config, projectPath));
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
_deleteTargets() {
|
|
1087
|
-
if (this.marked.size) return this.all.filter(s => this.marked.has(s.jsonlPath));
|
|
1088
|
-
const s = this.selectedSession();
|
|
1089
|
-
return s ? [s] : [];
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
_doDelete() {
|
|
1093
|
-
const targets = this._deleteTargets();
|
|
1094
|
-
const failed = [];
|
|
1095
|
-
for (const s of targets) {
|
|
1096
|
-
try { deleteSessionFiles(s); } catch (e) { failed.push(e.message); }
|
|
1097
|
-
}
|
|
1098
|
-
const gone = new Set(targets.map(s => s.jsonlPath));
|
|
1099
|
-
this.all = this.all.filter(x => !gone.has(x.jsonlPath) || fs.existsSync(x.jsonlPath));
|
|
1100
|
-
this.marked.clear();
|
|
1101
|
-
this.rebuild();
|
|
1102
|
-
this.setStatus(failed.length
|
|
1103
|
-
? `Delete failed for ${failed.length}: ${failed[0]}`
|
|
1104
|
-
: `Moved ${targets.length} session(s) to trash — run with --empty-trash to free space`, 4000);
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// ── Preview ─────────────────────────────────────────────────────────────────
|
|
1108
|
-
|
|
1109
|
-
_previewLines(s, W, H) {
|
|
1110
|
-
const key = s.jsonlPath + ':' + s.lastModified.valueOf();
|
|
1111
|
-
let msgs = this._previewCache.get(key);
|
|
1112
|
-
if (!msgs) { msgs = readRecentMessages(s.jsonlPath); this._previewCache.set(key, msgs); }
|
|
1113
|
-
// One line per message with a blank line between, newest at the bottom.
|
|
1114
|
-
const width = Math.max(10, W - 8);
|
|
1115
|
-
const lines = msgs.flatMap(m => ['',
|
|
1116
|
-
` ${m.role === 'user' ? fgc('92', 'you ') : fgc('96', 'cc ')} ${trunc(m.text.replace(/\s+/g, ' ').trim(), width)}`]);
|
|
1117
|
-
const body = lines.slice(-(H - 2));
|
|
1118
|
-
if (body[0] === '') body.shift();
|
|
1119
|
-
const head = dim(` ${fmtDate(s.lastModified)} · ${fmtSize(s.size)} · ${s.sessionId}`);
|
|
1120
|
-
return [head, '', ...(body.length ? body : [dim(' (no messages)')])];
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// ── Render ──────────────────────────────────────────────────────────────────
|
|
1124
|
-
|
|
1125
|
-
render() {
|
|
1126
|
-
const W = this.w, H = this.h;
|
|
1127
|
-
const out = [hideCursor, altBuf, clrScreen, mv(1, 1)];
|
|
1128
|
-
|
|
1129
|
-
const filterInfo = this.filterPath ? ` path:${shortenPath(this.filterPath)}` : '';
|
|
1130
|
-
const searchInfo = this.search ? ` search:${this.search}` : '';
|
|
1131
|
-
const sortInfo = this.sort !== 'date' ? ` sort:${this.sort}` : '';
|
|
1132
|
-
out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}${sortInfo}`, W), W)))));
|
|
1133
|
-
|
|
1134
|
-
if (this.mode === 'move') {
|
|
1135
|
-
this.movePicker.render(out, W, H);
|
|
1136
|
-
} else if (this.mode === 'new') {
|
|
1137
|
-
this.newPicker.render(out, W, H);
|
|
1138
|
-
} else if (this.mode === 'help') {
|
|
1139
|
-
HELP.forEach(([k, d], i) => out.push(mv(i + 3, 1), clrLine, ` ${fgc('93', pad(k, 14))}${d}`));
|
|
1140
|
-
out.push(mv(HELP.length + 4, 1), dim(' Env: CLAUDE_SESSIONS_TERMINAL, CLAUDE_SESSIONS_OPEN_CMD, CLAUDE_CONFIG_DIR'));
|
|
1141
|
-
} else {
|
|
1142
|
-
this._renderList(out, W);
|
|
1143
|
-
if (this.preview) this._renderPreview(out, W);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
out.push(mv(H, 1));
|
|
1147
|
-
const foot = trunc(this._footer(W), W);
|
|
1148
|
-
out.push(clrLine, this._footerIsStatus ? fgc('93', foot) : styleHints(foot)
|
|
1149
|
-
.replace(/^ (SEARCH|RENAME):/, (_, l) => ` ${bold(fgc('93', l + ':'))}`));
|
|
1150
|
-
const cur = this._cursorPos();
|
|
1151
|
-
if (cur) out.push(mv(cur[0], Math.min(W, cur[1])), showCursor);
|
|
1152
|
-
process.stdout.write(out.join(''));
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
_renderList(out, W) {
|
|
1156
|
-
const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
|
|
1157
|
-
let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
|
|
1158
|
-
|
|
1159
|
-
for (let r = 0; r < this.listH; r++) {
|
|
1160
|
-
out.push(mv(r + 2, 1), clrLine);
|
|
1161
|
-
const item = visible[r];
|
|
1162
|
-
if (!item) continue;
|
|
1163
|
-
|
|
1164
|
-
if (item.type === 'config') {
|
|
1165
|
-
const count = `· ${item.count} session${item.count === 1 ? '' : 's'}`;
|
|
1166
|
-
const rule = '─'.repeat(Math.max(0, W - item.label.length - count.length - 6));
|
|
1167
|
-
out.push(dim('── ') + bold(fgc('96', item.label)) + ' ' + dim(`${count} ${rule}`));
|
|
1168
|
-
continue;
|
|
1169
|
-
}
|
|
1170
|
-
const s = item.session;
|
|
1171
|
-
const isSel = si === this.selIdx;
|
|
1172
|
-
const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
|
|
1173
|
-
const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
|
|
1174
|
-
const project = shortenPath(s.project);
|
|
1175
|
-
const meta = `${fmtSize(s.size).padStart(5)} ${fmtAgo(s.lastModified).padStart(8)}`;
|
|
1176
|
-
const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
|
|
1177
|
-
const avail = Math.max(12, W - 3 - cfgTag.length - 4 - meta.length - 1);
|
|
1178
|
-
const titleW = Math.floor(avail * 0.55);
|
|
1179
|
-
const projectW = avail - titleW;
|
|
1180
|
-
const titleTxt = pad(trunc(title, titleW), titleW);
|
|
1181
|
-
const projTxt = pad(trunc(project, projectW), projectW);
|
|
1182
|
-
// On the selected line every part gets an explicit color (see selLine).
|
|
1183
|
-
const muted = t => isSel ? fgc('37', t) : dim(t);
|
|
1184
|
-
const styled = fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
|
|
1185
|
-
(s.title ? groupTitle(titleTxt, isSel ? '1;92' : '92') : muted(titleTxt)) +
|
|
1186
|
-
` ${fgc('33', projTxt)} ${muted(meta)}`;
|
|
1187
|
-
|
|
1188
|
-
out.push(isSel ? selLine(styled, `${mark} ${cfgTag}${titleTxt} ${projTxt} ${meta}`, W) : ` ${styled}`);
|
|
1189
|
-
si++;
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
_renderPreview(out, W) {
|
|
1194
|
-
const top = this.listH + 2;
|
|
1195
|
-
out.push(mv(top, 1), clrLine, dim('─'.repeat(W)));
|
|
1196
|
-
const s = this.selectedSession();
|
|
1197
|
-
const lines = s ? this._previewLines(s, W, this.previewH - 1) : [];
|
|
1198
|
-
for (let r = 0; r < this.previewH - 1; r++) out.push(mv(top + 1 + r, 1), clrLine, lines[r] || '');
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// Where to show the terminal cursor when a text field is active, else null.
|
|
1202
|
-
_cursorPos() {
|
|
1203
|
-
const at = (row, prefix, f) => [row, prefix.length + [...f.before].length + 1];
|
|
1204
|
-
if (this.mode === 'search') return at(this.h, ' SEARCH: ', this.searchField);
|
|
1205
|
-
if (this.mode === 'rename') return at(this.h, ' RENAME: ', this.renameField);
|
|
1206
|
-
const p = this.mode === 'move' ? this.movePicker : this.mode === 'new' ? this.newPicker : null;
|
|
1207
|
-
if (p && p.step === 'input') return at(4, ` ${p._inputPrompt} `, p.field);
|
|
1208
|
-
return null;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
_footer(W) {
|
|
1212
|
-
this._footerIsStatus = false;
|
|
1213
|
-
const sel = this.selectedSession();
|
|
1214
|
-
const total = this.sessionItems().length;
|
|
1215
|
-
const pick = p => p.step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
|
|
1216
|
-
: p.step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
|
|
1217
|
-
: ` ↑↓/jk=navigate Enter=select Esc=back`;
|
|
1218
|
-
switch (this.mode) {
|
|
1219
|
-
case 'delete': {
|
|
1220
|
-
const t = this._deleteTargets();
|
|
1221
|
-
const name = t.length > 1 ? `${t.length} sessions` : `"${trunc(sel?.title || sel?.firstMessage || 'this session', 35)}"`;
|
|
1222
|
-
return ` Move ${name} to trash? y=yes n/Esc=cancel`;
|
|
1223
|
-
}
|
|
1224
|
-
case 'move': return pick(this.movePicker);
|
|
1225
|
-
case 'new': return pick(this.newPicker);
|
|
1226
|
-
case 'search': return ` SEARCH: ${this.searchField.text} ↑↓=select Enter=open Tab=keep filter Esc=cancel`;
|
|
1227
|
-
case 'rename': return ` RENAME: ${this.renameField.text} Enter=save Esc=cancel`;
|
|
1228
|
-
case 'help': return ` Press any key to close help`;
|
|
1229
|
-
}
|
|
1230
|
-
if (this.statusMsg) { this._footerIsStatus = true; return ` ${this.statusMsg}`; }
|
|
1231
|
-
const right = `${total} session${total === 1 ? '' : 's'}${this.marked.size ? ` · ${this.marked.size} marked` : ''} `;
|
|
1232
|
-
let left = '';
|
|
1233
|
-
for (const h of ['Enter=open', '/=search', 'p=preview', 'r=rename', 'm=move', 'd=delete', '?=more', 'q=quit']) {
|
|
1234
|
-
if (`${left} ${h}`.length + right.length + 2 > W) break;
|
|
1235
|
-
left += ` ${h}`;
|
|
1236
|
-
}
|
|
1237
|
-
return left + ' '.repeat(Math.max(2, W - left.length - right.length)) + right;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
// ── Input ───────────────────────────────────────────────────────────────────
|
|
1241
|
-
|
|
1242
|
-
handleKey(key) {
|
|
1243
|
-
if (this.mode === 'help') { this.mode = null; return true; }
|
|
1244
|
-
|
|
1245
|
-
if (this.mode === 'delete') {
|
|
1246
|
-
this.mode = null;
|
|
1247
|
-
if (key === 'y' || key === 'Y') this._doDelete();
|
|
1248
|
-
return true;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
if (this.mode === 'move') {
|
|
1252
|
-
if (this.movePicker.handleKey(key) === false) this.mode = null;
|
|
1253
|
-
return true;
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
if (this.mode === 'new') {
|
|
1257
|
-
if (this.newPicker.handleKey(key) === false) this.mode = null;
|
|
1258
|
-
return true;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
if (this.mode === 'search') {
|
|
1262
|
-
if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
|
|
1263
|
-
else if (key === '\t') { this.mode = null; }
|
|
1264
|
-
else if (key === '\r') { this.mode = null; const s = this.selectedSession(); if (s) this._open(resumePlan(s)); }
|
|
1265
|
-
else if (key === '\x1b[A' || key === '\x1b[B' || key === '\x1b[5~' || key === '\x1b[6~') {
|
|
1266
|
-
const total = this.sessionItems().length;
|
|
1267
|
-
const delta = { '\x1b[A': -1, '\x1b[B': 1, '\x1b[5~': -this.listH, '\x1b[6~': this.listH }[key];
|
|
1268
|
-
this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
|
|
1269
|
-
this.ensureVisible();
|
|
1270
|
-
}
|
|
1271
|
-
else if (this.searchField.handle(key) && this.searchField.text !== this.search) {
|
|
1272
|
-
this.search = this.searchField.text; this.rebuild();
|
|
1273
|
-
}
|
|
1274
|
-
return true;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
if (this.mode === 'rename') {
|
|
1278
|
-
if (key === '\x1b') { this.mode = null; }
|
|
1279
|
-
else if (key === '\r') {
|
|
1280
|
-
this.mode = null;
|
|
1281
|
-
const s = this.selectedSession();
|
|
1282
|
-
const name = this.renameField.text;
|
|
1283
|
-
if (s && name.trim()) {
|
|
1284
|
-
try { renameSession(s, name); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
|
|
1285
|
-
catch (e) { this.setStatus(`Rename failed: ${e.message}`, 5000); }
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
else this.renameField.handle(key);
|
|
1289
|
-
return true;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Normal mode
|
|
1293
|
-
const total = this.sessionItems().length;
|
|
1294
|
-
const move = (delta) => {
|
|
1295
|
-
this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
|
|
1296
|
-
this.ensureVisible();
|
|
1297
|
-
};
|
|
1298
|
-
const s = this.selectedSession();
|
|
1299
|
-
|
|
1300
|
-
if (key === 'q' || key === '\x03') return false;
|
|
1301
|
-
|
|
1302
|
-
if (key === '/') { this.searchField.set(this.search); this.mode = 'search'; }
|
|
1303
|
-
else if (key === '?') { this.mode = 'help'; }
|
|
1304
|
-
else if (key === '\x1b') { this.search = ''; this.marked.clear(); this.rebuild(); }
|
|
1305
|
-
else if (key === '\r') { if (s) this._open(resumePlan(s)); }
|
|
1306
|
-
else if (key === 'o') { if (s) this._open(resumePlan(s), true); }
|
|
1307
|
-
else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
|
|
1308
|
-
else if (key === 's') { this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length]; this.rebuild(); this.selectById(s?.sessionId); }
|
|
1309
|
-
else if (key === 'r') { if (s) { this.renameField.set(s.title || ''); this.mode = 'rename'; } }
|
|
1310
|
-
else if (key === ' ') {
|
|
1311
|
-
if (s) { this.marked.has(s.jsonlPath) ? this.marked.delete(s.jsonlPath) : this.marked.add(s.jsonlPath); move(+1); }
|
|
1312
|
-
}
|
|
1313
|
-
else if (key === 'd') { if (this._deleteTargets().length) this.mode = 'delete'; }
|
|
1314
|
-
else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
|
|
1315
|
-
else if (key === 'm') {
|
|
1316
|
-
if (s) { this.movePicker.enter(); this.mode = 'move'; }
|
|
1317
|
-
else this.setStatus('No session selected.');
|
|
1318
|
-
}
|
|
1319
|
-
else if (key === '\x1b[A' || key === 'k') move(-1);
|
|
1320
|
-
else if (key === '\x1b[B' || key === 'j') move(+1);
|
|
1321
|
-
else if (key === '\x1b[5~') move(-this.listH);
|
|
1322
|
-
else if (key === '\x1b[6~') move(+this.listH);
|
|
1323
|
-
else if (key === 'g') { this.selIdx = 0; this.ensureVisible(); }
|
|
1324
|
-
else if (key === 'G') { this.selIdx = Math.max(0, total - 1); this.ensureVisible(); }
|
|
1325
|
-
|
|
1326
|
-
return true;
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// Hand the terminal to a child process, then take it back.
|
|
1330
|
-
suspend() {
|
|
1331
|
-
process.stdin.removeListener('data', this._onData);
|
|
1332
|
-
process.stdin.setRawMode(false);
|
|
1333
|
-
process.stdin.pause();
|
|
1334
|
-
process.stdout.write(showCursor + normBuf);
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
resume() {
|
|
1338
|
-
process.stdin.setRawMode(true);
|
|
1339
|
-
process.stdin.resume();
|
|
1340
|
-
process.stdin.on('data', this._onData);
|
|
1341
|
-
this.reloadAll();
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
cleanup() { process.stdout.write(showCursor + normBuf); }
|
|
1345
|
-
|
|
1346
|
-
start() {
|
|
1347
|
-
process.stdin.setRawMode(true);
|
|
1348
|
-
process.stdin.resume();
|
|
1349
|
-
process.stdin.setEncoding('utf8');
|
|
1350
|
-
|
|
1351
|
-
process.stdout.on('resize', () => {
|
|
1352
|
-
this.w = process.stdout.columns;
|
|
1353
|
-
this.h = process.stdout.rows;
|
|
1354
|
-
this.ensureVisible();
|
|
1355
|
-
this.render();
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
this._onData = chunk => {
|
|
1359
|
-
for (const key of splitKeys(chunk)) {
|
|
1360
|
-
if (this.handleKey(key) === false) {
|
|
1361
|
-
this.cleanup();
|
|
1362
|
-
process.exit(0);
|
|
1363
|
-
}
|
|
1364
|
-
}
|
|
1365
|
-
this.render();
|
|
1366
|
-
};
|
|
1367
|
-
process.stdin.on('data', this._onData);
|
|
1368
|
-
this.render();
|
|
1369
|
-
|
|
1370
|
-
process.on('exit', () => this.cleanup());
|
|
1371
|
-
process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
1376
|
-
|
|
1377
|
-
function printHelp() {
|
|
1378
|
-
const src = fs.readFileSync(__filename, 'utf8');
|
|
1379
|
-
const match = src.match(/\/\*\*([\s\S]*?)\*\//);
|
|
1380
|
-
if (match) console.log(match[0].replace(/\/\*\*|\*\//g, '').replace(/^ \* ?/gm, '').trim());
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
function main() {
|
|
1384
|
-
const opts = parseArgs();
|
|
1385
|
-
if (opts.version) { console.log(VERSION); return; }
|
|
1386
|
-
if (opts.help) { printHelp(); return; }
|
|
1387
|
-
if (opts.noCache) cache.enabled = false;
|
|
1388
|
-
|
|
1389
|
-
if (opts.emptyTrash) {
|
|
1390
|
-
const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
|
|
1391
|
-
console.log(`Freed ${fmtSize(emptyTrash(dirs))}`);
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
const sessions = loadSessions(opts);
|
|
1396
|
-
|
|
1397
|
-
if (opts.json) {
|
|
1398
|
-
const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
|
|
1399
|
-
console.log(JSON.stringify(out, null, 2));
|
|
1400
|
-
} else if (opts.list || !process.stdout.isTTY || !process.stdin.isTTY) {
|
|
1401
|
-
printList(sessions);
|
|
1402
|
-
} else {
|
|
1403
|
-
new TUI(sessions, opts).start();
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
if (require.main === module) {
|
|
1408
|
-
main();
|
|
1409
|
-
} else {
|
|
1410
|
-
module.exports = {
|
|
1411
|
-
encodePathToDir, projectDirNameFor, tabComplete, resolveProjectPath, moveSession, readSession,
|
|
1412
|
-
readRecentMessages, renameSession, deleteSessionFiles, emptyTrash, scanConfigDir, sortSessions,
|
|
1413
|
-
detectTerminal, wtArgs, parseArgs, fmtAgo, fmtSize, splitKeys, cache, LineInput,
|
|
1414
|
-
};
|
|
1415
|
-
}
|