@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.
Files changed (3) hide show
  1. package/README.md +6 -3
  2. package/index.js +173 -60
  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,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 ? inv(pad(` ${trunc(item.label, W - 4)}`, W)) : ` ${label}`);
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.renameInput = '';
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 lastConfig = null;
1008
+ let group = null;
925
1009
  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;
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
- return lines.slice(-H);
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
- out.push(bgc('40', fgc('37', pad(trunc(this._footer(W), W), W))));
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
- out.push(bold(fgc('96', ` [${item.label}]`)));
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)} ${fmtDate(s.lastModified)}`;
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 - 2 - cfgTag.length - 2 - 2 - meta.length);
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
- 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
- }
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.search}█ Esc=cancel Enter=done`;
1141
- case 'rename': return ` RENAME: ${this.renameInput}█ Enter=save Esc=cancel`;
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 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}`;
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') { 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(); }
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
- if (s && this.renameInput.trim()) {
1186
- try { renameSession(s, this.renameInput); this.reloadAll(); this.setStatus(`Renamed to "${s.title}"`); }
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 if (isPrintable(key)) { this.renameInput += key; }
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.renameInput = s.title || ''; this.mode = 'rename'; } }
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.2.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": "./index.js"
6
+ "claude-sessions": "index.js"
7
7
  },
8
8
  "engines": {
9
9
  "node": ">=18"