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 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
- - Checkbox selection for what to remove (all pre-selected by default)
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
- - Updates three locations:
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 and rename cache
159
+ - Removes the entry from the sessions index
152
160
  - Confirmation prompt before applying
153
161
 
154
162
  ---
@@ -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
  };
@@ -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);
@@ -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) {
@@ -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
- for (const line of lines) {
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))
@@ -1,87 +1,9 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
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 raw = await readFile(session.file, 'utf-8');
82
- const content = stripExistingTitle(raw);
83
- const line = JSON.stringify({ type: 'session_title', title });
84
- await writeFile(session.file, `${line}\n${content}`);
85
- await updateIndex(session, title);
86
- await updateUserMessage(session.file, title);
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
- let session = result;
79
- let previewLines = await preview(session);
80
- let subagentCount = await countSubagents(session);
81
- action: while (true) {
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
- return { toolBlocks, shortMessages, emptyMessages, systemTags };
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
- close(String(entry.title)
21
- .replace(/\s+/g, ' ')
22
- .trim());
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.replace(/\s+/g, ' ').trim();
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
- close(text);
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,2 @@
1
+ import type { SearchMatch, Session } from '../types.js';
2
+ export declare const searchSessions: (sessions: Session[], query: string) => Promise<SearchMatch[]>;
@@ -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;
@@ -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>;
@@ -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
+ };
@@ -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
- const PAGE_SIZE = 10;
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
- selected.add(index);
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.0",
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,3 +0,0 @@
1
- import type { RenameCache } from '../types.js';
2
- export declare const readCache: () => Promise<RenameCache>;
3
- export declare const writeCache: (cache: RenameCache) => Promise<void>;
@@ -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
- };