Add TUI launcher implementation and project docs

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>
This commit is contained in:
Alejandro Gutiérrez
2026-02-23 22:56:54 +00:00
parent 165ad7d352
commit fcafe652cf
14 changed files with 2757 additions and 91 deletions

287
CLAUDE.md
View File

@@ -1,111 +1,222 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
# cladm
Default to using Bun instead of Node.js.
Interactive terminal UI for launching Claude Code sessions across project folders. Built with OpenTUI + Bun.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## Project Overview
## APIs
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`.
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Tech Stack
## Testing
- **Runtime**: Bun (>=1.3.0) — use `bun` exclusively, never node/npm/npx
- **UI Framework**: @opentui/core (imperative API, not React/Solid bindings)
- **Language**: TypeScript
Use `bun test` to run tests.
## Architecture
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
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)
```
## Frontend
## Run
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
```sh
bun run src/index.ts
```
Server:
## Key Behaviors
```ts#index.ts
import index from "./index.html"
1. **Default mode**: reads `~/.claude/history.jsonl` to discover all projects (sorted by most recently used)
2. **Fallback**: recursive filesystem scan from `~/Desktop` if no history
3. **Interactive picker**: arrow keys navigate, space toggles, `a` all, `n` none, `s` cycles sort, enter launches, `q` quits
4. **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
5. **Launch**: each selected project opens a new Terminal.app window via osascript running `cd <path> && claude --dangerously-skip-permissions`
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
## OpenTUI Reference
### Quick Start Pattern
```typescript
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 unmount
- `requestRender()` 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`, `columnGap`
- `position: "relative" | "absolute"` with `top/right/bottom/left`
- `overflow: "visible" | "hidden" | "scroll"`
- `flexGrow`, `flexShrink`, `flexBasis`
### Box
```typescript
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
```typescript
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
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
Color helpers: `red()`, `green()`, `blue()`, `yellow()`, `cyan()`, `magenta()`, `white()`, `gray()`, `brightRed()`, etc.
Background: `bgRed()`, `bgGreen()`, `bgBlue()`, `bgYellow()`, etc.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
### Input
```typescript
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()
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
### Select
```typescript
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()
```
Then, run index.ts
```sh
bun --hot ./index.ts
### ScrollBox
```typescript
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
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
### Keyboard Handling
```typescript
// 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)
```typescript
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
```typescript
const renderer = await createCliRenderer({
exitOnCtrlC: true,
targetFps: 30,
useMouse: true,
useAlternateScreen: true,
onDestroy: () => { /* cleanup */ },
})
// renderer.start() — auto-called
// renderer.destroy() — cleanup and exit
```
### Animation
```typescript
import { createTimeline } from "@opentui/core"
const tl = createTimeline()
tl.add({ targets: myRenderable, duration: 500, ease: "outQuad", onUpdate: (anim) => { ... } })
```
### Key Patterns for This Project
- Use `Box` with `flexDirection: "column"` for the main layout (header, list, footer)
- Use `ScrollBox` for the project list (handles overflow + scrolling)
- Build each row as a `Box` with `flexDirection: "row"` containing `Text` elements
- Track selection state in a `Map<number, boolean>` or `boolean[]`
- Use `renderer.addInputHandler()` for global keybinds (q, a, n, s, enter, space, arrows)
- Launch via `Bun.$\`osascript -e '...'\`` or `Bun.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()` over `node: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