claude-starter 1.3.0 → 1.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 +10 -4
  2. package/index.js +244 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -75,7 +75,9 @@ claude-starter
75
75
  | 🔀 | **多种排序** | 时间 / 大小 / 消息数 / 项目 |
76
76
  | 📎 | **复制 ID** | `c` 一键复制到剪贴板 |
77
77
  | 🔒 | **权限模式** | `m` 设置权限模式,`d` 一键 danger 模式恢复 |
78
+ | ✏️ | **重命名会话** | `r` 直接重命名,支持中文输入 |
78
79
  | 🗑️ | **删除会话** | `x` 删除不需要的会话 |
80
+ | ⌨️ | **Vim 快捷键** | `j`/`k` 上下,`g`/`G` 跳顶/底 |
79
81
  | 🧠 | **智能 CLI** | 自动检测 `mai-claude` / `claude` |
80
82
  | 🔐 | **完全本地** | 不联网,不上传,不追踪 |
81
83
 
@@ -110,11 +112,12 @@ claude-starter --help # 显示帮助信息
110
112
 
111
113
  | 按键 | 功能 |
112
114
  |:---:|------|
113
- | `↑` `↓` | 上下导航 |
115
+ | `↑` `↓` / `j` `k` | 上下导航 |
114
116
  | `Enter` | 新建 / 恢复对话 |
115
117
  | `n` | 直接新建 |
116
118
  | `d` | Danger 模式恢复(bypassPermissions) |
117
119
  | `m` | 权限模式选择器 |
120
+ | `r` | 重命名会话 |
118
121
  | `/` | 搜索 |
119
122
  | `Backspace` | 删除搜索字符,删空自动退出 |
120
123
  | `Esc` | 清空搜索 |
@@ -122,7 +125,7 @@ claude-starter --help # 显示帮助信息
122
125
  | `s` | 切换排序(时间/大小/消息数/项目) |
123
126
  | `c` | 复制 Session ID |
124
127
  | `x` / `Delete` | 删除会话 |
125
- | `Home` / `End` | 跳到顶 / 底 |
128
+ | `g` / `G` | 跳到顶 / 底 |
126
129
  | `Ctrl-D` / `Ctrl-U` | 翻页 |
127
130
  | `q` / `Ctrl-C` | 退出 |
128
131
 
@@ -180,7 +183,9 @@ Searches across **everything** — project names, Git branches, conversation con
180
183
  | 🔀 | **Sort Modes** | Sort by time, size, messages, or project |
181
184
  | 📎 | **Copy ID** | Press `c` to copy session ID |
182
185
  | 🔒 | **Permission Modes** | Press `m` to configure, `d` for quick danger-mode resume |
186
+ | ✏️ | **Rename Sessions** | Press `r` to rename, supports CJK input |
183
187
  | 🗑️ | **Delete Sessions** | Press `x` to remove unwanted sessions |
188
+ | ⌨️ | **Vim Keybindings** | `j`/`k` navigate, `g`/`G` jump to top/bottom |
184
189
  | 🧠 | **Smart CLI** | Auto-detects `mai-claude` vs `claude` |
185
190
  | 🔐 | **100% Local** | No network, no telemetry, no data leaves your machine |
186
191
 
@@ -219,11 +224,12 @@ claude-starter --help # Show help
219
224
 
220
225
  | Key | Action |
221
226
  |:---:|--------|
222
- | `↑` `↓` | Navigate sessions |
227
+ | `↑` `↓` / `j` `k` | Navigate sessions |
223
228
  | `Enter` | Start new / resume selected session |
224
229
  | `n` | New session |
225
230
  | `d` | Resume with bypassPermissions (danger mode) |
226
231
  | `m` | Permission mode picker |
232
+ | `r` | Rename session |
227
233
  | `/` | Search |
228
234
  | `Backspace` | Edit search, auto-exit when empty |
229
235
  | `Esc` | Clear filter |
@@ -231,7 +237,7 @@ claude-starter --help # Show help
231
237
  | `s` | Cycle sort mode (time/size/messages/project) |
232
238
  | `c` | Copy session ID |
233
239
  | `x` / `Delete` | Delete session |
234
- | `Home` / `End` | Jump to first / last |
240
+ | `g` / `G` | Jump to top / bottom |
235
241
  | `Ctrl-D` / `Ctrl-U` | Page down / up |
236
242
  | `q` / `Ctrl-C` | Quit |
237
243
 
package/index.js CHANGED
@@ -206,16 +206,27 @@ function loadSessionQuick(filePath, projectName) {
206
206
  const headBuf = Buffer.alloc(Math.min(8192, stat.size));
207
207
  fs.readSync(fd, headBuf, 0, headBuf.length, 0);
208
208
 
209
- let tailBuf = Buffer.alloc(0);
209
+ // Read tail with progressive expansion: start at 32KB, grow up to 256KB
210
+ // until we find a JSON line with a top-level timestamp (to get accurate lastTs).
211
+ let tailStr = '';
210
212
  if (stat.size > 8192) {
211
- const tailSize = Math.min(4096, stat.size - 8192);
212
- tailBuf = Buffer.alloc(tailSize);
213
- fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
213
+ const tailSizes = [32768, 65536, 131072, 262144];
214
+ for (const ts of tailSizes) {
215
+ const tailSize = Math.min(ts, stat.size - 8192);
216
+ const tailBuf = Buffer.alloc(tailSize);
217
+ fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
218
+ tailStr = tailBuf.toString('utf-8');
219
+ // Check if any parseable JSON line has a top-level timestamp
220
+ const hasTopLevelTs = tailStr.split('\n').some(line => {
221
+ try { return !!JSON.parse(line).timestamp; } catch { return false; }
222
+ });
223
+ if (hasTopLevelTs) break;
224
+ if (tailSize >= stat.size - 8192) break; // already read entire file
225
+ }
214
226
  }
215
227
  fs.closeSync(fd);
216
228
 
217
229
  const headStr = headBuf.toString('utf-8');
218
- const tailStr = tailBuf.toString('utf-8');
219
230
 
220
231
  let firstTs = null, lastTs = null;
221
232
  let version = '', gitBranch = '', cwd = '', permissionMode = '';
@@ -520,16 +531,17 @@ function createApp() {
520
531
 
521
532
  function updateFooter() {
522
533
  const keys = [
523
- '{#7aa2f7-fg}{bold}n{/} {#565f89-fg}New{/}',
524
- '{#7aa2f7-fg}{bold}↵{/} {#565f89-fg}Resume{/}',
525
- '{#7aa2f7-fg}{bold}m{/} {#565f89-fg}Mode{/}',
526
- '{#f7768e-fg}{bold}d{/} {#565f89-fg}Danger{/}',
527
- '{#7aa2f7-fg}{bold}/{/} {#565f89-fg}Search{/}',
528
- '{#7aa2f7-fg}{bold}p{/} {#565f89-fg}Project{/}',
529
- '{#7aa2f7-fg}{bold}s{/} {#565f89-fg}Sort{/}',
530
- '{#7aa2f7-fg}{bold}c{/} {#565f89-fg}Copy ID{/}',
531
- '{#f7768e-fg}{bold}x{/} {#565f89-fg}Delete{/}',
532
- '{#7aa2f7-fg}{bold}q{/} {#565f89-fg}Quit{/}',
534
+ '{#9ece6a-fg}{bold}n{/} {#9ece6a-fg}New{/}',
535
+ '{#7aa2f7-fg}{bold}↵{/} {#7aa2f7-fg}Resume{/}',
536
+ '{#bb9af7-fg}{bold}m{/} {#bb9af7-fg}Mode{/}',
537
+ '{#f7768e-fg}{bold}d{/} {#f7768e-fg}Danger{/}',
538
+ '{#e0af68-fg}{bold}/{/} {#e0af68-fg}Search{/}',
539
+ '{#7dcfff-fg}{bold}p{/} {#7dcfff-fg}Project{/}',
540
+ '{#73daca-fg}{bold}s{/} {#73daca-fg}Sort{/}',
541
+ '{#565f89-fg}{bold}c{/} {#565f89-fg}Copy ID{/}',
542
+ '{#ff9e64-fg}{bold}r{/} {#ff9e64-fg}Rename{/}',
543
+ '{#f7768e-fg}{bold}x{/} {#f7768e-fg}Delete{/}',
544
+ '{#565f89-fg}{bold}q{/} {#565f89-fg}Quit{/}',
533
545
  ];
534
546
  footer.setContent(`\n ${keys.join(' {#414868-fg}│{/} ')}`);
535
547
  }
@@ -829,17 +841,15 @@ function createApp() {
829
841
  }
830
842
 
831
843
  screen.key(['down'], () => {
832
- if (popupOpen) return;
833
- if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
844
+ if (renameMode || popupOpen || isSearchMode) return;
834
845
  moveSelection(1);
835
846
  });
836
847
  screen.key(['up'], () => {
837
- if (popupOpen) return;
838
- if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
848
+ if (renameMode || popupOpen || isSearchMode) return;
839
849
  moveSelection(-1);
840
850
  });
841
851
  screen.key(['home'], () => {
842
- if (popupOpen) return;
852
+ if (renameMode || popupOpen) return;
843
853
  if (isSearchMode) { isSearchMode = false; }
844
854
  selectedIndex = -1;
845
855
  suppressSelectEvent = true; listPanel.select(0); suppressSelectEvent = false;
@@ -847,7 +857,7 @@ function createApp() {
847
857
  renderDetail(); updateHeader(); screen.render();
848
858
  });
849
859
  screen.key(['end'], () => {
850
- if (popupOpen) return;
860
+ if (renameMode || popupOpen) return;
851
861
  if (isSearchMode) { isSearchMode = false; }
852
862
  selectedIndex = Math.max(0, filteredSessions.length - 1);
853
863
  suppressSelectEvent = true; listPanel.select(selectedIndex + 1); suppressSelectEvent = false;
@@ -855,31 +865,86 @@ function createApp() {
855
865
  renderDetail(); updateHeader(); screen.render();
856
866
  });
857
867
  screen.key(['pagedown', 'C-d'], () => {
858
- if (popupOpen) return;
868
+ if (renameMode || popupOpen) return;
859
869
  if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
860
870
  moveSelection(Math.floor((listPanel.height || 20) / 2));
861
871
  });
862
872
  screen.key(['pageup', 'C-u'], () => {
863
- if (popupOpen) return;
873
+ if (renameMode || popupOpen) return;
864
874
  if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
865
875
  moveSelection(-Math.floor((listPanel.height || 20) / 2));
866
876
  });
867
877
 
868
878
  // Search
869
879
  screen.key(['/'], () => {
870
- if (isSearchMode) return;
880
+ if (renameMode || isSearchMode) return;
871
881
  isSearchMode = true; filterText = ''; applyFilter();
872
882
  });
873
883
 
874
884
  screen.on('keypress', (ch, key) => {
875
- // Backspace: always works when there's filter text, regardless of search mode
876
- if (key.name === 'backspace' && filterText) {
877
- filterText = filterText.slice(0, -1);
878
- selectedIndex = -1;
879
- isSearchMode = !!filterText; // exit search when empty
880
- applyFilter();
885
+ // ── Rename mode: capture all input ──
886
+ if (renameMode) {
887
+ if (key.name === 'return' || key.name === 'enter') {
888
+ const session = renameSession;
889
+ const value = renameValue;
890
+ closeRename();
891
+ submitRename(session, value);
892
+ return;
893
+ }
894
+ if (key.name === 'escape') {
895
+ closeRename();
896
+ listPanel.focus();
897
+ screen.render();
898
+ return;
899
+ }
900
+ if (key.name === 'backspace') {
901
+ if (renameValue.length > 0) {
902
+ renameValue = [...renameValue].slice(0, -1).join('');
903
+ renderRenameInput();
904
+ }
905
+ return;
906
+ }
907
+ if (ch && ch.length >= 1 && ch.charCodeAt(0) >= 32 && !key.ctrl && !key.meta) {
908
+ renameValue += ch;
909
+ renderRenameInput();
910
+ }
911
+ return; // swallow all keys while in rename mode
912
+ }
913
+
914
+ // Backspace: delete search char, or exit search mode if empty
915
+ if (key.name === 'backspace') {
916
+ if (filterText) {
917
+ filterText = filterText.slice(0, -1);
918
+ selectedIndex = -1;
919
+ isSearchMode = !!filterText;
920
+ applyFilter();
921
+ } else if (isSearchMode) {
922
+ isSearchMode = false;
923
+ applyFilter();
924
+ }
881
925
  return;
882
926
  }
927
+
928
+ // Vim-like navigation (only when NOT in search mode)
929
+ if (!isSearchMode && !popupOpen) {
930
+ if (ch === 'j') { moveSelection(1); return; }
931
+ if (ch === 'k') { moveSelection(-1); return; }
932
+ if (ch === 'G') {
933
+ selectedIndex = Math.max(0, filteredSessions.length - 1);
934
+ suppressSelectEvent = true; listPanel.select(selectedIndex + 1); suppressSelectEvent = false;
935
+ listPanel.childBase = Math.max(0, selectedIndex + 1 - listPanel.height + 1);
936
+ renderDetail(); updateHeader(); screen.render();
937
+ return;
938
+ }
939
+ if (ch === 'g') {
940
+ selectedIndex = -1;
941
+ suppressSelectEvent = true; listPanel.select(0); suppressSelectEvent = false;
942
+ listPanel.childBase = 0;
943
+ renderDetail(); updateHeader(); screen.render();
944
+ return;
945
+ }
946
+ }
947
+
883
948
  if (!isSearchMode) return;
884
949
  if (key.name === 'return' || key.name === 'enter') { isSearchMode = false; renderAll(); return; }
885
950
  if (key.name === 'escape') { isSearchMode = false; filterText = ''; applyFilter(); return; }
@@ -937,7 +1002,23 @@ function createApp() {
937
1002
  child.on('exit', (code) => process.exit(code || 0));
938
1003
  }
939
1004
 
1005
+ // Track the rename confirm popup and its session for Enter handling
1006
+ let renameConfirmPopup = null;
1007
+ let renameConfirmSession = null;
1008
+
940
1009
  screen.key(['enter'], () => {
1010
+ if (renameMode) return;
1011
+ if (renameJustFinished) return;
1012
+ // Handle rename confirm popup Enter
1013
+ if (renameConfirmPopup && popupOpen) {
1014
+ const session = renameConfirmSession;
1015
+ renameConfirmPopup.destroy();
1016
+ renameConfirmPopup = null;
1017
+ renameConfirmSession = null;
1018
+ popupOpen = false;
1019
+ resumeSession(session);
1020
+ return;
1021
+ }
941
1022
  if (isSearchMode) { isSearchMode = false; renderAll(); return; }
942
1023
  if (popupOpen) return;
943
1024
  if (selectedIndex === -1) { startNewSession(); return; }
@@ -947,13 +1028,13 @@ function createApp() {
947
1028
 
948
1029
  // Quick shortcut: n = new session
949
1030
  screen.key(['n'], () => {
950
- if (isSearchMode) return;
1031
+ if (renameMode || isSearchMode) return;
951
1032
  startNewSession();
952
1033
  });
953
1034
 
954
1035
  // Copy session ID
955
1036
  screen.key(['c'], () => {
956
- if (isSearchMode) return;
1037
+ if (renameMode || isSearchMode) return;
957
1038
  if (filteredSessions.length === 0) return;
958
1039
  const sid = filteredSessions[selectedIndex].sessionId;
959
1040
  try {
@@ -1097,14 +1178,14 @@ function createApp() {
1097
1178
 
1098
1179
  // ─── Quick dangerous resume (d key) ────────────────────────────────────
1099
1180
  screen.key(['d'], () => {
1100
- if (isSearchMode || popupOpen) return;
1181
+ if (renameMode || isSearchMode || popupOpen) return;
1101
1182
  if (selectedIndex < 0 || selectedIndex >= filteredSessions.length) return;
1102
1183
  resumeSession(filteredSessions[selectedIndex], 'bypassPermissions');
1103
1184
  });
1104
1185
 
1105
1186
  // ─── Permission mode picker (m key) ───────────────────────────────────
1106
1187
  screen.key(['m'], () => {
1107
- if (isSearchMode || popupOpen) return;
1188
+ if (renameMode || isSearchMode || popupOpen) return;
1108
1189
  if (selectedIndex < 0 || selectedIndex >= filteredSessions.length) return;
1109
1190
  showPermissionModePicker(filteredSessions[selectedIndex]);
1110
1191
  });
@@ -1169,18 +1250,143 @@ function createApp() {
1169
1250
  }
1170
1251
 
1171
1252
  screen.key(['x', 'delete'], () => {
1172
- if (isSearchMode || popupOpen) return;
1253
+ if (renameMode || isSearchMode || popupOpen) return;
1173
1254
  if (selectedIndex < 0 || selectedIndex >= filteredSessions.length) return;
1174
1255
  showDeleteConfirm(filteredSessions[selectedIndex]);
1175
1256
  });
1176
1257
 
1177
- screen.key(['s'], () => { if (!isSearchMode) cycleSort(); });
1178
- screen.key(['p'], () => { if (!isSearchMode) showProjectPicker(); });
1258
+ // ─── Rename Session ───────────────────────────────────────────────────
1259
+ const stringWidth = require('string-width');
1260
+ let renameMode = false;
1261
+ let renameJustFinished = false;
1262
+ let renameValue = '';
1263
+ let renameSession = null;
1264
+ let renamePopup = null;
1265
+ let renameDisplay = null;
1266
+ const renameMaxWidth = 46;
1267
+
1268
+ function renderRenameInput() {
1269
+ let display = renameValue;
1270
+ while (stringWidth(display) > renameMaxWidth && display.length > 0) {
1271
+ display = display.substring(1);
1272
+ }
1273
+ renameDisplay.setContent(display + '▌');
1274
+ screen.render();
1275
+ }
1276
+
1277
+ function showRenameInput(session) {
1278
+ renameSession = session;
1279
+ renameValue = session.customTitle || '';
1280
+
1281
+ renamePopup = blessed.box({
1282
+ parent: screen, top: 'center', left: 'center',
1283
+ width: 52, height: 7,
1284
+ label: ' {bold}{#73daca-fg}Rename Session{/} ',
1285
+ tags: true, border: { type: 'line' },
1286
+ style: {
1287
+ border: { fg: '#73daca' }, bg: '#24283b', fg: '#a9b1d6',
1288
+ label: { fg: '#73daca' },
1289
+ },
1290
+ });
1291
+
1292
+ renameDisplay = blessed.box({
1293
+ parent: renamePopup,
1294
+ top: 1, left: 1, right: 1, height: 1,
1295
+ tags: false,
1296
+ style: { fg: 'white', bg: '#1a1b26' },
1297
+ });
1298
+
1299
+ blessed.box({
1300
+ parent: renamePopup,
1301
+ top: 3, left: 1, right: 1, height: 1,
1302
+ tags: true,
1303
+ style: { bg: '#24283b' },
1304
+ content: ' {#9ece6a-fg}{bold}Enter{/}{#a9b1d6-fg} Save {/}{#565f89-fg}Esc{/}{#a9b1d6-fg} Cancel{/}',
1305
+ });
1306
+
1307
+ popupOpen = true;
1308
+ renameMode = true;
1309
+ renderRenameInput();
1310
+ }
1311
+
1312
+ function closeRename() {
1313
+ renameMode = false;
1314
+ if (renamePopup) { renamePopup.destroy(); renamePopup = null; }
1315
+ popupOpen = false;
1316
+ renameSession = null;
1317
+ renameDisplay = null;
1318
+ }
1319
+
1320
+ function submitRename(session, newTitle) {
1321
+ newTitle = (newTitle || '').trim();
1322
+
1323
+ // Save to meta
1324
+ if (!meta.sessions[session.sessionId]) meta.sessions[session.sessionId] = {};
1325
+ meta.sessions[session.sessionId].customTitle = newTitle || undefined;
1326
+ if (!newTitle) delete meta.sessions[session.sessionId].customTitle;
1327
+ saveMeta(meta);
1328
+
1329
+ // Update in-memory session
1330
+ session.customTitle = newTitle;
1331
+
1332
+ // Also append to JSONL file so Claude Code sees it
1333
+ if (newTitle && fs.existsSync(session.filePath)) {
1334
+ try {
1335
+ const entry = JSON.stringify({ type: 'custom-title', customTitle: newTitle });
1336
+ fs.appendFileSync(session.filePath, '\n' + entry);
1337
+ } catch (e) { /* silently fail */ }
1338
+ }
1339
+
1340
+ renderAll();
1341
+
1342
+ // Ask whether to resume this session after rename
1343
+ // We use renameJustFinished flag to prevent the Enter key from rename
1344
+ // from immediately triggering resume
1345
+ renameJustFinished = true;
1346
+ setTimeout(() => { renameJustFinished = false; }, 200);
1347
+
1348
+ setTimeout(() => {
1349
+ const titleLabel = newTitle ? `{#73daca-fg}${esc(newTitle)}{/}` : '{#565f89-fg}(title cleared){/}';
1350
+ renameConfirmSession = session;
1351
+ renameConfirmPopup = blessed.box({
1352
+ parent: screen, top: 'center', left: 'center',
1353
+ width: 48, height: 8,
1354
+ label: ' {bold}{#9ece6a-fg}Renamed{/} ',
1355
+ tags: true, border: { type: 'line' },
1356
+ style: {
1357
+ border: { fg: '#9ece6a' }, bg: '#24283b', fg: '#a9b1d6',
1358
+ label: { fg: '#9ece6a' },
1359
+ },
1360
+ content: `\n ${titleLabel}\n\n {#9ece6a-fg}{bold}Enter{/}{#a9b1d6-fg} Resume {/}{#565f89-fg}Esc{/}{#a9b1d6-fg} Back to list{/}`,
1361
+ });
1362
+ popupOpen = true;
1363
+ renameConfirmPopup.focus();
1364
+ screen.render();
1365
+
1366
+ renameConfirmPopup.key(['escape', 'q'], () => {
1367
+ renameConfirmPopup.destroy();
1368
+ renameConfirmPopup = null;
1369
+ renameConfirmSession = null;
1370
+ popupOpen = false;
1371
+ renderAll();
1372
+ });
1373
+ }, 50);
1374
+ }
1375
+
1376
+ screen.key(['r'], () => {
1377
+ if (isSearchMode || popupOpen) return;
1378
+ if (selectedIndex < 0 || selectedIndex >= filteredSessions.length) return;
1379
+ showRenameInput(filteredSessions[selectedIndex]);
1380
+ });
1381
+
1382
+ screen.key(['s'], () => { if (!renameMode && !isSearchMode) cycleSort(); });
1383
+ screen.key(['p'], () => { if (!renameMode && !isSearchMode) showProjectPicker(); });
1179
1384
  screen.key(['escape'], () => {
1385
+ if (renameMode) return; // handled in keypress
1180
1386
  if (isSearchMode) { isSearchMode = false; filterText = ''; applyFilter(); return; }
1181
1387
  filterText = ''; selectedIndex = -1; applyFilter();
1182
1388
  });
1183
- screen.key(['q', 'C-c'], () => { process.stdout.write('\x1b[0m'); screen.destroy(); process.exit(0); });
1389
+ screen.key(['q', 'C-c'], () => { if (renameMode) return; process.stdout.write('\x1b[0m'); screen.destroy(); process.exit(0); });
1184
1390
 
1185
1391
  // Remove blessed's built-in wheel handlers (they call select which changes selection)
1186
1392
  listPanel.removeAllListeners('element wheeldown');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-starter",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "A beautiful terminal UI for managing Claude Code sessions — start new or resume past conversations",
5
5
  "main": "index.js",
6
6
  "bin": {