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>
223 lines
7.9 KiB
Markdown
223 lines
7.9 KiB
Markdown
# 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 `bun` exclusively, 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
|
|
|
|
```sh
|
|
bun run src/index.ts
|
|
```
|
|
|
|
## Key Behaviors
|
|
|
|
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`
|
|
|
|
## 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
|
|
})
|
|
```
|
|
|
|
Color helpers: `red()`, `green()`, `blue()`, `yellow()`, `cyan()`, `magenta()`, `white()`, `gray()`, `brightRed()`, etc.
|
|
Background: `bgRed()`, `bgGreen()`, `bgBlue()`, `bgYellow()`, etc.
|
|
|
|
### 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()
|
|
```
|
|
|
|
### 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()
|
|
```
|
|
|
|
### 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
|
|
```
|
|
|
|
### 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
|