@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.
- package/README.md +52 -36
- package/index.js +807 -310
- 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
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
dirs = fs.readdirSync(home)
|
|
46
65
|
.filter(n => n === '.claude' || n.startsWith('.claude-'))
|
|
47
|
-
.map(n => path.join(home, n))
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
for (let i = 0; i <
|
|
67
|
-
const a =
|
|
68
|
-
if (a === '-d' || a === '--dir') opts.dir =
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
}
|
|
163
|
+
}
|
|
100
164
|
return { title, firstUserMessage, cwd, timestamp };
|
|
101
165
|
}
|
|
102
166
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
234
|
+
let names;
|
|
235
|
+
try { names = fs.readdirSync(pdFull).filter(f => f.endsWith('.jsonl')); } catch { continue; }
|
|
114
236
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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(
|
|
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
|
|
166
|
-
const
|
|
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
|
-
|
|
174
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
342
|
+
fs.mkdirSync(targetProjectDir, { recursive: true });
|
|
343
|
+
movePath(session.jsonlPath, targetJsonl);
|
|
188
344
|
|
|
189
|
-
const srcDir = session.jsonlPath
|
|
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
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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
|
|
232
|
-
const endsWithSlash = expanded
|
|
233
|
-
const baseDir = endsWithSlash ? (expanded.slice(0, -1) ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
if (
|
|
272
|
-
if (
|
|
273
|
-
if (
|
|
274
|
-
if (
|
|
275
|
-
if (
|
|
276
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
553
|
+
return { here: true };
|
|
328
554
|
}
|
|
329
555
|
}
|
|
330
556
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
const
|
|
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;
|
|
416
|
-
this.
|
|
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.
|
|
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
|
|
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.
|
|
455
|
-
this.
|
|
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.
|
|
790
|
+
const raw = this.field.text.trim();
|
|
462
791
|
this.completions = [];
|
|
463
|
-
if (raw)
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
801
|
+
const profiles = this._buildProfileItems();
|
|
802
|
+
if (this.step === 'folder' && profiles.length > 1) {
|
|
478
803
|
this.step = 'profile';
|
|
479
|
-
this.items =
|
|
804
|
+
this.items = profiles;
|
|
480
805
|
this.selIdx = 0;
|
|
481
806
|
this.scroll = 0;
|
|
482
|
-
|
|
483
|
-
return false; // cancel at profile step
|
|
807
|
+
return true;
|
|
484
808
|
}
|
|
485
|
-
return
|
|
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.
|
|
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 -
|
|
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.
|
|
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 ?
|
|
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,
|
|
909
|
+
constructor(sessions, opts = {}) {
|
|
555
910
|
this.all = sessions;
|
|
556
|
-
this.
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
643
|
-
for (const s of filtered) {
|
|
644
|
-
if (s.configDir !==
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
const targetConfig
|
|
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.
|
|
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.
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
|
|
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
|
-
|
|
800
|
-
|
|
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')
|
|
832
|
-
else if (key === '\
|
|
833
|
-
else if (key === '\
|
|
834
|
-
else if (key
|
|
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 === '/')
|
|
848
|
-
else if (key === '
|
|
849
|
-
else if (key === '\
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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')
|
|
860
|
-
else if (key === 'n')
|
|
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 (
|
|
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.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
|
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 = {
|
|
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
|
}
|