diff --git a/CLAUDE.md b/CLAUDE.md index ebda995..d40618b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` instead of `node ` or `ts-node ` -- Use `bun test` instead of `jest` or `vitest` -- Use `bun build ` instead of `webpack` or `esbuild` -- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` -- Use `bun run - - +### 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

Hello, world!

; -} - -root.render(); +### 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` 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 diff --git a/README.md b/README.md index 318209b..ef6e6b2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# tui-claude-director +# cladm To install dependencies: diff --git a/docs/launch-claude-original.sh b/docs/launch-claude-original.sh new file mode 100755 index 0000000..c1101c2 --- /dev/null +++ b/docs/launch-claude-original.sh @@ -0,0 +1,520 @@ +#!/bin/bash +# Launch Claude Code in new Terminal windows for selected project folders +# Discovers projects from Claude's own conversation history + filesystem scan +# +# Usage: +# ./launch-claude.sh # Interactive picker (default: history mode) +# ./launch-claude.sh --all # Launch all detected projects +# ./launch-claude.sh --scan # Filesystem scan mode (ignore history) +# ./launch-claude.sh --depth 5 # Scan depth for filesystem mode (default: 3) +# ./launch-claude.sh arrio nuc # Launch specific folders directly + +# ─── CONFIG ─────────────────────────────────────────────────────────── +SCAN_ROOT="$HOME/Desktop" +MAX_DEPTH=3 +CLAUDE_HISTORY="$HOME/.claude/history.jsonl" +CLAUDE_PROJECTS="$HOME/.claude/projects" +# ────────────────────────────────────────────────────────────────────── + +MODE="history" # history or scan + +# Parse flags +while [[ $# -gt 0 ]]; do + case "$1" in + --depth) MAX_DEPTH="$2"; shift 2 ;; + --scan) MODE="scan"; shift ;; + --all) MODE="all"; shift ;; + *) break ;; + esac +done + +# ─── HELPERS ────────────────────────────────────────────────────────── + +is_project() { + local dir="$1" + [ -d "$dir/.git" ] || [ -f "$dir/package.json" ] || [ -f "$dir/pyproject.toml" ] || \ + [ -f "$dir/setup.py" ] || [ -f "$dir/requirements.txt" ] || [ -f "$dir/Cargo.toml" ] || \ + [ -f "$dir/go.mod" ] || [ -f "$dir/Gemfile" ] || [ -f "$dir/composer.json" ] || \ + [ -f "$dir/CLAUDE.md" ] || [ -f "$dir/Dockerfile" ] || [ -f "$dir/docker-compose.yml" ] || \ + [ -f "$dir/docker-compose.yaml" ] +} + +find_projects_recursive() { + local dir="$1" depth="$2" + [[ $depth -gt $MAX_DEPTH ]] && return + for sub in "$dir"/*/; do + [[ ! -d "$sub" ]] && continue + local name=$(basename "$sub") + [[ "$name" == .* || "$name" == "node_modules" || "$name" == "vendor" || \ + "$name" == "venv" || "$name" == ".venv" || "$name" == "__pycache__" || \ + "$name" == "dist" || "$name" == "build" || "$name" == ".next" || \ + "$name" == "target" ]] && continue + if is_project "$sub"; then + echo "$sub" + else + find_projects_recursive "$sub" $((depth + 1)) + fi + done 2>/dev/null +} + +get_tags() { + local dir="$1" tags="" + [ -d "$dir/.git" ] && tags="${tags}git," + [ -f "$dir/package.json" ] && tags="${tags}node," + { [ -f "$dir/pyproject.toml" ] || [ -f "$dir/setup.py" ] || [ -f "$dir/requirements.txt" ]; } && tags="${tags}py," + [ -f "$dir/Cargo.toml" ] && tags="${tags}rust," + [ -f "$dir/go.mod" ] && tags="${tags}go," + [ -f "$dir/CLAUDE.md" ] && tags="${tags}claude," + { [ -f "$dir/Dockerfile" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && tags="${tags}docker," + echo "${tags%,}" +} + +get_relative_path() { + local rel="${1#$SCAN_ROOT/}" + echo "${rel%/}" +} + +get_git_branch() { + git -C "$1" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "-" +} + +get_last_commit() { + git -C "$1" log -1 --format="%ar|%s" 2>/dev/null || echo "-|-" +} + +get_dirty_status() { + local dir="$1" + git -C "$dir" rev-parse --git-dir &>/dev/null || return + local staged=$(git -C "$dir" diff --cached --numstat 2>/dev/null | wc -l | tr -d ' ') + local unstaged=$(git -C "$dir" diff --numstat 2>/dev/null | wc -l | tr -d ' ') + local untracked=$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ') + local parts="" + [[ "$staged" -gt 0 ]] && parts="${parts}+${staged} " + [[ "$unstaged" -gt 0 ]] && parts="${parts}~${unstaged} " + [[ "$untracked" -gt 0 ]] && parts="${parts}?${untracked} " + echo "${parts% }" +} + +time_ago_epoch_ms() { + local ms="$1" + [[ "$ms" == "0" || -z "$ms" ]] && echo "never" && return + local now_ms=$(($(date +%s) * 1000)) + local diff_s=$(( (now_ms - ms) / 1000 )) + if (( diff_s < 60 )); then echo "just now" + elif (( diff_s < 3600 )); then echo "$((diff_s/60))m ago" + elif (( diff_s < 86400 )); then echo "$((diff_s/3600))h ago" + elif (( diff_s < 604800 )); then echo "$((diff_s/86400))d ago" + elif (( diff_s < 2592000 )); then echo "$((diff_s/604800))w ago" + else echo "$((diff_s/2592000))mo ago" + fi +} + +launch_folders() { + local count=0 + for dir in "$@"; do + local name=$(get_relative_path "$dir") + osascript -e " + tell application \"Terminal\" + activate + do script \"cd '$dir' && claude --dangerously-skip-permissions\" + end tell + " + echo " ✓ $name" + ((count++)) + sleep 0.3 + done + echo "" + echo "Done! $count terminals launched." +} + +# ─── BUILD PROJECT LIST FROM HISTORY ────────────────────────────────── + +build_from_history() { + python3 -c " +import json, os, sys +from collections import defaultdict + +scan_root = '$SCAN_ROOT' +projects = defaultdict(lambda: {'sessions': set(), 'msgs': 0, 'last': 0}) + +with open(os.path.expanduser('$CLAUDE_HISTORY')) as f: + for line in f: + try: + d = json.loads(line) + p = d.get('project', '') + ts = d.get('timestamp', 0) + sid = d.get('sessionId', '') + # Only include projects under SCAN_ROOT, exclude SCAN_ROOT itself + if p and p.startswith(scan_root + '/') and p != scan_root: + projects[p]['sessions'].add(sid) + projects[p]['msgs'] += 1 + projects[p]['last'] = max(projects[p]['last'], ts) + except: + pass + +# Output: path|sessions|msgs|last_timestamp_ms +# Only include dirs that still exist +for p, info in sorted(projects.items(), key=lambda x: -x[1]['last']): + if os.path.isdir(p): + print(f\"{p}|{len(info['sessions'])}|{info['msgs']}|{info['last']}\") +" 2>/dev/null +} + +# ─── BUILD PROJECT LIST FROM FILESYSTEM ─────────────────────────────── + +build_from_scan() { + find_projects_recursive "$SCAN_ROOT" 1 +} + +# ─── DIRECT LAUNCH MODES ───────────────────────────────────────────── + +if [[ "$MODE" == "all" ]]; then + found=() + while IFS= read -r line; do found+=("$line"); done < <(build_from_history | cut -d'|' -f1) + if [[ ${#found[@]} -eq 0 ]]; then + while IFS= read -r line; do found+=("$line"); done < <(build_from_scan) + fi + echo "Launching ${#found[@]} projects..." + launch_folders "${found[@]}" + exit 0 +fi + +if [[ $# -gt 0 ]]; then + folders=() + for name in "$@"; do + dir="$SCAN_ROOT/$name" + [[ -d "$dir" ]] && folders+=("$dir") || echo "⚠ Skipping '$name' — not found" + done + launch_folders "${folders[@]}" + exit 0 +fi + +# ─── GATHER PROJECT DATA ───────────────────────────────────────────── + +clear +history_lines=() +scan_lines=() +if [[ "$MODE" == "history" ]]; then + printf "\033[1m Reading Claude history...\033[0m\n" + while IFS= read -r line; do history_lines+=("$line"); done < <(build_from_history) +else + printf "\033[1m Scanning filesystem (depth: %d)...\033[0m\n" "$MAX_DEPTH" + while IFS= read -r line; do scan_lines+=("$line"); done < <(build_from_scan) +fi + +projects=() +project_display=() +project_tags=() +project_branches=() +project_commit_age=() +project_commit_msg=() +project_dirty=() +project_claude_ago=() +project_claude_sessions=() +project_claude_msgs=() + +idx=0 + +if [[ "$MODE" == "history" ]]; then + for entry in "${history_lines[@]}"; do + [[ -z "$entry" ]] && continue + IFS='|' read -r dir sessions msgs last_ts <<< "$entry" + + projects+=("$dir") + project_display+=("$(get_relative_path "$dir")") + project_tags+=("$(get_tags "$dir")") + project_branches+=("$(get_git_branch "$dir")") + + commit_info=$(get_last_commit "$dir") + project_commit_age+=("${commit_info%%|*}") + cmsg="${commit_info#*|}" + [[ ${#cmsg} -gt 30 ]] && cmsg="${cmsg:0:27}..." + project_commit_msg+=("$cmsg") + + project_dirty+=("$(get_dirty_status "$dir")") + project_claude_ago+=("$(time_ago_epoch_ms "$last_ts")") + project_claude_sessions+=("$sessions") + project_claude_msgs+=("$msgs") + + ((idx++)) + printf "\r\033[K\033[2m Loading %d projects...\033[0m" "$idx" + done +else + for dir in "${scan_lines[@]}"; do + [[ -z "$dir" ]] && continue + + projects+=("$dir") + project_display+=("$(get_relative_path "$dir")") + project_tags+=("$(get_tags "$dir")") + project_branches+=("$(get_git_branch "$dir")") + + commit_info=$(get_last_commit "$dir") + project_commit_age+=("${commit_info%%|*}") + cmsg="${commit_info#*|}" + [[ ${#cmsg} -gt 30 ]] && cmsg="${cmsg:0:27}..." + project_commit_msg+=("$cmsg") + + project_dirty+=("$(get_dirty_status "$dir")") + project_claude_ago+=("-") + project_claude_sessions+=("-") + project_claude_msgs+=("-") + + ((idx++)) + printf "\r\033[K\033[2m Scanned %d projects...\033[0m" "$idx" + done +fi + +total=${#projects[@]} +if [[ $total -eq 0 ]]; then + echo "" + echo "No projects found." + exit 1 +fi + +# ─── INTERACTIVE PICKER ────────────────────────────────────────────── + +selected=() +for ((i=0; i= scroll_offset + visible)); then scroll_offset=$((cursor - visible + 1)); fi + + local end=$((scroll_offset + visible)) + [[ $end -gt $total ]] && end=$total + + if ((scroll_offset > 0)); then + printf " ${C_DIM} ↑ %d more${C_RESET}\033[K\n" "$scroll_offset" + else + printf "\033[K\n" + fi + + for ((row=scroll_offset; row 0)); then + printf " ${C_DIM} ↓ %d more${C_RESET}\033[K\n" "$remaining" + else + printf "\033[K\n" + fi + + for ((cl=0; cl<3; cl++)); do printf "\033[K\n"; done +} + +clear +draw + +while true; do + IFS= read -rsn1 key + + case "$key" in + $'\x1b') + read -rsn2 -t 0.1 seq + case "$seq" in + '[A') ((cursor > 0)) && ((cursor--)) ;; + '[B') ((cursor < total-1)) && ((cursor++)) ;; + '[5') read -rsn1 -t 0.1; ((cursor -= visible)); ((cursor < 0)) && cursor=0 ;; + '[6') read -rsn1 -t 0.1; ((cursor += visible)); ((cursor >= total)) && cursor=$((total-1)) ;; + esac + ;; + ' ') + local_i=${sort_indices[$cursor]} + selected[$local_i]=$(( 1 - ${selected[$local_i]} )) + ((cursor < total-1)) && ((cursor++)) + ;; + 'a'|'A') + for ((i=0; i/dev/null || echo "0") + epoch_pairs+=("$ep:$i") + done + sorted=$(printf '%s\n' "${epoch_pairs[@]}" | sort -t: -k1 -nr) + sort_indices=() + while IFS= read -r line; do sort_indices+=("${line#*:}"); done <<< "$sorted" + unset epoch_pairs + ;; + 3) # By most sessions + sort_label="most sessions" + declare -a sess_pairs=() + for ((i=0; i/dev/null || echo "0") + epoch_pairs+=("$ep:$i") + done + sorted=$(printf '%s\n' "${epoch_pairs[@]}" | sort -t: -k1 -nr) + sort_indices=() + while IFS= read -r line; do sort_indices+=("${line#*:}"); done <<< "$sorted" + unset epoch_pairs + ;; + esac + fi + cursor=0; scroll_offset=0 + ;; + ''|$'\n') + break + ;; + 'q'|'Q') + tput cnorm; clear + echo "Cancelled." + exit 0 + ;; + esac + + draw +done + +tput cnorm; clear + +chosen=() +for ((i=0; i Source: https://github.com/anomalyco/opentui — v0.1.81 +> Native terminal UI core written in Zig with TypeScript bindings. Bun >=1.3.0 required. + +## Packages +- `@opentui/core` — TypeScript bindings, imperative API, all primitives +- `@opentui/solid` — SolidJS reconciler +- `@opentui/react` — React reconciler + +--- + +## Renderer + +### createCliRenderer(config?) + +```typescript +const renderer = await createCliRenderer(config?: CliRendererConfig): Promise +``` + +### CliRendererConfig + +| Property | Type | Default | Description | +|---|---|---|---| +| `exitOnCtrlC` | boolean | true | Call renderer.destroy() on Ctrl+C | +| `targetFps` | number | 30 | Target frames per second | +| `maxFps` | number | 60 | Maximum FPS for immediate re-renders | +| `useMouse` | boolean | true | Enable mouse input | +| `autoFocus` | boolean | true | Focus nearest focusable on click | +| `enableMouseMovement` | boolean | true | Track mouse movement | +| `useAlternateScreen` | boolean | true | Use alternate screen buffer | +| `openConsoleOnError` | boolean | true | Auto-open console on errors | +| `exitSignals` | NodeJS.Signals[] | — | Signals triggering cleanup | +| `consoleOptions` | ConsoleOptions | — | Console overlay config | +| `onDestroy` | () => void | — | Cleanup callback | + +### CliRenderer Methods + +**Lifecycle:** `start()`, `pause()`, `stop()`, `suspend()`, `resume()`, `destroy()` +**Rendering:** `requestRender()`, `intermediateRender()`, `idle()` +**Input:** `addInputHandler(fn)`, `prependInputHandler(fn)`, `removeInputHandler(fn)` +**Mouse:** `enableMouse()`, `disableMouse()`, `setMousePointer()`, `hitTest()`, `dumpHitGrid()` +**Selection:** `startSelection()`, `updateSelection()`, `clearSelection()`, `getSelection()` +**Terminal:** `setCursorPosition()`, `setCursorStyle()`, `setCursorColor()`, `setTerminalTitle()` +**Clipboard:** `copyToClipboardOSC52()`, `clearClipboardOSC52()`, `isOsc52Supported()` +**Performance:** `setGatherStats()`, `getStats()`, `resetStats()` +**Getters:** `isRunning`, `controlState`, `useMouse`, `resolution`, `capabilities`, `themeMode` + +### Enums + +```typescript +enum MouseButton { LEFT, MIDDLE, RIGHT, WHEEL_UP, WHEEL_DOWN } +enum RendererControlState { IDLE, AUTO_STARTED, EXPLICIT_STARTED, EXPLICIT_PAUSED, EXPLICIT_SUSPENDED, EXPLICIT_STOPPED } +``` + +--- + +## Layout System (Yoga/Flexbox) + +### LayoutOptions + +```typescript +interface LayoutOptions { + width?: number | string + height?: number | string + minWidth?: number | string + maxWidth?: number | string + minHeight?: number | string + maxHeight?: number | string + flexGrow?: number + flexShrink?: number + flexBasis?: number | string + flexDirection?: "row" | "column" | "row-reverse" | "column-reverse" + flexWrap?: "wrap" | "nowrap" | "wrap-reverse" + justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly" + alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline" + alignSelf?: "auto" | "flex-start" | "flex-end" | "center" | "stretch" | "baseline" + alignContent?: "flex-start" | "flex-end" | "center" | "stretch" | "space-between" | "space-around" + position?: "relative" | "absolute" + top?: number + right?: number + bottom?: number + left?: number + padding?: number + paddingTop/Right/Bottom/Left?: number + margin?: number + marginTop/Right/Bottom/Left?: number + overflow?: "visible" | "hidden" | "scroll" +} +``` + +--- + +## RenderableOptions (extends LayoutOptions) + +```typescript +interface RenderableOptions extends LayoutOptions { + id?: string + zIndex?: number + opacity?: number + visibility?: "visible" | "hidden" + renderBefore?: Function + renderAfter?: Function + onMouseDown?: (event: MouseEvent) => void + onMouseUp?: (event: MouseEvent) => void + onClick?: (event: MouseEvent) => void + onMouseMove?: (event: MouseEvent) => void + onScroll?: (event: MouseEvent) => void + onMouseEnter?: (event: MouseEvent) => void + onMouseLeave?: (event: MouseEvent) => void + onKeyDown?: (key: KeyEvent) => void + onPaste?: (event: PasteEvent) => void + onSizeChange?: (width: number, height: number) => void +} +``` + +### Renderable Methods + +- `add(child)`, `insertBefore(child, anchor)`, `remove(id)`, `getChildren()` +- `focus()`, `blur()` +- `destroy()`, `destroyRecursively()` +- `requestRender()` +- `calculateLayout()` +- Getters/setters for all flex properties + +--- + +## Components + +### Box + +```typescript +interface BoxOptions extends RenderableOptions { + backgroundColor?: string | RGBA + borderStyle?: "single" | "double" | "rounded" | "heavy" + border?: boolean | ("top" | "right" | "bottom" | "left")[] + borderColor?: string | RGBA + customBorderChars?: BorderCharacters + shouldFill?: boolean + title?: string + titleAlignment?: "left" | "center" | "right" + focusedBorderColor?: ColorInput + focusable?: boolean + gap?: number | `${number}%` + rowGap?: number | `${number}%` + columnGap?: number | `${number}%` +} +``` + +### Text + +```typescript +interface TextOptions extends TextBufferOptions { + content?: StyledText | string +} + +interface TextBufferOptions extends RenderableOptions { + fg?: string | RGBA + bg?: string | RGBA + selectionBg?: string | RGBA + selectionFg?: string | RGBA + selectable?: boolean + attributes?: number + wrapMode?: "none" | "char" | "word" + tabIndicator?: string | number + tabIndicatorColor?: string | RGBA + truncate?: boolean +} +``` + +**Methods:** `content` (get/set), `add(text)`, `remove(id)`, `clear()`, `plainText` (getter), `scrollY/scrollX` (get/set), `wrapMode` (get/set) + +### Input + +```typescript +interface InputRenderableOptions extends Omit { + value?: string + maxLength?: number // default 1000 + placeholder?: string +} + +enum InputRenderableEvents { + INPUT = "input" + CHANGE = "change" + ENTER = "enter" +} +``` + +**Methods:** `value` (get/set), `maxLength` (get/set), `placeholder` (get/set), `focus()`, `blur()`, `submit()`, `undo()`, `redo()` + +### Textarea + +```typescript +interface TextareaOptions extends EditBufferOptions { + initialValue?: string + backgroundColor?: ColorInput + textColor?: ColorInput + focusedBackgroundColor?: ColorInput + focusedTextColor?: ColorInput + placeholder?: StyledText | string | null + placeholderColor?: ColorInput + keyBindings?: KeyBinding[] + keyAliasMap?: KeyAliasMap + onSubmit?: (event: SubmitEvent) => void +} +``` + +### Select + +```typescript +interface SelectOption { name: string; description: string; value?: any } + +interface SelectRenderableOptions extends RenderableOptions { + options?: SelectOption[] + backgroundColor?: ColorInput + textColor?: ColorInput + focusedBackgroundColor?: ColorInput + focusedTextColor?: ColorInput + selectedBackgroundColor?: ColorInput + selectedTextColor?: ColorInput + descriptionColor?: ColorInput + selectedDescriptionColor?: ColorInput + showScrollIndicator?: boolean + wrapSelection?: boolean + showDescription?: boolean + font?: ASCIIFontName + itemSpacing?: number + fastScrollStep?: number + keyBindings?: SelectKeyBinding[] + keyAliasMap?: KeyAliasMap +} + +enum SelectRenderableEvents { + SELECTION_CHANGED = "selectionChanged" + ITEM_SELECTED = "itemSelected" +} +``` + +**Methods:** `getSelectedOption()`, `getSelectedIndex()`, `moveUp(steps?)`, `moveDown(steps?)`, `selectCurrent()`, `setSelectedIndex(index)`, `handleKeyPress(key)` + +### TabSelect + +```typescript +interface TabSelectOption { name: string; description: string; value?: any } + +interface TabSelectRenderableOptions extends RenderableOptions { + height?: number + options?: TabSelectOption[] + tabWidth?: number + backgroundColor / textColor / focused* / selected*: ColorInput + showScrollArrows?: boolean + showDescription?: boolean + showUnderline?: boolean + wrapSelection?: boolean +} + +enum TabSelectRenderableEvents { SELECTION_CHANGED, ITEM_SELECTED } +``` + +### ScrollBox + +```typescript +interface ScrollBoxOptions extends BoxOptions { + rootOptions?: BoxOptions + wrapperOptions?: BoxOptions + viewportOptions?: BoxOptions + contentOptions?: BoxOptions + scrollbarOptions?: ScrollBarOptions + verticalScrollbarOptions?: ScrollBarOptions + horizontalScrollbarOptions?: ScrollBarOptions + stickyScroll?: boolean + stickyStart?: "top" | "bottom" | "left" | "right" + scrollX?: boolean // default false + scrollY?: boolean // default true + scrollAcceleration?: ScrollAcceleration + viewportCulling?: boolean // default true +} +``` + +**Properties:** `wrapper`, `viewport`, `content`, `scrollTop` (get/set), `scrollLeft` (get/set), `scrollWidth`, `scrollHeight` +**Methods:** `scrollBy(delta, unit?)`, `scrollTo(position)`, `add()`, `insertBefore()`, `remove()`, `getChildren()` +**ScrollUnit:** `"absolute" | "viewport" | "content" | "step"` + +### ScrollBar + +```typescript +interface ScrollBarOptions extends RenderableOptions { + orientation: "vertical" | "horizontal" + showArrows?: boolean + arrowOptions?: Omit + trackOptions?: Partial + onChange?: (position: number) => void +} +``` + +### Slider + +```typescript +interface SliderOptions extends RenderableOptions { + orientation: "vertical" | "horizontal" + value?: number + min?: number + max?: number + viewPortSize?: number + backgroundColor?: ColorInput + foregroundColor?: ColorInput + onChange?: (value: number) => void +} +``` + +### Code + +```typescript +interface CodeOptions extends TextBufferOptions { + content?: string + filetype?: string + syntaxStyle: SyntaxStyle // required + treeSitterClient?: TreeSitterClient + conceal?: boolean + drawUnstyledText?: boolean + streaming?: boolean + onHighlight?: OnHighlightCallback +} +``` + +### Markdown + +```typescript +interface MarkdownOptions extends RenderableOptions { + content?: string + syntaxStyle: SyntaxStyle + conceal?: boolean + treeSitterClient?: TreeSitterClient + streaming?: boolean + renderNode?: (token: Token, context: RenderNodeContext) => Renderable | undefined | null +} +``` + +### ASCIIFont + +```typescript +interface ASCIIFontOptions extends RenderableOptions { + text?: string + font?: ASCIIFontName // e.g. "tiny" + color?: ColorInput | ColorInput[] + backgroundColor?: ColorInput + selectable?: boolean +} +``` + +### Diff + +```typescript +interface DiffRenderableOptions extends RenderableOptions { + diff?: string + view?: "unified" | "split" + fg?: ColorInput + filetype?: string + syntaxStyle?: SyntaxStyle + wrapMode?: "none" | "char" | "word" + showLineNumbers?: boolean + addedBg / removedBg / contextBg: ColorInput + addedContentBg / removedContentBg / contextContentBg: ColorInput +} +``` + +### FrameBuffer + +```typescript +interface FrameBufferOptions extends RenderableOptions { + width: number // required + height: number // required + respectAlpha?: boolean +} +``` + +### TextTable + +```typescript +interface TextTableOptions extends RenderableOptions { + content?: TextTableCellContent[][] + wrapMode?: "none" | "char" | "word" + columnWidthMode?: "content" | "fill" + cellPadding?: number + showBorders?: boolean + border?: boolean + outerBorder?: boolean + borderStyle?: BorderStyle + borderColor / borderBackgroundColor / backgroundColor: ColorInput +} +``` + +--- + +## Color System + +```typescript +type ColorInput = string | RGBA + +class RGBA extends Float32Array { + static fromHex(hex: string): RGBA + static fromInts(r: number, g: number, b: number, a?: number): RGBA + static fromValues(r: number, g: number, b: number, a?: number): RGBA + static fromArray(arr: number[]): RGBA + get r/g/b/a(): number + set r/g/b/a(v: number) + toInts(): [number, number, number, number] + equals(other: RGBA): boolean +} +``` + +CSS color names (28): red, green, blue, yellow, cyan, magenta, white, black, gray, orange, pink, purple, brightRed, brightGreen, brightBlue, brightYellow, brightCyan, brightMagenta, brightWhite, etc. + +--- + +## Styled Text + +```typescript +import { t, bold, italic, underline, strikethrough, dim, reverse, blink, fg, bg, link } from "@opentui/core" + +// Tagged template +t`${bold("Hello")} ${fg("#FF0000")("World")}` + +// Style functions (nestable) +bold(text) +italic(text) +underline(text) +strikethrough(text) +dim(text) +fg(color)(text) +bg(color)(text) +link(url)(text) + +// Named color functions +red(text), green(text), blue(text), yellow(text), cyan(text), magenta(text), white(text), black(text), gray(text) +brightRed(text), brightGreen(text), brightBlue(text), brightYellow(text), brightCyan(text), brightMagenta(text), brightWhite(text) +bgRed(text), bgGreen(text), bgBlue(text), bgYellow(text), bgCyan(text), bgMagenta(text), bgWhite(text), bgBlack(text) +``` + +--- + +## Keyboard + +### KeyEvent + +```typescript +class KeyEvent { + name: string + ctrl: boolean + meta: boolean + shift: boolean + option: boolean + sequence: string + raw: string + eventType: KeyEventType + source: "raw" | "kitty" + preventDefault(): void + stopPropagation(): void + get defaultPrevented(): boolean + get propagationStopped(): boolean +} +``` + +### PasteEvent + +```typescript +class PasteEvent { + text: string + preventDefault(): void + stopPropagation(): void +} +``` + +### Key Binding System + +```typescript +interface KeyBinding { + name: string + action: T + ctrl?: boolean + shift?: boolean + meta?: boolean + super?: boolean +} +``` + +--- + +## Animation + +```typescript +import { createTimeline } from "@opentui/core" + +const tl = createTimeline({ duration?: number, loop?: boolean, autoplay?: boolean }) + +tl.add({ + targets: any, + duration: number, + ease?: EasingFunction, + onUpdate?: (anim: JSAnimation) => void, + onComplete?: () => void, + loop?: boolean, + alternate?: boolean, +}) +``` + +**Easing functions:** linear, inQuad, outQuad, inOutQuad, inExpo, outExpo, inOutSine, outBounce, inBounce, outElastic, inCirc, outCirc, inOutCirc, inBack, outBack, inOutBack + +--- + +## VNode System + +```typescript +import { h } from "@opentui/core" + +// Hyperscript-style (with Proxy for method chaining) +h(Box, { width: 100 }, h(Text, { content: "hello" })) + +// Or use factory functions directly +Box({ width: 100 }, Text({ content: "hello" })) +``` + +**Functions:** `isVNode(value)`, `instantiate(vnode, ctx)`, `delegate(vnode, mapping)`, `flattenChildren(children)` + +--- + +## Exports + +The package re-exports from: Renderable, types, utils, buffer, text-buffer, edit-buffer, syntax-style, animation/Timeline, lib (KeyHandler, RGBA, border, etc.), renderer, renderables (all components), console, Yoga namespace. diff --git a/package.json b/package.json index e34c3bf..79170a6 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { - "name": "tui-claude-director", + "name": "cladm", "module": "index.ts", "type": "module", - "private": true, "devDependencies": { "@types/bun": "latest" }, diff --git a/src/actions/launcher.ts b/src/actions/launcher.ts new file mode 100644 index 0000000..5c66bdd --- /dev/null +++ b/src/actions/launcher.ts @@ -0,0 +1,97 @@ +import type { Project } from "../lib/types" + +interface LaunchItem { + path: string + sessionId?: string + targetBranch?: string +} + +export async function launchSelections( + projects: Project[], + selectedProjects: Set, + selectedSessions: Set, + selectedBranches: Map = new Map() +): Promise { + const byProject = new Map() + + for (const path of selectedProjects) { + if (!byProject.has(path)) byProject.set(path, []) + const targetBranch = selectedBranches.get(path) + const project = projects.find(p => p.path === path) + const needsBranch = targetBranch && project && targetBranch !== project.branch + byProject.get(path)!.push({ path, targetBranch: needsBranch ? targetBranch : undefined }) + } + + for (const project of projects) { + if (!project.sessions) continue + for (const session of project.sessions) { + if (selectedSessions.has(session.id)) { + if (!byProject.has(project.path)) byProject.set(project.path, []) + const targetBranch = selectedBranches.get(project.path) + const needsBranch = targetBranch && targetBranch !== project.branch + byProject.get(project.path)!.push({ + path: project.path, + sessionId: session.id, + targetBranch: needsBranch ? targetBranch : undefined, + }) + } + } + } + + let count = 0 + for (const [, items] of byProject) { + const first = items[0] + const firstCmd = buildCmd(first) + + const newWindowScript = [ + 'tell application "Terminal"', + " activate", + ` do script "${escapeAS(firstCmd)}"`, + "end tell", + ].join("\n") + + await runOsascript(newWindowScript) + count++ + + for (let i = 1; i < items.length; i++) { + await Bun.sleep(400) + const cmd = buildCmd(items[i]) + + await runOsascript( + 'tell application "System Events" to keystroke "t" using command down' + ) + await Bun.sleep(300) + await runOsascript( + `tell application "Terminal" to do script "${escapeAS(cmd)}" in front window` + ) + count++ + } + + await Bun.sleep(300) + } + + return count +} + +function buildCmd(item: LaunchItem): string { + const base = `cd '${item.path}' && claude --dangerously-skip-permissions` + const branchFlag = item.targetBranch + ? ` -p "switch to branch ${item.targetBranch}, stash if needed"` + : "" + if (item.sessionId) { + return `${base} --resume '${item.sessionId}'${branchFlag}` + } + return `${base}${branchFlag}` +} + +function escapeAS(str: string): string { + return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"') +} + +async function runOsascript(script: string): Promise { + const proc = Bun.spawn(["osascript", "-e", script], { + stdout: "ignore", + stderr: "ignore", + }) + await proc.exited +} diff --git a/src/data/git.ts b/src/data/git.ts new file mode 100644 index 0000000..adf675e --- /dev/null +++ b/src/data/git.ts @@ -0,0 +1,127 @@ +import type { BranchInfo, Project } from "../lib/types" +import { timeAgo } from "../lib/time" + +async function gitCmd(path: string, ...args: string[]): Promise { + const proc = Bun.spawn(["git", "-C", path, ...args], { + stdout: "pipe", + stderr: "ignore", + }) + const text = await new Response(proc.stdout).text() + const code = await proc.exited + if (code !== 0) throw new Error(`git failed: ${code}`) + return text.trim() +} + +export async function loadGitMetadata(project: Project): Promise { + const path = project.path + + const [branchResult, logResult, statusResult, syncResult] = await Promise.allSettled([ + gitCmd(path, "rev-parse", "--abbrev-ref", "HEAD"), + gitCmd(path, "log", "-1", "--format=%ct|%s"), + gitCmd(path, "status", "--porcelain"), + gitCmd(path, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"), + ]) + + if (branchResult.status === "fulfilled") { + project.branch = branchResult.value || "-" + } else { + project.branch = "-" + } + + if (logResult.status === "fulfilled") { + const raw = logResult.value + const pipeIdx = raw.indexOf("|") + if (pipeIdx > -1) { + project.commitEpoch = parseInt(raw.slice(0, pipeIdx)) || 0 + project.commitAge = timeAgo(project.commitEpoch * 1000) + const msg = raw.slice(pipeIdx + 1) + project.commitMsg = msg.length > 22 ? msg.slice(0, 19) + "..." : msg + } + } + + if (statusResult.status === "fulfilled") { + const lines = statusResult.value.split("\n").filter(Boolean) + let staged = 0, + unstaged = 0, + untracked = 0 + for (const line of lines) { + if (line.length < 2) continue + const x = line[0], + y = line[1] + if (x === "?" && y === "?") { + untracked++ + } else { + if (x !== " " && x !== "?") staged++ + if (y !== " " && y !== "?") unstaged++ + } + } + const parts: string[] = [] + if (staged > 0) parts.push(`+${staged}`) + if (unstaged > 0) parts.push(`~${unstaged}`) + if (untracked > 0) parts.push(`?${untracked}`) + project.dirty = parts.join(" ") + } + + if (syncResult.status === "fulfilled") { + const parts = syncResult.value.split("\t") + project.ahead = parseInt(parts[0]) || 0 + project.behind = parseInt(parts[1]) || 0 + } else { + project.ahead = -1 + project.behind = -1 + } +} + +export async function loadBranches(projectPath: string): Promise { + let raw: string + try { + raw = await gitCmd( + projectPath, + "branch", + "--sort=-committerdate", + "--format=%(refname:short)|%(HEAD)|%(committerdate:unix)|%(subject)", + ) + } catch { + return [] + } + + if (!raw) return [] + + const lines = raw.split("\n").filter(Boolean) + const top = lines.slice(0, 5) + + const branches: BranchInfo[] = [] + for (const line of top) { + const parts = line.split("|") + if (parts.length < 4) continue + + const name = parts[0] + const isCurrent = parts[1] === "*" + const epoch = parseInt(parts[2]) || 0 + const subject = parts.slice(3).join("|") + const lastCommitAge = timeAgo(epoch * 1000) + const lastCommitMsg = subject.length > 40 ? subject.slice(0, 37) + "..." : subject + + let ahead = -1 + let behind = -1 + try { + const syncOut = await gitCmd( + projectPath, + "rev-list", + "--left-right", + "--count", + `${name}...${name}@{upstream}`, + ) + const syncParts = syncOut.split("\t") + ahead = parseInt(syncParts[0]) || 0 + behind = parseInt(syncParts[1]) || 0 + } catch { + ahead = -1 + behind = -1 + } + + branches.push({ name, isCurrent, lastCommitAge, lastCommitMsg, ahead, behind }) + } + + return branches +} diff --git a/src/data/history.ts b/src/data/history.ts new file mode 100644 index 0000000..a1425ef --- /dev/null +++ b/src/data/history.ts @@ -0,0 +1,75 @@ +import { existsSync, readdirSync } from "node:fs" +import { join, relative } from "node:path" +import { getTags } from "../lib/tags" +import { timeAgo } from "../lib/time" +import type { Project } from "../lib/types" + +const SCAN_ROOT = `${Bun.env.HOME}/Desktop` +const HISTORY_PATH = `${Bun.env.HOME}/.claude/history.jsonl` +const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects` + +interface HistoryAgg { + msgs: number + last: number +} + +export async function discoverProjects(): Promise { + const file = Bun.file(HISTORY_PATH) + if (!(await file.exists())) return [] + + const text = await file.text() + const agg = new Map() + + for (const line of text.split("\n")) { + if (!line.trim()) continue + try { + const d = JSON.parse(line) + const p = d.project as string + const ts = (d.timestamp as number) || 0 + if (p && p.startsWith(SCAN_ROOT + "/") && p !== SCAN_ROOT) { + let info = agg.get(p) + if (!info) { + info = { msgs: 0, last: 0 } + agg.set(p, info) + } + info.msgs++ + info.last = Math.max(info.last, ts) + } + } catch {} + } + + const projects: Project[] = [] + for (const [path, info] of agg) { + if (!existsSync(path)) continue + + const dirName = path.replaceAll("/", "-") + const projDir = join(PROJECTS_DIR, dirName) + let sessionCount = 0 + try { + sessionCount = readdirSync(projDir).filter((f) => f.endsWith(".jsonl")).length + } catch {} + + projects.push({ + path, + name: relative(SCAN_ROOT, path), + branch: "", + commitAge: "", + commitMsg: "", + commitEpoch: 0, + dirty: "", + ahead: 0, + behind: 0, + claudeAgo: timeAgo(info.last), + claudeLastMs: info.last, + sessionCount, + totalMessages: info.msgs, + tags: getTags(path), + expanded: false, + sessions: null, + branches: null, + }) + } + + projects.sort((a, b) => b.claudeLastMs - a.claudeLastMs) + return projects +} diff --git a/src/data/mock.ts b/src/data/mock.ts new file mode 100644 index 0000000..0bc337e --- /dev/null +++ b/src/data/mock.ts @@ -0,0 +1,410 @@ +import type { Project, SessionInfo, BranchInfo } from "../lib/types" + +const now = Date.now() +const hour = 3_600_000 +const day = 86_400_000 + +export function generateMockProjects(): Project[] { + return [ + { + path: "/Users/demo/projects/acme-api", + name: "acme-api", + branch: "main", + commitAge: "2h ago", + commitMsg: "fix: rate limiter edge case", + commitEpoch: (now - 2 * hour) / 1000, + dirty: "~2 ?1", + ahead: 1, + behind: 0, + claudeAgo: "25m ago", + claudeLastMs: now - 25 * 60_000, + sessionCount: 14, + totalMessages: 342, + tags: "ts bun hono", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/quantum-dashboard", + name: "quantum-dashboard", + branch: "feat/cha", + commitAge: "5h ago", + commitMsg: "add chart tooltip comp", + commitEpoch: (now - 5 * hour) / 1000, + dirty: "+3 ~1", + ahead: 3, + behind: 0, + claudeAgo: "1h ago", + claudeLastMs: now - 1 * hour, + sessionCount: 8, + totalMessages: 187, + tags: "ts react vite", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/pixel-engine", + name: "pixel-engine", + branch: "develop", + commitAge: "1d ago", + commitMsg: "perf: batch draw calls", + commitEpoch: (now - 1 * day) / 1000, + dirty: "", + ahead: 0, + behind: 0, + claudeAgo: "3h ago", + claudeLastMs: now - 3 * hour, + sessionCount: 22, + totalMessages: 891, + tags: "rust wgpu", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/infractl", + name: "infractl", + branch: "main", + commitAge: "3d ago", + commitMsg: "docs: update deploy guide", + commitEpoch: (now - 3 * day) / 1000, + dirty: "?2", + ahead: 0, + behind: 2, + claudeAgo: "2d ago", + claudeLastMs: now - 2 * day, + sessionCount: 5, + totalMessages: 78, + tags: "go docker k8s", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/notely-mobile", + name: "notely-mobile", + branch: "release", + commitAge: "6h ago", + commitMsg: "bump version to 2.4.1", + commitEpoch: (now - 6 * hour) / 1000, + dirty: "", + ahead: 0, + behind: 0, + claudeAgo: "5h ago", + claudeLastMs: now - 5 * hour, + sessionCount: 11, + totalMessages: 256, + tags: "ts rn expo", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/blog-engine", + name: "blog-engine", + branch: "main", + commitAge: "7d ago", + commitMsg: "feat: RSS feed generation", + commitEpoch: (now - 7 * day) / 1000, + dirty: "~1", + ahead: 0, + behind: 0, + claudeAgo: "5d ago", + claudeLastMs: now - 5 * day, + sessionCount: 3, + totalMessages: 45, + tags: "ts astro mdx", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/ml-pipeline", + name: "ml-pipeline", + branch: "exp/bert", + commitAge: "12h ago", + commitMsg: "tune hyperparams batch 3", + commitEpoch: (now - 12 * hour) / 1000, + dirty: "+1 ~4", + ahead: 7, + behind: 0, + claudeAgo: "just now", + claudeLastMs: now - 60_000, + sessionCount: 19, + totalMessages: 523, + tags: "py torch", + expanded: false, + sessions: null, + branches: null, + }, + { + path: "/Users/demo/projects/auth-service", + name: "auth-service", + branch: "main", + commitAge: "14d ago", + commitMsg: "chore: dep updates", + commitEpoch: (now - 14 * day) / 1000, + dirty: "", + ahead: -1, + behind: -1, + claudeAgo: "12d ago", + claudeLastMs: now - 12 * day, + sessionCount: 2, + totalMessages: 31, + tags: "ts express pg", + expanded: false, + sessions: null, + branches: null, + }, + ] +} + +const mockSessionData: Record = { + "acme-api": [ + { + id: "demo-acme-1", + timestamp: now - 25 * 60_000, + title: "Fix rate limiter to handle burst traffic correctly", + lastUserPrompt: "The rate limiter lets through 2x the limit when requests arrive simultaneously", + lastAssistantMsg: "Found the race condition in the sliding window counter. Fixed by using atomic increment.", + branch: "main", + sizeBytes: 48200, + }, + { + id: "demo-acme-2", + timestamp: now - 4 * hour, + title: "Add pagination to /api/users endpoint", + lastUserPrompt: "Add cursor-based pagination to the users list endpoint", + lastAssistantMsg: "Implemented cursor-based pagination using the user ID as cursor. Added limit param with max 100.", + branch: "main", + sizeBytes: 31400, + }, + { + id: "demo-acme-3", + timestamp: now - 2 * day, + title: "Set up OpenTelemetry tracing", + lastUserPrompt: "Configure otel tracing with jaeger exporter for all HTTP handlers", + lastAssistantMsg: "Added @opentelemetry/sdk-node with Jaeger exporter. Wrapped all Hono routes with span creation.", + branch: "feat/observability", + sizeBytes: 67800, + }, + ], + "quantum-dashboard": [ + { + id: "demo-quantum-1", + timestamp: now - 1 * hour, + title: "Build interactive chart tooltip component", + lastUserPrompt: "Create a tooltip that shows data point details on hover with smooth transitions", + lastAssistantMsg: "Created ChartTooltip with Framer Motion transitions. Uses portal to avoid clipping.", + branch: "feat/charts", + sizeBytes: 52100, + }, + { + id: "demo-quantum-2", + timestamp: now - 1 * day, + title: "Implement dark mode theme switching", + lastUserPrompt: "Add dark mode toggle that persists in localStorage", + lastAssistantMsg: "Added ThemeProvider with CSS variables approach. Toggle syncs to localStorage and system preference.", + branch: "main", + sizeBytes: 38900, + }, + ], + "pixel-engine": [ + { + id: "demo-pixel-1", + timestamp: now - 3 * hour, + title: "Batch draw calls for sprite rendering", + lastUserPrompt: "Sprite rendering is too slow with individual draw calls, need batching", + lastAssistantMsg: "Implemented instanced rendering with a shared vertex buffer. 60fps stable at 10k sprites now.", + branch: "develop", + sizeBytes: 89300, + }, + { + id: "demo-pixel-2", + timestamp: now - 3 * day, + title: "Add tilemap collision detection", + lastUserPrompt: "Need AABB collision against tilemap layers with slope support", + lastAssistantMsg: "Added spatial hash grid for broad phase, then precise AABB vs tile shape. Slopes use SAT.", + branch: "develop", + sizeBytes: 112400, + }, + { + id: "demo-pixel-3", + timestamp: now - 5 * day, + title: "Implement ECS architecture", + lastUserPrompt: "Refactor the game objects into an ECS pattern with archetypes", + lastAssistantMsg: "Built archetype-based ECS with sparse sets. Component queries use bitmasked signature matching.", + branch: "feat/ecs", + sizeBytes: 156700, + }, + { + id: "demo-pixel-4", + timestamp: now - 8 * day, + title: "WebGPU compute shader for particles", + lastUserPrompt: "Move particle simulation to a compute shader", + lastAssistantMsg: "Particle update runs in compute shader now. 100k particles at 60fps. Uses ping-pong buffers.", + branch: "develop", + sizeBytes: 73200, + }, + ], + "infractl": [ + { + id: "demo-infra-1", + timestamp: now - 2 * day, + title: "Add rollback command for deployments", + lastUserPrompt: "Need a rollback subcommand that reverts to the previous deployment version", + lastAssistantMsg: "Added `infractl rollback` with --revision flag. Tracks deployment history in etcd.", + branch: "main", + sizeBytes: 41200, + }, + { + id: "demo-infra-2", + timestamp: now - 6 * day, + title: "Implement health check polling", + lastUserPrompt: "After deploy, poll the health endpoint until ready or timeout", + lastAssistantMsg: "Added exponential backoff health polling with configurable timeout and retries.", + branch: "main", + sizeBytes: 28700, + }, + ], + "notely-mobile": [ + { + id: "demo-notely-1", + timestamp: now - 5 * hour, + title: "Fix offline sync conflict resolution", + lastUserPrompt: "Notes edited offline are overwriting server changes on sync", + lastAssistantMsg: "Switched to CRDT-based merge for note content. Conflicts now auto-resolve with last-write-wins for metadata.", + branch: "release", + sizeBytes: 64500, + }, + { + id: "demo-notely-2", + timestamp: now - 1 * day, + title: "Add biometric auth for app lock", + lastUserPrompt: "Implement FaceID/fingerprint unlock when opening the app", + lastAssistantMsg: "Used expo-local-authentication for biometrics. Falls back to PIN. Configurable in settings.", + branch: "feat/security", + sizeBytes: 35800, + }, + { + id: "demo-notely-3", + timestamp: now - 4 * day, + title: "Optimize list rendering for large notebooks", + lastUserPrompt: "The notes list stutters when you have 500+ notes", + lastAssistantMsg: "Replaced FlatList with FlashList and added virtualization. Smooth scrolling at 1000 notes.", + branch: "main", + sizeBytes: 29100, + }, + ], + "blog-engine": [ + { + id: "demo-blog-1", + timestamp: now - 5 * day, + title: "Generate RSS feed from MDX posts", + lastUserPrompt: "Add an RSS 2.0 feed at /feed.xml built from the MDX content collection", + lastAssistantMsg: "Added RSS generation in the Astro build pipeline. Extracts frontmatter for title, date, description.", + branch: "main", + sizeBytes: 22300, + }, + ], + "ml-pipeline": [ + { + id: "demo-ml-1", + timestamp: now - 60_000, + title: "Hyperparameter tuning batch 3 results", + lastUserPrompt: "Run the next batch of hyperparameter configs and log results to wandb", + lastAssistantMsg: "Batch 3 complete. Best config: lr=2e-5, warmup=500, dropout=0.1. F1 improved to 0.847.", + branch: "exp/bert", + sizeBytes: 94600, + }, + { + id: "demo-ml-2", + timestamp: now - 8 * hour, + title: "Add data augmentation pipeline", + lastUserPrompt: "Implement text augmentation with synonym replacement and back-translation", + lastAssistantMsg: "Added augmentation transforms: synonym swap, back-translation via MarianMT, random insertion.", + branch: "exp/bert", + sizeBytes: 71200, + }, + { + id: "demo-ml-3", + timestamp: now - 2 * day, + title: "Set up DVC for dataset versioning", + lastUserPrompt: "Configure DVC with S3 remote for versioning the training datasets", + lastAssistantMsg: "Initialized DVC with S3 backend. Added .dvc files for train/val/test splits. Pipeline stages defined.", + branch: "main", + sizeBytes: 45300, + }, + { + id: "demo-ml-4", + timestamp: now - 5 * day, + title: "Implement model evaluation dashboard", + lastUserPrompt: "Create a streamlit dashboard showing model metrics across experiments", + lastAssistantMsg: "Built Streamlit app with confusion matrix, per-class metrics, and experiment comparison charts.", + branch: "main", + sizeBytes: 58100, + }, + ], + "auth-service": [ + { + id: "demo-auth-1", + timestamp: now - 12 * day, + title: "Update JWT refresh token rotation", + lastUserPrompt: "Implement refresh token rotation to prevent token reuse attacks", + lastAssistantMsg: "Added one-time-use refresh tokens with family tracking. Reuse detection invalidates the whole family.", + branch: "main", + sizeBytes: 37400, + }, + ], +} + +const mockBranchData: Record = { + "acme-api": [ + { name: "main", isCurrent: true, lastCommitAge: "2h ago", lastCommitMsg: "fix: rate limiter edge case", ahead: 1, behind: 0 }, + { name: "feat/observability", isCurrent: false, lastCommitAge: "2d ago", lastCommitMsg: "add jaeger tracing config", ahead: 4, behind: 0 }, + { name: "feat/webhooks", isCurrent: false, lastCommitAge: "5d ago", lastCommitMsg: "webhook retry with backoff", ahead: 2, behind: 3 }, + ], + "quantum-dashboard": [ + { name: "feat/charts", isCurrent: true, lastCommitAge: "5h ago", lastCommitMsg: "add chart tooltip comp", ahead: 3, behind: 0 }, + { name: "main", isCurrent: false, lastCommitAge: "2d ago", lastCommitMsg: "merge: auth flow redesign", ahead: 0, behind: 0 }, + { name: "fix/memory-leak", isCurrent: false, lastCommitAge: "4d ago", lastCommitMsg: "dispose chart instances on unmount", ahead: 1, behind: 5 }, + ], + "pixel-engine": [ + { name: "develop", isCurrent: true, lastCommitAge: "1d ago", lastCommitMsg: "perf: batch draw calls", ahead: 0, behind: 0 }, + { name: "feat/ecs", isCurrent: false, lastCommitAge: "5d ago", lastCommitMsg: "archetype-based ECS impl", ahead: 12, behind: 2 }, + { name: "main", isCurrent: false, lastCommitAge: "10d ago", lastCommitMsg: "v0.3.0 release", ahead: 0, behind: 0 }, + ], + "infractl": [ + { name: "main", isCurrent: true, lastCommitAge: "3d ago", lastCommitMsg: "docs: update deploy guide", ahead: 0, behind: 2 }, + { name: "feat/canary", isCurrent: false, lastCommitAge: "8d ago", lastCommitMsg: "canary deploy strategy WIP", ahead: 3, behind: 4 }, + ], + "notely-mobile": [ + { name: "release", isCurrent: true, lastCommitAge: "6h ago", lastCommitMsg: "bump version to 2.4.1", ahead: 0, behind: 0 }, + { name: "feat/security", isCurrent: false, lastCommitAge: "1d ago", lastCommitMsg: "biometric auth integration", ahead: 5, behind: 0 }, + { name: "main", isCurrent: false, lastCommitAge: "3d ago", lastCommitMsg: "merge: offline sync fixes", ahead: 0, behind: 0 }, + ], + "blog-engine": [ + { name: "main", isCurrent: true, lastCommitAge: "7d ago", lastCommitMsg: "feat: RSS feed generation", ahead: 0, behind: 0 }, + { name: "feat/search", isCurrent: false, lastCommitAge: "12d ago", lastCommitMsg: "pagefind integration WIP", ahead: 2, behind: 1 }, + ], + "ml-pipeline": [ + { name: "exp/bert", isCurrent: true, lastCommitAge: "12h ago", lastCommitMsg: "tune hyperparams batch 3", ahead: 7, behind: 0 }, + { name: "main", isCurrent: false, lastCommitAge: "3d ago", lastCommitMsg: "merge: DVC setup", ahead: 0, behind: 0 }, + { name: "exp/t5", isCurrent: false, lastCommitAge: "6d ago", lastCommitMsg: "T5-small baseline results", ahead: 5, behind: 3 }, + ], + "auth-service": [ + { name: "main", isCurrent: true, lastCommitAge: "14d ago", lastCommitMsg: "chore: dep updates", ahead: -1, behind: -1 }, + ], +} + +export function generateMockSessions(projectPath: string): SessionInfo[] { + const name = projectPath.split("/").pop() || "" + return mockSessionData[name] || [] +} + +export function generateMockBranches(projectPath: string): BranchInfo[] { + const name = projectPath.split("/").pop() || "" + return mockBranchData[name] || [] +} diff --git a/src/data/sessions.ts b/src/data/sessions.ts new file mode 100644 index 0000000..db4652e --- /dev/null +++ b/src/data/sessions.ts @@ -0,0 +1,116 @@ +import { readdirSync } from "node:fs" +import { join } from "node:path" +import type { SessionInfo } from "../lib/types" + +const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects` + +export async function loadSessions(projectPath: string): Promise { + const dirName = projectPath.replaceAll("/", "-") + const projDir = join(PROJECTS_DIR, dirName) + + let files: string[] + try { + files = readdirSync(projDir).filter((f) => f.endsWith(".jsonl")) + } catch { + return [] + } + + const sessions = await Promise.all( + files.map((f) => extractSessionInfo(join(projDir, f), f.replace(".jsonl", ""))) + ) + + return sessions + .filter((s): s is SessionInfo => s !== null) + .sort((a, b) => b.timestamp - a.timestamp) +} + +async function extractSessionInfo( + filePath: string, + sessionId: string +): Promise { + try { + const file = Bun.file(filePath) + const size = file.size + if (size === 0) return null + + const headSize = Math.min(size, 15 * 1024) + const headText = await file.slice(0, headSize).text() + const headLines = headText.split("\n") + + const tailSize = Math.min(size, 60 * 1024) + const tailText = await file.slice(Math.max(0, size - tailSize), size).text() + const tailLines = tailText.split("\n") + if (tailSize < size) tailLines.shift() + + let title = "" + let firstTimestamp = 0 + let branch = "" + + for (const line of headLines) { + if (!line.trim()) continue + try { + const d = JSON.parse(line) + if (!firstTimestamp && d.timestamp) { + firstTimestamp = new Date(d.timestamp).getTime() + } + if (!branch && d.gitBranch) branch = d.gitBranch + if (d.type === "user" && typeof d.message?.content === "string") { + title = d.message.content + break + } + } catch {} + } + + let lastUserPrompt = "" + let lastAssistantMsg = "" + let lastTimestamp = 0 + + for (let i = tailLines.length - 1; i >= 0; i--) { + if (lastUserPrompt && lastAssistantMsg) break + const line = tailLines[i] + if (!line.trim()) continue + try { + const d = JSON.parse(line) + if (d.timestamp) { + const ts = new Date(d.timestamp).getTime() + if (ts > lastTimestamp) lastTimestamp = ts + } + if ( + !lastUserPrompt && + d.type === "user" && + typeof d.message?.content === "string" + ) { + lastUserPrompt = d.message.content + } + if (!lastAssistantMsg && d.type === "assistant" && Array.isArray(d.message?.content)) { + for (const block of d.message.content) { + if (block.type === "text" && block.text) { + lastAssistantMsg = block.text + break + } + } + } + } catch {} + } + + if (!title && !lastUserPrompt) return null + + return { + id: sessionId, + timestamp: lastTimestamp || firstTimestamp, + title: cleanText(title || lastUserPrompt, 120), + lastUserPrompt: cleanText(lastUserPrompt, 300), + lastAssistantMsg: cleanText(lastAssistantMsg, 300), + branch, + sizeBytes: size, + } + } catch { + return null + } +} + +function cleanText(text: string, maxLen: number): string { + const cleaned = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim() + if (cleaned.length > maxLen) return cleaned.slice(0, maxLen - 3) + "..." + return cleaned +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..354a0bd --- /dev/null +++ b/src/index.ts @@ -0,0 +1,605 @@ +import { + createCliRenderer, + Box, + Text, + BoxRenderable, + TextRenderable, + ScrollBoxRenderable, + t, + bold, + dim, + fg, + green, + yellow, + cyan, + magenta, + type KeyEvent, + type CliRenderer, +} from "@opentui/core" +import { discoverProjects } from "./data/history" +import { loadGitMetadata, loadBranches } from "./data/git" +import { loadSessions } from "./data/sessions" +import { generateMockProjects, generateMockSessions, generateMockBranches } from "./data/mock" +import { launchSelections } from "./actions/launcher" +import type { Project, DisplayRow } from "./lib/types" +import { timeAgo, formatSize } from "./lib/time" + +// ─── Theme ────────────────────────────────────────────────────────── +const CURSOR_BG = "#283457" +const ACCENT = "#7aa2f7" +const DIM_CLR = "#565f89" + +// ─── State ────────────────────────────────────────────────────────── +const demoMode = Bun.argv.includes("--demo") +let projects: Project[] = [] +const selectedProjects = new Set() +const selectedSessions = new Set() +const selectedBranches = new Map() +let cursor = 0 +let sortMode = 0 +const sortLabels = ["recent", "name", "commit", "sessions"] +let sortedIndices: number[] = [] +let displayRows: DisplayRow[] = [] + +// ─── UI Refs ──────────────────────────────────────────────────────── +let renderer: CliRenderer +let headerText: TextRenderable +let colHeaderText: TextRenderable +let listBox: ScrollBoxRenderable +let previewText: TextRenderable +let footerText: TextRenderable + +// ─── Display Rows ─────────────────────────────────────────────────── +function rebuildDisplayRows() { + displayRows = [] + for (const idx of sortedIndices) { + const project = projects[idx] + displayRows.push({ type: "project", projectIndex: idx }) + if (project.expanded) { + if (project.branches) { + for (const br of project.branches) { + if (!br.isCurrent) { + displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name }) + } + } + } + if (project.sessions) { + for (let si = 0; si < project.sessions.length; si++) { + displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si }) + } + } + displayRows.push({ type: "new-session", projectIndex: idx }) + } + } +} + +// ─── Sort ─────────────────────────────────────────────────────────── +function applySortMode() { + const indices = Array.from(projects.keys()) + switch (sortMode) { + case 0: + sortedIndices = indices + break + case 1: + sortedIndices = indices.sort((a, b) => + projects[a].name.localeCompare(projects[b].name) + ) + break + case 2: + sortedIndices = indices.sort( + (a, b) => (projects[b].commitEpoch || 0) - (projects[a].commitEpoch || 0) + ) + break + case 3: + sortedIndices = indices.sort( + (a, b) => projects[b].sessionCount - projects[a].sessionCount + ) + break + } + rebuildDisplayRows() +} + +// ─── Row Formatting ───────────────────────────────────────────────── +function fmtSyncIndicator(ahead: number, behind: number): string { + if (ahead === -1 && behind === -1) return "✗" + if (ahead === 0 && behind === 0) return "✓" + const parts: string[] = [] + if (ahead > 0) parts.push(`↑${ahead}`) + if (behind > 0) parts.push(`↓${behind}`) + return parts.join("") +} + +function fmtProjectRow(project: Project, isSelected: boolean) { + const check = isSelected ? green("✓") : " " + const arrow = project.expanded ? "▼" : "▶" + const name = + project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name + const branch = + project.branch.length > 8 + ? project.branch.slice(0, 7) + "…" + : project.branch + + const sync = fmtSyncIndicator(project.ahead, project.behind) + const syncCol = sync === "✓" ? green(sync.padEnd(5)) + : sync === "✗" ? dim(sync.padEnd(5)) + : yellow(sync.padEnd(5)) + + const dirtyCol = project.dirty + ? yellow(project.dirty.padEnd(9)) + : green("clean".padEnd(9)) + + const ca = project.claudeAgo + let claudeCol + if (ca === "never" || ca === "-") claudeCol = dim(ca.padEnd(9)) + else if (ca.includes("m ago") || ca.includes("h ago") || ca === "just now") + claudeCol = cyan(ca.padEnd(9)) + else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9)) + else claudeCol = dim(ca.padEnd(9)) + + return t` [${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( + (project.commitAge || "-").padEnd(10) + )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( + String(project.sessionCount).padStart(3) + )} ${dim(String(project.totalMessages).padStart(5))} ${dim(project.tags)}` +} + +function fmtSessionRow( + projectIdx: number, + sessionIdx: number, + isSelected: boolean, + isLastSession: boolean +) { + const project = projects[projectIdx] + const session = project.sessions![sessionIdx] + const check = isSelected ? green("✓") : " " + const prefix = isLastSession ? "│ " : "├─" + const title = + session.title.length > 55 + ? session.title.slice(0, 52) + "..." + : session.title + const age = timeAgo(session.timestamp) + const size = formatSize(session.sizeBytes) + + const promptText = session.lastUserPrompt + ? session.lastUserPrompt.length > 60 + ? session.lastUserPrompt.slice(0, 57) + "..." + : session.lastUserPrompt + : "(no text)" + const responseText = session.lastAssistantMsg + ? session.lastAssistantMsg.length > 60 + ? session.lastAssistantMsg.slice(0, 57) + "..." + : session.lastAssistantMsg + : "(no text response)" + + return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} + ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} + ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` +} + +function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { + const check = isSelected ? green("✓") : " " + return t` ${dim("└─")} [${check}] ${green("+ New session")}` +} + +function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) { + const project = projects[projectIdx] + const br = project.branches?.find(b => b.name === branchName) + if (!br) return t` ${dim("├─")} ${branchName}` + + const check = isSelected ? green("✓") : " " + const sync = fmtSyncIndicator(br.ahead, br.behind) + const syncCol = sync === "✓" ? green(sync) + : sync === "✗" ? dim(sync) + : yellow(sync) + const msg = br.lastCommitMsg.length > 40 ? br.lastCommitMsg.slice(0, 37) + "..." : br.lastCommitMsg + + return t` ${dim("├─")} [${check}] ${magenta(branchName.padEnd(18))} ${syncCol} ${dim(br.lastCommitAge.padEnd(9))} ${dim(msg)}` +} + +// ─── UI Updates ───────────────────────────────────────────────────── +function updateHeader() { + const total = selectedProjects.size + selectedSessions.size + const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : "" + const modeLabel = demoMode ? " [DEMO]" : "" + headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` + )}` +} + +function updateColumnHeaders() { + const cols = ` ${"PROJECT".padEnd(30)} ${"BRANCH".padEnd(9)}${"SYNC".padEnd(5)}${"COMMIT".padEnd(10)}${"MESSAGE".padEnd(22)}${"DIRTY".padEnd(9)}${"LAST USE".padEnd(9)}${"SES".padStart(3)} ${"MSGS".padStart(5)} STACK` + colHeaderText.content = t` ${dim(cols)}` +} + +function updateFooter() { + footerText.content = t` ${dim( + "↑↓ nav │ space select │ → expand │ ← collapse │ a all │ n none │ s sort │ enter launch │ q quit" + )}` +} + +function updatePreview() { + if (cursor >= displayRows.length) { + previewText.content = t`${dim(" No selection")}` + return + } + + const row = displayRows[cursor] + const project = projects[row.projectIndex] + + if (row.type === "project") { + previewText.content = t` ${bold(project.name)} ${dim(project.path)} + ${dim("Branch:")} ${magenta(project.branch)} ${dim("Commit:")} ${ + project.commitAge || "-" + } — ${project.commitMsg || "-"} + ${dim("Status:")} ${project.dirty ? yellow(project.dirty) : green("clean")} ${dim( + "Sessions:" + )} ${String(project.sessionCount)} ${dim("Msgs:")} ${String(project.totalMessages)} ${dim( + "Stack:" + )} ${project.tags || "-"}` + } else if (row.type === "session" && project.sessions) { + const s = project.sessions[row.sessionIndex!] + previewText.content = t` ${bold("Session:")} ${s.title} + ${dim(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")} + ${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")} + ${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}` + } else if (row.type === "branch" && project.branches) { + const br = project.branches.find(b => b.name === row.branchName) + if (br) { + const sync = fmtSyncIndicator(br.ahead, br.behind) + const selBranch = selectedBranches.get(project.path) + const selNote = selBranch === br.name + ? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}` + : t` ${dim("Press space to select this branch for launch")}` + previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} + ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} +${selNote}` + } + } else { + previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)} + ${dim(project.path)}` + } +} + +function rebuildList() { + for (const child of listBox.getChildren()) { + listBox.remove(child.id) + } + + for (let i = 0; i < displayRows.length; i++) { + const row = displayRows[i] + const isCursor = i === cursor + const project = projects[row.projectIndex] + + let content: ReturnType + let rowHeight = 1 + if (row.type === "project") { + const isSel = selectedProjects.has(project.path) + content = fmtProjectRow(project, isSel) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + const isSel = selectedSessions.has(session.id) + content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false) + rowHeight = 3 + } else if (row.type === "branch") { + const isSel = selectedBranches.get(project.path) === row.branchName + content = fmtBranchRow(row.projectIndex, row.branchName!, isSel) + } else { + const isSel = selectedProjects.has(project.path) + content = fmtNewSessionRow(row.projectIndex, isSel) + } + + if (isCursor) { + listBox.add( + Box( + { + backgroundColor: CURSOR_BG, + shouldFill: true, + width: "100%", + height: rowHeight, + }, + Text({ content }) + ) + ) + } else { + listBox.add(Text({ content, width: "100%", height: rowHeight })) + } + } + + ensureCursorVisible() + renderer.requestRender() +} + +function ensureCursorVisible() { + const vpH = listBox.viewport.height + if (vpH <= 0) return + + let cursorY = 0 + let cursorH = 1 + for (let i = 0; i < displayRows.length; i++) { + const h = displayRows[i].type === "session" ? 3 : 1 + if (i === cursor) { + cursorH = h + break + } + cursorY += h + } + + const top = listBox.scrollTop + if (cursorY < top) { + listBox.scrollTo(cursorY) + } else if (cursorY + cursorH > top + vpH) { + listBox.scrollTo(cursorY + cursorH - vpH) + } +} + +function updateAll() { + updateHeader() + rebuildList() + updatePreview() +} + +// ─── Keyboard ─────────────────────────────────────────────────────── +function handleKeypress(key: KeyEvent) { + const total = displayRows.length + if (total === 0) return + + switch (key.name) { + case "up": + if (cursor > 0) cursor-- + break + + case "down": + if (cursor < total - 1) cursor++ + break + + case "pageup": + cursor = Math.max(0, cursor - 15) + break + + case "pagedown": + cursor = Math.min(total - 1, cursor + 15) + break + + case "home": + cursor = 0 + break + + case "end": + cursor = total - 1 + break + + case "right": { + const row = displayRows[cursor] + if (row.type === "project") { + const project = projects[row.projectIndex] + if (!project.expanded) { + expandProject(row.projectIndex) + return + } + } + return + } + + case "left": { + const row = displayRows[cursor] + if (row.type === "project") { + projects[row.projectIndex].expanded = false + } else { + projects[row.projectIndex].expanded = false + const target = row.projectIndex + rebuildDisplayRows() + cursor = displayRows.findIndex( + (r) => r.type === "project" && r.projectIndex === target + ) + if (cursor < 0) cursor = 0 + } + rebuildDisplayRows() + if (cursor >= displayRows.length) cursor = displayRows.length - 1 + break + } + + case "space": { + const row = displayRows[cursor] + if (row.type === "project" || row.type === "new-session") { + const path = projects[row.projectIndex].path + if (selectedProjects.has(path)) selectedProjects.delete(path) + else selectedProjects.add(path) + } else if (row.type === "session") { + const session = projects[row.projectIndex].sessions![row.sessionIndex!] + if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) + else selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = projects[row.projectIndex].path + if (selectedBranches.get(path) === row.branchName) { + selectedBranches.delete(path) + } else { + selectedBranches.set(path, row.branchName!) + } + } + if (cursor < total - 1) cursor++ + break + } + + case "a": + for (const p of projects) selectedProjects.add(p.path) + break + + case "n": + selectedProjects.clear() + selectedSessions.clear() + selectedBranches.clear() + break + + case "s": + sortMode = (sortMode + 1) % sortLabels.length + applySortMode() + cursor = 0 + break + + case "return": + doLaunch() + return + + case "q": + case "escape": + renderer.destroy() + return + + default: + return + } + + updateAll() +} + +async function expandProject(projectIndex: number) { + const project = projects[projectIndex] + if (demoMode) { + if (!project.sessions) { + project.sessions = generateMockSessions(project.path) + project.sessionCount = project.sessions.length + } + if (!project.branches) { + project.branches = generateMockBranches(project.path) + } + } else { + const loads: Promise[] = [] + if (!project.sessions) { + loads.push( + loadSessions(project.path).then(s => { + project.sessions = s + project.sessionCount = s.length + }) + ) + } + if (!project.branches) { + loads.push( + loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] }) + ) + } + if (loads.length > 0) await Promise.all(loads) + } + project.expanded = true + rebuildDisplayRows() + updateAll() +} + +async function doLaunch() { + if (selectedProjects.size === 0 && selectedSessions.size === 0) return + if (demoMode) { + const total = selectedProjects.size + selectedSessions.size + renderer.destroy() + console.log(`[Demo] Would launch ${total} session(s). Launch disabled in demo mode.`) + return + } + renderer.destroy() + const total = selectedProjects.size + selectedSessions.size + console.log(`Launching ${total} session(s)...`) + const count = await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches) + console.log(`Done! ${count} terminal(s) launched.`) +} + +// ─── Main ─────────────────────────────────────────────────────────── +async function main() { + process.stdout.write("\x1b[2J\x1b[H") + process.stdout.write("\x1b[1m cladm\x1b[0m\n") + + if (demoMode) { + process.stdout.write("\x1b[2m [Demo mode] Loading mock projects...\x1b[0m\n") + projects = generateMockProjects() + } else { + process.stdout.write("\x1b[2m Loading projects...\x1b[0m\n") + projects = await discoverProjects() + if (projects.length === 0) { + console.log(" No projects found in ~/.claude/history.jsonl") + process.exit(1) + } + process.stdout.write( + `\x1b[2m Found ${projects.length} projects. Loading git metadata...\x1b[0m\n` + ) + await Promise.all(projects.map((p) => loadGitMetadata(p))) + } + + sortedIndices = projects.map((_, i) => i) + rebuildDisplayRows() + + renderer = await createCliRenderer({ + exitOnCtrlC: true, + useAlternateScreen: true, + useMouse: true, + }) + + // Build layout + const mainBox = new BoxRenderable(renderer, { + flexDirection: "column", + width: "100%", + height: "100%", + }) + + headerText = new TextRenderable(renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + + colHeaderText = new TextRenderable(renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + + listBox = new ScrollBoxRenderable(renderer, { + scrollY: true, + flexGrow: 1, + viewportCulling: true, + }) + + const previewBox = new BoxRenderable(renderer, { + height: 7, + flexShrink: 0, + width: "100%", + borderStyle: "single", + border: ["top"], + borderColor: DIM_CLR, + title: " Preview ", + titleAlignment: "left", + flexDirection: "column", + paddingLeft: 0, + }) + + previewText = new TextRenderable(renderer, { + width: "100%", + flexGrow: 1, + wrapMode: "word", + }) + previewBox.add(previewText) + + footerText = new TextRenderable(renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + + mainBox.add(headerText) + mainBox.add(colHeaderText) + mainBox.add(listBox) + mainBox.add(previewBox) + mainBox.add(footerText) + + renderer.root.add(mainBox) + + updateHeader() + updateColumnHeaders() + rebuildList() + updatePreview() + updateFooter() + + renderer.keyInput.on("keypress", handleKeypress) +} + +main().catch((err) => { + console.error("Fatal:", err) + process.exit(1) +}) diff --git a/src/lib/tags.ts b/src/lib/tags.ts new file mode 100644 index 0000000..a95bbc9 --- /dev/null +++ b/src/lib/tags.ts @@ -0,0 +1,15 @@ +import { existsSync } from "node:fs" +import { join } from "node:path" + +export function getTags(dir: string): string { + const tags: string[] = [] + const has = (f: string) => existsSync(join(dir, f)) + if (has(".git")) tags.push("git") + if (has("package.json")) tags.push("node") + if (has("pyproject.toml") || has("setup.py") || has("requirements.txt")) tags.push("py") + if (has("Cargo.toml")) tags.push("rust") + if (has("go.mod")) tags.push("go") + if (has("CLAUDE.md")) tags.push("claude") + if (has("Dockerfile") || has("docker-compose.yml") || has("docker-compose.yaml")) tags.push("docker") + return tags.join(",") +} diff --git a/src/lib/time.ts b/src/lib/time.ts new file mode 100644 index 0000000..977e62a --- /dev/null +++ b/src/lib/time.ts @@ -0,0 +1,17 @@ +export function timeAgo(ms: number): string { + if (!ms) return "never" + const diff = Math.floor((Date.now() - ms) / 1000) + if (diff < 0) return "just now" + if (diff < 60) return "just now" + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago` + if (diff < 2592000) return `${Math.floor(diff / 604800)}w ago` + return `${Math.floor(diff / 2592000)}mo ago` +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..46bac13 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,45 @@ +export interface BranchInfo { + name: string + isCurrent: boolean + lastCommitAge: string + lastCommitMsg: string + ahead: number + behind: number +} + +export interface Project { + path: string + name: string + branch: string + commitAge: string + commitMsg: string + commitEpoch: number + dirty: string + ahead: number + behind: number + claudeAgo: string + claudeLastMs: number + sessionCount: number + totalMessages: number + tags: string + expanded: boolean + sessions: SessionInfo[] | null + branches: BranchInfo[] | null +} + +export interface SessionInfo { + id: string + timestamp: number + title: string + lastUserPrompt: string + lastAssistantMsg: string + branch: string + sizeBytes: number +} + +export interface DisplayRow { + type: "project" | "session" | "new-session" | "branch" + projectIndex: number + sessionIndex?: number + branchName?: string +}