@vinh0809it/claude-sessions 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tui/app.js ADDED
@@ -0,0 +1,525 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { screen, bold, dim, fg, bg, trunc, pad, selectedLine, styleHints, groupTitle } = require('../ansi');
6
+ const { fmtDate, fmtAgo, fmtSize, shortenPath, shortConfig } = require('../format');
7
+ const { expandHome, projectDirNameFor } = require('../paths');
8
+ const { discoverConfigDirs } = require('../config');
9
+ const { loadSessions, readRecentMessages, SORTS, sortSessions } = require('../sessions');
10
+ const { deleteSessionFiles, moveSession, renameSession, listProjectFolders } = require('../operations');
11
+ const { resumePlan, newPlan, launchInTab, runHere } = require('../launch');
12
+ const { is, splitKeys } = require('./keys');
13
+ const { LineInput } = require('./line-input');
14
+ const { Picker } = require('./picker');
15
+
16
+ const { moveTo, clearLine } = screen;
17
+
18
+ const HELP = [
19
+ ['↑↓ / j k', 'Navigate'], ['PgUp PgDn', 'Page up / down'],
20
+ ['g / G', 'Top / bottom'], ['/', 'Search (↑↓ select, Enter open, Tab keep filter)'],
21
+ ['Esc', 'Clear search / marks'], ['Enter', 'Open (new tab if supported, else here)'],
22
+ ['o', 'Open in this terminal'], ['p', 'Toggle preview'],
23
+ ['r', 'Rename'], ['s', 'Sort: date / size / name'],
24
+ ['Space', 'Mark for bulk delete'], ['n', 'New session'],
25
+ ['m', 'Move to profile / folder'], ['d', 'Delete marked or selected (to trash)'],
26
+ ['q / Ctrl-C', 'Quit'],
27
+ ];
28
+
29
+ const FOOTER_HINTS = ['Enter=open', '/=search', 'p=preview', 'r=rename', 'm=move', 'd=delete', '?=more', 'q=quit'];
30
+
31
+ const STATUS_MS = 2500;
32
+ const ERROR_MS = 5000;
33
+
34
+ /**
35
+ * The full-screen session browser.
36
+ *
37
+ * `items` is the rendered list: profile headers ({ type: 'config' }) interleaved
38
+ * with sessions ({ type: 'session' }). `selIdx` counts sessions only.
39
+ * `mode` is null (browsing) or one of: search, rename, delete, move, new, help.
40
+ */
41
+ class App {
42
+ constructor(sessions, opts = {}) {
43
+ this.all = sessions;
44
+ this.opts = opts;
45
+ this.filterPath = opts.filterPath || '';
46
+ this.mode = null;
47
+ this.search = '';
48
+ this.sort = 'date';
49
+ this.preview = false;
50
+ this.marked = new Set(); // jsonlPath
51
+ this.items = [];
52
+ this.selIdx = 0;
53
+ this.scrollTop = 0;
54
+ this.statusMsg = '';
55
+ this.w = process.stdout.columns || 80;
56
+ this.h = process.stdout.rows || 24;
57
+
58
+ this.searchField = new LineInput();
59
+ this.renameField = new LineInput();
60
+ this._statusTimer = null;
61
+ this._previewCache = new Map();
62
+ this.movePicker = this._createMovePicker();
63
+ this.newPicker = this._createNewPicker();
64
+ this.rebuild();
65
+ }
66
+
67
+ get previewH() { return this.preview ? Math.max(4, Math.floor((this.h - 2) * 0.4)) : 0; }
68
+ get listH() { return this.h - 2 - this.previewH; }
69
+
70
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
71
+
72
+ start() {
73
+ process.stdin.setRawMode(true);
74
+ process.stdin.resume();
75
+ process.stdin.setEncoding('utf8');
76
+
77
+ process.stdout.on('resize', () => {
78
+ this.w = process.stdout.columns;
79
+ this.h = process.stdout.rows;
80
+ this.ensureVisible();
81
+ this.render();
82
+ });
83
+
84
+ this._onData = chunk => {
85
+ for (const key of splitKeys(chunk)) {
86
+ if (this.handleKey(key) === false) { this.cleanup(); process.exit(0); }
87
+ }
88
+ this.render();
89
+ };
90
+ process.stdin.on('data', this._onData);
91
+ process.on('exit', () => this.cleanup());
92
+ process.on('SIGTERM', () => { this.cleanup(); process.exit(0); });
93
+ this.render();
94
+ }
95
+
96
+ /** Hands the terminal to a child process. */
97
+ suspend() {
98
+ process.stdin.removeListener('data', this._onData);
99
+ process.stdin.setRawMode(false);
100
+ process.stdin.pause();
101
+ this.cleanup();
102
+ }
103
+
104
+ /** Takes the terminal back after suspend(). */
105
+ resume() {
106
+ process.stdin.setRawMode(true);
107
+ process.stdin.resume();
108
+ process.stdin.on('data', this._onData);
109
+ this.reloadAll();
110
+ }
111
+
112
+ cleanup() { process.stdout.write(screen.showCursor + screen.mainBuffer); }
113
+
114
+ // ── List state ──────────────────────────────────────────────────────────────
115
+
116
+ rebuild() {
117
+ const q = this.search.toLowerCase();
118
+ const matches = s =>
119
+ (s.title || '').toLowerCase().includes(q) ||
120
+ (s.firstMessage || '').toLowerCase().includes(q) ||
121
+ s.project.toLowerCase().includes(q);
122
+
123
+ this.items = [];
124
+ let group = null;
125
+ for (const s of sortSessions(q ? this.all.filter(matches) : this.all, this.sort)) {
126
+ if (s.configDir !== group?.label) {
127
+ group = { type: 'config', label: s.configDir, count: 0 };
128
+ this.items.push(group);
129
+ }
130
+ group.count++;
131
+ this.items.push({ type: 'session', session: s });
132
+ }
133
+ this.selIdx = Math.min(this.selIdx, Math.max(0, this.sessionCount() - 1));
134
+ this.ensureVisible();
135
+ }
136
+
137
+ sessionItems() { return this.items.filter(i => i.type === 'session'); }
138
+ sessionCount() { return this.sessionItems().length; }
139
+ selectedSession() { return this.sessionItems()[this.selIdx]?.session ?? null; }
140
+
141
+ /** Moves the selection by `delta` sessions, clamped. */
142
+ moveSelection(delta) {
143
+ this.selIdx = Math.max(0, Math.min(this.sessionCount() - 1, this.selIdx + delta));
144
+ this.ensureVisible();
145
+ }
146
+
147
+ selectById(sessionId) {
148
+ if (!sessionId) return;
149
+ const idx = this.sessionItems().findIndex(i => i.session.sessionId === sessionId);
150
+ if (idx >= 0) { this.selIdx = idx; this.ensureVisible(); }
151
+ }
152
+
153
+ /** Scrolls so the selected session's row is on screen. */
154
+ ensureVisible() {
155
+ let seen = -1;
156
+ const row = this.items.findIndex(i => i.type === 'session' && ++seen === this.selIdx);
157
+ if (row === -1) return;
158
+ if (row < this.scrollTop) this.scrollTop = row;
159
+ if (row >= this.scrollTop + this.listH) this.scrollTop = row - this.listH + 1;
160
+ if (this.scrollTop < 0) this.scrollTop = 0;
161
+ }
162
+
163
+ /** Re-reads every session from disk, keeping the selection. */
164
+ reloadAll() {
165
+ const keep = this.selectedSession()?.sessionId;
166
+ this.all = loadSessions(this.opts);
167
+ this._previewCache.clear();
168
+ this.rebuild();
169
+ this.selectById(keep);
170
+ }
171
+
172
+ setStatus(msg, ms = STATUS_MS) {
173
+ this.statusMsg = msg;
174
+ clearTimeout(this._statusTimer);
175
+ this._statusTimer = setTimeout(() => { this.statusMsg = ''; this.render(); }, ms);
176
+ }
177
+
178
+ // ── Actions ─────────────────────────────────────────────────────────────────
179
+
180
+ _open(plan, forceHere = false) {
181
+ const result = forceHere ? { here: true } : launchInTab(plan);
182
+ if (!result.here) {
183
+ this.setStatus(result.ok ? `Opened "${plan.title}" in new tab` : result.msg, result.ok ? STATUS_MS : ERROR_MS);
184
+ return;
185
+ }
186
+ if (!fs.existsSync(plan.cwd)) { this.setStatus(`Folder not found: ${plan.cwd}`, ERROR_MS); return; }
187
+ this.suspend();
188
+ const run = runHere(plan);
189
+ this.resume();
190
+ if (!run.ok) this.setStatus(run.msg, ERROR_MS);
191
+ }
192
+
193
+ _openSelected(forceHere = false) {
194
+ const s = this.selectedSession();
195
+ if (s) this._open(resumePlan(s), forceHere);
196
+ }
197
+
198
+ _doMove(targetFolderName, targetRawPath = null) {
199
+ const s = this.selectedSession();
200
+ this.mode = null;
201
+ if (!s) return;
202
+ const targetConfig = this.movePicker.config;
203
+ try {
204
+ moveSession(s, targetConfig, targetFolderName, targetRawPath);
205
+ this.reloadAll();
206
+ this.selectById(s.sessionId);
207
+ this.setStatus(`Moved to [${path.basename(targetConfig)}]`);
208
+ } catch (e) {
209
+ this.setStatus(`Move failed: ${e.message}`, ERROR_MS);
210
+ }
211
+ }
212
+
213
+ _doNew(projectPath) {
214
+ this.mode = null;
215
+ if (projectPath) this._open(newPlan(this.newPicker.config, projectPath));
216
+ }
217
+
218
+ _doRename() {
219
+ this.mode = null;
220
+ const s = this.selectedSession();
221
+ const name = this.renameField.text;
222
+ if (!s || !name.trim()) return;
223
+ try {
224
+ renameSession(s, name);
225
+ this.reloadAll();
226
+ this.setStatus(`Renamed to "${s.title}"`);
227
+ } catch (e) {
228
+ this.setStatus(`Rename failed: ${e.message}`, ERROR_MS);
229
+ }
230
+ }
231
+
232
+ /** Marked sessions, or the selected one when nothing is marked. */
233
+ _deleteTargets() {
234
+ if (this.marked.size) return this.all.filter(s => this.marked.has(s.jsonlPath));
235
+ const s = this.selectedSession();
236
+ return s ? [s] : [];
237
+ }
238
+
239
+ _doDelete() {
240
+ const targets = this._deleteTargets();
241
+ const failed = [];
242
+ for (const s of targets) {
243
+ try { deleteSessionFiles(s); } catch (e) { failed.push(e.message); }
244
+ }
245
+ const gone = new Set(targets.map(s => s.jsonlPath));
246
+ this.all = this.all.filter(s => !gone.has(s.jsonlPath) || fs.existsSync(s.jsonlPath));
247
+ this.marked.clear();
248
+ this.rebuild();
249
+ this.setStatus(failed.length
250
+ ? `Delete failed for ${failed.length}: ${failed[0]}`
251
+ : `Moved ${targets.length} session(s) to trash — run with --empty-trash to free space`, 4000);
252
+ }
253
+
254
+ _toggleMark() {
255
+ const s = this.selectedSession();
256
+ if (!s) return;
257
+ if (this.marked.has(s.jsonlPath)) this.marked.delete(s.jsonlPath);
258
+ else this.marked.add(s.jsonlPath);
259
+ this.moveSelection(+1);
260
+ }
261
+
262
+ _cycleSort() {
263
+ const keep = this.selectedSession()?.sessionId;
264
+ this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length];
265
+ this.rebuild();
266
+ this.selectById(keep);
267
+ }
268
+
269
+ // ── Pickers ─────────────────────────────────────────────────────────────────
270
+
271
+ _createMovePicker() {
272
+ return new Picker({
273
+ headerLabel: () => {
274
+ const s = this.selectedSession();
275
+ const label = s?.title || s?.firstMessage?.slice(0, 60) || s?.sessionId?.slice(0, 8) || '';
276
+ return 'Moving: ' + (label ? fg('92', `"${trunc(label, this.w - 12)}"`) : dim('(untitled)'));
277
+ },
278
+ profileLabel: 'Select target profile',
279
+ inputPrompt: 'New path:',
280
+ buildProfileItems: () => {
281
+ const s = this.selectedSession();
282
+ return discoverConfigDirs().map(d => ({
283
+ type: 'profile',
284
+ label: path.basename(d) + (d === s?.configDirPath ? ' (current)' : ''),
285
+ configDirPath: d,
286
+ }));
287
+ },
288
+ buildFolderItems: (configDirPath) => {
289
+ const s = this.selectedSession();
290
+ const sameConfig = configDirPath === s?.configDirPath;
291
+ const srcFolder = s ? path.basename(path.dirname(s.jsonlPath)) : null;
292
+ return [
293
+ ...(sameConfig ? [] : [{ type: 'same', label: `${s ? shortenPath(s.project) : ''} (keep same folder)`, dirName: null }]),
294
+ ...listProjectFolders(configDirPath)
295
+ .filter(f => !(sameConfig && f.projectDirName === srcFolder))
296
+ .map(f => ({ type: 'folder', label: f.displayPath, dirName: f.projectDirName })),
297
+ { type: 'new', label: '[type a new path…]', dirName: null },
298
+ ];
299
+ },
300
+ onConfirmItem: (item) => this._doMove(item.dirName),
301
+ onConfirmCustom: (absPath) => {
302
+ try { this._doMove(projectDirNameFor(this.movePicker.config, absPath), absPath); }
303
+ catch (e) { this.mode = null; this.setStatus(`Move failed: ${e.message}`, ERROR_MS); }
304
+ },
305
+ });
306
+ }
307
+
308
+ _createNewPicker() {
309
+ return new Picker({
310
+ headerLabel: fg('92', 'New session'),
311
+ inputPrompt: 'Path:',
312
+ buildProfileItems: () => discoverConfigDirs().map(d => ({ type: 'profile', label: path.basename(d), configDirPath: d })),
313
+ buildFolderItems: (configDirPath) => [
314
+ ...listProjectFolders(configDirPath).map(f => ({
315
+ type: 'folder', label: f.displayPath, projectPath: expandHome(f.displayPath),
316
+ })),
317
+ { type: 'new', label: '[type a new path…]', projectPath: null },
318
+ ],
319
+ onConfirmItem: (item) => { if (item.projectPath) this._doNew(item.projectPath); },
320
+ onConfirmCustom: (absPath) => this._doNew(absPath),
321
+ });
322
+ }
323
+
324
+ // ── Input ───────────────────────────────────────────────────────────────────
325
+
326
+ /** Returns false to quit. */
327
+ handleKey(key) {
328
+ switch (this.mode) {
329
+ case 'help': this.mode = null; return true;
330
+ case 'delete': this.mode = null; if (key === 'y' || key === 'Y') this._doDelete(); return true;
331
+ case 'move': if (this.movePicker.handleKey(key) === false) this.mode = null; return true;
332
+ case 'new': if (this.newPicker.handleKey(key) === false) this.mode = null; return true;
333
+ case 'search': this._handleSearchKey(key); return true;
334
+ case 'rename': this._handleRenameKey(key); return true;
335
+ default: return this._handleBrowseKey(key);
336
+ }
337
+ }
338
+
339
+ _handleSearchKey(key) {
340
+ if (is(key, 'ESCAPE')) { this.mode = null; this.search = ''; this.rebuild(); }
341
+ else if (is(key, 'TAB')) { this.mode = null; }
342
+ else if (is(key, 'ENTER')) { this.mode = null; this._openSelected(); }
343
+ else if (is(key, 'UP')) this.moveSelection(-1);
344
+ else if (is(key, 'DOWN')) this.moveSelection(+1);
345
+ else if (is(key, 'PAGE_UP')) this.moveSelection(-this.listH);
346
+ else if (is(key, 'PAGE_DOWN')) this.moveSelection(+this.listH);
347
+ else if (this.searchField.handle(key) && this.searchField.text !== this.search) {
348
+ this.search = this.searchField.text;
349
+ this.rebuild();
350
+ }
351
+ }
352
+
353
+ _handleRenameKey(key) {
354
+ if (is(key, 'ESCAPE')) this.mode = null;
355
+ else if (is(key, 'ENTER')) this._doRename();
356
+ else this.renameField.handle(key);
357
+ }
358
+
359
+ _handleBrowseKey(key) {
360
+ const s = this.selectedSession();
361
+ if (key === 'q' || is(key, 'CTRL_C')) return false;
362
+
363
+ if (key === '/') { this.searchField.set(this.search); this.mode = 'search'; }
364
+ else if (key === '?') this.mode = 'help';
365
+ else if (is(key, 'ESCAPE')) { this.search = ''; this.marked.clear(); this.rebuild(); }
366
+ else if (is(key, 'ENTER')) this._openSelected();
367
+ else if (key === 'o') this._openSelected(true);
368
+ else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
369
+ else if (key === 's') this._cycleSort();
370
+ else if (key === 'r') { if (s) { this.renameField.set(s.title || ''); this.mode = 'rename'; } }
371
+ else if (key === ' ') this._toggleMark();
372
+ else if (key === 'd') { if (this._deleteTargets().length) this.mode = 'delete'; }
373
+ else if (key === 'n') { this.newPicker.enter(); this.mode = 'new'; }
374
+ else if (key === 'm') { if (s) { this.movePicker.enter(); this.mode = 'move'; } else this.setStatus('No session selected.'); }
375
+ else if (is(key, 'UP') || key === 'k') this.moveSelection(-1);
376
+ else if (is(key, 'DOWN') || key === 'j') this.moveSelection(+1);
377
+ else if (is(key, 'PAGE_UP')) this.moveSelection(-this.listH);
378
+ else if (is(key, 'PAGE_DOWN')) this.moveSelection(+this.listH);
379
+ else if (key === 'g') this.moveSelection(-Infinity);
380
+ else if (key === 'G') this.moveSelection(+Infinity);
381
+ return true;
382
+ }
383
+
384
+ // ── Rendering ───────────────────────────────────────────────────────────────
385
+
386
+ render() {
387
+ const W = this.w, H = this.h;
388
+ const out = [screen.hideCursor, screen.altBuffer, screen.clear, moveTo(1, 1)];
389
+
390
+ const filterInfo = this.filterPath ? ` path:${shortenPath(this.filterPath)}` : '';
391
+ const searchInfo = this.search ? ` search:${this.search}` : '';
392
+ const sortInfo = this.sort !== 'date' ? ` sort:${this.sort}` : '';
393
+ out.push(bg('44', bold(fg('97', pad(trunc(` claude-sessions${filterInfo}${searchInfo}${sortInfo}`, W), W)))));
394
+
395
+ if (this.mode === 'move') this.movePicker.render(out, W, H);
396
+ else if (this.mode === 'new') this.newPicker.render(out, W, H);
397
+ else if (this.mode === 'help') this._renderHelp(out);
398
+ else {
399
+ this._renderList(out, W);
400
+ if (this.preview) this._renderPreview(out, W);
401
+ }
402
+
403
+ out.push(moveTo(H, 1), clearLine, this._renderFooter(W));
404
+ const cursor = this._cursorPosition();
405
+ if (cursor) out.push(moveTo(cursor[0], Math.min(W, cursor[1])), screen.showCursor);
406
+ process.stdout.write(out.join(''));
407
+ }
408
+
409
+ _renderHelp(out) {
410
+ HELP.forEach(([k, d], i) => out.push(moveTo(i + 3, 1), clearLine, ` ${fg('93', pad(k, 14))}${d}`));
411
+ out.push(moveTo(HELP.length + 4, 1),
412
+ dim(' Env: CLAUDE_SESSIONS_TERMINAL, CLAUDE_SESSIONS_OPEN_CMD, CLAUDE_CONFIG_DIR, NO_COLOR'));
413
+ }
414
+
415
+ _renderList(out, W) {
416
+ const visible = this.items.slice(this.scrollTop, this.scrollTop + this.listH);
417
+ let sessionIdx = this.items.slice(0, this.scrollTop).filter(i => i.type === 'session').length;
418
+
419
+ for (let r = 0; r < this.listH; r++) {
420
+ out.push(moveTo(r + 2, 1), clearLine);
421
+ const item = visible[r];
422
+ if (!item) continue;
423
+ if (item.type === 'config') { out.push(this._profileHeader(item, W)); continue; }
424
+ out.push(this._sessionRow(item.session, sessionIdx === this.selIdx, W));
425
+ sessionIdx++;
426
+ }
427
+ }
428
+
429
+ /** "── .claude-work · 4 sessions ─────" */
430
+ _profileHeader(group, W) {
431
+ const count = `· ${group.count} session${group.count === 1 ? '' : 's'}`;
432
+ const rule = '─'.repeat(Math.max(0, W - group.label.length - count.length - 6));
433
+ return dim('── ') + bold(fg('96', group.label)) + ' ' + dim(`${count} ${rule}`);
434
+ }
435
+
436
+ /** "▌● api/fix-login ~/projects/api 3.2M 2h ago" */
437
+ _sessionRow(s, isSelected, W) {
438
+ const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
439
+ const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
440
+ const meta = `${fmtSize(s.size).padStart(5)} ${fmtAgo(s.lastModified).padStart(8)}`;
441
+ const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
442
+ const avail = Math.max(12, W - 3 - cfgTag.length - 4 - meta.length - 1);
443
+ const titleW = Math.floor(avail * 0.55);
444
+ const titleTxt = pad(trunc(title, titleW), titleW);
445
+ const projTxt = pad(trunc(shortenPath(s.project), avail - titleW), avail - titleW);
446
+
447
+ // On the selected line every part gets an explicit color (see selectedLine).
448
+ const muted = t => isSelected ? fg('37', t) : dim(t);
449
+ const styled = fg('91', mark) + ' ' + (cfgTag ? fg('96', cfgTag) : '') +
450
+ (s.title ? groupTitle(titleTxt, isSelected ? '1;92' : '92') : muted(titleTxt)) +
451
+ ` ${fg('33', projTxt)} ${muted(meta)}`;
452
+
453
+ return isSelected
454
+ ? selectedLine(styled, `${mark} ${cfgTag}${titleTxt} ${projTxt} ${meta}`, W)
455
+ : ` ${styled}`;
456
+ }
457
+
458
+ _renderPreview(out, W) {
459
+ const top = this.listH + 2;
460
+ out.push(moveTo(top, 1), clearLine, dim('─'.repeat(W)));
461
+ const s = this.selectedSession();
462
+ const lines = s ? this._previewLines(s, W, this.previewH - 1) : [];
463
+ for (let r = 0; r < this.previewH - 1; r++) out.push(moveTo(top + 1 + r, 1), clearLine, lines[r] || '');
464
+ }
465
+
466
+ /** Date/size/id header, then one line per recent message with a blank line between. */
467
+ _previewLines(s, W, H) {
468
+ const key = `${s.jsonlPath}:${s.lastModified.valueOf()}`;
469
+ if (!this._previewCache.has(key)) this._previewCache.set(key, readRecentMessages(s.jsonlPath));
470
+ const width = Math.max(10, W - 8);
471
+ const lines = this._previewCache.get(key).flatMap(m => ['',
472
+ ` ${m.role === 'user' ? fg('92', 'you ') : fg('96', 'cc ')} ${trunc(m.text.replace(/\s+/g, ' ').trim(), width)}`]);
473
+ const body = lines.slice(-(H - 2));
474
+ if (body[0] === '') body.shift();
475
+ const head = dim(` ${fmtDate(s.lastModified)} · ${fmtSize(s.size)} · ${s.sessionId}`);
476
+ return [head, '', ...(body.length ? body : [dim(' (no messages)')])];
477
+ }
478
+
479
+ _renderFooter(W) {
480
+ if (!this.mode && this.statusMsg) return fg('93', trunc(` ${this.statusMsg}`, W));
481
+ return styleHints(trunc(this._footerText(W), W))
482
+ .replace(/^ (SEARCH|RENAME):/, (_, label) => ` ${bold(fg('93', label + ':'))}`);
483
+ }
484
+
485
+ /** Footer as "key=label" hints; _renderFooter styles them. */
486
+ _footerText(W) {
487
+ switch (this.mode) {
488
+ case 'delete': {
489
+ const targets = this._deleteTargets();
490
+ const s = this.selectedSession();
491
+ const name = targets.length > 1
492
+ ? `${targets.length} sessions`
493
+ : `"${trunc(s?.title || s?.firstMessage || 'this session', 35)}"`;
494
+ return ` Move ${name} to trash? y=yes n/Esc=cancel`;
495
+ }
496
+ case 'move': return this.movePicker.hints();
497
+ case 'new': return this.newPicker.hints();
498
+ case 'search': return ` SEARCH: ${this.searchField.text} ↑↓=select Enter=open Tab=keep filter Esc=cancel`;
499
+ case 'rename': return ` RENAME: ${this.renameField.text} Enter=save Esc=cancel`;
500
+ case 'help': return ' Press any key to close help';
501
+ }
502
+ const total = this.sessionCount();
503
+ const right = `${total} session${total === 1 ? '' : 's'}${this.marked.size ? ` · ${this.marked.size} marked` : ''} `;
504
+ let left = '';
505
+ for (const hint of FOOTER_HINTS) {
506
+ if (`${left} ${hint}`.length + right.length + 2 > W) break;
507
+ left += ` ${hint}`;
508
+ }
509
+ return left + ' '.repeat(Math.max(2, W - left.length - right.length)) + right;
510
+ }
511
+
512
+ /** Where to show the terminal cursor while a text field is active, else null. */
513
+ _cursorPosition() {
514
+ const inFooter = (label, field) => [this.h, ` ${label}: `.length + [...field.before].length + 1];
515
+ switch (this.mode) {
516
+ case 'search': return inFooter('SEARCH', this.searchField);
517
+ case 'rename': return inFooter('RENAME', this.renameField);
518
+ case 'move': return this.movePicker.cursorPosition();
519
+ case 'new': return this.newPicker.cursorPosition();
520
+ default: return null;
521
+ }
522
+ }
523
+ }
524
+
525
+ module.exports = { App };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // Raw key sequences as delivered by a terminal in raw mode. Several keys have
4
+ // more than one encoding depending on the terminal (xterm, VTE, tmux, …).
5
+
6
+ const KEY = {
7
+ ENTER: '\r',
8
+ TAB: '\t',
9
+ ESCAPE: '\x1b',
10
+ BACKSPACE: ['\x7f', '\b'],
11
+ DELETE: '\x1b[3~',
12
+ CTRL_C: '\x03',
13
+ UP: '\x1b[A',
14
+ DOWN: '\x1b[B',
15
+ PAGE_UP: '\x1b[5~',
16
+ PAGE_DOWN: '\x1b[6~',
17
+ LEFT: ['\x1b[D', '\x02'], // ←, Ctrl-B
18
+ RIGHT: ['\x1b[C', '\x06'], // →, Ctrl-F
19
+ HOME: ['\x1b[H', '\x1bOH', '\x1b[1~', '\x01'], // Home, Ctrl-A
20
+ END: ['\x1b[F', '\x1bOF', '\x1b[4~', '\x05'], // End, Ctrl-E
21
+ WORD_LEFT: ['\x1b[1;5D', '\x1b[1;3D'], // Ctrl-←, Alt-←
22
+ WORD_RIGHT: ['\x1b[1;5C', '\x1b[1;3C'], // Ctrl-→, Alt-→
23
+ KILL_LINE: '\x15', // Ctrl-U
24
+ KILL_WORD: '\x17', // Ctrl-W
25
+ };
26
+
27
+ /** True when `key` is `name` (a KEY entry with one or several encodings). */
28
+ function is(key, name) {
29
+ const k = KEY[name];
30
+ return Array.isArray(k) ? k.includes(key) : key === k;
31
+ }
32
+
33
+ /**
34
+ * A stdin chunk may hold several keys (fast typing, paste): split it into single
35
+ * characters, keeping escape sequences like "\x1b[A" or "\x1b[5~" whole.
36
+ */
37
+ function splitKeys(chunk) {
38
+ return chunk.match(/\x1b\[[0-9;]*[A-Za-z~]|\x1bO[A-Za-z]|\x1b|[\s\S]/gu) || [];
39
+ }
40
+
41
+ function isPrintable(key) {
42
+ return key.length >= 1 && !key.startsWith('\x1b') && [...key].every(ch => ch >= ' ' && ch !== '\x7f');
43
+ }
44
+
45
+ module.exports = { KEY, is, splitKeys, isPrintable };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ const { is, isPrintable } = require('./keys');
4
+
5
+ // Word boundaries for Ctrl-←/→ and Ctrl-W, so "keycloak/fix-login" splits at / and -.
6
+ const WORD_SEP = /[\s/\-_.]/;
7
+
8
+ /**
9
+ * A one-line text field with a movable cursor: ←/→, Home/End, Ctrl-←/→ by word,
10
+ * Backspace/Delete at the cursor, Ctrl-U (delete to start), Ctrl-W (delete word).
11
+ */
12
+ class LineInput {
13
+ constructor(text = '') { this.set(text); }
14
+
15
+ /** Replaces the text and puts the cursor at the end. */
16
+ set(text) {
17
+ this.chars = [...text]; // code points, so the cursor never splits a character
18
+ this.cursor = this.chars.length;
19
+ }
20
+
21
+ get text() { return this.chars.join(''); }
22
+ /** The text left of the cursor (its length is the cursor column). */
23
+ get before() { return this.chars.slice(0, this.cursor).join(''); }
24
+
25
+ /** Applies an editing key. Returns false when `key` is not one. */
26
+ handle(key) {
27
+ const c = this.chars;
28
+ if (is(key, 'LEFT')) this.cursor = Math.max(0, this.cursor - 1);
29
+ else if (is(key, 'RIGHT')) this.cursor = Math.min(c.length, this.cursor + 1);
30
+ else if (is(key, 'HOME')) this.cursor = 0;
31
+ else if (is(key, 'END')) this.cursor = c.length;
32
+ else if (is(key, 'WORD_LEFT')) this.cursor = this._wordLeft();
33
+ else if (is(key, 'WORD_RIGHT')) this.cursor = this._wordRight();
34
+ else if (is(key, 'BACKSPACE')) { if (this.cursor > 0) c.splice(--this.cursor, 1); }
35
+ else if (is(key, 'DELETE')) c.splice(this.cursor, 1);
36
+ else if (is(key, 'KILL_LINE')) { c.splice(0, this.cursor); this.cursor = 0; }
37
+ else if (is(key, 'KILL_WORD')) { const i = this._wordLeft(); c.splice(i, this.cursor - i); this.cursor = i; }
38
+ else if (isPrintable(key)) {
39
+ const inserted = [...key];
40
+ c.splice(this.cursor, 0, ...inserted);
41
+ this.cursor += inserted.length;
42
+ }
43
+ else return false;
44
+ return true;
45
+ }
46
+
47
+ _wordLeft() {
48
+ let i = this.cursor;
49
+ while (i > 0 && WORD_SEP.test(this.chars[i - 1])) i--;
50
+ while (i > 0 && !WORD_SEP.test(this.chars[i - 1])) i--;
51
+ return i;
52
+ }
53
+
54
+ _wordRight() {
55
+ let i = this.cursor;
56
+ const n = this.chars.length;
57
+ while (i < n && WORD_SEP.test(this.chars[i])) i++;
58
+ while (i < n && !WORD_SEP.test(this.chars[i])) i++;
59
+ return i;
60
+ }
61
+ }
62
+
63
+ module.exports = { LineInput };