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:
287
CLAUDE.md
287
CLAUDE.md
@@ -1,111 +1,222 @@
|
|||||||
---
|
# cladm
|
||||||
description: Use Bun instead of Node.js, npm, pnpm, or vite.
|
|
||||||
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
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>`
|
## Project Overview
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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`.
|
## Tech Stack
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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";
|
src/
|
||||||
|
index.ts — Entry point: createCliRenderer, compose layout, input loop
|
||||||
test("hello world", () => {
|
data/
|
||||||
expect(1).toBe(1);
|
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
|
1. **Default mode**: reads `~/.claude/history.jsonl` to discover all projects (sorted by most recently used)
|
||||||
import index from "./index.html"
|
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({
|
## OpenTUI Reference
|
||||||
routes: {
|
|
||||||
"/": index,
|
### Quick Start Pattern
|
||||||
"/api/users/:id": {
|
```typescript
|
||||||
GET: (req) => {
|
import { createCliRenderer, Box, Text, ScrollBox, Select, Input } from "@opentui/core"
|
||||||
return new Response(JSON.stringify({ id: req.params.id }));
|
|
||||||
},
|
const renderer = await createCliRenderer({ exitOnCtrlC: true })
|
||||||
},
|
renderer.root.add(
|
||||||
},
|
Box({ flexDirection: "column", width: "100%", height: "100%" },
|
||||||
// optional websocket support
|
// children...
|
||||||
websocket: {
|
)
|
||||||
open: (ws) => {
|
)
|
||||||
ws.send("Hello, world!");
|
```
|
||||||
},
|
|
||||||
message: (ws, message) => {
|
### Core Concepts
|
||||||
ws.send(message);
|
- **Constructs** are factory functions: `Box(props, ...children)` → returns VNode
|
||||||
},
|
- **Renderables** are class instances: `new BoxRenderable(renderer, options)`
|
||||||
close: (ws) => {
|
- Use constructs for declarative composition, renderables when you need imperative control
|
||||||
// handle close
|
- `renderer.root.add(vnode)` to mount, `.remove(id)` to unmount
|
||||||
}
|
- `requestRender()` to trigger re-draw after state changes
|
||||||
},
|
|
||||||
development: {
|
### Layout (Yoga/Flexbox)
|
||||||
hmr: true,
|
All components accept flexbox props:
|
||||||
console: true,
|
- `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
|
### Input
|
||||||
<html>
|
```typescript
|
||||||
<body>
|
Input({
|
||||||
<h1>Hello, world!</h1>
|
placeholder: string,
|
||||||
<script type="module" src="./frontend.tsx"></script>
|
maxLength: number, // default 1000
|
||||||
</body>
|
value: string, // initial value
|
||||||
</html>
|
// 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`:
|
### Select
|
||||||
|
```typescript
|
||||||
```tsx#frontend.tsx
|
Select({
|
||||||
import React from "react";
|
options: [{ name: string, description: string, value?: any }],
|
||||||
import { createRoot } from "react-dom/client";
|
backgroundColor / textColor / focusedBackgroundColor / focusedTextColor,
|
||||||
|
selectedBackgroundColor / selectedTextColor,
|
||||||
// import .css files directly and it works
|
descriptionColor / selectedDescriptionColor,
|
||||||
import './index.css';
|
showScrollIndicator: boolean,
|
||||||
|
wrapSelection: boolean,
|
||||||
const root = createRoot(document.body);
|
showDescription: boolean,
|
||||||
|
itemSpacing: number,
|
||||||
export default function Frontend() {
|
fastScrollStep: number,
|
||||||
return <h1>Hello, world!</h1>;
|
keyBindings: SelectKeyBinding[],
|
||||||
}
|
})
|
||||||
|
// Events: "selectionChanged", "itemSelected"
|
||||||
root.render(<Frontend />);
|
// Methods: .getSelectedOption(), .getSelectedIndex(), .moveUp/Down(), .selectCurrent()
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, run index.ts
|
### ScrollBox
|
||||||
|
```typescript
|
||||||
```sh
|
ScrollBox({
|
||||||
bun --hot ./index.ts
|
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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# tui-claude-director
|
# cladm
|
||||||
|
|
||||||
To install dependencies:
|
To install dependencies:
|
||||||
|
|
||||||
|
|||||||
520
docs/launch-claude-original.sh
Executable file
520
docs/launch-claude-original.sh
Executable file
@@ -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<total; i++)); do selected+=(0); done
|
||||||
|
cursor=0
|
||||||
|
|
||||||
|
sort_mode=0
|
||||||
|
if [[ "$MODE" == "history" ]]; then
|
||||||
|
sort_label="recent (claude)"
|
||||||
|
else
|
||||||
|
sort_label="name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
sort_indices=()
|
||||||
|
for ((i=0; i<total; i++)); do sort_indices+=($i); done
|
||||||
|
|
||||||
|
term_lines=$(tput lines)
|
||||||
|
term_cols=$(tput cols)
|
||||||
|
visible=$((term_lines - 8))
|
||||||
|
[[ $visible -lt 5 ]] && visible=5
|
||||||
|
scroll_offset=0
|
||||||
|
|
||||||
|
tput civis
|
||||||
|
trap 'tput cnorm; stty sane' EXIT
|
||||||
|
|
||||||
|
C_RESET="\033[0m"; C_BOLD="\033[1m"; C_DIM="\033[2m"; C_REV="\033[7m"
|
||||||
|
C_GREEN="\033[32m"; C_YELLOW="\033[33m"; C_CYAN="\033[36m"; C_MAGENTA="\033[35m"
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
tput cup 0 0
|
||||||
|
|
||||||
|
local sel_count=0
|
||||||
|
for s in "${selected[@]}"; do ((sel_count+=s)); done
|
||||||
|
|
||||||
|
local source_label="history"
|
||||||
|
[[ "$MODE" == "scan" ]] && source_label="filesystem"
|
||||||
|
|
||||||
|
printf "${C_BOLD} Claude Code Launcher — %d/%d selected ${C_DIM}sort: %s │ source: %s${C_RESET}\033[K\n" \
|
||||||
|
"$sel_count" "$total" "$sort_label" "$source_label"
|
||||||
|
printf " ${C_DIM}↑↓ navigate │ space toggle │ a all │ n none │ s sort │ enter launch │ q quit${C_RESET}\033[K\n"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "history" ]]; then
|
||||||
|
printf " ${C_DIM} %-33s %-8s %-11s %-25s %-9s %-9s %4s %5s %s${C_RESET}\033[K\n" \
|
||||||
|
"PROJECT" "BRANCH" "COMMIT" "MESSAGE" "DIRTY" "LAST USE" "SESS" "MSGS" "STACK"
|
||||||
|
else
|
||||||
|
printf " ${C_DIM} %-33s %-8s %-11s %-25s %-9s %s${C_RESET}\033[K\n" \
|
||||||
|
"PROJECT" "BRANCH" "COMMIT" "MESSAGE" "DIRTY" "STACK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((cursor < scroll_offset)); then scroll_offset=$cursor
|
||||||
|
elif ((cursor >= 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<end; row++)); do
|
||||||
|
local i=${sort_indices[$row]}
|
||||||
|
local display="${project_display[$i]}"
|
||||||
|
local branch="${project_branches[$i]}"
|
||||||
|
local cage="${project_commit_age[$i]}"
|
||||||
|
local cmsg="${project_commit_msg[$i]}"
|
||||||
|
local dirty="${project_dirty[$i]}"
|
||||||
|
local clago="${project_claude_ago[$i]}"
|
||||||
|
local clsess="${project_claude_sessions[$i]}"
|
||||||
|
local clmsgs="${project_claude_msgs[$i]}"
|
||||||
|
local tags="${project_tags[$i]}"
|
||||||
|
local check=" "
|
||||||
|
[[ ${selected[$i]} -eq 1 ]] && check="✓"
|
||||||
|
|
||||||
|
[[ ${#display} -gt 31 ]] && display="${display:0:28}..."
|
||||||
|
[[ ${#branch} -gt 8 ]] && branch="${branch:0:7}…"
|
||||||
|
|
||||||
|
local dirty_col=""
|
||||||
|
if [[ -n "$dirty" ]]; then dirty_col="${C_YELLOW}${dirty}${C_RESET}"
|
||||||
|
else dirty_col="${C_GREEN}clean${C_RESET}"; fi
|
||||||
|
|
||||||
|
local claude_col=""
|
||||||
|
if [[ "$clago" == "never" || "$clago" == "-" ]]; then claude_col="${C_DIM}${clago}${C_RESET}"
|
||||||
|
elif [[ "$clago" == *"m ago"* || "$clago" == *"h ago"* || "$clago" == "just now" ]]; then claude_col="${C_CYAN}${clago}${C_RESET}"
|
||||||
|
elif [[ "$clago" == *"d ago"* ]]; then claude_col="${C_GREEN}${clago}${C_RESET}"
|
||||||
|
else claude_col="${C_DIM}${clago}${C_RESET}"; fi
|
||||||
|
|
||||||
|
# Row highlight
|
||||||
|
if [[ $row -eq $cursor ]]; then
|
||||||
|
if [[ ${selected[$i]} -eq 1 ]]; then
|
||||||
|
printf " ${C_REV}${C_GREEN} [%s]${C_RESET}${C_REV} %-31s ${C_RESET}" "$check" "$display"
|
||||||
|
else
|
||||||
|
printf " ${C_REV} [%s] %-31s ${C_RESET}" "$check" "$display"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ ${selected[$i]} -eq 1 ]]; then
|
||||||
|
printf " ${C_GREEN} [%s] %-31s${C_RESET}" "$check" "$display"
|
||||||
|
else
|
||||||
|
printf " [%s] %-31s" "$check" "$display"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf " ${C_MAGENTA}%-8s${C_RESET}" "$branch"
|
||||||
|
printf " ${C_DIM}%-11s${C_RESET}" "$cage"
|
||||||
|
printf " %-25s" "$cmsg"
|
||||||
|
printf " %-11b" "$dirty_col"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "history" ]]; then
|
||||||
|
printf " %-11b" "$claude_col"
|
||||||
|
printf " ${C_DIM}%4s${C_RESET}" "$clsess"
|
||||||
|
printf " ${C_DIM}%5s${C_RESET}" "$clmsgs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf " ${C_DIM}%s${C_RESET}" "$tags"
|
||||||
|
printf "\033[K\n"
|
||||||
|
done
|
||||||
|
|
||||||
|
local remaining=$((total - end))
|
||||||
|
if ((remaining > 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<total; i++)); do selected[$i]=1; done
|
||||||
|
;;
|
||||||
|
'n'|'N')
|
||||||
|
for ((i=0; i<total; i++)); do selected[$i]=0; done
|
||||||
|
;;
|
||||||
|
's'|'S')
|
||||||
|
if [[ "$MODE" == "history" ]]; then
|
||||||
|
sort_mode=$(( (sort_mode + 1) % 4 ))
|
||||||
|
case $sort_mode in
|
||||||
|
0) # Default: by recent claude usage (already sorted from history)
|
||||||
|
sort_label="recent (claude)"
|
||||||
|
sort_indices=()
|
||||||
|
for ((i=0; i<total; i++)); do sort_indices+=($i); done
|
||||||
|
;;
|
||||||
|
1) # By name
|
||||||
|
sort_label="name"
|
||||||
|
declare -a name_pairs=()
|
||||||
|
for ((i=0; i<total; i++)); do
|
||||||
|
name_pairs+=("${project_display[$i]}|$i")
|
||||||
|
done
|
||||||
|
sorted=$(printf '%s\n' "${name_pairs[@]}" | sort -t'|' -k1 -f)
|
||||||
|
sort_indices=()
|
||||||
|
while IFS= read -r line; do sort_indices+=("${line##*|}"); done <<< "$sorted"
|
||||||
|
unset name_pairs
|
||||||
|
;;
|
||||||
|
2) # By last commit
|
||||||
|
sort_label="last commit"
|
||||||
|
declare -a epoch_pairs=()
|
||||||
|
for ((i=0; i<total; i++)); do
|
||||||
|
ep=$(git -C "${projects[$i]}" log -1 --format="%ct" 2>/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<total; i++)); do
|
||||||
|
sess_pairs+=("${project_claude_sessions[$i]}:$i")
|
||||||
|
done
|
||||||
|
sorted=$(printf '%s\n' "${sess_pairs[@]}" | sort -t: -k1 -nr)
|
||||||
|
sort_indices=()
|
||||||
|
while IFS= read -r line; do sort_indices+=("${line#*:}"); done <<< "$sorted"
|
||||||
|
unset sess_pairs
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
sort_mode=$(( (sort_mode + 1) % 2 ))
|
||||||
|
case $sort_mode in
|
||||||
|
0) sort_label="name"; sort_indices=(); for ((i=0; i<total; i++)); do sort_indices+=($i); done ;;
|
||||||
|
1)
|
||||||
|
sort_label="last commit"
|
||||||
|
declare -a epoch_pairs=()
|
||||||
|
for ((i=0; i<total; i++)); do
|
||||||
|
ep=$(git -C "${projects[$i]}" log -1 --format="%ct" 2>/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<total; i++)); do
|
||||||
|
[[ ${selected[$i]} -eq 1 ]] && chosen+=("${projects[$i]}")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#chosen[@]} -eq 0 ]]; then
|
||||||
|
echo "No projects selected."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Launching ${#chosen[@]} projects..."
|
||||||
|
echo ""
|
||||||
|
launch_folders "${chosen[@]}"
|
||||||
529
docs/opentui-api-reference.md
Normal file
529
docs/opentui-api-reference.md
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
# OpenTUI Complete API Reference
|
||||||
|
|
||||||
|
> 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<CliRenderer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<TextareaOptions, "height" | "minHeight" | "maxHeight" | "initialValue"> {
|
||||||
|
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<ArrowOptions, "direction">
|
||||||
|
trackOptions?: Partial<SliderOptions>
|
||||||
|
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<T = string> {
|
||||||
|
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.
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tui-claude-director",
|
"name": "cladm",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
|
|||||||
97
src/actions/launcher.ts
Normal file
97
src/actions/launcher.ts
Normal file
@@ -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<string>,
|
||||||
|
selectedSessions: Set<string>,
|
||||||
|
selectedBranches: Map<string, string> = new Map()
|
||||||
|
): Promise<number> {
|
||||||
|
const byProject = new Map<string, LaunchItem[]>()
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
const proc = Bun.spawn(["osascript", "-e", script], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
await proc.exited
|
||||||
|
}
|
||||||
127
src/data/git.ts
Normal file
127
src/data/git.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import type { BranchInfo, Project } from "../lib/types"
|
||||||
|
import { timeAgo } from "../lib/time"
|
||||||
|
|
||||||
|
async function gitCmd(path: string, ...args: string[]): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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<BranchInfo[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
75
src/data/history.ts
Normal file
75
src/data/history.ts
Normal file
@@ -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<Project[]> {
|
||||||
|
const file = Bun.file(HISTORY_PATH)
|
||||||
|
if (!(await file.exists())) return []
|
||||||
|
|
||||||
|
const text = await file.text()
|
||||||
|
const agg = new Map<string, HistoryAgg>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
410
src/data/mock.ts
Normal file
410
src/data/mock.ts
Normal file
@@ -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<string, SessionInfo[]> = {
|
||||||
|
"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<string, BranchInfo[]> = {
|
||||||
|
"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] || []
|
||||||
|
}
|
||||||
116
src/data/sessions.ts
Normal file
116
src/data/sessions.ts
Normal file
@@ -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<SessionInfo[]> {
|
||||||
|
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<SessionInfo | null> {
|
||||||
|
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
|
||||||
|
}
|
||||||
605
src/index.ts
Normal file
605
src/index.ts
Normal file
@@ -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<string>()
|
||||||
|
const selectedSessions = new Set<string>()
|
||||||
|
const selectedBranches = new Map<string, string>()
|
||||||
|
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<typeof t>
|
||||||
|
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<void>[] = []
|
||||||
|
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)
|
||||||
|
})
|
||||||
15
src/lib/tags.ts
Normal file
15
src/lib/tags.ts
Normal file
@@ -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(",")
|
||||||
|
}
|
||||||
17
src/lib/time.ts
Normal file
17
src/lib/time.ts
Normal file
@@ -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`
|
||||||
|
}
|
||||||
45
src/lib/types.ts
Normal file
45
src/lib/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user