sessioner 0.1.0 → 0.1.2
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 +19 -11
- package/lib/actions/delete.js +0 -12
- package/lib/actions/merge.js +2 -2
- package/lib/actions/preview.js +1 -1
- package/lib/actions/prune.js +15 -1
- package/lib/actions/rename.js +7 -85
- package/lib/bin/index.js +171 -138
- package/lib/sessions/analyze-prune.js +7 -1
- package/lib/sessions/extract-first-user-message.js +15 -6
- package/lib/sessions/search-sessions.d.ts +2 -0
- package/lib/sessions/search-sessions.js +51 -0
- package/lib/types.d.ts +7 -8
- package/lib/ui/layout.d.ts +16 -0
- package/lib/ui/layout.js +46 -0
- package/lib/ui/search.d.ts +4 -0
- package/lib/ui/search.js +157 -0
- package/lib/ui/select.d.ts +1 -1
- package/lib/ui/select.js +21 -51
- package/package.json +4 -2
- package/lib/helpers/rename-cache.d.ts +0 -3
- package/lib/helpers/rename-cache.js +0 -18
package/README.md
CHANGED
|
@@ -32,8 +32,6 @@ Claude Code supports native forking from specific points, but loses context like
|
|
|
32
32
|
npm i sessioner
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
> Requires **Node.js >= 22**.
|
|
36
|
-
|
|
37
35
|
---
|
|
38
36
|
|
|
39
37
|
## 🚀 Quick Start
|
|
@@ -58,7 +56,7 @@ sessioner
|
|
|
58
56
|
## 🔍 How It Works
|
|
59
57
|
|
|
60
58
|
1. Scans `{base}/{project}` for `.jsonl` session files
|
|
61
|
-
2. Shows a **main menu** (Sessions, Stats, Clean)
|
|
59
|
+
2. Shows a **main menu** (Sessions, Search, Stats, Clean)
|
|
62
60
|
3. Shows a **paginated session list** sorted by date (newest first)
|
|
63
61
|
4. Displays a **conversation preview** for the selected session (up to 10 messages)
|
|
64
62
|
5. Opens an **action menu** to operate on it
|
|
@@ -89,6 +87,19 @@ sessioner
|
|
|
89
87
|
|
|
90
88
|
---
|
|
91
89
|
|
|
90
|
+
### Search (across all sessions)
|
|
91
|
+
|
|
92
|
+
- Text input for a search query
|
|
93
|
+
- Streams all sessions in parallel for case-insensitive text matching
|
|
94
|
+
- Results grouped by session title, with match count and date
|
|
95
|
+
- Matched text highlighted in bold
|
|
96
|
+
- Paginated 10 matches at a time
|
|
97
|
+
- Clicking a match opens the session's action menu
|
|
98
|
+
- Back from the action menu returns to search results
|
|
99
|
+
- Shows "No results" when nothing matches
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
92
103
|
### Fork
|
|
93
104
|
|
|
94
105
|
- Creates an independent copy of the entire session
|
|
@@ -114,9 +125,10 @@ sessioner
|
|
|
114
125
|
- Analyzes the session and reports what can be removed:
|
|
115
126
|
- **Tool blocks**: `tool_use` and `tool_result` entries
|
|
116
127
|
- **Empty messages**: no text content after tools are stripped
|
|
117
|
-
- **Short messages**: text under 50 characters
|
|
118
128
|
- **System/IDE tags**: `<system-reminder>`, `<ide_selection>`, `<ide_opened_file>`
|
|
119
|
-
-
|
|
129
|
+
- **Old custom titles**: duplicate `custom-title` entries (keeps the most recent)
|
|
130
|
+
- **Short messages**: text under 50 characters (unselected by default)
|
|
131
|
+
- Checkbox selection for what to remove (all pre-selected by default, except short messages)
|
|
120
132
|
- Repairs the parent-child UUID chain after pruning
|
|
121
133
|
- Shows "Nothing to prune" when the session is already clean
|
|
122
134
|
|
|
@@ -135,11 +147,7 @@ sessioner
|
|
|
135
147
|
### Rename
|
|
136
148
|
|
|
137
149
|
- Text input for the new title (<kbd>Esc</kbd> to cancel)
|
|
138
|
-
-
|
|
139
|
-
- Session file (prepends a `session_title` line)
|
|
140
|
-
- Sessions index (`sessions-index.json`)
|
|
141
|
-
- First user message (injects title into metadata)
|
|
142
|
-
- Tracks renames in a local cache (`.cache/renames.json`)
|
|
150
|
+
- Writes a `custom-title` entry to the session file (same format used by the Claude Code extension)
|
|
143
151
|
|
|
144
152
|
---
|
|
145
153
|
|
|
@@ -148,7 +156,7 @@ sessioner
|
|
|
148
156
|
- Shows exactly what will be deleted:
|
|
149
157
|
- Session `.jsonl` file
|
|
150
158
|
- Subagents directory (with file count)
|
|
151
|
-
- Removes the entry from the sessions index
|
|
159
|
+
- Removes the entry from the sessions index
|
|
152
160
|
- Confirmation prompt before applying
|
|
153
161
|
|
|
154
162
|
---
|
package/lib/actions/delete.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { readFile, rm, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
|
-
import { readCache, writeCache } from '../helpers/rename-cache.js';
|
|
4
3
|
import { countSubagents } from '../sessions/count-subagents.js';
|
|
5
4
|
export const targets = async (session) => {
|
|
6
5
|
const name = basename(session.file);
|
|
@@ -23,16 +22,6 @@ const removeFromIndex = async (session) => {
|
|
|
23
22
|
}
|
|
24
23
|
catch { }
|
|
25
24
|
};
|
|
26
|
-
const removeFromCache = async (session) => {
|
|
27
|
-
try {
|
|
28
|
-
const cache = await readCache();
|
|
29
|
-
if (!(session.file in cache))
|
|
30
|
-
return;
|
|
31
|
-
delete cache[session.file];
|
|
32
|
-
await writeCache(cache);
|
|
33
|
-
}
|
|
34
|
-
catch { }
|
|
35
|
-
};
|
|
36
25
|
export const cleanEmpty = async (files) => {
|
|
37
26
|
for (const file of files) {
|
|
38
27
|
const id = basename(file, '.jsonl');
|
|
@@ -53,5 +42,4 @@ export const remove = async (session) => {
|
|
|
53
42
|
}
|
|
54
43
|
catch { }
|
|
55
44
|
await removeFromIndex(session);
|
|
56
|
-
await removeFromCache(session);
|
|
57
45
|
};
|
package/lib/actions/merge.js
CHANGED
|
@@ -9,7 +9,7 @@ const buildUuidMap = (lines) => {
|
|
|
9
9
|
continue;
|
|
10
10
|
try {
|
|
11
11
|
const parsed = JSON.parse(line);
|
|
12
|
-
if (parsed.type === 'session_title')
|
|
12
|
+
if (parsed.type === 'session_title' || parsed.type === 'custom-title')
|
|
13
13
|
continue;
|
|
14
14
|
if (parsed.uuid)
|
|
15
15
|
uuidMap.set(parsed.uuid, randomUUID());
|
|
@@ -26,7 +26,7 @@ const remapSession = (lines, uuidMap, sessionId, previousLastUuid) => {
|
|
|
26
26
|
continue;
|
|
27
27
|
try {
|
|
28
28
|
const parsed = JSON.parse(line);
|
|
29
|
-
if (parsed.type === 'session_title')
|
|
29
|
+
if (parsed.type === 'session_title' || parsed.type === 'custom-title')
|
|
30
30
|
continue;
|
|
31
31
|
if (parsed.uuid) {
|
|
32
32
|
const newUuid = uuidMap.get(parsed.uuid);
|
package/lib/actions/preview.js
CHANGED
|
@@ -29,7 +29,7 @@ export const preview = (session, limit = PREVIEW_LIMIT) => new Promise((resolve)
|
|
|
29
29
|
const entry = parseLine(line);
|
|
30
30
|
if (!entry)
|
|
31
31
|
return;
|
|
32
|
-
if (entry.type === 'session_title')
|
|
32
|
+
if (entry.type === 'session_title' || entry.type === 'custom-title')
|
|
33
33
|
return;
|
|
34
34
|
const texts = entry.message?.content ?? [];
|
|
35
35
|
for (const block of texts) {
|
package/lib/actions/prune.js
CHANGED
|
@@ -75,11 +75,25 @@ export const prune = async (session, options) => {
|
|
|
75
75
|
const lines = raw.split('\n');
|
|
76
76
|
const kept = [];
|
|
77
77
|
const survivingUuids = new Set();
|
|
78
|
-
|
|
78
|
+
let lastTitleIndex = -1;
|
|
79
|
+
if (options.customTitles) {
|
|
80
|
+
for (let index = 0; index < lines.length; index++) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(lines[index] ?? '');
|
|
83
|
+
if (parsed.type === 'custom-title')
|
|
84
|
+
lastTitleIndex = index;
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (let index = 0; index < lines.length; index++) {
|
|
90
|
+
const line = lines[index] ?? '';
|
|
79
91
|
if (!line.trim())
|
|
80
92
|
continue;
|
|
81
93
|
try {
|
|
82
94
|
const parsed = JSON.parse(line);
|
|
95
|
+
if (parsed.type === 'custom-title' && index !== lastTitleIndex)
|
|
96
|
+
continue;
|
|
83
97
|
if (hasContent(parsed)) {
|
|
84
98
|
const content = filterContent(parsed.message.content, options);
|
|
85
99
|
if (shouldSkipMessage(parsed.type, content, options))
|
package/lib/actions/rename.js
CHANGED
|
@@ -1,87 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import { readCache, writeCache } from '../helpers/rename-cache.js';
|
|
4
|
-
const updateIndex = async (session, title) => {
|
|
5
|
-
const path = join(dirname(session.file), 'sessions-index.json');
|
|
6
|
-
try {
|
|
7
|
-
const raw = await readFile(path, 'utf-8');
|
|
8
|
-
const index = JSON.parse(raw);
|
|
9
|
-
const entry = index.entries.find((item) => item.sessionId === session.id);
|
|
10
|
-
if (!entry)
|
|
11
|
-
return;
|
|
12
|
-
entry.firstPrompt = title;
|
|
13
|
-
await writeFile(path, JSON.stringify(index, null, 2));
|
|
14
|
-
}
|
|
15
|
-
catch { }
|
|
16
|
-
};
|
|
17
|
-
const stripExistingTitle = (content) => {
|
|
18
|
-
const newline = content.indexOf('\n');
|
|
19
|
-
if (newline === -1)
|
|
20
|
-
return content;
|
|
21
|
-
try {
|
|
22
|
-
const parsed = JSON.parse(content.slice(0, newline));
|
|
23
|
-
if (parsed.type === 'session_title')
|
|
24
|
-
return content.slice(newline + 1);
|
|
25
|
-
}
|
|
26
|
-
catch { }
|
|
27
|
-
return content;
|
|
28
|
-
};
|
|
29
|
-
const isUserLine = (value) => typeof value === 'object' &&
|
|
30
|
-
value !== null &&
|
|
31
|
-
value.type === 'user' &&
|
|
32
|
-
Array.isArray(value.message?.content);
|
|
33
|
-
const isTextBlock = (block) => block.type === 'text' &&
|
|
34
|
-
typeof block.text === 'string' &&
|
|
35
|
-
!block.text.startsWith('<');
|
|
36
|
-
const updateUserMessage = async (filePath, title) => {
|
|
37
|
-
const cache = await readCache();
|
|
38
|
-
const previous = cache[filePath];
|
|
39
|
-
const raw = await readFile(filePath, 'utf-8');
|
|
40
|
-
const lines = raw.split('\n');
|
|
41
|
-
for (let index = 0; index < lines.length; index++) {
|
|
42
|
-
const current = lines[index];
|
|
43
|
-
if (!current?.trim())
|
|
44
|
-
continue;
|
|
45
|
-
let parsed;
|
|
46
|
-
try {
|
|
47
|
-
parsed = JSON.parse(current);
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (!isUserLine(parsed))
|
|
53
|
-
continue;
|
|
54
|
-
const content = parsed.message.content;
|
|
55
|
-
for (const block of content) {
|
|
56
|
-
delete block._renamed;
|
|
57
|
-
}
|
|
58
|
-
let replaced = false;
|
|
59
|
-
if (previous) {
|
|
60
|
-
const existing = content.findIndex((block) => block.type === 'text' && block.text === previous);
|
|
61
|
-
const match = content[existing];
|
|
62
|
-
if (existing !== -1 && match) {
|
|
63
|
-
match.text = title;
|
|
64
|
-
replaced = true;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
if (!replaced) {
|
|
68
|
-
const target = content.findIndex(isTextBlock);
|
|
69
|
-
if (target === -1)
|
|
70
|
-
break;
|
|
71
|
-
content.splice(target, 0, { type: 'text', text: title });
|
|
72
|
-
}
|
|
73
|
-
lines[index] = JSON.stringify(parsed);
|
|
74
|
-
await writeFile(filePath, lines.join('\n'));
|
|
75
|
-
cache[filePath] = title;
|
|
76
|
-
await writeCache(cache);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
};
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
|
80
2
|
export const rename = async (session, title) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
await
|
|
3
|
+
const entry = JSON.stringify({
|
|
4
|
+
type: 'custom-title',
|
|
5
|
+
sessionId: session.id,
|
|
6
|
+
customTitle: title,
|
|
7
|
+
});
|
|
8
|
+
await appendFile(session.file, `\n${entry}`);
|
|
87
9
|
};
|
package/lib/bin/index.js
CHANGED
|
@@ -15,7 +15,9 @@ import { analyze } from '../sessions/analyze-prune.js';
|
|
|
15
15
|
import { countSubagents } from '../sessions/count-subagents.js';
|
|
16
16
|
import { extractMessages } from '../sessions/extract-messages.js';
|
|
17
17
|
import { listSessions } from '../sessions/list-sessions.js';
|
|
18
|
+
import { searchSessions } from '../sessions/search-sessions.js';
|
|
18
19
|
import { projectStats, stats } from '../sessions/stats.js';
|
|
20
|
+
import { promptSearch, selectSearchResult, showNoResults, } from '../ui/search.js';
|
|
19
21
|
import { promptConfirm, promptTitle, select, selectAction, selectMain, selectMultiple, selectPruneOptions, selectTrimLine, showProjectStats, } from '../ui/select.js';
|
|
20
22
|
const usage = [
|
|
21
23
|
'Usage: sessioner [options]',
|
|
@@ -50,6 +52,147 @@ if (process.stdin.isTTY) {
|
|
|
50
52
|
}
|
|
51
53
|
const project = values.project ?? process.cwd();
|
|
52
54
|
const base = values.base ?? join(homedir(), '.claude', 'projects');
|
|
55
|
+
const handleSession = async (initial, allSessions, project, base) => {
|
|
56
|
+
let session = initial;
|
|
57
|
+
let previewLines = await preview(session);
|
|
58
|
+
let subagentCount = await countSubagents(session);
|
|
59
|
+
action: while (true) {
|
|
60
|
+
const action = await selectAction(session, project, base, previewLines, subagentCount);
|
|
61
|
+
if (!action)
|
|
62
|
+
return 'back';
|
|
63
|
+
switch (action) {
|
|
64
|
+
case 'back':
|
|
65
|
+
return 'back';
|
|
66
|
+
case 'rename': {
|
|
67
|
+
const title = await promptTitle({
|
|
68
|
+
Project: project,
|
|
69
|
+
Base: base,
|
|
70
|
+
ID: session.id,
|
|
71
|
+
Date: session.date,
|
|
72
|
+
Title: session.message,
|
|
73
|
+
});
|
|
74
|
+
if (!title)
|
|
75
|
+
continue action;
|
|
76
|
+
await rename(session, title);
|
|
77
|
+
session.message = title;
|
|
78
|
+
continue action;
|
|
79
|
+
}
|
|
80
|
+
case 'open':
|
|
81
|
+
open(session);
|
|
82
|
+
continue action;
|
|
83
|
+
case 'stats': {
|
|
84
|
+
const sessionStats = await stats(session);
|
|
85
|
+
previewLines = formatStats(sessionStats);
|
|
86
|
+
continue action;
|
|
87
|
+
}
|
|
88
|
+
case 'fork': {
|
|
89
|
+
const defaultTitle = `Fork: ${session.message}`;
|
|
90
|
+
const forked = await fork(session, defaultTitle);
|
|
91
|
+
await rename(forked, defaultTitle);
|
|
92
|
+
const title = await promptTitle({
|
|
93
|
+
Project: project,
|
|
94
|
+
Base: base,
|
|
95
|
+
ID: forked.id,
|
|
96
|
+
Date: forked.date,
|
|
97
|
+
Title: forked.message,
|
|
98
|
+
});
|
|
99
|
+
if (title) {
|
|
100
|
+
await rename(forked, title);
|
|
101
|
+
forked.message = title;
|
|
102
|
+
}
|
|
103
|
+
session = forked;
|
|
104
|
+
previewLines = await preview(session);
|
|
105
|
+
subagentCount = await countSubagents(session);
|
|
106
|
+
continue action;
|
|
107
|
+
}
|
|
108
|
+
case 'merge': {
|
|
109
|
+
const current = session;
|
|
110
|
+
const others = await selectMultiple(allSessions.filter((session) => session.id !== current.id), project, base);
|
|
111
|
+
if (!others || others.length === 0)
|
|
112
|
+
continue action;
|
|
113
|
+
const all = [current, ...others];
|
|
114
|
+
const titles = all.map((session) => session.message);
|
|
115
|
+
const defaultTitle = `Merge: ${titles.join(', ')}`;
|
|
116
|
+
const merged = await merge(all, defaultTitle);
|
|
117
|
+
await rename(merged, defaultTitle);
|
|
118
|
+
const title = await promptTitle({
|
|
119
|
+
Project: project,
|
|
120
|
+
Base: base,
|
|
121
|
+
ID: merged.id,
|
|
122
|
+
Date: merged.date,
|
|
123
|
+
Title: merged.message,
|
|
124
|
+
});
|
|
125
|
+
if (title) {
|
|
126
|
+
await rename(merged, title);
|
|
127
|
+
merged.message = title;
|
|
128
|
+
}
|
|
129
|
+
session = merged;
|
|
130
|
+
previewLines = await preview(session);
|
|
131
|
+
subagentCount = await countSubagents(session);
|
|
132
|
+
continue action;
|
|
133
|
+
}
|
|
134
|
+
case 'prune': {
|
|
135
|
+
const pruneStats = await analyze(session);
|
|
136
|
+
const selected = await selectPruneOptions(pruneStats, {
|
|
137
|
+
Project: project,
|
|
138
|
+
Base: base,
|
|
139
|
+
ID: session.id,
|
|
140
|
+
Date: session.date,
|
|
141
|
+
Title: session.message,
|
|
142
|
+
});
|
|
143
|
+
if (selected === 'exit')
|
|
144
|
+
return 'exit';
|
|
145
|
+
if (!selected)
|
|
146
|
+
continue action;
|
|
147
|
+
await prune(session, {
|
|
148
|
+
toolBlocks: selected.has('toolBlocks'),
|
|
149
|
+
shortMessages: selected.has('shortMessages'),
|
|
150
|
+
emptyMessages: selected.has('emptyMessages'),
|
|
151
|
+
systemTags: selected.has('systemTags'),
|
|
152
|
+
customTitles: selected.has('customTitles'),
|
|
153
|
+
});
|
|
154
|
+
previewLines = await preview(session);
|
|
155
|
+
subagentCount = await countSubagents(session);
|
|
156
|
+
continue action;
|
|
157
|
+
}
|
|
158
|
+
case 'trim': {
|
|
159
|
+
const { total, lines } = await extractMessages(session);
|
|
160
|
+
if (lines.length <= 1)
|
|
161
|
+
continue action;
|
|
162
|
+
const selected = await selectTrimLine(lines, {
|
|
163
|
+
Project: project,
|
|
164
|
+
Base: base,
|
|
165
|
+
ID: session.id,
|
|
166
|
+
Date: session.date,
|
|
167
|
+
Title: session.message,
|
|
168
|
+
});
|
|
169
|
+
if (selected === null)
|
|
170
|
+
continue action;
|
|
171
|
+
const keepCount = selected + 1;
|
|
172
|
+
const removeCount = total - keepCount;
|
|
173
|
+
if (removeCount <= 0)
|
|
174
|
+
continue action;
|
|
175
|
+
const confirmed = await promptConfirm(`Remove last ${removeCount} of ${total} messages?`);
|
|
176
|
+
if (!confirmed)
|
|
177
|
+
continue action;
|
|
178
|
+
await trim(session, keepCount);
|
|
179
|
+
previewLines = await preview(session);
|
|
180
|
+
subagentCount = await countSubagents(session);
|
|
181
|
+
continue action;
|
|
182
|
+
}
|
|
183
|
+
case 'delete': {
|
|
184
|
+
const items = await targets(session);
|
|
185
|
+
const confirmed = await promptConfirm(`Delete ${items.join(' + ')}?`);
|
|
186
|
+
if (!confirmed)
|
|
187
|
+
continue action;
|
|
188
|
+
await remove(session);
|
|
189
|
+
return 'back';
|
|
190
|
+
}
|
|
191
|
+
case 'exit':
|
|
192
|
+
return 'exit';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
53
196
|
menu: while (true) {
|
|
54
197
|
const { sessions, empty } = await listSessions(project, base);
|
|
55
198
|
const choice = await selectMain(project, base, empty.length);
|
|
@@ -68,6 +211,30 @@ menu: while (true) {
|
|
|
68
211
|
await cleanEmpty(empty);
|
|
69
212
|
continue menu;
|
|
70
213
|
}
|
|
214
|
+
if (choice === 'search') {
|
|
215
|
+
const fields = { Project: project, Base: base };
|
|
216
|
+
const query = await promptSearch(fields);
|
|
217
|
+
if (!query)
|
|
218
|
+
continue menu;
|
|
219
|
+
search: while (true) {
|
|
220
|
+
const { sessions: current } = await listSessions(project, base);
|
|
221
|
+
const results = await searchSessions(current, query);
|
|
222
|
+
if (results.length === 0) {
|
|
223
|
+
await showNoResults(query, fields);
|
|
224
|
+
continue menu;
|
|
225
|
+
}
|
|
226
|
+
const selected = await selectSearchResult(results, query, fields);
|
|
227
|
+
if (!selected)
|
|
228
|
+
continue menu;
|
|
229
|
+
if (selected === 'exit')
|
|
230
|
+
break menu;
|
|
231
|
+
const { sessions: fresh } = await listSessions(project, base);
|
|
232
|
+
const actionResult = await handleSession(selected, fresh, project, base);
|
|
233
|
+
if (actionResult === 'exit')
|
|
234
|
+
break menu;
|
|
235
|
+
continue search;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
71
238
|
list: while (true) {
|
|
72
239
|
const { sessions: fresh } = await listSessions(project, base);
|
|
73
240
|
const result = await select(fresh, project, base);
|
|
@@ -75,143 +242,9 @@ menu: while (true) {
|
|
|
75
242
|
continue menu;
|
|
76
243
|
if (result === 'exit')
|
|
77
244
|
break menu;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const action = await selectAction(session, project, base, previewLines, subagentCount);
|
|
83
|
-
if (!action)
|
|
84
|
-
continue list;
|
|
85
|
-
switch (action) {
|
|
86
|
-
case 'back':
|
|
87
|
-
continue list;
|
|
88
|
-
case 'rename': {
|
|
89
|
-
const title = await promptTitle({
|
|
90
|
-
Project: project,
|
|
91
|
-
Base: base,
|
|
92
|
-
ID: session.id,
|
|
93
|
-
Date: session.date,
|
|
94
|
-
Title: session.message,
|
|
95
|
-
});
|
|
96
|
-
if (!title)
|
|
97
|
-
continue action;
|
|
98
|
-
await rename(session, title);
|
|
99
|
-
session.message = title;
|
|
100
|
-
continue action;
|
|
101
|
-
}
|
|
102
|
-
case 'open':
|
|
103
|
-
open(session);
|
|
104
|
-
continue action;
|
|
105
|
-
case 'stats': {
|
|
106
|
-
const sessionStats = await stats(session);
|
|
107
|
-
previewLines = formatStats(sessionStats);
|
|
108
|
-
continue action;
|
|
109
|
-
}
|
|
110
|
-
case 'fork': {
|
|
111
|
-
const defaultTitle = `Fork: ${session.message}`;
|
|
112
|
-
const forked = await fork(session, defaultTitle);
|
|
113
|
-
await rename(forked, defaultTitle);
|
|
114
|
-
const title = await promptTitle({
|
|
115
|
-
Project: project,
|
|
116
|
-
Base: base,
|
|
117
|
-
ID: forked.id,
|
|
118
|
-
Date: forked.date,
|
|
119
|
-
Title: forked.message,
|
|
120
|
-
});
|
|
121
|
-
if (title) {
|
|
122
|
-
await rename(forked, title);
|
|
123
|
-
forked.message = title;
|
|
124
|
-
}
|
|
125
|
-
session = forked;
|
|
126
|
-
previewLines = await preview(session);
|
|
127
|
-
subagentCount = await countSubagents(session);
|
|
128
|
-
continue action;
|
|
129
|
-
}
|
|
130
|
-
case 'merge': {
|
|
131
|
-
const current = session;
|
|
132
|
-
const others = await selectMultiple(fresh.filter((session) => session.id !== current.id), project, base);
|
|
133
|
-
if (!others || others.length === 0)
|
|
134
|
-
continue action;
|
|
135
|
-
const all = [current, ...others];
|
|
136
|
-
const titles = all.map((session) => session.message);
|
|
137
|
-
const defaultTitle = `Merge: ${titles.join(', ')}`;
|
|
138
|
-
const merged = await merge(all, defaultTitle);
|
|
139
|
-
await rename(merged, defaultTitle);
|
|
140
|
-
const title = await promptTitle({
|
|
141
|
-
Project: project,
|
|
142
|
-
Base: base,
|
|
143
|
-
ID: merged.id,
|
|
144
|
-
Date: merged.date,
|
|
145
|
-
Title: merged.message,
|
|
146
|
-
});
|
|
147
|
-
if (title) {
|
|
148
|
-
await rename(merged, title);
|
|
149
|
-
merged.message = title;
|
|
150
|
-
}
|
|
151
|
-
session = merged;
|
|
152
|
-
previewLines = await preview(session);
|
|
153
|
-
subagentCount = await countSubagents(session);
|
|
154
|
-
continue action;
|
|
155
|
-
}
|
|
156
|
-
case 'prune': {
|
|
157
|
-
const stats = await analyze(session);
|
|
158
|
-
const selected = await selectPruneOptions(stats, {
|
|
159
|
-
Project: project,
|
|
160
|
-
Base: base,
|
|
161
|
-
ID: session.id,
|
|
162
|
-
Date: session.date,
|
|
163
|
-
Title: session.message,
|
|
164
|
-
});
|
|
165
|
-
if (selected === 'exit')
|
|
166
|
-
break menu;
|
|
167
|
-
if (!selected)
|
|
168
|
-
continue action;
|
|
169
|
-
await prune(session, {
|
|
170
|
-
toolBlocks: selected.has('toolBlocks'),
|
|
171
|
-
shortMessages: selected.has('shortMessages'),
|
|
172
|
-
emptyMessages: selected.has('emptyMessages'),
|
|
173
|
-
systemTags: selected.has('systemTags'),
|
|
174
|
-
});
|
|
175
|
-
previewLines = await preview(session);
|
|
176
|
-
subagentCount = await countSubagents(session);
|
|
177
|
-
continue action;
|
|
178
|
-
}
|
|
179
|
-
case 'trim': {
|
|
180
|
-
const { total, lines } = await extractMessages(session);
|
|
181
|
-
if (lines.length <= 1)
|
|
182
|
-
continue action;
|
|
183
|
-
const selected = await selectTrimLine(lines, {
|
|
184
|
-
Project: project,
|
|
185
|
-
Base: base,
|
|
186
|
-
ID: session.id,
|
|
187
|
-
Date: session.date,
|
|
188
|
-
Title: session.message,
|
|
189
|
-
});
|
|
190
|
-
if (selected === null)
|
|
191
|
-
continue action;
|
|
192
|
-
const keepCount = selected + 1;
|
|
193
|
-
const removeCount = total - keepCount;
|
|
194
|
-
if (removeCount <= 0)
|
|
195
|
-
continue action;
|
|
196
|
-
const confirmed = await promptConfirm(`Remove last ${removeCount} of ${total} messages?`);
|
|
197
|
-
if (!confirmed)
|
|
198
|
-
continue action;
|
|
199
|
-
await trim(session, keepCount);
|
|
200
|
-
previewLines = await preview(session);
|
|
201
|
-
subagentCount = await countSubagents(session);
|
|
202
|
-
continue action;
|
|
203
|
-
}
|
|
204
|
-
case 'delete': {
|
|
205
|
-
const items = await targets(session);
|
|
206
|
-
const confirmed = await promptConfirm(`Delete ${items.join(' + ')}?`);
|
|
207
|
-
if (!confirmed)
|
|
208
|
-
continue action;
|
|
209
|
-
await remove(session);
|
|
210
|
-
continue list;
|
|
211
|
-
}
|
|
212
|
-
case 'exit':
|
|
213
|
-
break menu;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
245
|
+
const actionResult = await handleSession(result, fresh, project, base);
|
|
246
|
+
if (actionResult === 'exit')
|
|
247
|
+
break menu;
|
|
248
|
+
continue list;
|
|
216
249
|
}
|
|
217
250
|
}
|
|
@@ -52,11 +52,16 @@ export const analyze = async (session) => {
|
|
|
52
52
|
let shortMessages = 0;
|
|
53
53
|
let emptyMessages = 0;
|
|
54
54
|
let systemTags = 0;
|
|
55
|
+
let titleLines = 0;
|
|
55
56
|
for (const line of lines) {
|
|
56
57
|
if (!line.trim())
|
|
57
58
|
continue;
|
|
58
59
|
try {
|
|
59
60
|
const parsed = JSON.parse(line);
|
|
61
|
+
if (parsed.type === 'custom-title') {
|
|
62
|
+
titleLines++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
60
65
|
if (!hasContent(parsed))
|
|
61
66
|
continue;
|
|
62
67
|
const content = parsed.message.content;
|
|
@@ -73,5 +78,6 @@ export const analyze = async (session) => {
|
|
|
73
78
|
}
|
|
74
79
|
catch { }
|
|
75
80
|
}
|
|
76
|
-
|
|
81
|
+
const customTitles = Math.max(0, titleLines - 1);
|
|
82
|
+
return { toolBlocks, shortMessages, emptyMessages, systemTags, customTitles };
|
|
77
83
|
};
|
|
@@ -2,9 +2,12 @@ import { createReadStream } from 'node:fs';
|
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
3
|
import { parseLine } from './parse-line.js';
|
|
4
4
|
const MIN_TEXT_LENGTH = 5;
|
|
5
|
+
const normalizeText = (value) => value.replace(/\s+/g, ' ').trim();
|
|
5
6
|
export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
|
|
6
7
|
const stream = createReadStream(filePath, 'utf-8');
|
|
7
8
|
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
9
|
+
let customTitle = '';
|
|
10
|
+
let fallback = '';
|
|
8
11
|
const close = (value) => {
|
|
9
12
|
resolve(value);
|
|
10
13
|
reader.close();
|
|
@@ -16,27 +19,33 @@ export const extractFirstUserMessage = (filePath) => new Promise((resolve) => {
|
|
|
16
19
|
const entry = parseLine(line);
|
|
17
20
|
if (!entry)
|
|
18
21
|
return;
|
|
22
|
+
if (entry.type === 'custom-title' && 'customTitle' in entry) {
|
|
23
|
+
customTitle = normalizeText(String(entry.customTitle));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
19
26
|
if (entry.type === 'session_title' && 'title' in entry) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
if (!fallback) {
|
|
28
|
+
fallback = normalizeText(String(entry.title));
|
|
29
|
+
}
|
|
23
30
|
return;
|
|
24
31
|
}
|
|
32
|
+
if (fallback)
|
|
33
|
+
return;
|
|
25
34
|
if (entry.type !== 'user')
|
|
26
35
|
return;
|
|
27
36
|
const texts = entry.message?.content ?? [];
|
|
28
37
|
for (const block of texts) {
|
|
29
38
|
if (block.type !== 'text' || !block.text)
|
|
30
39
|
continue;
|
|
31
|
-
const text = block.text
|
|
40
|
+
const text = normalizeText(block.text);
|
|
32
41
|
if (text.startsWith('<'))
|
|
33
42
|
continue;
|
|
34
43
|
if (text.length <= MIN_TEXT_LENGTH)
|
|
35
44
|
continue;
|
|
36
|
-
|
|
45
|
+
fallback = text;
|
|
37
46
|
return;
|
|
38
47
|
}
|
|
39
48
|
});
|
|
40
|
-
reader.on('close', () => resolve(
|
|
49
|
+
reader.on('close', () => resolve(customTitle || fallback));
|
|
41
50
|
stream.on('error', () => close(''));
|
|
42
51
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { parseLine } from './parse-line.js';
|
|
4
|
+
const MIN_TEXT_LENGTH = 5;
|
|
5
|
+
const searchSession = (session, query) => new Promise((resolve) => {
|
|
6
|
+
const stream = createReadStream(session.file, 'utf-8');
|
|
7
|
+
const reader = createInterface({ input: stream, crlfDelay: Infinity });
|
|
8
|
+
const matches = [];
|
|
9
|
+
const lower = query.toLowerCase();
|
|
10
|
+
const close = () => {
|
|
11
|
+
resolve(matches);
|
|
12
|
+
reader.close();
|
|
13
|
+
stream.destroy();
|
|
14
|
+
};
|
|
15
|
+
reader.on('line', (line) => {
|
|
16
|
+
if (!line.trim())
|
|
17
|
+
return;
|
|
18
|
+
const entry = parseLine(line);
|
|
19
|
+
if (!entry)
|
|
20
|
+
return;
|
|
21
|
+
if (entry.type !== 'user' && entry.type !== 'assistant')
|
|
22
|
+
return;
|
|
23
|
+
const texts = entry.message?.content ?? [];
|
|
24
|
+
for (const block of texts) {
|
|
25
|
+
if (block.type !== 'text' || !block.text)
|
|
26
|
+
continue;
|
|
27
|
+
const text = block.text
|
|
28
|
+
.replace(/\r?\n/g, ' ')
|
|
29
|
+
.trim()
|
|
30
|
+
.replace(/ {2,}/g, ' ');
|
|
31
|
+
if (text.startsWith('<'))
|
|
32
|
+
continue;
|
|
33
|
+
if (text.length <= MIN_TEXT_LENGTH)
|
|
34
|
+
continue;
|
|
35
|
+
if (!text.toLowerCase().includes(lower))
|
|
36
|
+
continue;
|
|
37
|
+
const tag = entry.type === 'user' ? '[ YOU ]' : '[ LLM ]';
|
|
38
|
+
matches.push(`${tag} ${text}`);
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
reader.on('close', () => resolve(matches));
|
|
43
|
+
stream.on('error', () => close());
|
|
44
|
+
});
|
|
45
|
+
export const searchSessions = async (sessions, query) => {
|
|
46
|
+
const results = await Promise.all(sessions.map(async (session) => ({
|
|
47
|
+
session,
|
|
48
|
+
entries: await searchSession(session, query),
|
|
49
|
+
})));
|
|
50
|
+
return results.filter((result) => result.entries.length > 0);
|
|
51
|
+
};
|
package/lib/types.d.ts
CHANGED
|
@@ -51,19 +51,11 @@ export type SessionIndex = {
|
|
|
51
51
|
entries: IndexEntry[];
|
|
52
52
|
[key: string]: unknown;
|
|
53
53
|
};
|
|
54
|
-
export type RenameCache = Record<string, string>;
|
|
55
54
|
export type ContentBlock = {
|
|
56
55
|
type: string;
|
|
57
56
|
text?: string;
|
|
58
57
|
[key: string]: unknown;
|
|
59
58
|
};
|
|
60
|
-
export type UserLine = {
|
|
61
|
-
type: 'user';
|
|
62
|
-
message: {
|
|
63
|
-
content: ContentBlock[];
|
|
64
|
-
};
|
|
65
|
-
[key: string]: unknown;
|
|
66
|
-
};
|
|
67
59
|
export type ModelName = 'opus' | 'sonnet' | 'haiku' | 'unknown';
|
|
68
60
|
export type TokenUsage = {
|
|
69
61
|
input: number;
|
|
@@ -91,10 +83,17 @@ export type PruneStats = {
|
|
|
91
83
|
shortMessages: number;
|
|
92
84
|
emptyMessages: number;
|
|
93
85
|
systemTags: number;
|
|
86
|
+
customTitles: number;
|
|
94
87
|
};
|
|
95
88
|
export type PruneOptions = {
|
|
96
89
|
toolBlocks: boolean;
|
|
97
90
|
shortMessages: boolean;
|
|
98
91
|
emptyMessages: boolean;
|
|
99
92
|
systemTags: boolean;
|
|
93
|
+
customTitles: boolean;
|
|
94
|
+
};
|
|
95
|
+
export type SearchMatch = {
|
|
96
|
+
session: Session;
|
|
97
|
+
entries: string[];
|
|
100
98
|
};
|
|
99
|
+
export type ActionResult = 'back' | 'exit';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SelectOption } from '../types.js';
|
|
2
|
+
export declare const PAGE_SIZE = 10;
|
|
3
|
+
export declare const NEXT = -1;
|
|
4
|
+
export declare const PREVIOUS = -2;
|
|
5
|
+
export declare const EXIT = -3;
|
|
6
|
+
export declare const CONFIRM = -4;
|
|
7
|
+
export declare const CANCEL = -5;
|
|
8
|
+
export declare const HEADER_MARGIN = 13;
|
|
9
|
+
export declare const CLACK_PREFIX = 6;
|
|
10
|
+
export declare const CHECKBOX_WIDTH = 2;
|
|
11
|
+
export declare const EXIT_NUMBER = 0;
|
|
12
|
+
export declare const CANCEL_NUMBER = 0;
|
|
13
|
+
export declare const buildShortcuts: <T>(options: SelectOption<T>[]) => Map<string, number>;
|
|
14
|
+
export declare const clear: () => void;
|
|
15
|
+
export declare const printHeader: (fields: Record<string, string>) => void;
|
|
16
|
+
export declare const showHeader: (fields?: Record<string, string>) => void;
|
package/lib/ui/layout.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { dim } from '../helpers/ansi.js';
|
|
2
|
+
import { getColumns } from '../helpers/get-columns.js';
|
|
3
|
+
import { truncate } from '../helpers/truncate.js';
|
|
4
|
+
export const PAGE_SIZE = 10;
|
|
5
|
+
export const NEXT = -1;
|
|
6
|
+
export const PREVIOUS = -2;
|
|
7
|
+
export const EXIT = -3;
|
|
8
|
+
export const CONFIRM = -4;
|
|
9
|
+
export const CANCEL = -5;
|
|
10
|
+
export const HEADER_MARGIN = 13; // terminal right-side safety margin
|
|
11
|
+
export const CLACK_PREFIX = 6; // @clack/prompts left gutter width
|
|
12
|
+
export const CHECKBOX_WIDTH = 2; // "☑ " / "☐ " prefix
|
|
13
|
+
export const EXIT_NUMBER = 0;
|
|
14
|
+
export const CANCEL_NUMBER = 0;
|
|
15
|
+
export const buildShortcuts = (options) => {
|
|
16
|
+
const shortcuts = new Map();
|
|
17
|
+
for (let index = 0; index < options.length; index++) {
|
|
18
|
+
const option = options[index];
|
|
19
|
+
if (!option || option.number === undefined)
|
|
20
|
+
continue;
|
|
21
|
+
shortcuts.set(String(option.number), index);
|
|
22
|
+
}
|
|
23
|
+
return shortcuts;
|
|
24
|
+
};
|
|
25
|
+
export const clear = () => {
|
|
26
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
27
|
+
};
|
|
28
|
+
export const printHeader = (fields) => {
|
|
29
|
+
clear();
|
|
30
|
+
const columns = getColumns();
|
|
31
|
+
const longest = Math.max(...Object.keys(fields).map((key) => key.length));
|
|
32
|
+
console.log();
|
|
33
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
34
|
+
const padding = ' '.repeat(longest - key.length + 2);
|
|
35
|
+
const prefixLength = 2 + key.length + 1 + padding.length;
|
|
36
|
+
const available = columns - prefixLength - HEADER_MARGIN;
|
|
37
|
+
const truncated = available > 0 ? truncate(value, available) : value;
|
|
38
|
+
console.log(` ${dim(`${key}:`)}${padding}${truncated}`);
|
|
39
|
+
}
|
|
40
|
+
console.log();
|
|
41
|
+
};
|
|
42
|
+
export const showHeader = (fields) => {
|
|
43
|
+
if (!fields)
|
|
44
|
+
return clear();
|
|
45
|
+
printHeader(fields);
|
|
46
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SearchMatch, Session } from '../types.js';
|
|
2
|
+
export declare const promptSearch: (fields?: Record<string, string>) => Promise<string | null>;
|
|
3
|
+
export declare const showNoResults: (query: string, fields?: Record<string, string>) => Promise<void>;
|
|
4
|
+
export declare const selectSearchResult: (results: SearchMatch[], query: string, fields?: Record<string, string>) => Promise<Session | "exit" | null>;
|
package/lib/ui/search.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { isCancel, text } from '@clack/prompts';
|
|
2
|
+
import { bold, colorize } from '../helpers/ansi.js';
|
|
3
|
+
import { getColumns } from '../helpers/get-columns.js';
|
|
4
|
+
import { truncate } from '../helpers/truncate.js';
|
|
5
|
+
import { select as customSelect } from './custom-select.js';
|
|
6
|
+
import { buildShortcuts, CANCEL, CLACK_PREFIX, clear, EXIT, EXIT_NUMBER, HEADER_MARGIN, NEXT, PAGE_SIZE, PREVIOUS, showHeader, } from './layout.js';
|
|
7
|
+
const MATCH_COLOR = '38;5;208';
|
|
8
|
+
const SESSION_ICON = '◌';
|
|
9
|
+
const TITLE_MAX_LENGTH = 100;
|
|
10
|
+
const INDENT = ' ';
|
|
11
|
+
const INDENT_WIDTH = 2;
|
|
12
|
+
export const promptSearch = async (fields) => {
|
|
13
|
+
showHeader(fields);
|
|
14
|
+
const result = await text({
|
|
15
|
+
message: 'Search query (Esc to cancel)',
|
|
16
|
+
placeholder: 'Type to search across all sessions...',
|
|
17
|
+
});
|
|
18
|
+
if (isCancel(result))
|
|
19
|
+
return null;
|
|
20
|
+
const trimmed = result.trim();
|
|
21
|
+
if (!trimmed)
|
|
22
|
+
return null;
|
|
23
|
+
return trimmed;
|
|
24
|
+
};
|
|
25
|
+
export const showNoResults = async (query, fields) => {
|
|
26
|
+
showHeader(fields);
|
|
27
|
+
await customSelect({
|
|
28
|
+
message: `No results for "${query}"`,
|
|
29
|
+
options: [
|
|
30
|
+
{ label: '', value: 'back', disabled: true, separator: true },
|
|
31
|
+
{ label: 'Back', value: 'back', icon: '←' },
|
|
32
|
+
],
|
|
33
|
+
numbered: true,
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
const highlightMatch = (value, query) => {
|
|
38
|
+
const pattern = new RegExp(escapeRegExp(query), 'gi');
|
|
39
|
+
return value.replace(pattern, (match) => colorize(match, MATCH_COLOR));
|
|
40
|
+
};
|
|
41
|
+
const flattenMatches = (results) => {
|
|
42
|
+
const entries = [];
|
|
43
|
+
for (const result of results) {
|
|
44
|
+
for (const line of result.entries) {
|
|
45
|
+
entries.push({ session: result.session, text: line });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return entries;
|
|
49
|
+
};
|
|
50
|
+
export const selectSearchResult = async (results, query, fields) => {
|
|
51
|
+
const entries = flattenMatches(results);
|
|
52
|
+
let page = 0;
|
|
53
|
+
const total = Math.ceil(entries.length / PAGE_SIZE);
|
|
54
|
+
const matchCounts = new Map();
|
|
55
|
+
for (const result of results) {
|
|
56
|
+
matchCounts.set(result.session.id, result.entries.length);
|
|
57
|
+
}
|
|
58
|
+
pages: while (true) {
|
|
59
|
+
showHeader(fields);
|
|
60
|
+
const start = page * PAGE_SIZE;
|
|
61
|
+
const slice = entries.slice(start, start + PAGE_SIZE);
|
|
62
|
+
const columns = getColumns();
|
|
63
|
+
const labelMax = columns - CLACK_PREFIX - HEADER_MARGIN;
|
|
64
|
+
const options = [];
|
|
65
|
+
let lastSessionId = '';
|
|
66
|
+
for (let index = 0; index < slice.length; index++) {
|
|
67
|
+
const entry = slice[index];
|
|
68
|
+
if (!entry)
|
|
69
|
+
continue;
|
|
70
|
+
if (entry.session.id !== lastSessionId) {
|
|
71
|
+
if (options.length > 0) {
|
|
72
|
+
options.push({
|
|
73
|
+
label: '',
|
|
74
|
+
value: EXIT,
|
|
75
|
+
disabled: true,
|
|
76
|
+
separator: true,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
lastSessionId = entry.session.id;
|
|
80
|
+
const count = matchCounts.get(entry.session.id) ?? 0;
|
|
81
|
+
const suffix = count === 1 ? 'match' : 'matches';
|
|
82
|
+
options.push({
|
|
83
|
+
label: bold(truncate(entry.session.message, TITLE_MAX_LENGTH)),
|
|
84
|
+
value: EXIT,
|
|
85
|
+
disabled: true,
|
|
86
|
+
icon: colorize(SESSION_ICON, MATCH_COLOR),
|
|
87
|
+
hint: `${count} ${suffix} · ${entry.session.date}`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const truncated = truncate(entry.text, labelMax - INDENT_WIDTH);
|
|
91
|
+
const highlighted = highlightMatch(truncated, query);
|
|
92
|
+
options.push({
|
|
93
|
+
label: `${INDENT}${highlighted}`,
|
|
94
|
+
value: start + index,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
options.push({ label: '', value: EXIT, disabled: true, separator: true });
|
|
98
|
+
let actionNumber = 1;
|
|
99
|
+
if (page > 0) {
|
|
100
|
+
options.push({
|
|
101
|
+
label: 'Previous',
|
|
102
|
+
value: PREVIOUS,
|
|
103
|
+
icon: '←',
|
|
104
|
+
number: actionNumber,
|
|
105
|
+
});
|
|
106
|
+
actionNumber++;
|
|
107
|
+
}
|
|
108
|
+
if (page < total - 1) {
|
|
109
|
+
options.push({
|
|
110
|
+
label: 'Next',
|
|
111
|
+
value: NEXT,
|
|
112
|
+
icon: '→',
|
|
113
|
+
number: actionNumber,
|
|
114
|
+
});
|
|
115
|
+
actionNumber++;
|
|
116
|
+
}
|
|
117
|
+
options.push({ label: '', value: EXIT, disabled: true, separator: true });
|
|
118
|
+
options.push({
|
|
119
|
+
label: 'Back to menu',
|
|
120
|
+
value: EXIT,
|
|
121
|
+
icon: '☰',
|
|
122
|
+
number: actionNumber,
|
|
123
|
+
});
|
|
124
|
+
options.push({
|
|
125
|
+
label: 'Exit',
|
|
126
|
+
value: CANCEL,
|
|
127
|
+
icon: '✕',
|
|
128
|
+
iconColor: '38;5;204',
|
|
129
|
+
number: EXIT_NUMBER,
|
|
130
|
+
});
|
|
131
|
+
const result = await customSelect({
|
|
132
|
+
message: `Search results (${page + 1}/${total})`,
|
|
133
|
+
options,
|
|
134
|
+
shortcuts: buildShortcuts(options),
|
|
135
|
+
});
|
|
136
|
+
if (isCancel(result) || result === undefined)
|
|
137
|
+
return null;
|
|
138
|
+
if (result === EXIT)
|
|
139
|
+
return null;
|
|
140
|
+
if (result === CANCEL)
|
|
141
|
+
return 'exit';
|
|
142
|
+
if (result === NEXT) {
|
|
143
|
+
page++;
|
|
144
|
+
clear();
|
|
145
|
+
continue pages;
|
|
146
|
+
}
|
|
147
|
+
if (result === PREVIOUS) {
|
|
148
|
+
page--;
|
|
149
|
+
clear();
|
|
150
|
+
continue pages;
|
|
151
|
+
}
|
|
152
|
+
const selected = entries[result];
|
|
153
|
+
if (!selected)
|
|
154
|
+
continue pages;
|
|
155
|
+
return selected.session;
|
|
156
|
+
}
|
|
157
|
+
};
|
package/lib/ui/select.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Action, PruneStats, Session } from '../types.js';
|
|
2
|
-
export declare const selectMain: (project: string, base: string, emptyCount?: number) => Promise<"sessions" | "stats" | "clean" | null>;
|
|
2
|
+
export declare const selectMain: (project: string, base: string, emptyCount?: number) => Promise<"sessions" | "search" | "stats" | "clean" | null>;
|
|
3
3
|
export declare const select: (sessions: Session[], project: string, base: string) => Promise<Session | "exit" | null>;
|
|
4
4
|
export declare const selectAction: (session: Session, project: string, base: string, previewLines: string[], subagentCount: number) => Promise<Action | null>;
|
|
5
5
|
export declare const selectMultiple: (sessions: Session[], project: string, base: string) => Promise<Session[] | null>;
|
package/lib/ui/select.js
CHANGED
|
@@ -1,55 +1,13 @@
|
|
|
1
1
|
import { isCancel, text } from '@clack/prompts';
|
|
2
|
-
import { dim } from '../helpers/ansi.js';
|
|
3
2
|
import { getColumns } from '../helpers/get-columns.js';
|
|
4
3
|
import { truncate } from '../helpers/truncate.js';
|
|
5
4
|
import { select as customSelect } from './custom-select.js';
|
|
6
|
-
|
|
7
|
-
const NEXT = -1;
|
|
8
|
-
const PREVIOUS = -2;
|
|
9
|
-
const EXIT = -3;
|
|
10
|
-
const CONFIRM = -4;
|
|
11
|
-
const CANCEL = -5;
|
|
12
|
-
const HEADER_MARGIN = 13; // terminal right-side safety margin
|
|
13
|
-
const CLACK_PREFIX = 6; // @clack/prompts left gutter width
|
|
14
|
-
const CHECKBOX_WIDTH = 2; // "☑ " / "☐ " prefix
|
|
15
|
-
const EXIT_NUMBER = 0;
|
|
16
|
-
const CANCEL_NUMBER = 0;
|
|
17
|
-
const buildShortcuts = (options) => {
|
|
18
|
-
const shortcuts = new Map();
|
|
19
|
-
for (let index = 0; index < options.length; index++) {
|
|
20
|
-
const option = options[index];
|
|
21
|
-
if (!option || option.number === undefined)
|
|
22
|
-
continue;
|
|
23
|
-
shortcuts.set(String(option.number), index);
|
|
24
|
-
}
|
|
25
|
-
return shortcuts;
|
|
26
|
-
};
|
|
27
|
-
const clear = () => {
|
|
28
|
-
process.stdout.write('\x1b[2J\x1b[H');
|
|
29
|
-
};
|
|
30
|
-
const printHeader = (fields) => {
|
|
31
|
-
clear();
|
|
32
|
-
const columns = getColumns();
|
|
33
|
-
const longest = Math.max(...Object.keys(fields).map((key) => key.length));
|
|
34
|
-
console.log();
|
|
35
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
36
|
-
const padding = ' '.repeat(longest - key.length + 2);
|
|
37
|
-
const prefixLength = 2 + key.length + 1 + padding.length;
|
|
38
|
-
const available = columns - prefixLength - HEADER_MARGIN;
|
|
39
|
-
const truncated = available > 0 ? truncate(value, available) : value;
|
|
40
|
-
console.log(` ${dim(`${key}:`)}${padding}${truncated}`);
|
|
41
|
-
}
|
|
42
|
-
console.log();
|
|
43
|
-
};
|
|
44
|
-
const showHeader = (fields) => {
|
|
45
|
-
if (!fields)
|
|
46
|
-
return clear();
|
|
47
|
-
printHeader(fields);
|
|
48
|
-
};
|
|
5
|
+
import { buildShortcuts, CANCEL, CANCEL_NUMBER, CHECKBOX_WIDTH, CLACK_PREFIX, clear, CONFIRM, EXIT, EXIT_NUMBER, HEADER_MARGIN, NEXT, PAGE_SIZE, PREVIOUS, printHeader, showHeader, } from './layout.js';
|
|
49
6
|
export const selectMain = async (project, base, emptyCount = 0) => {
|
|
50
7
|
printHeader({ Project: project, Base: base });
|
|
51
8
|
const options = [
|
|
52
9
|
{ label: 'Sessions', value: 'sessions' },
|
|
10
|
+
{ label: 'Search', value: 'search' },
|
|
53
11
|
{ label: 'Stats', value: 'stats' },
|
|
54
12
|
];
|
|
55
13
|
if (emptyCount > 0) {
|
|
@@ -299,21 +257,31 @@ export const selectPruneOptions = async (stats, fields) => {
|
|
|
299
257
|
entries.push({
|
|
300
258
|
key: 'toolBlocks',
|
|
301
259
|
label: `${stats.toolBlocks} tool blocks`,
|
|
260
|
+
defaultSelected: true,
|
|
302
261
|
});
|
|
303
262
|
if (stats.emptyMessages > 0)
|
|
304
263
|
entries.push({
|
|
305
264
|
key: 'emptyMessages',
|
|
306
265
|
label: `${stats.emptyMessages} empty messages`,
|
|
307
|
-
|
|
308
|
-
if (stats.shortMessages > 0)
|
|
309
|
-
entries.push({
|
|
310
|
-
key: 'shortMessages',
|
|
311
|
-
label: `${stats.shortMessages} short messages (<50 chars)`,
|
|
266
|
+
defaultSelected: true,
|
|
312
267
|
});
|
|
313
268
|
if (stats.systemTags > 0)
|
|
314
269
|
entries.push({
|
|
315
270
|
key: 'systemTags',
|
|
316
271
|
label: `${stats.systemTags} system/IDE tags`,
|
|
272
|
+
defaultSelected: true,
|
|
273
|
+
});
|
|
274
|
+
if (stats.customTitles > 0)
|
|
275
|
+
entries.push({
|
|
276
|
+
key: 'customTitles',
|
|
277
|
+
label: `${stats.customTitles} old custom titles`,
|
|
278
|
+
defaultSelected: true,
|
|
279
|
+
});
|
|
280
|
+
if (stats.shortMessages > 0)
|
|
281
|
+
entries.push({
|
|
282
|
+
key: 'shortMessages',
|
|
283
|
+
label: `${stats.shortMessages} short messages (<50 chars)`,
|
|
284
|
+
defaultSelected: false,
|
|
317
285
|
});
|
|
318
286
|
if (entries.length === 0) {
|
|
319
287
|
showHeader(fields);
|
|
@@ -332,8 +300,10 @@ export const selectPruneOptions = async (stats, fields) => {
|
|
|
332
300
|
}
|
|
333
301
|
const selected = new Set();
|
|
334
302
|
let cursor;
|
|
335
|
-
for (let index = 0; index < entries.length; index++)
|
|
336
|
-
|
|
303
|
+
for (let index = 0; index < entries.length; index++) {
|
|
304
|
+
if (entries[index]?.defaultSelected)
|
|
305
|
+
selected.add(index);
|
|
306
|
+
}
|
|
337
307
|
toggle: while (true) {
|
|
338
308
|
showHeader(fields);
|
|
339
309
|
const options = entries.map((entry, index) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sessioner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "✨ An interactive CLI to navigate and manage Claude Code sessions from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"start": "tsx src/bin/index.ts",
|
|
28
28
|
"lint": "prettier --check .",
|
|
29
29
|
"lint:fix": "prettier --write .",
|
|
30
|
-
"typecheck": "tsc --noEmit"
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"patch": "npm version patch --no-git-tag-version",
|
|
32
|
+
"prepublish": "npm run build"
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@clack/core": "^1.0.1",
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname, join } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
const cacheDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.cache');
|
|
5
|
-
const cacheFile = join(cacheDir, 'renames.json');
|
|
6
|
-
export const readCache = async () => {
|
|
7
|
-
try {
|
|
8
|
-
const raw = await readFile(cacheFile, 'utf-8');
|
|
9
|
-
return JSON.parse(raw);
|
|
10
|
-
}
|
|
11
|
-
catch {
|
|
12
|
-
return {};
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
export const writeCache = async (cache) => {
|
|
16
|
-
await mkdir(cacheDir, { recursive: true });
|
|
17
|
-
await writeFile(cacheFile, JSON.stringify(cache, null, 2));
|
|
18
|
-
};
|