4 min read
I Built a TUI to Stop Losing Claude Sessions
How I solved the problem of managing 10+ Claude Code sessions across different projects with a simple terminal UI, fuzzy search, and tmux integration.
Coding
TL;DR: I built a terminal launcher that lets me fuzzy-search across all my Claude Code sessions and resume any of them instantly. Works with tmux for session grouping. GitHub →
The Problem
I run Claude Code in 10-12 project directories simultaneously. All of them in Ghostty split panes.
Then Ghostty crashes. Or I close the wrong window. Or my Mac restarts.
Poof. All sessions gone.
Sure, Claude has --resume. But that means I have to:
cdinto each project directory- Run
claude --resumeto see the list - Scroll through messages to find the right conversation
- Repeat for 10+ projects
That’s not a workflow. That’s punishment.
I wanted something simpler: type a few characters, find the conversation I need, hit Enter.
What I Built

Claude Launcher is a TUI (terminal UI) that:
- Fuzzy searches across all Claude session messages
- Resumes sessions in tmux (grouped by project) or plain Ghostty
- Bookmarks sessions with custom names
- Forks conversations - start fresh but with previous context
- Shows active indicators - see which projects have running Claude instances
One command: c. Type to filter. Enter to resume.
The Stack
- Ink 5: React for the terminal. Yes, really. JSX that renders to CLI.
- uFuzzy: Blazing fast fuzzy search (~100k ops/sec)
- Node.js: Claude stores sessions as JSON files. Easy to parse.
- tmux: Groups sessions by project, survives terminal crashes
Why Ink?
I’d used Ink before and loved it. You get React’s component model and hooks, but instead of rendering to the DOM, it renders to the terminal.
<Box flexDirection="column" padding={1}>
<Text bold color="cyan">Claude Sessions</Text>
<TextInput
value={query}
onChange={setQuery}
placeholder="type to filter..."
/>
<SessionList sessions={results} selectedIndex={selectedIndex} />
</Box>
State management just works. useState, useEffect, useMemo - all there.
Why uFuzzy over Fuse.js?
Speed. uFuzzy is specifically designed for search-as-you-type scenarios. It handles partial matches, typos, and out-of-order characters without breaking a sweat.
import uFuzzy from '@leeoniya/ufuzzy'
const fuzzy = new uFuzzy({})
const haystack = sessions.map(s => `${s.projectName} ${s.lastMessage}`)
// Search
const [idxs, info, order] = fuzzy.search(haystack, query)
Returns matches sorted by relevance. Dead simple.
How Sessions Are Stored
Claude Code stores conversation history at:
~/.claude/projects/<hash>/sessions/<session-id>.json
Each session file contains:
{
"id": "abc123...",
"messages": [
{ "role": "user", "content": "Can you refactor the auth module?" },
{ "role": "assistant", "content": "I'll help with that..." }
]
}
The launcher reads all sessions, extracts the last user message and project name, then builds a searchable index.
const getHistory = (): ClaudeSession[] => {
const projectsPath = join(homedir(), '.claude', 'projects')
const projects = readdirSync(projectsPath)
return projects.flatMap(project => {
const sessionsPath = join(projectsPath, project, 'sessions')
if (!existsSync(sessionsPath)) return []
return readdirSync(sessionsPath)
.filter(f => f.endsWith('.json'))
.map(file => parseSession(join(sessionsPath, file), project))
})
}
tmux Integration
Here’s where it gets good. Instead of opening 10 separate terminal windows, tmux lets you:
- Group all Claude sessions under one “project” session
- Split panes within that session
- Survive terminal crashes - tmux sessions persist
When you resume a session:
export const launchResume = (sessionId: string, project: string): void => {
const command = `claude --dangerously-skip-permissions --resume ${sessionId}`
if (tmuxExists()) {
const sessionName = getSessionName(project)
// Create session if it doesn't exist
try {
createSession(sessionName, project, command)
} catch {
// Session exists - split a new pane
splitPane(sessionName, project, command)
}
// Attach if not already attached
if (!isSessionAttached(sessionName)) {
spawnTerminal(['tmux', 'attach', '-t', sessionName])
}
} else {
// No tmux - open directly in terminal
spawnTerminalDirect(terminal, project, command)
}
}
Press Enter on a session → opens in tmux under the project name.
Press g → opens directly in Ghostty (no tmux).
Bookmarks
Some sessions I return to constantly. The bookmark feature lets me save them with custom names:
★ email-tracker: Working on CrunchBase scraper
★ portfolio: Blog post about Mastra
lumoflo: OAuth integration
andronix: Update billing page
Bookmarked sessions (★) float to the top. Press s to save, x to remove.
The Fork Feature
Sometimes I want to start fresh but keep the context. The “fork” feature (Ctrl+F):
- Extracts the last few messages from the session
- Writes them to a temp file
- Launches Claude with
-p "$(cat context.txt)"
New conversation, old context. Perfect for “let’s try a different approach.”
Installation
git clone https://github.com/imprakharshukla/claude-launcher.git
cd claude-launcher
pnpm install
pnpm build
Add to your shell:
alias c="/path/to/claude-launcher/bin/c.js"
Requirements:
- Node.js 18+
- Ghostty, WezTerm, Kitty, or Alacritty
- tmux (optional but recommended)
What I Learned
1. Ink is underrated. Building terminal UIs with React is genuinely pleasant. No ncurses nightmares. Just components.
2. File-watching matters. Sessions update while the launcher is open. I added chokidar to watch for changes:
const watcher = chokidar.watch(projectsPath, {
persistent: true,
depth: 3,
ignoreInitial: true
})
watcher.on('all', () => {
setSessions(getHistory())
})
3. tmux is worth learning. It’s one of those tools that seems like overkill until you need it. Then it’s indispensable.
Try It
If you use Claude Code daily like I do, give it a shot:
GitHub: github.com/imprakharshukla/claude-launcher
Open issues if something breaks. PRs welcome.
Links: