@vinh0809it/claude-sessions 0.2.0 → 0.3.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/README.md +6 -3
- package/index.js +173 -60
- 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,7 +867,7 @@ 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 ?
|
|
870
|
+
out.push(isSel ? selLine(` ${COLOR ? label.replace(/\x1b\[2m/g, '') : label}`, ` ${trunc(item.label, W - 5)}`, W) : ` ${label}`);
|
|
788
871
|
}
|
|
789
872
|
}
|
|
790
873
|
}
|
|
@@ -838,7 +921,8 @@ class TUI {
|
|
|
838
921
|
this.marked = new Set(); // jsonlPath
|
|
839
922
|
this.preview = false;
|
|
840
923
|
this._previewCache = new Map();
|
|
841
|
-
this.
|
|
924
|
+
this.renameField = new LineInput();
|
|
925
|
+
this.searchField = new LineInput();
|
|
842
926
|
this.w = process.stdout.columns || 80;
|
|
843
927
|
this.h = process.stdout.rows || 24;
|
|
844
928
|
|
|
@@ -921,12 +1005,13 @@ class TUI {
|
|
|
921
1005
|
: this.all;
|
|
922
1006
|
|
|
923
1007
|
this.items = [];
|
|
924
|
-
let
|
|
1008
|
+
let group = null;
|
|
925
1009
|
for (const s of sortSessions(filtered, this.sort)) {
|
|
926
|
-
if (s.configDir !==
|
|
927
|
-
|
|
928
|
-
|
|
1010
|
+
if (s.configDir !== group?.label) {
|
|
1011
|
+
group = { type: 'config', label: s.configDir, count: 0 };
|
|
1012
|
+
this.items.push(group);
|
|
929
1013
|
}
|
|
1014
|
+
group.count++;
|
|
930
1015
|
this.items.push({ type: 'session', session: s });
|
|
931
1016
|
}
|
|
932
1017
|
|
|
@@ -1046,7 +1131,8 @@ class TUI {
|
|
|
1046
1131
|
chunks.slice(0, 3).forEach((c, i) => lines.push(` ${i === 0 ? tag : ' '} ${i === 2 && chunks.length > 3 ? trunc(c, width - 1) + '…' : c}`));
|
|
1047
1132
|
}
|
|
1048
1133
|
if (!lines.length) lines.push(dim(' (no messages)'));
|
|
1049
|
-
|
|
1134
|
+
const head = dim(` ${fmtDate(s.lastModified)} · ${fmtSize(s.size)} · ${s.sessionId}`);
|
|
1135
|
+
return [head, ...lines.slice(-(H - 1))];
|
|
1050
1136
|
}
|
|
1051
1137
|
|
|
1052
1138
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
@@ -1073,7 +1159,11 @@ class TUI {
|
|
|
1073
1159
|
}
|
|
1074
1160
|
|
|
1075
1161
|
out.push(mv(H, 1));
|
|
1076
|
-
|
|
1162
|
+
const foot = trunc(this._footer(W), W);
|
|
1163
|
+
out.push(clrLine, this._footerIsStatus ? fgc('93', foot) : styleHints(foot)
|
|
1164
|
+
.replace(/^ (SEARCH|RENAME):/, (_, l) => ` ${bold(fgc('93', l + ':'))}`));
|
|
1165
|
+
const cur = this._cursorPos();
|
|
1166
|
+
if (cur) out.push(mv(cur[0], Math.min(W, cur[1])), showCursor);
|
|
1077
1167
|
process.stdout.write(out.join(''));
|
|
1078
1168
|
}
|
|
1079
1169
|
|
|
@@ -1087,7 +1177,9 @@ class TUI {
|
|
|
1087
1177
|
if (!item) continue;
|
|
1088
1178
|
|
|
1089
1179
|
if (item.type === 'config') {
|
|
1090
|
-
|
|
1180
|
+
const count = `· ${item.count} session${item.count === 1 ? '' : 's'}`;
|
|
1181
|
+
const rule = '─'.repeat(Math.max(0, W - item.label.length - count.length - 6));
|
|
1182
|
+
out.push(dim('── ') + bold(fgc('96', item.label)) + ' ' + dim(`${count} ${rule}`));
|
|
1091
1183
|
continue;
|
|
1092
1184
|
}
|
|
1093
1185
|
const s = item.session;
|
|
@@ -1095,22 +1187,20 @@ class TUI {
|
|
|
1095
1187
|
const mark = this.marked.has(s.jsonlPath) ? '●' : ' ';
|
|
1096
1188
|
const title = s.title || (s.firstMessage ? `(${s.firstMessage})` : '(untitled)');
|
|
1097
1189
|
const project = shortenPath(s.project);
|
|
1098
|
-
const meta = `${fmtSize(s.size).padStart(5)} ${
|
|
1190
|
+
const meta = `${fmtSize(s.size).padStart(5)} ${fmtAgo(s.lastModified).padStart(8)}`;
|
|
1099
1191
|
const cfgTag = this.search ? `[${shortConfig(s.configDir)}] ` : '';
|
|
1100
|
-
const avail = Math.max(12, W -
|
|
1192
|
+
const avail = Math.max(12, W - 3 - cfgTag.length - 4 - meta.length - 1);
|
|
1101
1193
|
const titleW = Math.floor(avail * 0.55);
|
|
1102
1194
|
const projectW = avail - titleW;
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
);
|
|
1113
|
-
}
|
|
1195
|
+
const titleTxt = pad(trunc(title, titleW), titleW);
|
|
1196
|
+
const projTxt = pad(trunc(project, projectW), projectW);
|
|
1197
|
+
// On the selected line every part gets an explicit color (see selLine).
|
|
1198
|
+
const muted = t => isSel ? fgc('37', t) : dim(t);
|
|
1199
|
+
const styled = fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
|
|
1200
|
+
(s.title ? groupTitle(titleTxt, isSel ? '1;92' : '92') : muted(titleTxt)) +
|
|
1201
|
+
` ${fgc('33', projTxt)} ${muted(meta)}`;
|
|
1202
|
+
|
|
1203
|
+
out.push(isSel ? selLine(styled, `${mark} ${cfgTag}${titleTxt} ${projTxt} ${meta}`, W) : ` ${styled}`);
|
|
1114
1204
|
si++;
|
|
1115
1205
|
}
|
|
1116
1206
|
}
|
|
@@ -1123,7 +1213,18 @@ class TUI {
|
|
|
1123
1213
|
for (let r = 0; r < this.previewH - 1; r++) out.push(mv(top + 1 + r, 1), clrLine, lines[r] || '');
|
|
1124
1214
|
}
|
|
1125
1215
|
|
|
1216
|
+
// Where to show the terminal cursor when a text field is active, else null.
|
|
1217
|
+
_cursorPos() {
|
|
1218
|
+
const at = (row, prefix, f) => [row, prefix.length + [...f.before].length + 1];
|
|
1219
|
+
if (this.mode === 'search') return at(this.h, ' SEARCH: ', this.searchField);
|
|
1220
|
+
if (this.mode === 'rename') return at(this.h, ' RENAME: ', this.renameField);
|
|
1221
|
+
const p = this.mode === 'move' ? this.movePicker : this.mode === 'new' ? this.newPicker : null;
|
|
1222
|
+
if (p && p.step === 'input') return at(4, ` ${p._inputPrompt} `, p.field);
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1126
1226
|
_footer(W) {
|
|
1227
|
+
this._footerIsStatus = false;
|
|
1127
1228
|
const sel = this.selectedSession();
|
|
1128
1229
|
const total = this.sessionItems().length;
|
|
1129
1230
|
const pick = p => p.step === 'profile' ? ` ↑↓/jk=navigate Enter=select profile Esc=cancel`
|
|
@@ -1137,14 +1238,18 @@ class TUI {
|
|
|
1137
1238
|
}
|
|
1138
1239
|
case 'move': return pick(this.movePicker);
|
|
1139
1240
|
case 'new': return pick(this.newPicker);
|
|
1140
|
-
case 'search': return ` SEARCH: ${this.
|
|
1141
|
-
case 'rename': return ` RENAME: ${this.
|
|
1241
|
+
case 'search': return ` SEARCH: ${this.searchField.text} ↑↓=select Enter=open Tab=keep filter Esc=cancel`;
|
|
1242
|
+
case 'rename': return ` RENAME: ${this.renameField.text} Enter=save Esc=cancel`;
|
|
1142
1243
|
case 'help': return ` Press any key to close help`;
|
|
1143
1244
|
}
|
|
1144
|
-
if (this.statusMsg) return ` ${this.statusMsg}`;
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
1245
|
+
if (this.statusMsg) { this._footerIsStatus = true; return ` ${this.statusMsg}`; }
|
|
1246
|
+
const right = `${total} session${total === 1 ? '' : 's'}${this.marked.size ? ` · ${this.marked.size} marked` : ''} `;
|
|
1247
|
+
let left = '';
|
|
1248
|
+
for (const h of ['Enter=open', '/=search', 'p=preview', 'r=rename', 'm=move', 'd=delete', '?=more', 'q=quit']) {
|
|
1249
|
+
if (`${left} ${h}`.length + right.length + 2 > W) break;
|
|
1250
|
+
left += ` ${h}`;
|
|
1251
|
+
}
|
|
1252
|
+
return left + ' '.repeat(Math.max(2, W - left.length - right.length)) + right;
|
|
1148
1253
|
}
|
|
1149
1254
|
|
|
1150
1255
|
// ── Input ───────────────────────────────────────────────────────────────────
|
|
@@ -1169,25 +1274,33 @@ class TUI {
|
|
|
1169
1274
|
}
|
|
1170
1275
|
|
|
1171
1276
|
if (this.mode === 'search') {
|
|
1172
|
-
if (key === '\x1b')
|
|
1173
|
-
else if (key === '\
|
|
1174
|
-
else if (key === '\
|
|
1175
|
-
else if (
|
|
1277
|
+
if (key === '\x1b') { this.mode = null; this.search = ''; this.rebuild(); }
|
|
1278
|
+
else if (key === '\t') { this.mode = null; }
|
|
1279
|
+
else if (key === '\r') { this.mode = null; const s = this.selectedSession(); if (s) this._open(resumePlan(s)); }
|
|
1280
|
+
else if (key === '\x1b[A' || key === '\x1b[B' || key === '\x1b[5~' || key === '\x1b[6~') {
|
|
1281
|
+
const total = this.sessionItems().length;
|
|
1282
|
+
const delta = { '\x1b[A': -1, '\x1b[B': 1, '\x1b[5~': -this.listH, '\x1b[6~': this.listH }[key];
|
|
1283
|
+
this.selIdx = Math.max(0, Math.min(total - 1, this.selIdx + delta));
|
|
1284
|
+
this.ensureVisible();
|
|
1285
|
+
}
|
|
1286
|
+
else if (this.searchField.handle(key) && this.searchField.text !== this.search) {
|
|
1287
|
+
this.search = this.searchField.text; this.rebuild();
|
|
1288
|
+
}
|
|
1176
1289
|
return true;
|
|
1177
1290
|
}
|
|
1178
1291
|
|
|
1179
1292
|
if (this.mode === 'rename') {
|
|
1180
1293
|
if (key === '\x1b') { this.mode = null; }
|
|
1181
|
-
else if (key === '\x7f' || key === '\b') { this.renameInput = this.renameInput.slice(0, -1); }
|
|
1182
1294
|
else if (key === '\r') {
|
|
1183
1295
|
this.mode = null;
|
|
1184
1296
|
const s = this.selectedSession();
|
|
1185
|
-
|
|
1186
|
-
|
|
1297
|
+
const name = this.renameField.text;
|
|
1298
|
+
if (s && name.trim()) {
|
|
1299
|
+
try { renameSession(s, name); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
|
|
1187
1300
|
catch (e) { this.setStatus(`Rename failed: ${e.message}`, 5000); }
|
|
1188
1301
|
}
|
|
1189
1302
|
}
|
|
1190
|
-
else
|
|
1303
|
+
else this.renameField.handle(key);
|
|
1191
1304
|
return true;
|
|
1192
1305
|
}
|
|
1193
1306
|
|
|
@@ -1201,14 +1314,14 @@ class TUI {
|
|
|
1201
1314
|
|
|
1202
1315
|
if (key === 'q' || key === '\x03') return false;
|
|
1203
1316
|
|
|
1204
|
-
if (key === '/') { this.mode = 'search'; }
|
|
1317
|
+
if (key === '/') { this.searchField.set(this.search); this.mode = 'search'; }
|
|
1205
1318
|
else if (key === '?') { this.mode = 'help'; }
|
|
1206
1319
|
else if (key === '\x1b') { this.search = ''; this.marked.clear(); this.rebuild(); }
|
|
1207
1320
|
else if (key === '\r') { if (s) this._open(resumePlan(s)); }
|
|
1208
1321
|
else if (key === 'o') { if (s) this._open(resumePlan(s), true); }
|
|
1209
1322
|
else if (key === 'p') { this.preview = !this.preview; this.ensureVisible(); }
|
|
1210
1323
|
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.
|
|
1324
|
+
else if (key === 'r') { if (s) { this.renameField.set(s.title || ''); this.mode = 'rename'; } }
|
|
1212
1325
|
else if (key === ' ') {
|
|
1213
1326
|
if (s) { this.marked.has(s.jsonlPath) ? this.marked.delete(s.jsonlPath) : this.marked.add(s.jsonlPath); move(+1); }
|
|
1214
1327
|
}
|
|
@@ -1312,6 +1425,6 @@ if (require.main === module) {
|
|
|
1312
1425
|
module.exports = {
|
|
1313
1426
|
encodePathToDir, projectDirNameFor, tabComplete, resolveProjectPath, moveSession, readSession,
|
|
1314
1427
|
readRecentMessages, renameSession, deleteSessionFiles, emptyTrash, scanConfigDir, sortSessions,
|
|
1315
|
-
detectTerminal, parseArgs, fmtSize, splitKeys, wrapWords, cache,
|
|
1428
|
+
detectTerminal, wtArgs, parseArgs, fmtAgo, fmtSize, splitKeys, wrapWords, cache, LineInput,
|
|
1316
1429
|
};
|
|
1317
1430
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vinh0809it/claude-sessions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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"
|