@kud/claude-sessions-cli 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.
- package/README.md +108 -0
- package/dist/index.js +577 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# claude-sessions
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>TUI session manager for Claude Code</b><br/>
|
|
5
|
+
Browse, open, create, and clean up your Claude sessions from a single interactive interface.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/@kud/claude-sessions-cli"><img alt="npm version" src="https://img.shields.io/npm/v/%40kud%2Fclaude-sessions-cli?color=brightgreen" /></a>
|
|
10
|
+
<a href="https://www.npmjs.com/package/@kud/claude-sessions-cli"><img alt="downloads" src="https://img.shields.io/npm/dm/%40kud%2Fclaude-sessions-cli" /></a>
|
|
11
|
+
<a href="LICENSE"><img alt="license" src="https://img.shields.io/npm/l/%40kud%2Fclaude-sessions-cli" /></a>
|
|
12
|
+
<a href="https://nodejs.org"><img alt="node version" src="https://img.shields.io/node/v/%40kud%2Fclaude-sessions-cli" /></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
> TL;DR: Run `claude-sessions`, pick a session, press `enter`. Claude Code opens right where you left off.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Table of Contents
|
|
20
|
+
|
|
21
|
+
- [Why](#why)
|
|
22
|
+
- [Features](#features)
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Usage](#usage)
|
|
25
|
+
- [Key Bindings](#key-bindings)
|
|
26
|
+
- [Clean Mode](#clean-mode)
|
|
27
|
+
- [Requirements](#requirements)
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
Claude Code stores sessions per directory but gives you no way to navigate them. This tool:
|
|
32
|
+
|
|
33
|
+
- Lists all your sessions (chat + code) sorted by last activity
|
|
34
|
+
- Lets you jump straight back into any session with `enter`
|
|
35
|
+
- Separates chat sessions (`~/.chats/`) from code sessions (project dirs)
|
|
36
|
+
- Keeps your session data clean with an interactive cleanup mode
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
| Category | Highlights |
|
|
41
|
+
| ---------- | ------------------------------------------------------------------- |
|
|
42
|
+
| Navigation | Sorted by last activity, grouped by type (chat / code) |
|
|
43
|
+
| Search | Fuzzy filter by name or path with `/` |
|
|
44
|
+
| Filter | Toggle between all / chat / code views with `tab` |
|
|
45
|
+
| New chat | Create a named chat session and open it immediately |
|
|
46
|
+
| Delete | Remove a session's history and directory with confirmation |
|
|
47
|
+
| Clean mode | Interactive cleanup of ghost entries, stale pointers, orphaned dirs |
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm install -g @kud/claude-sessions-cli
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
claude-sessions # open the TUI
|
|
59
|
+
claude-sessions clean # clean up stale session data
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### TUI
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
/ search…
|
|
66
|
+
|
|
67
|
+
+ New chat
|
|
68
|
+
|
|
69
|
+
── chat ────────────────────────
|
|
70
|
+
▶ Hey ~/.chats/hey just now
|
|
71
|
+
Planning ~/.chats/planning 2h
|
|
72
|
+
|
|
73
|
+
── code ────────────────────────
|
|
74
|
+
my-project ~/Projects/my-project yesterday
|
|
75
|
+
api ~/Projects/api 3d
|
|
76
|
+
|
|
77
|
+
↑↓ nav enter open d remove / search tab filter C clean q quit [all] chat code
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Key Bindings
|
|
81
|
+
|
|
82
|
+
| Key | Action |
|
|
83
|
+
| ----------- | ---------------------------------- |
|
|
84
|
+
| `↑` `↓` | Navigate |
|
|
85
|
+
| `enter` | Open session in Claude Code |
|
|
86
|
+
| `d` | Remove session (with confirmation) |
|
|
87
|
+
| `/` | Search by name or path |
|
|
88
|
+
| `tab` | Cycle filter: all → chat → code |
|
|
89
|
+
| `C` | Open clean mode |
|
|
90
|
+
| `q` / `esc` | Quit |
|
|
91
|
+
|
|
92
|
+
## Clean Mode
|
|
93
|
+
|
|
94
|
+
Scans `~/.claude.json` and `~/.claude/projects/` for stale data. Issues are grouped by type — select which categories to clean before confirming. Nothing is deleted without confirmation.
|
|
95
|
+
|
|
96
|
+
| Type | Meaning | Action |
|
|
97
|
+
| ---------------- | ------------------------------------------------------------------------ | ---------------------------- |
|
|
98
|
+
| ghost | Entry in `~/.claude.json` but the project directory no longer exists | Remove from `~/.claude.json` |
|
|
99
|
+
| no history | Entry in `~/.claude.json` with no conversation history | Remove from `~/.claude.json` |
|
|
100
|
+
| orphaned history | History in `~/.claude/projects/` with no matching `~/.claude.json` entry | Trash the history folder |
|
|
101
|
+
|
|
102
|
+
Available as both a TUI mode (`C` key) and a standalone subcommand (`claude-sessions clean`).
|
|
103
|
+
|
|
104
|
+
## Requirements
|
|
105
|
+
|
|
106
|
+
- Node.js ≥ 24
|
|
107
|
+
- [Claude Code](https://claude.ai/code) installed (`claude` in `PATH`)
|
|
108
|
+
- [`trash`](https://github.com/sindresorhus/trash-cli) for safe deletes (`npm install -g trash-cli`)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
|
|
4
|
+
// src/index.tsx
|
|
5
|
+
import React, { useState, useEffect } from "react";
|
|
6
|
+
import { render, Box, Text, useInput, useApp } from "ink";
|
|
7
|
+
import TextInput from "ink-text-input";
|
|
8
|
+
import { readdir, stat } from "fs/promises";
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { spawnSync, execSync } from "child_process";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
var HOME = homedir();
|
|
15
|
+
var CLAUDE_PROJECTS = join(HOME, ".claude", "projects");
|
|
16
|
+
var CLAUDE_JSON = join(HOME, ".claude.json");
|
|
17
|
+
var CHATS_DIR = join(HOME, ".chats");
|
|
18
|
+
var ICON_CHAT = "\u{F0B79}";
|
|
19
|
+
var ICON_CODE = "\uF121";
|
|
20
|
+
var pendingAction = null;
|
|
21
|
+
var slugify = (name) => name.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
22
|
+
var humanLabel = (dir) => {
|
|
23
|
+
const base = dir.split("/").pop() || dir;
|
|
24
|
+
const words = base.replace(/^[._-]+/, "").replace(/[-_]+/g, " ").trim();
|
|
25
|
+
return (words || base).replace(/\b\w/g, (c) => c.toUpperCase());
|
|
26
|
+
};
|
|
27
|
+
var kebabLabel = (dir) => dir.split("/").pop() || dir;
|
|
28
|
+
var timeAgo = (mtime) => {
|
|
29
|
+
const diff = Date.now() / 1e3 - mtime;
|
|
30
|
+
const m = Math.floor(diff / 60);
|
|
31
|
+
const h = Math.floor(diff / 3600);
|
|
32
|
+
const d = Math.floor(diff / 86400);
|
|
33
|
+
if (m < 1) return "just now";
|
|
34
|
+
if (m < 60) return `${m}m`;
|
|
35
|
+
if (h < 24) return `${h}h`;
|
|
36
|
+
if (d === 1) return "yesterday";
|
|
37
|
+
if (d < 7) return `${d}d`;
|
|
38
|
+
return new Date(mtime * 1e3).toLocaleDateString("en-GB", {
|
|
39
|
+
day: "2-digit",
|
|
40
|
+
month: "short"
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
var removeFromClaudeJson = (dir) => {
|
|
44
|
+
try {
|
|
45
|
+
const json = JSON.parse(readFileSync(CLAUDE_JSON, "utf8"));
|
|
46
|
+
if (json.projects?.[dir]) {
|
|
47
|
+
delete json.projects[dir];
|
|
48
|
+
writeFileSync(CLAUDE_JSON, JSON.stringify(json, null, 2));
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var toProjectDirName = (absPath) => absPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
54
|
+
var loadSessions = async () => {
|
|
55
|
+
const sessions = [];
|
|
56
|
+
if (existsSync(CLAUDE_JSON)) {
|
|
57
|
+
try {
|
|
58
|
+
const projectPaths = Object.keys(
|
|
59
|
+
JSON.parse(readFileSync(CLAUDE_JSON, "utf8")).projects ?? {}
|
|
60
|
+
);
|
|
61
|
+
await Promise.all(
|
|
62
|
+
projectPaths.map(async (cwd) => {
|
|
63
|
+
try {
|
|
64
|
+
const claudeProjectDir = join(
|
|
65
|
+
CLAUDE_PROJECTS,
|
|
66
|
+
toProjectDirName(cwd)
|
|
67
|
+
);
|
|
68
|
+
if (!existsSync(claudeProjectDir)) return;
|
|
69
|
+
const jsonlFiles = (await readdir(claudeProjectDir)).filter(
|
|
70
|
+
(f) => f.endsWith(".jsonl")
|
|
71
|
+
);
|
|
72
|
+
if (!jsonlFiles.length) return;
|
|
73
|
+
const mtime = Math.max(
|
|
74
|
+
...await Promise.all(
|
|
75
|
+
jsonlFiles.map(
|
|
76
|
+
(f) => stat(join(claudeProjectDir, f)).then((s) => s.mtimeMs / 1e3)
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
);
|
|
80
|
+
const shortPath = cwd.replace(HOME, "~");
|
|
81
|
+
const type = cwd === HOME || cwd.startsWith(CHATS_DIR) ? "chat" : "code";
|
|
82
|
+
sessions.push({
|
|
83
|
+
dir: cwd,
|
|
84
|
+
label: type === "chat" ? humanLabel(cwd) : kebabLabel(cwd),
|
|
85
|
+
path: shortPath,
|
|
86
|
+
type,
|
|
87
|
+
mtime,
|
|
88
|
+
ago: timeAgo(mtime),
|
|
89
|
+
claudeProjectDir
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const seen = new Set(sessions.map((s) => s.dir));
|
|
99
|
+
if (existsSync(CHATS_DIR)) {
|
|
100
|
+
try {
|
|
101
|
+
for (const dir of await readdir(CHATS_DIR)) {
|
|
102
|
+
try {
|
|
103
|
+
const fullPath = join(CHATS_DIR, dir);
|
|
104
|
+
if (!(await stat(fullPath)).isDirectory() || seen.has(fullPath))
|
|
105
|
+
continue;
|
|
106
|
+
const shortPath = fullPath.replace(HOME, "~");
|
|
107
|
+
sessions.push({
|
|
108
|
+
dir: fullPath,
|
|
109
|
+
label: humanLabel(fullPath),
|
|
110
|
+
path: shortPath,
|
|
111
|
+
type: "chat",
|
|
112
|
+
mtime: 0,
|
|
113
|
+
ago: "new",
|
|
114
|
+
claudeProjectDir: ""
|
|
115
|
+
});
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const unique = [...new Map(sessions.map((s) => [s.dir, s])).values()];
|
|
123
|
+
return unique.sort((a, b) => b.mtime - a.mtime);
|
|
124
|
+
};
|
|
125
|
+
var findCleanItems = async () => {
|
|
126
|
+
const items = [];
|
|
127
|
+
if (!existsSync(CLAUDE_JSON)) return items;
|
|
128
|
+
let projects = {};
|
|
129
|
+
try {
|
|
130
|
+
projects = JSON.parse(readFileSync(CLAUDE_JSON, "utf8")).projects ?? {};
|
|
131
|
+
} catch {
|
|
132
|
+
return items;
|
|
133
|
+
}
|
|
134
|
+
const projectPaths = Object.keys(projects);
|
|
135
|
+
for (const cwd of projectPaths) {
|
|
136
|
+
const projectDir = join(CLAUDE_PROJECTS, toProjectDirName(cwd));
|
|
137
|
+
const shortCwd = cwd.replace(HOME, "~");
|
|
138
|
+
if (!existsSync(cwd)) {
|
|
139
|
+
items.push({
|
|
140
|
+
label: shortCwd,
|
|
141
|
+
reason: "ghost (directory deleted)",
|
|
142
|
+
execute: () => {
|
|
143
|
+
removeFromClaudeJson(cwd);
|
|
144
|
+
if (existsSync(projectDir)) execSync(`trash "${projectDir}"`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!existsSync(projectDir)) {
|
|
150
|
+
items.push({
|
|
151
|
+
label: shortCwd,
|
|
152
|
+
reason: "no history",
|
|
153
|
+
execute: () => removeFromClaudeJson(cwd)
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const jsonlFiles = (await readdir(projectDir)).filter(
|
|
159
|
+
(f) => f.endsWith(".jsonl")
|
|
160
|
+
);
|
|
161
|
+
if (!jsonlFiles.length)
|
|
162
|
+
items.push({
|
|
163
|
+
label: shortCwd,
|
|
164
|
+
reason: "no history",
|
|
165
|
+
execute: () => {
|
|
166
|
+
removeFromClaudeJson(cwd);
|
|
167
|
+
execSync(`trash "${projectDir}"`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (existsSync(CLAUDE_PROJECTS)) {
|
|
174
|
+
const knownDirNames = new Set(projectPaths.map(toProjectDirName));
|
|
175
|
+
try {
|
|
176
|
+
for (const dir of await readdir(CLAUDE_PROJECTS)) {
|
|
177
|
+
if (!knownDirNames.has(dir)) {
|
|
178
|
+
const fullPath = join(CLAUDE_PROJECTS, dir);
|
|
179
|
+
items.push({
|
|
180
|
+
label: `~/.claude/projects/${dir}`,
|
|
181
|
+
reason: "orphaned history",
|
|
182
|
+
execute: () => execSync(`trash "${fullPath}"`)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return items;
|
|
190
|
+
};
|
|
191
|
+
var deleteSession = (item) => {
|
|
192
|
+
if (item.claudeProjectDir) {
|
|
193
|
+
try {
|
|
194
|
+
execSync(`trash "${item.claudeProjectDir}"`);
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
removeFromClaudeJson(item.dir);
|
|
198
|
+
}
|
|
199
|
+
if (item.type === "chat") {
|
|
200
|
+
try {
|
|
201
|
+
execSync(`trash "${item.dir}"`);
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
var SectionHeader = ({ label }) => {
|
|
207
|
+
return /* @__PURE__ */ jsx(Box, { paddingX: 2, marginTop: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
208
|
+
"\u2500\u2500 ",
|
|
209
|
+
label,
|
|
210
|
+
" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
|
|
211
|
+
] }) });
|
|
212
|
+
};
|
|
213
|
+
var Hint = ({ pairs }) => /* @__PURE__ */ jsx(Box, { gap: 2, children: pairs.map(([key, desc]) => /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
|
|
214
|
+
/* @__PURE__ */ jsx(Text, { color: "white", children: key }),
|
|
215
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: desc })
|
|
216
|
+
] }, key)) });
|
|
217
|
+
var FilterBar = ({ filter }) => {
|
|
218
|
+
const opts = ["all", "chat", "code"];
|
|
219
|
+
return /* @__PURE__ */ jsx(Box, { gap: 1, children: opts.map((o) => /* @__PURE__ */ jsx(
|
|
220
|
+
Text,
|
|
221
|
+
{
|
|
222
|
+
color: filter === o ? "cyan" : "gray",
|
|
223
|
+
bold: filter === o,
|
|
224
|
+
children: filter === o ? `[${o}]` : o
|
|
225
|
+
},
|
|
226
|
+
o
|
|
227
|
+
)) });
|
|
228
|
+
};
|
|
229
|
+
var CleanConfirm = ({
|
|
230
|
+
items,
|
|
231
|
+
onConfirm,
|
|
232
|
+
onCancel
|
|
233
|
+
}) => {
|
|
234
|
+
const groups = items ? [...new Map(items.map((item) => [item.reason, item.reason])).keys()] : [];
|
|
235
|
+
const [cursor, setCursor] = useState(0);
|
|
236
|
+
const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (groups.length) setSelected(new Set(groups));
|
|
239
|
+
}, [items]);
|
|
240
|
+
useInput(
|
|
241
|
+
(input, key) => {
|
|
242
|
+
if (!items) return;
|
|
243
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
244
|
+
if (key.downArrow) setCursor((c) => Math.min(groups.length - 1, c + 1));
|
|
245
|
+
if (input === " ")
|
|
246
|
+
setSelected((s) => {
|
|
247
|
+
const next = new Set(s);
|
|
248
|
+
const reason = groups[cursor];
|
|
249
|
+
if (next.has(reason)) next.delete(reason);
|
|
250
|
+
else next.add(reason);
|
|
251
|
+
return next;
|
|
252
|
+
});
|
|
253
|
+
if (input === "a")
|
|
254
|
+
setSelected(
|
|
255
|
+
(s) => s.size === groups.length ? /* @__PURE__ */ new Set() : new Set(groups)
|
|
256
|
+
);
|
|
257
|
+
if (input === "y")
|
|
258
|
+
onConfirm(items.filter((item) => selected.has(item.reason)));
|
|
259
|
+
if (input === "n" || key.escape) onCancel();
|
|
260
|
+
},
|
|
261
|
+
{ isActive: !!items }
|
|
262
|
+
);
|
|
263
|
+
if (!items) return /* @__PURE__ */ jsx(Text, { dimColor: true, children: " scanning\u2026" });
|
|
264
|
+
if (!items.length)
|
|
265
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
266
|
+
/* @__PURE__ */ jsx(Text, { color: "green", children: " nothing to clean" }),
|
|
267
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Hint, { pairs: [["esc", "back"]] }) })
|
|
268
|
+
] });
|
|
269
|
+
const REASON_COLOR = {
|
|
270
|
+
"ghost (directory deleted)": "red",
|
|
271
|
+
"no history": "yellow",
|
|
272
|
+
"orphaned history": "magenta"
|
|
273
|
+
};
|
|
274
|
+
const countByReason = items.reduce((acc, item) => {
|
|
275
|
+
acc[item.reason] = (acc[item.reason] ?? 0) + 1;
|
|
276
|
+
return acc;
|
|
277
|
+
}, {});
|
|
278
|
+
const itemsByReason = items.reduce(
|
|
279
|
+
(acc, item) => {
|
|
280
|
+
;
|
|
281
|
+
(acc[item.reason] ??= []).push(item);
|
|
282
|
+
return acc;
|
|
283
|
+
},
|
|
284
|
+
{}
|
|
285
|
+
);
|
|
286
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
287
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: "Clean up" }),
|
|
288
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, children: groups.map((reason, i) => {
|
|
289
|
+
const sel = i === cursor;
|
|
290
|
+
const checked = selected.has(reason);
|
|
291
|
+
const color = REASON_COLOR[reason] ?? "white";
|
|
292
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: i > 0 ? 1 : 0, children: [
|
|
293
|
+
/* @__PURE__ */ jsxs(Box, { gap: 2, children: [
|
|
294
|
+
/* @__PURE__ */ jsx(Text, { color: sel ? "cyan" : "gray", children: sel ? "\u25B6" : " " }),
|
|
295
|
+
/* @__PURE__ */ jsx(Text, { color: checked ? color : "gray", children: checked ? "[x]" : "[ ]" }),
|
|
296
|
+
/* @__PURE__ */ jsx(Text, { color: checked ? color : "gray", bold: checked, children: reason }),
|
|
297
|
+
/* @__PURE__ */ jsx(Text, { color: checked ? color : "gray", dimColor: true, children: countByReason[reason] })
|
|
298
|
+
] }),
|
|
299
|
+
itemsByReason[reason].map((item) => /* @__PURE__ */ jsxs(Box, { paddingLeft: 6, gap: 1, children: [
|
|
300
|
+
/* @__PURE__ */ jsx(Text, { color: checked ? color : "gray", dimColor: true, children: "\u2502" }),
|
|
301
|
+
/* @__PURE__ */ jsx(Text, { color: checked ? "white" : "gray", dimColor: !checked, children: item.label })
|
|
302
|
+
] }, item.label))
|
|
303
|
+
] }, reason);
|
|
304
|
+
}) }),
|
|
305
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(
|
|
306
|
+
Hint,
|
|
307
|
+
{
|
|
308
|
+
pairs: [
|
|
309
|
+
["\u2191\u2193", "nav"],
|
|
310
|
+
["space", "toggle"],
|
|
311
|
+
["a", "all"],
|
|
312
|
+
["y", "confirm"],
|
|
313
|
+
["n / esc", "cancel"]
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
) })
|
|
317
|
+
] });
|
|
318
|
+
};
|
|
319
|
+
var CleanApp = () => {
|
|
320
|
+
const { exit } = useApp();
|
|
321
|
+
const [items, setItems] = useState(null);
|
|
322
|
+
const [done, setDone] = useState(false);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
findCleanItems().then(setItems);
|
|
325
|
+
}, []);
|
|
326
|
+
if (done) return /* @__PURE__ */ jsx(Text, { color: "green", children: " done" });
|
|
327
|
+
return /* @__PURE__ */ jsx(
|
|
328
|
+
CleanConfirm,
|
|
329
|
+
{
|
|
330
|
+
items,
|
|
331
|
+
onConfirm: (selected) => {
|
|
332
|
+
for (const item of selected) item.execute();
|
|
333
|
+
setDone(true);
|
|
334
|
+
setTimeout(exit, 300);
|
|
335
|
+
},
|
|
336
|
+
onCancel: exit
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
};
|
|
340
|
+
var App = () => {
|
|
341
|
+
const { exit } = useApp();
|
|
342
|
+
const [sessions, setSessions] = useState(null);
|
|
343
|
+
const [cursor, setCursor] = useState(0);
|
|
344
|
+
const [mode, setMode] = useState("list");
|
|
345
|
+
const [newName, setNewName] = useState("");
|
|
346
|
+
const [filter, setFilter] = useState("all");
|
|
347
|
+
const [search, setSearch] = useState("");
|
|
348
|
+
const [cleanItems, setCleanItems] = useState(null);
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
loadSessions().then(setSessions);
|
|
351
|
+
}, []);
|
|
352
|
+
const chatSessions = sessions?.filter((s) => s.type === "chat") ?? [];
|
|
353
|
+
const codeSessions = sessions?.filter((s) => s.type === "code") ?? [];
|
|
354
|
+
const grouped = filter === "chat" ? chatSessions : filter === "code" ? codeSessions : [...chatSessions, ...codeSessions];
|
|
355
|
+
const filtered = search ? grouped.filter(
|
|
356
|
+
(s) => s.label.toLowerCase().includes(search.toLowerCase()) || s.path.toLowerCase().includes(search.toLowerCase())
|
|
357
|
+
) : grouped;
|
|
358
|
+
const items = sessions ? [null, ...filtered] : [];
|
|
359
|
+
const doExit = (action) => {
|
|
360
|
+
pendingAction = action;
|
|
361
|
+
exit();
|
|
362
|
+
};
|
|
363
|
+
const cycleFilter = () => {
|
|
364
|
+
setFilter((f) => f === "all" ? "chat" : f === "chat" ? "code" : "all");
|
|
365
|
+
setCursor(0);
|
|
366
|
+
};
|
|
367
|
+
useInput(
|
|
368
|
+
(input, key) => {
|
|
369
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
370
|
+
if (key.downArrow) setCursor((c) => Math.min(items.length - 1, c + 1));
|
|
371
|
+
if (key.tab) cycleFilter();
|
|
372
|
+
if (input === "/") {
|
|
373
|
+
setMode("search");
|
|
374
|
+
setSearch("");
|
|
375
|
+
setCursor(0);
|
|
376
|
+
}
|
|
377
|
+
if (key.return) {
|
|
378
|
+
if (cursor === 0) {
|
|
379
|
+
setMode("new");
|
|
380
|
+
setNewName("");
|
|
381
|
+
} else if (items[cursor])
|
|
382
|
+
doExit({ type: "open", dir: items[cursor].dir });
|
|
383
|
+
}
|
|
384
|
+
if (input === "d" && cursor > 0) setMode("confirm-delete");
|
|
385
|
+
if (input === "C") {
|
|
386
|
+
setCleanItems(null);
|
|
387
|
+
setMode("clean-confirm");
|
|
388
|
+
findCleanItems().then(setCleanItems);
|
|
389
|
+
}
|
|
390
|
+
if (input === "q" || key.escape) exit();
|
|
391
|
+
},
|
|
392
|
+
{ isActive: mode === "list" && !!sessions }
|
|
393
|
+
);
|
|
394
|
+
useInput(
|
|
395
|
+
(input, key) => {
|
|
396
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
397
|
+
if (key.downArrow) setCursor((c) => Math.min(items.length - 1, c + 1));
|
|
398
|
+
if (key.return) {
|
|
399
|
+
if (cursor === 0) {
|
|
400
|
+
setSearch("");
|
|
401
|
+
setMode("new");
|
|
402
|
+
setNewName("");
|
|
403
|
+
} else if (items[cursor])
|
|
404
|
+
doExit({ type: "open", dir: items[cursor].dir });
|
|
405
|
+
}
|
|
406
|
+
if (key.escape) {
|
|
407
|
+
setSearch("");
|
|
408
|
+
setMode("list");
|
|
409
|
+
setCursor(0);
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
{ isActive: mode === "search" && !!sessions }
|
|
413
|
+
);
|
|
414
|
+
useInput(
|
|
415
|
+
(input, key) => {
|
|
416
|
+
if (input === "y") {
|
|
417
|
+
const item = items[cursor];
|
|
418
|
+
deleteSession(item);
|
|
419
|
+
setSessions((s) => s.filter((x) => x.dir !== item.dir));
|
|
420
|
+
setCursor((c) => Math.min(c, items.length - 2));
|
|
421
|
+
setMode("list");
|
|
422
|
+
}
|
|
423
|
+
if (input === "n" || key.escape) setMode("list");
|
|
424
|
+
},
|
|
425
|
+
{ isActive: mode === "confirm-delete" }
|
|
426
|
+
);
|
|
427
|
+
useInput(
|
|
428
|
+
(_, key) => {
|
|
429
|
+
if (key.escape) setMode("list");
|
|
430
|
+
},
|
|
431
|
+
{ isActive: mode === "new" }
|
|
432
|
+
);
|
|
433
|
+
if (!sessions) return /* @__PURE__ */ jsx(Text, { dimColor: true, children: " loading\u2026" });
|
|
434
|
+
if (mode === "new")
|
|
435
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
436
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: "New chat" }),
|
|
437
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, gap: 1, children: [
|
|
438
|
+
/* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u203A" }),
|
|
439
|
+
/* @__PURE__ */ jsx(
|
|
440
|
+
TextInput,
|
|
441
|
+
{
|
|
442
|
+
value: newName,
|
|
443
|
+
onChange: setNewName,
|
|
444
|
+
onSubmit: (val) => {
|
|
445
|
+
if (!val.trim()) {
|
|
446
|
+
setMode("list");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
doExit({ type: "new", dir: join(CHATS_DIR, slugify(val.trim())) });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
] }),
|
|
454
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Hint, { pairs: [["esc", "cancel"]] }) })
|
|
455
|
+
] });
|
|
456
|
+
if (mode === "confirm-delete") {
|
|
457
|
+
const item = items[cursor];
|
|
458
|
+
const toDelete = [
|
|
459
|
+
...item.type === "chat" ? [item.dir] : [],
|
|
460
|
+
...item.claudeProjectDir ? [item.claudeProjectDir] : []
|
|
461
|
+
];
|
|
462
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [
|
|
463
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
464
|
+
"Remove",
|
|
465
|
+
" ",
|
|
466
|
+
/* @__PURE__ */ jsx(Text, { color: "red", bold: true, children: item.label }),
|
|
467
|
+
"?"
|
|
468
|
+
] }),
|
|
469
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, children: toDelete.map((p) => /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
470
|
+
" ",
|
|
471
|
+
p.replace(HOME, "~")
|
|
472
|
+
] }, p)) }),
|
|
473
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(
|
|
474
|
+
Hint,
|
|
475
|
+
{
|
|
476
|
+
pairs: [
|
|
477
|
+
["y", "confirm"],
|
|
478
|
+
["n / esc", "cancel"]
|
|
479
|
+
]
|
|
480
|
+
}
|
|
481
|
+
) })
|
|
482
|
+
] });
|
|
483
|
+
}
|
|
484
|
+
if (mode === "clean-confirm")
|
|
485
|
+
return /* @__PURE__ */ jsx(
|
|
486
|
+
CleanConfirm,
|
|
487
|
+
{
|
|
488
|
+
items: cleanItems,
|
|
489
|
+
onConfirm: (selected) => {
|
|
490
|
+
for (const item of selected) item.execute();
|
|
491
|
+
setMode("list");
|
|
492
|
+
loadSessions().then(setSessions);
|
|
493
|
+
},
|
|
494
|
+
onCancel: () => setMode("list")
|
|
495
|
+
}
|
|
496
|
+
);
|
|
497
|
+
const isSearching = mode === "search";
|
|
498
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingY: 1, children: [
|
|
499
|
+
/* @__PURE__ */ jsxs(Box, { paddingX: 2, marginBottom: 1, gap: 1, children: [
|
|
500
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: "/" }),
|
|
501
|
+
isSearching ? /* @__PURE__ */ jsx(
|
|
502
|
+
TextInput,
|
|
503
|
+
{
|
|
504
|
+
value: search,
|
|
505
|
+
onChange: (v) => {
|
|
506
|
+
setSearch(v);
|
|
507
|
+
setCursor(0);
|
|
508
|
+
},
|
|
509
|
+
onSubmit: () => {
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
) : /* @__PURE__ */ jsx(Text, { dimColor: true, children: search || "search\u2026" })
|
|
513
|
+
] }),
|
|
514
|
+
items.map((item, i) => {
|
|
515
|
+
const sel = i === cursor;
|
|
516
|
+
const prev = items[i - 1];
|
|
517
|
+
const showChatHeader = !search && filter === "all" && item?.type === "chat" && prev?.type !== "chat";
|
|
518
|
+
const showCodeHeader = !search && filter === "all" && item?.type === "code" && prev?.type !== "code";
|
|
519
|
+
if (!item)
|
|
520
|
+
return /* @__PURE__ */ jsxs(Box, { paddingX: 2, children: [
|
|
521
|
+
/* @__PURE__ */ jsx(Text, { color: sel ? "cyan" : "gray", children: sel ? "\u25B6 " : " " }),
|
|
522
|
+
/* @__PURE__ */ jsx(Text, { color: "white", bold: sel, children: "+ New chat" })
|
|
523
|
+
] }, "new");
|
|
524
|
+
return /* @__PURE__ */ jsxs(React.Fragment, { children: [
|
|
525
|
+
showChatHeader && /* @__PURE__ */ jsx(SectionHeader, { label: "chat" }),
|
|
526
|
+
showCodeHeader && /* @__PURE__ */ jsx(SectionHeader, { label: "code" }),
|
|
527
|
+
/* @__PURE__ */ jsxs(Box, { paddingX: 2, gap: 2, children: [
|
|
528
|
+
/* @__PURE__ */ jsx(Text, { color: sel ? "cyan" : "gray", children: sel ? "\u25B6" : " " }),
|
|
529
|
+
/* @__PURE__ */ jsx(
|
|
530
|
+
Text,
|
|
531
|
+
{
|
|
532
|
+
color: item.type === "chat" ? "magenta" : "green",
|
|
533
|
+
bold: sel,
|
|
534
|
+
children: item.type === "chat" ? ICON_CHAT : ICON_CODE
|
|
535
|
+
}
|
|
536
|
+
),
|
|
537
|
+
/* @__PURE__ */ jsx(Text, { color: "white", bold: sel, children: item.label }),
|
|
538
|
+
/* @__PURE__ */ jsx(Text, { color: "gray", bold: sel, children: item.path }),
|
|
539
|
+
/* @__PURE__ */ jsx(Text, { color: "yellow", bold: sel, dimColor: !sel, children: item.ago })
|
|
540
|
+
] })
|
|
541
|
+
] }, item.dir);
|
|
542
|
+
}),
|
|
543
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, paddingX: 2, gap: 3, children: [
|
|
544
|
+
/* @__PURE__ */ jsx(
|
|
545
|
+
Hint,
|
|
546
|
+
{
|
|
547
|
+
pairs: [
|
|
548
|
+
["\u2191\u2193", "nav"],
|
|
549
|
+
["enter", "open"],
|
|
550
|
+
["d", "remove"],
|
|
551
|
+
["/", "search"],
|
|
552
|
+
["tab", "filter"],
|
|
553
|
+
["C", "clean"],
|
|
554
|
+
["q", "quit"]
|
|
555
|
+
]
|
|
556
|
+
}
|
|
557
|
+
),
|
|
558
|
+
/* @__PURE__ */ jsx(FilterBar, { filter })
|
|
559
|
+
] })
|
|
560
|
+
] });
|
|
561
|
+
};
|
|
562
|
+
mkdirSync(CHATS_DIR, { recursive: true });
|
|
563
|
+
if (process.argv[2] === "clean") {
|
|
564
|
+
const { waitUntilExit } = render(/* @__PURE__ */ jsx(CleanApp, {}), { exitOnCtrlC: true });
|
|
565
|
+
await waitUntilExit();
|
|
566
|
+
} else {
|
|
567
|
+
const { waitUntilExit } = render(/* @__PURE__ */ jsx(App, {}), { exitOnCtrlC: true });
|
|
568
|
+
await waitUntilExit();
|
|
569
|
+
if (pendingAction) {
|
|
570
|
+
const { type, dir } = pendingAction;
|
|
571
|
+
mkdirSync(dir, { recursive: true });
|
|
572
|
+
process.chdir(dir);
|
|
573
|
+
spawnSync("claude", type === "open" ? ["--continue"] : [], {
|
|
574
|
+
stdio: "inherit"
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kud/claude-sessions-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TUI session manager for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-sessions": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"dev": "tsx src/index.tsx",
|
|
18
|
+
"build": "tsup",
|
|
19
|
+
"build:watch": "tsup --watch",
|
|
20
|
+
"clean": "rm -rf dist",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"prepublishOnly": "npm run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"ink": "6.8.0",
|
|
26
|
+
"ink-text-input": "6.0.0",
|
|
27
|
+
"react": "19.2.4"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^25.5.0",
|
|
31
|
+
"@types/react": "^19.2.14",
|
|
32
|
+
"tsup": "^8.5.1",
|
|
33
|
+
"tsx": "4.21.0",
|
|
34
|
+
"typescript": "^6.0.2"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=24.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|