@vinh0809it/claude-sessions 0.1.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/LICENSE +21 -0
- package/README.md +130 -0
- package/index.js +933 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vinh0809it
|
|
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,130 @@
|
|
|
1
|
+
# claude-sessions
|
|
2
|
+
|
|
3
|
+
A zero-dependency terminal UI for browsing, resuming, moving, and deleting [Claude Code](https://claude.ai/code) sessions.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
claude-sessions path:/home/you/projects/acme
|
|
7
|
+
[.claude-main]
|
|
8
|
+
/home/you/projects/acme/api
|
|
9
|
+
> booking-overlap-check 2026-05-11 23:00
|
|
10
|
+
ekyc-japan-fallback 2026-05-11 22:52
|
|
11
|
+
[.claude-company]
|
|
12
|
+
/home/you/projects/acme/api
|
|
13
|
+
payment-status-check 2026-05-11 16:10
|
|
14
|
+
↑↓/jk=nav /=search Enter=open n=new m=move d=del q=quit [42]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Auto-discovers** all `~/.claude*` config directories — works with multiple Claude Code profiles out of the box
|
|
20
|
+
- **Live search** across session titles and first messages; results show `[profile] project ›` inline
|
|
21
|
+
- **Open in new tab** — press Enter to resume a session in a new terminal tab (kitty, WezTerm, tmux, or custom)
|
|
22
|
+
- **New session** — press `n` to open a fresh Claude session in any profile and folder
|
|
23
|
+
- **Move sessions** — press `m` to move a session to a different profile, an existing folder, or a custom path
|
|
24
|
+
- **Delete sessions** with confirmation (removes both the `.jsonl` file and companion directory)
|
|
25
|
+
- **Filter by folder** — pass a path to scope the list to sessions in that directory tree
|
|
26
|
+
- Zero runtime dependencies — pure Node.js stdlib
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
# Global install (recommended)
|
|
32
|
+
npm install -g cli-claude-sessions
|
|
33
|
+
|
|
34
|
+
# Or run without installing
|
|
35
|
+
npx cli-claude-sessions
|
|
36
|
+
|
|
37
|
+
# Or manually
|
|
38
|
+
curl -fsSL https://raw.githubusercontent.com/vinh0809it/claude-sessions/main/index.js \
|
|
39
|
+
-o ~/.local/bin/claude-sessions && chmod +x ~/.local/bin/claude-sessions
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Node.js 18+.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
claude-sessions # Browse all sessions (TUI)
|
|
48
|
+
claude-sessions ~/projects/acme # Filter to a specific folder
|
|
49
|
+
claude-sessions --named-only # Only show /rename'd sessions
|
|
50
|
+
claude-sessions --list # Plain list (pipe-friendly)
|
|
51
|
+
claude-sessions --json # JSON output
|
|
52
|
+
claude-sessions -d ~/.claude-work # Scan a specific config dir
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### TUI keys
|
|
56
|
+
|
|
57
|
+
| Key | Action |
|
|
58
|
+
|-----|--------|
|
|
59
|
+
| `↑` `↓` / `j` `k` | Navigate |
|
|
60
|
+
| `PgUp` `PgDn` | Page up / down |
|
|
61
|
+
| `g` / `G` | Jump to top / bottom |
|
|
62
|
+
| `/` | Search by title or message content |
|
|
63
|
+
| `Esc` | Clear search |
|
|
64
|
+
| `Enter` | Open session in a new terminal tab |
|
|
65
|
+
| `n` | New session (choose profile + folder) |
|
|
66
|
+
| `m` | Move session to another profile / folder |
|
|
67
|
+
| `d` | Delete session (asks confirmation) |
|
|
68
|
+
| `q` / `Ctrl-C` | Quit |
|
|
69
|
+
|
|
70
|
+
## Multiple profiles
|
|
71
|
+
|
|
72
|
+
Claude Code supports custom config directories via `CLAUDE_CONFIG_DIR`. `claude-sessions` auto-discovers **all** `~/.claude*` directories, so multiple profiles just work:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# ~/.bashrc or ~/.zshrc
|
|
76
|
+
alias claude-main='CLAUDE_CONFIG_DIR=~/.claude-main claude'
|
|
77
|
+
alias claude-work='CLAUDE_CONFIG_DIR=~/.claude-work claude'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Sessions are grouped by profile in the UI. Press `m` to move a session to a different profile or folder (including a custom typed path). Press `n` to open a new session in any profile and folder.
|
|
81
|
+
|
|
82
|
+
## Open in new tab
|
|
83
|
+
|
|
84
|
+
Press `Enter` on any session to resume it in a new terminal tab. The terminal is auto-detected:
|
|
85
|
+
|
|
86
|
+
| Terminal | Support | Notes |
|
|
87
|
+
|----------|---------|-------|
|
|
88
|
+
| **Kitty** | ✅ | Requires `allow_remote_control yes` in `kitty.conf` |
|
|
89
|
+
| **WezTerm** | ✅ | Uses `wezterm cli spawn` |
|
|
90
|
+
| **tmux** | ✅ | Opens a new window in the current session |
|
|
91
|
+
| Others | ⚙️ | Use `CLAUDE_SESSIONS_OPEN_CMD` |
|
|
92
|
+
|
|
93
|
+
### Custom open command
|
|
94
|
+
|
|
95
|
+
Set `CLAUDE_SESSIONS_OPEN_CMD` with placeholders `%cmd%`, `%title%`, and `%project%`:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
# Ghostty (or any terminal with a new-tab flag)
|
|
99
|
+
export CLAUDE_SESSIONS_OPEN_CMD="ghostty --command=bash -i -c %cmd%"
|
|
100
|
+
|
|
101
|
+
# iTerm2 via osascript
|
|
102
|
+
export CLAUDE_SESSIONS_OPEN_CMD="osascript -e 'tell app \"iTerm\" to create window with default profile command \"bash -i -c %cmd%\"'"
|
|
103
|
+
|
|
104
|
+
# Force a specific supported terminal
|
|
105
|
+
export CLAUDE_SESSIONS_TERMINAL=wezterm
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How sessions are stored
|
|
109
|
+
|
|
110
|
+
Claude Code stores sessions in `~/.claude*/projects/<encoded-path>/<uuid>.jsonl`. Each line is a JSON record. `claude-sessions` reads:
|
|
111
|
+
|
|
112
|
+
- `type: "custom-title"` → the name set by `/rename`
|
|
113
|
+
- First `type: "user"` message → shown as a fallback title
|
|
114
|
+
- `cwd` field → the actual project path (used for filtering)
|
|
115
|
+
|
|
116
|
+
## Contributing
|
|
117
|
+
|
|
118
|
+
Issues and PRs welcome. The entire tool is a single `index.js` with no build step.
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
git clone https://github.com/vinh0809it/claude-sessions
|
|
122
|
+
cd claude-sessions
|
|
123
|
+
node index.js # run directly
|
|
124
|
+
npm link # install globally from source
|
|
125
|
+
npm test # run the test suite (Node built-in test runner, no extra deps)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
[MIT](LICENSE)
|
package/index.js
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* claude-sessions — Terminal UI for Claude Code sessions
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* claude-sessions [folder] TUI, optionally filtered by folder path
|
|
9
|
+
* claude-sessions --list [folder] Plain list output
|
|
10
|
+
* claude-sessions --named-only Only /rename'd sessions
|
|
11
|
+
* claude-sessions --json JSON output
|
|
12
|
+
* claude-sessions -d <path> Scan a specific config dir
|
|
13
|
+
* claude-sessions --version Print version
|
|
14
|
+
* claude-sessions -h Help
|
|
15
|
+
*
|
|
16
|
+
* TUI keys:
|
|
17
|
+
* ↑ ↓ j k Navigate
|
|
18
|
+
* PgUp PgDn Page up / down
|
|
19
|
+
* g G Jump to top / bottom
|
|
20
|
+
* / Search (shows [config] project inline)
|
|
21
|
+
* Esc Clear search
|
|
22
|
+
* Enter Open session in a new terminal tab
|
|
23
|
+
* n New session
|
|
24
|
+
* m Move session to another config / folder
|
|
25
|
+
* d Delete session
|
|
26
|
+
* q Ctrl-C Quit
|
|
27
|
+
*
|
|
28
|
+
* Environment variables:
|
|
29
|
+
* CLAUDE_SESSIONS_TERMINAL Force terminal: kitty | wezterm | tmux
|
|
30
|
+
* CLAUDE_SESSIONS_OPEN_CMD Custom open command (placeholders: %cmd% %title% %project%)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const os = require('os');
|
|
36
|
+
const { spawnSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
const VERSION = require('./package.json').version;
|
|
39
|
+
|
|
40
|
+
// ─── Config dir discovery ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function discoverConfigDirs() {
|
|
43
|
+
const home = os.homedir();
|
|
44
|
+
try {
|
|
45
|
+
return fs.readdirSync(home)
|
|
46
|
+
.filter(n => n === '.claude' || n.startsWith('.claude-'))
|
|
47
|
+
.map(n => path.join(home, n))
|
|
48
|
+
.filter(d => {
|
|
49
|
+
try { return fs.statSync(d).isDirectory() && fs.existsSync(path.join(d, 'projects')); }
|
|
50
|
+
catch { return false; }
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => {
|
|
53
|
+
const na = path.basename(a), nb = path.basename(b);
|
|
54
|
+
if (na === '.claude') return 1;
|
|
55
|
+
if (nb === '.claude') return -1;
|
|
56
|
+
return na.localeCompare(nb);
|
|
57
|
+
});
|
|
58
|
+
} catch { return []; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Args ─────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function parseArgs() {
|
|
64
|
+
const args = process.argv.slice(2);
|
|
65
|
+
const opts = { dir: null, namedOnly: false, json: false, list: false, help: false, version: false, filterPath: null };
|
|
66
|
+
for (let i = 0; i < args.length; i++) {
|
|
67
|
+
const a = args[i];
|
|
68
|
+
if (a === '-d' || a === '--dir') opts.dir = args[++i];
|
|
69
|
+
else if (a === '--named-only') opts.namedOnly = true;
|
|
70
|
+
else if (a === '--json') opts.json = true;
|
|
71
|
+
else if (a === '--list') opts.list = true;
|
|
72
|
+
else if (a === '--version' || a === '-v') opts.version = true;
|
|
73
|
+
else if (a === '-h' || a === '--help') opts.help = true;
|
|
74
|
+
else if (!a.startsWith('-')) opts.filterPath = path.resolve(a);
|
|
75
|
+
}
|
|
76
|
+
return opts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Session reading ──────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function readSession(jsonlPath) {
|
|
82
|
+
let title = null, firstUserMessage = null, cwd = null, timestamp = null;
|
|
83
|
+
try {
|
|
84
|
+
const lines = fs.readFileSync(jsonlPath, 'utf8').split('\n');
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (!line) continue;
|
|
87
|
+
try {
|
|
88
|
+
const r = JSON.parse(line);
|
|
89
|
+
if (r.type === 'custom-title' && r.customTitle) title = r.customTitle;
|
|
90
|
+
if (!firstUserMessage && r.type === 'user' && r.message?.content) {
|
|
91
|
+
const c = r.message.content;
|
|
92
|
+
const text = typeof c === 'string' ? c : (c.find?.(x => x.type === 'text')?.text ?? '');
|
|
93
|
+
if (text) firstUserMessage = text.trim().replace(/\s+/g, ' ').slice(0, 80);
|
|
94
|
+
}
|
|
95
|
+
if (!cwd && r.cwd) cwd = r.cwd;
|
|
96
|
+
if (!timestamp && r.timestamp) timestamp = r.timestamp;
|
|
97
|
+
} catch { /* malformed line — skip */ }
|
|
98
|
+
}
|
|
99
|
+
} catch { /* unreadable file — return nulls */ }
|
|
100
|
+
return { title, firstUserMessage, cwd, timestamp };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scanConfigDir(configDir, filterPath) {
|
|
104
|
+
const projectsDir = path.join(configDir, 'projects');
|
|
105
|
+
if (!fs.existsSync(projectsDir)) return [];
|
|
106
|
+
const results = [];
|
|
107
|
+
|
|
108
|
+
let projectEntries;
|
|
109
|
+
try { projectEntries = fs.readdirSync(projectsDir); } catch { return []; }
|
|
110
|
+
|
|
111
|
+
for (const pd of projectEntries) {
|
|
112
|
+
const pdFull = path.join(projectsDir, pd);
|
|
113
|
+
try { if (!fs.statSync(pdFull).isDirectory()) continue; } catch { continue; }
|
|
114
|
+
|
|
115
|
+
let files;
|
|
116
|
+
try {
|
|
117
|
+
files = fs.readdirSync(pdFull)
|
|
118
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
119
|
+
.map(f => {
|
|
120
|
+
const p = path.join(pdFull, f);
|
|
121
|
+
return { file: f, mtime: fs.statSync(p).mtime, jsonlPath: p };
|
|
122
|
+
})
|
|
123
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
124
|
+
} catch { continue; }
|
|
125
|
+
|
|
126
|
+
// .project-path hint written at move time takes highest priority
|
|
127
|
+
let dirPathHint = null;
|
|
128
|
+
try {
|
|
129
|
+
const hint = fs.readFileSync(path.join(pdFull, '.project-path'), 'utf8').trim();
|
|
130
|
+
if (hint) dirPathHint = hint;
|
|
131
|
+
} catch {}
|
|
132
|
+
|
|
133
|
+
for (const { file, mtime, jsonlPath } of files) {
|
|
134
|
+
const { title, firstUserMessage, cwd, timestamp } = readSession(jsonlPath);
|
|
135
|
+
let project = cwd || pd;
|
|
136
|
+
if (dirPathHint) {
|
|
137
|
+
project = dirPathHint;
|
|
138
|
+
} else if (cwd && encodePathToDir(cwd) !== pd) {
|
|
139
|
+
// JSONL cwd is stale; find a sibling whose cwd encodes to this dir name
|
|
140
|
+
for (const sib of files) {
|
|
141
|
+
if (sib.jsonlPath === jsonlPath) continue;
|
|
142
|
+
const { cwd: sibCwd } = readSession(sib.jsonlPath);
|
|
143
|
+
if (sibCwd && encodePathToDir(sibCwd) === pd) { project = sibCwd; break; }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (filterPath && !project.startsWith(filterPath)) continue;
|
|
147
|
+
results.push({
|
|
148
|
+
configDir: path.basename(configDir),
|
|
149
|
+
configDirPath: configDir,
|
|
150
|
+
project,
|
|
151
|
+
sessionId: file.replace('.jsonl', ''),
|
|
152
|
+
title,
|
|
153
|
+
firstMessage: firstUserMessage,
|
|
154
|
+
lastModified: mtime,
|
|
155
|
+
timestamp,
|
|
156
|
+
jsonlPath,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return results.sort((a, b) => b.lastModified - a.lastModified);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function loadSessions(opts) {
|
|
164
|
+
const dirs = opts.dir ? [path.resolve(opts.dir)] : discoverConfigDirs();
|
|
165
|
+
if (dirs.length === 0) process.stderr.write('No Claude Code config directories found in ~\n');
|
|
166
|
+
const all = dirs.flatMap(d => scanConfigDir(d, opts.filterPath));
|
|
167
|
+
return opts.namedOnly ? all.filter(s => s.title) : all;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Session operations ───────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
function deleteSessionFiles(session) {
|
|
173
|
+
try { fs.unlinkSync(session.jsonlPath); } catch {}
|
|
174
|
+
const dir = session.jsonlPath.replace(/\.jsonl$/, '');
|
|
175
|
+
try {
|
|
176
|
+
if (fs.statSync(dir).isDirectory()) fs.rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function moveSession(session, targetConfigDirPath, targetProjectDirName, targetRawPath = null) {
|
|
181
|
+
const srcProjectDirName = path.basename(path.dirname(session.jsonlPath));
|
|
182
|
+
const destDirName = targetProjectDirName || srcProjectDirName;
|
|
183
|
+
const targetProjectDir = path.join(targetConfigDirPath, 'projects', destDirName);
|
|
184
|
+
fs.mkdirSync(targetProjectDir, { recursive: true });
|
|
185
|
+
|
|
186
|
+
const targetJsonl = path.join(targetProjectDir, path.basename(session.jsonlPath));
|
|
187
|
+
fs.renameSync(session.jsonlPath, targetJsonl);
|
|
188
|
+
|
|
189
|
+
const srcDir = session.jsonlPath.replace(/\.jsonl$/, '');
|
|
190
|
+
try {
|
|
191
|
+
if (fs.statSync(srcDir).isDirectory()) {
|
|
192
|
+
fs.renameSync(srcDir, targetJsonl.replace(/\.jsonl$/, ''));
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
|
|
196
|
+
if (targetRawPath) {
|
|
197
|
+
try { fs.writeFileSync(path.join(targetProjectDir, '.project-path'), targetRawPath, 'utf8'); } catch {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
session.configDir = path.basename(targetConfigDirPath);
|
|
201
|
+
session.configDirPath = targetConfigDirPath;
|
|
202
|
+
session.jsonlPath = targetJsonl;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function listProjectFolders(configDirPath) {
|
|
206
|
+
const projectsDir = path.join(configDirPath, 'projects');
|
|
207
|
+
if (!fs.existsSync(projectsDir)) return [];
|
|
208
|
+
try {
|
|
209
|
+
return fs.readdirSync(projectsDir)
|
|
210
|
+
.filter(d => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } })
|
|
211
|
+
.map(d => {
|
|
212
|
+
let displayPath = d;
|
|
213
|
+
try {
|
|
214
|
+
const f = fs.readdirSync(path.join(projectsDir, d)).find(f => f.endsWith('.jsonl'));
|
|
215
|
+
if (f) {
|
|
216
|
+
const { cwd } = readSession(path.join(projectsDir, d, f));
|
|
217
|
+
if (cwd) displayPath = shortenPath(cwd);
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
return { projectDirName: d, displayPath };
|
|
221
|
+
})
|
|
222
|
+
.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
|
|
223
|
+
} catch { return []; }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function encodePathToDir(absPath) {
|
|
227
|
+
return absPath.replace(/\//g, '-');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function tabComplete(input) {
|
|
231
|
+
const expanded = input.replace(/^~/, os.homedir());
|
|
232
|
+
const endsWithSlash = expanded.endsWith('/');
|
|
233
|
+
const baseDir = endsWithSlash ? (expanded.slice(0, -1) || '/') : path.dirname(expanded);
|
|
234
|
+
const prefix = endsWithSlash ? '' : path.basename(expanded);
|
|
235
|
+
|
|
236
|
+
let matches;
|
|
237
|
+
try {
|
|
238
|
+
matches = fs.readdirSync(baseDir)
|
|
239
|
+
.filter(n => n.startsWith(prefix) && (() => {
|
|
240
|
+
try { return fs.statSync(path.join(baseDir, n)).isDirectory(); } catch { return false; }
|
|
241
|
+
})())
|
|
242
|
+
.sort();
|
|
243
|
+
} catch { return { completed: input, matches: [] }; }
|
|
244
|
+
|
|
245
|
+
if (!matches.length) return { completed: input, matches: [] };
|
|
246
|
+
|
|
247
|
+
if (matches.length === 1) {
|
|
248
|
+
const completed = (path.join(baseDir, matches[0]) + '/').replace(os.homedir(), '~');
|
|
249
|
+
return { completed, matches: [] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let common = matches[0];
|
|
253
|
+
for (const m of matches.slice(1)) {
|
|
254
|
+
let i = 0;
|
|
255
|
+
while (i < common.length && i < m.length && common[i] === m[i]) i++;
|
|
256
|
+
common = common.slice(0, i);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (common.length > prefix.length) {
|
|
260
|
+
const completed = path.join(baseDir, common).replace(os.homedir(), '~');
|
|
261
|
+
return { completed, matches: matches.map(m => m + '/') };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { completed: input, matches: matches.map(m => m + '/') };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ─── Terminal detection & open ────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
function detectTerminal() {
|
|
270
|
+
const forced = process.env.CLAUDE_SESSIONS_TERMINAL;
|
|
271
|
+
if (forced) return forced;
|
|
272
|
+
if (process.env.KITTY_WINDOW_ID !== undefined) return 'kitty';
|
|
273
|
+
if (process.env.WEZTERM_PANE !== undefined) return 'wezterm';
|
|
274
|
+
if (process.env.TMUX) return 'tmux';
|
|
275
|
+
if (process.env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function shellEsc(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; }
|
|
280
|
+
|
|
281
|
+
function trySpawn(cmd, args) {
|
|
282
|
+
const r = spawnSync(cmd, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
283
|
+
return r.status === 0
|
|
284
|
+
? { ok: true }
|
|
285
|
+
: { ok: false, msg: r.stderr?.toString().trim() || `${cmd} exited with ${r.status}` };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function resolveProjectPath(session) {
|
|
289
|
+
const sessionDir = path.dirname(session.jsonlPath);
|
|
290
|
+
// Check hint file first (written at move time; survives restarts)
|
|
291
|
+
try {
|
|
292
|
+
const hint = fs.readFileSync(path.join(sessionDir, '.project-path'), 'utf8').trim();
|
|
293
|
+
if (hint && fs.existsSync(hint)) return hint;
|
|
294
|
+
} catch {}
|
|
295
|
+
// Fall back to a sibling session whose cwd resolves to an existing path
|
|
296
|
+
try {
|
|
297
|
+
const siblings = fs.readdirSync(sessionDir)
|
|
298
|
+
.filter(f => f.endsWith('.jsonl') && f !== path.basename(session.jsonlPath));
|
|
299
|
+
for (const sibling of siblings) {
|
|
300
|
+
const { cwd } = readSession(path.join(sessionDir, sibling));
|
|
301
|
+
if (cwd && fs.existsSync(cwd)) return cwd;
|
|
302
|
+
}
|
|
303
|
+
} catch {}
|
|
304
|
+
return fs.existsSync(session.project) ? session.project : os.homedir();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function launchInTerminal(shellCmd, title, projectPath) {
|
|
308
|
+
const customTpl = process.env.CLAUDE_SESSIONS_OPEN_CMD;
|
|
309
|
+
if (customTpl) {
|
|
310
|
+
const expanded = customTpl
|
|
311
|
+
.replace(/%cmd%/g, shellEsc(shellCmd))
|
|
312
|
+
.replace(/%title%/g, shellEsc(title))
|
|
313
|
+
.replace(/%project%/g, shellEsc(projectPath));
|
|
314
|
+
const r = spawnSync('bash', ['-c', expanded], { stdio: 'ignore' });
|
|
315
|
+
return r.status === 0 ? { ok: true } : { ok: false, msg: `Custom open command failed (exit ${r.status})` };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const term = detectTerminal();
|
|
319
|
+
switch (term) {
|
|
320
|
+
case 'kitty':
|
|
321
|
+
return trySpawn('kitty', ['@', 'launch', '--type=tab', `--tab-title=${title}`, 'bash', '-i', '-c', shellCmd]);
|
|
322
|
+
case 'wezterm':
|
|
323
|
+
return trySpawn('wezterm', ['cli', 'spawn', '--', 'bash', '-i', '-c', shellCmd]);
|
|
324
|
+
case 'tmux':
|
|
325
|
+
return trySpawn('tmux', ['new-window', '-n', title.slice(0, 20), `bash -i -c ${shellEsc(shellCmd)}`]);
|
|
326
|
+
default:
|
|
327
|
+
return { ok: false, msg: 'Terminal not supported. Set CLAUDE_SESSIONS_OPEN_CMD or CLAUDE_SESSIONS_TERMINAL.' };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function openSession(session) {
|
|
332
|
+
const projectPath = resolveProjectPath(session);
|
|
333
|
+
const title = session.title || session.sessionId.slice(0, 8);
|
|
334
|
+
const resumeCmd = `CLAUDE_CONFIG_DIR=${shellEsc(session.configDirPath)} claude -r ${session.sessionId}`;
|
|
335
|
+
const shellCmd = `cd ${shellEsc(projectPath)} && ${resumeCmd}; exec $SHELL`;
|
|
336
|
+
return launchInTerminal(shellCmd, title, projectPath);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function openNewSession(configDirPath, projectPath) {
|
|
340
|
+
const title = path.basename(projectPath);
|
|
341
|
+
const cmd = `CLAUDE_CONFIG_DIR=${shellEsc(configDirPath)} claude`;
|
|
342
|
+
const shellCmd = `cd ${shellEsc(projectPath)} && ${cmd}; exec $SHELL`;
|
|
343
|
+
return launchInTerminal(shellCmd, title, projectPath);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
function fmtDate(d) {
|
|
349
|
+
return new Date(d).toLocaleString(undefined, { hour12: false });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function shortConfig(configDir) {
|
|
353
|
+
return path.basename(configDir).replace(/^\.claude-?/, '') || 'default';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function shortenPath(p) {
|
|
357
|
+
const home = os.homedir();
|
|
358
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Plain list output ────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function ansi(code, s) { return process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s; }
|
|
364
|
+
|
|
365
|
+
function printList(sessions) {
|
|
366
|
+
if (!sessions.length) { console.log('No sessions found.'); return; }
|
|
367
|
+
let lastConfig = null, lastProject = null;
|
|
368
|
+
for (const s of sessions) {
|
|
369
|
+
if (s.configDir !== lastConfig) {
|
|
370
|
+
if (lastConfig) console.log();
|
|
371
|
+
console.log(ansi('1;36', `[${s.configDir}]`));
|
|
372
|
+
lastConfig = s.configDir; lastProject = null;
|
|
373
|
+
}
|
|
374
|
+
if (s.project !== lastProject) {
|
|
375
|
+
console.log(ansi('33', ` ${shortenPath(s.project)}`));
|
|
376
|
+
lastProject = s.project;
|
|
377
|
+
}
|
|
378
|
+
const label = s.title
|
|
379
|
+
? ansi('1;32', s.title)
|
|
380
|
+
: ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
|
|
381
|
+
console.log(` ${label} ${ansi('2', fmtDate(s.lastModified))}`);
|
|
382
|
+
console.log(ansi('2', ` id: ${s.sessionId}`));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── TUI ──────────────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
const ESC = '\x1b';
|
|
389
|
+
const clrLine = `${ESC}[2K`;
|
|
390
|
+
const clrScreen = `${ESC}[2J`;
|
|
391
|
+
const hideCursor = `${ESC}[?25l`;
|
|
392
|
+
const showCursor = `${ESC}[?25h`;
|
|
393
|
+
const altBuf = `${ESC}[?1049h`;
|
|
394
|
+
const normBuf = `${ESC}[?1049l`;
|
|
395
|
+
|
|
396
|
+
const mv = (r, c) => `${ESC}[${r};${c}H`;
|
|
397
|
+
const bold = s => `${ESC}[1m${s}${ESC}[0m`;
|
|
398
|
+
const dim = s => `${ESC}[2m${s}${ESC}[0m`;
|
|
399
|
+
const inv = s => `${ESC}[7m${s}${ESC}[0m`;
|
|
400
|
+
const fgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
|
|
401
|
+
const bgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
|
|
402
|
+
const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
403
|
+
const pad = (s, n) => s.padEnd(n).slice(0, n);
|
|
404
|
+
|
|
405
|
+
// ─── Picker ───────────────────────────────────────────────────────────────────
|
|
406
|
+
// Shared profile → folder → input flow used by both move and new modes.
|
|
407
|
+
// step: 'profile' | 'folder' | 'input'
|
|
408
|
+
|
|
409
|
+
class Picker {
|
|
410
|
+
constructor({ headerLabel, profileLabel = 'Select profile', inputPrompt, buildProfileItems, buildFolderItems, onConfirmItem, onConfirmCustom }) {
|
|
411
|
+
this.step = 'profile';
|
|
412
|
+
this.items = [];
|
|
413
|
+
this.selIdx = 0;
|
|
414
|
+
this.scroll = 0;
|
|
415
|
+
this.config = null; // config dir path chosen at the profile step
|
|
416
|
+
this.input = '';
|
|
417
|
+
this.completions = [];
|
|
418
|
+
|
|
419
|
+
this._headerLabel = headerLabel;
|
|
420
|
+
this._profileLabel = profileLabel;
|
|
421
|
+
this._inputPrompt = inputPrompt;
|
|
422
|
+
this._buildProfileItems = buildProfileItems;
|
|
423
|
+
this._buildFolderItems = buildFolderItems;
|
|
424
|
+
this._onConfirmItem = onConfirmItem;
|
|
425
|
+
this._onConfirmCustom = onConfirmCustom;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
enter() {
|
|
429
|
+
this.step = 'profile';
|
|
430
|
+
this.items = this._buildProfileItems();
|
|
431
|
+
this.selIdx = 0;
|
|
432
|
+
this.scroll = 0;
|
|
433
|
+
this.config = null;
|
|
434
|
+
this.input = '';
|
|
435
|
+
this.completions = [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_enterFolderStep(configDirPath) {
|
|
439
|
+
this.config = configDirPath;
|
|
440
|
+
this.step = 'folder';
|
|
441
|
+
this.items = this._buildFolderItems(configDirPath);
|
|
442
|
+
this.selIdx = 0;
|
|
443
|
+
this.scroll = 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Returns false when the picker should be dismissed (Esc at profile step).
|
|
447
|
+
handleKey(key) {
|
|
448
|
+
if (this.step === 'input') {
|
|
449
|
+
if (key === '\x1b') {
|
|
450
|
+
this.step = 'folder';
|
|
451
|
+
this.completions = [];
|
|
452
|
+
this.items = this._buildFolderItems(this.config);
|
|
453
|
+
} else if (key === '\t') {
|
|
454
|
+
const { completed, matches } = tabComplete(this.input || '~/');
|
|
455
|
+
this.input = completed;
|
|
456
|
+
this.completions = matches.length > 1 ? matches : [];
|
|
457
|
+
} else if (key === '\x7f') {
|
|
458
|
+
this.input = this.input.slice(0, -1);
|
|
459
|
+
this.completions = [];
|
|
460
|
+
} else if (key === '\r') {
|
|
461
|
+
const raw = this.input.trim();
|
|
462
|
+
this.completions = [];
|
|
463
|
+
if (raw) {
|
|
464
|
+
const absPath = raw.replace(/^~/, os.homedir()).replace(/\/+$/, '');
|
|
465
|
+
this._onConfirmCustom(absPath);
|
|
466
|
+
} else {
|
|
467
|
+
this.step = 'folder';
|
|
468
|
+
}
|
|
469
|
+
} else if (key.length === 1 && key >= ' ') {
|
|
470
|
+
this.input += key;
|
|
471
|
+
this.completions = [];
|
|
472
|
+
}
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (key === '\x1b') {
|
|
477
|
+
if (this.step === 'folder') {
|
|
478
|
+
this.step = 'profile';
|
|
479
|
+
this.items = this._buildProfileItems();
|
|
480
|
+
this.selIdx = 0;
|
|
481
|
+
this.scroll = 0;
|
|
482
|
+
} else {
|
|
483
|
+
return false; // cancel at profile step
|
|
484
|
+
}
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (key === '\x1b[A' || key === 'k') { this.selIdx = Math.max(0, this.selIdx - 1); return true; }
|
|
489
|
+
if (key === '\x1b[B' || key === 'j') { this.selIdx = Math.min(this.items.length - 1, this.selIdx + 1); return true; }
|
|
490
|
+
|
|
491
|
+
if (key === '\r') {
|
|
492
|
+
const item = this.items[this.selIdx];
|
|
493
|
+
if (!item) return true;
|
|
494
|
+
if (item.type === 'profile') { this._enterFolderStep(item.configDirPath); return true; }
|
|
495
|
+
if (item.type === 'new') { this.step = 'input'; this.input = '~/'; this.completions = []; return true; }
|
|
496
|
+
this._onConfirmItem(item);
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
render(out, W, H) {
|
|
502
|
+
const headerLabel = typeof this._headerLabel === 'function' ? this._headerLabel() : this._headerLabel;
|
|
503
|
+
out.push(mv(2, 1), clrLine, ` ${headerLabel}`);
|
|
504
|
+
|
|
505
|
+
out.push(mv(3, 1), clrLine);
|
|
506
|
+
const divider = dim('─'.repeat(Math.max(0, W - 2)));
|
|
507
|
+
if (this.step === 'profile') {
|
|
508
|
+
out.push(` ` + bold(this._profileLabel) + ` ` + divider);
|
|
509
|
+
} else {
|
|
510
|
+
out.push(` ` + bold('Select folder in ') + bold(fgc('96', `[${path.basename(this.config)}]`)) + ` ` + divider);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let listStart = 4;
|
|
514
|
+
if (this.step === 'input') {
|
|
515
|
+
out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.input}█`);
|
|
516
|
+
out.push(mv(5, 1), clrLine);
|
|
517
|
+
if (this.completions.length > 1) {
|
|
518
|
+
const cols = Math.max(1, Math.floor((W - 2) / 22));
|
|
519
|
+
const rows = Math.ceil(this.completions.length / cols);
|
|
520
|
+
for (let r = 0; r < Math.min(rows, H - 8); r++) {
|
|
521
|
+
out.push(mv(r + 5, 1), clrLine);
|
|
522
|
+
out.push(` ` + dim(this.completions.slice(r * cols, r * cols + cols).map(n => pad(n, 22)).join(' ')));
|
|
523
|
+
}
|
|
524
|
+
listStart = H;
|
|
525
|
+
} else {
|
|
526
|
+
out.push(dim(` Tab to complete path`));
|
|
527
|
+
listStart = 6;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const items = this.step === 'input' ? [] : this.items;
|
|
532
|
+
const maxVis = Math.max(0, H - listStart - 1);
|
|
533
|
+
if (this.selIdx < this.scroll) this.scroll = this.selIdx;
|
|
534
|
+
if (this.selIdx >= this.scroll + maxVis) this.scroll = this.selIdx - maxVis + 1;
|
|
535
|
+
if (this.scroll < 0) this.scroll = 0;
|
|
536
|
+
|
|
537
|
+
for (let r = 0; r < maxVis; r++) {
|
|
538
|
+
out.push(mv(listStart + r, 1), clrLine);
|
|
539
|
+
const item = items[this.scroll + r];
|
|
540
|
+
if (!item) continue;
|
|
541
|
+
const isSel = (this.scroll + r) === this.selIdx;
|
|
542
|
+
const label = item.type === 'same' ? dim(item.label)
|
|
543
|
+
: item.type === 'new' ? fgc('93', item.label)
|
|
544
|
+
: item.type === 'profile' ? bold(fgc('96', item.label))
|
|
545
|
+
: fgc('33', item.label);
|
|
546
|
+
out.push(isSel ? inv(pad(` ${item.label}`, W)) : ` ${label}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ─── TUI ─────────────────────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
class TUI {
|
|
554
|
+
constructor(sessions, filterPath) {
|
|
555
|
+
this.all = sessions;
|
|
556
|
+
this.filterPath = filterPath || '';
|
|
557
|
+
this.search = '';
|
|
558
|
+
this.mode = null; // null | 'search' | 'delete' | 'move' | 'new'
|
|
559
|
+
this.statusMsg = '';
|
|
560
|
+
this._statusTimer = null;
|
|
561
|
+
this.selIdx = 0;
|
|
562
|
+
this.scrollTop = 0;
|
|
563
|
+
this.items = [];
|
|
564
|
+
this.w = process.stdout.columns || 80;
|
|
565
|
+
this.h = process.stdout.rows || 24;
|
|
566
|
+
|
|
567
|
+
this.movePicker = this._createMovePicker();
|
|
568
|
+
this.newPicker = this._createNewPicker();
|
|
569
|
+
this.rebuild();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
get listH() { return this.h - 2; }
|
|
573
|
+
|
|
574
|
+
_createMovePicker() {
|
|
575
|
+
return new Picker({
|
|
576
|
+
headerLabel: () => {
|
|
577
|
+
const s = this.selectedSession();
|
|
578
|
+
const label = s?.title || s?.firstMessage?.slice(0, 60) || s?.sessionId?.slice(0, 8) || '';
|
|
579
|
+
return `Moving: ` + (label ? fgc('92', `"${trunc(label, this.w - 12)}"`) : dim('(untitled)'));
|
|
580
|
+
},
|
|
581
|
+
profileLabel: 'Select target profile',
|
|
582
|
+
inputPrompt: 'New path:',
|
|
583
|
+
buildProfileItems: () => {
|
|
584
|
+
const s = this.selectedSession();
|
|
585
|
+
return discoverConfigDirs().map(d => ({
|
|
586
|
+
type: 'profile',
|
|
587
|
+
label: path.basename(d) + (d === s?.configDirPath ? ' (current)' : ''),
|
|
588
|
+
configDirPath: d,
|
|
589
|
+
}));
|
|
590
|
+
},
|
|
591
|
+
buildFolderItems: (configDirPath) => {
|
|
592
|
+
const s = this.selectedSession();
|
|
593
|
+
const isSameConfig = configDirPath === s?.configDirPath;
|
|
594
|
+
const srcDirName = s ? path.basename(path.dirname(s.jsonlPath)) : null;
|
|
595
|
+
const items = [];
|
|
596
|
+
if (!isSameConfig) {
|
|
597
|
+
items.push({ type: 'same', label: (s ? shortenPath(s.project) : '') + ' (keep same folder)', dirName: null });
|
|
598
|
+
}
|
|
599
|
+
listProjectFolders(configDirPath)
|
|
600
|
+
.filter(f => !(isSameConfig && f.projectDirName === srcDirName))
|
|
601
|
+
.forEach(f => items.push({ type: 'folder', label: f.displayPath, dirName: f.projectDirName }));
|
|
602
|
+
items.push({ type: 'new', label: '[type a new path…]', dirName: null });
|
|
603
|
+
return items;
|
|
604
|
+
},
|
|
605
|
+
onConfirmItem: (item) => this._doMove(item.dirName),
|
|
606
|
+
onConfirmCustom: (absPath) => this._doMove(encodePathToDir(absPath), absPath),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
_createNewPicker() {
|
|
611
|
+
return new Picker({
|
|
612
|
+
headerLabel: fgc('92', 'New session'),
|
|
613
|
+
inputPrompt: 'Path:',
|
|
614
|
+
buildProfileItems: () => discoverConfigDirs().map(d => ({
|
|
615
|
+
type: 'profile', label: path.basename(d), configDirPath: d,
|
|
616
|
+
})),
|
|
617
|
+
buildFolderItems: (configDirPath) => [
|
|
618
|
+
...listProjectFolders(configDirPath).map(f => ({
|
|
619
|
+
type: 'folder',
|
|
620
|
+
label: f.displayPath,
|
|
621
|
+
projectPath: f.displayPath.replace(/^~/, os.homedir()),
|
|
622
|
+
})),
|
|
623
|
+
{ type: 'new', label: '[type a new path…]', projectPath: null },
|
|
624
|
+
],
|
|
625
|
+
onConfirmItem: (item) => { if (item.projectPath) this._doNew(item.projectPath); },
|
|
626
|
+
onConfirmCustom: (absPath) => this._doNew(absPath),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── Item list ───────────────────────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
rebuild() {
|
|
633
|
+
const q = this.search.toLowerCase();
|
|
634
|
+
const filtered = q
|
|
635
|
+
? this.all.filter(s =>
|
|
636
|
+
(s.title || '').toLowerCase().includes(q) ||
|
|
637
|
+
(s.firstMessage || '').toLowerCase().includes(q) ||
|
|
638
|
+
s.project.toLowerCase().includes(q))
|
|
639
|
+
: this.all;
|
|
640
|
+
|
|
641
|
+
this.items = [];
|
|
642
|
+
let lastConfig = null;
|
|
643
|
+
for (const s of filtered) {
|
|
644
|
+
if (s.configDir !== lastConfig) {
|
|
645
|
+
this.items.push({ type: 'config', label: s.configDir });
|
|
646
|
+
lastConfig = s.configDir;
|
|
647
|
+
}
|
|
648
|
+
this.items.push({ type: 'session', session: s });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const total = this.sessionItems().length;
|
|
652
|
+
this.selIdx = Math.min(this.selIdx, Math.max(0, total - 1));
|
|
653
|
+
this.ensureVisible();
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
sessionItems() { return this.items.filter(i => i.type === 'session'); }
|
|
657
|
+
selectedSession() { return this.sessionItems()[this.selIdx]?.session ?? null; }
|
|
658
|
+
|
|
659
|
+
selItemRow() {
|
|
660
|
+
let si = 0;
|
|
661
|
+
for (let i = 0; i < this.items.length; i++) {
|
|
662
|
+
if (this.items[i].type === 'session') {
|
|
663
|
+
if (si === this.selIdx) return i;
|
|
664
|
+
si++;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return -1;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
ensureVisible() {
|
|
671
|
+
const r = this.selItemRow();
|
|
672
|
+
if (r === -1) return;
|
|
673
|
+
if (r < this.scrollTop) this.scrollTop = r;
|
|
674
|
+
if (r >= this.scrollTop + this.listH) this.scrollTop = r - this.listH + 1;
|
|
675
|
+
if (this.scrollTop < 0) this.scrollTop = 0;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
setStatus(msg, ms = 2500) {
|
|
679
|
+
this.statusMsg = msg;
|
|
680
|
+
clearTimeout(this._statusTimer);
|
|
681
|
+
this._statusTimer = setTimeout(() => { this.statusMsg = ''; this.render(); }, ms);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
reloadAll() {
|
|
685
|
+
this.all = discoverConfigDirs().flatMap(d => scanConfigDir(d, this.filterPath || null));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
_doMove(targetDirName, targetRawPath = null) {
|
|
689
|
+
const s = this.selectedSession();
|
|
690
|
+
if (!s) { this.mode = null; return; }
|
|
691
|
+
const sessionId = s.sessionId;
|
|
692
|
+
const targetConfig = this.movePicker.config;
|
|
693
|
+
try {
|
|
694
|
+
moveSession(s, targetConfig, targetDirName, targetRawPath);
|
|
695
|
+
this.mode = null;
|
|
696
|
+
this.reloadAll();
|
|
697
|
+
this.rebuild();
|
|
698
|
+
const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
|
|
699
|
+
if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
|
|
700
|
+
this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
|
|
701
|
+
} catch (e) {
|
|
702
|
+
this.mode = null;
|
|
703
|
+
this.setStatus(`Move failed: ${e.message}`, 4000);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
_doNew(projectPath) {
|
|
708
|
+
if (!projectPath) return;
|
|
709
|
+
const result = openNewSession(this.newPicker.config, projectPath);
|
|
710
|
+
this.mode = null;
|
|
711
|
+
this.setStatus(
|
|
712
|
+
result.ok ? `Opening new session in ${shortenPath(projectPath)}` : result.msg,
|
|
713
|
+
result.ok ? 2500 : 5000
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
718
|
+
|
|
719
|
+
render() {
|
|
720
|
+
const W = this.w, H = this.h;
|
|
721
|
+
const out = [hideCursor, altBuf, clrScreen, mv(1, 1)];
|
|
722
|
+
|
|
723
|
+
const filterInfo = this.filterPath ? ` path:${this.filterPath}` : '';
|
|
724
|
+
const searchInfo = this.search ? ` search:${this.search}` : '';
|
|
725
|
+
out.push(bgc('44', bold(fgc('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}`, W), W)))));
|
|
726
|
+
|
|
727
|
+
if (this.mode === 'move') {
|
|
728
|
+
this.movePicker.render(out, W, H);
|
|
729
|
+
} else if (this.mode === 'new') {
|
|
730
|
+
this.newPicker.render(out, W, H);
|
|
731
|
+
} else {
|
|
732
|
+
// Session list
|
|
733
|
+
const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
|
|
734
|
+
let si = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
|
|
735
|
+
|
|
736
|
+
for (let r = 0; r < this.listH; r++) {
|
|
737
|
+
out.push(mv(r + 2, 1), clrLine);
|
|
738
|
+
const item = visible[r];
|
|
739
|
+
if (!item) continue;
|
|
740
|
+
|
|
741
|
+
if (item.type === 'config') {
|
|
742
|
+
out.push(bold(fgc('96', ` [${item.label}]`)));
|
|
743
|
+
} else {
|
|
744
|
+
const s = item.session;
|
|
745
|
+
const isSel = si === this.selIdx;
|
|
746
|
+
const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
|
|
747
|
+
const project = shortenPath(s.project);
|
|
748
|
+
const dateStr = fmtDate(s.lastModified);
|
|
749
|
+
const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
|
|
750
|
+
const dateW = dateStr.length;
|
|
751
|
+
const avail = Math.max(12, W - 2 - cfgTag.length - 2 - 2 - dateW);
|
|
752
|
+
const titleW = Math.floor(avail * 0.5);
|
|
753
|
+
const projectW = avail - titleW;
|
|
754
|
+
|
|
755
|
+
if (isSel) {
|
|
756
|
+
const plain = ` ${cfgTag}${trunc(title, titleW).padEnd(titleW)} ${trunc(project, projectW).padEnd(projectW)} ${dateStr}`;
|
|
757
|
+
out.push(inv(pad(plain, W)));
|
|
758
|
+
} else {
|
|
759
|
+
out.push(
|
|
760
|
+
` ${this.search ? fgc('96', cfgTag) : ''}` +
|
|
761
|
+
(s.title ? fgc('92', pad(trunc(title, titleW), titleW)) : dim(pad(trunc(title, titleW), titleW))) +
|
|
762
|
+
` ${fgc('33', pad(trunc(project, projectW), projectW))}` +
|
|
763
|
+
` ${dim(dateStr)}`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
si++;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Footer
|
|
772
|
+
out.push(mv(H, 1));
|
|
773
|
+
const sel = this.selectedSession();
|
|
774
|
+
const total = this.sessionItems().length;
|
|
775
|
+
let footer;
|
|
776
|
+
|
|
777
|
+
if (this.mode === 'delete') {
|
|
778
|
+
const name = sel?.title || sel?.firstMessage?.slice(0, 35) || 'this session';
|
|
779
|
+
footer = ` Delete "${trunc(name, 35)}"? y=yes n/Esc=cancel`;
|
|
780
|
+
} else if (this.mode === 'move') {
|
|
781
|
+
const step = this.movePicker.step;
|
|
782
|
+
footer = step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
|
|
783
|
+
: step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
|
|
784
|
+
: ` ↑↓/jk=navigate Enter=select Esc=back to profiles`;
|
|
785
|
+
} else if (this.mode === 'new') {
|
|
786
|
+
const step = this.newPicker.step;
|
|
787
|
+
footer = step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
|
|
788
|
+
: step === 'input' ? ` Tab=complete path Enter=confirm Esc=back`
|
|
789
|
+
: ` ↑↓/jk=navigate Enter=select folder Esc=back to profiles`;
|
|
790
|
+
} else if (this.mode === 'search') {
|
|
791
|
+
footer = ` SEARCH: ${this.search}█ Esc=cancel Enter=done`;
|
|
792
|
+
} else if (this.statusMsg) {
|
|
793
|
+
footer = ` ${this.statusMsg}`;
|
|
794
|
+
} else {
|
|
795
|
+
const ctx = sel ? ` ${dim(trunc(sel.project, W - 45))}` : '';
|
|
796
|
+
footer = ` ↑↓/jk=nav /=search Enter=open n=new m=move d=del q=quit [${total}]${ctx}`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
out.push(bgc('40', fgc('37', pad(trunc(footer, W), W))));
|
|
800
|
+
process.stdout.write(out.join(''));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── Input ───────────────────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
handleKey(key) {
|
|
806
|
+
if (this.mode === 'delete') {
|
|
807
|
+
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
|
+
}
|
|
817
|
+
return true;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (this.mode === 'move') {
|
|
821
|
+
if (this.movePicker.handleKey(key) === false) this.mode = null;
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (this.mode === 'new') {
|
|
826
|
+
if (this.newPicker.handleKey(key) === false) this.mode = null;
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (this.mode === 'search') {
|
|
831
|
+
if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
|
|
832
|
+
else if (key === '\r') { this.mode = null; }
|
|
833
|
+
else if (key === '\x7f') { this.search = this.search.slice(0, -1); this.rebuild(); }
|
|
834
|
+
else if (key.length === 1 && key >= ' ') { this.search += key; this.rebuild(); }
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Normal mode
|
|
839
|
+
const total = this.sessionItems().length;
|
|
840
|
+
const move = (delta) => {
|
|
841
|
+
this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
|
|
842
|
+
this.ensureVisible();
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
if (key === 'q' || key === '\x03') return false;
|
|
846
|
+
|
|
847
|
+
if (key === '/') { this.mode = 'search'; }
|
|
848
|
+
else if (key === '\x1b') { this.search = ''; this.rebuild(); }
|
|
849
|
+
else if (key === '\r') {
|
|
850
|
+
const s = this.selectedSession();
|
|
851
|
+
if (s) {
|
|
852
|
+
const result = openSession(s);
|
|
853
|
+
this.setStatus(
|
|
854
|
+
result.ok ? `Opened "${s.title || s.sessionId.slice(0, 8)}" in new tab` : result.msg,
|
|
855
|
+
result.ok ? 2500 : 5000
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
else if (key === 'd') { if (this.selectedSession()) this.mode = 'delete'; }
|
|
860
|
+
else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
|
|
861
|
+
else if (key === 'm') {
|
|
862
|
+
if (this.selectedSession()) { this.movePicker.enter(); this.mode = 'move'; }
|
|
863
|
+
else this.setStatus('No session selected.');
|
|
864
|
+
}
|
|
865
|
+
else if (key === '\x1b[A' || key === 'k') move(-1);
|
|
866
|
+
else if (key === '\x1b[B' || key === 'j') move(+1);
|
|
867
|
+
else if (key === '\x1b[5~') move(-this.listH);
|
|
868
|
+
else if (key === '\x1b[6~') move(+this.listH);
|
|
869
|
+
else if (key === 'g') { this.selIdx = 0; this.ensureVisible(); }
|
|
870
|
+
else if (key === 'G') { this.selIdx = Math.max(0, total - 1); this.ensureVisible(); }
|
|
871
|
+
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
cleanup() { process.stdout.write(showCursor + normBuf); }
|
|
876
|
+
|
|
877
|
+
start() {
|
|
878
|
+
process.stdin.setRawMode(true);
|
|
879
|
+
process.stdin.resume();
|
|
880
|
+
process.stdin.setEncoding('utf8');
|
|
881
|
+
|
|
882
|
+
process.stdout.on('resize', () => {
|
|
883
|
+
this.w = process.stdout.columns;
|
|
884
|
+
this.h = process.stdout.rows;
|
|
885
|
+
this.ensureVisible();
|
|
886
|
+
this.render();
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
this.render();
|
|
890
|
+
|
|
891
|
+
process.stdin.on('data', key => {
|
|
892
|
+
if (this.handleKey(key) === false) {
|
|
893
|
+
this.cleanup();
|
|
894
|
+
process.exit(0);
|
|
895
|
+
}
|
|
896
|
+
this.render();
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
process.on('exit', () => this.cleanup());
|
|
900
|
+
process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
function printHelp() {
|
|
907
|
+
const src = fs.readFileSync(__filename, 'utf8');
|
|
908
|
+
const match = src.match(/\/\*\*([\s\S]*?)\*\//);
|
|
909
|
+
if (match) console.log(match[0].replace(/\/\*\*|\*\//g, '').replace(/^ \* ?/gm, '').trim());
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function main() {
|
|
913
|
+
const opts = parseArgs();
|
|
914
|
+
if (opts.version) { console.log(VERSION); return; }
|
|
915
|
+
if (opts.help) { printHelp(); return; }
|
|
916
|
+
|
|
917
|
+
const sessions = loadSessions(opts);
|
|
918
|
+
|
|
919
|
+
if (opts.json) {
|
|
920
|
+
const out = sessions.map(({ jsonlPath: _j, configDirPath: _c, ...rest }) => rest);
|
|
921
|
+
console.log(JSON.stringify(out, null, 2));
|
|
922
|
+
} else if (opts.list || !process.stdout.isTTY) {
|
|
923
|
+
printList(sessions);
|
|
924
|
+
} else {
|
|
925
|
+
new TUI(sessions, opts.filterPath).start();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (require.main === module) {
|
|
930
|
+
main();
|
|
931
|
+
} else {
|
|
932
|
+
module.exports = { encodePathToDir, tabComplete, resolveProjectPath, moveSession, readSession };
|
|
933
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vinh0809it/claude-sessions",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal UI for browsing, resuming, moving, and deleting Claude Code sessions",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-sessions": "./index.js"
|
|
7
|
+
},
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=18"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude",
|
|
13
|
+
"claude-code",
|
|
14
|
+
"anthropic",
|
|
15
|
+
"cli",
|
|
16
|
+
"tui",
|
|
17
|
+
"terminal",
|
|
18
|
+
"sessions"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/vinh0809it/claude-sessions.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/vinh0809it/claude-sessions/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/vinh0809it/claude-sessions#readme",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"index.js"
|
|
34
|
+
]
|
|
35
|
+
}
|