reclaude 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/index.js +964 -0
  4. package/package.json +20 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dongwook-chan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # total-reclaude
2
+
3
+ Interactive CLI session picker for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Browse, filter, rename, and resume all your sessions from the terminal.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npx total-reclaude
9
+ ```
10
+
11
+ Or install globally:
12
+
13
+ ```bash
14
+ npm install -g total-reclaude
15
+ total-reclaude
16
+ ```
17
+
18
+ ### Prerequisites
19
+
20
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) must be installed and on your PATH
21
+ - Node.js >= 14
22
+
23
+ ## Screenshot
24
+
25
+ ```
26
+ Claude Code Sessions
27
+ ─────────────────────────────────────────────────────────────
28
+
29
+ Title Project Modified Session
30
+
31
+ > 1 minimize-sys-prompt my-project 03/07/2026, 17:06 6566b87b
32
+ 2 debug-auth-flow my-app 03/07/2026, 15:51 eec06353
33
+ 3 refactor-api-layer backend 03/07/2026, 15:41 d8d86cb2
34
+ 4 add-dark-mode frontend 03/07/2026, 15:41 69b5fb55
35
+ 5 how do I fix this bug? server 03/07/2026, 15:41 5ec54a4c
36
+
37
+ Page 1/1 ←/→ page | ↑/↓ select | Enter resume | r rename | / name | p path | s settings | q quit
38
+ ```
39
+
40
+ - **Bold** titles = named sessions (with custom title)
41
+ - *Dim italic* titles = unnamed sessions (showing first user message)
42
+
43
+ ## Keybindings
44
+
45
+ | Key | Action |
46
+ |-----|--------|
47
+ | `↑` / `↓` | Move cursor |
48
+ | `Enter` | Resume selected session |
49
+ | `r` | Rename selected session |
50
+ | `/` | Filter sessions by name |
51
+ | `p` | Filter sessions by project path |
52
+ | `s` | Open settings |
53
+ | `←` / `→` | Navigate pages |
54
+ | `ESC` | Cancel / go back |
55
+ | `q` | Quit |
56
+ | `Ctrl+C` | Quit |
57
+
58
+ ## Settings
59
+
60
+ Press `s` to open the settings panel:
61
+
62
+ | Setting | Description |
63
+ |---------|-------------|
64
+ | Full directory path | Show full `cwd` instead of short project name |
65
+ | Full session ID | Show complete session ID instead of first 8 characters |
66
+ | Page size | Number of sessions per page (5-50, adjustable with `-`/`+`) |
67
+ | Locale | Date format locale (e.g. `en-US`, `ko-KR`, `sv-SE`) |
68
+
69
+ Settings are saved to `~/.config/total-reclaude/config.json`.
70
+
71
+ ## How it works
72
+
73
+ total-reclaude scans `~/.claude/projects/` for session files (`.jsonl`). Sessions with a custom title are shown in bold; unnamed sessions display the first user message in dim italic as a fallback title. All sessions are sorted by last modified time, with caching (`~/.cache/total-reclaude/sessions.json`) for fast startup.
74
+
75
+ When you select a session, it runs `claude --resume <session-id>` to resume it.
76
+
77
+ ## License
78
+
79
+ [MIT](LICENSE)
package/index.js ADDED
@@ -0,0 +1,964 @@
1
+ #!/usr/bin/env node
2
+ // Interactive Claude Code session picker with pagination and filtering
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const os = require("os");
7
+ const { spawnSync } = require("child_process");
8
+
9
+ const CONFIG_PATH = path.join(
10
+ os.homedir(),
11
+ ".config",
12
+ "total-reclaude",
13
+ "config.json"
14
+ );
15
+
16
+ // Column definitions: id → { label, width, get(session, config) }
17
+ const COLUMN_DEFS = {
18
+ title: { label: "Title", width: 35, get: (s) => s.title || s.firstMessage },
19
+ firstMessage: { label: "First Message", width: 35, get: (s) => s.firstMessage },
20
+ project: { label: "Project", width: 20, get: (s) => s.proj },
21
+ path: { label: "Directory", width: 40, get: (s) => s.cwd },
22
+ modified: { label: "Modified", width: 20, get: (s, cfg) => formatDate(s.mtime, cfg) },
23
+ sid: { label: "Session", width: 8, get: (s) => s.sid.slice(0, 8) },
24
+ sid_full: { label: "Session ID", width: 36, get: (s) => s.sid },
25
+ };
26
+ const ALL_COLUMN_IDS = Object.keys(COLUMN_DEFS);
27
+ const DEFAULT_COLUMNS = ["title", "project", "modified", "sid"];
28
+
29
+ const DEFAULT_CONFIG = {
30
+ pageSize: 10,
31
+ locale: "",
32
+ columns: DEFAULT_COLUMNS,
33
+ };
34
+
35
+ function formatDate(mtime, config) {
36
+ return new Date(mtime).toLocaleString(config.locale || undefined, {
37
+ year: "numeric",
38
+ month: "2-digit",
39
+ day: "2-digit",
40
+ hour: "2-digit",
41
+ minute: "2-digit",
42
+ hour12: false,
43
+ });
44
+ }
45
+
46
+ function loadConfig() {
47
+ try {
48
+ const raw = { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) };
49
+ raw.pageSize = Math.max(5, Math.min(50, parseInt(raw.pageSize, 10) || 10));
50
+ raw.locale = typeof raw.locale === "string" ? raw.locale : "";
51
+ // Validate columns
52
+ if (!Array.isArray(raw.columns) || raw.columns.length === 0) {
53
+ raw.columns = DEFAULT_COLUMNS.slice();
54
+ } else {
55
+ raw.columns = raw.columns.filter((c) => ALL_COLUMN_IDS.includes(c));
56
+ if (raw.columns.length === 0) raw.columns = DEFAULT_COLUMNS.slice();
57
+ }
58
+ return raw;
59
+ } catch {
60
+ return { ...DEFAULT_CONFIG, columns: DEFAULT_COLUMNS.slice() };
61
+ }
62
+ }
63
+
64
+ function saveConfig(config) {
65
+ const dir = path.dirname(CONFIG_PATH);
66
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
67
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
68
+ }
69
+
70
+ const CACHE_PATH = path.join(
71
+ os.homedir(),
72
+ ".cache",
73
+ "total-reclaude",
74
+ "sessions.json"
75
+ );
76
+
77
+ function loadCache() {
78
+ try {
79
+ return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ function saveCache(cache) {
86
+ const dir = path.dirname(CACHE_PATH);
87
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
88
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(cache));
89
+ }
90
+
91
+ function extractProjName(projDir) {
92
+ const homePrefix = os.homedir().replace(/\//g, "-");
93
+ if (projDir.startsWith(homePrefix)) {
94
+ const rest = projDir.slice(homePrefix.length).replace(/^-/, "");
95
+ return rest || "~";
96
+ }
97
+ return projDir;
98
+ }
99
+
100
+ function extractFirstUserMessage(lines) {
101
+ for (const line of lines) {
102
+ if (!line || !line.includes('"user"')) continue;
103
+ try {
104
+ const d = JSON.parse(line);
105
+ if (d.type !== "user") continue;
106
+ const content = d.message && d.message.content;
107
+ if (!content) continue;
108
+ let text = "";
109
+ if (typeof content === "string") {
110
+ text = content;
111
+ } else if (Array.isArray(content)) {
112
+ for (const c of content) {
113
+ if (c && c.type === "text" && c.text) {
114
+ text = c.text;
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ text = text.trim().split("\n")[0].trim();
120
+ if (text && !text.startsWith("[")) return text;
121
+ } catch {} // malformed JSONL lines are expected, skip
122
+ }
123
+ return "";
124
+ }
125
+
126
+ function parseSessionFile(fpath) {
127
+ const content = fs.readFileSync(fpath, "utf8");
128
+ const lines = content.split("\n");
129
+
130
+ const titles = [];
131
+ let cwd = null;
132
+ let sid = null;
133
+
134
+ for (const line of lines) {
135
+ if (!line) continue;
136
+ if (line.includes('"custom-title"')) {
137
+ try {
138
+ const d = JSON.parse(line);
139
+ if (d.type === "custom-title") titles.push(d);
140
+ } catch {} // malformed JSONL lines are expected, skip
141
+ }
142
+ if (!cwd && line.includes('"cwd"')) {
143
+ try {
144
+ const d = JSON.parse(line);
145
+ if (d.cwd) cwd = d.cwd;
146
+ } catch {} // malformed JSONL lines are expected, skip
147
+ }
148
+ if (!sid && line.includes('"sessionId"')) {
149
+ try {
150
+ const d = JSON.parse(line);
151
+ if (d.sessionId) sid = d.sessionId;
152
+ } catch {} // malformed JSONL lines are expected, skip
153
+ }
154
+ }
155
+
156
+ let title = "";
157
+ let sessionId = sid || "";
158
+
159
+ if (titles.length > 0) {
160
+ const last = titles[titles.length - 1];
161
+ title = last.customTitle || "";
162
+ sessionId = last.sessionId || sessionId;
163
+ }
164
+
165
+ const firstMessage = extractFirstUserMessage(lines);
166
+ if (!title && !firstMessage) return null;
167
+
168
+ return {
169
+ sid: sessionId,
170
+ title,
171
+ firstMessage,
172
+ cwd: cwd || "",
173
+ };
174
+ }
175
+
176
+ function getSessions() {
177
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
178
+ if (!fs.existsSync(projectsDir)) return [];
179
+
180
+ const cache = loadCache();
181
+ const cachedDirs = cache.dirs || {};
182
+ const newDirs = {};
183
+ const sessions = [];
184
+ let dirty = false;
185
+
186
+ for (const projDir of fs.readdirSync(projectsDir)) {
187
+ const projPath = path.join(projectsDir, projDir);
188
+ let dirStat;
189
+ try {
190
+ dirStat = fs.statSync(projPath);
191
+ } catch {
192
+ continue;
193
+ }
194
+ if (!dirStat.isDirectory()) continue;
195
+
196
+ const dirMtime = dirStat.mtimeMs;
197
+ const cachedDir = cachedDirs[projDir];
198
+ const proj = extractProjName(projDir);
199
+
200
+ if (cachedDir && cachedDir.dmtime === dirMtime) {
201
+ newDirs[projDir] = cachedDir;
202
+ const files = cachedDir.files || {};
203
+ for (const [fname, entry] of Object.entries(files)) {
204
+ sessions.push({ mtime: entry.mtime, proj, ...entry.data, fpath: path.join(projPath, fname) });
205
+ }
206
+ continue;
207
+ }
208
+
209
+ dirty = true;
210
+ const cachedFiles = (cachedDir && cachedDir.files) || {};
211
+ const newFiles = {};
212
+
213
+ let files;
214
+ try {
215
+ files = fs.readdirSync(projPath).filter((f) => f.endsWith(".jsonl"));
216
+ } catch {
217
+ continue;
218
+ }
219
+
220
+ for (const file of files) {
221
+ if (file.includes("subagents")) continue;
222
+ const fpath = path.join(projPath, file);
223
+
224
+ let mtime;
225
+ try {
226
+ mtime = fs.statSync(fpath).mtimeMs;
227
+ } catch {
228
+ continue;
229
+ }
230
+
231
+ const cachedFile = cachedFiles[file];
232
+ if (cachedFile && cachedFile.mtime === mtime) {
233
+ newFiles[file] = cachedFile;
234
+ sessions.push({ mtime, proj, ...cachedFile.data, fpath });
235
+ continue;
236
+ }
237
+
238
+ try {
239
+ const result = parseSessionFile(fpath);
240
+ if (!result) continue;
241
+ newFiles[file] = { mtime, data: result };
242
+ sessions.push({ mtime, proj, ...result, fpath });
243
+ } catch {
244
+ continue;
245
+ }
246
+ }
247
+
248
+ newDirs[projDir] = { dmtime: dirMtime, files: newFiles };
249
+ }
250
+
251
+ if (dirty || Object.keys(newDirs).length !== Object.keys(cachedDirs).length) {
252
+ saveCache({ dirs: newDirs });
253
+ }
254
+
255
+ sessions.sort((a, b) => b.mtime - a.mtime);
256
+ return sessions;
257
+ }
258
+
259
+ // East Asian wide character width calculation
260
+ function charWidth(code) {
261
+ if (
262
+ (code >= 0x1100 && code <= 0x115f) ||
263
+ (code >= 0x2e80 && code <= 0x303e) ||
264
+ (code >= 0x3040 && code <= 0x33bf) ||
265
+ (code >= 0x3400 && code <= 0x4dbf) ||
266
+ (code >= 0x4e00 && code <= 0xa4cf) ||
267
+ (code >= 0xac00 && code <= 0xd7af) ||
268
+ (code >= 0xf900 && code <= 0xfaff) ||
269
+ (code >= 0xfe30 && code <= 0xfe6f) ||
270
+ (code >= 0xff01 && code <= 0xff60) ||
271
+ (code >= 0xffe0 && code <= 0xffe6) ||
272
+ (code >= 0x20000 && code <= 0x2fffd) ||
273
+ (code >= 0x30000 && code <= 0x3fffd)
274
+ ) {
275
+ return 2;
276
+ }
277
+ return 1;
278
+ }
279
+
280
+ function strWidth(str) {
281
+ let w = 0;
282
+ for (const ch of str) {
283
+ w += charWidth(ch.codePointAt(0));
284
+ }
285
+ return w;
286
+ }
287
+
288
+ function sliceByWidth(str, maxWidth) {
289
+ let w = 0;
290
+ let i = 0;
291
+ for (const ch of str) {
292
+ const cw = charWidth(ch.codePointAt(0));
293
+ if (w + cw > maxWidth) break;
294
+ w += cw;
295
+ i += ch.length;
296
+ }
297
+ return str.slice(0, i);
298
+ }
299
+
300
+ function padEndByWidth(str, targetWidth) {
301
+ const w = strWidth(str);
302
+ if (w >= targetWidth) return str;
303
+ return str + " ".repeat(targetWidth - w);
304
+ }
305
+
306
+ function renderColumnValue(colId, session, config) {
307
+ const def = COLUMN_DEFS[colId];
308
+ const raw = def.get(session, config);
309
+ const sliced = sliceByWidth(raw, def.width);
310
+ return padEndByWidth(sliced, def.width);
311
+ }
312
+
313
+ function render(filtered, page, inputBuf, mode, config, cursor) {
314
+ const cols = config.columns;
315
+ const total = filtered.length;
316
+ const totalPages = Math.max(1, Math.ceil(total / config.pageSize));
317
+ page = Math.max(0, Math.min(page, totalPages - 1));
318
+ const start = page * config.pageSize;
319
+ const end = Math.min(start + config.pageSize, total);
320
+
321
+ process.stdout.write("\x1b[2J\x1b[H");
322
+ console.log();
323
+ console.log(" \x1b[1mClaude Code Sessions\x1b[0m");
324
+ console.log(
325
+ " \x1b[2m─────────────────────────────────────────────────────────────\x1b[0m"
326
+ );
327
+ if (mode === "filter_name") {
328
+ console.log(` \x1b[33mName filter: ${inputBuf}\x1b[0m`);
329
+ } else if (mode === "filter_path") {
330
+ console.log(` \x1b[33mPath filter: ${inputBuf}\x1b[0m`);
331
+ }
332
+ console.log();
333
+
334
+ if (total === 0) {
335
+ console.log(" \x1b[2mNo matching sessions.\x1b[0m");
336
+ } else {
337
+ // Header
338
+ const headerParts = cols.map((c) => COLUMN_DEFS[c].label.padEnd(COLUMN_DEFS[c].width));
339
+ console.log(` \x1b[2m ${headerParts.join(" ")}\x1b[0m`);
340
+ console.log();
341
+
342
+ for (let i = start; i < end; i++) {
343
+ const s = filtered[i];
344
+ const isCursor = i === cursor;
345
+ const pointer = isCursor ? "\x1b[1;33m>\x1b[0m" : " ";
346
+
347
+ // Build column values
348
+ const colValues = cols.map((c) => renderColumnValue(c, s, config));
349
+
350
+ // Title column gets special styling; others are dim
351
+ const titleIdx = cols.indexOf("title");
352
+ let line = "";
353
+ for (let ci = 0; ci < cols.length; ci++) {
354
+ if (ci > 0) line += " ";
355
+ if (ci === titleIdx) {
356
+ // Title column: styled based on named/cursor
357
+ if (s.title) {
358
+ const hl = isCursor ? "\x1b[1;33m" : "\x1b[1m";
359
+ line += `${hl}${colValues[ci]}\x1b[0m`;
360
+ } else {
361
+ const hl = isCursor ? "\x1b[33m" : "\x1b[2;3m";
362
+ line += `${hl}${colValues[ci]}\x1b[0m`;
363
+ }
364
+ } else {
365
+ line += `\x1b[2m${colValues[ci]}\x1b[0m`;
366
+ }
367
+ }
368
+
369
+ const num = String(i + 1).padStart(3);
370
+ const numStyle = s.title ? "\x1b[1;36m" : "\x1b[2m";
371
+ console.log(` ${pointer} ${numStyle}${num}\x1b[0m ${line}`);
372
+ }
373
+ }
374
+
375
+ console.log();
376
+ console.log(
377
+ ` \x1b[2mPage ${page + 1}/${totalPages} \u2190/\u2192 page | \u2191/\u2193 select | Enter resume | r rename | / name | p path | s settings | q quit\x1b[0m`
378
+ );
379
+ console.log();
380
+
381
+ return { page, totalPages };
382
+ }
383
+
384
+ function renderSettings(config, settingsCursor) {
385
+ process.stdout.write("\x1b[2J\x1b[H");
386
+ console.log();
387
+ console.log(" \x1b[1mSettings\x1b[0m");
388
+ console.log(
389
+ " \x1b[2m─────────────────────────────────────────────────────────────\x1b[0m"
390
+ );
391
+ console.log();
392
+
393
+ const items = [
394
+ { label: `Page size: \x1b[1m${config.pageSize}\x1b[0m \x1b[2m(-/+ to adjust)\x1b[0m`, type: "pageSize" },
395
+ { label: `Locale: \x1b[1m${config.locale || "system default"}\x1b[0m \x1b[2m(Enter to edit)\x1b[0m`, type: "locale" },
396
+ { label: "", type: "separator" },
397
+ { label: "\x1b[1mColumns\x1b[0m \x1b[2m(Space toggle, Shift+↑/↓ reorder)\x1b[0m", type: "header" },
398
+ ];
399
+
400
+ // Add column items
401
+ for (const colId of ALL_COLUMN_IDS) {
402
+ const enabled = config.columns.includes(colId);
403
+ const def = COLUMN_DEFS[colId];
404
+ const orderNum = enabled ? config.columns.indexOf(colId) + 1 : "-";
405
+ items.push({ label: def.label, type: "column", colId, enabled, orderNum });
406
+ }
407
+
408
+ for (let i = 0; i < items.length; i++) {
409
+ const item = items[i];
410
+ if (item.type === "separator") {
411
+ console.log();
412
+ continue;
413
+ }
414
+ if (item.type === "header") {
415
+ console.log(` ${item.label}`);
416
+ console.log();
417
+ continue;
418
+ }
419
+
420
+ const pointer = i === settingsCursor ? "\x1b[1;33m>\x1b[0m" : " ";
421
+
422
+ if (item.type === "column") {
423
+ const check = item.enabled ? "\x1b[1;32m[x]\x1b[0m" : "\x1b[2m[ ]\x1b[0m";
424
+ const order = item.enabled ? `\x1b[1;36m${String(item.orderNum).padStart(2)}\x1b[0m` : "\x1b[2m -\x1b[0m";
425
+ console.log(` ${pointer} ${check} ${order} ${item.label}`);
426
+ } else {
427
+ console.log(` ${pointer} ${item.label}`);
428
+ }
429
+ }
430
+
431
+ console.log();
432
+ console.log(
433
+ " \x1b[2m\u2191/\u2193 navigate | Space toggle | Shift+\u2191/\u2193 reorder | -/+ page size | q/ESC back\x1b[0m"
434
+ );
435
+ console.log();
436
+
437
+ return items;
438
+ }
439
+
440
+ function renderLocaleEditor(_config, localeBuf) {
441
+ process.stdout.write("\x1b[2J\x1b[H");
442
+ console.log();
443
+ console.log(" \x1b[1mSet Date Locale\x1b[0m");
444
+ console.log(
445
+ " \x1b[2m─────────────────────────────────────────────────────────────\x1b[0m"
446
+ );
447
+ console.log();
448
+ console.log(` \x1b[33mLocale: ${localeBuf || "(empty = system default)"}\x1b[0m`);
449
+ console.log();
450
+
451
+ const preview = new Date().toLocaleString(localeBuf || undefined, {
452
+ year: "numeric",
453
+ month: "2-digit",
454
+ day: "2-digit",
455
+ hour: "2-digit",
456
+ minute: "2-digit",
457
+ hour12: false,
458
+ });
459
+ console.log(` \x1b[2mPreview: ${preview}\x1b[0m`);
460
+ console.log();
461
+ console.log(
462
+ " \x1b[2mType locale (e.g. en-US, ko-KR, sv-SE) | Enter confirm | ESC cancel\x1b[0m"
463
+ );
464
+ console.log();
465
+ }
466
+
467
+ function renderRename(session, renameBuf) {
468
+ process.stdout.write("\x1b[2J\x1b[H");
469
+ console.log();
470
+ console.log(" \x1b[1mRename Session\x1b[0m");
471
+ console.log(
472
+ " \x1b[2m─────────────────────────────────────────────────────────────\x1b[0m"
473
+ );
474
+ console.log();
475
+ const displayName = session.title || session.firstMessage;
476
+ console.log(` \x1b[2mCurrent:\x1b[0m \x1b[1m${displayName}\x1b[0m`);
477
+ console.log(` \x1b[33mNew title: ${renameBuf}\x1b[0m \x1b[2m(max 100 chars)\x1b[0m`);
478
+ console.log();
479
+ console.log(
480
+ " \x1b[2mEnter confirm | ESC cancel\x1b[0m"
481
+ );
482
+ console.log();
483
+ }
484
+
485
+ function renameSession(session, newTitle) {
486
+ const cleaned = newTitle.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 100);
487
+ if (!cleaned) return false;
488
+ const entry = {
489
+ type: "custom-title",
490
+ sessionId: session.sid,
491
+ customTitle: cleaned,
492
+ };
493
+ fs.appendFileSync(session.fpath, "\n" + JSON.stringify(entry));
494
+ session.title = cleaned;
495
+ return true;
496
+ }
497
+
498
+ function resumeSession(s) {
499
+ process.stdout.write("\x1b[2J\x1b[H");
500
+ console.log(`\n Resuming: ${s.title || s.firstMessage} (${s.sid.slice(0, 8)})\n`);
501
+
502
+ const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
503
+ process.stdin.setRawMode(false);
504
+ const useShell = process.platform === "win32";
505
+ spawnSync("claude", ["--resume", s.sid], {
506
+ stdio: "inherit",
507
+ cwd,
508
+ shell: useShell,
509
+ });
510
+ process.exit(0);
511
+ }
512
+
513
+ function parseKey(buf) {
514
+ if (buf[0] === 0x1b) {
515
+ if (buf.length === 1) return "escape";
516
+ if (buf[1] === 0x5b) {
517
+ if (buf[2] === 0x44) return "left";
518
+ if (buf[2] === 0x43) return "right";
519
+ if (buf[2] === 0x41) return "up";
520
+ if (buf[2] === 0x42) return "down";
521
+ // Shift+Up: ESC [ 1 ; 2 A
522
+ if (buf[2] === 0x31 && buf[3] === 0x3b && buf[4] === 0x32) {
523
+ if (buf[5] === 0x41) return "shift_up";
524
+ if (buf[5] === 0x42) return "shift_down";
525
+ }
526
+ return null;
527
+ }
528
+ return null;
529
+ }
530
+ if (buf[0] === 0x03) return "quit"; // Ctrl+C
531
+ if (buf[0] === 0x0d || buf[0] === 0x0a) return "enter";
532
+ if (buf[0] === 0x7f) return "backspace";
533
+ const ch = buf.toString("utf8");
534
+ if (ch.length === 1 && ch.charCodeAt(0) >= 32) return ch;
535
+ return null;
536
+ }
537
+
538
+ const SPLASH = [
539
+ "",
540
+ " \x1b[1;38;2;200;235;255m ████████ ████████ ████████ ██ ██ \x1b[0m",
541
+ " \x1b[1;38;2;175;225;248m ██ ██ ██ ██ ██ ██ \x1b[0m",
542
+ " \x1b[1;38;2;150;215;240m ██ ██ ██ ██ ████ ██ \x1b[0m",
543
+ " \x1b[1;38;2;125;200;230m ██ ██ ██ ██ ████ ██ \x1b[0m",
544
+ " \x1b[1;38;2;100;185;220m ██ ██ ██ ██ ██ ██ ██ \x1b[0m",
545
+ " \x1b[1;38;2;75;165;210m ██ ██ ██ ██ ██ ██ ██ \x1b[0m",
546
+ " \x1b[1;38;2;55;150;200m ██ ██ ██ ██ ████████ ██ \x1b[0m",
547
+ " \x1b[1;38;2;35;130;185m ██ ████████ ██ ██ ██ ████████\x1b[0m",
548
+ "",
549
+ " \x1b[1;38;2;200;235;255m███████ ████████ ████████ ██ ██ ██ ██ ███████ ████████\x1b[0m",
550
+ " \x1b[1;38;2;175;225;248m██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ \x1b[0m",
551
+ " \x1b[1;38;2;150;215;240m██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ \x1b[0m",
552
+ " \x1b[1;38;2;125;200;230m███████ ███████ ██ ██ ████ ██ ██ ██ ██ ████████\x1b[0m",
553
+ " \x1b[1;38;2;100;185;220m██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ \x1b[0m",
554
+ " \x1b[1;38;2;75;165;210m██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ \x1b[0m",
555
+ " \x1b[1;38;2;55;150;200m██ ██ ██ ██ ██ ████████ ██ ██ ██ ██ ██ \x1b[0m",
556
+ " \x1b[1;38;2;35;130;185m██ ██ ████████ ████████ ████████ ██ ██ ████████ ███████ ████████\x1b[0m",
557
+ "",
558
+ " \x1b[38;2;120;160;180m GET READY FOR THE RESUME OF YOUR SESSIONS\x1b[0m",
559
+ "",
560
+ " \x1b[2;3m Loading sessions...\x1b[0m",
561
+ "",
562
+ ].join("\n");
563
+
564
+ function showSplash() {
565
+ process.stdout.write("\x1b[2J\x1b[H");
566
+ process.stdout.write(SPLASH + "\n");
567
+ }
568
+
569
+ const REPO = "dongwook-chan/total-reclaude";
570
+
571
+ function isRepoStarred() {
572
+ try {
573
+ const res = spawnSync("gh", ["api", `user/starred/${REPO}`], { encoding: "utf8", timeout: 5000 });
574
+ return res.status === 0; // 204 (exit 0) = starred, 404 (exit 1) = not
575
+ } catch {
576
+ return true; // if gh fails, assume starred (don't bother user)
577
+ }
578
+ }
579
+
580
+ function starRepo() {
581
+ try {
582
+ spawnSync("gh", ["api", "-X", "PUT", `user/starred/${REPO}`], { encoding: "utf8", timeout: 5000 });
583
+ } catch {} // best effort
584
+ }
585
+
586
+ function showStarPrompt() {
587
+ try {
588
+ // Check if gh is available
589
+ const which = spawnSync("which", ["gh"], { encoding: "utf8" });
590
+ if (which.status !== 0) return;
591
+
592
+ if (isRepoStarred()) return;
593
+
594
+ process.stdout.write(`\x1b[33m \u2605 Enjoy total-reclaude? Star it on GitHub! (y/n) \x1b[0m`);
595
+
596
+ // Single keypress without Enter via bash read -n1
597
+ const result = spawnSync("bash", ["-c", 'read -rsn1 key < /dev/tty; echo -n "$key"'], { encoding: "utf8", timeout: 10000 });
598
+ const key = (result.stdout || "").trim();
599
+
600
+ if (key === "y" || key === "Y") {
601
+ starRepo();
602
+ console.log(`\x1b[32m Thank you! \u2605\x1b[0m`);
603
+ } else {
604
+ console.log();
605
+ }
606
+ } catch {} // never let this break the exit flow
607
+ }
608
+
609
+ function cleanup() {
610
+ try { process.stdin.setRawMode(false); } catch {}
611
+ process.stdout.write("\x1b[2J\x1b[H");
612
+ }
613
+
614
+ function main() {
615
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
616
+ console.log("Usage: total-reclaude\n");
617
+ console.log("Interactive Claude Code session picker: filter, rename, and resume sessions.\n");
618
+ console.log("Keybindings:");
619
+ console.log(" Up/Down Move cursor");
620
+ console.log(" Enter Resume selected session");
621
+ console.log(" r Rename selected session");
622
+ console.log(" / Filter by name");
623
+ console.log(" p Filter by path");
624
+ console.log(" s Settings (columns, page size, locale)");
625
+ console.log(" Left/Right Page navigation");
626
+ console.log(" q, Ctrl+C Quit");
627
+ console.log(" ESC Cancel / back\n");
628
+ console.log("Available columns: " + ALL_COLUMN_IDS.join(", "));
629
+ console.log("Config: " + CONFIG_PATH);
630
+ return;
631
+ }
632
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
633
+ console.log(require("./package.json").version);
634
+ return;
635
+ }
636
+
637
+ if (!process.stdin.isTTY) {
638
+ console.error("total-reclaude requires an interactive terminal.");
639
+ process.exit(1);
640
+ }
641
+
642
+ showSplash();
643
+ showStarPrompt();
644
+ const { execSync } = require("child_process");
645
+ execSync("sleep 1");
646
+ const allSessions = getSessions();
647
+ if (allSessions.length === 0) {
648
+ console.log("No sessions found.");
649
+ return;
650
+ }
651
+
652
+ let config = loadConfig();
653
+ let page = 0;
654
+ let totalPages = 1;
655
+ let inputBuf = "";
656
+ let mode = "normal";
657
+ let cursor = 0;
658
+ let settingsCursor = 0;
659
+ let settingsItems = [];
660
+ let settingsDirty = false;
661
+ let filtered = allSessions.slice();
662
+
663
+ process.on("exit", cleanup);
664
+ process.on("uncaughtException", (err) => {
665
+ cleanup();
666
+ console.error(err);
667
+ process.exit(1);
668
+ });
669
+
670
+ ({ page, totalPages } = render(filtered, page, inputBuf, mode, config, cursor));
671
+
672
+ function applyFilter() {
673
+ const q = inputBuf.toLowerCase();
674
+ if (!q) return allSessions.slice();
675
+ if (mode === "filter_name") {
676
+ return allSessions.filter((s) => s.title.toLowerCase().includes(q) || s.firstMessage.toLowerCase().includes(q));
677
+ } else if (mode === "filter_path") {
678
+ return allSessions.filter(
679
+ (s) =>
680
+ s.proj.toLowerCase().includes(q) || s.cwd.toLowerCase().includes(q)
681
+ );
682
+ }
683
+ return allSessions.slice();
684
+ }
685
+
686
+ function rerender() {
687
+ if (filtered.length > 0) {
688
+ cursor = Math.max(0, Math.min(cursor, filtered.length - 1));
689
+ } else {
690
+ cursor = 0;
691
+ }
692
+ const cursorPage = Math.floor(cursor / config.pageSize);
693
+ page = cursorPage;
694
+ ({ page, totalPages } = render(filtered, page, inputBuf, mode, config, cursor));
695
+ }
696
+
697
+ // Settings helpers
698
+ function isSelectableSettingsItem(idx) {
699
+ const item = settingsItems[idx];
700
+ return item && item.type !== "separator" && item.type !== "header";
701
+ }
702
+
703
+ function moveSettingsCursor(dir) {
704
+ let next = settingsCursor + dir;
705
+ while (next >= 0 && next < settingsItems.length && !isSelectableSettingsItem(next)) {
706
+ next += dir;
707
+ }
708
+ if (next >= 0 && next < settingsItems.length) {
709
+ settingsCursor = next;
710
+ }
711
+ }
712
+
713
+ function rerenderSettings() {
714
+ settingsItems = renderSettings(config, settingsCursor);
715
+ }
716
+
717
+ process.stdin.setRawMode(true);
718
+ process.stdin.resume();
719
+
720
+ process.stdin.on("data", (buf) => {
721
+ const key = parseKey(buf);
722
+ if (key === null) return;
723
+
724
+ if (key === "quit") {
725
+ process.stdout.write("\x1b[2J\x1b[H");
726
+ process.stdin.setRawMode(false);
727
+ process.exit(0);
728
+ }
729
+
730
+ // Settings mode
731
+ if (mode === "settings") {
732
+ if (key === "q" || key === "escape") {
733
+ if (settingsDirty) {
734
+ saveConfig(config);
735
+ settingsDirty = false;
736
+ }
737
+ mode = "normal";
738
+ rerender();
739
+ return;
740
+ }
741
+
742
+ if (key === "up") {
743
+ moveSettingsCursor(-1);
744
+ rerenderSettings();
745
+ return;
746
+ }
747
+ if (key === "down") {
748
+ moveSettingsCursor(1);
749
+ rerenderSettings();
750
+ return;
751
+ }
752
+
753
+ const currentItem = settingsItems[settingsCursor];
754
+
755
+ if (key === "-") {
756
+ config.pageSize = Math.max(5, config.pageSize - 5);
757
+ settingsDirty = true;
758
+ rerenderSettings();
759
+ return;
760
+ }
761
+ if (key === "=" || key === "+") {
762
+ config.pageSize = Math.min(50, config.pageSize + 5);
763
+ settingsDirty = true;
764
+ rerenderSettings();
765
+ return;
766
+ }
767
+
768
+ if (currentItem && currentItem.type === "locale" && key === "enter") {
769
+ if (settingsDirty) {
770
+ saveConfig(config);
771
+ settingsDirty = false;
772
+ }
773
+ mode = "settings_locale";
774
+ inputBuf = config.locale || "";
775
+ renderLocaleEditor(config, inputBuf);
776
+ return;
777
+ }
778
+
779
+ if (currentItem && currentItem.type === "column") {
780
+ if (key === " ") {
781
+ // Toggle column
782
+ const colId = currentItem.colId;
783
+ const idx = config.columns.indexOf(colId);
784
+ if (idx >= 0) {
785
+ // Don't allow removing last column
786
+ if (config.columns.length > 1) {
787
+ config.columns.splice(idx, 1);
788
+ }
789
+ } else {
790
+ config.columns.push(colId);
791
+ }
792
+ settingsDirty = true;
793
+ rerenderSettings();
794
+ return;
795
+ }
796
+
797
+ if (key === "shift_up" || key === "shift_down") {
798
+ const colId = currentItem.colId;
799
+ const idx = config.columns.indexOf(colId);
800
+ if (idx < 0) return; // not enabled, can't reorder
801
+ const newIdx = key === "shift_up" ? idx - 1 : idx + 1;
802
+ if (newIdx < 0 || newIdx >= config.columns.length) return;
803
+ // Swap
804
+ [config.columns[idx], config.columns[newIdx]] = [config.columns[newIdx], config.columns[idx]];
805
+ settingsDirty = true;
806
+ rerenderSettings();
807
+ return;
808
+ }
809
+ }
810
+
811
+ return;
812
+ }
813
+
814
+ // Rename input mode
815
+ if (mode === "rename_input") {
816
+ if (key === "escape") {
817
+ mode = "normal";
818
+ inputBuf = "";
819
+ rerender();
820
+ } else if (key === "enter" && inputBuf) {
821
+ renameSession(filtered[cursor], inputBuf);
822
+ mode = "normal";
823
+ inputBuf = "";
824
+ rerender();
825
+ } else if (key === "backspace") {
826
+ inputBuf = inputBuf.slice(0, -1);
827
+ renderRename(filtered[cursor], inputBuf);
828
+ } else if (key.length === 1 && key.charCodeAt(0) >= 32) {
829
+ if (inputBuf.length < 100) {
830
+ inputBuf += key;
831
+ }
832
+ renderRename(filtered[cursor], inputBuf);
833
+ }
834
+ return;
835
+ }
836
+
837
+ // Locale editor mode
838
+ if (mode === "settings_locale") {
839
+ if (key === "escape") {
840
+ mode = "settings";
841
+ inputBuf = "";
842
+ rerenderSettings();
843
+ } else if (key === "enter") {
844
+ config.locale = inputBuf;
845
+ settingsDirty = true;
846
+ mode = "settings";
847
+ inputBuf = "";
848
+ rerenderSettings();
849
+ } else if (key === "backspace") {
850
+ inputBuf = inputBuf.slice(0, -1);
851
+ renderLocaleEditor(config, inputBuf);
852
+ } else if (key.length === 1 && key.charCodeAt(0) >= 32) {
853
+ inputBuf += key;
854
+ renderLocaleEditor(config, inputBuf);
855
+ }
856
+ return;
857
+ }
858
+
859
+ // Arrow navigation
860
+ if (key === "up") {
861
+ if (cursor > 0) {
862
+ cursor -= 1;
863
+ rerender();
864
+ }
865
+ return;
866
+ }
867
+ if (key === "down") {
868
+ if (cursor < filtered.length - 1) {
869
+ cursor += 1;
870
+ rerender();
871
+ }
872
+ return;
873
+ }
874
+ if (key === "left") {
875
+ if (page > 0) {
876
+ page -= 1;
877
+ cursor = page * config.pageSize;
878
+ rerender();
879
+ }
880
+ return;
881
+ }
882
+ if (key === "right") {
883
+ if (page < totalPages - 1) {
884
+ page += 1;
885
+ cursor = page * config.pageSize;
886
+ rerender();
887
+ }
888
+ return;
889
+ }
890
+
891
+ if (key === "escape") {
892
+ if (mode !== "normal") {
893
+ mode = "normal";
894
+ inputBuf = "";
895
+ filtered = allSessions.slice();
896
+ cursor = 0;
897
+ page = 0;
898
+ rerender();
899
+ }
900
+ return;
901
+ }
902
+
903
+ if (key === "enter") {
904
+ if (filtered.length > 0) {
905
+ resumeSession(filtered[cursor]);
906
+ }
907
+ return;
908
+ }
909
+
910
+ if (mode === "normal") {
911
+ if (key === "q") {
912
+ process.stdout.write("\x1b[2J\x1b[H");
913
+ process.stdin.setRawMode(false);
914
+ process.exit(0);
915
+ } else if (key === "/") {
916
+ mode = "filter_name";
917
+ inputBuf = "";
918
+ cursor = 0;
919
+ page = 0;
920
+ rerender();
921
+ } else if (key === "p") {
922
+ mode = "filter_path";
923
+ inputBuf = "";
924
+ cursor = 0;
925
+ page = 0;
926
+ rerender();
927
+ } else if (key === "r") {
928
+ if (filtered.length > 0) {
929
+ mode = "rename_input";
930
+ inputBuf = "";
931
+ renderRename(filtered[cursor], inputBuf);
932
+ }
933
+ } else if (key === "s") {
934
+ mode = "settings";
935
+ settingsDirty = false;
936
+ settingsCursor = 0;
937
+ settingsItems = renderSettings(config, settingsCursor);
938
+ }
939
+ return;
940
+ }
941
+
942
+ // Filter mode
943
+ if (key === "backspace") {
944
+ if (inputBuf) {
945
+ inputBuf = inputBuf.slice(0, -1);
946
+ filtered = inputBuf ? applyFilter() : allSessions.slice();
947
+ } else {
948
+ mode = "normal";
949
+ filtered = allSessions.slice();
950
+ }
951
+ cursor = 0;
952
+ page = 0;
953
+ rerender();
954
+ } else if (key.length === 1 && key.charCodeAt(0) >= 32) {
955
+ inputBuf += key;
956
+ filtered = applyFilter();
957
+ cursor = 0;
958
+ page = 0;
959
+ rerender();
960
+ }
961
+ });
962
+ }
963
+
964
+ main();
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "reclaude",
3
+ "version": "1.0.0",
4
+ "description": "Interactive Claude Code session picker: filter, rename, and resume sessions from your terminal",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "reclaude": "./index.js"
8
+ },
9
+ "files": ["index.js", "LICENSE"],
10
+ "engines": {
11
+ "node": ">=14"
12
+ },
13
+ "keywords": ["claude", "claude-code", "session", "picker", "cli", "resume", "rename"],
14
+ "author": "dongwook-chan",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/dongwook-chan/total-reclaude.git"
19
+ }
20
+ }