@vinh0809it/claude-sessions 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -36
- package/index.js +673 -289
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* claude-sessions --named-only Only /rename'd sessions
|
|
11
11
|
* claude-sessions --json JSON output
|
|
12
12
|
* claude-sessions -d <path> Scan a specific config dir
|
|
13
|
+
* claude-sessions --empty-trash Permanently remove deleted sessions
|
|
14
|
+
* claude-sessions --no-cache Ignore the metadata cache
|
|
13
15
|
* claude-sessions --version Print version
|
|
14
16
|
* claude-sessions -h Help
|
|
15
17
|
*
|
|
@@ -18,16 +20,23 @@
|
|
|
18
20
|
* PgUp PgDn Page up / down
|
|
19
21
|
* g G Jump to top / bottom
|
|
20
22
|
* / Search (shows [config] project inline)
|
|
21
|
-
* Esc Clear search
|
|
22
|
-
* Enter Open session
|
|
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,29 @@ 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
|
-
return
|
|
458
|
+
function detectTerminal(env = process.env) {
|
|
459
|
+
if (env.CLAUDE_SESSIONS_TERMINAL) return env.CLAUDE_SESSIONS_TERMINAL;
|
|
460
|
+
if (env.KITTY_WINDOW_ID !== undefined) return 'kitty';
|
|
461
|
+
if (env.WEZTERM_PANE !== undefined) return 'wezterm';
|
|
462
|
+
if (env.TMUX) return 'tmux';
|
|
463
|
+
if (env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
|
|
464
|
+
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
|
|
465
|
+
return 'here';
|
|
277
466
|
}
|
|
278
467
|
|
|
279
468
|
function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
469
|
+
function appleStr(s) { return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; }
|
|
280
470
|
|
|
281
471
|
function trySpawn(cmd, args) {
|
|
282
472
|
const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
473
|
+
if (r.error) return { ok: false, msg: `${cmd}: ${r.error.code === 'ENOENT' ? 'not found' : r.error.message}` };
|
|
283
474
|
return r.status === 0
|
|
284
475
|
? { ok: true }
|
|
285
476
|
: { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
|
|
@@ -287,12 +478,10 @@ function trySpawn(cmd, args) {
|
|
|
287
478
|
|
|
288
479
|
function resolveProjectPath(session) {
|
|
289
480
|
const sessionDir = path.dirname(session.jsonlPath);
|
|
290
|
-
// Check hint file first (written at move time; survives restarts)
|
|
291
481
|
try {
|
|
292
482
|
const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
|
|
293
483
|
if (hint && fs.existsSync(hint)) return hint;
|
|
294
484
|
} catch {}
|
|
295
|
-
// Fall back to a sibling session whose cwd resolves to an existing path
|
|
296
485
|
try {
|
|
297
486
|
const siblings = fs.readdirSync(sessionDir)
|
|
298
487
|
.filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
|
|
@@ -304,49 +493,80 @@ function resolveProjectPath(session) {
|
|
|
304
493
|
return fs.existsSync(session.project) ? session.project : os.homedir();
|
|
305
494
|
}
|
|
306
495
|
|
|
307
|
-
|
|
496
|
+
// A launch plan: where to run, which claude args, under which config dir.
|
|
497
|
+
function resumePlan(session) {
|
|
498
|
+
return {
|
|
499
|
+
cwd: resolveProjectPath(session),
|
|
500
|
+
args: ['-r', session.sessionId],
|
|
501
|
+
configDirPath: session.configDirPath,
|
|
502
|
+
title: session.title || session.sessionId.slice(0, 8),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function newPlan(configDirPath, projectPath) {
|
|
507
|
+
return { cwd: projectPath, args: [], configDirPath, title: path.basename(projectPath) };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function planShellCmd(plan) {
|
|
511
|
+
const claude = ['claude', ...plan.args].join(' ');
|
|
512
|
+
return `cd ${shellEsc(plan.cwd)} && CLAUDE_CONFIG_DIR=${shellEsc(plan.configDirPath)} ${claude}; exec "\${SHELL:-bash}"`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Returns { ok, msg } or { here: true } when the caller should run it in this terminal.
|
|
516
|
+
function launchInTab(plan) {
|
|
517
|
+
const shellCmd = planShellCmd(plan);
|
|
308
518
|
const customTpl = process.env.CLAUDE_SESSIONS_OPEN_CMD;
|
|
309
519
|
if (customTpl) {
|
|
310
520
|
const expanded = customTpl
|
|
311
521
|
.replace(/%cmd%/g, shellEsc(shellCmd))
|
|
312
|
-
.replace(/%title%/g, shellEsc(title))
|
|
313
|
-
.replace(/%project%/g, shellEsc(
|
|
522
|
+
.replace(/%title%/g, shellEsc(plan.title))
|
|
523
|
+
.replace(/%project%/g, shellEsc(plan.cwd));
|
|
314
524
|
const r = spawnSync('bash', ['-c', expanded], { stdio: 'ignore' });
|
|
315
525
|
return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
|
|
316
526
|
}
|
|
317
527
|
|
|
318
|
-
|
|
319
|
-
switch (term) {
|
|
528
|
+
switch (detectTerminal()) {
|
|
320
529
|
case 'kitty':
|
|
321
|
-
return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${title}`, 'bash', '-i', '-c', shellCmd]);
|
|
530
|
+
return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${plan.title}`, 'bash', '-i', '-c', shellCmd]);
|
|
322
531
|
case 'wezterm':
|
|
323
532
|
return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
|
|
324
533
|
case 'tmux':
|
|
325
|
-
return trySpawn('tmux', ['new-window', '-n', title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
|
|
534
|
+
return trySpawn('tmux', ['new-window', '-n', plan.title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
|
|
535
|
+
case 'iterm2':
|
|
536
|
+
return trySpawn('osascript', ['-e',
|
|
537
|
+
`tell application "iTerm2" to tell current window to create tab with default profile command ${appleStr('bash -i -c ' + shellEsc(shellCmd))}`]);
|
|
538
|
+
case 'apple-terminal':
|
|
539
|
+
return trySpawn('osascript', ['-e', `tell application "Terminal" to do script ${appleStr(shellCmd)}`]);
|
|
326
540
|
default:
|
|
327
|
-
return {
|
|
541
|
+
return { here: true };
|
|
328
542
|
}
|
|
329
543
|
}
|
|
330
544
|
|
|
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);
|
|
545
|
+
// Runs claude in the current terminal and waits for it to exit.
|
|
546
|
+
function runHere(plan) {
|
|
547
|
+
const r = spawnSync('claude', plan.args, {
|
|
548
|
+
cwd: plan.cwd,
|
|
549
|
+
env: { ...process.env, CLAUDE_CONFIG_DIR: plan.configDirPath },
|
|
550
|
+
stdio: 'inherit',
|
|
551
|
+
shell: IS_WIN, // claude may be a .cmd shim on Windows
|
|
552
|
+
});
|
|
553
|
+
if (r.error) return { ok: false, msg: r.error.code === 'ENOENT' ? '`claude` not found on PATH' : r.error.message };
|
|
554
|
+
return { ok: true };
|
|
344
555
|
}
|
|
345
556
|
|
|
346
557
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
347
558
|
|
|
348
559
|
function fmtDate(d) {
|
|
349
|
-
|
|
560
|
+
const t = new Date(d), p = n => String(n).padStart(2, '0');
|
|
561
|
+
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function fmtSize(n) {
|
|
565
|
+
if (n < 1024) return `${n}B`;
|
|
566
|
+
const u = ['K', 'M', 'G'];
|
|
567
|
+
let i = -1;
|
|
568
|
+
do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
|
|
569
|
+
return `${n < 10 ? n.toFixed(1) : Math.round(n)}${u[i]}`;
|
|
350
570
|
}
|
|
351
571
|
|
|
352
572
|
function shortConfig(configDir) {
|
|
@@ -355,7 +575,19 @@ function shortConfig(configDir) {
|
|
|
355
575
|
|
|
356
576
|
function shortenPath(p) {
|
|
357
577
|
const home = os.homedir();
|
|
358
|
-
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
578
|
+
return p === home || p.startsWith(home + path.sep) || p.startsWith(home + '/') ? '~' + p.slice(home.length) : p;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const SORTS = ['date', 'size', 'name'];
|
|
582
|
+
function sortSessions(sessions, mode) {
|
|
583
|
+
const order = new Map();
|
|
584
|
+
for (const s of sessions) if (!order.has(s.configDir)) order.set(s.configDir, order.size);
|
|
585
|
+
const key = {
|
|
586
|
+
date: (a, b) => b.lastModified - a.lastModified,
|
|
587
|
+
size: (a, b) => b.size - a.size,
|
|
588
|
+
name: (a, b) => (a.title || a.firstMessage || '~').localeCompare(b.title || b.firstMessage || '~'),
|
|
589
|
+
}[mode];
|
|
590
|
+
return [...sessions].sort((a, b) => (order.get(a.configDir) - order.get(b.configDir)) || key(a, b));
|
|
359
591
|
}
|
|
360
592
|
|
|
361
593
|
// ─── Plain list output ────────────────────────────────────────────────────────
|
|
@@ -378,7 +610,7 @@ function printList(sessions) {
|
|
|
378
610
|
const label = s.title
|
|
379
611
|
? ansi('1;32', s.title)
|
|
380
612
|
: ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
|
|
381
|
-
console.log(` ${label} ${ansi('2', fmtDate(s.lastModified))}`);
|
|
613
|
+
console.log(` ${label} ${ansi('2', `${fmtDate(s.lastModified)} ${fmtSize(s.size)}`)}`);
|
|
382
614
|
console.log(ansi('2', ` id: ${s.sessionId}`));
|
|
383
615
|
}
|
|
384
616
|
}
|
|
@@ -402,6 +634,17 @@ const bgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
|
|
|
402
634
|
const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
403
635
|
const pad = (s, n) => s.padEnd(n).slice(0, n);
|
|
404
636
|
|
|
637
|
+
const HELP = [
|
|
638
|
+
['↑↓ / j k', 'Navigate'], ['PgUp PgDn', 'Page up / down'],
|
|
639
|
+
['g / G', 'Top / bottom'], ['/', 'Search titles, messages, paths'],
|
|
640
|
+
['Esc', 'Clear search / marks'], ['Enter', 'Open (new tab if supported, else here)'],
|
|
641
|
+
['o', 'Open in this terminal'], ['p', 'Toggle preview'],
|
|
642
|
+
['r', 'Rename'], ['s', 'Sort: date / size / name'],
|
|
643
|
+
['Space', 'Mark for bulk delete'], ['n', 'New session'],
|
|
644
|
+
['m', 'Move to profile / folder'], ['d', 'Delete marked or selected (to trash)'],
|
|
645
|
+
['q / Ctrl-C', 'Quit'],
|
|
646
|
+
];
|
|
647
|
+
|
|
405
648
|
// ─── Picker ───────────────────────────────────────────────────────────────────
|
|
406
649
|
// Shared profile → folder → input flow used by both move and new modes.
|
|
407
650
|
// step: 'profile' | 'folder' | 'input'
|
|
@@ -412,7 +655,7 @@ class Picker {
|
|
|
412
655
|
this.items = [];
|
|
413
656
|
this.selIdx = 0;
|
|
414
657
|
this.scroll = 0;
|
|
415
|
-
this.config = null;
|
|
658
|
+
this.config = null;
|
|
416
659
|
this.input = '';
|
|
417
660
|
this.completions = [];
|
|
418
661
|
|
|
@@ -433,6 +676,8 @@ class Picker {
|
|
|
433
676
|
this.config = null;
|
|
434
677
|
this.input = '';
|
|
435
678
|
this.completions = [];
|
|
679
|
+
// Skip the profile step when there is only one.
|
|
680
|
+
if (this.items.length === 1) this._enterFolderStep(this.items[0].configDirPath);
|
|
436
681
|
}
|
|
437
682
|
|
|
438
683
|
_enterFolderStep(configDirPath) {
|
|
@@ -443,7 +688,7 @@ class Picker {
|
|
|
443
688
|
this.scroll = 0;
|
|
444
689
|
}
|
|
445
690
|
|
|
446
|
-
// Returns false when the picker should be dismissed
|
|
691
|
+
// Returns false when the picker should be dismissed.
|
|
447
692
|
handleKey(key) {
|
|
448
693
|
if (this.step === 'input') {
|
|
449
694
|
if (key === '\x1b') {
|
|
@@ -454,19 +699,15 @@ class Picker {
|
|
|
454
699
|
const { completed, matches } = tabComplete(this.input || '~/');
|
|
455
700
|
this.input = completed;
|
|
456
701
|
this.completions = matches.length > 1 ? matches : [];
|
|
457
|
-
} else if (key === '\x7f') {
|
|
702
|
+
} else if (key === '\x7f' || key === '\b') {
|
|
458
703
|
this.input = this.input.slice(0, -1);
|
|
459
704
|
this.completions = [];
|
|
460
705
|
} else if (key === '\r') {
|
|
461
706
|
const raw = this.input.trim();
|
|
462
707
|
this.completions = [];
|
|
463
|
-
if (raw)
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
} else {
|
|
467
|
-
this.step = 'folder';
|
|
468
|
-
}
|
|
469
|
-
} else if (key.length === 1 && key >= ' ') {
|
|
708
|
+
if (raw) this._onConfirmCustom(path.resolve(expandHome(raw).replace(/[\\/]+$/, '') || path.sep));
|
|
709
|
+
else this.step = 'folder';
|
|
710
|
+
} else if (isPrintable(key)) {
|
|
470
711
|
this.input += key;
|
|
471
712
|
this.completions = [];
|
|
472
713
|
}
|
|
@@ -474,15 +715,15 @@ class Picker {
|
|
|
474
715
|
}
|
|
475
716
|
|
|
476
717
|
if (key === '\x1b') {
|
|
477
|
-
|
|
718
|
+
const profiles = this._buildProfileItems();
|
|
719
|
+
if (this.step === 'folder' && profiles.length > 1) {
|
|
478
720
|
this.step = 'profile';
|
|
479
|
-
this.items =
|
|
721
|
+
this.items = profiles;
|
|
480
722
|
this.selIdx = 0;
|
|
481
723
|
this.scroll = 0;
|
|
482
|
-
|
|
483
|
-
return false; // cancel at profile step
|
|
724
|
+
return true;
|
|
484
725
|
}
|
|
485
|
-
return
|
|
726
|
+
return false;
|
|
486
727
|
}
|
|
487
728
|
|
|
488
729
|
if (key === '\x1b[A' || key === 'k') { this.selIdx = Math.max(0, this.selIdx - 1); return true; }
|
|
@@ -503,7 +744,7 @@ class Picker {
|
|
|
503
744
|
out.push(mv(2, 1), clrLine, ` ${headerLabel}`);
|
|
504
745
|
|
|
505
746
|
out.push(mv(3, 1), clrLine);
|
|
506
|
-
const divider = dim('─'.repeat(Math.max(0, W -
|
|
747
|
+
const divider = dim('─'.repeat(Math.max(0, W - 30)));
|
|
507
748
|
if (this.step === 'profile') {
|
|
508
749
|
out.push(` ` + bold(this._profileLabel) + ` ` + divider);
|
|
509
750
|
} else {
|
|
@@ -543,24 +784,61 @@ class Picker {
|
|
|
543
784
|
: item.type === 'new' ? fgc('93', item.label)
|
|
544
785
|
: item.type === 'profile' ? bold(fgc('96', item.label))
|
|
545
786
|
: fgc('33', item.label);
|
|
546
|
-
out.push(isSel ? inv(pad(` ${item.label}`, W)) : ` ${label}`);
|
|
787
|
+
out.push(isSel ? inv(pad(` ${trunc(item.label, W - 4)}`, W)) : ` ${label}`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Word-wraps text to `width` columns; words longer than a line are split.
|
|
793
|
+
function wrapWords(text, width) {
|
|
794
|
+
const lines = [];
|
|
795
|
+
let cur = '';
|
|
796
|
+
for (let word of text.split(' ')) {
|
|
797
|
+
while (word.length > width) {
|
|
798
|
+
if (cur) { lines.push(cur); cur = ''; }
|
|
799
|
+
lines.push(word.slice(0, width)); word = word.slice(width);
|
|
547
800
|
}
|
|
801
|
+
if (!cur) cur = word;
|
|
802
|
+
else if (cur.length + 1 + word.length <= width) cur += ' ' + word;
|
|
803
|
+
else { lines.push(cur); cur = word; }
|
|
548
804
|
}
|
|
805
|
+
if (cur || !lines.length) lines.push(cur);
|
|
806
|
+
return lines;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// A stdin chunk may hold several keys (fast typing, paste): split into single
|
|
810
|
+
// chars, keeping escape sequences like "\x1b[A" or "\x1b[5~" whole.
|
|
811
|
+
function splitKeys(chunk) {
|
|
812
|
+
const keys = [];
|
|
813
|
+
const re = /\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Za-z]|\x1b|[\s\S]/gu;
|
|
814
|
+
let m;
|
|
815
|
+
while ((m = re.exec(chunk))) keys.push(m[0]);
|
|
816
|
+
return keys;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function isPrintable(key) {
|
|
820
|
+
return key.length >= 1 && !key.startsWith('\x1b') && [...key].every(ch => ch >= ' ' && ch !== '\x7f');
|
|
549
821
|
}
|
|
550
822
|
|
|
551
823
|
// ─── TUI ─────────────────────────────────────────────────────────────────────
|
|
552
824
|
|
|
553
825
|
class TUI {
|
|
554
|
-
constructor(sessions,
|
|
826
|
+
constructor(sessions, opts = {}) {
|
|
555
827
|
this.all = sessions;
|
|
556
|
-
this.
|
|
828
|
+
this.opts = opts;
|
|
829
|
+
this.filterPath = opts.filterPath || '';
|
|
557
830
|
this.search = '';
|
|
558
|
-
this.mode = null; // null | 'search' | 'delete' | 'move' | 'new'
|
|
831
|
+
this.mode = null; // null | 'search' | 'delete' | 'move' | 'new' | 'rename' | 'help'
|
|
559
832
|
this.statusMsg = '';
|
|
560
833
|
this._statusTimer = null;
|
|
561
834
|
this.selIdx = 0;
|
|
562
835
|
this.scrollTop = 0;
|
|
563
836
|
this.items = [];
|
|
837
|
+
this.sort = 'date';
|
|
838
|
+
this.marked = new Set(); // jsonlPath
|
|
839
|
+
this.preview = false;
|
|
840
|
+
this._previewCache = new Map();
|
|
841
|
+
this.renameInput = '';
|
|
564
842
|
this.w = process.stdout.columns || 80;
|
|
565
843
|
this.h = process.stdout.rows || 24;
|
|
566
844
|
|
|
@@ -569,7 +847,8 @@ class TUI {
|
|
|
569
847
|
this.rebuild();
|
|
570
848
|
}
|
|
571
849
|
|
|
572
|
-
get
|
|
850
|
+
get previewH() { return this.preview ? Math.max(4, Math.floor((this.h - 2) * 0.4)) : 0; }
|
|
851
|
+
get listH() { return this.h - 2 - this.previewH; }
|
|
573
852
|
|
|
574
853
|
_createMovePicker() {
|
|
575
854
|
return new Picker({
|
|
@@ -589,7 +868,7 @@ class TUI {
|
|
|
589
868
|
}));
|
|
590
869
|
},
|
|
591
870
|
buildFolderItems: (configDirPath) => {
|
|
592
|
-
const s
|
|
871
|
+
const s = this.selectedSession();
|
|
593
872
|
const isSameConfig = configDirPath === s?.configDirPath;
|
|
594
873
|
const srcDirName = s ? path.basename(path.dirname(s.jsonlPath)) : null;
|
|
595
874
|
const items = [];
|
|
@@ -603,7 +882,10 @@ class TUI {
|
|
|
603
882
|
return items;
|
|
604
883
|
},
|
|
605
884
|
onConfirmItem: (item) => this._doMove(item.dirName),
|
|
606
|
-
onConfirmCustom: (absPath) =>
|
|
885
|
+
onConfirmCustom: (absPath) => {
|
|
886
|
+
try { this._doMove(projectDirNameFor(this.movePicker.config, absPath), absPath); }
|
|
887
|
+
catch (e) { this.mode = null; this.setStatus(`Move failed: ${e.message}`, 5000); }
|
|
888
|
+
},
|
|
607
889
|
});
|
|
608
890
|
}
|
|
609
891
|
|
|
@@ -618,7 +900,7 @@ class TUI {
|
|
|
618
900
|
...listProjectFolders(configDirPath).map(f => ({
|
|
619
901
|
type: 'folder',
|
|
620
902
|
label: f.displayPath,
|
|
621
|
-
projectPath: f.displayPath
|
|
903
|
+
projectPath: expandHome(f.displayPath),
|
|
622
904
|
})),
|
|
623
905
|
{ type: 'new', label: '[type a new path…]', projectPath: null },
|
|
624
906
|
],
|
|
@@ -640,7 +922,7 @@ class TUI {
|
|
|
640
922
|
|
|
641
923
|
this.items = [];
|
|
642
924
|
let lastConfig = null;
|
|
643
|
-
for (const s of filtered) {
|
|
925
|
+
for (const s of sortSessions(filtered, this.sort)) {
|
|
644
926
|
if (s.configDir !== lastConfig) {
|
|
645
927
|
this.items.push({ type: 'config', label: s.configDir });
|
|
646
928
|
lastConfig = s.configDir;
|
|
@@ -682,36 +964,89 @@ class TUI {
|
|
|
682
964
|
}
|
|
683
965
|
|
|
684
966
|
reloadAll() {
|
|
685
|
-
|
|
967
|
+
const keep = this.selectedSession()?.sessionId;
|
|
968
|
+
this.all = loadSessions(this.opts);
|
|
969
|
+
this._previewCache.clear();
|
|
970
|
+
this.rebuild();
|
|
971
|
+
this.selectById(keep);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
selectById(sessionId) {
|
|
975
|
+
if (!sessionId) return;
|
|
976
|
+
const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
|
|
977
|
+
if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
|
|
686
978
|
}
|
|
687
979
|
|
|
688
980
|
_doMove(targetDirName, targetRawPath = null) {
|
|
689
981
|
const s = this.selectedSession();
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
const targetConfig
|
|
982
|
+
this.mode = null;
|
|
983
|
+
if (!s) return;
|
|
984
|
+
const targetConfig = this.movePicker.config;
|
|
693
985
|
try {
|
|
694
986
|
moveSession(s, targetConfig, targetDirName, targetRawPath);
|
|
695
|
-
this.mode = null;
|
|
696
987
|
this.reloadAll();
|
|
697
|
-
this.
|
|
698
|
-
const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
|
|
699
|
-
if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
|
|
988
|
+
this.selectById(s.sessionId);
|
|
700
989
|
this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
|
|
701
990
|
} catch (e) {
|
|
702
|
-
this.
|
|
703
|
-
this.setStatus(`Move failed: ${e.message}`, 4000);
|
|
991
|
+
this.setStatus(`Move failed: ${e.message}`, 5000);
|
|
704
992
|
}
|
|
705
993
|
}
|
|
706
994
|
|
|
995
|
+
_open(plan, forceHere = false) {
|
|
996
|
+
const r = forceHere ? { here: true } : launchInTab(plan);
|
|
997
|
+
if (r.here) {
|
|
998
|
+
if (!fs.existsSync(plan.cwd)) { this.setStatus(`Folder not found: ${plan.cwd}`, 5000); return; }
|
|
999
|
+
this.suspend();
|
|
1000
|
+
const res = runHere(plan);
|
|
1001
|
+
this.resume();
|
|
1002
|
+
if (!res.ok) this.setStatus(res.msg, 5000);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
this.setStatus(r.ok ? `Opened "${plan.title}" in new tab` : r.msg, r.ok ? 2500 : 5000);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
707
1008
|
_doNew(projectPath) {
|
|
708
|
-
if (!projectPath) return;
|
|
709
|
-
const result = openNewSession(this.newPicker.config, projectPath);
|
|
710
1009
|
this.mode = null;
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1010
|
+
if (!projectPath) return;
|
|
1011
|
+
this._open(newPlan(this.newPicker.config, projectPath));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
_deleteTargets() {
|
|
1015
|
+
if (this.marked.size) return this.all.filter(s => this.marked.has(s.jsonlPath));
|
|
1016
|
+
const s = this.selectedSession();
|
|
1017
|
+
return s ? [s] : [];
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
_doDelete() {
|
|
1021
|
+
const targets = this._deleteTargets();
|
|
1022
|
+
const failed = [];
|
|
1023
|
+
for (const s of targets) {
|
|
1024
|
+
try { deleteSessionFiles(s); } catch (e) { failed.push(e.message); }
|
|
1025
|
+
}
|
|
1026
|
+
const gone = new Set(targets.map(s => s.jsonlPath));
|
|
1027
|
+
this.all = this.all.filter(x => !gone.has(x.jsonlPath) || fs.existsSync(x.jsonlPath));
|
|
1028
|
+
this.marked.clear();
|
|
1029
|
+
this.rebuild();
|
|
1030
|
+
this.setStatus(failed.length
|
|
1031
|
+
? `Delete failed for ${failed.length}: ${failed[0]}`
|
|
1032
|
+
: `Moved ${targets.length} session(s) to trash — run with --empty-trash to free space`, 4000);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ── Preview ─────────────────────────────────────────────────────────────────
|
|
1036
|
+
|
|
1037
|
+
_previewLines(s, W, H) {
|
|
1038
|
+
const key = s.jsonlPath + ':' + s.lastModified.valueOf();
|
|
1039
|
+
let msgs = this._previewCache.get(key);
|
|
1040
|
+
if (!msgs) { msgs = readRecentMessages(s.jsonlPath); this._previewCache.set(key, msgs); }
|
|
1041
|
+
const lines = [];
|
|
1042
|
+
for (const m of msgs) {
|
|
1043
|
+
const tag = m.role === 'user' ? fgc('92', 'you ') : fgc('96', 'cc ');
|
|
1044
|
+
const width = Math.max(10, W - 8);
|
|
1045
|
+
const chunks = wrapWords(m.text, width);
|
|
1046
|
+
chunks.slice(0, 3).forEach((c, i) => lines.push(` ${i === 0 ? tag : ' '} ${i === 2 && chunks.length > 3 ? trunc(c, width - 1) + '…' : c}`));
|
|
1047
|
+
}
|
|
1048
|
+
if (!lines.length) lines.push(dim(' (no messages)'));
|
|
1049
|
+
return lines.slice(-H);
|
|
715
1050
|
}
|
|
716
1051
|
|
|
717
1052
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
@@ -720,100 +1055,106 @@ class TUI {
|
|
|
720
1055
|
const W = this.w, H = this.h;
|
|
721
1056
|
const out = [hideCursor, altBuf, clrScreen, mv(1, 1)];
|
|
722
1057
|
|
|
723
|
-
const filterInfo = this.filterPath ? ` path:${this.filterPath}` : '';
|
|
1058
|
+
const filterInfo = this.filterPath ? ` path:${shortenPath(this.filterPath)}` : '';
|
|
724
1059
|
const searchInfo = this.search ? ` search:${this.search}` : '';
|
|
725
|
-
|
|
1060
|
+
const sortInfo = this.sort !== 'date' ? ` sort:${this.sort}` : '';
|
|
1061
|
+
out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}${sortInfo}`, W), W)))));
|
|
726
1062
|
|
|
727
1063
|
if (this.mode === 'move') {
|
|
728
1064
|
this.movePicker.render(out, W, H);
|
|
729
1065
|
} else if (this.mode === 'new') {
|
|
730
1066
|
this.newPicker.render(out, W, H);
|
|
1067
|
+
} else if (this.mode === 'help') {
|
|
1068
|
+
HELP.forEach(([k, d], i) => out.push(mv(i + 3, 1), clrLine, ` ${fgc('93', pad(k, 14))}${d}`));
|
|
1069
|
+
out.push(mv(HELP.length + 4, 1), dim(' Env: CLAUDE_SESSIONS_TERMINAL, CLAUDE_SESSIONS_OPEN_CMD, CLAUDE_CONFIG_DIR'));
|
|
731
1070
|
} else {
|
|
732
|
-
|
|
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
|
-
}
|
|
1071
|
+
this._renderList(out, W);
|
|
1072
|
+
if (this.preview) this._renderPreview(out, W);
|
|
769
1073
|
}
|
|
770
1074
|
|
|
771
|
-
// Footer
|
|
772
1075
|
out.push(mv(H, 1));
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1076
|
+
out.push(bgc('40', fgc('37', pad(trunc(this._footer(W), W), W))));
|
|
1077
|
+
process.stdout.write(out.join(''));
|
|
1078
|
+
}
|
|
776
1079
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
const
|
|
796
|
-
|
|
1080
|
+
_renderList(out, W) {
|
|
1081
|
+
const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
|
|
1082
|
+
let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
|
|
1083
|
+
|
|
1084
|
+
for (let r = 0; r < this.listH; r++) {
|
|
1085
|
+
out.push(mv(r + 2, 1), clrLine);
|
|
1086
|
+
const item = visible[r];
|
|
1087
|
+
if (!item) continue;
|
|
1088
|
+
|
|
1089
|
+
if (item.type === 'config') {
|
|
1090
|
+
out.push(bold(fgc('96', ` [${item.label}]`)));
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const s = item.session;
|
|
1094
|
+
const isSel = si === this.selIdx;
|
|
1095
|
+
const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
|
|
1096
|
+
const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
|
|
1097
|
+
const project = shortenPath(s.project);
|
|
1098
|
+
const meta = `${fmtSize(s.size).padStart(5)} ${fmtDate(s.lastModified)}`;
|
|
1099
|
+
const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
|
|
1100
|
+
const avail = Math.max(12, W - 2 - cfgTag.length - 2 - 2 - meta.length);
|
|
1101
|
+
const titleW = Math.floor(avail * 0.55);
|
|
1102
|
+
const projectW = avail - titleW;
|
|
1103
|
+
|
|
1104
|
+
if (isSel) {
|
|
1105
|
+
out.push(inv(pad(`${mark} ${cfgTag}${pad(trunc(title, titleW), titleW)} ${pad(trunc(project, projectW), projectW)} ${meta}`, W)));
|
|
1106
|
+
} else {
|
|
1107
|
+
out.push(
|
|
1108
|
+
fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
|
|
1109
|
+
(s.title ? fgc('92', pad(trunc(title, titleW), titleW)) : dim(pad(trunc(title, titleW), titleW))) +
|
|
1110
|
+
` ${fgc('33', pad(trunc(project, projectW), projectW))}` +
|
|
1111
|
+
` ${dim(meta)}`
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
si++;
|
|
797
1115
|
}
|
|
1116
|
+
}
|
|
798
1117
|
|
|
799
|
-
|
|
800
|
-
|
|
1118
|
+
_renderPreview(out, W) {
|
|
1119
|
+
const top = this.listH + 2;
|
|
1120
|
+
out.push(mv(top, 1), clrLine, dim('─'.repeat(W)));
|
|
1121
|
+
const s = this.selectedSession();
|
|
1122
|
+
const lines = s ? this._previewLines(s, W, this.previewH - 1) : [];
|
|
1123
|
+
for (let r = 0; r < this.previewH - 1; r++) out.push(mv(top + 1 + r, 1), clrLine, lines[r] || '');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
_footer(W) {
|
|
1127
|
+
const sel = this.selectedSession();
|
|
1128
|
+
const total = this.sessionItems().length;
|
|
1129
|
+
const pick = p => p.step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
|
|
1130
|
+
: p.step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
|
|
1131
|
+
: ` ↑↓/jk=navigate Enter=select Esc=back`;
|
|
1132
|
+
switch (this.mode) {
|
|
1133
|
+
case 'delete': {
|
|
1134
|
+
const t = this._deleteTargets();
|
|
1135
|
+
const name = t.length > 1 ? `${t.length} sessions` : `"${trunc(sel?.title || sel?.firstMessage || 'this session', 35)}"`;
|
|
1136
|
+
return ` Move ${name} to trash? y=yes n/Esc=cancel`;
|
|
1137
|
+
}
|
|
1138
|
+
case 'move': return pick(this.movePicker);
|
|
1139
|
+
case 'new': return pick(this.newPicker);
|
|
1140
|
+
case 'search': return ` SEARCH: ${this.search}█ Esc=cancel Enter=done`;
|
|
1141
|
+
case 'rename': return ` RENAME: ${this.renameInput}█ Enter=save Esc=cancel`;
|
|
1142
|
+
case 'help': return ` Press any key to close help`;
|
|
1143
|
+
}
|
|
1144
|
+
if (this.statusMsg) return ` ${this.statusMsg}`;
|
|
1145
|
+
const marks = this.marked.size ? ` ${this.marked.size} marked` : '';
|
|
1146
|
+
const ctx = sel ? ` ${dim(trunc(shortenPath(sel.project), Math.max(0, W - 80)))}` : '';
|
|
1147
|
+
return ` Enter=open o=here /=search p=preview r=rename m=move d=del ?=help q=quit [${total}]${marks}${ctx}`;
|
|
801
1148
|
}
|
|
802
1149
|
|
|
803
1150
|
// ── Input ───────────────────────────────────────────────────────────────────
|
|
804
1151
|
|
|
805
1152
|
handleKey(key) {
|
|
1153
|
+
if (this.mode === 'help') { this.mode = null; return true; }
|
|
1154
|
+
|
|
806
1155
|
if (this.mode === 'delete') {
|
|
807
1156
|
this.mode = null;
|
|
808
|
-
if (key === 'y' || key === 'Y')
|
|
809
|
-
const s = this.selectedSession();
|
|
810
|
-
if (s) {
|
|
811
|
-
deleteSessionFiles(s);
|
|
812
|
-
this.all = this.all.filter(x => x.sessionId !== s.sessionId);
|
|
813
|
-
this.setStatus(`Deleted: ${s.title || s.sessionId.slice(0, 8)}`);
|
|
814
|
-
this.rebuild();
|
|
815
|
-
}
|
|
816
|
-
}
|
|
1157
|
+
if (key === 'y' || key === 'Y') this._doDelete();
|
|
817
1158
|
return true;
|
|
818
1159
|
}
|
|
819
1160
|
|
|
@@ -828,10 +1169,25 @@ class TUI {
|
|
|
828
1169
|
}
|
|
829
1170
|
|
|
830
1171
|
if (this.mode === 'search') {
|
|
831
|
-
if (key === '\x1b')
|
|
832
|
-
else if (key === '\r')
|
|
833
|
-
else if (key === '\x7f')
|
|
834
|
-
else if (key
|
|
1172
|
+
if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
|
|
1173
|
+
else if (key === '\r') { this.mode = null; }
|
|
1174
|
+
else if (key === '\x7f' || key === '\b') { this.search = this.search.slice(0, -1); this.rebuild(); }
|
|
1175
|
+
else if (isPrintable(key)) { this.search += key; this.rebuild(); }
|
|
1176
|
+
return true;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (this.mode === 'rename') {
|
|
1180
|
+
if (key === '\x1b') { this.mode = null; }
|
|
1181
|
+
else if (key === '\x7f' || key === '\b') { this.renameInput = this.renameInput.slice(0, -1); }
|
|
1182
|
+
else if (key === '\r') {
|
|
1183
|
+
this.mode = null;
|
|
1184
|
+
const s = this.selectedSession();
|
|
1185
|
+
if (s && this.renameInput.trim()) {
|
|
1186
|
+
try { renameSession(s, this.renameInput); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
|
|
1187
|
+
catch (e) { this.setStatus(`Rename failed: ${e.message}`, 5000); }
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
else if (isPrintable(key)) { this.renameInput += key; }
|
|
835
1191
|
return true;
|
|
836
1192
|
}
|
|
837
1193
|
|
|
@@ -841,25 +1197,25 @@ class TUI {
|
|
|
841
1197
|
this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
|
|
842
1198
|
this.ensureVisible();
|
|
843
1199
|
};
|
|
1200
|
+
const s = this.selectedSession();
|
|
844
1201
|
|
|
845
1202
|
if (key === 'q' || key === '\x03') return false;
|
|
846
1203
|
|
|
847
|
-
if (key === '/')
|
|
848
|
-
else if (key === '
|
|
849
|
-
else if (key === '\
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
}
|
|
1204
|
+
if (key === '/') { this.mode = 'search'; }
|
|
1205
|
+
else if (key === '?') { this.mode = 'help'; }
|
|
1206
|
+
else if (key === '\x1b') { this.search = ''; this.marked.clear(); this.rebuild(); }
|
|
1207
|
+
else if (key === '\r') { if (s) this._open(resumePlan(s)); }
|
|
1208
|
+
else if (key === 'o') { if (s) this._open(resumePlan(s), true); }
|
|
1209
|
+
else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
|
|
1210
|
+
else if (key === 's') { this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length]; this.rebuild(); this.selectById(s?.sessionId); }
|
|
1211
|
+
else if (key === 'r') { if (s) { this.renameInput = s.title || ''; this.mode = 'rename'; } }
|
|
1212
|
+
else if (key === ' ') {
|
|
1213
|
+
if (s) { this.marked.has(s.jsonlPath) ? this.marked.delete(s.jsonlPath) : this.marked.add(s.jsonlPath); move(+1); }
|
|
858
1214
|
}
|
|
859
|
-
else if (key === 'd')
|
|
860
|
-
else if (key === 'n')
|
|
1215
|
+
else if (key === 'd') { if (this._deleteTargets().length) this.mode = 'delete'; }
|
|
1216
|
+
else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
|
|
861
1217
|
else if (key === 'm') {
|
|
862
|
-
if (
|
|
1218
|
+
if (s) { this.movePicker.enter(); this.mode = 'move'; }
|
|
863
1219
|
else this.setStatus('No session selected.');
|
|
864
1220
|
}
|
|
865
1221
|
else if (key === '\x1b[A' || key === 'k') move(-1);
|
|
@@ -872,6 +1228,21 @@ class TUI {
|
|
|
872
1228
|
return true;
|
|
873
1229
|
}
|
|
874
1230
|
|
|
1231
|
+
// Hand the terminal to a child process, then take it back.
|
|
1232
|
+
suspend() {
|
|
1233
|
+
process.stdin.removeListener('data', this._onData);
|
|
1234
|
+
process.stdin.setRawMode(false);
|
|
1235
|
+
process.stdin.pause();
|
|
1236
|
+
process.stdout.write(showCursor + normBuf);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
resume() {
|
|
1240
|
+
process.stdin.setRawMode(true);
|
|
1241
|
+
process.stdin.resume();
|
|
1242
|
+
process.stdin.on('data', this._onData);
|
|
1243
|
+
this.reloadAll();
|
|
1244
|
+
}
|
|
1245
|
+
|
|
875
1246
|
cleanup() { process.stdout.write(showCursor + normBuf); }
|
|
876
1247
|
|
|
877
1248
|
start() {
|
|
@@ -886,15 +1257,17 @@ class TUI {
|
|
|
886
1257
|
this.render();
|
|
887
1258
|
});
|
|
888
1259
|
|
|
889
|
-
this.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1260
|
+
this._onData = chunk => {
|
|
1261
|
+
for (const key of splitKeys(chunk)) {
|
|
1262
|
+
if (this.handleKey(key) === false) {
|
|
1263
|
+
this.cleanup();
|
|
1264
|
+
process.exit(0);
|
|
1265
|
+
}
|
|
895
1266
|
}
|
|
896
1267
|
this.render();
|
|
897
|
-
}
|
|
1268
|
+
};
|
|
1269
|
+
process.stdin.on('data', this._onData);
|
|
1270
|
+
this.render();
|
|
898
1271
|
|
|
899
1272
|
process.on('exit', () => this.cleanup());
|
|
900
1273
|
process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
|
|
@@ -913,21 +1286,32 @@ function main() {
|
|
|
913
1286
|
const opts = parseArgs();
|
|
914
1287
|
if (opts.version) { console.log(VERSION); return; }
|
|
915
1288
|
if (opts.help) { printHelp(); return; }
|
|
1289
|
+
if (opts.noCache) cache.enabled = false;
|
|
1290
|
+
|
|
1291
|
+
if (opts.emptyTrash) {
|
|
1292
|
+
const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
|
|
1293
|
+
console.log(`Freed ${fmtSize(emptyTrash(dirs))}`);
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
916
1296
|
|
|
917
1297
|
const sessions = loadSessions(opts);
|
|
918
1298
|
|
|
919
1299
|
if (opts.json) {
|
|
920
1300
|
const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
|
|
921
1301
|
console.log(JSON.stringify(out, null, 2));
|
|
922
|
-
} else if (opts.list || !process.stdout.isTTY) {
|
|
1302
|
+
} else if (opts.list || !process.stdout.isTTY || !process.stdin.isTTY) {
|
|
923
1303
|
printList(sessions);
|
|
924
1304
|
} else {
|
|
925
|
-
new TUI(sessions, opts
|
|
1305
|
+
new TUI(sessions, opts).start();
|
|
926
1306
|
}
|
|
927
1307
|
}
|
|
928
1308
|
|
|
929
1309
|
if (require.main === module) {
|
|
930
1310
|
main();
|
|
931
1311
|
} else {
|
|
932
|
-
module.exports = {
|
|
1312
|
+
module.exports = {
|
|
1313
|
+
encodePathToDir, projectDirNameFor, tabComplete, resolveProjectPath, moveSession, readSession,
|
|
1314
|
+
readRecentMessages, renameSession, deleteSessionFiles, emptyTrash, scanConfigDir, sortSessions,
|
|
1315
|
+
detectTerminal, parseArgs, fmtSize, splitKeys, wrapWords, cache,
|
|
1316
|
+
};
|
|
933
1317
|
}
|