claude-starter 1.1.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 +212 -0
  2. package/index.js +881 -0
  3. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ <p align="center">
2
+ <img src="https://img.shields.io/badge/%F0%9F%9A%80-Claude_Starter-7aa2f7?style=for-the-badge&labelColor=1a1b26" alt="Claude Starter" />
3
+ <br/>
4
+ <img src="https://img.shields.io/badge/node-%3E%3D18-9ece6a?style=flat-square&logo=node.js&logoColor=white" alt="Node.js" />
5
+ <img src="https://img.shields.io/badge/license-MIT-bb9af7?style=flat-square" alt="MIT License" />
6
+ <img src="https://img.shields.io/github/v/release/Bojun-Vvibe/claude-starter?style=flat-square&color=7dcfff" alt="Release" />
7
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-e0af68?style=flat-square" alt="Platform" />
8
+ </p>
9
+
10
+ <h1 align="center">🚀 Claude Starter</h1>
11
+
12
+ <p align="center">
13
+ <strong>Claude Code 的主页。</strong>你的所有会话,一目了然。<br/>
14
+ <strong>Your homepage for Claude Code.</strong> All your sessions, at a glance.
15
+ </p>
16
+
17
+ <p align="center">
18
+ <code>git clone</code>&nbsp;&nbsp;→&nbsp;&nbsp;<code>npm link</code>&nbsp;&nbsp;→&nbsp;&nbsp;<code>start-claude</code>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <img src="./screenshot.svg" alt="Claude Starter Screenshot" width="800" />
23
+ </p>
24
+
25
+ ---
26
+
27
+ # 🇨🇳 中文
28
+
29
+ ## 痛点
30
+
31
+ 用过 Claude Code 的 `/resume` 吗?它给你的是这样一坨东西:
32
+
33
+ ```
34
+ ? Select a conversation
35
+ 3ee0f33a-b882-424f-9ba4-260342e4dd5b - 4/3/2026, 10:53:41 AM
36
+ 87570bab-ee92-4681-9591-54abf2fcb486 - 4/3/2026, 10:18:55 AM
37
+ ...200 个 UUID...
38
+ ```
39
+
40
+ 一堆 UUID,没有上下文,无法搜索。**想找到上周帮你调过 bug 的那个 session?祝你好运。**
41
+
42
+ ## 解决方案
43
+
44
+ **Claude Starter** 是一个精美的终端可视化工具,让你能像浏览网页一样浏览所有 Claude 历史会话。它是你的 **Claude 主页** —— 每次打开终端,`start-claude` 一敲,所有 session 一目了然。
45
+
46
+ ```bash
47
+ start-claude
48
+ ```
49
+
50
+ 精美的分屏 UI,Tokyo Night 配色。左侧列表一目了然,右侧实时预览对话详情。不是 UUID,是你**真正说过的话**。
51
+
52
+ ## 🔍 搜索 — 杀手级功能
53
+
54
+ 按 `/` 开始输入,**就这么简单**。无需按回车。
55
+
56
+ 跨项目名、Git 分支、对话内容**全文实时搜索**。输入即过滤,`↑↓` 直接导航结果。
57
+
58
+ - `auth` → 所有认证相关的对话
59
+ - `refactor` → 上周的代码重构
60
+ - `web-app fix` → 某个项目的 bug 修复
61
+
62
+ **不需要管理模式,不需要确认。输入即搜,方向键即走。**
63
+
64
+ ## 核心能力
65
+
66
+ | | 功能 | 说明 |
67
+ |---|---|---|
68
+ | 🎨 | **精美 TUI** | Tokyo Night 配色,分屏布局,终端里的 App |
69
+ | ✨ | **一键新建** | 列表顶部直接新建对话 |
70
+ | 🔍 | **即时搜索** | `/` 全文搜索,无需回车 |
71
+ | 📂 | **项目过滤** | `p` 按项目筛选 |
72
+ | ⚡ | **秒级恢复** | 选中 → Enter → 回到对话 |
73
+ | 📋 | **对话预览** | 右侧面板展示完整元数据和对话历史 |
74
+ | 🔀 | **多种排序** | 时间 / 大小 / 消息数 / 项目 |
75
+ | 📎 | **复制 ID** | `c` 一键复制到剪贴板 |
76
+ | 🧠 | **智能 CLI** | 自动检测 `mai-claude` / `claude` |
77
+ | 🔒 | **完全本地** | 不联网,不上传,不追踪 |
78
+
79
+ ## 安装
80
+
81
+ ```bash
82
+ git clone https://github.com/Bojun-Vvibe/claude-starter.git
83
+ cd claude-starter
84
+ npm install
85
+ npm link
86
+ ```
87
+
88
+ 然后运行 `start-claude`,就这么简单。
89
+
90
+ ## 快捷键
91
+
92
+ | 按键 | 功能 |
93
+ |:---:|------|
94
+ | `↑` `↓` | 上下导航 |
95
+ | `Enter` | 新建 / 恢复对话 |
96
+ | `n` | 直接新建 |
97
+ | `/` | 搜索 |
98
+ | `Backspace` | 删除搜索字符,删空自动退出 |
99
+ | `Esc` | 清空搜索 |
100
+ | `p` | 按项目过滤 |
101
+ | `s` | 切换排序 |
102
+ | `c` | 复制 Session ID |
103
+ | `Home` / `End` | 跳到顶 / 底 |
104
+ | `Ctrl-D` / `Ctrl-U` | 翻页 |
105
+ | `q` / `Ctrl-C` | 退出 |
106
+
107
+ ## 原理
108
+
109
+ 读取 `~/.claude/projects/` 下的 JSONL 会话文件,解析元数据和对话内容。200 个 session 加载耗时 ~10ms。**所有数据留在本地,不联网。**
110
+
111
+ ---
112
+
113
+ # 🇬🇧 English
114
+
115
+ ## The Problem
116
+
117
+ Claude Code's `/resume` gives you a wall of UUIDs:
118
+
119
+ ```
120
+ ? Select a conversation
121
+ 3ee0f33a-b882-424f-9ba4-260342e4dd5b - 4/3/2026, 10:53:41 AM
122
+ 87570bab-ee92-4681-9591-54abf2fcb486 - 4/3/2026, 10:18:55 AM
123
+ ...200 more UUIDs...
124
+ ```
125
+
126
+ Good luck finding that session where Claude fixed your auth bug last Tuesday.
127
+
128
+ ## The Solution
129
+
130
+ ```bash
131
+ start-claude
132
+ ```
133
+
134
+ Beautiful split-pane UI with Tokyo Night colors. The left panel shows every session with project, time, and topic. The right panel previews the full conversation. Not UUIDs — your **actual words**.
135
+
136
+ ## 🔍 Search — The Killer Feature
137
+
138
+ Press `/` and start typing. **That's it.** No Enter needed.
139
+
140
+ Searches across **everything** — project names, Git branches, conversation content. Results update as you type, `↑↓` to navigate instantly.
141
+
142
+ - `auth` → all auth-related sessions
143
+ - `refactor` → that cleanup from last week
144
+ - `web-app fix` → bug fixes in a specific project
145
+
146
+ **No modes. No confirmation. Just type and go.**
147
+
148
+ ## Features
149
+
150
+ | | Feature | Description |
151
+ |---|---|---|
152
+ | 🎨 | **Beautiful TUI** | Tokyo Night color scheme, split-pane layout, feels native in your terminal |
153
+ | ✨ | **New Session** | Launch a fresh conversation in one keystroke |
154
+ | 🔍 | **Instant Search** | Fuzzy search across everything, no Enter needed |
155
+ | 📂 | **Project Filter** | Press `p` to filter by project |
156
+ | ⚡ | **One-Key Resume** | Arrow, Enter, you're back in the conversation |
157
+ | 📋 | **Session Preview** | Full metadata + conversation history in the right panel |
158
+ | 🔀 | **Sort Modes** | Sort by time, size, messages, or project |
159
+ | 📎 | **Copy ID** | Press `c` to copy session ID |
160
+ | 🧠 | **Smart CLI** | Auto-detects `mai-claude` vs `claude` |
161
+ | 🔒 | **100% Local** | No network, no telemetry, no data leaves your machine |
162
+
163
+ ## Install
164
+
165
+ ```bash
166
+ git clone https://github.com/Bojun-Vvibe/claude-starter.git
167
+ cd claude-starter
168
+ npm install
169
+ npm link
170
+ ```
171
+
172
+ Then run:
173
+
174
+ ```bash
175
+ start-claude
176
+ ```
177
+
178
+ ## Keyboard Shortcuts
179
+
180
+ | Key | Action |
181
+ |:---:|--------|
182
+ | `↑` `↓` | Navigate sessions |
183
+ | `Enter` | Start new / resume selected session |
184
+ | `n` | New session |
185
+ | `/` | Search |
186
+ | `Backspace` | Edit search, auto-exit when empty |
187
+ | `Esc` | Clear filter |
188
+ | `p` | Filter by project |
189
+ | `s` | Cycle sort mode |
190
+ | `c` | Copy session ID |
191
+ | `Home` / `End` | Jump to first / last |
192
+ | `Ctrl-D` / `Ctrl-U` | Page down / up |
193
+ | `q` / `Ctrl-C` | Quit |
194
+
195
+ ## How It Works
196
+
197
+ Reads the JSONL session files from `~/.claude/projects/`, parses metadata and conversation content. 200 sessions load in ~10ms. **Everything stays local. No API calls, no telemetry.**
198
+
199
+ ## Requirements
200
+
201
+ - **Node.js** >= 18
202
+ - **Claude Code** ([`claude`](https://docs.anthropic.com/en/docs/claude-code) in PATH)
203
+
204
+ ## License
205
+
206
+ MIT
207
+
208
+ ---
209
+
210
+ <p align="center">
211
+ <sub>Built with 💜 by <a href="https://github.com/Bojun-Vvibe">Bojun</a> — powered by Claude Code itself</sub>
212
+ </p>
package/index.js ADDED
@@ -0,0 +1,881 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Claude Starter (start-claude)
5
+ * ──────────────────────────────
6
+ * A beautiful TUI for starting new and resuming past Claude Code sessions.
7
+ *
8
+ * Usage:
9
+ * start-claude # Launch interactive TUI
10
+ * start-claude --list # Print sessions as a table (no TUI)
11
+ * start-claude --list N # Print the latest N sessions
12
+ *
13
+ * Keyboard shortcuts (TUI mode):
14
+ * ↑/↓ Navigate sessions
15
+ * Enter Start new / resume selected session
16
+ * / Start search (fuzzy filter)
17
+ * Esc Clear search / cancel
18
+ * p Filter by project (popup)
19
+ * s Cycle sort: time → size → messages → project
20
+ * n Start new session
21
+ * Home / End Jump to top / bottom
22
+ * Ctrl-D/U Page down / up
23
+ * c Copy session ID to clipboard
24
+ * q / Ctrl-C Quit
25
+ */
26
+
27
+ const blessed = require('blessed');
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const { spawn, execSync } = require('child_process');
31
+ const os = require('os');
32
+
33
+ // ─── CLI Detection ──────────────────────────────────────────────────────────
34
+ // Detect whether `mai-claude` is available (binary, alias, or function).
35
+ // We check inside an interactive shell so aliases defined in .bashrc/.zshrc
36
+ // are visible. Falls back to plain `claude`.
37
+ //
38
+ // Returns { name, cmd } where:
39
+ // name = display label ("mai-claude" or "claude")
40
+ // cmd = the actual command string to spawn (resolves aliases)
41
+
42
+ function detectCLI() {
43
+ const shell = process.env.SHELL || '/bin/sh';
44
+ try {
45
+ const raw = execSync(`${shell} -ic "command -v mai-claude" 2>/dev/null`, {
46
+ stdio: ['pipe', 'pipe', 'pipe'],
47
+ timeout: 3000,
48
+ }).toString().trim();
49
+
50
+ // Interactive shells may print extra lines (e.g. "Restored session: …").
51
+ // The relevant output is the last line(s) containing the alias or path.
52
+ const lines = raw.split('\n');
53
+ const aliasLine = lines.find(l => l.startsWith('alias ')) || lines[lines.length - 1];
54
+
55
+ // `command -v` for an alias returns: alias mai-claude='actual command'
56
+ // Extract the real command from inside the quotes if it's an alias.
57
+ const aliasMatch = aliasLine.match(/^alias [^=]+=(?:'(.+)'|"(.+)")$/s);
58
+ if (aliasMatch) {
59
+ return { name: 'mai-claude', cmd: aliasMatch[1] || aliasMatch[2] };
60
+ }
61
+ // Otherwise it's a binary/function path — use the name directly
62
+ return { name: 'mai-claude', cmd: 'mai-claude' };
63
+ } catch {
64
+ return { name: 'claude', cmd: 'claude' };
65
+ }
66
+ }
67
+
68
+ const CLI = detectCLI();
69
+
70
+ // ─── Color Palette (Tokyo Night) ─────────────────────────────────────────────
71
+ const PROJECT_COLORS = [
72
+ '#7aa2f7', '#bb9af7', '#7dcfff', '#9ece6a',
73
+ '#e0af68', '#f7768e', '#73daca', '#ff9e64',
74
+ ];
75
+
76
+ // ─── Paths ───────────────────────────────────────────────────────────────────
77
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
78
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
79
+
80
+ // ─── Data Layer ──────────────────────────────────────────────────────────────
81
+
82
+ function getProjectDisplayName(dirName) {
83
+ return dirName
84
+ .replace(/-Users-[^-]+-Desktop-MSProject-/, '')
85
+ .replace(/-Users-[^-]+-Desktop-/, '')
86
+ .replace(/-Users-[^-]+/, '~')
87
+ .replace(/^-/, '') || '~';
88
+ }
89
+
90
+ function loadSessionQuick(filePath, projectName) {
91
+ const sessionId = path.basename(filePath, '.jsonl');
92
+ const stat = fs.statSync(filePath);
93
+
94
+ const fd = fs.openSync(filePath, 'r');
95
+ const headBuf = Buffer.alloc(Math.min(8192, stat.size));
96
+ fs.readSync(fd, headBuf, 0, headBuf.length, 0);
97
+
98
+ let tailBuf = Buffer.alloc(0);
99
+ if (stat.size > 8192) {
100
+ const tailSize = Math.min(4096, stat.size - 8192);
101
+ tailBuf = Buffer.alloc(tailSize);
102
+ fs.readSync(fd, tailBuf, 0, tailSize, stat.size - tailSize);
103
+ }
104
+ fs.closeSync(fd);
105
+
106
+ const headStr = headBuf.toString('utf-8');
107
+ const tailStr = tailBuf.toString('utf-8');
108
+
109
+ let firstTs = null, lastTs = null;
110
+ let version = '', gitBranch = '', cwd = '';
111
+ let firstUserMsg = '';
112
+ let userMsgCount = 0;
113
+
114
+ const headLines = headStr.split('\n').filter(Boolean);
115
+ for (const line of headLines) {
116
+ try {
117
+ const d = JSON.parse(line);
118
+ const ts = d.timestamp;
119
+ if (ts && !firstTs) firstTs = ts;
120
+ if (ts) lastTs = ts;
121
+ if (!version && d.version) version = d.version;
122
+ if (!gitBranch && d.gitBranch) gitBranch = d.gitBranch;
123
+ if (!cwd && d.cwd) cwd = d.cwd;
124
+ if (d.type === 'user') {
125
+ userMsgCount++;
126
+ if (!firstUserMsg) firstUserMsg = extractUserText(d);
127
+ }
128
+ } catch (e) { /* partial line */ }
129
+ }
130
+
131
+ if (tailStr) {
132
+ const tailLines = tailStr.split('\n').filter(Boolean);
133
+ for (const line of tailLines) {
134
+ try {
135
+ const d = JSON.parse(line);
136
+ if (d.timestamp) lastTs = d.timestamp;
137
+ if (d.type === 'user') userMsgCount++;
138
+ } catch (e) { /* partial line */ }
139
+ }
140
+ }
141
+
142
+ const estimatedMessages = Math.max(userMsgCount, Math.ceil(stat.size / 500 * 0.3));
143
+
144
+ let durationStr = '';
145
+ if (firstTs && lastTs) {
146
+ const diffMs = new Date(lastTs).getTime() - new Date(firstTs).getTime();
147
+ if (diffMs > 0) {
148
+ const hours = Math.floor(diffMs / 3600000);
149
+ const minutes = Math.floor((diffMs % 3600000) / 60000);
150
+ durationStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
151
+ }
152
+ }
153
+
154
+ let topic = firstUserMsg.replace(/\n/g, ' ').trim();
155
+ if (topic.length > 120) topic = topic.substring(0, 120) + '…';
156
+
157
+ return {
158
+ sessionId, project: projectName,
159
+ topic: topic || '(no user messages)',
160
+ firstTs, lastTs, version, gitBranch, cwd,
161
+ fileSize: stat.size, duration: durationStr,
162
+ estimatedMessages, filePath, _detailLoaded: false,
163
+ };
164
+ }
165
+
166
+ function extractUserText(d) {
167
+ const msg = d.message;
168
+ if (!msg || typeof msg !== 'object') return '';
169
+ const content = msg.content;
170
+ let text = '';
171
+ if (Array.isArray(content)) {
172
+ for (const c of content) {
173
+ if (c && c.type === 'text') { text = c.text || ''; break; }
174
+ }
175
+ } else if (typeof content === 'string') {
176
+ text = content;
177
+ }
178
+ if (text.startsWith('<local-command') || text.startsWith('<command-')) return '';
179
+ return text;
180
+ }
181
+
182
+ function loadSessionDetail(session) {
183
+ if (session._detailLoaded) return session;
184
+ const lines = fs.readFileSync(session.filePath, 'utf-8').split('\n').filter(Boolean);
185
+
186
+ let userMessages = [], assistantSnippets = [], totalMessages = 0;
187
+ let toolsUsed = new Set();
188
+
189
+ for (const line of lines) {
190
+ try {
191
+ const d = JSON.parse(line);
192
+ if (d.type === 'user') {
193
+ totalMessages++;
194
+ const text = extractUserText(d);
195
+ if (text) userMessages.push(text.substring(0, 300));
196
+ }
197
+ if (d.type === 'assistant') {
198
+ totalMessages++;
199
+ const msg = d.message;
200
+ if (msg && typeof msg === 'object' && Array.isArray(msg.content)) {
201
+ for (const c of msg.content) {
202
+ if (c && c.type === 'text' && c.text) {
203
+ assistantSnippets.push(c.text.substring(0, 400));
204
+ break;
205
+ }
206
+ if (c && c.type === 'tool_use') toolsUsed.add(c.name || 'unknown');
207
+ }
208
+ }
209
+ }
210
+ if (d.type === 'tool_use') toolsUsed.add(d.name || 'unknown');
211
+ } catch (e) { /* skip */ }
212
+ }
213
+
214
+ session.userMessages = userMessages;
215
+ session.assistantSnippets = assistantSnippets;
216
+ session.totalMessages = totalMessages;
217
+ session.estimatedMessages = totalMessages;
218
+ session.toolsUsed = Array.from(toolsUsed);
219
+ session._detailLoaded = true;
220
+
221
+ if (userMessages.length > 0) {
222
+ let topic = userMessages[0].replace(/\n/g, ' ').trim();
223
+ if (topic.length > 120) topic = topic.substring(0, 120) + '…';
224
+ session.topic = topic;
225
+ }
226
+ return session;
227
+ }
228
+
229
+ function loadAllSessions() {
230
+ const sessions = [];
231
+ if (!fs.existsSync(PROJECTS_DIR)) return sessions;
232
+
233
+ const projDirs = fs.readdirSync(PROJECTS_DIR).filter(d => {
234
+ try { return fs.statSync(path.join(PROJECTS_DIR, d)).isDirectory() && d !== '.'; }
235
+ catch { return false; }
236
+ });
237
+
238
+ for (const projDir of projDirs) {
239
+ const projectName = getProjectDisplayName(projDir);
240
+ const projPath = path.join(PROJECTS_DIR, projDir);
241
+ const files = fs.readdirSync(projPath).filter(f => f.endsWith('.jsonl'));
242
+ for (const file of files) {
243
+ try {
244
+ const session = loadSessionQuick(path.join(projPath, file), projectName);
245
+ if (session.firstTs) sessions.push(session);
246
+ } catch (e) { /* skip */ }
247
+ }
248
+ }
249
+
250
+ sessions.sort((a, b) => {
251
+ const ta = a.lastTs ? new Date(a.lastTs).getTime() : 0;
252
+ const tb = b.lastTs ? new Date(b.lastTs).getTime() : 0;
253
+ return tb - ta;
254
+ });
255
+ return sessions;
256
+ }
257
+
258
+ // ─── Formatting Helpers ──────────────────────────────────────────────────────
259
+
260
+ function formatTimestamp(ts) {
261
+ if (!ts) return 'unknown';
262
+ const d = new Date(ts);
263
+ const now = new Date();
264
+ const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000);
265
+ const time = d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
266
+ if (diffDays === 0) return `Today ${time}`;
267
+ if (diffDays === 1) return `Yesterday ${time}`;
268
+ if (diffDays < 7) return `${diffDays}d ago ${time}`;
269
+ if (diffDays < 365) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
270
+ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
271
+ }
272
+
273
+ function formatFileSize(bytes) {
274
+ if (bytes < 1024) return `${bytes}B`;
275
+ if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}K`;
276
+ return `${(bytes / 1048576).toFixed(1)}M`;
277
+ }
278
+
279
+ function getProjectColor(projectName, colorMap) {
280
+ if (!colorMap.has(projectName)) {
281
+ colorMap.set(projectName, PROJECT_COLORS[colorMap.size % PROJECT_COLORS.length]);
282
+ }
283
+ return colorMap.get(projectName);
284
+ }
285
+
286
+ function esc(text) {
287
+ return text.replace(/\{/g, '\\{');
288
+ }
289
+
290
+ // ─── CLI Mode (--list) ───────────────────────────────────────────────────────
291
+
292
+ function runListMode(limit) {
293
+ const sessions = loadAllSessions();
294
+ const display = sessions.slice(0, limit || 30);
295
+ const C = {
296
+ reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
297
+ cyan: '\x1b[36m', yellow: '\x1b[33m', green: '\x1b[32m',
298
+ magenta: '\x1b[35m', blue: '\x1b[34m', white: '\x1b[37m',
299
+ };
300
+ console.log(`\n${C.cyan}${C.bold}🚀 Claude Sessions${C.reset} ${C.dim}(${sessions.length} total, showing ${display.length})${C.reset}\n`);
301
+ console.log(`${C.dim}${'─'.repeat(100)}${C.reset}`);
302
+ console.log(`${C.bold}${'#'.padStart(3)} ${'Time'.padEnd(18)} ${'Project'.padEnd(18)} ${'Branch'.padEnd(22)} ${'Msgs'.padStart(5)} ${'Size'.padStart(6)} Topic${C.reset}`);
303
+ console.log(`${C.dim}${'─'.repeat(100)}${C.reset}`);
304
+ display.forEach((s, i) => {
305
+ console.log(`${C.dim}${`${i+1}`.padStart(3)}${C.reset} ${C.yellow}${formatTimestamp(s.lastTs).padEnd(18)}${C.reset} ${C.magenta}${s.project.substring(0,17).padEnd(18)}${C.reset} ${C.green}${(s.gitBranch||'').substring(0,21).padEnd(22)}${C.reset} ${C.blue}${`${s.estimatedMessages}`.padStart(5)}${C.reset} ${C.dim}${formatFileSize(s.fileSize).padStart(6)}${C.reset} ${C.white}${s.topic.substring(0,40)}${C.reset}`);
306
+ });
307
+ console.log(`${C.dim}${'─'.repeat(100)}${C.reset}`);
308
+ console.log(`\n${C.dim}Resume: ${C.cyan}${CLI.name} --resume <session-id>${C.reset}\n`);
309
+ }
310
+
311
+ // ─── TUI Application ────────────────────────────────────────────────────────
312
+
313
+ function createApp() {
314
+ const allSessions = loadAllSessions();
315
+ let filteredSessions = [...allSessions];
316
+ let selectedIndex = -1; // -1 = "New Session", 0+ = session index
317
+ let filterText = '';
318
+ let isSearchMode = false;
319
+ let sortMode = 'time';
320
+
321
+ const projectColorMap = new Map();
322
+ const uniqueProjects = [...new Set(allSessions.map(s => s.project))];
323
+ uniqueProjects.forEach(p => getProjectColor(p, projectColorMap));
324
+
325
+ // ─── Screen ────────────────────────────────────────────────────────────
326
+ const screen = blessed.screen({
327
+ smartCSR: true,
328
+ title: 'Claude Starter',
329
+ fullUnicode: true,
330
+ autoPadding: true,
331
+ });
332
+
333
+ // ─── Header ────────────────────────────────────────────────────────────
334
+ const header = blessed.box({
335
+ parent: screen, top: 0, left: 0, width: '100%', height: 3,
336
+ tags: true, style: { fg: 'white', bg: '#1a1b26' },
337
+ });
338
+
339
+ function updateHeader() {
340
+ const title = '{bold}{#7aa2f7-fg}🚀 Claude Starter{/}';
341
+ const count = `{#9ece6a-fg}${filteredSessions.length}{/}{#565f89-fg}/${allSessions.length} sessions{/}`;
342
+ const proj = `{#bb9af7-fg}${uniqueProjects.length}{/}{#565f89-fg} projects{/}`;
343
+ const sort = `{#73daca-fg}↕${sortMode}{/}`;
344
+ const search = isSearchMode
345
+ ? `{#e0af68-fg}/ ${filterText}▌{/}`
346
+ : (filterText ? `{#e0af68-fg}/ ${filterText}{/}` : '');
347
+ header.setContent(`\n ${title} {#414868-fg}│{/} ${count} {#414868-fg}│{/} ${proj} {#414868-fg}│{/} ${sort}${search ? ` {#414868-fg}│{/} ${search}` : ''}`);
348
+ }
349
+
350
+ blessed.line({ parent: screen, top: 3, left: 0, width: '100%', orientation: 'horizontal', style: { fg: '#414868' } });
351
+
352
+ // ─── Left Panel: blessed.list for correct scroll tracking ──────────────
353
+ const listPanel = blessed.list({
354
+ parent: screen,
355
+ top: 4, left: 0, width: '50%', height: '100%-7',
356
+ tags: true,
357
+ scrollable: true,
358
+ alwaysScroll: true,
359
+ scrollbar: { ch: '▐', style: { fg: '#565f89' } },
360
+ style: {
361
+ bg: '#1a1b26',
362
+ fg: '#a9b1d6',
363
+ selected: { bg: '#3d59a1', fg: 'white', bold: true },
364
+ },
365
+ keys: false,
366
+ vi: false,
367
+ mouse: true,
368
+ interactive: true,
369
+ });
370
+
371
+ blessed.line({ parent: screen, top: 4, left: '50%', height: '100%-7', orientation: 'vertical', style: { fg: '#414868' } });
372
+
373
+ // ─── Right Panel ───────────────────────────────────────────────────────
374
+ const detailPanel = blessed.box({
375
+ parent: screen,
376
+ top: 4, left: '50%+1', width: '50%-1', height: '100%-7',
377
+ tags: true, scrollable: true, alwaysScroll: true,
378
+ scrollbar: { ch: '▐', style: { fg: '#565f89' } },
379
+ style: { bg: '#1a1b26' },
380
+ mouse: true,
381
+ });
382
+
383
+ blessed.line({ parent: screen, bottom: 2, left: 0, width: '100%', orientation: 'horizontal', style: { fg: '#414868' } });
384
+
385
+ // ─── Footer ────────────────────────────────────────────────────────────
386
+ const footer = blessed.box({
387
+ parent: screen, bottom: 0, left: 0, width: '100%', height: 2,
388
+ tags: true, style: { fg: '#a9b1d6', bg: '#1a1b26' },
389
+ });
390
+
391
+ function updateFooter() {
392
+ const keys = [
393
+ '{#7aa2f7-fg}{bold}↵{/} {#565f89-fg}Start/Resume{/}',
394
+ '{#7aa2f7-fg}{bold}n{/} {#565f89-fg}New{/}',
395
+ '{#7aa2f7-fg}{bold}/{/} {#565f89-fg}Search{/}',
396
+ '{#7aa2f7-fg}{bold}↑/↓{/} {#565f89-fg}Nav{/}',
397
+ '{#7aa2f7-fg}{bold}p{/} {#565f89-fg}Project{/}',
398
+ '{#7aa2f7-fg}{bold}s{/} {#565f89-fg}Sort{/}',
399
+ '{#7aa2f7-fg}{bold}c{/} {#565f89-fg}Copy ID{/}',
400
+ '{#7aa2f7-fg}{bold}q{/} {#565f89-fg}Quit{/}',
401
+ ];
402
+ footer.setContent(`\n ${keys.join(' {#414868-fg}│{/} ')}`);
403
+ }
404
+
405
+ // ─── Build list items from sessions ────────────────────────────────────
406
+ function buildListItems() {
407
+ const listW = Math.floor((screen.width || 100) / 2) - 2;
408
+
409
+ return filteredSessions.map((session) => {
410
+ const color = getProjectColor(session.project, projectColorMap);
411
+ const proj = `{${color}-fg}${session.project.substring(0, 14).padEnd(14)}{/}`;
412
+ const time = `{#e0af68-fg}${formatTimestamp(session.lastTs).padEnd(18)}{/}`;
413
+ const msgs = `{#7aa2f7-fg}${String(session.estimatedMessages).padStart(4)}{/}{#565f89-fg}msg{/}`;
414
+ const size = `{#565f89-fg}${formatFileSize(session.fileSize).padStart(6)}{/}`;
415
+
416
+ const topicMaxLen = Math.max(20, listW - 2);
417
+ let topic = session.topic;
418
+ if (topic.length > topicMaxLen) topic = topic.substring(0, topicMaxLen) + '…';
419
+
420
+ const branch = session.gitBranch
421
+ ? `{#73daca-fg}${session.gitBranch.substring(0, 25)}{/}`
422
+ : '';
423
+ const dur = session.duration ? `{#565f89-fg}⏱${session.duration}{/}` : '';
424
+
425
+ // Compose a multi-line string for each list item.
426
+ // blessed.list renders each item as a single row, so we pack info densely.
427
+ // Line: project | time | msgs | size
428
+ // (topic + branch shown on next visual line via padding trick)
429
+ let line1 = ` ${proj} ${time} ${msgs} ${size}`;
430
+ let line2 = ` {#a9b1d6-fg}${esc(topic)}{/}`;
431
+ let line3 = branch ? ` ${branch} ${dur}` : (dur ? ` ${dur}` : '');
432
+
433
+ // blessed.list items are single-line, but we can use \n inside them
434
+ // if the list height per item supports it. Unfortunately blessed.list
435
+ // doesn't natively support multi-line items well.
436
+ //
437
+ // So we use a compact two-line format:
438
+ return `${line1}\n${line2}${line3 ? '\n' + line3 : ''}`;
439
+ });
440
+ }
441
+
442
+ // ─── Populate list ─────────────────────────────────────────────────────
443
+ // Index 0 = "New Session", index 1+ = sessions
444
+ const NEW_SESSION_LABEL = ' {#9ece6a-fg}{bold}✨ New Conversation{/}';
445
+
446
+ function refreshList() {
447
+ const listW = Math.floor((screen.width || 100) / 2) - 2;
448
+
449
+ const sessionItems = filteredSessions.map((session) => {
450
+ const color = getProjectColor(session.project, projectColorMap);
451
+ const proj = `{${color}-fg}${session.project.substring(0, 13).padEnd(13)}{/}`;
452
+ const time = `{#e0af68-fg}${formatTimestamp(session.lastTs).padEnd(16)}{/}`;
453
+ const msgs = `{#7aa2f7-fg}${String(session.estimatedMessages).padStart(4)}{/}{#565f89-fg}m{/}`;
454
+
455
+ const fixedLen = 13 + 1 + 16 + 1 + 5 + 2 + 3;
456
+ const topicMaxLen = Math.max(15, listW - fixedLen);
457
+ let topic = session.topic;
458
+ if (topic.length > topicMaxLen) topic = topic.substring(0, topicMaxLen) + '…';
459
+
460
+ return ` ${proj} ${time} ${msgs} {#a9b1d6-fg}${esc(topic)}{/}`;
461
+ });
462
+
463
+ const items = [NEW_SESSION_LABEL, ...sessionItems];
464
+
465
+ listPanel.setItems(items);
466
+ listPanel.select(selectedIndex + 1); // +1 because index 0 is "New Session"
467
+ screen.render();
468
+ }
469
+
470
+ // ─── Render Detail Panel ───────────────────────────────────────────────
471
+ function renderDetail() {
472
+ if (selectedIndex === -1) {
473
+ const cli = CLI.name;
474
+ let c = '';
475
+ c += `\n {#9ece6a-fg}{bold}✨ Start a New Conversation{/}\n`;
476
+ c += ` {#414868-fg}${'─'.repeat(44)}{/}\n\n`;
477
+ c += ` {#a9b1d6-fg}Open a fresh Claude session and start{/}\n`;
478
+ c += ` {#a9b1d6-fg}coding from scratch.{/}\n\n`;
479
+ c += ` {#565f89-fg}Working Dir{/} {#7dcfff-fg}${process.cwd()}{/}\n`;
480
+ c += ` {#565f89-fg}CLI{/} {#73daca-fg}${cli}{/}\n`;
481
+ c += ` {#565f89-fg}Command{/} {#565f89-fg}${cli}{/}\n\n`;
482
+ c += ` {#414868-fg}${'─'.repeat(44)}{/}\n`;
483
+ c += ` {#9ece6a-fg}{bold}↵ Enter{/}{#9ece6a-fg} or {/}{#9ece6a-fg}{bold}n{/}{#9ece6a-fg} to launch{/}\n`;
484
+ detailPanel.setContent(c);
485
+ detailPanel.setScroll(0);
486
+ return;
487
+ }
488
+
489
+ if (filteredSessions.length === 0 || !filteredSessions[selectedIndex]) {
490
+ detailPanel.setContent('\n {#565f89-fg}No session selected{/}');
491
+ return;
492
+ }
493
+
494
+ const session = filteredSessions[selectedIndex];
495
+ loadSessionDetail(session);
496
+
497
+ const color = getProjectColor(session.project, projectColorMap);
498
+ let c = '';
499
+ const sep = ` {#414868-fg}${'─'.repeat(44)}{/}`;
500
+
501
+ c += `\n {${color}-fg}{bold}█ ${session.project}{/}\n`;
502
+ c += sep + '\n\n';
503
+
504
+ const fields = [
505
+ ['Session', `{#7dcfff-fg}${session.sessionId}{/}`],
506
+ ['Started', `{#e0af68-fg}${session.firstTs ? new Date(session.firstTs).toLocaleString() : '?'}{/}`],
507
+ ['Last active', `{#e0af68-fg}${session.lastTs ? new Date(session.lastTs).toLocaleString() : '?'}{/}`],
508
+ ['Duration', `{#9ece6a-fg}${session.duration || '<1m'}{/}`],
509
+ ['Messages', `{#7aa2f7-fg}${session.totalMessages || session.estimatedMessages}{/}`],
510
+ ['Size', `{#bb9af7-fg}${formatFileSize(session.fileSize)}{/}`],
511
+ ];
512
+ if (session.gitBranch) fields.push(['Branch', `{#73daca-fg} ${session.gitBranch}{/}`]);
513
+ if (session.version) fields.push(['Claude', `{#565f89-fg}v${session.version}{/}`]);
514
+ if (session.cwd) fields.push(['Directory', `{#565f89-fg}${session.cwd}{/}`]);
515
+
516
+ for (const [label, value] of fields) {
517
+ c += ` {#565f89-fg}${label.padEnd(12)}{/} ${value}\n`;
518
+ }
519
+
520
+ if (session.toolsUsed && session.toolsUsed.length > 0) {
521
+ c += `\n {#7dcfff-fg}{bold}Tools Used{/}\n`;
522
+ const chips = session.toolsUsed.slice(0, 10).map(t => `{#414868-fg}[{/}{#7dcfff-fg}${t}{/}{#414868-fg}]{/}`).join(' ');
523
+ c += ` ${chips}\n`;
524
+ if (session.toolsUsed.length > 10) c += ` {#565f89-fg}+${session.toolsUsed.length - 10} more{/}\n`;
525
+ }
526
+
527
+ c += `\n {#bb9af7-fg}{bold}💬 Conversation{/}\n`;
528
+ c += sep + '\n';
529
+
530
+ const msgs = (session.userMessages || []).slice(0, 10);
531
+ const assists = (session.assistantSnippets || []);
532
+
533
+ if (msgs.length === 0) {
534
+ c += `\n {#565f89-fg}(no readable messages){/}\n`;
535
+ } else {
536
+ msgs.forEach((msg, i) => {
537
+ const clean = esc(msg.replace(/\n/g, ' ').trim());
538
+ const trunc = clean.length > 80 ? clean.substring(0, 80) + '…' : clean;
539
+ c += `\n {#7aa2f7-fg}{bold}You ❯{/} ${trunc}\n`;
540
+ if (assists[i]) {
541
+ const aClean = esc(assists[i].replace(/\n/g, ' ').trim());
542
+ const aTrunc = aClean.length > 80 ? aClean.substring(0, 80) + '…' : aClean;
543
+ c += ` {#9ece6a-fg}Claude ❯{/} {#565f89-fg}${aTrunc}{/}\n`;
544
+ }
545
+ });
546
+ }
547
+
548
+ c += `\n${sep}`;
549
+ c += `\n {#9ece6a-fg}{bold}↵ Enter{/}{#9ece6a-fg} to resume this conversation{/}`;
550
+ c += `\n {#565f89-fg}${CLI.name} --resume ${session.sessionId}{/}\n`;
551
+
552
+ detailPanel.setContent(c);
553
+ detailPanel.setScroll(0);
554
+ }
555
+
556
+ // ─── Render All ────────────────────────────────────────────────────────
557
+ function renderAll() {
558
+ updateHeader();
559
+ refreshList();
560
+ renderDetail();
561
+ updateFooter();
562
+ listPanel.focus();
563
+ screen.render();
564
+ }
565
+
566
+ // ─── Filter ────────────────────────────────────────────────────────────
567
+ function applyFilter() {
568
+ if (!filterText) {
569
+ filteredSessions = [...allSessions];
570
+ } else {
571
+ const terms = filterText.toLowerCase().split(/\s+/);
572
+ filteredSessions = allSessions.filter(s => {
573
+ const haystack = [s.project, s.topic, s.gitBranch || '', s.sessionId, ...(s.userMessages || [])].join(' ').toLowerCase();
574
+ return terms.every(t => haystack.includes(t));
575
+ });
576
+ }
577
+ selectedIndex = Math.min(selectedIndex, Math.max(-1, filteredSessions.length - 1));
578
+ // When filtering, select first result; when clearing, select New Session
579
+ if (filterText && filteredSessions.length > 0) {
580
+ selectedIndex = 0;
581
+ }
582
+ listPanel.childBase = 0; // reset scroll to top
583
+ renderAll();
584
+ }
585
+
586
+ // ─── Sort ──────────────────────────────────────────────────────────────
587
+ function cycleSort() {
588
+ const modes = ['time', 'size', 'messages', 'project'];
589
+ sortMode = modes[(modes.indexOf(sortMode) + 1) % modes.length];
590
+ const sorters = {
591
+ time: (a, b) => (new Date(b.lastTs || 0).getTime()) - (new Date(a.lastTs || 0).getTime()),
592
+ size: (a, b) => b.fileSize - a.fileSize,
593
+ messages: (a, b) => b.estimatedMessages - a.estimatedMessages,
594
+ project: (a, b) => a.project.localeCompare(b.project) || (new Date(b.lastTs || 0).getTime()) - (new Date(a.lastTs || 0).getTime()),
595
+ };
596
+ allSessions.sort(sorters[sortMode]);
597
+ selectedIndex = 0;
598
+ applyFilter();
599
+ }
600
+
601
+ // ─── Project Picker ────────────────────────────────────────────────────
602
+ let popupOpen = false;
603
+
604
+ function showProjectPicker() {
605
+ const projects = [' All Projects', ...uniqueProjects.map(p => ` ${p}`)];
606
+ const popup = blessed.list({
607
+ parent: screen, top: 'center', left: 'center',
608
+ width: Math.min(50, Math.max(...projects.map(p => p.length)) + 8),
609
+ height: Math.min(projects.length + 4, 20),
610
+ label: ' {bold}{#7aa2f7-fg}Filter by Project{/} ',
611
+ tags: true, border: { type: 'line' },
612
+ style: {
613
+ border: { fg: '#7aa2f7' }, bg: '#24283b', fg: '#a9b1d6',
614
+ selected: { bg: '#3d59a1', fg: 'white', bold: true },
615
+ label: { fg: '#7aa2f7' },
616
+ },
617
+ items: projects, keys: true, vi: true, mouse: true,
618
+ });
619
+ popupOpen = true;
620
+ popup.focus(); screen.render();
621
+ popup.on('select', (item, index) => {
622
+ filterText = index === 0 ? '' : uniqueProjects[index - 1];
623
+ popup.destroy(); popupOpen = false; selectedIndex = 0; applyFilter();
624
+ });
625
+ popup.key(['escape', 'q'], () => { popup.destroy(); popupOpen = false; screen.render(); });
626
+ }
627
+
628
+ // ─── Key Bindings ──────────────────────────────────────────────────────
629
+
630
+ // Monkey-patch listPanel.select: update selection WITHOUT scrolling.
631
+ const _origSelect = listPanel.select.bind(listPanel);
632
+ listPanel.select = function(index) {
633
+ const sb = this.childBase;
634
+ _origSelect(index);
635
+ this.childBase = sb;
636
+ };
637
+
638
+ // Prevent blessed's internal select-on-click from double-firing moveSelection
639
+ let suppressSelectEvent = false;
640
+
641
+ listPanel.on('select item', (item, index) => {
642
+ if (suppressSelectEvent) return;
643
+ selectedIndex = index - 1; // list index 0 = New Session = -1
644
+ renderDetail(); updateHeader(); screen.render();
645
+ });
646
+
647
+ function moveSelection(delta) {
648
+ const newIdx = selectedIndex + delta;
649
+ // -1 = New Session, 0..length-1 = sessions
650
+ if (newIdx >= -1 && newIdx < filteredSessions.length) {
651
+ selectedIndex = newIdx;
652
+ const listIdx = selectedIndex + 1; // list index (0 = New Session row)
653
+ suppressSelectEvent = true;
654
+ listPanel.select(listIdx);
655
+ suppressSelectEvent = false;
656
+
657
+ // Scroll only if selection went out of viewport
658
+ const base = listPanel.childBase;
659
+ const visible = listPanel.height;
660
+ if (listIdx < base) {
661
+ listPanel.childBase = listIdx;
662
+ } else if (listIdx >= base + visible) {
663
+ listPanel.childBase = listIdx - visible + 1;
664
+ }
665
+
666
+ renderDetail();
667
+ updateHeader();
668
+ screen.render();
669
+ }
670
+ }
671
+
672
+ screen.key(['down'], () => {
673
+ if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
674
+ moveSelection(1);
675
+ });
676
+ screen.key(['up'], () => {
677
+ if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
678
+ moveSelection(-1);
679
+ });
680
+ screen.key(['home'], () => {
681
+ if (isSearchMode) { isSearchMode = false; }
682
+ selectedIndex = -1;
683
+ suppressSelectEvent = true; listPanel.select(0); suppressSelectEvent = false;
684
+ listPanel.childBase = 0;
685
+ renderDetail(); updateHeader(); screen.render();
686
+ });
687
+ screen.key(['end'], () => {
688
+ if (isSearchMode) { isSearchMode = false; }
689
+ selectedIndex = Math.max(0, filteredSessions.length - 1);
690
+ suppressSelectEvent = true; listPanel.select(selectedIndex + 1); suppressSelectEvent = false;
691
+ listPanel.childBase = Math.max(0, selectedIndex + 1 - listPanel.height + 1);
692
+ renderDetail(); updateHeader(); screen.render();
693
+ });
694
+ screen.key(['pagedown', 'C-d'], () => {
695
+ if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
696
+ moveSelection(Math.floor((listPanel.height || 20) / 2));
697
+ });
698
+ screen.key(['pageup', 'C-u'], () => {
699
+ if (isSearchMode) { isSearchMode = false; updateHeader(); screen.render(); }
700
+ moveSelection(-Math.floor((listPanel.height || 20) / 2));
701
+ });
702
+
703
+ // Search
704
+ screen.key(['/'], () => {
705
+ if (isSearchMode) return;
706
+ isSearchMode = true; filterText = ''; applyFilter();
707
+ });
708
+
709
+ screen.on('keypress', (ch, key) => {
710
+ // Backspace: always works when there's filter text, regardless of search mode
711
+ if (key.name === 'backspace' && filterText) {
712
+ filterText = filterText.slice(0, -1);
713
+ selectedIndex = -1;
714
+ isSearchMode = !!filterText; // exit search when empty
715
+ applyFilter();
716
+ return;
717
+ }
718
+ if (!isSearchMode) return;
719
+ if (key.name === 'return' || key.name === 'enter') { isSearchMode = false; renderAll(); return; }
720
+ if (key.name === 'escape') { isSearchMode = false; filterText = ''; applyFilter(); return; }
721
+ // Only accept printable characters (exclude control chars like \r \n \t)
722
+ if (ch && ch.length === 1 && ch.charCodeAt(0) >= 32 && !key.ctrl && !key.meta) { filterText += ch; selectedIndex = -1; applyFilter(); }
723
+ });
724
+
725
+ // ─── Resume Session ─────────────────────────────────────────────────────
726
+ // Auto-detect: use mai-claude if available, otherwise fall back to claude
727
+
728
+ function resumeSession(session) {
729
+ screen.destroy();
730
+
731
+ const label = CLI.name;
732
+
733
+ console.log(`\n\x1b[36m⚡ Resuming conversation with ${label}\x1b[0m`);
734
+ console.log(`\x1b[90m Session: ${session.sessionId}\x1b[0m`);
735
+ console.log(`\x1b[90m Project: ${session.project} │ Branch: ${session.gitBranch || 'N/A'} │ Messages: ${session.estimatedMessages}\x1b[0m\n`);
736
+
737
+ const child = spawn(
738
+ `${CLI.cmd} --resume ${session.sessionId}`,
739
+ { stdio: 'inherit', cwd: session.cwd || process.cwd(), shell: true },
740
+ );
741
+ child.on('error', (err) => {
742
+ console.error(`\x1b[31mFailed to resume: ${err.message}\x1b[0m`);
743
+ console.log(`\x1b[33mManual: ${label} --resume ${session.sessionId}\x1b[0m`);
744
+ process.exit(1);
745
+ });
746
+ child.on('exit', (code) => process.exit(code || 0));
747
+ }
748
+
749
+ function startNewSession() {
750
+ screen.destroy();
751
+
752
+ const label = CLI.name;
753
+
754
+ console.log(`\n\x1b[36m✨ Starting new conversation with ${label}\x1b[0m\n`);
755
+
756
+ const child = spawn(CLI.cmd, { stdio: 'inherit', cwd: process.cwd(), shell: true });
757
+ child.on('error', (err) => {
758
+ console.error(`\x1b[31mFailed to start: ${err.message}\x1b[0m`);
759
+ process.exit(1);
760
+ });
761
+ child.on('exit', (code) => process.exit(code || 0));
762
+ }
763
+
764
+ screen.key(['enter'], () => {
765
+ if (isSearchMode) { isSearchMode = false; renderAll(); return; }
766
+ if (popupOpen) return;
767
+ if (selectedIndex === -1) { startNewSession(); return; }
768
+ if (filteredSessions.length === 0) return;
769
+ resumeSession(filteredSessions[selectedIndex]);
770
+ });
771
+
772
+ // Quick shortcut: n = new session
773
+ screen.key(['n'], () => {
774
+ if (isSearchMode) return;
775
+ startNewSession();
776
+ });
777
+
778
+ // Copy session ID
779
+ screen.key(['c'], () => {
780
+ if (isSearchMode) return;
781
+ if (filteredSessions.length === 0) return;
782
+ const sid = filteredSessions[selectedIndex].sessionId;
783
+ try {
784
+ const proc = spawn('pbcopy', [], { stdio: ['pipe', 'ignore', 'ignore'] });
785
+ proc.stdin.write(sid); proc.stdin.end();
786
+ footer.setContent(`\n {#9ece6a-fg}{bold}✓ Copied:{/} {#7dcfff-fg}${sid}{/}`);
787
+ screen.render();
788
+ setTimeout(() => { updateFooter(); screen.render(); }, 1500);
789
+ } catch (e) { /* silently fail */ }
790
+ });
791
+
792
+ screen.key(['s'], () => { if (!isSearchMode) cycleSort(); });
793
+ screen.key(['p'], () => { if (!isSearchMode) showProjectPicker(); });
794
+ screen.key(['escape'], () => {
795
+ if (isSearchMode) { isSearchMode = false; filterText = ''; applyFilter(); return; }
796
+ filterText = ''; selectedIndex = -1; applyFilter();
797
+ });
798
+ screen.key(['q', 'C-c'], () => { screen.destroy(); process.exit(0); });
799
+
800
+ // Remove blessed's built-in wheel handlers (they call select which changes selection)
801
+ listPanel.removeAllListeners('element wheeldown');
802
+ listPanel.removeAllListeners('element wheelup');
803
+
804
+ // Mouse wheel on list — scroll viewport, keep selection in view
805
+ function clampSelection() {
806
+ const base = listPanel.childBase;
807
+ const visible = listPanel.height;
808
+ const listIdx = selectedIndex + 1; // +1 for New Session row
809
+ if (listIdx < base) {
810
+ selectedIndex = base - 1; // -1 to convert back
811
+ suppressSelectEvent = true; listPanel.select(base); suppressSelectEvent = false;
812
+ renderDetail(); updateHeader();
813
+ } else if (listIdx >= base + visible) {
814
+ selectedIndex = base + visible - 1 - 1; // -1 for list→session offset
815
+ suppressSelectEvent = true; listPanel.select(base + visible - 1); suppressSelectEvent = false;
816
+ renderDetail(); updateHeader();
817
+ }
818
+ }
819
+
820
+ listPanel.on('element wheeldown', () => {
821
+ const maxBase = Math.max(0, listPanel.items.length - listPanel.height);
822
+ if (listPanel.childBase < maxBase) {
823
+ listPanel.childBase++;
824
+ clampSelection();
825
+ screen.render();
826
+ }
827
+ });
828
+ listPanel.on('element wheelup', () => {
829
+ if (listPanel.childBase > 0) {
830
+ listPanel.childBase--;
831
+ clampSelection();
832
+ screen.render();
833
+ }
834
+ });
835
+
836
+ // Mouse wheel on detail
837
+ detailPanel.on('wheeldown', () => { detailPanel.scroll(2); screen.render(); });
838
+ detailPanel.on('wheelup', () => { detailPanel.scroll(-2); screen.render(); });
839
+
840
+ // ─── Go! ───────────────────────────────────────────────────────────────
841
+ renderAll();
842
+ listPanel.focus();
843
+ }
844
+
845
+ // ─── Entry Point ─────────────────────────────────────────────────────────────
846
+
847
+ const args = process.argv.slice(2);
848
+
849
+ if (args.includes('--help') || args.includes('-h')) {
850
+ console.log(`
851
+ \x1b[36m🚀 Claude Starter\x1b[0m
852
+
853
+ Usage:
854
+ start-claude Launch interactive TUI
855
+ start-claude --list [N] Print latest N sessions (default: 30)
856
+ start-claude --help Show this help
857
+
858
+ TUI Keyboard Shortcuts:
859
+ ↑/↓ Navigate sessions
860
+ Enter Start new / resume selected session
861
+ n Start new session
862
+ / Search (fuzzy filter)
863
+ p Filter by project
864
+ s Cycle sort mode
865
+ c Copy session ID
866
+ Home / End Jump to top / bottom
867
+ Ctrl-D/U Page down / up
868
+ Esc Clear filter
869
+ q / Ctrl-C Quit
870
+ `);
871
+ process.exit(0);
872
+ }
873
+
874
+ if (args.includes('--list') || args.includes('-l')) {
875
+ const limitIdx = args.indexOf('--list') !== -1 ? args.indexOf('--list') : args.indexOf('-l');
876
+ const limit = parseInt(args[limitIdx + 1]) || 30;
877
+ runListMode(limit);
878
+ process.exit(0);
879
+ }
880
+
881
+ createApp();
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "claude-starter",
3
+ "version": "1.1.0",
4
+ "description": "A beautiful terminal UI for managing Claude Code sessions — start new or resume past conversations",
5
+ "main": "index.js",
6
+ "bin": {
7
+ "start-claude": "./index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "tui",
16
+ "terminal",
17
+ "session",
18
+ "resume",
19
+ "ai"
20
+ ],
21
+ "author": "Bojun Chai <just_cbj@sina.com>",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/Bojun-Vvibe/claude-starter.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "README.md"
33
+ ],
34
+ "dependencies": {
35
+ "blessed": "^0.1.81",
36
+ "blessed-contrib": "^4.11.0"
37
+ }
38
+ }