@vinh0809it/claude-sessions 0.2.0 → 0.3.1
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 +6 -3
- package/index.js +179 -85
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ A zero-dependency terminal UI for browsing, previewing, resuming, renaming, movi
|
|
|
10
10
|
- **Auto-discovers** all `~/.claude*` config directories and `$CLAUDE_CONFIG_DIR` — multiple Claude Code profiles just work
|
|
11
11
|
- **Live search** across session titles, first messages and project paths
|
|
12
12
|
- **Preview** — press `p` to see the last few messages of the selected session
|
|
13
|
-
- **Resume anywhere** — `Enter` opens a new tab in kitty, WezTerm, tmux, iTerm2 or Terminal
|
|
13
|
+
- **Resume anywhere** — `Enter` opens a new tab in kitty, WezTerm, tmux, iTerm2, Terminal.app or Windows Terminal, and otherwise resumes right in the current terminal (you return to the list when Claude exits). `o` always resumes in place
|
|
14
14
|
- **Rename** — press `r`; writes the same record `/rename` does
|
|
15
15
|
- **Move** sessions to another profile, an existing folder, or a typed path
|
|
16
16
|
- **Delete safely** — sessions go to a trash folder; mark several with `Space` to delete in bulk; `--empty-trash` frees the space
|
|
@@ -55,7 +55,7 @@ Tip: `alias ccs=claude-sessions`
|
|
|
55
55
|
| `↑` `↓` / `j` `k` | Navigate |
|
|
56
56
|
| `PgUp` `PgDn` | Page up / down |
|
|
57
57
|
| `g` / `G` | Jump to top / bottom |
|
|
58
|
-
| `/` | Search by title, message or path |
|
|
58
|
+
| `/` | Search by title, message or path. While typing: `↑↓` select, `Enter` open, `Tab` keep the filter and go back to the list |
|
|
59
59
|
| `Esc` | Clear search and marks |
|
|
60
60
|
| `Enter` | Open session (new tab if supported, otherwise here) |
|
|
61
61
|
| `o` | Open session in this terminal |
|
|
@@ -92,10 +92,13 @@ Sessions are grouped by profile in the UI. Press `m` to move a session to a diff
|
|
|
92
92
|
| **tmux** | ✅ new window | In the current session |
|
|
93
93
|
| **iTerm2** | ✅ new tab | Via `osascript` |
|
|
94
94
|
| **Terminal.app** | ✅ new window | Via `osascript` |
|
|
95
|
+
| **Windows Terminal** | ✅ new tab | Via `wt new-tab` (native Windows; in WSL it resumes in place) |
|
|
95
96
|
| Anything else | ✅ same terminal | GNOME Terminal, Ghostty, VS Code, … |
|
|
96
97
|
|
|
97
98
|
Set `CLAUDE_SESSIONS_TERMINAL=here` to always resume in place.
|
|
98
99
|
|
|
100
|
+
Set `NO_COLOR=1` to turn off colors (bold and inverse are kept).
|
|
101
|
+
|
|
99
102
|
### Custom open command
|
|
100
103
|
|
|
101
104
|
Set `CLAUDE_SESSIONS_OPEN_CMD` with placeholders `%cmd%`, `%title%`, and `%project%`:
|
|
@@ -107,7 +110,7 @@ export CLAUDE_SESSIONS_OPEN_CMD="ghostty --command=bash -i -c %cmd%"
|
|
|
107
110
|
# iTerm2 via osascript
|
|
108
111
|
export CLAUDE_SESSIONS_OPEN_CMD="osascript -e 'tell app \"iTerm\" to create window with default profile command \"bash -i -c %cmd%\"'"
|
|
109
112
|
|
|
110
|
-
# Force a specific mode: kitty | wezterm | tmux | iterm2 | apple-terminal | here
|
|
113
|
+
# Force a specific mode: kitty | wezterm | tmux | iterm2 | apple-terminal | windows-terminal | here
|
|
111
114
|
export CLAUDE_SESSIONS_TERMINAL=wezterm
|
|
112
115
|
```
|
|
113
116
|
|
package/index.js
CHANGED
|
@@ -455,13 +455,15 @@ function tabComplete(input) {
|
|
|
455
455
|
|
|
456
456
|
// ─── Terminal detection & open ────────────────────────────────────────────────
|
|
457
457
|
|
|
458
|
-
function detectTerminal(env = process.env) {
|
|
458
|
+
function detectTerminal(env = process.env, platform = process.platform) {
|
|
459
459
|
if (env.CLAUDE_SESSIONS_TERMINAL) return env.CLAUDE_SESSIONS_TERMINAL;
|
|
460
460
|
if (env.KITTY_WINDOW_ID !== undefined) return 'kitty';
|
|
461
461
|
if (env.WEZTERM_PANE !== undefined) return 'wezterm';
|
|
462
462
|
if (env.TMUX) return 'tmux';
|
|
463
463
|
if (env.TERM_PROGRAM === 'iTerm.app') return 'iterm2';
|
|
464
464
|
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple-terminal';
|
|
465
|
+
// WSL also sees WT_SESSION, but there `wt` would open a Windows shell, not Linux.
|
|
466
|
+
if (env.WT_SESSION && platform === 'win32') return 'windows-terminal';
|
|
465
467
|
return 'here';
|
|
466
468
|
}
|
|
467
469
|
|
|
@@ -512,6 +514,14 @@ function planShellCmd(plan) {
|
|
|
512
514
|
return `cd ${shellEsc(plan.cwd)} && CLAUDE_CONFIG_DIR=${shellEsc(plan.configDirPath)} ${claude}; exec "\${SHELL:-bash}"`;
|
|
513
515
|
}
|
|
514
516
|
|
|
517
|
+
// `wt new-tab` args. wt treats ';' as a command separator, so it is escaped.
|
|
518
|
+
// cmd's `set X=v&& …` takes everything up to && as the value (spaces allowed).
|
|
519
|
+
function wtArgs(plan) {
|
|
520
|
+
const semi = v => String(v).replace(/;/g, '\\;');
|
|
521
|
+
return ['-w', '0', 'nt', '--title', semi(plan.title), '-d', semi(plan.cwd),
|
|
522
|
+
'cmd', '/k', semi(`set CLAUDE_CONFIG_DIR=${plan.configDirPath}&& claude ${plan.args.join(' ')}`.trim())];
|
|
523
|
+
}
|
|
524
|
+
|
|
515
525
|
// Returns { ok, msg } or { here: true } when the caller should run it in this terminal.
|
|
516
526
|
function launchInTab(plan) {
|
|
517
527
|
const shellCmd = planShellCmd(plan);
|
|
@@ -537,6 +547,8 @@ function launchInTab(plan) {
|
|
|
537
547
|
`tell application "iTerm2" to tell current window to create tab with default profile command ${appleStr('bash -i -c ' + shellEsc(shellCmd))}`]);
|
|
538
548
|
case 'apple-terminal':
|
|
539
549
|
return trySpawn('osascript', ['-e', `tell application "Terminal" to do script ${appleStr(shellCmd)}`]);
|
|
550
|
+
case 'windows-terminal':
|
|
551
|
+
return trySpawn('wt', wtArgs(plan));
|
|
540
552
|
default:
|
|
541
553
|
return { here: true };
|
|
542
554
|
}
|
|
@@ -561,6 +573,16 @@ function fmtDate(d) {
|
|
|
561
573
|
return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())} ${p(t.getHours())}:${p(t.getMinutes())}`;
|
|
562
574
|
}
|
|
563
575
|
|
|
576
|
+
function fmtAgo(d, now = Date.now()) {
|
|
577
|
+
const m = Math.floor((now - new Date(d)) / 60000);
|
|
578
|
+
if (m < 1) return 'just now';
|
|
579
|
+
if (m < 60) return `${m}m ago`;
|
|
580
|
+
if (m < 1440) return `${Math.floor(m / 60)}h ago`;
|
|
581
|
+
if (m < 43200) return `${Math.floor(m / 1440)}d ago`;
|
|
582
|
+
if (m < 525600) return `${Math.floor(m / 43200)}mo ago`;
|
|
583
|
+
return `${Math.floor(m / 525600)}y ago`;
|
|
584
|
+
}
|
|
585
|
+
|
|
564
586
|
function fmtSize(n) {
|
|
565
587
|
if (n < 1024) return `${n}B`;
|
|
566
588
|
const u = ['K', 'M', 'G'];
|
|
@@ -592,7 +614,7 @@ function sortSessions(sessions, mode) {
|
|
|
592
614
|
|
|
593
615
|
// ─── Plain list output ────────────────────────────────────────────────────────
|
|
594
616
|
|
|
595
|
-
function ansi(code, s) { return process.stdout.isTTY ? `\x1b[${code}m${s}\x1b[0m` : s; }
|
|
617
|
+
function ansi(code, s) { return process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; }
|
|
596
618
|
|
|
597
619
|
function printList(sessions) {
|
|
598
620
|
if (!sessions.length) { console.log('No sessions found.'); return; }
|
|
@@ -608,7 +630,7 @@ function printList(sessions) {
|
|
|
608
630
|
lastProject = s.project;
|
|
609
631
|
}
|
|
610
632
|
const label = s.title
|
|
611
|
-
?
|
|
633
|
+
? groupTitle(s.title, '1;32', ansi)
|
|
612
634
|
: ansi('2', `(${(s.firstMessage || 'untitled').slice(0, 60)}…)`);
|
|
613
635
|
console.log(` ${label} ${ansi('2', `${fmtDate(s.lastModified)} ${fmtSize(s.size)}`)}`);
|
|
614
636
|
console.log(ansi('2', ` id: ${s.sessionId}`));
|
|
@@ -626,17 +648,34 @@ const altBuf = `${ESC}[?1049h`;
|
|
|
626
648
|
const normBuf = `${ESC}[?1049l`;
|
|
627
649
|
|
|
628
650
|
const mv = (r, c) => `${ESC}[${r};${c}H`;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
632
|
-
const
|
|
633
|
-
const
|
|
651
|
+
// Each helper resets only its own attribute, so a row background survives nested
|
|
652
|
+
// colors. NO_COLOR (https://no-color.org) drops colors but keeps bold/dim/inverse.
|
|
653
|
+
const COLOR = !process.env.NO_COLOR;
|
|
654
|
+
const bold = s => `${ESC}[1m${s}${ESC}[22m`;
|
|
655
|
+
const dim = s => `${ESC}[2m${s}${ESC}[22m`;
|
|
656
|
+
const inv = s => `${ESC}[7m${s}${ESC}[27m`;
|
|
657
|
+
const fgc = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[22;39m` : s;
|
|
658
|
+
const bgc = (code, s) => COLOR ? `${ESC}[${code}m${s}${ESC}[49m` : s;
|
|
659
|
+
const SEL_BG = `${ESC}[48;5;236m`;
|
|
660
|
+
// Selected line: dark background + accent bar (inverse when colors are off).
|
|
661
|
+
// `styled` must color every visible part explicitly so it reads on light themes too.
|
|
662
|
+
const selLine = (styled, plain, W) => COLOR
|
|
663
|
+
? `${SEL_BG}${fgc('96', '▌')}${styled}${ESC}[K${ESC}[0m`
|
|
664
|
+
: inv(pad(` ${plain}`, W));
|
|
665
|
+
const stripAnsi = s => s.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
666
|
+
// "Enter=open /=search" → key in bold, label dimmed.
|
|
667
|
+
const styleHints = s => s.replace(/(\S+)=(.+?)(?= |$)/g, (_, k, l) => `${bold(k)} ${dim(l)}`);
|
|
634
668
|
const trunc = (s, n) => n > 0 && s.length > n ? s.slice(0, n - 1) + '…' : s;
|
|
635
669
|
const pad = (s, n) => s.padEnd(n).slice(0, n);
|
|
670
|
+
// "keycloak/fix-login" → group prefix in its own color so related sessions stand out
|
|
671
|
+
const groupTitle = (t, color, fmt = fgc) => {
|
|
672
|
+
const i = t.indexOf('/');
|
|
673
|
+
return i > 0 ? fmt('95', t.slice(0, i + 1)) + fmt(color, t.slice(i + 1)) : fmt(color, t);
|
|
674
|
+
};
|
|
636
675
|
|
|
637
676
|
const HELP = [
|
|
638
677
|
['↑↓ / j k', 'Navigate'], ['PgUp PgDn', 'Page up / down'],
|
|
639
|
-
['g / G', 'Top / bottom'], ['/', 'Search
|
|
678
|
+
['g / G', 'Top / bottom'], ['/', 'Search (↑↓ select, Enter open, Tab keep filter)'],
|
|
640
679
|
['Esc', 'Clear search / marks'], ['Enter', 'Open (new tab if supported, else here)'],
|
|
641
680
|
['o', 'Open in this terminal'], ['p', 'Toggle preview'],
|
|
642
681
|
['r', 'Rename'], ['s', 'Sort: date / size / name'],
|
|
@@ -645,6 +684,54 @@ const HELP = [
|
|
|
645
684
|
['q / Ctrl-C', 'Quit'],
|
|
646
685
|
];
|
|
647
686
|
|
|
687
|
+
// ─── LineInput ───────────────────────────────────────────────────────────────
|
|
688
|
+
// One-line text field with a movable cursor: ←/→, Home/End, Ctrl-←/→ by word,
|
|
689
|
+
// Backspace/Delete at the cursor, Ctrl-U (clear to start), Ctrl-W (delete word).
|
|
690
|
+
|
|
691
|
+
const WORD_SEP = /[\s/\-_.]/;
|
|
692
|
+
|
|
693
|
+
class LineInput {
|
|
694
|
+
constructor(text = '') { this.set(text); }
|
|
695
|
+
set(text) { this.chars = [...text]; this.cur = this.chars.length; }
|
|
696
|
+
get text() { return this.chars.join(''); }
|
|
697
|
+
get before() { return this.chars.slice(0, this.cur).join(''); }
|
|
698
|
+
|
|
699
|
+
_wordLeft(i) {
|
|
700
|
+
while (i > 0 && WORD_SEP.test(this.chars[i - 1])) i--;
|
|
701
|
+
while (i > 0 && !WORD_SEP.test(this.chars[i - 1])) i--;
|
|
702
|
+
return i;
|
|
703
|
+
}
|
|
704
|
+
_wordRight(i) {
|
|
705
|
+
const n = this.chars.length;
|
|
706
|
+
while (i < n && WORD_SEP.test(this.chars[i])) i++;
|
|
707
|
+
while (i < n && !WORD_SEP.test(this.chars[i])) i++;
|
|
708
|
+
return i;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Returns true when the key was an editing key.
|
|
712
|
+
handle(key) {
|
|
713
|
+
const c = this.chars;
|
|
714
|
+
switch (key) {
|
|
715
|
+
case '\x1b[D': case '\x02': this.cur = Math.max(0, this.cur - 1); break;
|
|
716
|
+
case '\x1b[C': case '\x06': this.cur = Math.min(c.length, this.cur + 1); break;
|
|
717
|
+
case '\x1b[H': case '\x1bOH': case '\x1b[1~': case '\x01': this.cur = 0; break;
|
|
718
|
+
case '\x1b[F': case '\x1bOF': case '\x1b[4~': case '\x05': this.cur = c.length; break;
|
|
719
|
+
case '\x1b[1;5D': case '\x1b[1;3D': this.cur = this._wordLeft(this.cur); break;
|
|
720
|
+
case '\x1b[1;5C': case '\x1b[1;3C': this.cur = this._wordRight(this.cur); break;
|
|
721
|
+
case '\x7f': case '\b': if (this.cur > 0) c.splice(--this.cur, 1); break;
|
|
722
|
+
case '\x1b[3~': c.splice(this.cur, 1); break;
|
|
723
|
+
case '\x15': c.splice(0, this.cur); this.cur = 0; break;
|
|
724
|
+
case '\x17': { const i = this._wordLeft(this.cur); c.splice(i, this.cur - i); this.cur = i; break; }
|
|
725
|
+
default:
|
|
726
|
+
if (!isPrintable(key)) return false;
|
|
727
|
+
const ins = [...key];
|
|
728
|
+
c.splice(this.cur, 0, ...ins);
|
|
729
|
+
this.cur += ins.length;
|
|
730
|
+
}
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
648
735
|
// ─── Picker ───────────────────────────────────────────────────────────────────
|
|
649
736
|
// Shared profile → folder → input flow used by both move and new modes.
|
|
650
737
|
// step: 'profile' | 'folder' | 'input'
|
|
@@ -656,7 +743,7 @@ class Picker {
|
|
|
656
743
|
this.selIdx = 0;
|
|
657
744
|
this.scroll = 0;
|
|
658
745
|
this.config = null;
|
|
659
|
-
this.
|
|
746
|
+
this.field = new LineInput();
|
|
660
747
|
this.completions = [];
|
|
661
748
|
|
|
662
749
|
this._headerLabel = headerLabel;
|
|
@@ -674,7 +761,7 @@ class Picker {
|
|
|
674
761
|
this.selIdx = 0;
|
|
675
762
|
this.scroll = 0;
|
|
676
763
|
this.config = null;
|
|
677
|
-
this.
|
|
764
|
+
this.field.set('');
|
|
678
765
|
this.completions = [];
|
|
679
766
|
// Skip the profile step when there is only one.
|
|
680
767
|
if (this.items.length === 1) this._enterFolderStep(this.items[0].configDirPath);
|
|
@@ -696,19 +783,15 @@ class Picker {
|
|
|
696
783
|
this.completions = [];
|
|
697
784
|
this.items = this._buildFolderItems(this.config);
|
|
698
785
|
} else if (key === '\t') {
|
|
699
|
-
const { completed, matches } = tabComplete(this.
|
|
700
|
-
this.
|
|
786
|
+
const { completed, matches } = tabComplete(this.field.text || '~/');
|
|
787
|
+
this.field.set(completed);
|
|
701
788
|
this.completions = matches.length > 1 ? matches : [];
|
|
702
|
-
} else if (key === '\x7f' || key === '\b') {
|
|
703
|
-
this.input = this.input.slice(0, -1);
|
|
704
|
-
this.completions = [];
|
|
705
789
|
} else if (key === '\r') {
|
|
706
|
-
const raw = this.
|
|
790
|
+
const raw = this.field.text.trim();
|
|
707
791
|
this.completions = [];
|
|
708
792
|
if (raw) this._onConfirmCustom(path.resolve(expandHome(raw).replace(/[\\/]+$/, '') || path.sep));
|
|
709
793
|
else this.step = 'folder';
|
|
710
|
-
} else if (
|
|
711
|
-
this.input += key;
|
|
794
|
+
} else if (this.field.handle(key)) {
|
|
712
795
|
this.completions = [];
|
|
713
796
|
}
|
|
714
797
|
return true;
|
|
@@ -733,7 +816,7 @@ class Picker {
|
|
|
733
816
|
const item = this.items[this.selIdx];
|
|
734
817
|
if (!item) return true;
|
|
735
818
|
if (item.type === 'profile') { this._enterFolderStep(item.configDirPath); return true; }
|
|
736
|
-
if (item.type === 'new') { this.step = 'input'; this.
|
|
819
|
+
if (item.type === 'new') { this.step = 'input'; this.field.set('~/'); this.completions = []; return true; }
|
|
737
820
|
this._onConfirmItem(item);
|
|
738
821
|
}
|
|
739
822
|
return true;
|
|
@@ -753,7 +836,7 @@ class Picker {
|
|
|
753
836
|
|
|
754
837
|
let listStart = 4;
|
|
755
838
|
if (this.step === 'input') {
|
|
756
|
-
out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.
|
|
839
|
+
out.push(mv(4, 1), clrLine, ` ${fgc('93', this._inputPrompt)} ${this.field.text}`);
|
|
757
840
|
out.push(mv(5, 1), clrLine);
|
|
758
841
|
if (this.completions.length > 1) {
|
|
759
842
|
const cols = Math.max(1, Math.floor((W - 2) / 22));
|
|
@@ -784,26 +867,9 @@ class Picker {
|
|
|
784
867
|
: item.type === 'new' ? fgc('93', item.label)
|
|
785
868
|
: item.type === 'profile' ? bold(fgc('96', item.label))
|
|
786
869
|
: fgc('33', item.label);
|
|
787
|
-
out.push(isSel ?
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// Word-wraps text to `width` columns; words longer than a line are split.
|
|
793
|
-
function wrapWords(text, width) {
|
|
794
|
-
const lines = [];
|
|
795
|
-
let cur = '';
|
|
796
|
-
for (let word of text.split(' ')) {
|
|
797
|
-
while (word.length > width) {
|
|
798
|
-
if (cur) { lines.push(cur); cur = ''; }
|
|
799
|
-
lines.push(word.slice(0, width)); word = word.slice(width);
|
|
870
|
+
out.push(isSel ? selLine(` ${COLOR ? label.replace(/\x1b\[2m/g, '') : label}`, ` ${trunc(item.label, W - 5)}`, W) : ` ${label}`);
|
|
800
871
|
}
|
|
801
|
-
if (!cur) cur = word;
|
|
802
|
-
else if (cur.length + 1 + word.length <= width) cur += ' ' + word;
|
|
803
|
-
else { lines.push(cur); cur = word; }
|
|
804
872
|
}
|
|
805
|
-
if (cur || !lines.length) lines.push(cur);
|
|
806
|
-
return lines;
|
|
807
873
|
}
|
|
808
874
|
|
|
809
875
|
// A stdin chunk may hold several keys (fast typing, paste): split into single
|
|
@@ -838,7 +904,8 @@ class TUI {
|
|
|
838
904
|
this.marked = new Set(); // jsonlPath
|
|
839
905
|
this.preview = false;
|
|
840
906
|
this._previewCache = new Map();
|
|
841
|
-
this.
|
|
907
|
+
this.renameField = new LineInput();
|
|
908
|
+
this.searchField = new LineInput();
|
|
842
909
|
this.w = process.stdout.columns || 80;
|
|
843
910
|
this.h = process.stdout.rows || 24;
|
|
844
911
|
|
|
@@ -921,12 +988,13 @@ class TUI {
|
|
|
921
988
|
: this.all;
|
|
922
989
|
|
|
923
990
|
this.items = [];
|
|
924
|
-
let
|
|
991
|
+
let group = null;
|
|
925
992
|
for (const s of sortSessions(filtered, this.sort)) {
|
|
926
|
-
if (s.configDir !==
|
|
927
|
-
|
|
928
|
-
|
|
993
|
+
if (s.configDir !== group?.label) {
|
|
994
|
+
group = { type: 'config', label: s.configDir, count: 0 };
|
|
995
|
+
this.items.push(group);
|
|
929
996
|
}
|
|
997
|
+
group.count++;
|
|
930
998
|
this.items.push({ type: 'session', session: s });
|
|
931
999
|
}
|
|
932
1000
|
|
|
@@ -1038,15 +1106,14 @@ class TUI {
|
|
|
1038
1106
|
const key = s.jsonlPath + ':' + s.lastModified.valueOf();
|
|
1039
1107
|
let msgs = this._previewCache.get(key);
|
|
1040
1108
|
if (!msgs) { msgs = readRecentMessages(s.jsonlPath); this._previewCache.set(key, msgs); }
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
return lines.slice(-H);
|
|
1109
|
+
// One line per message with a blank line between, newest at the bottom.
|
|
1110
|
+
const width = Math.max(10, W - 8);
|
|
1111
|
+
const lines = msgs.flatMap(m => ['',
|
|
1112
|
+
` ${m.role === 'user' ? fgc('92', 'you ') : fgc('96', 'cc ')} ${trunc(m.text.replace(/\s+/g, ' ').trim(), width)}`]);
|
|
1113
|
+
const body = lines.slice(-(H - 2));
|
|
1114
|
+
if (body[0] === '') body.shift();
|
|
1115
|
+
const head = dim(` ${fmtDate(s.lastModified)} · ${fmtSize(s.size)} · ${s.sessionId}`);
|
|
1116
|
+
return [head, '', ...(body.length ? body : [dim(' (no messages)')])];
|
|
1050
1117
|
}
|
|
1051
1118
|
|
|
1052
1119
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
@@ -1073,7 +1140,11 @@ class TUI {
|
|
|
1073
1140
|
}
|
|
1074
1141
|
|
|
1075
1142
|
out.push(mv(H, 1));
|
|
1076
|
-
|
|
1143
|
+
const foot = trunc(this._footer(W), W);
|
|
1144
|
+
out.push(clrLine, this._footerIsStatus ? fgc('93', foot) : styleHints(foot)
|
|
1145
|
+
.replace(/^ (SEARCH|RENAME):/, (_, l) => ` ${bold(fgc('93', l + ':'))}`));
|
|
1146
|
+
const cur = this._cursorPos();
|
|
1147
|
+
if (cur) out.push(mv(cur[0], Math.min(W, cur[1])), showCursor);
|
|
1077
1148
|
process.stdout.write(out.join(''));
|
|
1078
1149
|
}
|
|
1079
1150
|
|
|
@@ -1087,7 +1158,9 @@ class TUI {
|
|
|
1087
1158
|
if (!item) continue;
|
|
1088
1159
|
|
|
1089
1160
|
if (item.type === 'config') {
|
|
1090
|
-
|
|
1161
|
+
const count = `· ${item.count} session${item.count === 1 ? '' : 's'}`;
|
|
1162
|
+
const rule = '─'.repeat(Math.max(0, W - item.label.length - count.length - 6));
|
|
1163
|
+
out.push(dim('── ') + bold(fgc('96', item.label)) + ' ' + dim(`${count} ${rule}`));
|
|
1091
1164
|
continue;
|
|
1092
1165
|
}
|
|
1093
1166
|
const s = item.session;
|
|
@@ -1095,22 +1168,20 @@ class TUI {
|
|
|
1095
1168
|
const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
|
|
1096
1169
|
const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
|
|
1097
1170
|
const project = shortenPath(s.project);
|
|
1098
|
-
const meta = `${fmtSize(s.size).padStart(5)} ${
|
|
1171
|
+
const meta = `${fmtSize(s.size).padStart(5)} ${fmtAgo(s.lastModified).padStart(8)}`;
|
|
1099
1172
|
const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
|
|
1100
|
-
const avail = Math.max(12, W -
|
|
1173
|
+
const avail = Math.max(12, W - 3 - cfgTag.length - 4 - meta.length - 1);
|
|
1101
1174
|
const titleW = Math.floor(avail * 0.55);
|
|
1102
1175
|
const projectW = avail - titleW;
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
);
|
|
1113
|
-
}
|
|
1176
|
+
const titleTxt = pad(trunc(title, titleW), titleW);
|
|
1177
|
+
const projTxt = pad(trunc(project, projectW), projectW);
|
|
1178
|
+
// On the selected line every part gets an explicit color (see selLine).
|
|
1179
|
+
const muted = t => isSel ? fgc('37', t) : dim(t);
|
|
1180
|
+
const styled = fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
|
|
1181
|
+
(s.title ? groupTitle(titleTxt, isSel ? '1;92' : '92') : muted(titleTxt)) +
|
|
1182
|
+
` ${fgc('33', projTxt)} ${muted(meta)}`;
|
|
1183
|
+
|
|
1184
|
+
out.push(isSel ? selLine(styled, `${mark} ${cfgTag}${titleTxt} ${projTxt} ${meta}`, W) : ` ${styled}`);
|
|
1114
1185
|
si++;
|
|
1115
1186
|
}
|
|
1116
1187
|
}
|
|
@@ -1123,7 +1194,18 @@ class TUI {
|
|
|
1123
1194
|
for (let r = 0; r < this.previewH - 1; r++) out.push(mv(top + 1 + r, 1), clrLine, lines[r] || '');
|
|
1124
1195
|
}
|
|
1125
1196
|
|
|
1197
|
+
// Where to show the terminal cursor when a text field is active, else null.
|
|
1198
|
+
_cursorPos() {
|
|
1199
|
+
const at = (row, prefix, f) => [row, prefix.length + [...f.before].length + 1];
|
|
1200
|
+
if (this.mode === 'search') return at(this.h, ' SEARCH: ', this.searchField);
|
|
1201
|
+
if (this.mode === 'rename') return at(this.h, ' RENAME: ', this.renameField);
|
|
1202
|
+
const p = this.mode === 'move' ? this.movePicker : this.mode === 'new' ? this.newPicker : null;
|
|
1203
|
+
if (p && p.step === 'input') return at(4, ` ${p._inputPrompt} `, p.field);
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1126
1207
|
_footer(W) {
|
|
1208
|
+
this._footerIsStatus = false;
|
|
1127
1209
|
const sel = this.selectedSession();
|
|
1128
1210
|
const total = this.sessionItems().length;
|
|
1129
1211
|
const pick = p => p.step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
|
|
@@ -1137,14 +1219,18 @@ class TUI {
|
|
|
1137
1219
|
}
|
|
1138
1220
|
case 'move': return pick(this.movePicker);
|
|
1139
1221
|
case 'new': return pick(this.newPicker);
|
|
1140
|
-
case 'search': return ` SEARCH: ${this.
|
|
1141
|
-
case 'rename': return ` RENAME: ${this.
|
|
1222
|
+
case 'search': return ` SEARCH: ${this.searchField.text} ↑↓=select Enter=open Tab=keep filter Esc=cancel`;
|
|
1223
|
+
case 'rename': return ` RENAME: ${this.renameField.text} Enter=save Esc=cancel`;
|
|
1142
1224
|
case 'help': return ` Press any key to close help`;
|
|
1143
1225
|
}
|
|
1144
|
-
if (this.statusMsg) return ` ${this.statusMsg}`;
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1226
|
+
if (this.statusMsg) { this._footerIsStatus = true; return ` ${this.statusMsg}`; }
|
|
1227
|
+
const right = `${total} session${total === 1 ? '' : 's'}${this.marked.size ? ` · ${this.marked.size} marked` : ''} `;
|
|
1228
|
+
let left = '';
|
|
1229
|
+
for (const h of ['Enter=open', '/=search', 'p=preview', 'r=rename', 'm=move', 'd=delete', '?=more', 'q=quit']) {
|
|
1230
|
+
if (`${left} ${h}`.length + right.length + 2 > W) break;
|
|
1231
|
+
left += ` ${h}`;
|
|
1232
|
+
}
|
|
1233
|
+
return left + ' '.repeat(Math.max(2, W - left.length - right.length)) + right;
|
|
1148
1234
|
}
|
|
1149
1235
|
|
|
1150
1236
|
// ── Input ───────────────────────────────────────────────────────────────────
|
|
@@ -1169,25 +1255,33 @@ class TUI {
|
|
|
1169
1255
|
}
|
|
1170
1256
|
|
|
1171
1257
|
if (this.mode === 'search') {
|
|
1172
|
-
if (key === '\x1b')
|
|
1173
|
-
else if (key === '\
|
|
1174
|
-
else if (key === '\
|
|
1175
|
-
else if (
|
|
1258
|
+
if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
|
|
1259
|
+
else if (key === '\t') { this.mode = null; }
|
|
1260
|
+
else if (key === '\r') { this.mode = null; const s = this.selectedSession(); if (s) this._open(resumePlan(s)); }
|
|
1261
|
+
else if (key === '\x1b[A' || key === '\x1b[B' || key === '\x1b[5~' || key === '\x1b[6~') {
|
|
1262
|
+
const total = this.sessionItems().length;
|
|
1263
|
+
const delta = { '\x1b[A': -1, '\x1b[B': 1, '\x1b[5~': -this.listH, '\x1b[6~': this.listH }[key];
|
|
1264
|
+
this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
|
|
1265
|
+
this.ensureVisible();
|
|
1266
|
+
}
|
|
1267
|
+
else if (this.searchField.handle(key) && this.searchField.text !== this.search) {
|
|
1268
|
+
this.search = this.searchField.text; this.rebuild();
|
|
1269
|
+
}
|
|
1176
1270
|
return true;
|
|
1177
1271
|
}
|
|
1178
1272
|
|
|
1179
1273
|
if (this.mode === 'rename') {
|
|
1180
1274
|
if (key === '\x1b') { this.mode = null; }
|
|
1181
|
-
else if (key === '\x7f' || key === '\b') { this.renameInput = this.renameInput.slice(0, -1); }
|
|
1182
1275
|
else if (key === '\r') {
|
|
1183
1276
|
this.mode = null;
|
|
1184
1277
|
const s = this.selectedSession();
|
|
1185
|
-
|
|
1186
|
-
|
|
1278
|
+
const name = this.renameField.text;
|
|
1279
|
+
if (s && name.trim()) {
|
|
1280
|
+
try { renameSession(s, name); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
|
|
1187
1281
|
catch (e) { this.setStatus(`Rename failed: ${e.message}`, 5000); }
|
|
1188
1282
|
}
|
|
1189
1283
|
}
|
|
1190
|
-
else
|
|
1284
|
+
else this.renameField.handle(key);
|
|
1191
1285
|
return true;
|
|
1192
1286
|
}
|
|
1193
1287
|
|
|
@@ -1201,14 +1295,14 @@ class TUI {
|
|
|
1201
1295
|
|
|
1202
1296
|
if (key === 'q' || key === '\x03') return false;
|
|
1203
1297
|
|
|
1204
|
-
if (key === '/') { this.mode = 'search'; }
|
|
1298
|
+
if (key === '/') { this.searchField.set(this.search); this.mode = 'search'; }
|
|
1205
1299
|
else if (key === '?') { this.mode = 'help'; }
|
|
1206
1300
|
else if (key === '\x1b') { this.search = ''; this.marked.clear(); this.rebuild(); }
|
|
1207
1301
|
else if (key === '\r') { if (s) this._open(resumePlan(s)); }
|
|
1208
1302
|
else if (key === 'o') { if (s) this._open(resumePlan(s), true); }
|
|
1209
1303
|
else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
|
|
1210
1304
|
else if (key === 's') { this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length]; this.rebuild(); this.selectById(s?.sessionId); }
|
|
1211
|
-
else if (key === 'r') { if (s) { this.
|
|
1305
|
+
else if (key === 'r') { if (s) { this.renameField.set(s.title || ''); this.mode = 'rename'; } }
|
|
1212
1306
|
else if (key === ' ') {
|
|
1213
1307
|
if (s) { this.marked.has(s.jsonlPath) ? this.marked.delete(s.jsonlPath) : this.marked.add(s.jsonlPath); move(+1); }
|
|
1214
1308
|
}
|
|
@@ -1312,6 +1406,6 @@ if (require.main === module) {
|
|
|
1312
1406
|
module.exports = {
|
|
1313
1407
|
encodePathToDir, projectDirNameFor, tabComplete, resolveProjectPath, moveSession, readSession,
|
|
1314
1408
|
readRecentMessages, renameSession, deleteSessionFiles, emptyTrash, scanConfigDir, sortSessions,
|
|
1315
|
-
detectTerminal, parseArgs, fmtSize, splitKeys,
|
|
1409
|
+
detectTerminal, wtArgs, parseArgs, fmtAgo, fmtSize, splitKeys, cache, LineInput,
|
|
1316
1410
|
};
|
|
1317
1411
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vinh0809it/claude-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Fast terminal UI for browsing, previewing, resuming, renaming, moving, and deleting Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
|
-
"claude-sessions": "
|
|
6
|
+
"claude-sessions": "index.js"
|
|
7
7
|
},
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=18"
|