Source modules for history parsing, git metadata, project scanning, terminal launching, and OpenTUI component layout. Remove private flag for publishing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7.9 KiB
7.9 KiB
cladm
Interactive terminal UI for launching Claude Code sessions across project folders. Built with OpenTUI + Bun.
Project Overview
A rich TUI app that replaces ~/Desktop/launch-claude.sh. Scans ~/.claude/history.jsonl to discover projects, displays them in an interactive multi-select list with metadata (git branch, last commit, dirty status, Claude session stats), and launches selected projects in new Terminal windows with claude --dangerously-skip-permissions.
Tech Stack
- Runtime: Bun (>=1.3.0) — use
bunexclusively, never node/npm/npx - UI Framework: @opentui/core (imperative API, not React/Solid bindings)
- Language: TypeScript
Architecture
src/
index.ts — Entry point: createCliRenderer, compose layout, input loop
data/
history.ts — Parse ~/.claude/history.jsonl → project list with sessions/msgs/timestamps
git.ts — Git metadata: branch, last commit, dirty status (staged/unstaged/untracked)
scanner.ts — Filesystem fallback scanner (recursive, skips node_modules etc.)
components/
project-list.ts — ScrollBox + selectable rows with checkbox toggles
header.ts — Title bar with selected count, sort mode, source label
footer.ts — Keybinding hints
row.ts — Single project row: checkbox, name, branch, commit, dirty, claude stats, stack tags
actions/
launcher.ts — osascript Terminal.app integration to open claude sessions
sorter.ts — Sort logic: recent claude, name, last commit, most sessions
lib/
colors.ts — Theme colors and highlight helpers
time.ts — Relative time formatting (time_ago)
Run
bun run src/index.ts
Key Behaviors
- Default mode: reads
~/.claude/history.jsonlto discover all projects (sorted by most recently used) - Fallback: recursive filesystem scan from
~/Desktopif no history - Interactive picker: arrow keys navigate, space toggles,
aall,nnone,scycles sort, enter launches,qquits - Per-row metadata: project name (relative path), git branch, last commit age + message, dirty status (+staged ~modified ?untracked), last Claude use, session count, message count, stack tags
- Launch: each selected project opens a new Terminal.app window via osascript running
cd <path> && claude --dangerously-skip-permissions
OpenTUI Reference
Quick Start Pattern
import { createCliRenderer, Box, Text, ScrollBox, Select, Input } from "@opentui/core"
const renderer = await createCliRenderer({ exitOnCtrlC: true })
renderer.root.add(
Box({ flexDirection: "column", width: "100%", height: "100%" },
// children...
)
)
Core Concepts
- Constructs are factory functions:
Box(props, ...children)→ returns VNode - Renderables are class instances:
new BoxRenderable(renderer, options) - Use constructs for declarative composition, renderables when you need imperative control
renderer.root.add(vnode)to mount,.remove(id)to unmountrequestRender()to trigger re-draw after state changes
Layout (Yoga/Flexbox)
All components accept flexbox props:
flexDirection: "row" | "column" | "row-reverse" | "column-reverse"justifyContent: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly"alignItems: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"width/height: number (chars) or string ("100%", "50%")padding,paddingTop/Right/Bottom/Left,margin*,gap,rowGap,columnGapposition: "relative" | "absolute"withtop/right/bottom/leftoverflow: "visible" | "hidden" | "scroll"flexGrow,flexShrink,flexBasis
Box
Box({
borderStyle: "single" | "double" | "rounded" | "heavy",
border: boolean | ("top" | "right" | "bottom" | "left")[],
borderColor: string | RGBA,
backgroundColor: string | RGBA,
focusedBorderColor: ColorInput,
title: string,
titleAlignment: "left" | "center" | "right",
shouldFill: boolean,
focusable: boolean,
gap: number | `${number}%`,
}, ...children)
Text + Styled Text
import { Text, t, bold, italic, underline, dim, fg, bg, strikethrough } from "@opentui/core"
Text({
content: "plain" | t`${bold(fg("#00FFFF")("styled"))}`,
fg: string | RGBA,
bg: string | RGBA,
wrapMode: "none" | "char" | "word",
truncate: boolean,
selectable: boolean,
attributes: number, // TextAttributes bitmask
})
Color helpers: red(), green(), blue(), yellow(), cyan(), magenta(), white(), gray(), brightRed(), etc.
Background: bgRed(), bgGreen(), bgBlue(), bgYellow(), etc.
Input
Input({
placeholder: string,
maxLength: number, // default 1000
value: string, // initial value
// Inherits TextareaOptions for colors, keybindings
})
// Events: "input" (each char), "change" (on blur), "enter" (on Enter key)
// Methods: .value, .focus(), .blur(), .submit()
Select
Select({
options: [{ name: string, description: string, value?: any }],
backgroundColor / textColor / focusedBackgroundColor / focusedTextColor,
selectedBackgroundColor / selectedTextColor,
descriptionColor / selectedDescriptionColor,
showScrollIndicator: boolean,
wrapSelection: boolean,
showDescription: boolean,
itemSpacing: number,
fastScrollStep: number,
keyBindings: SelectKeyBinding[],
})
// Events: "selectionChanged", "itemSelected"
// Methods: .getSelectedOption(), .getSelectedIndex(), .moveUp/Down(), .selectCurrent()
ScrollBox
ScrollBox({
scrollX: boolean, // default false
scrollY: boolean, // default true
stickyScroll: boolean,
viewportCulling: boolean, // default true
// Inherits BoxOptions (border, bg, etc.)
})
// Methods: .scrollBy(delta), .scrollTo(pos), .scrollTop/.scrollLeft
// Children added via .add() are delegated to internal content container
Keyboard Handling
// Global input handler
renderer.addInputHandler((key: KeyEvent) => {
key.name // "a", "return", "escape", "up", "down", etc.
key.ctrl // boolean
key.meta // boolean (Cmd on mac)
key.shift // boolean
key.preventDefault()
key.stopPropagation()
})
// Per-component
Box({ onKeyDown: (key) => { ... }, focusable: true })
Colors (RGBA)
import { RGBA } from "@opentui/core"
RGBA.fromHex("#FF0000")
RGBA.fromInts(255, 0, 0, 255)
RGBA.fromValues(1.0, 0.0, 0.0, 1.0)
Renderer Lifecycle
const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 30,
useMouse: true,
useAlternateScreen: true,
onDestroy: () => { /* cleanup */ },
})
// renderer.start() — auto-called
// renderer.destroy() — cleanup and exit
Animation
import { createTimeline } from "@opentui/core"
const tl = createTimeline()
tl.add({ targets: myRenderable, duration: 500, ease: "outQuad", onUpdate: (anim) => { ... } })
Key Patterns for This Project
- Use
BoxwithflexDirection: "column"for the main layout (header, list, footer) - Use
ScrollBoxfor the project list (handles overflow + scrolling) - Build each row as a
BoxwithflexDirection: "row"containingTextelements - Track selection state in a
Map<number, boolean>orboolean[] - Use
renderer.addInputHandler()for global keybinds (q, a, n, s, enter, space, arrows) - Launch via
Bun.$\osascript -e '...'`orBun.spawn()` - Parse history.jsonl with
Bun.file().text()+ line-by-line JSON.parse - Git data via
Bun.spawn(["git", "-C", path, ...])
Conventions
- Use Bun exclusively (no node, npm, npx)
- Bun auto-loads .env — no dotenv
- Prefer
Bun.file()overnode:fs - Use
Bun.$for shell commands,Bun.spawn()for processes - No JSDoc comments, no excessive documentation
- camelCase for variables/functions, PascalCase for types/classes
- Keep it simple — this is a launcher, not a framework