@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.
Files changed (3) hide show
  1. package/README.md +6 -3
  2. package/index.js +179 -85
  3. 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.app, and otherwise resumes right in the current terminal (you return to the list when Claude exits). `o` always resumes in place
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
- ? ansi('1;32', s.title)
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
- const bold = s => `${ESC}[1m${s}${ESC}[0m`;
630
- const dim = s => `${ESC}[2m${s}${ESC}[0m`;
631
- const inv = s => `${ESC}[7m${s}${ESC}[0m`;
632
- const fgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
633
- const bgc = (code, s) => `${ESC}[${code}m${s}${ESC}[0m`;
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 titles, messages, paths'],
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.input = '';
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.input = '';
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.input || '~/');
700
- this.input = completed;
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.input.trim();
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 (isPrintable(key)) {
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.input = '~/'; this.completions = []; return true; }
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.input}█`);
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 ? inv(pad(` ${trunc(item.label, W - 4)}`, W)) : ` ${label}`);
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.renameInput = '';
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 lastConfig = null;
991
+ let group = null;
925
992
  for (const s of sortSessions(filtered, this.sort)) {
926
- if (s.configDir !== lastConfig) {
927
- this.items.push({ type: 'config', label: s.configDir });
928
- lastConfig = s.configDir;
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
- const lines = [];
1042
- for (const m of msgs) {
1043
- const tag = m.role === 'user' ? fgc('92', 'you ') : fgc('96', 'cc ');
1044
- const width = Math.max(10, W - 8);
1045
- const chunks = wrapWords(m.text, width);
1046
- chunks.slice(0, 3).forEach((c, i) => lines.push(` ${i === 0 ? tag : ' '} ${i === 2 && chunks.length > 3 ? trunc(c, width - 1) + '…' : c}`));
1047
- }
1048
- if (!lines.length) lines.push(dim(' (no messages)'));
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
- out.push(bgc('40', fgc('37', pad(trunc(this._footer(W), W), W))));
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
- out.push(bold(fgc('96', ` [${item.label}]`)));
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)} ${fmtDate(s.lastModified)}`;
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 - 2 - cfgTag.length - 2 - 2 - meta.length);
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
- if (isSel) {
1105
- out.push(inv(pad(`${mark} ${cfgTag}${pad(trunc(title, titleW), titleW)} ${pad(trunc(project, projectW), projectW)} ${meta}`, W)));
1106
- } else {
1107
- out.push(
1108
- fgc('91', mark) + ' ' + (cfgTag ? fgc('96', cfgTag) : '') +
1109
- (s.title ? fgc('92', pad(trunc(title, titleW), titleW)) : dim(pad(trunc(title, titleW), titleW))) +
1110
- ` ${fgc('33', pad(trunc(project, projectW), projectW))}` +
1111
- ` ${dim(meta)}`
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.search}█ Esc=cancel Enter=done`;
1141
- case 'rename': return ` RENAME: ${this.renameInput}█ Enter=save Esc=cancel`;
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 marks = this.marked.size ? ` ${this.marked.size} marked` : '';
1146
- const ctx = sel ? ` ${dim(trunc(shortenPath(sel.project), Math.max(0, W - 80)))}` : '';
1147
- return ` Enter=open o=here /=search p=preview r=rename m=move d=del ?=help q=quit [${total}]${marks}${ctx}`;
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') { this.mode = null; this.search = ''; this.rebuild(); }
1173
- else if (key === '\r') { this.mode = null; }
1174
- else if (key === '\x7f' || key === '\b') { this.search = this.search.slice(0, -1); this.rebuild(); }
1175
- else if (isPrintable(key)) { this.search += key; this.rebuild(); }
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
- if (s && this.renameInput.trim()) {
1186
- try { renameSession(s, this.renameInput); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
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 if (isPrintable(key)) { this.renameInput += key; }
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.renameInput = s.title || ''; this.mode = 'rename'; } }
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, wrapWords, cache,
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.2.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": "./index.js"
6
+ "claude-sessions": "index.js"
7
7
  },
8
8
  "engines": {
9
9
  "node": ">=18"