@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.
Files changed (3) hide show
  1. package/README.md +108 -0
  2. package/dist/index.js +577 -0
  3. 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
+ }