sessioner 0.1.1 โ 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 +14 -1
- package/lib/bin/index.js +171 -139
- package/lib/sessions/search-sessions.d.ts +2 -0
- package/lib/sessions/search-sessions.js +51 -0
- package/lib/types.d.ts +5 -0
- 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 +2 -44
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ sessioner
|
|
|
56
56
|
## ๐ How It Works
|
|
57
57
|
|
|
58
58
|
1. Scans `{base}/{project}` for `.jsonl` session files
|
|
59
|
-
2. Shows a **main menu** (Sessions, Stats, Clean)
|
|
59
|
+
2. Shows a **main menu** (Sessions, Search, Stats, Clean)
|
|
60
60
|
3. Shows a **paginated session list** sorted by date (newest first)
|
|
61
61
|
4. Displays a **conversation preview** for the selected session (up to 10 messages)
|
|
62
62
|
5. Opens an **action menu** to operate on it
|
|
@@ -87,6 +87,19 @@ sessioner
|
|
|
87
87
|
|
|
88
88
|
---
|
|
89
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
|
+
|
|
90
103
|
### Fork
|
|
91
104
|
|
|
92
105
|
- Creates an independent copy of the entire session
|
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,144 +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
|
-
customTitles: selected.has('customTitles'),
|
|
175
|
-
});
|
|
176
|
-
previewLines = await preview(session);
|
|
177
|
-
subagentCount = await countSubagents(session);
|
|
178
|
-
continue action;
|
|
179
|
-
}
|
|
180
|
-
case 'trim': {
|
|
181
|
-
const { total, lines } = await extractMessages(session);
|
|
182
|
-
if (lines.length <= 1)
|
|
183
|
-
continue action;
|
|
184
|
-
const selected = await selectTrimLine(lines, {
|
|
185
|
-
Project: project,
|
|
186
|
-
Base: base,
|
|
187
|
-
ID: session.id,
|
|
188
|
-
Date: session.date,
|
|
189
|
-
Title: session.message,
|
|
190
|
-
});
|
|
191
|
-
if (selected === null)
|
|
192
|
-
continue action;
|
|
193
|
-
const keepCount = selected + 1;
|
|
194
|
-
const removeCount = total - keepCount;
|
|
195
|
-
if (removeCount <= 0)
|
|
196
|
-
continue action;
|
|
197
|
-
const confirmed = await promptConfirm(`Remove last ${removeCount} of ${total} messages?`);
|
|
198
|
-
if (!confirmed)
|
|
199
|
-
continue action;
|
|
200
|
-
await trim(session, keepCount);
|
|
201
|
-
previewLines = await preview(session);
|
|
202
|
-
subagentCount = await countSubagents(session);
|
|
203
|
-
continue action;
|
|
204
|
-
}
|
|
205
|
-
case 'delete': {
|
|
206
|
-
const items = await targets(session);
|
|
207
|
-
const confirmed = await promptConfirm(`Delete ${items.join(' + ')}?`);
|
|
208
|
-
if (!confirmed)
|
|
209
|
-
continue action;
|
|
210
|
-
await remove(session);
|
|
211
|
-
continue list;
|
|
212
|
-
}
|
|
213
|
-
case 'exit':
|
|
214
|
-
break menu;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
245
|
+
const actionResult = await handleSession(result, fresh, project, base);
|
|
246
|
+
if (actionResult === 'exit')
|
|
247
|
+
break menu;
|
|
248
|
+
continue list;
|
|
217
249
|
}
|
|
218
250
|
}
|
|
@@ -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
|
@@ -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) {
|