diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 320050bd..d8694ebd 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -30,7 +30,7 @@ │ SessionMonitor │ │ TmuxManager (tmux_manager.py) │ │ (session_monitor.py) │ │ - list/find/create/kill windows│ │ - Poll JSONL every 2s │ │ - send_keys to pane │ -│ - Detect mtime changes │ │ - capture_pane for screenshot │ +│ - Detect size changes │ │ - capture_pane for screenshot │ │ - Parse new lines │ └──────────────┬─────────────────┘ │ - Track pending tools │ │ │ across poll cycles │ │ diff --git a/.claude/rules/message-handling.md b/.claude/rules/message-handling.md index ab108a86..33138307 100644 --- a/.claude/rules/message-handling.md +++ b/.claude/rules/message-handling.md @@ -32,7 +32,7 @@ Per-user message queues + worker pattern for all send tasks: ## Performance Optimizations -**mtime cache**: The monitoring loop maintains an in-memory file mtime cache, skipping reads for unchanged files. +**File size fast path**: The monitoring loop compares file size against the last byte offset, skipping reads for unchanged files. **Byte offset incremental reads**: Each tracked session records `last_byte_offset`, reading only new content. File truncation (offset > file_size) is detected and offset is auto-reset. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..eba3817c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(cmd.exe:*)", + "Bash(pip show:*)", + "Bash(python3:*)", + "Bash(\"/c/Users/krisd/AppData/Local/Programs/Python/Python314/python.exe\":*)", + "Bash(uv run:*)", + "Bash(~/.local/bin/uv run:*)", + "Bash(ls:*)" + ] + } +} diff --git a/ccbot-workshop-setup.md b/ccbot-workshop-setup.md new file mode 100644 index 00000000..bef0cec5 --- /dev/null +++ b/ccbot-workshop-setup.md @@ -0,0 +1,310 @@ +# CCBot Workshop Setup Guide + +Complete setup from a fresh Windows machine to running CCBot with Claude Code sessions accessible via Telegram. + +--- + +## Prerequisites + +Before you begin, you'll need: + +- Windows 10 (version 2004+) or Windows 11 +- A Telegram account +- A Claude Code subscription (Claude Pro/Team/Enterprise) +- Your project repositories cloned into `C:\GitHub\` + +--- + +## Part 1: Install WSL and Ubuntu + +Open **PowerShell as Administrator** and run: + +```powershell +wsl --install +``` + +This installs WSL 2 with Ubuntu. Restart your computer when prompted. + +After restart, Ubuntu will open automatically and ask you to create a username and password. Remember these — you'll need the password for `sudo` commands. + +Once you're at the Ubuntu prompt, update everything: + +```bash +sudo apt update && sudo apt upgrade -y +``` + +--- + +## Part 2: Install Core Tools + +### Node.js (required for Claude Code) + +```bash +curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +sudo apt install -y nodejs +``` + +Verify: + +```bash +node --version +npm --version +``` + +### Claude Code + +```bash +npm install -g @anthropic-ai/claude-code +``` + +Add npm global bin to your PATH if not already there: + +```bash +echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc +source ~/.bashrc +``` + +Verify Claude Code works: + +```bash +claude --version +``` + +### tmux + +```bash +sudo apt install -y tmux +``` + +### uv (Python package manager) + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +Then: + +```bash +source ~/.bashrc +``` + +--- + +## Part 3: Create a Telegram Bot + +1. Open Telegram and search for **@BotFather** +2. Send `/newbot` +3. Follow the prompts to name your bot +4. BotFather gives you a **bot token** — save it (looks like `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### Get your Telegram user ID + +1. Search for **@userinfobot** in Telegram +2. Start a chat with it +3. It replies with your numeric user ID — save it + +### Create a Telegram group + +1. Create a new group in Telegram +2. Name it something like "Workshop Sessions" +3. Add your bot to the group +4. Go to group settings → **Topics** → Enable topics (use list format) +5. Make the bot an **admin** of the group + +--- + +## Part 4: Install CCBot Workshop + +```bash +uv tool install git+https://github.com/JanusMarko/ccbot-workshop.git +``` + +Verify it installed: + +```bash +which ccbot +``` + +If it's not found, add the path: + +```bash +export PATH="$HOME/.local/bin:$PATH" +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +``` + +### Configure CCBot + +Create the config directory and environment file: + +```bash +mkdir -p ~/.ccbot +nano ~/.ccbot/.env +``` + +Paste the following, replacing the placeholder values with your actual token and user ID: + +``` +TELEGRAM_BOT_TOKEN=your_bot_token_here +ALLOWED_USERS=your_telegram_user_id_here +TMUX_SESSION_NAME=ccbot +CLAUDE_COMMAND=claude --dangerously-skip-permissions +CCBOT_BROWSE_ROOT=/mnt/c/GitHub +``` + +Save with `Ctrl+O`, exit with `Ctrl+X`. + +The `CCBOT_BROWSE_ROOT` setting ensures the directory browser always starts from your `C:\GitHub\` folder when creating new sessions. + +### Install the Claude Code hook + +This lets CCBot track which Claude session runs in which tmux window: + +```bash +ccbot hook --install +``` + +Or manually add to `~/.claude/settings.json`: + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [{ "type": "command", "command": "ccbot hook", "timeout": 5 }] + } + ] + } +} +``` + +--- + +## Part 5: Starting CCBot + +### First time startup + +```bash +tmux new -s ccbot +``` + +Inside the tmux session: + +```bash +ccbot +``` + +You should see log output confirming the bot started, including your allowed users and Claude projects path. + +### Detach from tmux + +Press `Ctrl+b`, release, then press `d`. CCBot keeps running in the background. You can close the terminal — it stays alive. + +### Start a session from Telegram + +1. Open your Telegram group +2. Create a new topic (name it after your project, e.g. "PAIOS") +3. Send a message in the topic +4. CCBot shows a directory browser starting from `C:\GitHub\` — tap your project folder +5. Tap **Select** to confirm +6. A new tmux window is created with Claude Code running in that directory +7. Your message is forwarded to Claude Code + +### View sessions in the terminal + +```bash +tmux attach -t ccbot +``` + +Switch between windows using `Ctrl+b` then the window number (shown in the bottom bar). For example: + +- `Ctrl+b` then `1` → ccbot process (don't close this) +- `Ctrl+b` then `2` → first Claude Code session +- `Ctrl+b` then `3` → second Claude Code session + +Detach again with `Ctrl+b` then `d`. + +--- + +## Part 6: Daily Usage + +### Starting CCBot after a reboot + +```bash +tmux new -s ccbot || tmux attach -t ccbot +ccbot +``` + +Then `Ctrl+b` then `d` to detach. + +### Useful Telegram commands + +Send these in a topic: + +- `/screenshot` — see what the terminal looks like right now +- `/history` — browse conversation history +- `/esc` — send Escape key (toggles plan mode, same as Shift+Tab) +- `/cost` — check token usage +- `/kill` — kill the session and delete the topic + +### Ending a session + +Close or delete the topic in Telegram. The tmux window is automatically killed. + +### Multiple projects + +Create a new topic for each project. CCBot's design is **1 topic = 1 window = 1 session**. Each topic can run a separate Claude Code session in a different project directory. + +### Switching between phone and desktop + +From your phone, just use Telegram — all interaction goes through topics. + +To switch to your desktop terminal: + +```bash +tmux attach -t ccbot +``` + +Navigate to the right window with `Ctrl+b` then the window number. You're in the same session with full scrollback. + +--- + +## Part 7: Uninstall and Reinstall + +Use this after pushing updates to your fork. + +### Stop CCBot + +```bash +tmux attach -t ccbot +``` + +Press `Ctrl+C` to stop ccbot. Stay in the tmux session. + +### Uninstall the current version + +```bash +uv tool uninstall ccbot +``` + +### Install the updated version + +```bash +uv tool install git+https://github.com/JanusMarko/ccbot-workshop.git +``` + +If you're getting a cached version and not seeing your changes, force a fresh install: + +```bash +uv tool install --force git+https://github.com/JanusMarko/ccbot-workshop.git +``` + +### Verify and restart + +```bash +which ccbot +ccbot +``` + +Then `Ctrl+b` then `d` to detach. + +Your `~/.ccbot/.env` configuration and `~/.ccbot/state.json` session state are preserved across reinstalls — you don't need to reconfigure anything. diff --git a/ccweb/CLAUDE.md b/ccweb/CLAUDE.md new file mode 100644 index 00000000..b2ee78fb --- /dev/null +++ b/ccweb/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +ccweb — React web gateway to Claude Code sessions via tmux. Replaces the Telegram interface with a browser-based UI. + +Tech stack: Python (FastAPI + WebSocket), React (TypeScript + Vite + Tailwind), tmux (libtmux). + +## Common Commands + +```bash +# Backend +cd ccweb && uv run ruff check ccweb/backend/ # Lint +cd ccweb && uv run ruff format ccweb/backend/ # Format +cd ccweb && pip install -e . # Install backend + +# Frontend +cd ccweb/frontend && npx tsc -b --noEmit # Type check +cd ccweb/frontend && npx vite build # Production build +cd ccweb/frontend && npm run dev # Dev server (:5173) + +# Run +ccweb # Start server (default :8765) +ccweb install # Install hook + global commands +ccweb hook # SessionStart hook handler +``` + +## Documentation Wiki — IMPORTANT + +The `docs/` directory contains all user-facing documentation, rendered both as repo-readable markdown AND in the in-app wiki at `/wiki`. + +**When you change any feature, endpoint, component, protocol message, configuration option, or user-facing behavior, you MUST update the corresponding doc file in `docs/`.** This is not optional — the wiki is the primary user documentation. + +Doc files use YAML frontmatter (`title`, `description`, `order`) for wiki sidebar sorting. Internal links use relative paths (`../features/sessions.md`). + +Key doc locations: +- `docs/getting-started/` — installation, setup, quickstart +- `docs/configuration/` — env vars, preferences +- `docs/features/` — one file per feature +- `docs/architecture/` — system design, protocol reference +- `docs/troubleshooting/` — common issues, debugging + +## Architecture + +- Backend: `ccweb/backend/` — FastAPI server, WebSocket handler, session management +- Core modules: `ccweb/backend/core/` — forked from ccbot (tmux_manager, session_monitor, terminal_parser, etc.) +- Frontend: `frontend/src/` — React components, hooks, protocol types +- State: `~/.ccweb/` — state.json, session_map.json, monitor_state.json, preferences.json +- Docs: `docs/` — markdown wiki files + +## Key Design Constraints + +- **Single user** — no multi-user auth +- **File-based decision grids** — skills write JSON to `.ccweb/pending/`, backend polls, AskUserQuestion blocks Claude +- **WebSocket protocol** — all real-time comms via typed JSON messages (see `ws_protocol.py` / `protocol.ts`) +- **Forked modules** — core/ modules are forks from ccbot with import paths adapted; `~/.ccweb/` state is fully separate from `~/.ccbot/` diff --git a/ccweb/README.md b/ccweb/README.md new file mode 100644 index 00000000..335e493b --- /dev/null +++ b/ccweb/README.md @@ -0,0 +1,110 @@ +# CCWeb + +A browser-based interface for Claude Code sessions running in tmux. Replaces the Telegram bot interface with a richer web experience. + +## Features + +- **Styled message stream** with markdown rendering, expandable thinking blocks, copy buttons on code +- **Interactive UI** — AskUserQuestion, permission prompts, plan mode rendered as clickable buttons (not terminal arrow-key navigation) +- **Decision grids** — batch option selection with notes column, rendered from JSON files written by Claude Code skills +- **Session management** — create, switch, rename, kill sessions via sidebar with directory picker +- **Command palette** — `/` auto-complete with built-in commands + project-specific skill discovery +- **File upload** — drag-and-drop or paperclip button, saved to project's `docs/inbox/` +- **Message filters** — All / Chat / No Thinking / Tools toggle chips +- **Browser notifications** — alerts when Claude needs input and tab is unfocused +- **Context indicator** — shows Claude's context usage percentage with color coding +- **Export** — download conversation as Markdown, JSON, or plain text +- **Responsive** — works on desktop Chrome and tablet (Samsung Z Fold 7 tested) +- **In-app wiki** — documentation rendered from `docs/` markdown files with search + +## Quick Start + +```bash +# Install +pip install -e . +ccweb install # Set up SessionStart hook + global slash commands + +# Start tmux +tmux new -s ccbot + +# Start server (in another terminal) +ccweb # Runs on http://localhost:8765 + +# Frontend dev mode +cd frontend && npm install && npm run dev # http://localhost:5173 +``` + +## Architecture + +``` +ccweb/ +├── ccweb/backend/ Python FastAPI + WebSocket server +│ ├── core/ Forked from ccbot (tmux, session monitor, parsers) +│ ├── server.py Main server (WebSocket handler, REST endpoints) +│ ├── session.py Session state management +│ ├── ws_protocol.py Typed WebSocket message definitions +│ └── ui_parser.py Terminal text → structured interactive UI data +├── frontend/src/ React + TypeScript + Vite +│ ├── App.tsx Main layout (sidebar, content, overlays, routing) +│ ├── components/ UI components (15 files) +│ ├── hooks/ State + WebSocket hooks (4 files) +│ └── protocol.ts TypeScript mirror of ws_protocol.py +├── docs/ Documentation wiki (rendered in-app) +│ ├── architecture/ Design plan, v2 roadmap, deferred items, session history +│ ├── getting-started/ Installation, quickstart +│ ├── features/ Per-feature docs +│ └── troubleshooting/ Common issues +├── CLAUDE.md Claude Code instructions for this project +├── pyproject.toml Python package config +└── README.md This file +``` + +## Documentation + +All documentation lives in `docs/` and is also rendered in the in-app wiki (click "Wiki / Help" in the sidebar). + +| Document | What it covers | +|----------|---------------| +| [Design Plan](docs/architecture/design-plan.md) | Full design plan with all architectural decisions, protocol specs, and phase breakdown | +| [V2 Roadmap](docs/architecture/v2-roadmap.md) | Deferred features organized by category | +| [Deferred Items Grid](docs/architecture/deferred-items.md) | Prioritized grid of all 27 deferred items with effort, usefulness, success probability | +| [Session History](docs/architecture/session-history.md) | History of the build session — decisions, review process, lessons learned, what a new session needs to know | +| [Installation](docs/getting-started/installation.md) | Prerequisites, setup, configuration | +| [Quick Start](docs/getting-started/quickstart.md) | First session walkthrough | + +## Configuration + +Server config via `~/.ccweb/.env`: + +```bash +CCWEB_HOST=0.0.0.0 # Bind address (default: 0.0.0.0) +CCWEB_PORT=8765 # Port (default: 8765) +CCWEB_AUTH_TOKEN= # Optional bearer token +TMUX_SESSION_NAME=ccbot # Tmux session name (default: ccbot) +CLAUDE_COMMAND=claude # Claude command (default: claude) +CCWEB_BROWSE_ROOT= # Starting dir for session browser +``` + +## Tech Stack + +- **Backend**: Python 3.12+, FastAPI, uvicorn, libtmux, aiofiles +- **Frontend**: React 19, TypeScript 6, Vite 8, Tailwind CSS 4, react-markdown +- **Runtime**: tmux, Claude Code CLI + +## Making This a Standalone Repo + +This folder is completely self-contained. To make it a standalone repo: + +```bash +cp -r ccweb/ /path/to/new/ccweb +cd /path/to/new/ccweb +git init +git add . +git commit -m "Initial commit" +``` + +State is stored in `~/.ccweb/` (separate from ccbot's `~/.ccbot/`). Both can coexist. + +## License + +See parent repository. diff --git a/ccweb/ccweb/__init__.py b/ccweb/ccweb/__init__.py new file mode 100644 index 00000000..ed9269fd --- /dev/null +++ b/ccweb/ccweb/__init__.py @@ -0,0 +1 @@ +"""CCWeb — React web gateway to Claude Code sessions via tmux.""" diff --git a/ccweb/ccweb/backend/__init__.py b/ccweb/ccweb/backend/__init__.py new file mode 100644 index 00000000..7b0546a4 --- /dev/null +++ b/ccweb/ccweb/backend/__init__.py @@ -0,0 +1 @@ +"""CCWeb backend — FastAPI + WebSocket gateway to Claude Code via tmux.""" diff --git a/ccweb/ccweb/backend/config.py b/ccweb/ccweb/backend/config.py new file mode 100644 index 00000000..5662789a --- /dev/null +++ b/ccweb/ccweb/backend/config.py @@ -0,0 +1,125 @@ +"""Application configuration — reads env vars and exposes a singleton. + +Loads web server settings, tmux/Claude paths, and monitoring intervals +from environment variables (with .env support). +.env loading priority: local .env (cwd) > $CCWEB_DIR/.env (default ~/.ccweb). + +The module-level `config` instance is imported by nearly every other module. +Attribute names match those expected by forked ccbot core modules. + +Key class: Config (singleton instantiated as `config`). +""" + +import logging +import os +from pathlib import Path + +from dotenv import load_dotenv + +from .core.utils import ccweb_dir + +logger = logging.getLogger(__name__) + +# Env vars that must not leak to child processes (e.g. Claude Code via tmux) +SENSITIVE_ENV_VARS: set[str] = {"CCWEB_AUTH_TOKEN"} + + +class Config: + """Application configuration loaded from environment variables. + + Exposes the same attribute names as ccbot's Config so that forked + core modules (tmux_manager, session_monitor, etc.) work unchanged. + """ + + def __init__(self) -> None: + self.config_dir = ccweb_dir() + self.config_dir.mkdir(parents=True, exist_ok=True) + + # Load .env: local (cwd) takes priority over config_dir + local_env = Path(".env") + global_env = self.config_dir / ".env" + if local_env.is_file(): + load_dotenv(local_env) + logger.debug("Loaded env from %s", local_env.resolve()) + if global_env.is_file(): + load_dotenv(global_env) + logger.debug("Loaded env from %s", global_env) + + # --- Web server settings --- + self.web_host: str = os.getenv("CCWEB_HOST", "0.0.0.0") + self.web_port: int = int(os.getenv("CCWEB_PORT", "8765")) + self.web_auth_token: str = os.getenv("CCWEB_AUTH_TOKEN", "") + + # --- Tmux settings (same attr names as ccbot) --- + self.tmux_session_name: str = os.getenv("TMUX_SESSION_NAME", "ccbot") + self.tmux_main_window_name: str = "__main__" + + # Claude command to run in new windows + self.claude_command: str = os.getenv("CLAUDE_COMMAND", "claude") + + # --- State files (all under config_dir) --- + self.state_file: Path = self.config_dir / "state.json" + self.session_map_file: Path = self.config_dir / "session_map.json" + self.monitor_state_file: Path = self.config_dir / "monitor_state.json" + + # --- Claude Code session monitoring --- + custom_projects_path = os.getenv("CCWEB_CLAUDE_PROJECTS_PATH") + claude_config_dir = os.getenv("CLAUDE_CONFIG_DIR") + + if custom_projects_path: + self.claude_projects_path: Path = Path(custom_projects_path) + elif claude_config_dir: + self.claude_projects_path = Path(claude_config_dir) / "projects" + else: + self.claude_projects_path = Path.home() / ".claude" / "projects" + + self.monitor_poll_interval: float = float( + os.getenv("MONITOR_POLL_INTERVAL", "2.0") + ) + + # Display user messages in history and real-time notifications + self.show_user_messages: bool = True + + # Directory browser settings + self.show_hidden_dirs: bool = ( + os.getenv("CCWEB_SHOW_HIDDEN_DIRS", "").lower() == "true" + ) + self.browse_root: str = os.getenv("CCWEB_BROWSE_ROOT", "") + + # --- Memory monitoring --- + self.memory_monitor_enabled: bool = ( + os.getenv("CCWEB_MEMORY_MONITOR", "true").lower() != "false" + ) + self.memory_warning_mb: float = float( + os.getenv("CCWEB_MEMORY_WARNING_MB", "2048") + ) + self.memory_check_interval: float = float( + os.getenv("CCWEB_MEMORY_CHECK_INTERVAL", "10") + ) + self.mem_avail_warn_mb: float = float( + os.getenv("CCWEB_MEM_AVAIL_WARN_MB", "1024") + ) + self.mem_avail_interrupt_mb: float = float( + os.getenv("CCWEB_MEM_AVAIL_INTERRUPT_MB", "512") + ) + self.mem_avail_kill_mb: float = float( + os.getenv("CCWEB_MEM_AVAIL_KILL_MB", "256") + ) + + # Scrub sensitive vars from os.environ + for var in SENSITIVE_ENV_VARS: + os.environ.pop(var, None) + + logger.debug( + "Config initialized: dir=%s, host=%s, port=%d, " + "tmux_session=%s, claude_projects_path=%s", + self.config_dir, + self.web_host, + self.web_port, + self.tmux_session_name, + self.claude_projects_path, + ) + + +# Lazy singleton — instantiated on first import. Tests can replace this. +config = Config() diff --git a/ccweb/ccweb/backend/core/__init__.py b/ccweb/ccweb/backend/core/__init__.py new file mode 100644 index 00000000..1757889a --- /dev/null +++ b/ccweb/ccweb/backend/core/__init__.py @@ -0,0 +1 @@ +"""Core modules forked from ccbot — transport-agnostic tmux/session management.""" diff --git a/ccweb/ccweb/backend/core/hook.py b/ccweb/ccweb/backend/core/hook.py new file mode 100644 index 00000000..f8e48b1e --- /dev/null +++ b/ccweb/ccweb/backend/core/hook.py @@ -0,0 +1,225 @@ +"""Hook subcommand for Claude Code session tracking. + +Called by Claude Code's SessionStart hook to maintain a window-to-session +mapping in /session_map.json. Also provides --install to +auto-configure the hook in ~/.claude/settings.json. + +This module must NOT import config.py (which requires env vars for +server settings), since hooks run inside tmux panes where those vars +are not set. Config directory resolution uses utils.ccweb_dir(). + +Key functions: hook_main() (CLI entry), _install_hook(). +""" + +import argparse +import fcntl +import json +import logging +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Validate session_id looks like a UUID +_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + +_CLAUDE_SETTINGS_FILE = Path.home() / ".claude" / "settings.json" + +# The hook command suffix for detection +_HOOK_COMMAND_SUFFIX = "ccweb hook" + + +def _find_ccweb_path() -> str: + """Find the full path to the ccweb executable.""" + ccweb_path = shutil.which("ccweb") + if ccweb_path: + return ccweb_path + + python_dir = Path(sys.executable).parent + ccweb_in_venv = python_dir / "ccweb" + if ccweb_in_venv.exists(): + return str(ccweb_in_venv) + + return "ccweb" + + +def _is_hook_installed(settings: dict) -> bool: # type: ignore[type-arg] + """Check if ccweb hook is already installed in the settings.""" + hooks = settings.get("hooks", {}) + session_start = hooks.get("SessionStart", []) + + for entry in session_start: + if not isinstance(entry, dict): + continue + inner_hooks = entry.get("hooks", []) + for h in inner_hooks: + if not isinstance(h, dict): + continue + cmd = h.get("command", "") + if cmd == _HOOK_COMMAND_SUFFIX or cmd.endswith("/" + _HOOK_COMMAND_SUFFIX): + return True + return False + + +def _install_hook() -> int: + """Install the ccweb hook into Claude's settings.json. Returns 0 on success.""" + settings_file = _CLAUDE_SETTINGS_FILE + settings_file.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} # type: ignore[type-arg] + if settings_file.exists(): + try: + settings = json.loads(settings_file.read_text()) + except (json.JSONDecodeError, OSError) as e: + print(f"Error reading {settings_file}: {e}", file=sys.stderr) + return 1 + + if _is_hook_installed(settings): + print(f"Hook already installed in {settings_file}") + return 0 + + ccweb_path = _find_ccweb_path() + hook_command = f"{ccweb_path} hook" + hook_config = {"type": "command", "command": hook_command, "timeout": 5} + + if "hooks" not in settings: + settings["hooks"] = {} + if "SessionStart" not in settings["hooks"]: + settings["hooks"]["SessionStart"] = [] + + settings["hooks"]["SessionStart"].append({"hooks": [hook_config]}) + + try: + settings_file.write_text( + json.dumps(settings, indent=2, ensure_ascii=False) + "\n" + ) + except OSError as e: + print(f"Error writing {settings_file}: {e}", file=sys.stderr) + return 1 + + print(f"Hook installed successfully in {settings_file}") + return 0 + + +def hook_main() -> None: + """Process a Claude Code hook event from stdin, or install the hook.""" + logging.basicConfig( + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + level=logging.DEBUG, + stream=sys.stderr, + ) + + parser = argparse.ArgumentParser( + prog="ccweb hook", + description="Claude Code session tracking hook for CCWeb", + ) + parser.add_argument( + "--install", + action="store_true", + help="Install the hook into ~/.claude/settings.json", + ) + args, _ = parser.parse_known_args(sys.argv[2:]) + + if args.install: + sys.exit(_install_hook()) + + # Normal hook processing: read JSON from stdin + logger.debug("Processing hook event from stdin") + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse stdin JSON: %s", e) + return + + session_id = payload.get("session_id", "") + cwd = payload.get("cwd", "") + event = payload.get("hook_event_name", "") + + if not session_id or not event: + return + + if not _UUID_RE.match(session_id): + logger.warning("Invalid session_id format: %s", session_id) + return + + if cwd and not os.path.isabs(cwd): + logger.warning("cwd is not absolute: %s", cwd) + return + + if event != "SessionStart": + return + + # Get tmux session:window key + pane_id = os.environ.get("TMUX_PANE", "") + if not pane_id: + logger.warning("TMUX_PANE not set, cannot determine window") + return + + result = subprocess.run( + [ + "tmux", + "display-message", + "-t", + pane_id, + "-p", + "#{session_name}:#{window_id}:#{window_name}", + ], + capture_output=True, + text=True, + ) + raw_output = result.stdout.strip() + parts = raw_output.split(":", 2) + if len(parts) < 3: + logger.warning( + "Failed to parse tmux output (pane=%s, output=%s)", + pane_id, + raw_output, + ) + return + tmux_session_name, window_id, window_name = parts + session_window_key = f"{tmux_session_name}:{window_id}" + + # Write to ~/.ccweb/session_map.json (NOT ~/.ccbot/) + from .utils import atomic_write_json, ccweb_dir + + map_file = ccweb_dir() / "session_map.json" + map_file.parent.mkdir(parents=True, exist_ok=True) + + lock_path = map_file.with_suffix(".lock") + try: + with open(lock_path, "w") as lock_f: + fcntl.flock(lock_f, fcntl.LOCK_EX) + try: + session_map: dict[str, dict[str, str]] = {} + if map_file.exists(): + try: + session_map = json.loads(map_file.read_text()) + except (json.JSONDecodeError, OSError): + logger.warning("Failed to read session_map, starting fresh") + + session_map[session_window_key] = { + "session_id": session_id, + "cwd": cwd, + "window_name": window_name, + } + + # Clean up old-format key if it exists + old_key = f"{tmux_session_name}:{window_name}" + if old_key != session_window_key and old_key in session_map: + del session_map[old_key] + + atomic_write_json(map_file, session_map) + logger.info( + "Updated session_map: %s -> session_id=%s, cwd=%s", + session_window_key, + session_id, + cwd, + ) + finally: + fcntl.flock(lock_f, fcntl.LOCK_UN) + except OSError as e: + logger.error("Failed to write session_map: %s", e) diff --git a/ccweb/ccweb/backend/core/monitor_state.py b/ccweb/ccweb/backend/core/monitor_state.py new file mode 100644 index 00000000..41481787 --- /dev/null +++ b/ccweb/ccweb/backend/core/monitor_state.py @@ -0,0 +1,109 @@ +"""Monitor state persistence — tracks byte offsets for each session. + +Persists TrackedSession records (session_id, file_path, last_byte_offset) +to ~/.ccweb/monitor_state.json so the session monitor can resume +incremental reading after restarts without re-sending old messages. + +Key classes: MonitorState, TrackedSession. +""" + +import json +import logging +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class TrackedSession: + """State for a tracked Claude Code session.""" + + session_id: str + file_path: str # Path to .jsonl file + last_byte_offset: int = 0 # Byte offset for incremental reading + + def to_dict(self) -> dict[str, Any]: + """Convert to dict for JSON serialization.""" + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TrackedSession": + """Create from dict.""" + return cls( + session_id=data.get("session_id", ""), + file_path=data.get("file_path", ""), + last_byte_offset=data.get("last_byte_offset", 0), + ) + + +@dataclass +class MonitorState: + """Persistent state for the session monitor. + + Stores tracking information for all monitored sessions + to prevent duplicate notifications after restarts. + """ + + state_file: Path + tracked_sessions: dict[str, TrackedSession] = field(default_factory=dict) + _dirty: bool = field(default=False, repr=False) + + def load(self) -> None: + """Load state from file.""" + if not self.state_file.exists(): + logger.debug(f"State file does not exist: {self.state_file}") + return + + try: + data = json.loads(self.state_file.read_text()) + sessions = data.get("tracked_sessions", {}) + self.tracked_sessions = { + k: TrackedSession.from_dict(v) for k, v in sessions.items() + } + logger.info( + f"Loaded {len(self.tracked_sessions)} tracked sessions from state" + ) + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.warning(f"Failed to load state file: {e}") + self.tracked_sessions = {} + + def save(self) -> None: + """Save state to file atomically.""" + from .utils import atomic_write_json + + data = { + "tracked_sessions": { + k: v.to_dict() for k, v in self.tracked_sessions.items() + } + } + + try: + atomic_write_json(self.state_file, data) + self._dirty = False + logger.debug( + "Saved %d tracked sessions to state", len(self.tracked_sessions) + ) + except OSError as e: + logger.error("Failed to save state file: %s", e) + + def get_session(self, session_id: str) -> TrackedSession | None: + """Get tracked session by ID.""" + return self.tracked_sessions.get(session_id) + + def update_session(self, session: TrackedSession) -> None: + """Update or add a tracked session.""" + self.tracked_sessions[session.session_id] = session + self._dirty = True + + def remove_session(self, session_id: str) -> None: + """Remove a tracked session.""" + if session_id in self.tracked_sessions: + del self.tracked_sessions[session_id] + self._dirty = True + + def save_if_dirty(self) -> None: + """Save state only if it has been modified.""" + if self._dirty: + self.save() diff --git a/ccweb/ccweb/backend/core/session_monitor.py b/ccweb/ccweb/backend/core/session_monitor.py new file mode 100644 index 00000000..d006166b --- /dev/null +++ b/ccweb/ccweb/backend/core/session_monitor.py @@ -0,0 +1,533 @@ +"""Session monitoring service — watches JSONL files for new messages. + +Runs an async polling loop that: + 1. Loads the current session_map to know which sessions to watch. + 2. Detects session_map changes (new/changed/deleted windows) and cleans up. + 3. Reads new JSONL lines from each session file using byte-offset tracking. + 4. Parses entries via TranscriptParser and emits NewMessage objects to a callback. + +Optimizations: file size check skips unchanged files; byte offset avoids re-reading. + +Key classes: SessionMonitor, NewMessage, SessionInfo. +""" + +import asyncio +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Awaitable + +import aiofiles + +from ..config import config +from .monitor_state import MonitorState, TrackedSession +from .tmux_manager import tmux_manager +from .transcript_parser import TranscriptParser +from .utils import read_cwd_from_jsonl + +logger = logging.getLogger(__name__) + + +@dataclass +class SessionInfo: + """Information about a Claude Code session.""" + + session_id: str + file_path: Path + + +@dataclass +class NewMessage: + """A new message detected by the monitor.""" + + session_id: str + text: str + is_complete: bool # True when stop_reason is set (final message) + content_type: str = "text" # "text" or "thinking" + tool_use_id: str | None = None + role: str = "assistant" # "user" or "assistant" + tool_name: str | None = None # For tool_use messages, the tool name + image_data: list[tuple[str, bytes]] | None = None # From tool_result images + + +class SessionMonitor: + """Monitors Claude Code sessions for new assistant messages. + + Uses simple async polling with aiofiles for non-blocking I/O. + Emits both intermediate and complete assistant messages. + """ + + def __init__( + self, + projects_path: Path | None = None, + poll_interval: float | None = None, + state_file: Path | None = None, + ): + self.projects_path = ( + projects_path if projects_path is not None else config.claude_projects_path + ) + self.poll_interval = ( + poll_interval if poll_interval is not None else config.monitor_poll_interval + ) + + self.state = MonitorState(state_file=state_file or config.monitor_state_file) + self.state.load() + + self._running = False + self._task: asyncio.Task | None = None + self._message_callback: Callable[[NewMessage], Awaitable[None]] | None = None + # Per-session pending tool_use state carried across poll cycles + self._pending_tools: dict[str, dict[str, Any]] = {} # session_id -> pending + # Track last known session_map for detecting changes + # Keys may be window_id (@12) or window_name (old format) during transition + self._last_session_map: dict[str, str] = {} # window_key -> session_id + + def set_message_callback( + self, callback: Callable[[NewMessage], Awaitable[None]] + ) -> None: + self._message_callback = callback + + async def _get_active_cwds(self) -> set[str]: + """Get normalized cwds of all active tmux windows.""" + cwds = set() + windows = await tmux_manager.list_windows() + for w in windows: + try: + cwds.add(str(Path(w.cwd).resolve())) + except (OSError, ValueError): + cwds.add(w.cwd) + return cwds + + async def scan_projects(self) -> list[SessionInfo]: + """Scan projects that have active tmux windows.""" + active_cwds = await self._get_active_cwds() + if not active_cwds: + return [] + + sessions = [] + + if not self.projects_path.exists(): + return sessions + + for project_dir in self.projects_path.iterdir(): + if not project_dir.is_dir(): + continue + + index_file = project_dir / "sessions-index.json" + original_path = "" + indexed_ids: set[str] = set() + + if index_file.exists(): + try: + async with aiofiles.open(index_file, "r") as f: + content = await f.read() + index_data = json.loads(content) + entries = index_data.get("entries", []) + original_path = index_data.get("originalPath", "") + + for entry in entries: + session_id = entry.get("sessionId", "") + full_path = entry.get("fullPath", "") + project_path = entry.get("projectPath", original_path) + + if not session_id or not full_path: + continue + + try: + norm_pp = str(Path(project_path).resolve()) + except (OSError, ValueError): + norm_pp = project_path + if norm_pp not in active_cwds: + continue + + indexed_ids.add(session_id) + file_path = Path(full_path) + if file_path.exists(): + sessions.append( + SessionInfo( + session_id=session_id, + file_path=file_path, + ) + ) + + except (json.JSONDecodeError, OSError) as e: + logger.debug(f"Error reading index {index_file}: {e}") + + # Pick up un-indexed .jsonl files + try: + for jsonl_file in project_dir.glob("*.jsonl"): + session_id = jsonl_file.stem + if session_id in indexed_ids: + continue + + # Determine project_path for this file + file_project_path = original_path + if not file_project_path: + file_project_path = await asyncio.to_thread( + read_cwd_from_jsonl, jsonl_file + ) + if not file_project_path: + dir_name = project_dir.name + if dir_name.startswith("-"): + file_project_path = dir_name.replace("-", "/") + + try: + norm_fp = str(Path(file_project_path).resolve()) + except (OSError, ValueError): + norm_fp = file_project_path + + if norm_fp not in active_cwds: + continue + + sessions.append( + SessionInfo( + session_id=session_id, + file_path=jsonl_file, + ) + ) + except OSError as e: + logger.debug(f"Error scanning jsonl files in {project_dir}: {e}") + + return sessions + + async def _read_new_lines( + self, session: TrackedSession, file_path: Path + ) -> list[dict]: + """Read new lines from a session file using byte offset for efficiency. + + Detects file truncation (e.g. after /clear) and resets offset. + Recovers from corrupted offsets (mid-line) by scanning to next line. + """ + new_entries = [] + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + # Get file size to detect truncation + await f.seek(0, 2) # Seek to end + file_size = await f.tell() + + # Detect file truncation: if offset is beyond file size, reset + if session.last_byte_offset > file_size: + logger.info( + "File truncated for session %s " + "(offset %d > size %d). Resetting.", + session.session_id, + session.last_byte_offset, + file_size, + ) + session.last_byte_offset = 0 + + # Seek to last read position for incremental reading + await f.seek(session.last_byte_offset) + + # Detect corrupted offset: if we're mid-line (not at '{'), + # scan forward to the next line start. This can happen if + # the state file was manually edited or corrupted. + if session.last_byte_offset > 0: + first_char = await f.read(1) + if first_char and first_char != "{": + logger.warning( + "Corrupted offset %d in session %s (mid-line), " + "scanning to next line", + session.last_byte_offset, + session.session_id, + ) + await f.readline() # Skip rest of partial line + session.last_byte_offset = await f.tell() + return [] + await f.seek(session.last_byte_offset) # Reset for normal read + + # Read only new lines from the offset. + # Track safe_offset: only advance past lines that parsed + # successfully. A non-empty line that fails JSON parsing is + # likely a partial write; stop and retry next cycle. + safe_offset = session.last_byte_offset + async for line in f: + data = TranscriptParser.parse_line(line) + if data: + new_entries.append(data) + safe_offset = await f.tell() + elif line.strip(): + # Partial JSONL line — don't advance offset past it + logger.warning( + "Partial JSONL line in session %s, will retry next cycle", + session.session_id, + ) + break + else: + # Empty line — safe to skip + safe_offset = await f.tell() + + session.last_byte_offset = safe_offset + + except (OSError, UnicodeDecodeError) as e: + logger.error("Error reading session file %s: %s", file_path, e) + # On UnicodeDecodeError (corrupted offset mid-character), reset + # offset to 0 so the next cycle can recover. + if isinstance(e, UnicodeDecodeError): + logger.warning( + "Resetting byte offset for session %s due to decode error", + session.session_id, + ) + session.last_byte_offset = 0 + return new_entries + + async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessage]: + """Check all sessions for new assistant messages. + + Reads from last byte offset. Emits both intermediate + (stop_reason=null) and complete messages. + + Args: + active_session_ids: Set of session IDs currently in session_map + """ + new_messages = [] + + # Scan projects to get available session files + sessions = await self.scan_projects() + + # Only process sessions that are in session_map + for session_info in sessions: + if session_info.session_id not in active_session_ids: + continue + try: + tracked = self.state.get_session(session_info.session_id) + + if tracked is None: + # For new sessions, initialize offset to end of file + # to avoid re-processing old messages + try: + file_size = session_info.file_path.stat().st_size + except OSError: + file_size = 0 + tracked = TrackedSession( + session_id=session_info.session_id, + file_path=str(session_info.file_path), + last_byte_offset=file_size, + ) + self.state.update_session(tracked) + logger.info(f"Started tracking session: {session_info.session_id}") + continue + + # Quick size check: skip reading if file size hasn't changed. + # For append-only JSONL files, size == offset means no new + # content. Size < offset (truncation) and size > offset (new + # data) both need processing — handled inside _read_new_lines. + try: + current_size = session_info.file_path.stat().st_size + except OSError: + continue + + if current_size == tracked.last_byte_offset: + continue + + # File changed, read new content from last offset + new_entries = await self._read_new_lines( + tracked, session_info.file_path + ) + + if new_entries: + logger.debug( + f"Read {len(new_entries)} new entries for " + f"session {session_info.session_id}" + ) + + # Parse new entries using the shared logic, carrying over pending tools + carry = self._pending_tools.get(session_info.session_id, {}) + parsed_entries, remaining = TranscriptParser.parse_entries( + new_entries, + pending_tools=carry, + ) + if remaining: + self._pending_tools[session_info.session_id] = remaining + else: + self._pending_tools.pop(session_info.session_id, None) + + for entry in parsed_entries: + if not entry.text and not entry.image_data: + continue + # Skip user messages unless show_user_messages is enabled + if entry.role == "user" and not config.show_user_messages: + continue + new_messages.append( + NewMessage( + session_id=session_info.session_id, + text=entry.text, + is_complete=True, + content_type=entry.content_type, + tool_use_id=entry.tool_use_id, + role=entry.role, + tool_name=entry.tool_name, + image_data=entry.image_data, + ) + ) + + self.state.update_session(tracked) + + except OSError as e: + logger.debug(f"Error processing session {session_info.session_id}: {e}") + + # NOTE: save_if_dirty() is intentionally NOT called here. + # Offsets must be persisted only AFTER delivery to the client (in + # _monitor_loop) to guarantee at-least-once delivery. Saving before + # delivery would risk silent message loss on crash. + return new_messages + + async def _load_current_session_map(self) -> dict[str, str]: + """Load current session_map and return window_key -> session_id mapping. + + Keys in session_map are formatted as "tmux_session:window_id" + (e.g. "ccbot:@12"). Old-format keys ("ccbot:window_name") are also + accepted so that sessions running before a code upgrade continue + to be monitored until the hook re-fires with new format. + Only entries matching our tmux_session_name are processed. + """ + window_to_session: dict[str, str] = {} + if config.session_map_file.exists(): + try: + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + session_map = json.loads(content) + prefix = f"{config.tmux_session_name}:" + for key, info in session_map.items(): + # Only process entries for our tmux session + if not key.startswith(prefix): + continue + window_key = key[len(prefix) :] + session_id = info.get("session_id", "") + if session_id: + window_to_session[window_key] = session_id + except (json.JSONDecodeError, OSError): + pass + return window_to_session + + async def _cleanup_all_stale_sessions(self) -> None: + """Clean up all tracked sessions not in current session_map (used on startup).""" + current_map = await self._load_current_session_map() + active_session_ids = set(current_map.values()) + + stale_sessions = [] + for session_id in self.state.tracked_sessions.keys(): + if session_id not in active_session_ids: + stale_sessions.append(session_id) + + if stale_sessions: + logger.info( + f"[Startup cleanup] Removing {len(stale_sessions)} stale sessions" + ) + for session_id in stale_sessions: + self.state.remove_session(session_id) + self._pending_tools.pop(session_id, None) + self.state.save_if_dirty() + + async def _detect_and_cleanup_changes(self) -> dict[str, str]: + """Detect session_map changes and cleanup replaced/removed sessions. + + Returns current session_map for further processing. + """ + current_map = await self._load_current_session_map() + + sessions_to_remove: set[str] = set() + + # Check for window session changes (window exists in both, but session_id changed) + for window_id, old_session_id in self._last_session_map.items(): + new_session_id = current_map.get(window_id) + if new_session_id and new_session_id != old_session_id: + logger.info( + "Window '%s' session changed: %s -> %s", + window_id, + old_session_id, + new_session_id, + ) + sessions_to_remove.add(old_session_id) + + # Check for deleted windows (window in old map but not in current) + old_windows = set(self._last_session_map.keys()) + current_windows = set(current_map.keys()) + deleted_windows = old_windows - current_windows + + for window_id in deleted_windows: + old_session_id = self._last_session_map[window_id] + logger.info( + "Window '%s' deleted, removing session %s", + window_id, + old_session_id, + ) + sessions_to_remove.add(old_session_id) + + # Perform cleanup + if sessions_to_remove: + for session_id in sessions_to_remove: + self.state.remove_session(session_id) + self._pending_tools.pop(session_id, None) + self.state.save_if_dirty() + + # Update last known map + self._last_session_map = current_map + + return current_map + + async def _monitor_loop(self) -> None: + """Background loop for checking session updates. + + Uses simple async polling with aiofiles for non-blocking I/O. + """ + logger.info("Session monitor started, polling every %ss", self.poll_interval) + + # Deferred import to avoid circular dependency (cached once) + from ..session import session_manager + + # Clean up all stale sessions on startup + await self._cleanup_all_stale_sessions() + # Initialize last known session_map + self._last_session_map = await self._load_current_session_map() + + while self._running: + try: + # Load hook-based session map updates + await session_manager.load_session_map() + + # Detect session_map changes and cleanup replaced/removed sessions + current_map = await self._detect_and_cleanup_changes() + active_session_ids = set(current_map.values()) + + # Check for new messages (all I/O is async) + new_messages = await self.check_for_updates(active_session_ids) + + for msg in new_messages: + status = "complete" if msg.is_complete else "streaming" + preview = msg.text[:80] + ("..." if len(msg.text) > 80 else "") + logger.info("[%s] session=%s: %s", status, msg.session_id, preview) + if self._message_callback: + try: + await self._message_callback(msg) + except Exception as e: + logger.error(f"Message callback error: {e}") + + # Persist byte offsets AFTER delivering messages to the client. + # This guarantees at-least-once delivery: if the server crashes + # before this save, messages will be re-read and re-delivered + # on restart (safe duplicate) rather than silently lost. + self.state.save_if_dirty() + + except Exception as e: + logger.error(f"Monitor loop error: {e}") + + await asyncio.sleep(self.poll_interval) + + logger.info("Session monitor stopped") + + def start(self) -> None: + if self._running: + logger.warning("Monitor already running") + return + self._running = True + self._task = asyncio.create_task(self._monitor_loop()) + + def stop(self) -> None: + self._running = False + if self._task: + self._task.cancel() + self._task = None + self.state.save() + logger.info("Session monitor stopped and state saved") diff --git a/ccweb/ccweb/backend/core/terminal_parser.py b/ccweb/ccweb/backend/core/terminal_parser.py new file mode 100644 index 00000000..1afefed0 --- /dev/null +++ b/ccweb/ccweb/backend/core/terminal_parser.py @@ -0,0 +1,365 @@ +"""Terminal output parser — detects Claude Code UI elements in pane text. + +Parses captured tmux pane content to detect: + - Interactive UIs (AskUserQuestion, ExitPlanMode, Permission Prompt, + RestoreCheckpoint) via regex-based UIPattern matching with top/bottom + delimiters. + - Status line (spinner characters + working text) by scanning from bottom up. + +All Claude Code text patterns live here. To support a new UI type or +a changed Claude Code version, edit UI_PATTERNS / STATUS_SPINNERS. + +Key functions: is_interactive_ui(), extract_interactive_content(), +parse_status_line(), strip_pane_chrome(), extract_bash_output(). +""" + +import re +from dataclasses import dataclass + + +@dataclass +class InteractiveUIContent: + """Content extracted from an interactive UI.""" + + content: str # The extracted display content + name: str = "" # Pattern name that matched (e.g. "AskUserQuestion") + + +@dataclass(frozen=True) +class UIPattern: + """A text-marker pair that delimits an interactive UI region. + + Extraction scans lines top-down: the first line matching any `top` pattern + marks the start, the first subsequent line matching any `bottom` pattern + marks the end. Both boundary lines are included in the extracted content. + + ``top`` and ``bottom`` are tuples of compiled regexes — any single match + is sufficient. This accommodates wording changes across Claude Code + versions (e.g. a reworded confirmation prompt). + """ + + name: str # Descriptive label (not used programmatically) + top: tuple[re.Pattern[str], ...] + bottom: tuple[re.Pattern[str], ...] + min_gap: int = 2 # minimum lines between top and bottom (inclusive) + + +# ── UI pattern definitions (order matters — first match wins) ──────────── + +UI_PATTERNS: list[UIPattern] = [ + UIPattern( + name="ExitPlanMode", + top=( + re.compile(r"^\s*Would you like to proceed\?"), + # v2.1.29+: longer prefix that may wrap across lines + re.compile(r"^\s*Claude has written up a plan"), + ), + bottom=( + re.compile(r"^\s*ctrl-g to edit in "), + re.compile(r"^\s*Esc to (cancel|exit)"), + ), + ), + UIPattern( + name="AskUserQuestion", + top=(re.compile(r"^\s*←\s+[☐✔☒]"),), # Multi-tab: no bottom needed + bottom=(), + min_gap=1, + ), + UIPattern( + name="AskUserQuestion", + top=(re.compile(r"^\s*[☐✔☒]"),), # Single-tab: bottom required + bottom=(re.compile(r"^\s*Enter to select"),), + min_gap=1, + ), + UIPattern( + name="PermissionPrompt", + top=( + re.compile(r"^\s*Do you want to proceed\?"), + re.compile(r"^\s*Do you want to make this edit"), + re.compile(r"^\s*Do you want to create \S"), + re.compile(r"^\s*Do you want to delete \S"), + ), + bottom=(re.compile(r"^\s*Esc to cancel"),), + ), + UIPattern( + # Permission menu with numbered choices (no "Esc to cancel" line) + name="PermissionPrompt", + top=(re.compile(r"^\s*❯\s*1\.\s*Yes"),), + bottom=(), + min_gap=2, + ), + UIPattern( + # Bash command approval + name="BashApproval", + top=( + re.compile(r"^\s*Bash command\s*$"), + re.compile(r"^\s*This command requires approval"), + ), + bottom=(re.compile(r"^\s*Esc to cancel"),), + ), + UIPattern( + name="RestoreCheckpoint", + top=(re.compile(r"^\s*Restore the code"),), + bottom=(re.compile(r"^\s*Enter to continue"),), + ), + UIPattern( + name="Settings", + top=( + re.compile(r"^\s*Settings:.*tab to cycle"), + re.compile(r"^\s*Select model"), + ), + bottom=( + re.compile(r"Esc to cancel"), + re.compile(r"Esc to exit"), + re.compile(r"Enter to confirm"), + re.compile(r"^\s*Type to filter"), + ), + ), +] + + +# ── Post-processing ────────────────────────────────────────────────────── + +_RE_LONG_DASH = re.compile(r"^─{5,}$") + + +def _shorten_separators(text: str) -> str: + """Replace lines of 5+ ─ characters with exactly ─────.""" + return "\n".join( + "─────" if _RE_LONG_DASH.match(line) else line for line in text.split("\n") + ) + + +# ── Core extraction ────────────────────────────────────────────────────── + + +def _try_extract(lines: list[str], pattern: UIPattern) -> InteractiveUIContent | None: + """Try to extract content matching a single UI pattern. + + When ``pattern.bottom`` is empty, the region extends from the top marker + to the last non-empty line (used for multi-tab AskUserQuestion where the + bottom delimiter varies by tab). + """ + top_idx: int | None = None + bottom_idx: int | None = None + + for i, line in enumerate(lines): + if top_idx is None: + if any(p.search(line) for p in pattern.top): + top_idx = i + elif pattern.bottom and any(p.search(line) for p in pattern.bottom): + bottom_idx = i + break + + if top_idx is None: + return None + + # No bottom patterns → use last non-empty line as boundary + if not pattern.bottom: + for i in range(len(lines) - 1, top_idx, -1): + if lines[i].strip(): + bottom_idx = i + break + + if bottom_idx is None or bottom_idx - top_idx < pattern.min_gap: + return None + + content = "\n".join(lines[top_idx : bottom_idx + 1]).rstrip() + return InteractiveUIContent(content=_shorten_separators(content), name=pattern.name) + + +# ── Public API ─────────────────────────────────────────────────────────── + + +def extract_interactive_content(pane_text: str) -> InteractiveUIContent | None: + """Extract content from an interactive UI in terminal output. + + Tries each UI pattern in declaration order; first match wins. + Returns None if no recognizable interactive UI is found. + """ + if not pane_text: + return None + + lines = pane_text.strip().split("\n") + for pattern in UI_PATTERNS: + result = _try_extract(lines, pattern) + if result: + return result + return None + + +def is_interactive_ui(pane_text: str) -> bool: + """Check if terminal currently shows an interactive UI.""" + return extract_interactive_content(pane_text) is not None + + +# ── Status line parsing ───────────────────────────────────────────────── + +# Spinner characters Claude Code uses in its status line +STATUS_SPINNERS = frozenset(["·", "✻", "✽", "✶", "✳", "✢"]) + + +def parse_status_line(pane_text: str) -> str | None: + """Extract the Claude Code status line from terminal output. + + The status line (spinner + working text) appears immediately above + the chrome separator (a full line of ``─`` characters). We locate + the separator first, then check the line just above it — this avoids + false positives from ``·`` bullets in Claude's regular output. + + Returns the text after the spinner, or None if no status line found. + """ + if not pane_text: + return None + + lines = pane_text.split("\n") + + # Find the chrome separator: topmost ──── line in the last 10 lines + chrome_idx: int | None = None + search_start = max(0, len(lines) - 10) + for i in range(search_start, len(lines)): + stripped = lines[i].strip() + if len(stripped) >= 20 and all(c == "─" for c in stripped): + chrome_idx = i + break + + if chrome_idx is None: + return None # No chrome visible — can't determine status + + # Check lines just above the separator (skip blanks, up to 4 lines) + for i in range(chrome_idx - 1, max(chrome_idx - 5, -1), -1): + line = lines[i].strip() + if not line: + continue + if line[0] in STATUS_SPINNERS: + return line[1:].strip() + # First non-empty line above separator isn't a spinner → no status + return None + return None + + +# ── Pane chrome stripping & bash output extraction ───────────────────── + + +def strip_pane_chrome(lines: list[str]) -> list[str]: + """Strip Claude Code's bottom chrome (prompt area + status bar). + + The bottom of the pane looks like:: + + ──────────────────────── (separator) + ❯ (prompt) + ──────────────────────── (separator) + [Opus 4.6] Context: 34% + ⏵⏵ bypass permissions… + + This function finds the topmost ``────`` separator in the last 10 lines + and strips everything from there down. + """ + search_start = max(0, len(lines) - 10) + for i in range(search_start, len(lines)): + stripped = lines[i].strip() + if len(stripped) >= 20 and all(c == "─" for c in stripped): + return lines[:i] + return lines + + +def extract_bash_output(pane_text: str, command: str) -> str | None: + """Extract ``!`` command output from a captured tmux pane. + + Searches from the bottom for the ``! `` echo line, then + returns that line and everything below it (including the ``⎿`` output). + Returns *None* if the command echo wasn't found. + """ + lines = strip_pane_chrome(pane_text.splitlines()) + + # Find the last "! " echo line (search from bottom). + # Match on the first 10 chars of the command in case the line is truncated. + cmd_idx: int | None = None + match_prefix = command[:10] + for i in range(len(lines) - 1, -1, -1): + stripped = lines[i].strip() + if stripped.startswith(f"! {match_prefix}") or stripped.startswith( + f"!{match_prefix}" + ): + cmd_idx = i + break + + if cmd_idx is None: + return None + + # Include the command echo line and everything after it + raw_output = lines[cmd_idx:] + + # Strip trailing empty lines + while raw_output and not raw_output[-1].strip(): + raw_output.pop() + + if not raw_output: + return None + + return "\n".join(raw_output).strip() + + +# ── Usage modal parsing ────────────────────────────────────────────────────────── + + +@dataclass +class UsageInfo: + """Parsed output from Claude Code's /usage modal.""" + + raw_text: str # Full captured pane text + parsed_lines: list[str] # Cleaned content lines from the modal + + +def parse_usage_output(pane_text: str) -> UsageInfo | None: + """Extract usage information from Claude Code's /usage settings tab. + + The /usage modal shows a Settings overlay with a "Usage" tab containing + progress bars and reset times. This parser looks for the Settings header + line, then collects all content until "Esc to cancel". + + Returns UsageInfo with cleaned lines, or None if not detected. + """ + if not pane_text: + return None + + lines = pane_text.strip().split("\n") + + # Find the Settings header that indicates we're in the usage modal + start_idx: int | None = None + end_idx: int | None = None + + for i, line in enumerate(lines): + stripped = line.strip() + if start_idx is None: + # The usage tab header line + if "Settings:" in stripped and "Usage" in stripped: + start_idx = i + 1 # skip the header itself + else: + if stripped.startswith("Esc to"): + end_idx = i + break + + if start_idx is None: + return None + if end_idx is None: + end_idx = len(lines) + + # Collect content lines, stripping progress bar characters and whitespace + cleaned: list[str] = [] + for line in lines[start_idx:end_idx]: + # Strip the line but preserve meaningful content + stripped = line.strip() + if not stripped: + continue + # Remove progress bar block characters but keep the rest + # Progress bars are like: █████▋ 38% used + # Strip leading block chars, keep the percentage + stripped = re.sub(r"^[\u2580-\u259f\s]+", "", stripped).strip() + if stripped: + cleaned.append(stripped) + + if cleaned: + return UsageInfo(raw_text=pane_text, parsed_lines=cleaned) + + return None diff --git a/ccweb/ccweb/backend/core/tmux_manager.py b/ccweb/ccweb/backend/core/tmux_manager.py new file mode 100644 index 00000000..0ec01cc4 --- /dev/null +++ b/ccweb/ccweb/backend/core/tmux_manager.py @@ -0,0 +1,477 @@ +"""Tmux session/window management via libtmux. + +Wraps libtmux to provide async-friendly operations on a single tmux session: + - list_windows / find_window_by_name: discover Claude Code windows. + - capture_pane: read terminal content (plain or with ANSI colors). + - send_keys: forward user input or control keys to a window. + - create_window / kill_window: lifecycle management. + +All blocking libtmux calls are wrapped in asyncio.to_thread(). + +Key class: TmuxManager (singleton instantiated as `tmux_manager`). +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass +from pathlib import Path + +import libtmux + +from ..config import SENSITIVE_ENV_VARS, config + +logger = logging.getLogger(__name__) + +# Process names that indicate a bare shell (Claude Code has exited). +# Used to prevent sending user input to a shell prompt. +SHELL_COMMANDS = frozenset( + { + "bash", + "zsh", + "sh", + "fish", + "dash", + "tcsh", + "csh", + "ksh", + "ash", + } +) + + +@dataclass +class TmuxWindow: + """Information about a tmux window.""" + + window_id: str + window_name: str + cwd: str # Current working directory + pane_current_command: str = "" # Process running in active pane + + +class TmuxManager: + """Manages tmux windows for Claude Code sessions.""" + + # How long cached list_windows results are valid (seconds). + _CACHE_TTL = 1.0 + + def __init__(self, session_name: str | None = None): + """Initialize tmux manager. + + Args: + session_name: Name of the tmux session to use (default from config) + """ + self.session_name = session_name or config.tmux_session_name + self._server: libtmux.Server | None = None + self._windows_cache: list[TmuxWindow] | None = None + self._windows_cache_time: float = 0.0 + + @property + def server(self) -> libtmux.Server: + """Get or create tmux server connection.""" + if self._server is None: + self._server = libtmux.Server() + return self._server + + def get_session(self) -> libtmux.Session | None: + """Get the tmux session if it exists.""" + try: + return self.server.sessions.get(session_name=self.session_name) + except Exception: + return None + + def get_or_create_session(self) -> libtmux.Session: + """Get existing session or create a new one.""" + session = self.get_session() + if session: + self._scrub_session_env(session) + return session + + # Create new session with main window named specifically + session = self.server.new_session( + session_name=self.session_name, + start_directory=str(Path.home()), + ) + # Rename the default window to the main window name + if session.windows: + session.windows[0].rename_window(config.tmux_main_window_name) + self._scrub_session_env(session) + return session + + @staticmethod + def _scrub_session_env(session: libtmux.Session) -> None: + """Remove sensitive env vars from the tmux session environment. + + Prevents new windows (and their child processes like Claude Code) + from inheriting secrets such as auth tokens. + """ + for var in SENSITIVE_ENV_VARS: + try: + session.unset_environment(var) + except Exception: + pass # var not set in session env — nothing to remove + + def invalidate_cache(self) -> None: + """Invalidate the cached window list (call after mutations).""" + self._windows_cache = None + + async def list_windows(self) -> list[TmuxWindow]: + """List all windows in the session with their working directories. + + Results are cached for ``_CACHE_TTL`` seconds to avoid hammering + the tmux server when multiple callers need window info in the same + poll cycle. + """ + now = time.monotonic() + if ( + self._windows_cache is not None + and (now - self._windows_cache_time) < self._CACHE_TTL + ): + return self._windows_cache + + def _sync_list_windows() -> list[TmuxWindow]: + windows = [] + session = self.get_session() + + if not session: + return windows + + for window in session.windows: + name = window.window_name or "" + # Skip the main window (placeholder window) + if name == config.tmux_main_window_name: + continue + + try: + # Get the active pane's current path and command + pane = window.active_pane + if pane: + cwd = pane.pane_current_path or "" + pane_cmd = pane.pane_current_command or "" + else: + cwd = "" + pane_cmd = "" + + windows.append( + TmuxWindow( + window_id=window.window_id or "", + window_name=name, + cwd=cwd, + pane_current_command=pane_cmd, + ) + ) + except Exception as e: + logger.debug(f"Error getting window info: {e}") + + return windows + + result = await asyncio.to_thread(_sync_list_windows) + self._windows_cache = result + self._windows_cache_time = time.monotonic() + return result + + async def find_window_by_name(self, window_name: str) -> TmuxWindow | None: + """Find a window by its name. + + Args: + window_name: The window name to match + + Returns: + TmuxWindow if found, None otherwise + """ + windows = await self.list_windows() + for window in windows: + if window.window_name == window_name: + return window + logger.debug("Window not found by name: %s", window_name) + return None + + async def find_window_by_id(self, window_id: str) -> TmuxWindow | None: + """Find a window by its tmux window ID (e.g. '@0', '@12'). + + Args: + window_id: The tmux window ID to match + + Returns: + TmuxWindow if found, None otherwise + """ + windows = await self.list_windows() + for window in windows: + if window.window_id == window_id: + return window + logger.debug("Window not found by id: %s", window_id) + return None + + async def get_pane_pid(self, window_id: str) -> int | None: + """Get the PID of the shell process in a window's active pane.""" + try: + proc = await asyncio.create_subprocess_exec( + "tmux", + "display-message", + "-p", + "-t", + window_id, + "#{pane_pid}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, _ = await proc.communicate() + if proc.returncode == 0: + pid_str = stdout.decode("utf-8").strip() + if pid_str: + return int(pid_str) + except (OSError, ValueError) as e: + logger.debug("Failed to get pane PID for %s: %s", window_id, e) + return None + + async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | None: + """Capture the visible text content of a window's active pane. + + Uses a direct ``tmux capture-pane`` subprocess for both plain text + and ANSI modes — avoids the multiple tmux round-trips that libtmux + would generate (list-windows → list-panes → capture-pane). + """ + cmd = ["tmux", "capture-pane", "-p", "-t", window_id] + if with_ansi: + cmd.insert(2, "-e") + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return stdout.decode("utf-8") + logger.error( + "Failed to capture pane %s: %s", window_id, stderr.decode("utf-8") + ) + return None + except Exception as e: + logger.error("Unexpected error capturing pane %s: %s", window_id, e) + return None + + async def send_keys( + self, window_id: str, text: str, enter: bool = True, literal: bool = True + ) -> bool: + """Send keys to a specific window. + + Args: + window_id: The window ID to send to + text: Text to send + enter: Whether to press enter after the text + literal: If True, send text literally. If False, interpret special keys + like "Up", "Down", "Left", "Right", "Escape", "Enter". + + Returns: + True if successful, False otherwise + """ + if literal and enter: + # Split into text + delay + Enter via libtmux. + # Claude Code's TUI sometimes interprets a rapid-fire Enter + # (arriving in the same input batch as the text) as a newline + # rather than submit. A 500ms gap lets the TUI process the + # text before receiving Enter. + def _send_literal(chars: str) -> bool: + session = self.get_session() + if not session: + logger.error("No tmux session found") + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + logger.error(f"Window {window_id} not found") + return False + pane = window.active_pane + if not pane: + logger.error(f"No active pane in window {window_id}") + return False + pane.send_keys(chars, enter=False, literal=True) + return True + except Exception as e: + logger.error(f"Failed to send keys to window {window_id}: {e}") + return False + + def _send_enter() -> bool: + session = self.get_session() + if not session: + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + return False + pane = window.active_pane + if not pane: + return False + pane.send_keys("", enter=True, literal=False) + return True + except Exception as e: + logger.error(f"Failed to send Enter to window {window_id}: {e}") + return False + + # Claude Code's ! command mode: send "!" first so the TUI + # switches to bash mode, wait 1s, then send the rest. + if text.startswith("!"): + if not await asyncio.to_thread(_send_literal, "!"): + return False + rest = text[1:] + if rest: + await asyncio.sleep(1.0) + if not await asyncio.to_thread(_send_literal, rest): + return False + else: + if not await asyncio.to_thread(_send_literal, text): + return False + await asyncio.sleep(0.5) + return await asyncio.to_thread(_send_enter) + + # Other cases: special keys (literal=False) or no-enter + def _sync_send_keys() -> bool: + session = self.get_session() + if not session: + logger.error("No tmux session found") + return False + + try: + window = session.windows.get(window_id=window_id) + if not window: + logger.error(f"Window {window_id} not found") + return False + + pane = window.active_pane + if not pane: + logger.error(f"No active pane in window {window_id}") + return False + + pane.send_keys(text, enter=enter, literal=literal) + return True + + except Exception as e: + logger.error(f"Failed to send keys to window {window_id}: {e}") + return False + + return await asyncio.to_thread(_sync_send_keys) + + async def rename_window(self, window_id: str, new_name: str) -> bool: + """Rename a tmux window by its ID.""" + self.invalidate_cache() + + def _sync_rename() -> bool: + session = self.get_session() + if not session: + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + return False + window.rename_window(new_name) + logger.info("Renamed window %s to '%s'", window_id, new_name) + return True + except Exception as e: + logger.error(f"Failed to rename window {window_id}: {e}") + return False + + return await asyncio.to_thread(_sync_rename) + + async def kill_window(self, window_id: str) -> bool: + """Kill a tmux window by its ID.""" + self.invalidate_cache() + + def _sync_kill() -> bool: + session = self.get_session() + if not session: + return False + try: + window = session.windows.get(window_id=window_id) + if not window: + return False + window.kill() + logger.info("Killed window %s", window_id) + return True + except Exception as e: + logger.error(f"Failed to kill window {window_id}: {e}") + return False + + return await asyncio.to_thread(_sync_kill) + + async def create_window( + self, + work_dir: str, + window_name: str | None = None, + start_claude: bool = True, + ) -> tuple[bool, str, str, str]: + """Create a new tmux window and optionally start Claude Code. + + Args: + work_dir: Working directory for the new window + window_name: Optional window name (defaults to directory name) + start_claude: Whether to start claude command + + Returns: + Tuple of (success, message, window_name, window_id) + """ + # Validate directory first + path = Path(work_dir).expanduser().resolve() + if not path.exists(): + return False, f"Directory does not exist: {work_dir}", "", "" + if not path.is_dir(): + return False, f"Not a directory: {work_dir}", "", "" + + # Create window name, adding suffix if name already exists + final_window_name = window_name if window_name else path.name + + # Check for existing window name + base_name = final_window_name + counter = 2 + while await self.find_window_by_name(final_window_name): + final_window_name = f"{base_name}-{counter}" + counter += 1 + + # Create window in thread + self.invalidate_cache() + + def _create_and_start() -> tuple[bool, str, str, str]: + session = self.get_or_create_session() + try: + # Create new window + window = session.new_window( + window_name=final_window_name, + start_directory=str(path), + ) + + wid = window.window_id or "" + + # Prevent Claude Code from overriding window name + window.set_window_option("allow-rename", "off") + + # Start Claude Code if requested + if start_claude: + pane = window.active_pane + if pane: + pane.send_keys(config.claude_command, enter=True) + + logger.info( + "Created window '%s' (id=%s) at %s", + final_window_name, + wid, + path, + ) + return ( + True, + f"Created window '{final_window_name}' at {path}", + final_window_name, + wid, + ) + + except Exception as e: + logger.error(f"Failed to create window: {e}") + return False, f"Failed to create window: {e}", "", "" + + return await asyncio.to_thread(_create_and_start) + + +# Global instance with default session name +tmux_manager = TmuxManager() diff --git a/ccweb/ccweb/backend/core/transcript_parser.py b/ccweb/ccweb/backend/core/transcript_parser.py new file mode 100644 index 00000000..35c84419 --- /dev/null +++ b/ccweb/ccweb/backend/core/transcript_parser.py @@ -0,0 +1,774 @@ +"""JSONL transcript parser for Claude Code session files. + +Parses Claude Code session JSONL files and extracts structured messages. +Handles: text, thinking, tool_use, tool_result, local_command, and user messages. +Tool pairing: tool_use blocks in assistant messages are matched with +tool_result blocks in subsequent user messages via tool_use_id. + +Forked from ccbot — removed Telegram expandable quote sentinels. +Collapsible content is handled by the React frontend, not by text markers. + +Key classes: TranscriptParser (static methods), ParsedEntry, ParsedMessage, PendingToolInfo. +""" + +import base64 +import difflib +import json +import logging +import re +from dataclasses import dataclass +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class ParsedMessage: + """Parsed message from a transcript.""" + + message_type: str # "user", "assistant", "tool_use", "tool_result", etc. + text: str # Extracted text content + tool_name: str | None = None # For tool_use messages + + +@dataclass +class ParsedEntry: + """A single parsed message entry ready for display.""" + + role: str # "user" | "assistant" + text: str # Already formatted text + content_type: ( + str # "text" | "thinking" | "tool_use" | "tool_result" | "local_command" + ) + tool_use_id: str | None = None + timestamp: str | None = None # ISO timestamp from JSONL + tool_name: str | None = ( + None # For tool_use entries, the tool name (e.g. "AskUserQuestion") + ) + image_data: list[tuple[str, bytes]] | None = ( + None # For tool_result entries with images: (media_type, raw_bytes) + ) + + +@dataclass +class PendingToolInfo: + """Information about a pending tool_use waiting for its tool_result.""" + + summary: str # Formatted tool summary (e.g. "**Read**(file.py)") + tool_name: str # Tool name (e.g. "Read", "Edit") + input_data: Any = None # Tool input parameters (for Edit to generate diff) + + +class TranscriptParser: + """Parser for Claude Code JSONL session files. + + Expected JSONL entry structure: + - type: "user" | "assistant" | "summary" | "file-history-snapshot" | ... + - message.content: list of blocks (text, tool_use, tool_result, thinking) + - sessionId, cwd, timestamp, uuid: metadata fields + + Tool pairing model: tool_use blocks appear in assistant messages, + matching tool_result blocks appear in the next user message (keyed by tool_use_id). + """ + + # Magic string constants + _NO_CONTENT_PLACEHOLDER = "(no content)" + _INTERRUPTED_TEXT = "[Request interrupted by user for tool use]" + _MAX_SUMMARY_LENGTH = 200 + + @staticmethod + def parse_line(line: str) -> dict | None: + """Parse a single JSONL line. + + Args: + line: A single line from the JSONL file + + Returns: + Parsed dict or None if line is empty/invalid + """ + line = line.strip() + if not line: + return None + + try: + return json.loads(line) + except json.JSONDecodeError: + return None + + @staticmethod + def get_message_type(data: dict) -> str | None: + """Get the message type from parsed data. + + Returns: + Message type: "user", "assistant", "file-history-snapshot", etc. + """ + return data.get("type") + + @staticmethod + def is_user_message(data: dict) -> bool: + """Check if this is a user message.""" + return data.get("type") == "user" + + @staticmethod + def extract_text_only(content_list: list[Any]) -> str: + """Extract only text content from structured content. + + This is used for Telegram notifications where we only want + the actual text response, not tool calls or thinking. + + Args: + content_list: List of content blocks + + Returns: + Combined text content only + """ + if not isinstance(content_list, list): + if isinstance(content_list, str): + return content_list + return "" + + texts = [] + for item in content_list: + if isinstance(item, str): + texts.append(item) + elif isinstance(item, dict): + if item.get("type") == "text": + text = item.get("text", "") + if text: + texts.append(text) + + return "\n".join(texts) + + _RE_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + + _RE_COMMAND_NAME = re.compile(r"(.*?)") + _RE_LOCAL_STDOUT = re.compile( + r"(.*?)", re.DOTALL + ) + _RE_SYSTEM_TAGS = re.compile( + r"<(bash-input|bash-stdout|bash-stderr|local-command-caveat|system-reminder)" + ) + + @staticmethod + def _format_edit_diff(old_string: str, new_string: str) -> str: + """Generate a compact unified diff between old_string and new_string.""" + old_lines = old_string.splitlines(keepends=True) + new_lines = new_string.splitlines(keepends=True) + diff = difflib.unified_diff(old_lines, new_lines, lineterm="") + # Skip the --- / +++ header lines + result_lines: list[str] = [] + for line in diff: + if line.startswith("---") or line.startswith("+++"): + continue + # Strip trailing newline for clean display + result_lines.append(line.rstrip("\n")) + return "\n".join(result_lines) + + @classmethod + def format_tool_use_summary(cls, name: str, input_data: dict | Any) -> str: + """Format a tool_use block into a brief summary line. + + Args: + name: Tool name (e.g. "Read", "Write", "Bash") + input_data: The tool input dict + + Returns: + Formatted string like "**Read**(file.py)" + """ + if not isinstance(input_data, dict): + return f"**{name}**" + + # Pick a meaningful short summary based on tool name + summary = "" + if name in ("Read", "Glob"): + summary = input_data.get("file_path") or input_data.get("pattern", "") + elif name == "Write": + summary = input_data.get("file_path", "") + elif name in ("Edit", "NotebookEdit"): + summary = input_data.get("file_path") or input_data.get("notebook_path", "") + # Note: Edit/Update diff and stats are generated in tool_result stage, + # not here. We just show the tool name and file path. + elif name == "Bash": + summary = input_data.get("command", "") + elif name == "Grep": + summary = input_data.get("pattern", "") + elif name == "Task": + summary = input_data.get("description", "") + elif name == "WebFetch": + summary = input_data.get("url", "") + elif name == "WebSearch": + summary = input_data.get("query", "") + elif name == "TodoWrite": + todos = input_data.get("todos", []) + if isinstance(todos, list): + summary = f"{len(todos)} item(s)" + elif name == "TodoRead": + summary = "" + elif name == "AskUserQuestion": + questions = input_data.get("questions", []) + if isinstance(questions, list) and questions: + q = questions[0] + if isinstance(q, dict): + summary = q.get("question", "") + elif name == "ExitPlanMode": + summary = "" + elif name == "Skill": + summary = input_data.get("skill", "") + else: + # Generic: show first string value + for v in input_data.values(): + if isinstance(v, str) and v: + summary = v + break + + if summary: + if len(summary) > cls._MAX_SUMMARY_LENGTH: + summary = summary[: cls._MAX_SUMMARY_LENGTH] + "…" + return f"**{name}**({summary})" + return f"**{name}**" + + @staticmethod + def extract_tool_result_text(content: list | Any) -> str: + """Extract text from a tool_result content block.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + t = item.get("text", "") + if t: + parts.append(t) + elif isinstance(item, str): + parts.append(item) + return "\n".join(parts) + return "" + + @staticmethod + def extract_tool_result_images( + content: list | Any, + ) -> list[tuple[str, bytes]] | None: + """Extract base64-encoded images from a tool_result content block. + + Returns list of (media_type, raw_bytes) tuples, or None if no images found. + """ + if not isinstance(content, list): + return None + images: list[tuple[str, bytes]] = [] + for item in content: + if not isinstance(item, dict) or item.get("type") != "image": + continue + source = item.get("source") + if not isinstance(source, dict) or source.get("type") != "base64": + continue + media_type = source.get("media_type", "image/png") + data_str = source.get("data", "") + if not data_str: + continue + try: + raw_bytes = base64.b64decode(data_str) + images.append((media_type, raw_bytes)) + except Exception: + logger.debug("Failed to decode base64 image in tool_result") + return images if images else None + + @classmethod + def parse_message(cls, data: dict) -> ParsedMessage | None: + """Parse a message entry from the JSONL data. + + Args: + data: Parsed JSON dict from a JSONL line + + Returns: + ParsedMessage or None if not a parseable message + """ + msg_type = cls.get_message_type(data) + + if msg_type not in ("user", "assistant"): + return None + + message = data.get("message") + if not isinstance(message, dict): + return None + content = message.get("content", "") + + if isinstance(content, list): + text = cls.extract_text_only(content) + else: + text = str(content) if content else "" + text = cls._RE_ANSI_ESCAPE.sub("", text) + + # Detect local command responses in user messages. + # These are rendered as bot replies: "❯ /cmd\n ⎿ output" + if msg_type == "user" and text: + stdout_match = cls._RE_LOCAL_STDOUT.search(text) + if stdout_match: + stdout = stdout_match.group(1).strip() + cmd_match = cls._RE_COMMAND_NAME.search(text) + cmd = cmd_match.group(1) if cmd_match else None + return ParsedMessage( + message_type="local_command", + text=stdout, + tool_name=cmd, # reuse field for command name + ) + # Pure command invocation (no stdout) — carry command name + cmd_match = cls._RE_COMMAND_NAME.search(text) + if cmd_match: + return ParsedMessage( + message_type="local_command_invoke", + text="", + tool_name=cmd_match.group(1), + ) + + return ParsedMessage( + message_type=msg_type, + text=text, + ) + + @staticmethod + def get_timestamp(data: dict) -> str | None: + """Extract timestamp from message data.""" + return data.get("timestamp") + + # No expandable quote sentinels — the React frontend handles collapsible rendering. + + @classmethod + def _format_collapsible(cls, text: str) -> str: + """Return text as-is. The frontend renders collapsible content.""" + return text + + @classmethod + def _format_tool_result_text(cls, text: str, tool_name: str | None = None) -> str: + """Format tool result text with statistics summary. + + Shows relevant statistics for each tool type, with expandable quote for full content. + + No truncation here — per project principles, truncation is handled + only at the send layer (split_message / _render_expandable_quote). + """ + if not text: + return "" + + line_count = text.count("\n") + 1 if text else 0 + + # Tool-specific statistics + if tool_name == "Read": + # Read: show line count instead of full content + return f" ⎿ Read {line_count} lines" + + elif tool_name == "Write": + # Write: show lines written + stats = f" ⎿ Wrote {line_count} lines" + return stats + + elif tool_name == "Bash": + # Bash: show output line count + if line_count > 0: + stats = f" ⎿ Output {line_count} lines" + return stats + "\n" + cls._format_collapsible(text) + return cls._format_collapsible(text) + + elif tool_name == "Grep": + # Grep: show match count (count non-empty lines) + matches = len([line for line in text.split("\n") if line.strip()]) + stats = f" ⎿ Found {matches} matches" + return stats + "\n" + cls._format_collapsible(text) + + elif tool_name == "Glob": + # Glob: show file count + files = len([line for line in text.split("\n") if line.strip()]) + stats = f" ⎿ Found {files} files" + return stats + "\n" + cls._format_collapsible(text) + + elif tool_name == "Task": + # Task: show output length + if line_count > 0: + stats = f" ⎿ Agent output {line_count} lines" + return stats + "\n" + cls._format_collapsible(text) + return cls._format_collapsible(text) + + elif tool_name == "WebFetch": + # WebFetch: show content length + char_count = len(text) + stats = f" ⎿ Fetched {char_count} characters" + return stats + "\n" + cls._format_collapsible(text) + + elif tool_name == "WebSearch": + # WebSearch: show results count (estimate by sections) + results = text.count("\n\n") + 1 if text else 0 + stats = f" ⎿ {results} search results" + return stats + "\n" + cls._format_collapsible(text) + + # Default: expandable quote without stats + return cls._format_collapsible(text) + + @classmethod + def parse_entries( + cls, + entries: list[dict], + pending_tools: dict[str, PendingToolInfo] | None = None, + ) -> tuple[list[ParsedEntry], dict[str, PendingToolInfo]]: + """Parse a list of JSONL entries into a flat list of display-ready messages. + + This is the shared core logic used by both get_recent_messages (history) + and check_for_updates (monitor). + + Args: + entries: List of parsed JSONL dicts (already filtered through parse_line) + pending_tools: Optional carry-over pending tool_use state from a + previous call (tool_use_id -> formatted summary). Used by the + monitor to handle tool_use and tool_result arriving in separate + poll cycles. + + Returns: + Tuple of (parsed entries, remaining pending_tools state) + """ + result: list[ParsedEntry] = [] + last_cmd_name: str | None = None + # Pending tool_use blocks keyed by id + _carry_over = pending_tools is not None + if pending_tools is None: + pending_tools = {} + else: + pending_tools = dict(pending_tools) # don't mutate caller's dict + + for data in entries: + msg_type = cls.get_message_type(data) + if msg_type not in ("user", "assistant"): + continue + + # Extract timestamp for this entry + entry_timestamp = cls.get_timestamp(data) + + message = data.get("message") + if not isinstance(message, dict): + continue + content = message.get("content", "") + if not isinstance(content, list): + content = [{"type": "text", "text": str(content)}] if content else [] + + parsed = cls.parse_message(data) + + # Handle local command messages first + if parsed: + if parsed.message_type == "local_command_invoke": + last_cmd_name = parsed.tool_name + continue + if parsed.message_type == "local_command": + cmd = parsed.tool_name or last_cmd_name or "" + text = parsed.text + if cmd: + if "\n" in text: + formatted = f"❯ `{cmd}`\n```\n{text}\n```" + else: + formatted = f"❯ `{cmd}`\n`{text}`" + else: + if "\n" in text: + formatted = f"```\n{text}\n```" + else: + formatted = f"`{text}`" + result.append( + ParsedEntry( + role="assistant", + text=formatted, + content_type="local_command", + timestamp=entry_timestamp, + ) + ) + last_cmd_name = None + continue + last_cmd_name = None + + if msg_type == "assistant": + # Pre-scan: check if this message contains an interactive + # tool_use (ExitPlanMode / AskUserQuestion). When present, + # suppress text entries from this same message — those text + # blocks are preamble that the terminal capture already + # includes. Emitting them as separate content messages + # causes a race: the content message clears the interactive + # UI state set by the status poller, leading to a duplicate + # interactive message being sent by the JSONL callable. + _INTERACTIVE_TOOLS = frozenset({"AskUserQuestion", "ExitPlanMode"}) + has_interactive_tool = any( + isinstance(b, dict) + and b.get("type") == "tool_use" + and b.get("name") in _INTERACTIVE_TOOLS + for b in content + ) + + # Process content blocks + has_text = False + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type", "") + + if btype == "text": + # Skip text blocks when an interactive tool_use is + # present in the same message to avoid clearing the + # interactive UI state prematurely. + if has_interactive_tool: + continue + t = block.get("text", "").strip() + if t and t != cls._NO_CONTENT_PLACEHOLDER: + result.append( + ParsedEntry( + role="assistant", + text=t, + content_type="text", + timestamp=entry_timestamp, + ) + ) + has_text = True + + elif btype == "tool_use": + tool_id = block.get("id", "") + name = block.get("name", "unknown") + inp = block.get("input", {}) + summary = cls.format_tool_use_summary(name, inp) + + # ExitPlanMode: emit plan content as text before tool_use entry + if name == "ExitPlanMode" and isinstance(inp, dict): + plan = inp.get("plan", "") + if plan: + result.append( + ParsedEntry( + role="assistant", + text=plan, + content_type="text", + timestamp=entry_timestamp, + ) + ) + if tool_id: + # Store tool info for later tool_result formatting + # Edit tool needs input_data to generate diff in tool_result stage + input_data = ( + inp if name in ("Edit", "NotebookEdit") else None + ) + pending_tools[tool_id] = PendingToolInfo( + summary=summary, + tool_name=name, + input_data=input_data, + ) + # Also emit tool_use entry with tool_name for immediate handling + result.append( + ParsedEntry( + role="assistant", + text=summary, + content_type="tool_use", + tool_use_id=tool_id, + timestamp=entry_timestamp, + tool_name=name, + ) + ) + else: + result.append( + ParsedEntry( + role="assistant", + text=summary, + content_type="tool_use", + tool_use_id=tool_id or None, + timestamp=entry_timestamp, + tool_name=name, + ) + ) + + elif btype == "thinking": + thinking_text = block.get("thinking", "") + if thinking_text: + quoted = cls._format_collapsible(thinking_text) + result.append( + ParsedEntry( + role="assistant", + text=quoted, + content_type="thinking", + timestamp=entry_timestamp, + ) + ) + elif not has_text: + result.append( + ParsedEntry( + role="assistant", + text="(thinking)", + content_type="thinking", + timestamp=entry_timestamp, + ) + ) + + elif msg_type == "user": + # Check for tool_result blocks and merge with pending tools + user_text_parts: list[str] = [] + + for block in content: + if not isinstance(block, dict): + if isinstance(block, str) and block.strip(): + user_text_parts.append(block.strip()) + continue + btype = block.get("type", "") + + if btype == "tool_result": + tool_use_id = block.get("tool_use_id", "") + result_content = block.get("content", "") + result_text = cls.extract_tool_result_text(result_content) + result_images = cls.extract_tool_result_images(result_content) + is_error = block.get("is_error", False) + is_interrupted = result_text == cls._INTERRUPTED_TEXT + tool_info = pending_tools.pop(tool_use_id, None) + _tuid = tool_use_id or None + + # Extract tool info from PendingToolInfo object + if tool_info is None: + tool_summary = None + tool_name = None + tool_input_data = None + else: + tool_summary = tool_info.summary + tool_name = tool_info.tool_name + tool_input_data = tool_info.input_data + + if is_interrupted: + # Show interruption inline with tool summary + entry_text = tool_summary or "" + if entry_text: + entry_text += "\n⏹ Interrupted" + else: + entry_text = "⏹ Interrupted" + result.append( + ParsedEntry( + role="assistant", + text=entry_text, + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + ) + ) + elif is_error: + # Show error in stats line + if tool_summary: + entry_text = tool_summary + else: + entry_text = "**Error**" + # Add error message in stats format + if result_text: + # Take first line of error as summary + error_summary = result_text.split("\n")[0] + if len(error_summary) > 100: + error_summary = error_summary[:100] + "…" + entry_text += f"\n ⎿ Error: {error_summary}" + # If multi-line error, add expandable quote + if "\n" in result_text: + entry_text += "\n" + cls._format_collapsible( + result_text + ) + else: + entry_text += "\n ⎿ Error" + result.append( + ParsedEntry( + role="assistant", + text=entry_text, + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + image_data=result_images, + ) + ) + elif tool_summary: + entry_text = tool_summary + # For Edit tool, generate diff stats and expandable quote + if tool_name == "Edit" and tool_input_data and result_text: + old_s = tool_input_data.get("old_string", "") + new_s = tool_input_data.get("new_string", "") + if old_s and new_s: + diff_text = cls._format_edit_diff(old_s, new_s) + if diff_text: + added = sum( + 1 + for line in diff_text.split("\n") + if line.startswith("+") + and not line.startswith("+++") + ) + removed = sum( + 1 + for line in diff_text.split("\n") + if line.startswith("-") + and not line.startswith("---") + ) + stats = f" ⎿ Added {added} lines, removed {removed} lines" + entry_text += ( + "\n" + + stats + + "\n" + + cls._format_collapsible(diff_text) + ) + # For other tools, append formatted result text + elif result_text: + entry_text += "\n" + cls._format_tool_result_text( + result_text, tool_name + ) + result.append( + ParsedEntry( + role="assistant", + text=entry_text, + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + image_data=result_images, + ) + ) + elif result_text or result_images: + result.append( + ParsedEntry( + role="assistant", + text=cls._format_tool_result_text( + result_text, tool_name + ) + if result_text + else (tool_summary or ""), + content_type="tool_result", + tool_use_id=_tuid, + timestamp=entry_timestamp, + image_data=result_images, + ) + ) + + elif btype == "text": + t = block.get("text", "").strip() + if t and not cls._RE_SYSTEM_TAGS.search(t): + user_text_parts.append(t) + + # Add user text if present (skip if message was only tool_results) + if user_text_parts: + combined = "\n".join(user_text_parts) + # Skip if it looks like local command XML + if not cls._RE_LOCAL_STDOUT.search( + combined + ) and not cls._RE_COMMAND_NAME.search(combined): + result.append( + ParsedEntry( + role="user", + text=combined, + content_type="text", + timestamp=entry_timestamp, + ) + ) + + # Flush remaining pending tools at end. + # In carry-over mode (monitor), keep them pending for the next call + # without emitting entries. In one-shot mode (history), emit them. + remaining_pending = dict(pending_tools) + if not _carry_over: + for tool_id, tool_info in pending_tools.items(): + result.append( + ParsedEntry( + role="assistant", + text=tool_info.summary, + content_type="tool_use", + tool_use_id=tool_id, + ) + ) + + # Strip whitespace + for entry in result: + entry.text = entry.text.strip() + + return result, remaining_pending diff --git a/ccweb/ccweb/backend/core/utils.py b/ccweb/ccweb/backend/core/utils.py new file mode 100644 index 00000000..13c94ec4 --- /dev/null +++ b/ccweb/ccweb/backend/core/utils.py @@ -0,0 +1,69 @@ +"""Shared utility functions used across CCWeb modules. + +Provides: + - ccweb_dir(): resolve config directory from CCWEB_DIR env var. + - atomic_write_json(): crash-safe JSON file writes via temp+rename. + - read_cwd_from_jsonl(): extract the cwd field from the first JSONL entry. +""" + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +CCWEB_DIR_ENV = "CCWEB_DIR" + + +def ccweb_dir() -> Path: + """Resolve config directory from CCWEB_DIR env var or default ~/.ccweb.""" + raw = os.environ.get(CCWEB_DIR_ENV, "") + return Path(raw) if raw else Path.home() / ".ccweb" + + +def atomic_write_json(path: Path, data: Any, indent: int = 2) -> None: + """Write JSON data to a file atomically. + + Writes to a temporary file in the same directory, then renames it + to the target path. This prevents data corruption if the process + is interrupted mid-write. + """ + path.parent.mkdir(parents=True, exist_ok=True) + content = json.dumps(data, indent=indent) + + # Write to temp file in same directory (same filesystem for atomic rename) + fd, tmp_path = tempfile.mkstemp( + dir=str(path.parent), suffix=".tmp", prefix=f".{path.name}." + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(path)) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def read_cwd_from_jsonl(file_path: str | Path) -> str: + """Read the cwd field from the first JSONL entry that has one.""" + try: + with open(file_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + cwd = data.get("cwd") + if cwd: + return cwd + except json.JSONDecodeError: + continue + except OSError: + pass + return "" diff --git a/ccweb/ccweb/backend/main.py b/ccweb/ccweb/backend/main.py new file mode 100644 index 00000000..bb1cae48 --- /dev/null +++ b/ccweb/ccweb/backend/main.py @@ -0,0 +1,197 @@ +"""CLI entry point for CCWeb. + +Provides three subcommands: + - ccweb: Start the web server (default) + - ccweb install: Install SessionStart hook + global Claude commands + - ccweb hook: Handle SessionStart hook (called by Claude Code) + +Key function: main(). +""" + +from __future__ import annotations + +import json +import logging +import sys +from pathlib import Path + +LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + + +def _setup_logging() -> None: + """Configure logging for the application.""" + logging.basicConfig( + level=logging.INFO, + format=LOG_FORMAT, + ) + + +def _cmd_serve() -> None: + """Start the CCWeb server.""" + import uvicorn + + from .config import config + from .server import create_app + + app = create_app() + uvicorn.run( + app, + host=config.web_host, + port=config.web_port, + log_level="info", + ) + + +def _cmd_install() -> None: + """Install SessionStart hook and global Claude Code commands.""" + # Install hook + settings_path = Path.home() / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + settings: dict = {} + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text()) + except json.JSONDecodeError: + pass + + hooks = settings.setdefault("hooks", {}) + session_start = hooks.setdefault("SessionStart", []) + + # Check if ccweb hook already exists + already_installed = False + for entry in session_start: + for hook in entry.get("hooks", []): + if "ccweb hook" in hook.get("command", ""): + already_installed = True + break + + if not already_installed: + session_start.append( + {"hooks": [{"type": "command", "command": "ccweb hook", "timeout": 5}]} + ) + settings_path.write_text(json.dumps(settings, indent=2)) + print(f"Installed SessionStart hook in {settings_path}") + else: + print("SessionStart hook already installed.") + + # Install global commands + commands_dir = Path.home() / ".claude" / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + + commands = { + "option-grid.md": _OPTION_GRID_COMMAND, + "checklist.md": _CHECKLIST_COMMAND, + "status-report.md": _STATUS_REPORT_COMMAND, + "confirm.md": _CONFIRM_COMMAND, + } + + for filename, content in commands.items(): + path = commands_dir / filename + path.write_text(content) + print(f"Installed command: {path}") + + print("\nCCWeb installation complete!") + + +def _cmd_hook() -> None: + """Handle SessionStart hook — write session_map.json.""" + from .core.hook import hook_main + + hook_main() + + +# ── Global command templates ───────────────────────────────────────────── + +_OPTION_GRID_COMMAND = """\ +# Option Grid + +When called, research the topics provided and output an option grid for the user. + +You MUST do these two steps in sequence: + +Step 1: Write the grid as a JSON file using your Write tool. Create the directory +first if needed, then write to: {cwd}/.ccweb/pending/grid-{timestamp}.json + +Use this schema: +{"id": "unique-id", "type": "ccweb:grid", "title": "...", "items": [ + {"topic": "...", "description": "...", "allow_custom": true, + "options": [{"label": "...", "recommended": true}, ...]} +]} + +Each item must have: topic, description, options (array with recommended flag), +allow_custom: true. Always include 2-4 options per topic with one marked as +recommended. + +Step 2: IMMEDIATELY after writing the file, use AskUserQuestion to ask: +"I've prepared an option grid. Please review it in your CCWeb interface and +submit your selections. Your choices will appear here automatically." + +This is critical — AskUserQuestion blocks you until the user responds via CCWeb. +Do NOT proceed without waiting. The user's selections will be sent back as text. +""" + +_CHECKLIST_COMMAND = """\ +# Checklist + +When called, output an interactive checklist for the user. + +Write the checklist as a JSON file: + {cwd}/.ccweb/pending/checklist-{timestamp}.json + +Schema: {"type": "ccweb:checklist", "title": "...", "items": [ + {"label": "...", "checked": false}, ... +]} + +Then IMMEDIATELY use AskUserQuestion to wait for the user's response. +""" + +_STATUS_REPORT_COMMAND = """\ +# Status Report + +When called, output a status dashboard. This is read-only (no user response needed). + +Write the report as a JSON file: + {cwd}/.ccweb/pending/status-{timestamp}.json + +Schema: {"type": "ccweb:status", "title": "...", "items": [ + {"label": "...", "status": "pass|fail|warn", "detail": "..."}, ... +]} +""" + +_CONFIRM_COMMAND = """\ +# Confirm + +When called, present a confirmation dialog for a critical action. + +Write the dialog as a JSON file: + {cwd}/.ccweb/pending/confirm-{timestamp}.json + +Schema: {"type": "ccweb:confirm", "title": "...", + "description": "...", "severity": "high|medium|low", + "actions": [{"label": "...", "value": "..."}, ...]} + +Then IMMEDIATELY use AskUserQuestion to wait for the user's response. +""" + + +def main() -> None: + """CLI entry point.""" + _setup_logging() + + args = sys.argv[1:] + + if not args: + _cmd_serve() + elif args[0] == "install": + _cmd_install() + elif args[0] == "hook": + _cmd_hook() + else: + print(f"Unknown command: {args[0]}") + print("Usage: ccweb [install | hook]") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ccweb/ccweb/backend/server.py b/ccweb/ccweb/backend/server.py new file mode 100644 index 00000000..d8e4373b --- /dev/null +++ b/ccweb/ccweb/backend/server.py @@ -0,0 +1,993 @@ +"""FastAPI server — the web layer of CCWeb. + +Serves the React frontend (static files in production), provides REST +endpoints for session management, and a WebSocket endpoint for real-time +bidirectional communication with the browser. + +On startup: + 1. Run health checks (tmux running, hook installed, state dir exists) + 2. Initialize TmuxManager, SessionManager, SessionMonitor + 3. Start SessionMonitor polling loop + 4. Start status polling loop (1s interval) + 5. Set message callback to broadcast to connected WebSocket clients + +Key functions: create_app(), ws_endpoint(), handle_new_message(). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import uuid +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncGenerator + +from fastapi import FastAPI, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.responses import Response +from fastapi.staticfiles import StaticFiles + +from .config import config +from .core.session_monitor import NewMessage, SessionMonitor +from .core.terminal_parser import ( + extract_interactive_content, + is_interactive_ui, + parse_status_line, +) +from .core.tmux_manager import tmux_manager +from .ui_parser import parse_interactive_ui +from .ws_protocol import ( + CLIENT_CREATE_SESSION, + CLIENT_GET_HISTORY, + CLIENT_KILL_SESSION, + CLIENT_PING, + CLIENT_SEND_KEY, + CLIENT_SEND_TEXT, + CLIENT_SUBMIT_DECISIONS, + CLIENT_SWITCH_SESSION, + WsDecisionGrid, + WsError, + WsHealth, + WsHistory, + WsInteractiveUI, + WsMessage, + WsPong, + WsSendAck, + WsSessions, + WsStatus, +) + +logger = logging.getLogger(__name__) + +_RE_CONTEXT_PCT = re.compile(r"Context:\s*(\d+)%") + + +def _extract_context_pct(pane_text: str) -> int | None: + """Extract context usage percentage from Claude Code's status bar chrome.""" + # The bottom of the pane shows something like: [Opus 4.6] Context: 34% + for line in reversed(pane_text.splitlines()): + m = _RE_CONTEXT_PCT.search(line) + if m: + return int(m.group(1)) + return None + + +def _parse_frontmatter(path: Path) -> dict[str, Any]: + """Parse YAML frontmatter from a markdown file (simple key: value parser).""" + try: + text = path.read_text(encoding="utf-8") + except OSError: + return {} + if not text.startswith("---"): + return {} + end = text.find("---", 3) + if end == -1: + return {} + fm: dict[str, Any] = {} + for line in text[3:end].strip().splitlines(): + if ":" in line: + key, _, val = line.partition(":") + val = val.strip().strip('"').strip("'") + if val.isdigit(): + fm[key.strip()] = int(val) + else: + fm[key.strip()] = val + return fm + + +# ── Connected clients ──────────────────────────────────────────────────── + +# client_id → WebSocket +_clients: dict[str, WebSocket] = {} +# client_id → window_id (which session the client is viewing) +_client_bindings: dict[str, str] = {} + +# Session monitor instance +_monitor: SessionMonitor | None = None +# Status polling task +_status_task: asyncio.Task[None] | None = None + + +# ── Health checks ──────────────────────────────────────────────────────── + + +async def _check_tmux_running() -> bool: + """Check if tmux server is running.""" + proc = await asyncio.create_subprocess_exec( + "tmux", + "list-sessions", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.communicate() + return proc.returncode == 0 + + +def _check_hook_installed() -> bool: + """Check if the SessionStart hook is installed for ccweb.""" + settings_path = Path.home() / ".claude" / "settings.json" + if not settings_path.exists(): + return False + try: + data = json.loads(settings_path.read_text()) + hooks = data.get("hooks", {}).get("SessionStart", []) + for entry in hooks: + for hook in entry.get("hooks", []): + cmd = hook.get("command", "") + if "ccweb hook" in cmd: + return True + except (json.JSONDecodeError, OSError): + pass + return False + + +async def _build_health() -> WsHealth: + """Build health check status.""" + tmux_running = await _check_tmux_running() + hook_installed = _check_hook_installed() + warnings: list[str] = [] + + if not tmux_running: + warnings.append( + "tmux is not running. Start tmux first: tmux new -s " + f"{config.tmux_session_name}" + ) + if not hook_installed: + warnings.append("SessionStart hook not installed. Run: ccweb install") + + windows = await tmux_manager.list_windows() if tmux_running else [] + + return WsHealth( + tmux_running=tmux_running, + hook_installed=hook_installed, + sessions_found=len(windows), + warnings=warnings, + ) + + +# ── Session list ───────────────────────────────────────────────────────── + + +async def _build_session_list() -> list[dict[str, Any]]: + """Build session list for the frontend.""" + windows = await tmux_manager.list_windows() + return [ + { + "window_id": w.window_id, + "name": w.window_name, + "cwd": w.cwd, + "command": w.pane_current_command, + } + for w in windows + ] + + +async def _broadcast_sessions() -> None: + """Send updated session list to all connected clients.""" + sessions = await _build_session_list() + msg = WsSessions(sessions=sessions).to_dict() + for ws in list(_clients.values()): + try: + await ws.send_json(msg) + except Exception as e: + logger.debug("WebSocket send failed: %s", e) + + +# ── Message callback (from SessionMonitor) ─────────────────────────────── + + +async def _handle_new_message(msg: NewMessage) -> None: + """Route a new message from SessionMonitor to bound WebSocket clients.""" + # Find which clients are bound to a window matching this session + from .session import session_manager + + for client_id, window_id in list(_client_bindings.items()): + ws = _clients.get(client_id) + if not ws: + continue + + # Check if this window's session matches the message's session + # Use lookup (no auto-create) to avoid phantom state entries + state = session_manager.lookup_window_state(window_id) + if not state: + logger.debug( + "No window_state for %s (client=%s), skipping message", + window_id, + client_id[:8], + ) + continue + if state.session_id != msg.session_id: + continue + + # Convert image_data to base64 dicts for JSON serialization + import base64 + + images: list[dict[str, str]] = [] + if msg.image_data: + for media_type, raw_bytes in msg.image_data: + images.append( + { + "media_type": media_type, + "data": base64.b64encode(raw_bytes).decode("ascii"), + } + ) + + ws_msg = WsMessage( + window_id=window_id, + role=msg.role, + content_type=msg.content_type, + text=msg.text, + tool_use_id=msg.tool_use_id, + tool_name=msg.tool_name, + images=images, + ) + try: + await ws.send_json(ws_msg.to_dict()) + except Exception as e: + logger.debug("WebSocket send failed: %s", e) + + +# ── Status polling ─────────────────────────────────────────────────────── + +# Per-client tracking of sent grid files (client_id → set of file paths) +_client_sent_grids: dict[str, set[str]] = {} + +# Dedup: last interactive UI content sent per (client_id, window_id) +_last_interactive_content: dict[tuple[str, str], str] = {} + + +async def _check_decision_grids(client_id: str, window_id: str, ws: WebSocket) -> None: + """Check for new decision grid files in .ccweb/pending/ for a window.""" + from .session import session_manager + + state = session_manager.lookup_window_state(window_id) + if not state or not state.cwd: + return + + pending_dir = Path(state.cwd) / ".ccweb" / "pending" + if not pending_dir.exists(): + return + + # Stale file cleanup: move files older than 1 hour to failed/ + import time as _time + + stale_cutoff = _time.time() - 3600 + for stale_file in pending_dir.glob("*.json"): + try: + if stale_file.stat().st_mtime < stale_cutoff: + failed_dir = pending_dir.parent / "failed" + failed_dir.mkdir(exist_ok=True) + stale_file.rename(failed_dir / stale_file.name) + logger.info("Moved stale grid file to failed/: %s", stale_file.name) + except (FileNotFoundError, OSError): + pass + + sent = _client_sent_grids.setdefault(client_id, set()) + + for grid_file in pending_dir.glob("*.json"): + file_key = str(grid_file) + if file_key in sent: + continue + + try: + grid_data = json.loads(grid_file.read_text()) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Invalid grid file %s: %s", grid_file, e) + try: + failed_dir = pending_dir.parent / "failed" + failed_dir.mkdir(exist_ok=True) + grid_file.rename(failed_dir / grid_file.name) + except FileNotFoundError: + pass # File already moved by another handler + continue + + sent.add(file_key) + grid_msg = WsDecisionGrid( + window_id=window_id, + grid=grid_data, + ) + try: + await ws.send_json(grid_msg.to_dict()) + logger.info( + "Sent decision grid to client %s: %s", client_id[:8], grid_file.name + ) + except Exception as e: + logger.debug("WebSocket send failed: %s", e) + sent.discard(file_key) + + +async def _status_poll_loop() -> None: + """Poll terminal status and decision grids at 1-second intervals.""" + while True: + try: + for client_id, window_id in list(_client_bindings.items()): + ws = _clients.get(client_id) + if not ws: + continue + + w = await tmux_manager.find_window_by_id(window_id) + if not w: + continue + + # Check for decision grid files + await _check_decision_grids(client_id, window_id, ws) + + pane_text = await tmux_manager.capture_pane(w.window_id) + if not pane_text: + continue + + # Check for interactive UI (with dedup) + # Single call — is_interactive_ui internally calls extract too + content = extract_interactive_content(pane_text) + if content: + dedup_key = (client_id, window_id) + if _last_interactive_content.get(dedup_key) == content.content: + continue # Same UI, don't re-send + _last_interactive_content[dedup_key] = content.content + + parsed = parse_interactive_ui(content.content, content.name) + ui_msg = WsInteractiveUI( + window_id=window_id, + ui_name=content.name, + raw_content=content.content, + structured=parsed.to_dict() if parsed else None, + ) + try: + await ws.send_json(ui_msg.to_dict()) + except Exception as e: + logger.debug("WebSocket send failed: %s", e) + continue + else: + # No interactive UI — clear dedup cache + _last_interactive_content.pop((client_id, window_id), None) + + # Check for status line + context % + status_text = parse_status_line(pane_text) + context_pct = _extract_context_pct(pane_text) + if status_text or context_pct is not None: + status_msg = WsStatus( + window_id=window_id, + text=status_text or "", + context_pct=context_pct, + ) + try: + await ws.send_json(status_msg.to_dict()) + except Exception as e: + logger.debug("WebSocket send failed: %s", e) + + except Exception as e: + logger.error("Status poll error: %s", e) + + await asyncio.sleep(1.0) + + +# ── WebSocket handler ──────────────────────────────────────────────────── + + +async def _handle_ws_message( + client_id: str, + ws: WebSocket, + data: dict[str, Any], +) -> None: + """Dispatch a single incoming WebSocket message.""" + msg_type = data.get("type", "") + window_id = data.get("window_id", "") + + if msg_type == CLIENT_PING: + await ws.send_json(WsPong().to_dict()) + return + + if msg_type == CLIENT_SWITCH_SESSION: + if not window_id: + return + _client_bindings[client_id] = window_id + # Send history for the new session + from .session import session_manager + + messages, total = await session_manager.get_recent_messages(window_id) + await ws.send_json( + WsHistory(window_id=window_id, messages=messages, total=total).to_dict() + ) + return + + if msg_type == CLIENT_SEND_TEXT: + text = data.get("text", "") + if text and window_id: + from .session import session_manager + + success, message = await session_manager.send_to_window(window_id, text) + if success: + await ws.send_json(WsSendAck(window_id=window_id).to_dict()) + else: + await ws.send_json( + WsError(code="send_failed", message=message).to_dict() + ) + return + + if msg_type == CLIENT_SEND_KEY: + key = data.get("key", "") + if key and window_id: + # Escape is always allowed — it's the interrupt key + # Other keys get a stale UI guard to prevent blind key injection + if key != "Escape": + w = await tmux_manager.find_window_by_id(window_id) + if w: + pane_text = await tmux_manager.capture_pane(w.window_id) + if pane_text and not is_interactive_ui(pane_text): + await ws.send_json( + WsError( + code="stale_ui", + message="This prompt has expired.", + ).to_dict() + ) + return + + # Map key names to tmux send_keys parameters + key_map: dict[str, tuple[str, bool, bool]] = { + "Enter": ("Enter", False, False), + "Escape": ("Escape", False, False), + "Space": ("Space", False, False), + "Tab": ("Tab", False, False), + "Up": ("Up", False, False), + "Down": ("Down", False, False), + "Left": ("Left", False, False), + "Right": ("Right", False, False), + } + key_info = key_map.get(key) + if key_info: + text_val, enter, literal = key_info + await tmux_manager.send_keys( + window_id, text_val, enter=enter, literal=literal + ) + return + + if msg_type == CLIENT_CREATE_SESSION: + work_dir = data.get("work_dir", "") + name = data.get("name") + if work_dir: + success, message, wname, wid = await tmux_manager.create_window( + work_dir, window_name=name + ) + if success: + _client_bindings[client_id] = wid + # Auto-create .ccweb/pending/ for decision grid files + pending = Path(work_dir).expanduser().resolve() / ".ccweb" / "pending" + pending.mkdir(parents=True, exist_ok=True) + await _broadcast_sessions() + else: + await ws.send_json( + WsError(code="create_failed", message=message).to_dict() + ) + return + + if msg_type == CLIENT_KILL_SESSION: + if window_id: + await tmux_manager.kill_window(window_id) + # Unbind any clients from this window + for cid, wid in list(_client_bindings.items()): + if wid == window_id: + del _client_bindings[cid] + await _broadcast_sessions() + return + + if msg_type == CLIENT_GET_HISTORY: + if window_id: + from .session import session_manager + + messages, total = await session_manager.get_recent_messages(window_id) + await ws.send_json( + { + "type": "history", + "window_id": window_id, + "messages": messages, + "total": total, + } + ) + return + + if msg_type == CLIENT_SUBMIT_DECISIONS: + selections = data.get("selections", []) + grid_title = data.get("title", "Decisions") + if selections and window_id: + # Format selections as text for Claude + lines = [f'Decisions for "{grid_title}":'] + for sel in selections: + topic = sel.get("topic", "") + choice = sel.get("choice") + notes = sel.get("notes", "") + if choice: + lines.append(f"- {topic}: {choice}") + if notes: + lines.append(f' Note: "{notes}"') + elif notes: + lines.append(f'- {topic}: [Custom] "{notes}"') + else: + lines.append(f"- {topic}: (no selection)") + + formatted = "\n".join(lines) + + from .session import session_manager + + success, message = await session_manager.send_to_window( + window_id, formatted + ) + if not success: + await ws.send_json( + WsError(code="send_failed", message=message).to_dict() + ) + else: + # Move grid file to completed/ + state = session_manager.lookup_window_state(window_id) + if state and state.cwd: + pending_dir = Path(state.cwd) / ".ccweb" / "pending" + completed_dir = Path(state.cwd) / ".ccweb" / "completed" + completed_dir.mkdir(parents=True, exist_ok=True) + for f in pending_dir.glob("*.json"): + file_key = str(f) + try: + f.rename(completed_dir / f.name) + except FileNotFoundError: + pass # Already moved by poll loop or another client + # Clear from all clients' sent tracking + for sent_set in _client_sent_grids.values(): + sent_set.discard(file_key) + return + + logger.warning("Unknown WebSocket message type: %s", msg_type) + + +async def ws_endpoint(websocket: WebSocket) -> None: + """WebSocket connection handler.""" + await websocket.accept() + client_id = str(uuid.uuid4()) + _clients[client_id] = websocket + logger.info("WebSocket connected: %s", client_id) + + # New client gets fresh grid tracking (will receive any pending grids) + _client_sent_grids[client_id] = set() + + try: + # Send health check + health = await _build_health() + await websocket.send_json(health.to_dict()) + + # Send session list + sessions = await _build_session_list() + await websocket.send_json(WsSessions(sessions=sessions).to_dict()) + + # Message loop + while True: + raw = await websocket.receive_text() + try: + data = json.loads(raw) + except json.JSONDecodeError: + await websocket.send_json( + WsError(code="invalid_json", message="Invalid JSON").to_dict() + ) + continue + + await _handle_ws_message(client_id, websocket, data) + + except WebSocketDisconnect: + logger.info("WebSocket disconnected: %s", client_id) + except Exception as e: + logger.error("WebSocket error for %s: %s", client_id, e) + finally: + _clients.pop(client_id, None) + _client_bindings.pop(client_id, None) + _client_sent_grids.pop(client_id, None) + # Clear interactive UI dedup for this client + for key in list(_last_interactive_content): + if key[0] == client_id: + del _last_interactive_content[key] + + +# ── App lifecycle ──────────────────────────────────────────────────────── + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """FastAPI lifespan: start monitor + status polling on startup.""" + global _monitor, _status_task + + # Startup health checks (log warnings, don't crash) + tmux_ok = await _check_tmux_running() + if not tmux_ok: + logger.warning( + "tmux is not running! Start tmux first: tmux new -s %s", + config.tmux_session_name, + ) + + hook_ok = _check_hook_installed() + if not hook_ok: + logger.warning("SessionStart hook not installed. Run: ccweb install") + + # Initialize session manager (loads state) + from .session import session_manager + + await session_manager.resolve_stale_ids() + await session_manager.load_session_map() + + # Start session monitor + _monitor = SessionMonitor() + _monitor.set_message_callback(_handle_new_message) + _monitor.start() + + # Start status polling + _status_task = asyncio.create_task(_status_poll_loop()) + + logger.info("CCWeb started on %s:%d", config.web_host, config.web_port) + + yield + + # Shutdown + if _status_task: + _status_task.cancel() + try: + await _status_task + except asyncio.CancelledError: + pass + if _monitor: + _monitor.stop() + # Clear module-level state + _clients.clear() + _client_bindings.clear() + _client_sent_grids.clear() + _last_interactive_content.clear() + logger.info("CCWeb stopped") + + +def create_app() -> FastAPI: + """Create and configure the FastAPI application.""" + app = FastAPI(title="CCWeb", lifespan=lifespan) + + # WebSocket endpoint + app.add_api_websocket_route("/ws", ws_endpoint) + + # REST endpoints + @app.get("/api/sessions") + async def list_sessions() -> list[dict[str, Any]]: + return await _build_session_list() + + @app.post("/api/sessions") + async def create_session_rest(body: dict[str, Any]) -> dict[str, Any]: + """Create a new session (REST alternative to WebSocket create_session).""" + work_dir = body.get("work_dir", "") + name = body.get("name") + if not work_dir: + return {"error": "work_dir is required"} + success, message, wname, wid = await tmux_manager.create_window( + work_dir, window_name=name + ) + if success: + # Auto-create .ccweb/pending/ in the session's cwd + pending_dir = Path(work_dir).expanduser().resolve() / ".ccweb" / "pending" + pending_dir.mkdir(parents=True, exist_ok=True) + await _broadcast_sessions() + return {"window_id": wid, "name": wname, "message": message} + return {"error": message} + + @app.delete("/api/sessions/{window_id}") + async def delete_session_rest(window_id: str) -> dict[str, str]: + """Kill a session (REST alternative to WebSocket kill_session).""" + await tmux_manager.kill_window(window_id) + for cid, wid in list(_client_bindings.items()): + if wid == window_id: + del _client_bindings[cid] + await _broadcast_sessions() + return {"status": "ok"} + + @app.post("/api/sessions/{window_id}/setup-ccweb") + async def setup_ccweb(window_id: str) -> dict[str, str]: + """Create .ccweb/instructions.md in the session's project.""" + from .session import session_manager + + state = session_manager.lookup_window_state(window_id) + if not state or not state.cwd: + return {"error": "Session not found or cwd unknown"} + + ccweb_dir = Path(state.cwd) / ".ccweb" + ccweb_dir.mkdir(parents=True, exist_ok=True) + instructions_path = ccweb_dir / "instructions.md" + instructions_path.write_text( + "## CCWeb Integration\n" + "- When presenting multiple options/decisions to the user, " + "use /option-grid\n" + "- When reporting build/test/deploy status, use /status-report\n" + "- For interactive checklists, use /checklist\n" + "- For critical destructive actions, use /confirm before proceeding\n", + encoding="utf-8", + ) + return {"path": str(instructions_path)} + + @app.put("/api/sessions/{window_id}/rename") + async def rename_session(window_id: str, body: dict[str, Any]) -> dict[str, str]: + """Rename a tmux window (display name).""" + from .session import session_manager + + new_name = body.get("name", "").strip() + if not new_name: + return {"error": "name is required"} + success = await tmux_manager.rename_window(window_id, new_name) + if success: + session_manager.window_display_names[window_id] = new_name + ws = session_manager.lookup_window_state(window_id) + if ws: + ws.window_name = new_name + session_manager._save_state() + await _broadcast_sessions() + return {"status": "ok", "name": new_name} + return {"error": "Failed to rename window"} + + @app.get("/api/sessions/{window_id}/export") + async def export_session(window_id: str, fmt: str = "markdown") -> Response: + """Export conversation as Markdown, JSON, or plain text.""" + from .session import session_manager + + messages, total = await session_manager.get_recent_messages(window_id) + if not messages: + return Response(content="No messages", status_code=404) + + display = session_manager.get_display_name(window_id) + + if fmt == "json": + import json as _json + + content = _json.dumps(messages, indent=2) + return Response( + content=content, + media_type="application/json", + headers={ + "Content-Disposition": f'attachment; filename="{display}.json"' + }, + ) + + # Markdown or plain text + lines: list[str] = [f"# {display}\n"] + for msg in messages: + role = msg.get("role", "assistant") + text = msg.get("text", "") + ct = msg.get("content_type", "text") + if role == "user": + lines.append(f"## You\n\n{text}\n") + elif ct == "thinking": + lines.append( + f"
Thinking\n\n{text}\n\n
\n" + ) + elif ct in ("tool_use", "tool_result"): + lines.append(f"```\n{text}\n```\n") + else: + lines.append(f"{text}\n") + + content = "\n".join(lines) + if fmt == "plain": + return Response( + content=content, + media_type="text/plain", + headers={ + "Content-Disposition": f'attachment; filename="{display}.txt"' + }, + ) + return Response( + content=content, + media_type="text/markdown", + headers={"Content-Disposition": f'attachment; filename="{display}.md"'}, + ) + + @app.get("/api/health") + async def health() -> dict[str, Any]: + h = await _build_health() + return h.to_dict() + + @app.get("/api/sessions/{window_id}/skills") + async def get_skills(window_id: str) -> list[dict[str, str]]: + """Discover slash commands available in the session's project.""" + from .session import session_manager + + state = session_manager.lookup_window_state(window_id) + if not state or not state.cwd: + return [] + + skills: list[dict[str, str]] = [] + commands_dir = Path(state.cwd) / ".claude" / "commands" + if commands_dir.exists(): + for f in sorted(commands_dir.iterdir()): + if f.suffix == ".md" and f.is_file(): + name = f.stem + # First non-empty line of the file is the description + desc = "" + try: + for line in f.read_text(encoding="utf-8").splitlines(): + stripped = line.strip().lstrip("# ").strip() + if stripped: + desc = stripped + break + except OSError: + pass + skills.append({"name": name, "description": desc}) + return skills + + @app.get("/api/browse") + async def browse_directory(path: str = "") -> dict[str, Any]: + """Browse directories for session creation.""" + root = Path(config.browse_root or str(Path.home())).resolve() + browse_path = path or str(root) + target = Path(browse_path).expanduser().resolve() + + # Containment: prevent browsing outside the root + if not target.is_relative_to(root): + return {"path": str(root), "dirs": [], "error": "Outside browse root"} + if not target.is_dir(): + return {"path": str(target), "dirs": [], "error": "Not a directory"} + + dirs: list[dict[str, str]] = [] + try: + for entry in sorted(target.iterdir()): + if not entry.is_dir(): + continue + if entry.name.startswith(".") and not config.show_hidden_dirs: + continue + dirs.append({"name": entry.name, "path": str(entry)}) + except PermissionError: + return {"path": str(target), "dirs": [], "error": "Permission denied"} + + # Only show parent if it's still within the browse root + parent = str(target.parent) if target != root else None + return {"path": str(target), "parent": parent, "dirs": dirs} + + @app.post("/api/sessions/{window_id}/upload") + async def upload_file(window_id: str, file: UploadFile) -> dict[str, str]: + """Upload a file to the session's docs/inbox/ directory.""" + from .session import session_manager + + state = session_manager.lookup_window_state(window_id) + if not state or not state.cwd: + return {"error": "Session not found or cwd unknown"} + + inbox = Path(state.cwd) / "docs" / "inbox" + inbox.mkdir(parents=True, exist_ok=True) + + # Strip directory components to prevent path traversal (e.g., ../../) + filename = Path(file.filename or "unnamed").name + dest = inbox / filename + if dest.exists(): + stem = Path(filename).stem + ext = Path(filename).suffix + import time as _time + + dest = inbox / f"{stem}_{int(_time.time())}{ext}" + + content = await file.read() + dest.write_bytes(content) + + rel_path = f"docs/inbox/{dest.name}" + notice = ( + f"A file has been saved to {rel_path} " + f"(absolute path: {dest}). Read it with your Read tool." + ) + success, msg = await session_manager.send_to_window(window_id, notice) + if not success: + return {"error": msg, "path": str(dest)} + return {"path": str(dest), "relative": rel_path} + + @app.get("/api/sessions/{window_id}/screenshot") + async def screenshot(window_id: str) -> Response: + """Capture the tmux pane as text.""" + w = await tmux_manager.find_window_by_id(window_id) + if not w: + return Response(content="Window not found", status_code=404) + pane_text = await tmux_manager.capture_pane(w.window_id, with_ansi=False) + if not pane_text: + return Response(content="Failed to capture pane", status_code=500) + return Response(content=pane_text, media_type="text/plain") + + # ── Documentation wiki endpoints ───────────────────────────────────── + + docs_dir = Path(__file__).parent.parent.parent / "docs" + + @app.get("/api/docs") + async def get_doc_tree() -> list[dict[str, Any]]: + """Return the documentation tree structure with frontmatter.""" + if not docs_dir.exists(): + return [] + + tree: list[dict[str, Any]] = [] + for md_file in sorted(docs_dir.rglob("*.md")): + rel = md_file.relative_to(docs_dir) + frontmatter = _parse_frontmatter(md_file) + tree.append( + { + "path": str(rel), + "title": frontmatter.get( + "title", rel.stem.replace("-", " ").title() + ), + "description": frontmatter.get("description", ""), + "order": frontmatter.get("order", 99), + "section": str(rel.parent) if str(rel.parent) != "." else "", + } + ) + # Sort by section then order + tree.sort(key=lambda x: (x["section"], x["order"], x["title"])) + return tree + + @app.get("/api/docs/{path:path}") + async def get_doc_content(path: str) -> Response: + """Return raw markdown content for a doc file.""" + target = (docs_dir / path).resolve() + # Prevent path traversal (is_relative_to is safe against prefix attacks) + if not target.is_relative_to(docs_dir.resolve()): + return Response(content="Forbidden", status_code=403) + try: + content = target.read_text(encoding="utf-8") + except (FileNotFoundError, IsADirectoryError): + return Response(content="Not found", status_code=404) + except OSError: + return Response(content="Read error", status_code=500) + # Strip frontmatter before returning + if content.startswith("---"): + end = content.find("---", 3) + if end != -1: + content = content[end + 3 :].lstrip("\n") + return Response(content=content, media_type="text/markdown; charset=utf-8") + + @app.get("/api/docs-search") + async def search_docs(q: str = "") -> list[dict[str, Any]]: + """Full-text search across all doc files.""" + if not q or not docs_dir.exists(): + return [] + query = q.lower() + results: list[dict[str, Any]] = [] + for md_file in docs_dir.rglob("*.md"): + try: + text = md_file.read_text(encoding="utf-8") + except OSError: + continue + if query in text.lower(): + rel = str(md_file.relative_to(docs_dir)) + frontmatter = _parse_frontmatter(md_file) + # Find a snippet around the match + idx = text.lower().find(query) + start = max(0, idx - 60) + end = min(len(text), idx + len(query) + 60) + snippet = text[start:end].replace("\n", " ").strip() + results.append( + { + "path": rel, + "title": frontmatter.get( + "title", + md_file.stem.replace("-", " ").title(), + ), + "snippet": f"...{snippet}...", + } + ) + return results + + # Serve static frontend files (production) + # __file__ is ccweb/ccweb/backend/server.py → .parent.parent.parent = ccweb/ + static_dir = Path(__file__).parent.parent.parent / "frontend" / "dist" + if static_dir.exists(): + app.mount("/", StaticFiles(directory=str(static_dir), html=True)) + + return app diff --git a/ccweb/ccweb/backend/session.py b/ccweb/ccweb/backend/session.py new file mode 100644 index 00000000..2466babb --- /dev/null +++ b/ccweb/ccweb/backend/session.py @@ -0,0 +1,382 @@ +"""Claude Code session management — the core state hub for CCWeb. + +Simplified from ccbot's session.py: replaces Telegram thread_bindings +with a flat client_bindings model (client_id → window_id). Removes +group_chat_ids and all Telegram-specific routing. + +Manages the key mappings: + Window→Session (window_states): which Claude session_id a window holds. + Client→Window (client_bindings): which window a WebSocket client is viewing. + +Key class: SessionManager (singleton instantiated as `session_manager`). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import aiofiles + +from .config import config +from .core.tmux_manager import SHELL_COMMANDS, tmux_manager +from .core.transcript_parser import TranscriptParser +from .core.utils import atomic_write_json + +logger = logging.getLogger(__name__) + +# Patterns for detecting Claude Code resume commands in pane output +_RESUME_CMD_RE = re.compile(r"(claude\s+(?:--resume|-r)\s+\S+)") +_STOPPED_RE = re.compile(r"Stopped\s+.*claude", re.IGNORECASE) + + +def _extract_resume_command(pane_text: str) -> str | None: + """Extract a resume command from pane content after Claude Code exit.""" + if _STOPPED_RE.search(pane_text): + return "fg" + match = _RESUME_CMD_RE.search(pane_text) + if match: + return match.group(1) + return None + + +@dataclass +class WindowState: + """Persistent state for a tmux window.""" + + session_id: str = "" + cwd: str = "" + window_name: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"session_id": self.session_id, "cwd": self.cwd} + if self.window_name: + d["window_name"] = self.window_name + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> WindowState: + return cls( + session_id=data.get("session_id", ""), + cwd=data.get("cwd", ""), + window_name=data.get("window_name", ""), + ) + + +@dataclass +class ClaudeSession: + """Information about a Claude Code session.""" + + session_id: str + summary: str + message_count: int + file_path: str + + +@dataclass +class SessionManager: + """Manages session state for CCWeb. + + Simplified from ccbot: no thread_bindings, no group_chat_ids. + Client bindings are ephemeral (WebSocket connections) and not persisted. + """ + + window_states: dict[str, WindowState] = field(default_factory=dict) + window_display_names: dict[str, str] = field(default_factory=dict) + # Per-client read offsets (client_id → {window_id → byte_offset}) + # Not persisted — clients are ephemeral + user_window_offsets: dict[int, dict[str, int]] = field(default_factory=dict) + + def __post_init__(self) -> None: + self._load_state() + + def _save_state(self) -> None: + state: dict[str, Any] = { + "window_states": {k: v.to_dict() for k, v in self.window_states.items()}, + "window_display_names": self.window_display_names, + } + atomic_write_json(config.state_file, state) + logger.debug("State saved to %s", config.state_file) + + def _is_window_id(self, key: str) -> bool: + """Check if a key looks like a tmux window ID (e.g. '@0', '@12').""" + return key.startswith("@") and len(key) > 1 and key[1:].isdigit() + + def _load_state(self) -> None: + """Load state synchronously during initialization.""" + if config.state_file.exists(): + try: + state = json.loads(config.state_file.read_text()) + self.window_states = { + k: WindowState.from_dict(v) + for k, v in state.get("window_states", {}).items() + } + self.window_display_names = state.get("window_display_names", {}) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to load state: %s", e) + self.window_states = {} + self.window_display_names = {} + + async def resolve_stale_ids(self) -> None: + """Re-resolve persisted window IDs against live tmux windows.""" + windows = await tmux_manager.list_windows() + live_by_name: dict[str, str] = {} + live_ids: set[str] = set() + for w in windows: + live_by_name[w.window_name] = w.window_id + live_ids.add(w.window_id) + + changed = False + new_window_states: dict[str, WindowState] = {} + for key, ws in self.window_states.items(): + if self._is_window_id(key): + if key in live_ids: + new_window_states[key] = ws + else: + display = self.window_display_names.get(key, ws.window_name or key) + new_id = live_by_name.get(display) + if new_id: + logger.info( + "Re-resolved stale window_id %s -> %s (name=%s)", + key, + new_id, + display, + ) + new_window_states[new_id] = ws + ws.window_name = display + self.window_display_names[new_id] = display + changed = True + else: + logger.info("Dropping stale window_state: %s", key) + changed = True + else: + new_id = live_by_name.get(key) + if new_id: + logger.info("Migrating window_state key %s -> %s", key, new_id) + ws.window_name = key + new_window_states[new_id] = ws + self.window_display_names[new_id] = key + changed = True + else: + changed = True + + self.window_states = new_window_states + if changed: + self._save_state() + + async def load_session_map(self) -> None: + """Read session_map.json and update window_states.""" + if not config.session_map_file.exists(): + return + try: + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + session_map = json.loads(content) + except (json.JSONDecodeError, OSError): + return + + prefix = f"{config.tmux_session_name}:" + valid_wids: set[str] = set() + changed = False + + for key, info in session_map.items(): + if not key.startswith(prefix): + continue + window_id = key[len(prefix) :] + if not self._is_window_id(window_id): + continue + valid_wids.add(window_id) + new_sid = info.get("session_id", "") + new_cwd = info.get("cwd", "") + new_wname = info.get("window_name", "") + if not new_sid: + continue + state = self.get_window_state(window_id) + if state.session_id != new_sid or state.cwd != new_cwd: + state.session_id = new_sid + state.cwd = new_cwd + changed = True + if new_wname: + state.window_name = new_wname + if self.window_display_names.get(window_id) != new_wname: + self.window_display_names[window_id] = new_wname + changed = True + + # Only clean up stale entries when we actually found valid entries. + # If valid_wids is empty (e.g., session_map is mid-write or has no + # entries for our tmux session), skip cleanup to avoid wiping all state. + if valid_wids: + stale_wids = [w for w in self.window_states if w and w not in valid_wids] + for wid in stale_wids: + del self.window_states[wid] + changed = True + + if changed: + self._save_state() + + # --- Display name management --- + + def get_display_name(self, window_id: str) -> str: + return self.window_display_names.get(window_id, window_id) + + # --- Window state management --- + + def get_window_state(self, window_id: str) -> WindowState: + """Get or create window state (use for write paths like load_session_map).""" + if window_id not in self.window_states: + self.window_states[window_id] = WindowState() + return self.window_states[window_id] + + def lookup_window_state(self, window_id: str) -> WindowState | None: + """Look up window state without creating it (use for read-only checks).""" + return self.window_states.get(window_id) + + # --- Window → Session resolution --- + + def _build_session_file_path(self, session_id: str, cwd: str) -> Path | None: + if not session_id or not cwd: + return None + encoded_cwd = cwd.replace("/", "-") + return config.claude_projects_path / encoded_cwd / f"{session_id}.jsonl" + + async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | None: + """Resolve a tmux window to the best matching Claude session.""" + state = self.lookup_window_state(window_id) + if not state or not state.session_id or not state.cwd: + return None + + file_path = self._build_session_file_path(state.session_id, state.cwd) + if not file_path or not file_path.exists(): + pattern = f"*/{state.session_id}.jsonl" + matches = list(config.claude_projects_path.glob(pattern)) + if matches: + file_path = matches[0] + else: + return None + + return ClaudeSession( + session_id=state.session_id, + summary="", + message_count=0, + file_path=str(file_path), + ) + + async def wait_for_session_map_entry( + self, window_id: str, timeout: float = 5.0, interval: float = 0.5 + ) -> bool: + """Poll session_map.json until an entry for window_id appears.""" + key = f"{config.tmux_session_name}:{window_id}" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + try: + if config.session_map_file.exists(): + async with aiofiles.open(config.session_map_file, "r") as f: + content = await f.read() + sm = json.loads(content) + info = sm.get(key, {}) + if info.get("session_id"): + await self.load_session_map() + return True + except (json.JSONDecodeError, OSError): + pass + await asyncio.sleep(interval) + return False + + # --- Tmux helpers --- + + async def send_to_window(self, window_id: str, text: str) -> tuple[bool, str]: + """Send text to a tmux window by ID, auto-resuming if needed.""" + display = self.get_display_name(window_id) + window = await tmux_manager.find_window_by_id(window_id) + if not window: + return False, "Window not found (may have been closed)" + if window.pane_current_command in SHELL_COMMANDS: + resumed = await self._try_resume_claude(window_id, display) + if not resumed: + return False, "Claude Code is not running (session exited)" + success = await tmux_manager.send_keys(window.window_id, text) + if success: + return True, f"Sent to {display}" + return False, "Failed to send keys" + + async def _try_resume_claude(self, window_id: str, display: str) -> bool: + """Attempt to resume Claude Code when pane has dropped to shell.""" + pane_text = await tmux_manager.capture_pane(window_id) + if not pane_text: + return False + resume_cmd = _extract_resume_command(pane_text) + if not resume_cmd: + return False + logger.info( + "Auto-resuming Claude in %s (%s): %s", window_id, display, resume_cmd + ) + await tmux_manager.send_keys(window_id, resume_cmd) + max_wait = 3.0 if resume_cmd == "fg" else 15.0 + elapsed = 0.0 + while elapsed < max_wait: + await asyncio.sleep(0.5) + elapsed += 0.5 + w = await tmux_manager.find_window_by_id(window_id) + if w and w.pane_current_command not in SHELL_COMMANDS: + await asyncio.sleep(1.0) + return True + return False + + # --- Message history --- + + async def get_recent_messages( + self, + window_id: str, + *, + start_byte: int = 0, + end_byte: int | None = None, + ) -> tuple[list[dict[str, Any]], int]: + """Get messages for a window's session.""" + session = await self.resolve_session_for_window(window_id) + if not session or not session.file_path: + return [], 0 + + file_path = Path(session.file_path) + if not file_path.exists(): + return [], 0 + + entries: list[dict[str, Any]] = [] + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + if start_byte > 0: + await f.seek(start_byte) + while True: + if end_byte is not None: + current_pos = await f.tell() + if current_pos >= end_byte: + break + line = await f.readline() + if not line: + break + data = TranscriptParser.parse_line(line) + if data: + entries.append(data) + except OSError as e: + logger.error("Error reading session file %s: %s", file_path, e) + return [], 0 + + parsed_entries, _ = TranscriptParser.parse_entries(entries) + all_messages = [ + { + "role": e.role, + "text": e.text, + "content_type": e.content_type, + "timestamp": e.timestamp, + } + for e in parsed_entries + ] + return all_messages, len(all_messages) + + +# Singleton +session_manager = SessionManager() diff --git a/ccweb/ccweb/backend/ui_parser.py b/ccweb/ccweb/backend/ui_parser.py new file mode 100644 index 00000000..811fc863 --- /dev/null +++ b/ccweb/ccweb/backend/ui_parser.py @@ -0,0 +1,213 @@ +"""Structured UI parser — converts raw terminal text to interactive data. + +terminal_parser.py detects interactive UIs and returns raw text content. +This module takes that raw text and attempts to parse it into structured +data (option labels, action descriptions, etc.) that the React frontend +can render as clickable components. + +Parsing is fragile screen-scraping of Claude Code's terminal UI. The +parser is defensive: if parsing fails, it returns None and the frontend +falls back to displaying raw text with generic navigation buttons. + +Key function: parse_interactive_ui(). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ParsedOption: + """A single option in an interactive UI.""" + + label: str + checked: bool = False + index: int = 0 + + +@dataclass +class ParsedInteractiveUI: + """Structured data extracted from an interactive terminal UI.""" + + ui_name: str + options: list[ParsedOption] = field(default_factory=list) + description: str = "" + command: str = "" # For BashApproval + + def to_dict(self) -> dict[str, Any]: + return { + "ui_name": self.ui_name, + "options": [ + {"label": o.label, "checked": o.checked, "index": o.index} + for o in self.options + ], + "description": self.description, + "command": self.command, + } + + +# Checkbox markers used by Claude Code's AskUserQuestion UI +_RE_CHECKBOX = re.compile(r"^\s*(?:←\s+)?([☐✔☒])\s+(.+)$") + +# Permission prompt action line +_RE_PERMISSION_ACTION = re.compile( + r"^\s*Do you want to (proceed|make this edit|create|delete)\b" +) + +# Numbered choice (e.g., "❯ 1. Yes, allow once") +_RE_NUMBERED_CHOICE = re.compile(r"^\s*[❯ ]\s*(\d+)\.\s+(.+)$") + +# Bash command line +_RE_BASH_COMMAND = re.compile(r"^\s*(.+)$") + + +def parse_interactive_ui( + raw_content: str, + ui_name: str, +) -> ParsedInteractiveUI | None: + """Parse raw terminal text into structured interactive UI data. + + Args: + raw_content: The raw text content from terminal_parser + ui_name: The UI type name ("AskUserQuestion", "PermissionPrompt", etc.) + + Returns: + Parsed structure or None if parsing fails (frontend uses raw text fallback) + """ + if not raw_content or not ui_name: + return None + + lines = raw_content.strip().split("\n") + + if ui_name == "AskUserQuestion": + return _parse_ask_user_question(lines) + if ui_name == "ExitPlanMode": + return _parse_exit_plan_mode(lines) + if ui_name in ("PermissionPrompt", "BashApproval"): + return _parse_permission_prompt(lines, ui_name) + if ui_name == "RestoreCheckpoint": + return _parse_restore_checkpoint(lines) + + return None + + +def _parse_ask_user_question(lines: list[str]) -> ParsedInteractiveUI | None: + """Parse AskUserQuestion: extract checkbox options.""" + options: list[ParsedOption] = [] + idx = 0 + for line in lines: + match = _RE_CHECKBOX.match(line) + if match: + marker = match.group(1) + label = match.group(2).strip() + checked = marker in ("✔", "☒") + options.append(ParsedOption(label=label, checked=checked, index=idx)) + idx += 1 + + if not options: + return None + + return ParsedInteractiveUI(ui_name="AskUserQuestion", options=options) + + +def _parse_exit_plan_mode(lines: list[str]) -> ParsedInteractiveUI | None: + """Parse ExitPlanMode: extract proceed/edit options.""" + description_lines: list[str] = [] + for line in lines: + stripped = line.strip() + if ( + stripped + and not stripped.startswith("ctrl-g") + and not stripped.startswith("Esc") + ): + description_lines.append(stripped) + + return ParsedInteractiveUI( + ui_name="ExitPlanMode", + description="\n".join(description_lines), + options=[ + ParsedOption(label="Proceed", index=0), + ParsedOption(label="Edit Plan", index=1), + ], + ) + + +def _parse_permission_prompt( + lines: list[str], ui_name: str +) -> ParsedInteractiveUI | None: + """Parse PermissionPrompt or BashApproval.""" + description_lines: list[str] = [] + command = "" + options: list[ParsedOption] = [] + idx = 0 + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Check for numbered choices + num_match = _RE_NUMBERED_CHOICE.match(line) + if num_match: + label = num_match.group(2).strip() + options.append(ParsedOption(label=label, index=idx)) + idx += 1 + continue + + # Skip footer lines + if stripped.startswith("Esc to"): + continue + + description_lines.append(stripped) + + description = "\n".join(description_lines) + + # If no numbered choices found, provide default Allow/Deny + if not options: + options = [ + ParsedOption(label="Allow", index=0), + ParsedOption(label="Deny", index=1), + ] + + # For BashApproval, try to extract the command + if ui_name == "BashApproval" and len(description_lines) > 1: + # The command is typically the line after "Bash command" + for i, line in enumerate(description_lines): + if "bash command" in line.lower() or "requires approval" in line.lower(): + if i + 1 < len(description_lines): + command = description_lines[i + 1] + break + + return ParsedInteractiveUI( + ui_name=ui_name, + description=description, + options=options, + command=command, + ) + + +def _parse_restore_checkpoint(lines: list[str]) -> ParsedInteractiveUI | None: + """Parse RestoreCheckpoint: extract checkpoint options.""" + options: list[ParsedOption] = [] + idx = 0 + for line in lines: + stripped = line.strip() + if ( + not stripped + or stripped.startswith("Enter to") + or stripped.startswith("Esc") + ): + continue + options.append(ParsedOption(label=stripped, index=idx)) + idx += 1 + + if not options: + return None + + return ParsedInteractiveUI( + ui_name="RestoreCheckpoint", + options=options, + ) diff --git a/ccweb/ccweb/backend/ws_protocol.py b/ccweb/ccweb/backend/ws_protocol.py new file mode 100644 index 00000000..0c2f9fee --- /dev/null +++ b/ccweb/ccweb/backend/ws_protocol.py @@ -0,0 +1,164 @@ +"""WebSocket protocol message types for CCWeb. + +Defines the bidirectional JSON message format between the FastAPI backend +and the React frontend. Server->Client messages deliver Claude output, +interactive UIs, status updates, and session lists. Client->Server messages +send user text, key presses, decision grid submissions, and session commands. + +All messages are JSON objects with a "type" field for dispatch. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + + +# ── Server → Client messages ───────────────────────────────────────────── + + +@dataclass +class WsMessage: + """A new message from a Claude Code session.""" + + type: str = "message" + window_id: str = "" + role: str = "assistant" + content_type: str = "text" + text: str = "" + tool_use_id: str | None = None + tool_name: str | None = None + timestamp: str | None = None + # Base64-encoded images from tool_result: list of {media_type, data} + images: list[dict[str, str]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + d = asdict(self) + # Omit empty images to save bandwidth + if not d["images"]: + del d["images"] + return d + + +@dataclass +class WsInteractiveUI: + """An interactive UI detected in the terminal (AskUserQuestion, etc.).""" + + type: str = "interactive_ui" + window_id: str = "" + ui_name: str = "" + raw_content: str = "" # Always included as fallback + structured: dict[str, Any] | None = None # Parsed options when available + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsDecisionGrid: + """A decision grid file detected in .ccweb/pending/.""" + + type: str = "decision_grid" + window_id: str = "" + grid: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsStatus: + """Status update (spinner text from Claude's status line).""" + + type: str = "status" + window_id: str = "" + text: str = "" + context_pct: int | None = None # Context usage % from status bar chrome + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsSessions: + """Current session list broadcast.""" + + type: str = "sessions" + sessions: list[dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsHealth: + """Health check sent on WebSocket connect.""" + + type: str = "health" + tmux_running: bool = False + hook_installed: bool = False + sessions_found: int = 0 + warnings: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsError: + """Backend error notification.""" + + type: str = "error" + code: str = "" + message: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsPong: + """Keepalive response.""" + + type: str = "pong" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsHistory: + """Message history for a session.""" + + type: str = "history" + window_id: str = "" + messages: list[dict[str, Any]] = field(default_factory=list) + total: int = 0 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class WsSendAck: + """Acknowledgment that text was sent to Claude.""" + + type: str = "send_ack" + window_id: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +# ── Client → Server messages (parsed from JSON dicts) ──────────────────── + +# These are not dataclasses — they're parsed from incoming JSON. +# Type field values for dispatch: +CLIENT_SEND_TEXT = "send_text" +CLIENT_SEND_KEY = "send_key" +CLIENT_SUBMIT_DECISIONS = "submit_decisions" +CLIENT_CREATE_SESSION = "create_session" +CLIENT_KILL_SESSION = "kill_session" +CLIENT_SWITCH_SESSION = "switch_session" +CLIENT_GET_HISTORY = "get_history" +CLIENT_PING = "ping" diff --git a/ccweb/docs/architecture/deferred-items.md b/ccweb/docs/architecture/deferred-items.md new file mode 100644 index 00000000..b1a6d410 --- /dev/null +++ b/ccweb/docs/architecture/deferred-items.md @@ -0,0 +1,81 @@ +--- +title: Deferred Items +description: Complete grid of all deferred features with effort, usefulness, and success estimates +order: 3 +--- + +# Deferred Items Grid + +All features deferred from v1, with estimates for prioritization. + +## A. Rich Tool Output Rendering + +| # | Item | Effort | Usefulness | Success | Reason Deferred | +|---|------|--------|------------|---------|-----------------| +| 1 | **File diff viewer** — green/red highlighting, collapsible per file | Medium (2-3 days) | **Very High** — most frequent tool output, massive UX win | 95% | Requires custom diff parser component; `
` blocks work for v1 |
+| 2 | **Progress tracker** — TodoWrite as persistent checkbox panel | Medium (1-2 days) | Medium — nice visibility but TodoWrite is used inconsistently | 90% | Needs new pinned panel UI pattern; low priority vs core features |
+| 3 | **Search results cards** — Grep/WebSearch as structured cards | Medium (2 days) | Medium — improves readability of search results | 85% | Requires parsing multiple tool output formats into structured data |
+| 4 | **Destructive action warning** — red-bordered permission prompts | Small (0.5 days) | Medium — safety UX improvement | 98% | Simple keyword detection; deferred because base permission UI works |
+
+## B. Subagent Tracking
+
+| # | Item | Effort | Usefulness | Success | Reason Deferred |
+|---|------|--------|------------|---------|-----------------|
+| 5 | **Collapsible task cards** — nested agent activity in message stream | Medium (2 days) | High — critical for multi-agent workflows | 90% | Requires indentation/nesting model in message stream; tool_use/result pairing already works flat |
+| 6 | **Subagent status badge** — "2 agents running" indicator | Small (0.5 days) | Medium — quick glance at agent activity | 95% | Depends on #5 for real value |
+| 7 | **Real-time subagent JSONL streaming** — monitor subagent session files | Large (3-5 days) | High — see what agents are doing live, not just final results | 60% | Requires discovering subagent session IDs from parent JSONL, monitoring multiple files dynamically; Claude Code's subagent format may change |
+
+## C. Saved Prompts Library
+
+| # | Item | Effort | Usefulness | Success | Reason Deferred |
+|---|------|--------|------------|---------|-----------------|
+| 8 | **Prompt Library UI** — fuzzy search, categories, preview | Medium (2-3 days) | High — eliminates copy/paste for repeated prompts | 90% | Full CRUD UI + backend; deferred because command palette covers slash commands |
+| 9 | **Variable placeholders** — `{{filename}}` with fill-in form | Medium (1-2 days) | Medium — power-user feature | 80% | Template parsing + dynamic form generation; nice but not essential |
+| 10 | **Global + project prompts** — `~/.ccweb/prompts/` + `{project}/.ccweb/prompts/` | Small (1 day) | Medium — organization for prompt collections | 95% | File-based storage is simple; deferred because the library UI (#8) is needed first |
+
+## D. Decision Grid v2
+
+| # | Item | Effort | Usefulness | Success | Reason Deferred |
+|---|------|--------|------------|---------|-----------------|
+| 11 | **Keyboard navigation** — arrow keys between cells, Tab to notes | Small (1 day) | Medium — power-user speed improvement | 95% | Focus management in a grid is fiddly; mouse works fine for v1 |
+| 12 | **Comparison mode** — side-by-side option comparison | Medium (2 days) | Low — rarely need to compare options in detail | 75% | Niche use case; the description field usually provides enough context |
+| 13 | **"Explain this option" button** — ask Claude for elaboration per row | Medium (1-2 days) | Medium — reduces back-and-forth | 70% | Requires sending text to Claude while grid is open, handling the response asynchronously; timing is tricky with AskUserQuestion blocking |
+
+## E. Performance
+
+| # | Item | Effort | Usefulness | Success | Reason Deferred |
+|---|------|--------|------------|---------|-----------------|
+| 14 | **inotify/watchdog streaming** — replace 2s JSONL polling | Medium (2-3 days) | **Very High** — near-instant responses instead of 2s bursts | 80% | Requires `watchdog` dependency, careful partial-write handling, and testing on WSL (inotify can be unreliable on WSL2 filesystem mounts) |
+
+## F. Creative UX Explorations
+
+| # | Item | Effort | Usefulness | Success | Reason Deferred |
+|---|------|--------|------------|---------|-----------------|
+| 15 | **Optimistic input** — show user message immediately with pending indicator | Small (0.5 days) | High — feels much more responsive | 95% | Simple state addition; deferred because it's pure polish |
+| 16 | **Message threading** — nest Q&A exchanges (Slack-style) | Large (3-5 days) | Medium — helps in long sessions but changes the core message model | 50% | Fundamental change to message stream rendering; unclear how to detect thread boundaries in Claude's output |
+| 17 | **Session timeline scrubber** — minimap of session phases | Large (3-4 days) | Medium — novel visualization | 60% | Requires phase detection heuristics (thinking vs tool use vs conversation); complex custom UI |
+| 18 | **Live file preview pane** — split view showing file state after Edit | Large (3-5 days) | High — see what the file looks like now, not just the diff | 65% | Requires reading files from the project directory via a new API; security implications of serving arbitrary file content |
+| 19 | **Pinned messages** — pin key decisions to session top | Medium (1-2 days) | Medium — useful for long sessions | 85% | Needs persistent pin state (per-session localStorage or backend); simple UI but needs thought on what "pinning" means |
+| 20 | **Session heatmap** — activity timeline + token spend per session | Medium (2 days) | Low — analytics/insight feature | 70% | Requires tracking activity timestamps and token data that may not be in JSONL |
+| 21 | **Quick reactions on tool results** — thumbs-up/flag | Small (1 day) | Low — feedback signal with no immediate use | 80% | Needs storage for reactions; unclear what value they provide without a feedback loop |
+| 22 | **Minimap** — VS Code-style vertical scroll overview | Large (3-4 days) | Low-Medium — scroll orientation aid | 50% | Complex canvas/SVG rendering; high effort for modest value |
+| 23 | **Keyboard-first navigation** — Linear-style G+S, G+P shortcuts | Small (1 day) | Medium — power-user productivity | 90% | Simple key handler additions; deferred because mouse/touch works |
+| 24 | **Stackable message filters** — combine filters (chat + tools) | Small (0.5 days) | Medium — more granular filtering | 95% | Change filter from single enum to bitfield; simple but the current 4 presets cover most needs |
+
+## G. UI Polish
+
+| # | Item | Effort | Usefulness | Success | Reason Deferred |
+|---|------|--------|------------|---------|-----------------|
+| 25 | **Dark/light theme toggle** | Medium (1-2 days) | Medium — accessibility and preference | 90% | CSS variable swap; needs a second set of color values and a preferences persistence mechanism |
+| 26 | **Notification badges on sessions** — unread indicator | Small (1 day) | High — know which sessions have new activity | 90% | Track last-read offset per session in frontend; show badge when messages arrive for non-active session |
+| 27 | **Per-project setup banner** — "Enable CCWeb features?" on first session | Small (0.5 days) | Low — one-time convenience | 95% | Check for `.ccweb/instructions.md` existence; show dismissable banner |
+
+## Top 5 by Impact/Effort Ratio
+
+If picking what to build next:
+
+1. **#15 Optimistic input** — 0.5 days, high usefulness, 95% success
+2. **#26 Notification badges** — 1 day, high usefulness, 90% success
+3. **#4 Destructive action warning** — 0.5 days, medium usefulness, 98% success
+4. **#1 File diff viewer** — 2-3 days, very high usefulness, 95% success
+5. **#14 inotify streaming** — 2-3 days, very high usefulness, 80% success (WSL risk)
diff --git a/ccweb/docs/architecture/design-plan.md b/ccweb/docs/architecture/design-plan.md
new file mode 100644
index 00000000..28075d9d
--- /dev/null
+++ b/ccweb/docs/architecture/design-plan.md
@@ -0,0 +1,843 @@
+# CCWeb: React Web Gateway to Claude Code
+
+## Context
+
+CCBot currently bridges Telegram Forum topics to Claude Code sessions via tmux. The user wants a **richer web-based interface** that replaces Telegram entirely, with:
+- A styled message stream (not raw terminal) with text input for sending messages/commands
+- Interactive components: when Claude asks questions (AskUserQuestion, permission prompts), render them as clickable HTML instead of terminal navigation
+- A **decision grid** system: a Claude Code skill outputs structured options, CCWeb renders them as an interactive HTML grid, user clicks selections, answers sent back to Claude Code
+- Session management (create, switch, kill) similar to Telegram topics
+
+The existing ccbot backend (tmux management, session monitoring, terminal parsing, JSONL parsing, hook system) is transport-agnostic and highly reusable. The Telegram-specific layer (bot.py, handlers/, markdown_v2.py, telegram_sender.py) gets replaced.
+
+## User Workflow
+
+**Current (ccbot + Telegram):**
+```
+WSL → tmux → run `ccbot` → interact via Telegram app
+```
+
+**New (ccweb + Browser):**
+```
+WSL → tmux → run `ccweb` → interact via browser at http://:8765
+```
+
+Everything else stays the same: Claude Code still runs in tmux windows, the SessionStart hook still writes session_map.json, the monitor still polls JSONL files. Only the UI layer changes.
+
+Both can coexist: run `ccbot` AND `ccweb` simultaneously, both connecting to the same tmux sessions.
+
+## Interactive UI in the Browser
+
+All interactive Claude Code UIs currently handled by ccbot's terminal-navigation keyboard will be rendered as proper HTML components:
+
+| Claude Code UI | Current (Telegram) | CCWeb (Browser) |
+|---|---|---|
+| ExitPlanMode | Arrow-key navigation | "Proceed" / "Edit Plan" buttons, plan rendered as markdown |
+| AskUserQuestion | Arrow-key checkboxes | Clickable option cards, click to select + Submit |
+| Permission Prompt | Arrow-key Yes/No | "Allow" / "Deny" buttons with action description |
+| Bash Approval | Arrow-key approve | "Run" / "Deny" with command in code block |
+| Settings/Model | Arrow-key menu | Dropdown or card selector |
+
+Detection uses the same `terminal_parser.py` from ccbot — `extract_interactive_content()` identifies the UI type and content, then the backend sends structured data to the frontend via WebSocket, and the frontend renders the appropriate component. When the user clicks, the frontend sends the corresponding key (Enter, Escape, Space, Tab, arrows) back to tmux.
+
+## Design Constraints
+
+- **Single user**: This is a single-user application. No multi-user auth, no per-user state separation. The one user who runs `ccweb` is the only user.
+- **Single browser session at a time**: While multiple tabs technically work (fan-out from SessionMonitor callback), interactive UI responses are not tab-locked — last click wins. This is acceptable for single-user.
+
+## Architecture Overview
+
+```
+ccweb/
+├── backend/          # Python (FastAPI + WebSocket)
+│   ├── core/         # Copied + adapted from ccbot (transport-agnostic)
+│   │   ├── tmux_manager.py      ← copy verbatim
+│   │   ├── terminal_parser.py   ← copy verbatim
+│   │   ├── transcript_parser.py ← adapt (remove Telegram expandable quote sentinels)
+│   │   ├── session_monitor.py   ← copy verbatim
+│   │   ├── monitor_state.py     ← copy verbatim
+│   │   ├── hook.py              ← copy verbatim
+│   │   └── utils.py             ← copy verbatim
+│   ├── config.py         # New: web-specific config (port, auth, no Telegram)
+│   ├── session.py        # Adapted: simplified bindings (client_id → window_id)
+│   ├── server.py         # New: FastAPI app, WebSocket handler, REST endpoints
+│   ├── ws_protocol.py    # New: WebSocket message types and serialization
+│   └── main.py           # New: CLI entry point
+├── frontend/         # React + TypeScript (Vite)
+│   ├── src/
+│   │   ├── App.tsx
+│   │   ├── components/
+│   │   │   ├── MessageStream.tsx    # Styled message display
+│   │   │   ├── MessageInput.tsx     # Text input + command sending
+│   │   │   ├── SessionSidebar.tsx   # Session list + create/switch/kill
+│   │   │   ├── InteractiveUI.tsx    # AskUserQuestion, permissions, etc.
+│   │   │   ├── DecisionGrid.tsx     # Custom option grid rendering
+│   │   │   ├── StatusBar.tsx        # Claude status (spinner, working text)
+│   │   │   └── ExpandableBlock.tsx  # Thinking, tool results (click to expand)
+│   │   ├── hooks/
+│   │   │   ├── useWebSocket.ts      # WebSocket connection + reconnect
+│   │   │   └── useSession.ts        # Session state management
+│   │   ├── types.ts                 # Shared TypeScript types
+│   │   └── protocol.ts             # WebSocket message types (mirrors ws_protocol.py)
+│   └── package.json
+├── pyproject.toml    # Python package config
+└── README.md
+```
+
+## Module Reuse Map
+
+### Forked Modules (NOT verbatim copies — all need adaptation)
+Every ccbot module imports `from .config import config` and `from .utils import ccbot_dir`. These are **forks**, not copies. Each needs:
+- Import paths updated (now under `ccweb.backend.core`)
+- `ccbot_dir()` → `ccweb_dir()` (returns `~/.ccweb/` or `$CCWEB_DIR`)
+- Config interface matched to the new ccweb config singleton
+
+| Source | Destination | Changes Required |
+|--------|------------|-----------------|
+| `src/ccbot/tmux_manager.py` | `ccweb/backend/core/tmux_manager.py` | Update config import. Uses `config.tmux_session_name`, `config.claude_command`, `config.tmux_main_window_name` — new config must expose same attrs. |
+| `src/ccbot/terminal_parser.py` | `ccweb/backend/core/terminal_parser.py` | Zero config deps — this one IS a true copy. Pure regex, no imports from config/utils. |
+| `src/ccbot/session_monitor.py` | `ccweb/backend/core/session_monitor.py` | Update config import. Uses `config.claude_projects_path`, `config.monitor_poll_interval`, `config.session_map_file`, `config.show_user_messages`. |
+| `src/ccbot/monitor_state.py` | `ccweb/backend/core/monitor_state.py` | Update utils import (`atomic_write_json`). Otherwise clean. |
+| `src/ccbot/hook.py` | `ccweb/backend/core/hook.py` | Update to write to `~/.ccweb/session_map.json` (via `ccweb_dir()`). Hook command becomes `ccweb hook` instead of `ccbot hook`. |
+| `src/ccbot/utils.py` | `ccweb/backend/core/utils.py` | Rename `ccbot_dir()` → `ccweb_dir()`, default `~/.ccweb/`, env var `CCWEB_DIR`. |
+| `src/ccbot/transcript_parser.py` | `ccweb/backend/core/transcript_parser.py` | Remove `EXPANDABLE_QUOTE_START/END` sentinels. Keep all JSONL parsing logic. |
+| `src/ccbot/config.py` | `ccweb/backend/config.py` | Full rewrite. Remove Telegram vars. Add web vars. Must expose same attribute names used by forked modules: `tmux_session_name`, `tmux_main_window_name`, `claude_command`, `claude_projects_path`, `monitor_poll_interval`, `state_file`, `session_map_file`, `monitor_state_file`, `show_user_messages`, `show_hidden_dirs`, `browse_root`, `memory_monitor_enabled`, etc. |
+| `src/ccbot/session.py` | `ccweb/backend/session.py` | Replace `thread_bindings` with `client_bindings` (client_id → window_id). Remove group_chat_ids. Keep window_states, user_window_offsets, load_session_map, resolve_stale_ids, send_to_window, get_recent_messages. |
+
+### Drop (Telegram-only, replaced by web equivalents)
+- `src/ccbot/bot.py` → replaced by `server.py`
+- `src/ccbot/handlers/` → replaced by WebSocket message handlers in `server.py`
+- `src/ccbot/markdown_v2.py` → React renders markdown natively
+- `src/ccbot/telegram_sender.py` → WebSocket sends (no 4096 char limit)
+- `src/ccbot/screenshot.py` → optional; styled view replaces primary use
+
+## WebSocket Protocol (`ws_protocol.py` / `protocol.ts`)
+
+Bidirectional JSON messages over WebSocket:
+
+### Server → Client
+```
+# New message from Claude (text, thinking, tool_use, tool_result)
+{"type": "message", "window_id": "@0", "role": "assistant", "content_type": "text",
+ "text": "...", "tool_use_id": null, "tool_name": null, "timestamp": "..."}
+
+# Interactive UI detected (AskUserQuestion, permission, etc.)
+{"type": "interactive_ui", "window_id": "@0", "ui_name": "AskUserQuestion",
+ "content": "...", "options": [...]}   # parsed from terminal capture
+
+# Decision grid (custom skill output)
+{"type": "decision_grid", "window_id": "@0", "grid": {...}}
+
+# Status update (spinner, working text)
+{"type": "status", "window_id": "@0", "text": "Reading files..."}
+
+# Session list update
+{"type": "sessions", "sessions": [{"window_id": "@0", "name": "project", "cwd": "/path"}]}
+```
+
+### Client → Server
+```
+# Send text to Claude (like typing in terminal/Telegram)
+{"type": "send_text", "window_id": "@0", "text": "Fix the bug in auth.py"}
+
+# Send key press (for interactive UI navigation)
+{"type": "send_key", "window_id": "@0", "key": "Enter"}
+
+# Submit decision grid selections (each item has selected option + optional notes)
+{"type": "submit_decisions", "window_id": "@0", "selections": [
+  {"topic": "Auth module refactor", "choice": "Update to bcrypt v4 API", "notes": ""},
+  {"topic": "Error handling", "choice": "Add retry with backoff", "notes": "Handle 429 specifically"},
+  {"topic": "Logging", "choice": null, "notes": "Let's discuss structured logging first"}
+]}
+
+# Session management
+{"type": "create_session", "work_dir": "/path/to/project", "name": "my-project"}
+{"type": "kill_session", "window_id": "@0"}
+{"type": "switch_session", "window_id": "@0"}
+
+# Request history
+{"type": "get_history", "window_id": "@0", "page": 0}
+```
+
+## Decision Grid Protocol
+
+For the user's custom skill that generates option grids:
+
+1. **Detection**: The skill writes a JSON file to `{session_cwd}/.ccweb/decisions/{id}.json`
+2. **Backend monitors** this directory (or the skill outputs a marker in JSONL text)
+3. **Grid JSON schema**:
+```json
+{
+  "id": "decision-001",
+  "title": "Code Review Decisions",
+  "items": [
+    {
+      "topic": "Auth module refactor",
+      "description": "The auth module uses deprecated bcrypt API...",
+      "options": [
+        {"label": "Update to bcrypt v4 API", "recommended": true},
+        {"label": "Switch to argon2", "recommended": false},
+        {"label": "Keep current (suppress warning)", "recommended": false}
+      ],
+      "allow_custom": true
+    }
+  ]
+}
+```
+4. **Frontend** renders this as an interactive HTML table/card grid:
+   - Each row: topic name | description | option radio buttons/cards | **notes column**
+   - The **notes column** is always present on every row — a text input that lets the user:
+     - Add additional context/details to accompany their selection
+     - Provide a custom option not listed (override)
+     - Ask a question or request more info on that specific topic
+   - Recommended option is pre-selected/highlighted but user can change
+   - "Submit All" button at bottom
+5. **On submit**: client sends `{"type": "submit_decisions", ...}` with selections AND notes, backend formats as text and sends to Claude via `tmux_manager.send_keys()`
+6. The text sent to Claude would be formatted like:
+```
+Decisions for "Code Review Decisions":
+- Auth module refactor: Update to bcrypt v4 API
+- Error handling: Add retry with backoff
+  Note: "Make sure we handle the 429 case specifically"
+- Logging strategy: [Custom] "Let's discuss this one more - what about structured logging?"
+...
+```
+
+## Custom Interaction Types (CCWeb Protocol)
+
+Beyond the decision grid, define additional structured interaction types that Claude Code skills can output. All use the same detection mechanism (marker in JSONL text or file in `.ccweb/`):
+
+### 1. Decision Grid (primary use case)
+See above. Skill outputs structured JSON, user selects options + adds notes, answers sent back.
+
+### 2. Checklist
+Simpler than a decision grid — just checkboxes with labels. User checks items and submits.
+```json
+{"type": "ccweb:checklist", "title": "Pre-deploy checks", "items": [
+  {"label": "Tests passing", "checked": false},
+  {"label": "Migrations reviewed", "checked": false},
+  {"label": "ENV vars updated", "checked": true}
+]}
+```
+Submitted as: "Checked: Tests passing, Migrations reviewed. Unchecked: ENV vars updated."
+
+### 3. Status Report
+A skill outputs a structured status dashboard. Read-only (no user interaction needed).
+```json
+{"type": "ccweb:status", "title": "Build Status", "items": [
+  {"label": "Unit tests", "status": "pass", "detail": "142/142"},
+  {"label": "Lint", "status": "fail", "detail": "3 errors in auth.py"},
+  {"label": "Type check", "status": "pass", "detail": "0 errors"}
+]}
+```
+Rendered as cards with green/yellow/red indicators.
+
+### 4. Confirmation Dialog
+For critical actions that need explicit user approval with context.
+```json
+{"type": "ccweb:confirm", "title": "Deploy to production?",
+ "description": "This will deploy commit abc123 to prod. 3 migrations pending.",
+ "severity": "high",
+ "actions": [{"label": "Deploy", "value": "yes"}, {"label": "Cancel", "value": "no"}]}
+```
+
+### Detection Mechanism (file-based, primary)
+The Claude Code skill writes a JSON file to a well-known location using the Write tool (which is atomic and reliable — no risk of malformed output or marker splitting):
+
+```
+{session_cwd}/.ccweb/pending/{type}-{timestamp}.json
+```
+
+Example: `.ccweb/pending/grid-1712345678.json`
+
+The backend watches this directory (via polling alongside JSONL monitoring). When a new file appears:
+1. Read and validate the JSON
+2. Send structured WebSocket message to the frontend
+3. Move file to `.ccweb/completed/` after user submits (or `.ccweb/dismissed/` if ignored)
+
+**Why file-based instead of text markers**: Writing a file via Claude's Write tool is a deterministic tool call — always produces valid JSON, no streaming chunk splits, no code-block false positives, easy to debug (`cat` the file). Text markers (``) are fragile because Claude's output is free-form text that can be malformed, wrapped in code blocks, or split across JSONL entries.
+
+**CRITICAL: Timing — how Claude waits for the user's response**:
+The skill MUST use `AskUserQuestion` after writing the grid file. Without this, Claude writes the file and immediately proceeds — by the time the user sees the grid, Claude is 3 tool calls ahead, and the submitted text arrives as garbage input mid-thought.
+
+Correct flow:
+1. Skill writes grid JSON to `.ccweb/pending/grid-xxx.json` (via Write tool)
+2. Skill IMMEDIATELY calls `AskUserQuestion` with: "I've prepared an option grid for you. Please review it in your CCWeb interface and submit your selections. (Your answers will appear here automatically.)"
+3. `AskUserQuestion` **blocks Claude** — it waits for user input
+4. Backend detects the grid file → sends to frontend via WebSocket
+5. User fills out grid, clicks Submit
+6. Backend sends formatted selection text to Claude via tmux keystrokes
+7. Claude receives the text as the AskUserQuestion response, unblocks, and continues
+
+This is the ONLY correct approach. The skill prompt must enforce steps 1+2 together.
+
+### CCWeb Skill Installation
+Skills are installed **globally** in `~/.claude/commands/` so they're available in every project. No per-repo copying needed.
+
+**`ccweb install`** command auto-installs:
+1. The SessionStart hook (same as ccbot's `ccbot hook --install`)
+2. Global slash commands for all ccweb interaction types:
+   - `~/.claude/commands/option-grid.md` — Decision/option grid
+   - `~/.claude/commands/checklist.md` — Interactive checklist
+   - `~/.claude/commands/status-report.md` — Status dashboard
+   - `~/.claude/commands/confirm.md` — Confirmation dialog
+
+**Example: `~/.claude/commands/option-grid.md`:**
+```markdown
+# Option Grid
+
+When called, research the topics provided and output an option grid for the user.
+
+You MUST do these two steps in sequence:
+
+Step 1: Write the grid as a JSON file using your Write tool to this exact path:
+  {cwd}/.ccweb/pending/grid-{timestamp}.json
+
+Use this schema:
+{"id": "unique-id", "type": "ccweb:grid", "title": "...", "items": [
+  {"topic": "...", "description": "...", "allow_custom": true,
+   "options": [{"label": "...", "recommended": true}, ...]}
+]}
+
+Each item must have: topic, description, options (array with recommended flag),
+allow_custom: true. Always include 2-4 options per topic with one marked as recommended.
+
+Step 2: IMMEDIATELY after writing the file, use AskUserQuestion to ask:
+"I've prepared an option grid. Please review it in your CCWeb interface and submit
+your selections. Your choices will appear here automatically."
+
+This is critical — AskUserQuestion blocks you until the user responds via CCWeb.
+Do NOT proceed without waiting. The user's selections will be sent back as text.
+```
+
+Run `ccweb install` once and all commands are available everywhere.
+
+## Implementation Order
+
+### Phase 1: Project Scaffold + Core Backend
+1. Create `ccweb/` directory structure
+2. Create `pyproject.toml` with deps: `fastapi`, `uvicorn[standard]`, `websockets`, `libtmux`, `aiofiles`, `python-dotenv` — pin same `libtmux` version as ccbot to avoid API breakage
+3. **Create `config.py` FIRST** — this is the foundation. Must expose all attribute names used by forked modules (`tmux_session_name`, `claude_projects_path`, `state_file`, `session_map_file`, etc.) without Telegram deps
+4. **Create `utils.py` SECOND** — `ccweb_dir()` returning `~/.ccweb/`, `atomic_write_json()`
+5. Fork modules from ccbot to `ccweb/backend/core/`: update all `from .config import config` and `from .utils import` paths. `terminal_parser.py` is the only true copy (zero config/utils deps).
+6. Adapt `transcript_parser.py` (remove Telegram expandable quote sentinels)
+7. Adapt `hook.py` — writes to `~/.ccweb/session_map.json`, command is `ccweb hook`
+8. Create simplified `session.py` (client_bindings instead of thread_bindings)
+9. Create `ws_protocol.py` (message type definitions)
+10. Create `server.py` (FastAPI app, WebSocket handler, REST endpoints)
+11. Create `main.py` (CLI entry with `ccweb`, `ccweb install`, `ccweb hook` subcommands)
+12. **Verify**: run `ccweb` — FastAPI starts, WebSocket connects, health check passes
+
+### Phase 2: React Frontend Scaffold
+1. `npm create vite@latest frontend -- --template react-ts`
+2. Install deps: `react-markdown`, `remark-gfm` (markdown rendering), a CSS framework (Tailwind or similar)
+3. Create `protocol.ts` (mirrors WebSocket types)
+4. Create `useWebSocket.ts` hook (connect, reconnect, message dispatch)
+5. Create basic `App.tsx` layout (sidebar + main content area)
+
+### Phase 3: Message Stream + Input
+1. `MessageStream.tsx` - renders messages with role-based styling:
+   - Assistant text → markdown rendered
+   - Thinking → collapsible/expandable block
+   - tool_use → summary line (like "**Read**(file.py)")
+   - tool_result → `
` block (collapsible). Rich rendering (diff viewer, search cards) deferred to v2
+   - User messages → right-aligned or prefixed
+2. `FileUpload.tsx` - document/file upload:
+   - Paperclip (📎) button next to the text input
+   - Accepts: text files, code, Markdown, PDF, Word docs, images (same as ccbot's allowed types)
+   - Upload flow: file sent to backend → saved to `{session_cwd}/docs/inbox/{filename}` → path sent to Claude via tmux as "A file has been saved to docs/inbox/{name}. Read it with your Read tool."
+   - Optional caption/instruction text alongside the upload
+   - Drag-and-drop support on the message area
+   - Image files rendered as inline thumbnails before sending
+3. `MessageInput.tsx` - multi-line text area with:
+   - **Enter = newline** (multi-line input by default)
+   - **Visible Submit button** to send the message
+   - **Ctrl+Enter / Cmd+Enter** keyboard shortcut to submit (optional accelerator)
+   - `/command` forwarding (auto-complete dropdown on `/`)
+   - Command history (Ctrl+Up / Ctrl+Down to cycle through previous messages)
+3. `StatusBar.tsx` - shows Claude's current status (spinner text)
+
+**Note on latency**: JSONL polling at 2s means responses appear in bursts, not token-by-token streaming. The status bar (polled at 1s) provides "Claude is working..." feedback during the gap. This matches ccbot's current latency. True streaming would require monitoring the JSONL file via inotify/watchdog instead of polling — a v2 enhancement.
+
+### Phase 4: Session Management
+The session sidebar replaces Telegram's topic list:
+1. `SessionSidebar.tsx`:
+   - List active sessions (window_id, name, cwd, status indicator)
+   - Click to switch (binds WebSocket to that window, loads history)
+   - **"+ New Session" button** → opens directory picker modal
+   - Kill session: X button on each session (confirms first)
+   - Session status: idle / working / waiting for input (based on status polling)
+2. `DirectoryPicker.tsx` (modal):
+   - File-tree browser (reuses ccbot's directory browsing logic on backend)
+   - Click folders to navigate, "Select" to confirm
+   - Path text input for direct entry
+   - Recent directories list (persisted in localStorage)
+   - Optional session name field
+3. Backend:
+   - `POST /api/sessions` → create_window + start Claude + wait for session_map
+   - `GET /api/sessions` → list active sessions
+   - `DELETE /api/sessions/{window_id}` → kill_window + cleanup
+   - WebSocket broadcasts session list changes to all connected clients
+
+### Phase 5: Interactive UI Components
+1. **Backend: structured UI parser** (`ui_parser.py`):
+   - `terminal_parser.py` returns raw text (`InteractiveUIContent.content` is a string with Unicode checkboxes like `☐✔☒`, cursor markers, etc.)
+   - New `ui_parser.py` module parses this raw text into structured data:
+     - AskUserQuestion: extract option labels + checked/unchecked state from `☐`/`✔`/`☒` markers
+     - PermissionPrompt: extract the action description + yes/no options
+     - ExitPlanMode: extract plan summary text
+     - BashApproval: extract the bash command being requested
+   - This is **fragile screen-scraping** — Claude Code can change its terminal UI format. The parser must be defensive with fallback to raw text display.
+   - WebSocket message includes both structured data (when parsing succeeds) AND raw text (always, as fallback)
+2. `InteractiveUI.tsx`:
+   - When `type: "interactive_ui"` arrives via WebSocket:
+     - If structured data present: render as clickable cards/buttons
+     - If only raw text: render in a `
` block with generic navigation buttons (like ccbot's keyboard)
+     - AskUserQuestion → clickable option cards
+     - PermissionPrompt → "Allow" / "Deny" buttons with action description
+     - ExitPlanMode → "Proceed" / "Edit" buttons
+   - Clicking sends `send_key` back (Enter, Escape, Space, Tab, arrows)
+   - **Stale UI guard**: before sending a key, backend re-captures the pane and verifies the interactive UI is still showing. If not, discard the click and notify the user "This prompt has expired."
+3. Backend: detect interactive UIs via `terminal_parser.py`, parse via `ui_parser.py`, send structured data + raw text over WebSocket
+
+### Phase 6: Decision Grid
+1. `DecisionGrid.tsx`:
+   - Renders grid items as cards in a table/grid layout
+   - Each item shows topic, description, and radio buttons for options
+   - Recommended option highlighted
+   - "Submit All" button
+   - Overlay/modal that slides over the message stream
+2. Backend: poll `.ccweb/pending/` directory alongside JSONL monitoring (same poll loop, no extra loop)
+   - On new file: validate JSON schema, send to frontend, move to `.ccweb/completed/` on submit
+   - On invalid JSON: log warning, move to `.ccweb/failed/`, notify frontend with error
+   - Stale file cleanup: files older than 1 hour in `pending/` are moved to `failed/`
+   - `.ccweb/pending/` directory is auto-created by the backend on session creation
+3. Define the Claude Code skill contract (JSON schema for decision files)
+4. **Timing**: the skill MUST call AskUserQuestion after writing the file to block Claude (see Detection Mechanism section above)
+
+### Phase 7: Command Palette & Skill Picker
+1. **Command auto-complete in MessageInput**:
+   - Typing `/` in the text input triggers a dropdown above the input
+   - Built-in commands shown with descriptions: `/clear` (Clear history), `/compact` (Compact context), `/model` (Switch model), `/fast` (Toggle fast mode), `/plan` (Enter plan mode), `/cost` (Show usage), `/help`, `/memory`, `/config`, etc.
+   - Click to insert and send, or keep typing to filter
+2. **Repo skills discovery**:
+   - Backend endpoint: `GET /api/sessions/{window_id}/skills`
+   - Reads `{session_cwd}/.claude/commands/` directory for custom slash commands
+   - Parses command files to extract name + description (first line of the file is typically the description)
+   - Frontend shows these in the command dropdown, grouped under "Project Commands" separately from "Built-in Commands"
+   - Skills refresh when switching sessions (different repos have different skills)
+3. **Dropdown command selector**: A `▾` button next to the text input that opens the full command/skill list without needing to type `/`. Provides discoverability — browse all available commands and skills via click. Same grouped list as the auto-complete (Built-in Commands | Project Commands).
+4. **Toolbar quick-actions**: Persistent buttons above the message input for frequent actions:
+   - `/esc` (interrupt Claude) — prominent, always visible
+   - Command palette button (opens full command/skill list)
+   - Screenshot button (captures terminal as image, useful for sharing)
+
+### Phase 8: Message Filters
+1. **Filter bar** at the top of the message stream with toggleable chips:
+   - **All** — everything (default)
+   - **Chat** — only user messages + assistant text (hides tool_use, tool_result, thinking)
+   - **No thinking** — everything except thinking blocks
+   - **Tools** — only tool_use + tool_result (see what Claude did without the prose)
+2. Filter state persisted per session (localStorage) — each session can have its own filter
+3. Implementation: each message already has `content_type` from the JSONL parse, so filtering is a simple frontend predicate on the message list. No backend changes needed.
+
+### Phase 9: Documentation Wiki
+**Single source of truth**: Markdown files in `ccweb/docs/` serve both as repo-readable docs AND as the in-app wiki. No content duplication.
+
+**Doc structure** (`ccweb/docs/`):
+```
+docs/
+├── index.md                  # Home page, links to all sections
+├── getting-started/
+│   ├── installation.md       # Prerequisites, install steps, first run
+│   ├── setup.md              # Tailscale setup, .env config, ccweb install
+│   └── quickstart.md         # Create your first session, send a message, see response
+├── configuration/
+│   ├── env-variables.md      # Full .env reference table (every variable, default, description)
+│   ├── preferences.md        # Web UI settings page options
+│   └── claude-code-setup.md  # SessionStart hook, Claude Code configuration
+├── features/
+│   ├── sessions.md           # Creating, switching, killing sessions
+│   ├── message-stream.md     # Message types, markdown rendering, auto-scroll
+│   ├── interactive-ui.md     # AskUserQuestion, permissions, plan mode buttons
+│   ├── option-grid.md        # Decision grid: how it works, skill usage, JSON schema
+│   ├── custom-interactions.md # Checklist, status report, confirm dialog
+│   ├── commands.md           # /command auto-complete, dropdown, built-in vs project commands
+│   ├── message-filters.md    # Filtering by content type
+│   ├── subagents.md          # Subagent activity tracking and display
+│   ├── diff-viewer.md        # File diff rendering
+│   └── keyboard-shortcuts.md # All shortcuts reference
+├── architecture/
+│   ├── overview.md           # System architecture diagram
+│   ├── design-plan.md        # This plan document (original design decisions & rationale)
+│   ├── backend.md            # FastAPI, WebSocket protocol, session management
+│   ├── frontend.md           # React components, state management
+│   ├── ccweb-protocol.md     # Custom interaction type markers, JSON schemas
+│   └── reused-from-ccbot.md  # What was reused from ccbot and how
+├── troubleshooting/
+│   ├── common-issues.md      # FAQ: can't connect, sessions not appearing, etc.
+│   ├── websocket.md          # WebSocket connection issues, reconnection
+│   ├── tmux.md               # Tmux session issues, window ID resolution
+│   └── logs.md               # Where to find logs, debug mode
+└── changelog.md              # Version history
+```
+
+**Every doc file has:**
+- YAML frontmatter: `title`, `description`, `order` (for sidebar sorting)
+- Table of contents (auto-generated from headings)
+- Internal links using relative paths: `[Option Grid](../features/option-grid.md)`
+- Works when browsing on GitHub/filesystem AND in the web UI
+
+**In-app wiki (`/wiki` route in frontend):**
+- `WikiPage.tsx` — renders a single doc page with react-markdown
+- `WikiSidebar.tsx` — navigation tree built from the docs/ directory structure
+- Backend: `GET /api/docs` returns the doc tree (filenames + frontmatter)
+- Backend: `GET /api/docs/{path}` returns raw markdown content for a specific file
+- Internal links rewritten to `#/wiki/...` routes in the web UI
+- Search: full-text search across all doc files (backend endpoint)
+- Breadcrumb navigation at top of each page
+
+### Phase 10: Responsive Design (Desktop + Tablet)
+Target: Chrome for Windows (desktop) + Chrome for Android on Samsung Z Fold 7 (tablet mode).
+
+1. **Layout breakpoints**:
+   - Desktop (>1024px): sidebar visible + main content area side by side
+   - Tablet (768-1024px): sidebar as hamburger drawer, full-width content
+   - The Z Fold 7 in tablet mode is ~7.6" at ~904px width → tablet layout
+2. **Touch-friendly**:
+   - All interactive elements minimum 44x44px touch targets
+   - Swipe gestures: swipe right to open sidebar, swipe left to close
+   - Decision grid options as large tap targets (cards, not tiny radio buttons)
+   - Submit button prominently sized
+3. **Input handling**:
+   - On tablet: virtual keyboard push-up handling (input stays visible above keyboard)
+   - Auto-resize text area as you type
+4. **CSS approach**: Tailwind CSS with responsive utilities (`md:`, `lg:` prefixes)
+   - Flexbox layout that reflows naturally
+   - No fixed pixel widths on content areas
+
+### Phase 11: Quality-of-Life Features
+
+1. **Browser notifications** (`useNotifications.ts`):
+   - Request notification permission on first visit
+   - Push notification when: Claude finishes a task (stop_reason set), interactive UI appears (AskUserQuestion, permission), subagent completes
+   - Only fires when the tab is not focused or screen is off
+   - Notification click brings you to the correct session
+   - On Android/tablet: works with Chrome's built-in notification system
+
+2. **Context & cost indicator** (`ContextBar.tsx`):
+   - Persistent badge in the status bar: "Context: 34% | $2.15"
+   - Backend periodically captures `/usage` output via terminal_parser's `parse_usage_output()`
+   - Or: parse the status line chrome (bottom bar of Claude Code pane shows model + context %)
+   - Color changes: green (<50%), yellow (50-80%), red (>80%)
+   - Click to expand full usage details (token counts, rate limits)
+
+3. **Session persistence on page reload**:
+   - On WebSocket connect, backend sends full session list + which session was last active
+   - Client stores last active session in localStorage
+   - On reconnect: auto-binds to last session, requests message history via `get_recent_messages()`
+   - Messages load from JSONL (the full history is always there), so no data loss on browser close/refresh
+
+4. **Copy buttons on code blocks** (`CodeBlock.tsx`):
+   - One-click copy icon (📋) on every fenced code block, inline code, diff output, and command output
+   - "Copied!" toast feedback
+   - For diffs: option to copy just the new content (without diff markers)
+   - Uses `navigator.clipboard.writeText()`
+
+5. **Connection status indicator**:
+   - Small badge in the top bar: 🟢 Connected | 🟡 Reconnecting | 🔴 Disconnected
+   - On disconnect: auto-reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s)
+   - On reconnect: replays any missed messages (backend tracks last-delivered offset per client)
+   - Manual "Reconnect" button when auto-reconnect fails
+
+6. **Session rename**:
+   - Double-click session name in sidebar → inline edit field
+   - Or right-click → "Rename" context menu
+   - Backend calls `tmux_manager.rename_window()` + updates display name in session state
+   - Updates reflected immediately across all connected clients
+
+7. **Export conversation** (`ExportButton.tsx`):
+   - Button in session header: "Export" → dropdown: Markdown / JSON / Plain Text
+   - **Markdown**: formatted conversation with headers, code blocks, tool summaries (ready for sharing)
+   - **JSON**: raw JSONL entries (for programmatic use)
+   - **Plain text**: stripped of all formatting
+   - Filters applied: export respects current message filter (e.g., "Chat only" exports only user+assistant text)
+   - Downloads as a file: `{session-name}-{date}.md`
+
+### Phase 12: Polish & UX
+1. Auto-scroll behavior (pause on scroll-up, resume on new messages)
+2. Expandable blocks for thinking/tool results (click to expand/collapse)
+3. Image rendering for tool_result images (base64 → inline img)
+4. WebSocket reconnection (auto-reconnect + replay missed messages)
+5. Keyboard shortcuts: Escape to interrupt, Ctrl+K for command palette, Ctrl+N for new session
+6. Dark/light theme toggle (default dark, matching terminal aesthetic)
+7. Notification badge on sessions with unread messages (when viewing a different session)
+
+## Technical Issues to Address (from review)
+
+1. **SessionMonitor single callback**: Wrap in a fan-out broadcaster. One callback registered, it iterates all connected WebSocket clients and sends. Single-user means this is simple.
+
+2. **Byte offset + WebSocket delivery**: Since single-user, offsets persist after `ws.send()` (fire-and-forget). On reconnect, client sends its last-received message timestamp; backend replays from that point via `get_recent_messages()` with byte offset.
+
+3. **WebSocket keepalive**: Add ping/pong frames (FastAPI/Starlette supports this natively with `websocket.send_json` + periodic pings). 30s interval.
+
+4. **Error message type**: Add `{"type": "error", "code": "...", "message": "..."}` to the WebSocket protocol for backend failures (tmux down, session_map corrupt, etc.).
+
+5. **`session_map.json` read race**: Use `fcntl.flock` for reads too (shared lock), matching the hook's exclusive lock on writes. Or: read with retry on `JSONDecodeError`.
+
+6. **`config.py` import chain**: The adapted `config.py` for ccweb does NOT import Telegram config. All "copy verbatim" modules import from `ccweb.backend.core.config` (the new one), not the old ccbot config. This breaks the Telegram dependency chain entirely.
+
+7. **WebSocket protocol additions**:
+   - `{"type": "ping"}` / `{"type": "pong"}` — keepalive
+   - `{"type": "error", "code": "...", "message": "..."}` — backend errors
+   - `{"type": "replay_request", "since_timestamp": "..."}` — client reconnection catch-up
+   - `{"type": "replay", "messages": [...]}` — server replay response
+
+## V2 Roadmap (deferred features)
+
+Saved as `ccweb/docs/architecture/v2-roadmap.md` alongside the design plan.
+
+### Rich Tool Output (from Phase 7.5, deferred)
+- **File diff viewer** (`DiffViewer.tsx`): Edit tool diffs rendered with green/red line highlighting, collapsible per file
+- **Progress tracker** (`ProgressTracker.tsx`): TodoWrite rendered as persistent checkbox panel, pinned above message stream
+- **Search results cards** (`SearchResults.tsx`): WebSearch as cards with title/snippet/link, Grep as highlighted matches
+- **Destructive action warning**: Permission prompts for `rm`, `reset --hard` etc. rendered with red highlight border
+
+### Subagent Activity Tracking (from Phase 7.6, deferred)
+- Collapsible task cards in message stream (header: "Agent: {description}" with running/complete indicator)
+- Subagent status badge ("2 agents running")
+- Future: monitor subagent JSONL files directly for real-time streaming
+
+### Saved Prompts Library (from Phase 8, deferred)
+- Prompt Library UI with title, category, preview, fuzzy search
+- Variable placeholders: `{{filename}}`, `{{description}}` → form fill on insert
+- Global prompts (`~/.ccweb/prompts/`) + per-project prompts (`{project}/.ccweb/prompts/`)
+- CRUD API endpoints
+
+### Creative UX Explorations
+Ideas from the creative review to explore for v2:
+- **Message threading**: Nest question/answer exchanges visually (Slack-style threads)
+- **Optimistic input**: Show user's message immediately with pending indicator before tmux round-trip
+- **Session timeline scrubber**: Horizontal minimap of session phases (thinking/tool use/conversation/idle), click to jump
+- **Live file preview pane**: Split view showing file's current state after Edit, not just the diff
+- **Pinned messages**: Pin key decisions/outputs to the top of a session
+- **Session heatmap**: Which sessions were active when, token spend per session
+- **Quick reactions on tool results**: Thumbs-up/flag individual outputs as feedback
+- **Minimap**: VS Code-style vertical scroll overview of message density and type
+- **Keyboard-first navigation**: Linear-style shortcuts (G+S for sessions, G+P for prompts)
+- **Stackable message filters**: Combine filters (e.g., "chat + tools but not thinking")
+
+### Decision Grid v2 Improvements
+- **Keyboard navigation**: Arrow keys between cells, Enter to select, Tab to notes column
+- **Comparison mode**: Select two options, see side-by-side with differences highlighted
+- **Fuzzy search in saved prompts**: Raycast/Alfred-style instant matching
+- **Collapsible file-change groups**: GitHub-style when diffs are numerous
+
+## Key Files to Create
+
+| File | Purpose |
+|------|---------|
+| `ccweb/pyproject.toml` | Python package: fastapi, uvicorn, libtmux, aiofiles, dotenv |
+| `ccweb/backend/core/__init__.py` | Core module init |
+| `ccweb/backend/core/tmux_manager.py` | Copy from ccbot |
+| `ccweb/backend/core/terminal_parser.py` | Copy from ccbot |
+| `ccweb/backend/core/transcript_parser.py` | Adapted from ccbot |
+| `ccweb/backend/core/session_monitor.py` | Copy from ccbot |
+| `ccweb/backend/core/monitor_state.py` | Copy from ccbot |
+| `ccweb/backend/core/hook.py` | Copy from ccbot |
+| `ccweb/backend/core/utils.py` | Copy from ccbot |
+| `ccweb/backend/config.py` | Web-specific configuration |
+| `ccweb/backend/session.py` | Simplified session management |
+| `ccweb/backend/server.py` | FastAPI + WebSocket server |
+| `ccweb/backend/ws_protocol.py` | Message type definitions |
+| `ccweb/backend/main.py` | CLI entry point |
+| `ccweb/frontend/package.json` | React app dependencies |
+| `ccweb/frontend/src/App.tsx` | Main layout |
+| `ccweb/frontend/src/protocol.ts` | WebSocket message types |
+| `ccweb/frontend/src/hooks/useWebSocket.ts` | WebSocket hook |
+| `ccweb/frontend/src/components/MessageStream.tsx` | Message display |
+| `ccweb/frontend/src/components/MessageInput.tsx` | Text input |
+| `ccweb/frontend/src/components/SessionSidebar.tsx` | Session list |
+| `ccweb/frontend/src/components/InteractiveUI.tsx` | Interactive prompts |
+| `ccweb/frontend/src/components/DecisionGrid.tsx` | Option grid |
+| `ccweb/frontend/src/components/StatusBar.tsx` | Status display |
+| `ccweb/frontend/src/components/ExpandableBlock.tsx` | Collapsible content |
+
+## Startup Health Checks & Error UX
+
+On startup, `ccweb` runs validation before accepting connections:
+
+1. **tmux running?** — Check `tmux list-sessions`. If not: print clear error "tmux is not running. Start tmux first: `tmux new -s ccbot`"
+2. **Hook installed?** — Check `~/.claude/settings.json` for SessionStart hook pointing to `ccweb hook`. If not: print warning "SessionStart hook not installed. Run `ccweb install` first. Sessions will not be monitored."
+3. **State directory exists?** — Create `~/.ccweb/` if missing.
+4. **Config valid?** — Validate .env loaded, required vars present.
+
+On WebSocket connect, send a `{"type": "health", "status": {...}}` message with:
+- `tmux_running: bool`
+- `hook_installed: bool`
+- `sessions_found: int`
+- `warnings: string[]`
+
+Frontend renders a diagnostic banner for any issues: "Hook not installed — run `ccweb install`" with a one-click fix button (calls backend endpoint that installs the hook).
+
+**Error states in the UI**:
+- tmux not running → red banner: "tmux is not running"
+- WebSocket disconnected → yellow banner with reconnect button
+- Session killed externally → session shows "(ended)" in sidebar with explanation
+- Hook not installed → orange banner with install button
+- No sessions → helpful empty state: "No sessions yet. Click + New Session to start."
+
+## Backend Server Design (`server.py`)
+
+```python
+# FastAPI app serves:
+# - GET / → React static files (production) or proxy to Vite dev server
+# - WebSocket /ws → bidirectional real-time communication
+# - GET /api/sessions → list active sessions
+# - POST /api/sessions → create new session
+# - DELETE /api/sessions/{window_id} → kill session
+
+# On startup:
+# 1. Initialize TmuxManager, SessionManager, SessionMonitor
+# 2. Start SessionMonitor polling loop
+# 3. Start status polling loop (1s interval, like ccbot)
+# 4. Set message callback to broadcast to connected WebSocket clients
+
+# WebSocket handler:
+# - On connect: send current session list + bind to active window
+# - On message: dispatch by type (send_text, send_key, submit_decisions, etc.)
+# - On disconnect: clean up client binding
+
+# Message callback (from SessionMonitor):
+# - For each NewMessage, find connected clients bound to that window
+# - Serialize and send via WebSocket
+# - For AskUserQuestion/ExitPlanMode tool_use: capture terminal, 
+#   parse interactive UI, send structured data instead of raw text
+```
+
+## Hosting & Remote Access
+
+The FastAPI server serves both the API (WebSocket + REST) and the built React static files on a **single port** (default 8765). No separate frontend hosting needed.
+
+**With Tailscale (user's current setup):**
+- Server binds to `0.0.0.0:8765`
+- Access from any device on the tailnet: `http://:8765`
+- Tailscale ACLs control access (no separate auth needed if tailnet is trusted)
+- Optional HTTPS: `tailscale cert` + uvicorn SSL config, or `tailscale serve --bg 8765` for automatic HTTPS at `https://.tail-net.ts.net`
+
+**Server config** (`.env` file in `~/.ccweb/` or env vars — set once, requires restart):
+```bash
+CCWEB_HOST=0.0.0.0          # Bind address
+CCWEB_PORT=8765              # Port
+CCWEB_AUTH_TOKEN=            # Optional bearer token (empty = no auth, rely on Tailscale)
+TMUX_SESSION_NAME=ccbot      # Tmux session name
+CLAUDE_COMMAND=claude         # Command to start Claude Code
+CCWEB_BROWSE_ROOT=           # Starting dir for session browser (empty = home)
+MONITOR_POLL_INTERVAL=2.0    # Seconds between JSONL polls
+CCWEB_SHOW_HIDDEN_DIRS=false # Show dot-dirs in browser
+# Memory monitoring
+CCWEB_MEMORY_MONITOR=true
+CCWEB_MEM_AVAIL_WARN_MB=1024
+CCWEB_MEM_AVAIL_INTERRUPT_MB=512
+CCWEB_MEM_AVAIL_KILL_MB=256
+```
+
+**User preferences** (settings page in the web UI — changeable live, persisted to `~/.ccweb/preferences.json`):
+- Theme: dark / light
+- Default message filter: All / Chat / No thinking / Tools
+- Show hidden directories in browser
+- Auto-scroll behavior (pause on scroll-up, resume on new messages)
+- Submit shortcut: Ctrl+Enter / Cmd+Enter toggle
+
+The settings page is accessible via a gear icon in the sidebar. Changes take effect immediately without restart.
+
+**Production serving:**
+- `uvicorn ccweb.backend.main:app --host 0.0.0.0 --port 8765`
+- React app built with `npm run build` → static files served by FastAPI's `StaticFiles` mount
+- In dev: Vite dev server on :5173 proxies API calls to FastAPI on :8765
+- **IMPORTANT**: Vite proxy must explicitly enable WebSocket: `server.proxy["/ws"] = { target: "ws://localhost:8765", ws: true }` in `vite.config.ts`. Without `ws: true`, WebSocket upgrade handshake fails silently.
+
+## Per-Project Setup (What a new repo needs)
+
+**Short answer**: Run `ccweb install` once, and new projects work automatically.
+
+**What `ccweb install` sets up globally (one time):**
+- `~/.claude/settings.json` → SessionStart hook (writes session_map.json when Claude starts)
+- `~/.claude/commands/option-grid.md` → option grid slash command
+- `~/.claude/commands/checklist.md`, `status-report.md`, `confirm.md` → other interaction commands
+
+**What a new project gets for free (zero setup):**
+- Session creation via ccweb's directory browser
+- Real-time message streaming
+- All interactive UI handling (AskUserQuestion, permissions, plan mode)
+- All built-in /commands and the global ccweb skills
+- File upload, message filters, subagent tracking, diff viewer, etc.
+
+**Optional per-project enhancements** (add to the project's `CLAUDE.md` if you want Claude to proactively use ccweb features):
+```markdown
+## CCWeb Integration
+- When presenting multiple options/decisions to the user, use the /option-grid command
+- When reporting build/test status, use the /status-report command
+- For critical destructive actions, use the /confirm command before proceeding
+```
+
+**Optional per-project prompts** (for the Saved Prompts library):
+- Create `{project}/.ccweb/prompts/deploy.md`, `review.md`, etc.
+- These show up in the prompt library only when that project's session is active
+
+**Per-project CCWeb instructions** (avoids mutating CLAUDE.md directly):
+Instead of writing into CLAUDE.md (which creates git noise, merge conflicts, and meaningless diffs), ccweb creates a separate `.ccweb/instructions.md` file that CLAUDE.md can reference:
+
+1. **On session creation**: If the project has no `.ccweb/instructions.md`, a subtle banner appears: "Enable CCWeb features for this project?" → click to create.
+2. **"Setup CCWeb" button**: Available in the session sidebar (per-session gear icon). One click creates/updates `.ccweb/instructions.md`.
+3. **The file** contains ccweb-specific instructions for Claude:
+```markdown
+## CCWeb Integration
+- When presenting multiple options/decisions to the user, use /option-grid
+- When reporting build/test/deploy status, use /status-report
+- For interactive checklists, use /checklist
+- For critical destructive actions, use /confirm before proceeding
+```
+4. **CLAUDE.md reference** (optional, user adds manually if they want): Add one line to CLAUDE.md: `See @.ccweb/instructions.md for CCWeb integration.`
+5. `.ccweb/` can be `.gitignore`d if the user doesn't want it tracked — no git noise.
+6. **Backend**: `POST /api/sessions/{window_id}/setup-ccweb` → creates `.ccweb/instructions.md` in the session's working directory.
+
+**Summary**: A bare repo with no ccweb-specific files works perfectly. The CLAUDE.md management is an optional convenience — one click to enable, auto-updated as ccweb evolves, and clearly delimited so it doesn't interfere with your own CLAUDE.md content.
+
+## Portability
+
+The `ccweb/` folder is **completely self-contained** — no imports from the parent ccbot-workshop repo. All reused modules are copied into `ccweb/backend/core/`. To make it a standalone repo:
+
+1. Copy `ccweb/` to a new location
+2. `git init` → it's a new repo
+3. Has its own `pyproject.toml`, `package.json`, README, docs, etc.
+4. No changes needed — everything works out of the box
+
+The only shared resource is the tmux session itself. State is fully separate:
+- ccbot uses `~/.ccbot/` (state.json, session_map.json, monitor_state.json)
+- ccweb uses `~/.ccweb/` (its own state.json, session_map.json, monitor_state.json)
+- Both can monitor the same tmux windows without conflict (read-only JSONL access)
+- The SessionStart hook must be `ccweb hook` (writes to `~/.ccweb/`), not `ccbot hook`
+- If running both simultaneously, install BOTH hooks in `~/.claude/settings.json`
+
+## Key Reusable Methods from session.py
+
+The adapted `session.py` will keep these critical methods (changing only the binding model):
+- `resolve_stale_ids()` — re-resolve window IDs after tmux restart
+- `load_session_map()` — read hook-generated session_map.json
+- `get_window_state()` / `clear_window_session()` — window state management
+- `send_to_window()` — send text + auto-resume Claude if exited
+- `get_recent_messages()` — retrieve history with byte-range support
+- `resolve_session_for_window()` — window_id → ClaudeSession resolution
+- `wait_for_session_map_entry()` — poll for hook to fire after window creation
+- `find_users_for_session()` → renamed `find_clients_for_session()` — route incoming messages to connected WebSocket clients
+
+**Binding model change**: Replace `thread_bindings: dict[int, dict[int, str]]` (user_id → {thread_id → window_id}) with `client_bindings: dict[str, str]` (client_id → window_id). Each WebSocket connection has a unique client_id and can be bound to one window at a time.
+
+## Verification Plan
+
+1. **Backend standalone**: Start server, connect WebSocket client (wscat), verify session list, create session, send text, receive messages
+2. **Frontend dev**: `npm run dev` in frontend/, verify React app loads, WebSocket connects
+3. **End-to-end**: Create session via UI, send a message, see Claude's response stream in, interact with AskUserQuestion via clickable options
+4. **Decision grid**: Manually create a test `.ccweb/decisions/test.json`, verify it renders in the frontend, submit selections, verify text arrives in Claude's tmux pane
+5. **Linting**: `ruff check` + `ruff format` + `pyright` on all Python files in `ccweb/backend/`
diff --git a/ccweb/docs/architecture/session-history.md b/ccweb/docs/architecture/session-history.md
new file mode 100644
index 00000000..47e79c70
--- /dev/null
+++ b/ccweb/docs/architecture/session-history.md
@@ -0,0 +1,110 @@
+---
+title: Build Session History
+description: History of the original build session — decisions, reviews, and lessons learned
+order: 4
+---
+
+# Build Session History
+
+This document captures the history of the original Claude Code session that built CCWeb, so a new session can pick up where we left off with full context.
+
+## Origin
+
+CCWeb was designed as a replacement for CCBot's Telegram interface. The user wanted a richer browser-based experience for interacting with Claude Code sessions running in tmux, specifically:
+- A styled message stream instead of raw terminal output
+- Clickable interactive UI (instead of terminal arrow-key navigation)
+- A decision grid system for batch choices
+- Session management similar to Telegram topics
+
+The existing CCBot codebase at `/home/user/ccbot-workshop/src/ccbot/` was used as a reference. Seven core modules were **forked** (not copied verbatim — all needed import path changes) into `ccweb/ccweb/backend/core/`.
+
+## Build Phases Completed
+
+All 12 phases from the design plan are complete:
+
+| Phase | What | Status |
+|-------|------|--------|
+| 1 | Backend scaffold (FastAPI, WebSocket, forked core modules) | Done |
+| 2 | React frontend scaffold (Vite, TypeScript, Tailwind) | Done |
+| 3 | Message stream, file upload, text input, status bar | Done |
+| 4 | Session management (sidebar, directory picker with file tree) | Done |
+| 5 | Interactive UI (AskUserQuestion, permissions, plan mode as buttons) | Done |
+| 6 | Decision grid (file-based detection, AskUserQuestion blocking) | Done |
+| 7 | Command palette (/ auto-complete, skill discovery, dropdown) | Done |
+| 8 | Message filters (All/Chat/No Thinking/Tools) | Done |
+| 9 | Documentation wiki (in-app rendering of docs/ markdown) | Done |
+| 10 | Responsive design (tablet drawer, swipe, touch targets) | Done |
+| 11 | QoL (notifications, context %, rename, export, persistence) | Done |
+| 12 | Polish (auto-scroll, expandable blocks, images, reconnect, shortcuts) | Done |
+
+## Key Architectural Decisions
+
+1. **Forked modules, not shared package**: Core modules (`tmux_manager`, `session_monitor`, `terminal_parser`, etc.) were copied and adapted from ccbot, not imported. This ensures ccweb is self-contained and can be a standalone repo. All use `~/.ccweb/` for state (not `~/.ccbot/`).
+
+2. **File-based decision grids**: The original plan used text markers (``) in Claude's output. Adversarial review identified this as fragile (LLM output is unreliable). Changed to file-based: Claude writes JSON to `.ccweb/pending/`, backend polls the directory. The skill MUST call `AskUserQuestion` after writing the file to block Claude until the user responds.
+
+3. **Single user, single browser session**: No multi-user auth. WebSocket client_bindings are ephemeral. Interactive UI responses are last-click-wins.
+
+4. **Per-project instructions via `.ccweb/instructions.md`**: Instead of mutating CLAUDE.md (causes git noise), ccweb creates a separate file that CLAUDE.md can reference. Can be .gitignored.
+
+5. **Separate state directories**: ccbot uses `~/.ccbot/`, ccweb uses `~/.ccweb/`. Both can coexist monitoring the same tmux sessions. The SessionStart hook must be `ccweb hook` (writes to `~/.ccweb/session_map.json`).
+
+## Adversarial Review Process
+
+The codebase went through **multiple rounds of three-agent adversarial code review**. Each round used three independent agents (backend, frontend, integration) that read the actual code from scratch with no context from previous rounds. Reviews continued until all three agents passed clean simultaneously.
+
+### Total bugs found and fixed across all review rounds: 40+
+
+Key categories of bugs caught:
+- **Missing dependency**: `python-multipart` not in pyproject.toml (app wouldn't start)
+- **Package structure**: wrong `packages` path in pyproject.toml (empty wheel)
+- **Security**: path traversal in file upload (filename with `../`), browse endpoint without containment
+- **Protocol gaps**: decision_grid handler missing in frontend, dead WsReplay code
+- **Race conditions**: grid file rename during concurrent access, double switch_session, stale closures
+- **State management**: phantom WindowState entries, session_map wipe on empty valid_wids, externally killed session leaving stale UI
+- **UX**: hamburger button z-index above modals, CommandPalette Enter interception, StrictMode toggle
+
+## What a New Session Needs to Know
+
+### To run the project
+```bash
+# Backend
+cd ccweb
+pip install -e .
+ccweb install    # Install hook + global commands
+ccweb            # Start server on :8765
+
+# Frontend (dev)
+cd ccweb/frontend
+npm install
+npm run dev      # Vite dev server on :5173 (proxies to :8765)
+```
+
+### To make changes
+- Backend Python: `ccweb/ccweb/backend/` — run `uv run ruff check` and `ruff format`
+- Frontend TypeScript: `ccweb/frontend/src/` — run `npx tsc -b --noEmit` and `npx vite build`
+- **When changing any feature, update the corresponding doc in `docs/`** (see CLAUDE.md)
+
+### Key files
+- `ccweb/CLAUDE.md` — project instructions for Claude Code
+- `docs/architecture/design-plan.md` — the full design plan with all decisions
+- `docs/architecture/v2-roadmap.md` — deferred features by category
+- `docs/architecture/deferred-items.md` — prioritized grid with effort/usefulness estimates
+- `ccweb/ccweb/backend/server.py` — the main FastAPI server (largest file, ~850 lines)
+- `ccweb/ccweb/backend/ws_protocol.py` — all WebSocket message type definitions
+- `ccweb/frontend/src/protocol.ts` — TypeScript mirror of ws_protocol.py
+- `ccweb/frontend/src/App.tsx` — main React component (wires everything together)
+- `ccweb/frontend/src/hooks/useSession.ts` — session state management
+- `ccweb/frontend/src/hooks/useWebSocket.ts` — WebSocket connection + reconnect
+
+### Known limitations
+- **2-second message latency**: JSONL polling interval. inotify streaming is item #14 in deferred items.
+- **No streaming**: Messages appear in bursts, not token-by-token. Status bar provides "working" feedback.
+- **Fragile interactive UI parsing**: `ui_parser.py` screen-scrapes terminal text for checkbox markers. Falls back to raw text if parsing fails. Will break if Claude Code changes its UI format.
+- **Tool output is `
` blocks**: No rich diff viewer, search cards, or progress tracker yet (deferred items #1-4).
+
+### Things that are NOT bugs (came up in reviews)
+- `ccbot` in tmux session name references is intentional (shared tmux session)
+- Infinite WebSocket reconnect (no "disconnected" terminal state) is by design
+- `user_window_offsets` in session.py is dead code (never read/written) — harmless
+- Module-level `msgCounter` in useSession.ts grows monotonically — intentional for unique React keys
diff --git a/ccweb/docs/architecture/v2-roadmap.md b/ccweb/docs/architecture/v2-roadmap.md
new file mode 100644
index 00000000..7a79ac89
--- /dev/null
+++ b/ccweb/docs/architecture/v2-roadmap.md
@@ -0,0 +1,78 @@
+---
+title: V2 Roadmap
+description: Deferred features and future enhancements for CCWeb
+order: 2
+---
+
+# CCWeb V2 Roadmap
+
+Features deferred from v1, organized by category.
+
+## Table of Contents
+
+- [Rich Tool Output](#rich-tool-output)
+- [Subagent Activity Tracking](#subagent-activity-tracking)
+- [Saved Prompts Library](#saved-prompts-library)
+- [Creative UX Explorations](#creative-ux-explorations)
+- [Decision Grid v2 Improvements](#decision-grid-v2-improvements)
+- [Performance: Streaming via inotify](#performance-streaming-via-inotify)
+
+---
+
+## Rich Tool Output
+
+Replace `
` blocks with interactive, tool-specific rendering:
+
+- **File diff viewer** (`DiffViewer.tsx`): Edit tool diffs rendered with green/red line highlighting, collapsible per file. One of the most frequent tool outputs — huge UX improvement.
+- **Progress tracker** (`ProgressTracker.tsx`): TodoWrite rendered as persistent checkbox panel, pinned above message stream. Updates in real-time as Claude marks items complete.
+- **Search results cards** (`SearchResults.tsx`): WebSearch as cards with title/snippet/link. Grep as file paths with highlighted match lines. Glob as file tree-style list.
+- **Destructive action warning**: Permission prompts for `rm`, `reset --hard`, `drop`, `delete` rendered with red highlight border and explicit warning text.
+
+## Subagent Activity Tracking
+
+Better visibility into Claude's Agent/Task tool usage:
+
+- Collapsible task cards in message stream (header: "Agent: {description}" with running/complete indicator)
+- Visually nested/indented from the parent conversation
+- Subagent status badge ("2 agents running") that updates in real-time
+- Future: monitor subagent JSONL files directly for real-time streaming of subagent activity (not just final results)
+
+## Saved Prompts Library
+
+System for creating, storing, and reusing frequently-used prompts:
+
+- **Prompt Library UI** with title, category, preview, fuzzy search (Raycast/Alfred-style)
+- **Variable placeholders**: `{{filename}}`, `{{description}}` — form pops up on insert to fill them in
+- **Global prompts** in `~/.ccweb/prompts/` + **project prompts** in `{project}/.ccweb/prompts/`
+- CRUD API endpoints: `GET/POST/PUT/DELETE /api/prompts`
+
+## Creative UX Explorations
+
+Ideas from design review to explore:
+
+- **Message threading**: Nest question/answer exchanges visually (Slack-style threads)
+- **Optimistic input**: Show user's message immediately with pending indicator before tmux round-trip
+- **Session timeline scrubber**: Horizontal minimap of session phases (thinking/tool use/conversation/idle), click to jump
+- **Live file preview pane**: Split view showing file's current state after Edit, not just the diff
+- **Pinned messages**: Pin key decisions/outputs to the top of a session
+- **Session heatmap**: Which sessions were active when, token spend per session
+- **Quick reactions on tool results**: Thumbs-up/flag individual outputs as feedback
+- **Minimap**: VS Code-style vertical scroll overview of message density and type
+- **Keyboard-first navigation**: Linear-style shortcuts (G+S for sessions, G+P for prompts)
+- **Stackable message filters**: Combine filters (e.g., "chat + tools but not thinking")
+
+## Decision Grid v2 Improvements
+
+- **Keyboard navigation**: Arrow keys between cells, Enter to select, Tab to notes column
+- **Comparison mode**: Select two options, see side-by-side with differences highlighted
+- **"Explain this option" button**: Per-row button that asks Claude for elaboration without losing context
+- **Collapsible file-change groups**: GitHub-style when diffs are numerous
+
+## Performance: Streaming via inotify
+
+Replace 2-second JSONL polling with file-system event monitoring:
+
+- Use `watchdog` or `inotify` to detect JSONL file changes instantly
+- Reduces response latency from 2s+ to near-instant
+- Status bar already provides 1s feedback, but streaming content would be dramatically better
+- Requires careful handling of partial writes (same partial-line logic as current monitor)
diff --git a/ccweb/docs/features/interactive-ui.md b/ccweb/docs/features/interactive-ui.md
new file mode 100644
index 00000000..7ab512d0
--- /dev/null
+++ b/ccweb/docs/features/interactive-ui.md
@@ -0,0 +1,29 @@
+---
+title: Interactive UI
+description: Handling Claude Code prompts — permissions, questions, plan mode
+order: 2
+---
+
+# Interactive UI
+
+When Claude Code displays an interactive prompt (AskUserQuestion, permission request, plan mode), CCWeb renders it as clickable HTML components instead of requiring terminal keyboard navigation.
+
+## AskUserQuestion
+
+Rendered as clickable option cards. Click an option to select it, then click **Submit**.
+
+## Permission Prompts
+
+Rendered as **Allow** / **Deny** buttons with the action description shown. For bash commands, the command is displayed in a code block.
+
+## Plan Mode (ExitPlanMode)
+
+Rendered as **Proceed** / **Edit Plan** buttons with the plan summary displayed as text.
+
+## Fallback
+
+If CCWeb can't parse the terminal UI into structured data (e.g., a new Claude Code UI format), it falls back to displaying the raw terminal text with generic navigation buttons (Space, Up, Down, Enter, Escape, Tab).
+
+## Stale UI Guard
+
+If you click a button after the prompt has already been dismissed (e.g., Claude timed out), CCWeb detects this and shows "This prompt has expired" instead of sending a blind keystroke.
diff --git a/ccweb/docs/features/sessions.md b/ccweb/docs/features/sessions.md
new file mode 100644
index 00000000..6466b6ec
--- /dev/null
+++ b/ccweb/docs/features/sessions.md
@@ -0,0 +1,34 @@
+---
+title: Sessions
+description: Creating, switching, and managing Claude Code sessions
+order: 1
+---
+
+# Sessions
+
+Each session is a Claude Code instance running in a tmux window, bound to a project directory.
+
+## Creating a Session
+
+1. Click **+ New** in the sidebar or press **Ctrl+N**
+2. Browse to your project directory using the file tree
+3. Optionally enter a session name
+4. Click **Create Session**
+
+The session appears in the sidebar and is auto-selected.
+
+## Switching Sessions
+
+Click any session in the sidebar to switch to it. The message stream loads that session's history.
+
+## Killing a Session
+
+Click the **x** button on a session in the sidebar. This kills the tmux window and the Claude Code process in it.
+
+## Session Status
+
+Sessions show their working directory below the name. The sidebar updates in real-time as sessions are created or killed.
+
+## Health Warnings
+
+If tmux is not running or the SessionStart hook is not installed, warning banners appear at the top of the sidebar with instructions to fix the issue.
diff --git a/ccweb/docs/getting-started/installation.md b/ccweb/docs/getting-started/installation.md
new file mode 100644
index 00000000..b2fac7cc
--- /dev/null
+++ b/ccweb/docs/getting-started/installation.md
@@ -0,0 +1,55 @@
+---
+title: Installation
+description: Prerequisites and setup instructions for CCWeb
+order: 1
+---
+
+# Installation
+
+## Prerequisites
+
+- Python 3.12+
+- Node.js 20+
+- tmux
+- Claude Code CLI installed and configured
+
+## Install CCWeb
+
+```bash
+cd ccweb
+pip install -e .
+```
+
+## Install Hook & Commands
+
+```bash
+ccweb install
+```
+
+This installs:
+- The `SessionStart` hook in `~/.claude/settings.json`
+- Global slash commands (`/option-grid`, `/checklist`, `/status-report`, `/confirm`) in `~/.claude/commands/`
+
+## Configure
+
+Create `~/.ccweb/.env` with your settings:
+
+```bash
+CCWEB_HOST=0.0.0.0
+CCWEB_PORT=8765
+TMUX_SESSION_NAME=ccbot
+```
+
+See [Environment Variables](../configuration/env-variables.md) for the full reference.
+
+## Start
+
+```bash
+# Start tmux session first
+tmux new -s ccbot
+
+# In another terminal, start CCWeb
+ccweb
+```
+
+Open your browser to `http://localhost:8765`.
diff --git a/ccweb/docs/getting-started/quickstart.md b/ccweb/docs/getting-started/quickstart.md
new file mode 100644
index 00000000..2349bead
--- /dev/null
+++ b/ccweb/docs/getting-started/quickstart.md
@@ -0,0 +1,36 @@
+---
+title: Quick Start
+description: Create your first Claude Code session in CCWeb
+order: 2
+---
+
+# Quick Start
+
+## 1. Create a Session
+
+Click **+ New** in the sidebar, or press **Ctrl+N**. The directory picker opens — browse to your project folder and click **Create Session**.
+
+Claude Code launches in a new tmux window for that directory.
+
+## 2. Send a Message
+
+Type in the message input area at the bottom. Press **Ctrl+Enter** or click **Send** to submit.
+
+Claude's responses appear in the message stream with markdown rendering.
+
+## 3. Use Commands
+
+Type `/` to open the command palette, or click the **/ Commands** button. Built-in commands like `/clear`, `/compact`, `/model` are available, plus any project-specific commands from `.claude/commands/`.
+
+## 4. Interactive Prompts
+
+When Claude asks a question (AskUserQuestion, permission prompts, plan mode), clickable buttons appear instead of terminal navigation. Click to respond.
+
+## 5. Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| Ctrl+Enter | Send message |
+| Ctrl+N | New session |
+| Ctrl+Up/Down | Command history |
+| Esc button | Interrupt Claude |
diff --git a/ccweb/docs/index.md b/ccweb/docs/index.md
new file mode 100644
index 00000000..e235eafd
--- /dev/null
+++ b/ccweb/docs/index.md
@@ -0,0 +1,31 @@
+---
+title: CCWeb Documentation
+description: React web gateway to Claude Code sessions via tmux
+order: 0
+---
+
+# CCWeb Documentation
+
+Welcome to CCWeb — a browser-based interface for Claude Code sessions running in tmux.
+
+## Getting Started
+
+- [Installation](getting-started/installation.md) — prerequisites and setup
+- [Quick Start](getting-started/quickstart.md) — create your first session
+
+## Features
+
+- [Sessions](features/sessions.md) — creating, switching, and managing sessions
+- [Interactive UI](features/interactive-ui.md) — AskUserQuestion, permissions, plan mode
+- [Option Grid](features/option-grid.md) — decision grids for batch choices
+- [Commands](features/commands.md) — slash command palette and skill discovery
+- [Message Filters](features/message-filters.md) — filter by content type
+
+## Architecture
+
+- [Design Plan](architecture/design-plan.md) — original design decisions
+- [V2 Roadmap](architecture/v2-roadmap.md) — deferred features
+
+## Troubleshooting
+
+- [Common Issues](troubleshooting/common-issues.md) — FAQ and fixes
diff --git a/ccweb/docs/troubleshooting/common-issues.md b/ccweb/docs/troubleshooting/common-issues.md
new file mode 100644
index 00000000..d9b68eb9
--- /dev/null
+++ b/ccweb/docs/troubleshooting/common-issues.md
@@ -0,0 +1,41 @@
+---
+title: Common Issues
+description: FAQ and troubleshooting for CCWeb
+order: 1
+---
+
+# Common Issues
+
+## "tmux is not running"
+
+Start a tmux session before running CCWeb:
+
+```bash
+tmux new -s ccbot
+```
+
+## Sessions not appearing
+
+Make sure the SessionStart hook is installed:
+
+```bash
+ccweb install
+```
+
+Check `~/.claude/settings.json` has a `SessionStart` hook entry pointing to `ccweb hook`.
+
+## WebSocket disconnected
+
+The connection status indicator in the bottom bar shows the current state. CCWeb auto-reconnects with exponential backoff. If it stays disconnected, check that the backend is running.
+
+## No messages appearing
+
+Messages are delivered via JSONL polling (2-second interval). If messages don't appear:
+
+1. Check the session is bound correctly (click it in the sidebar)
+2. Check the hook wrote to `~/.ccweb/session_map.json`
+3. Check Claude Code is actually running in the tmux window
+
+## File upload fails
+
+Ensure the session has a known working directory (the hook must have fired). The file is saved to `{cwd}/docs/inbox/` in the session's project.
diff --git a/ccweb/frontend/.gitignore b/ccweb/frontend/.gitignore
new file mode 100644
index 00000000..b9470778
--- /dev/null
+++ b/ccweb/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/ccweb/frontend/index.html b/ccweb/frontend/index.html
new file mode 100644
index 00000000..b98c287f
--- /dev/null
+++ b/ccweb/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+  
+    
+    
+    CCWeb
+  
+  
+    
+ + + diff --git a/ccweb/frontend/package-lock.json b/ccweb/frontend/package-lock.json new file mode 100644 index 00000000..b921fc65 --- /dev/null +++ b/ccweb/frontend/package-lock.json @@ -0,0 +1,2864 @@ +{ + "name": "frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.8" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ccweb/frontend/package.json b/ccweb/frontend/package.json new file mode 100644 index 00000000..348b84e2 --- /dev/null +++ b/ccweb/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "ccweb-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.8" + } +} diff --git a/ccweb/frontend/src/App.tsx b/ccweb/frontend/src/App.tsx new file mode 100644 index 00000000..f18a4c41 --- /dev/null +++ b/ccweb/frontend/src/App.tsx @@ -0,0 +1,437 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWebSocket } from "./hooks/useWebSocket"; +import { filterMessages, useSession } from "./hooks/useSession"; +import { SessionSidebar } from "./components/SessionSidebar"; +import { MessageStream } from "./components/MessageStream"; +import { MessageInput } from "./components/MessageInput"; +import { StatusBar } from "./components/StatusBar"; +import { InteractiveUI } from "./components/InteractiveUI"; +import { DecisionGrid } from "./components/DecisionGrid"; +import { FilterBar } from "./components/FilterBar"; +import { FileUpload } from "./components/FileUpload"; +import { DirectoryPicker } from "./components/DirectoryPicker"; +import { WikiSidebar } from "./components/WikiSidebar"; +import { WikiPage } from "./components/WikiPage"; +import { + BUILTIN_COMMANDS, + type CommandItem, +} from "./components/CommandPalette"; +import type { ClientSendKey, ClientSubmitDecisions } from "./protocol"; +import { useResponsive } from "./hooks/useResponsive"; +import { useNotifications } from "./hooks/useNotifications"; + +function App() { + const { + sessions, + activeWindowId, + messages, + statusText, + contextPct, + health, + interactiveUI, + decisionGrid, + filter, + setFilter, + handleServerMessage, + setActiveWindowId, + clearDecisionGrid, + clearSessionState, + } = useSession(); + + const { status, send } = useWebSocket(handleServerMessage); + const { isTablet } = useResponsive(); + const { notify } = useNotifications(); + + // Notify on interactive UI or status changes when tab not focused + const prevInteractiveRef = useRef(interactiveUI); + useEffect(() => { + if (interactiveUI && interactiveUI !== prevInteractiveRef.current) { + notify("Claude needs input", interactiveUI.ui_name); + } + prevInteractiveRef.current = interactiveUI; + }, [interactiveUI, notify]); + + const [showDirectoryPicker, setShowDirectoryPicker] = useState(false); + const [wikiPath, setWikiPath] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + + // On connect/reconnect: re-send switch_session for the active session + // so the server knows which session to stream messages for. + // Only fires when status transitions TO "connected" (not on activeWindowId change, + // which is handled by handleSelectSession). + const prevStatusRef = useRef(status); + useEffect(() => { + const wasDisconnected = prevStatusRef.current !== "connected"; + prevStatusRef.current = status; + if (status === "connected" && wasDisconnected && activeWindowId) { + send({ type: "switch_session", window_id: activeWindowId }); + } + }, [status, activeWindowId, send]); + + // Fetch project skills when active session changes + const [projectCommands, setProjectCommands] = useState([]); + useEffect(() => { + if (!activeWindowId) { + setProjectCommands([]); + return; + } + const controller = new AbortController(); + fetch(`/api/sessions/${encodeURIComponent(activeWindowId)}/skills`, { + signal: controller.signal, + }) + .then((r) => r.json()) + .then((skills: Array<{ name: string; description: string }>) => { + setProjectCommands( + skills.map((s) => ({ + name: s.name, + description: s.description, + group: "project" as const, + })), + ); + }) + .catch(() => { + if (!controller.signal.aborted) setProjectCommands([]); + }); + return () => controller.abort(); + }, [activeWindowId]); + + const allCommands = useMemo( + () => [...projectCommands, ...BUILTIN_COMMANDS], + [projectCommands], + ); + + const handleSelectSession = useCallback( + (windowId: string) => { + clearSessionState(); + setActiveWindowId(windowId); + send({ type: "switch_session", window_id: windowId }); + setSidebarOpen(false); // Close drawer on tablet after selection + }, + [send, setActiveWindowId, clearSessionState], + ); + + const handleCreateSession = useCallback( + (workDir: string, name?: string) => { + send({ type: "create_session", work_dir: workDir, name }); + setShowDirectoryPicker(false); + }, + [send], + ); + + const handleKillSession = useCallback( + (windowId: string) => { + send({ type: "kill_session", window_id: windowId }); + if (activeWindowId === windowId) { + clearSessionState(); // Clear messages, interactive UI, AND decision grid + setActiveWindowId(null); + } + }, + [send, activeWindowId, setActiveWindowId, clearSessionState], + ); + + const handleSendText = useCallback( + (text: string) => { + if (!activeWindowId) return; + send({ type: "send_text", window_id: activeWindowId, text }); + }, + [send, activeWindowId], + ); + + const handleSendKey = useCallback( + (key: ClientSendKey["key"]) => { + if (!activeWindowId) return; + send({ type: "send_key", window_id: activeWindowId, key }); + }, + [send, activeWindowId], + ); + + const handleEscape = useCallback(() => { + if (!activeWindowId) return; + send({ type: "send_key", window_id: activeWindowId, key: "Escape" }); + }, [send, activeWindowId]); + + const handleSubmitDecisions = useCallback( + (submission: ClientSubmitDecisions) => { + send(submission); + clearDecisionGrid(); + }, + [send, clearDecisionGrid], + ); + + const handleRenameSession = useCallback( + async (windowId: string, newName: string) => { + try { + await fetch( + `/api/sessions/${encodeURIComponent(windowId)}/rename`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + }, + ); + } catch { + console.error("Rename failed"); + } + }, + [], + ); + + const handleExport = useCallback(async () => { + if (!activeWindowId) return; + try { + const resp = await fetch( + `/api/sessions/${encodeURIComponent(activeWindowId)}/export?fmt=markdown`, + ); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = + resp.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || + "export.md"; + a.click(); + URL.revokeObjectURL(url); + } catch { + console.error("Export failed"); + } + }, [activeWindowId]); + + const handleScreenshot = useCallback(async () => { + if (!activeWindowId) return; + try { + const resp = await fetch( + `/api/sessions/${encodeURIComponent(activeWindowId)}/screenshot`, + ); + const text = await resp.text(); + // Open in new window as pre-formatted text + const w = window.open("", "_blank"); + if (w) { + w.document.write( + `
${text.replace(/`,
+        );
+      }
+    } catch {
+      console.error("Screenshot failed");
+    }
+  }, [activeWindowId]);
+
+  // Ctrl+K toggle — counter increments trigger palette open in MessageInput
+  // Uses a counter (not boolean) so StrictMode double-invocation doesn't cancel out
+  const [paletteToggle, setPaletteToggle] = useState(
+    undefined,
+  );
+
+  // Global keyboard shortcuts
+  useEffect(() => {
+    const handler = (e: KeyboardEvent) => {
+      if (e.key === "n" && (e.ctrlKey || e.metaKey)) {
+        e.preventDefault();
+        setShowDirectoryPicker(true);
+      }
+      if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
+        e.preventDefault();
+        setPaletteToggle((prev) => (prev ?? 0) + 1);
+      }
+    };
+    window.addEventListener("keydown", handler);
+    return () => window.removeEventListener("keydown", handler);
+  }, []);
+
+  // Swipe gestures for sidebar drawer on tablet
+  const touchStartX = useRef(0);
+  useEffect(() => {
+    if (!isTablet) return;
+    const handleTouchStart = (e: TouchEvent) => {
+      touchStartX.current = e.touches[0].clientX;
+    };
+    const handleTouchEnd = (e: TouchEvent) => {
+      const dx = e.changedTouches[0].clientX - touchStartX.current;
+      if (dx > 80 && touchStartX.current < 40) {
+        // Swipe right from left edge → open
+        setSidebarOpen(true);
+      } else if (dx < -80) {
+        // Swipe left → close
+        setSidebarOpen(false);
+      }
+    };
+    window.addEventListener("touchstart", handleTouchStart, { passive: true });
+    window.addEventListener("touchend", handleTouchEnd, { passive: true });
+    return () => {
+      window.removeEventListener("touchstart", handleTouchStart);
+      window.removeEventListener("touchend", handleTouchEnd);
+    };
+  }, [isTablet]);
+
+  const filteredMessages = filterMessages(messages, filter);
+
+  return (
+    
+ {/* Sidebar — responsive drawer on tablet, fixed on desktop */} + {isTablet && !showDirectoryPicker && !decisionGrid && ( + <> + {/* Hamburger button */} + + {/* Backdrop */} + {sidebarOpen && ( +
setSidebarOpen(false)} + style={{ + position: "fixed", + inset: 0, + background: "rgba(0,0,0,0.5)", + zIndex: 99, + }} + /> + )} + + )} +
+ {wikiPath !== null ? ( + { + setWikiPath(p); + setSidebarOpen(false); + }} + onClose={() => { + setWikiPath(null); + setSidebarOpen(false); + }} + /> + ) : ( + { + setShowDirectoryPicker(true); + setSidebarOpen(false); + }} + onKillSession={handleKillSession} + onRenameSession={handleRenameSession} + onOpenWiki={() => { + setWikiPath("index.md"); + setSidebarOpen(false); + }} + /> + )} +
+ + {/* Main content — wiki or session */} +
+ {wikiPath !== null ? ( + + ) : activeWindowId ? ( + <> + + + {interactiveUI && ( + + )} + + + + + + ) : ( +
+
CCWeb
+
Select a session or create a new one
+
+ )} +
+ + {/* Overlays */} + {decisionGrid && ( + + )} + {showDirectoryPicker && ( + setShowDirectoryPicker(false)} + /> + )} +
+ ); +} + +export default App; diff --git a/ccweb/frontend/src/components/CommandPalette.tsx b/ccweb/frontend/src/components/CommandPalette.tsx new file mode 100644 index 00000000..90a6ac81 --- /dev/null +++ b/ccweb/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface CommandItem { + name: string; + description: string; + group: "built-in" | "project"; +} + +interface CommandPaletteProps { + commands: CommandItem[]; + filter: string; + visible: boolean; + onSelect: (command: string) => void; + onClose: () => void; +} + +export const BUILTIN_COMMANDS: CommandItem[] = [ + { name: "clear", description: "Clear conversation history", group: "built-in" }, + { name: "compact", description: "Compact conversation context", group: "built-in" }, + { name: "cost", description: "Show token/cost usage", group: "built-in" }, + { name: "model", description: "Switch AI model", group: "built-in" }, + { name: "fast", description: "Toggle fast mode", group: "built-in" }, + { name: "plan", description: "Enter plan mode", group: "built-in" }, + { name: "help", description: "Show Claude Code help", group: "built-in" }, + { name: "memory", description: "Edit CLAUDE.md", group: "built-in" }, + { name: "config", description: "Open settings", group: "built-in" }, + { name: "doctor", description: "Run diagnostics", group: "built-in" }, + { name: "bug", description: "Report a bug", group: "built-in" }, + { name: "hooks", description: "View hooks configuration", group: "built-in" }, + { name: "login", description: "Log in to Anthropic", group: "built-in" }, + { name: "logout", description: "Log out", group: "built-in" }, + { name: "review", description: "Review code changes", group: "built-in" }, +]; + +export function CommandPalette({ + commands, + filter, + visible, + onSelect, + onClose, +}: CommandPaletteProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const listRef = useRef(null); + + const filtered = commands.filter((cmd) => + cmd.name.toLowerCase().includes(filter.toLowerCase()), + ); + + // Reset selection when filter changes + useEffect(() => { + setSelectedIndex(0); + }, [filter]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!visible) return; + + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && filtered.length > 0) { + e.preventDefault(); + // Clamp to prevent out-of-bounds if filter shrank the list + const idx = Math.min(selectedIndex, filtered.length - 1); + onSelect(filtered[idx].name); + } else if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }, + [visible, filtered, selectedIndex, onSelect, onClose], + ); + + // Only register global keydown when palette is visible + useEffect(() => { + if (!visible) return; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [visible, handleKeyDown]); + + // Scroll selected item into view (use data attribute, not child index, + // because group headers shift child indices) + useEffect(() => { + const list = listRef.current; + if (!list) return; + const item = list.querySelector( + `[data-cmd-index="${selectedIndex}"]`, + ) as HTMLElement | null; + item?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (!visible || filtered.length === 0) return null; + + // Group commands + const builtIn = filtered.filter((c) => c.group === "built-in"); + const project = filtered.filter((c) => c.group === "project"); + + return ( +
+ {project.length > 0 && ( + <> +
+ Project Commands +
+ {project.map((cmd) => { + const globalIdx = filtered.indexOf(cmd); + return ( + onSelect(cmd.name)} + /> + ); + })} + + )} + {builtIn.length > 0 && ( + <> +
+ Built-in Commands +
+ {builtIn.map((cmd) => { + const globalIdx = filtered.indexOf(cmd); + return ( + onSelect(cmd.name)} + /> + ); + })} + + )} +
+ ); +} + +function CommandRow({ + cmd, + selected, + onClick, + index, +}: { + cmd: CommandItem; + selected: boolean; + onClick: () => void; + index: number; +}) { + return ( +
+ /{cmd.name} + + {cmd.description} + +
+ ); +} diff --git a/ccweb/frontend/src/components/DecisionGrid.tsx b/ccweb/frontend/src/components/DecisionGrid.tsx new file mode 100644 index 00000000..e88fb8e3 --- /dev/null +++ b/ccweb/frontend/src/components/DecisionGrid.tsx @@ -0,0 +1,224 @@ +import { useState } from "react"; +import type { WsDecisionGrid, ClientSubmitDecisions } from "../protocol"; + +interface DecisionGridProps { + grid: WsDecisionGrid; + onSubmit: (submission: ClientSubmitDecisions) => void; + onDismiss: () => void; +} + +interface RowState { + choice: string | null; + notes: string; +} + +function initRows(grid: WsDecisionGrid): RowState[] { + return grid.grid.items.map((item) => { + const recommended = item.options.find((o) => o.recommended); + return { choice: recommended?.label ?? null, notes: "" }; + }); +} + +export function DecisionGrid({ grid, onSubmit, onDismiss }: DecisionGridProps) { + const data = grid.grid; + // Component is keyed by grid.id in App.tsx, so it remounts on new grids. + // No useEffect needed for resetting state. + const [rows, setRows] = useState(() => initRows(grid)); + + const updateRow = (index: number, update: Partial) => { + setRows((prev) => + prev.map((r, i) => (i === index ? { ...r, ...update } : r)), + ); + }; + + const handleSubmit = () => { + const selections = data.items.map((item, i) => ({ + topic: item.topic, + choice: rows[i].choice, + notes: rows[i].notes, + })); + onSubmit({ + type: "submit_decisions", + window_id: grid.window_id, + title: data.title, + selections, + }); + }; + + return ( +
+
+
+

{data.title}

+ +
+ +
+ {data.items.map((item, i) => ( +
+
+ {item.topic} +
+
+ {item.description} +
+ + {/* Options */} +
+ {item.options.map((opt) => ( + + ))} +
+ + {/* Notes */} + updateRow(i, { notes: e.target.value })} + placeholder="Add notes, provide a custom option, or ask a question..." + style={{ + width: "100%", + padding: "6px 10px", + background: "var(--bg-primary)", + color: "var(--text-primary)", + border: "1px solid var(--border)", + borderRadius: 6, + fontSize: 13, + fontFamily: "inherit", + outline: "none", + }} + /> +
+ ))} +
+ +
+ + +
+
+
+ ); +} diff --git a/ccweb/frontend/src/components/DirectoryPicker.tsx b/ccweb/frontend/src/components/DirectoryPicker.tsx new file mode 100644 index 00000000..a2b74e11 --- /dev/null +++ b/ccweb/frontend/src/components/DirectoryPicker.tsx @@ -0,0 +1,346 @@ +import { useCallback, useEffect, useState } from "react"; + +interface DirectoryPickerProps { + onSelect: (path: string, name?: string) => void; + onCancel: () => void; +} + +interface DirEntry { + name: string; + path: string; +} + +interface BrowseResult { + path: string; + parent: string | null; + dirs: DirEntry[]; + error?: string; +} + +const RECENT_DIRS_KEY = "ccweb_recent_dirs"; +const MAX_RECENT = 5; + +function getRecentDirs(): string[] { + try { + return JSON.parse(localStorage.getItem(RECENT_DIRS_KEY) || "[]"); + } catch { + return []; + } +} + +function addRecentDir(path: string) { + try { + const recent = getRecentDirs().filter((d) => d !== path); + recent.unshift(path); + localStorage.setItem( + RECENT_DIRS_KEY, + JSON.stringify(recent.slice(0, MAX_RECENT)), + ); + } catch { + // localStorage unavailable or at quota + } +} + +export function DirectoryPicker({ onSelect, onCancel }: DirectoryPickerProps) { + const [currentPath, setCurrentPath] = useState(""); + const [dirs, setDirs] = useState([]); + const [parentPath, setParentPath] = useState(null); + const [pathInput, setPathInput] = useState(""); + const [sessionName, setSessionName] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [recentDirs] = useState(getRecentDirs); + + const browse = useCallback(async (path: string) => { + setLoading(true); + setError(""); + try { + const resp = await fetch( + `/api/browse?path=${encodeURIComponent(path)}`, + ); + const data: BrowseResult = await resp.json(); + setCurrentPath(data.path); + setPathInput(data.path); + setDirs(data.dirs); + setParentPath(data.parent); + if (data.error) setError(data.error); + } catch { + setError("Failed to browse directory"); + } finally { + setLoading(false); + } + }, []); + + // Load initial directory + useEffect(() => { + browse(""); + }, [browse]); + + const handleConfirm = () => { + const path = pathInput.trim() || currentPath; + if (!path) return; + addRecentDir(path); + const parts = path.split("/"); + const name = sessionName.trim() || parts[parts.length - 1]; + onSelect(path, name); + }; + + const handlePathSubmit = () => { + if (pathInput.trim()) { + browse(pathInput.trim()); + } + }; + + return ( +
+
+ {/* Header */} +
+

New Session

+ + {/* Path input */} +
+ setPathInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handlePathSubmit()} + placeholder="/path/to/project" + style={{ + flex: 1, + padding: "6px 10px", + background: "var(--bg-secondary)", + color: "var(--text-primary)", + border: "1px solid var(--border)", + borderRadius: 6, + fontSize: 13, + fontFamily: "inherit", + outline: "none", + }} + /> + +
+ + {/* Session name */} + setSessionName(e.target.value)} + placeholder="Session name (optional)" + style={{ + width: "100%", + padding: "6px 10px", + background: "var(--bg-secondary)", + color: "var(--text-primary)", + border: "1px solid var(--border)", + borderRadius: 6, + fontSize: 13, + fontFamily: "inherit", + outline: "none", + }} + /> +
+ + {/* Recent directories */} + {recentDirs.length > 0 && !loading && ( +
+
+ Recent +
+ {recentDirs.map((d) => ( +
{ + setPathInput(d); + browse(d); + }} + style={{ + padding: "4px 0", + cursor: "pointer", + fontSize: 13, + color: "var(--accent)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }} + > + {d} +
+ ))} +
+ )} + + {/* Directory list */} +
+ {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Loading... +
+ ) : ( + <> + {parentPath && ( +
browse(parentPath)} + style={{ + padding: "8px 20px", + cursor: "pointer", + color: "var(--accent)", + fontSize: 13, + borderBottom: "1px solid var(--border)", + }} + > + .. (parent directory) +
+ )} + {dirs.map((d) => ( +
browse(d.path)} + style={{ + padding: "8px 20px", + cursor: "pointer", + fontSize: 13, + borderBottom: "1px solid var(--border)", + display: "flex", + alignItems: "center", + gap: 8, + }} + > + {'>'} + {d.name} +
+ ))} + {dirs.length === 0 && !error && ( +
+ No subdirectories +
+ )} + + )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/ccweb/frontend/src/components/ExpandableBlock.tsx b/ccweb/frontend/src/components/ExpandableBlock.tsx new file mode 100644 index 00000000..cbe0bfb5 --- /dev/null +++ b/ccweb/frontend/src/components/ExpandableBlock.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; + +interface ExpandableBlockProps { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +} + +export function ExpandableBlock({ + title, + children, + defaultExpanded = false, +}: ExpandableBlockProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+ + {expanded && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/ccweb/frontend/src/components/FileUpload.tsx b/ccweb/frontend/src/components/FileUpload.tsx new file mode 100644 index 00000000..57b2655b --- /dev/null +++ b/ccweb/frontend/src/components/FileUpload.tsx @@ -0,0 +1,86 @@ +import { useCallback, useRef, useState } from "react"; + +interface FileUploadProps { + windowId: string; + disabled?: boolean; +} + +export function FileUpload({ windowId, disabled }: FileUploadProps) { + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const inputRef = useRef(null); + + const uploadFile = useCallback( + async (file: File) => { + setUploading(true); + try { + const form = new FormData(); + form.append("file", file); + const resp = await fetch( + `/api/sessions/${encodeURIComponent(windowId)}/upload`, + { method: "POST", body: form }, + ); + const data = await resp.json(); + if (data.error) { + console.error("Upload failed:", data.error); + } + } catch (err) { + console.error("Upload error:", err); + } finally { + setUploading(false); + } + }, + [windowId], + ); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadFile(file); + // Reset input so the same file can be re-uploaded + if (inputRef.current) inputRef.current.value = ""; + }; + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) uploadFile(file); + }, + [uploadFile], + ); + + return ( + <> + + + + ); +} diff --git a/ccweb/frontend/src/components/FilterBar.tsx b/ccweb/frontend/src/components/FilterBar.tsx new file mode 100644 index 00000000..f99ace71 --- /dev/null +++ b/ccweb/frontend/src/components/FilterBar.tsx @@ -0,0 +1,51 @@ +import type { MessageFilter } from "../hooks/useSession"; + +interface FilterBarProps { + filter: MessageFilter; + onFilterChange: (f: MessageFilter) => void; +} + +const FILTERS: Array<{ value: MessageFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "chat", label: "Chat" }, + { value: "no_thinking", label: "No Thinking" }, + { value: "tools", label: "Tools" }, +]; + +export function FilterBar({ filter, onFilterChange }: FilterBarProps) { + return ( +
+ {FILTERS.map((f) => ( + + ))} +
+ ); +} diff --git a/ccweb/frontend/src/components/InteractiveUI.tsx b/ccweb/frontend/src/components/InteractiveUI.tsx new file mode 100644 index 00000000..1ba98a9a --- /dev/null +++ b/ccweb/frontend/src/components/InteractiveUI.tsx @@ -0,0 +1,276 @@ +import type { WsInteractiveUI, ClientSendKey } from "../protocol"; + +interface InteractiveUIProps { + ui: WsInteractiveUI; + onSendKey: (key: ClientSendKey["key"]) => void; +} + +function StructuredUI({ + ui, + onSendKey, +}: { + ui: WsInteractiveUI; + onSendKey: (key: ClientSendKey["key"]) => void; +}) { + const data = ui.structured!; + + if (data.ui_name === "AskUserQuestion") { + return ( +
+ {data.options.map((opt) => ( + + ))} + +
+ ); + } + + if (data.ui_name === "ExitPlanMode") { + return ( +
+ {data.description && ( +
+ {data.description} +
+ )} +
+ + +
+
+ ); + } + + if ( + data.ui_name === "PermissionPrompt" || + data.ui_name === "BashApproval" + ) { + return ( +
+
+ {data.description} +
+ {data.command && ( +
+            {data.command}
+          
+ )} +
+ + +
+
+ ); + } + + // Fallback for unknown structured UI types + return ; +} + +function RawUI({ + content, + onSendKey, +}: { + content: string; + onSendKey: (key: ClientSendKey["key"]) => void; +}) { + return ( +
+
+        {content}
+      
+
+ {( + [ + ["Space", "\u2423 Space"], + ["Up", "\u2191"], + ["Tab", "\u21E5 Tab"], + ["Left", "\u2190"], + ["Down", "\u2193"], + ["Right", "\u2192"], + ["Escape", "Esc"], + ["Enter", "\u23CE Enter"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+ ); +} + +export function InteractiveUI({ ui, onSendKey }: InteractiveUIProps) { + return ( +
+
+ {ui.ui_name} +
+ {ui.structured ? ( + + ) : ( + + )} +
+ ); +} diff --git a/ccweb/frontend/src/components/MessageInput.tsx b/ccweb/frontend/src/components/MessageInput.tsx new file mode 100644 index 00000000..d425dfaa --- /dev/null +++ b/ccweb/frontend/src/components/MessageInput.tsx @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { CommandItem } from "./CommandPalette"; +import { CommandPalette } from "./CommandPalette"; + +interface MessageInputProps { + onSend: (text: string) => void; + onEscape: () => void; + onScreenshot?: () => void; + commands: CommandItem[]; + disabled?: boolean; + children?: React.ReactNode; // Slot for FileUpload button + externalPaletteToggle?: number; // Counter incremented by parent (Ctrl+K) +} + +export function MessageInput({ + onSend, + onEscape, + onScreenshot, + commands, + disabled, + children, + externalPaletteToggle, +}: MessageInputProps) { + const [text, setText] = useState(""); + const [showPalette, setShowPalette] = useState(false); + const [slashFilter, setSlashFilter] = useState(""); + const textareaRef = useRef(null); + const historyRef = useRef([]); + const historyIndexRef = useRef(-1); + + // React to external palette toggle (e.g., Ctrl+K from parent) + // Uses counter so StrictMode double-invocation sets true twice (idempotent) + useEffect(() => { + if (externalPaletteToggle !== undefined) { + setShowPalette(true); + setSlashFilter(""); + textareaRef.current?.focus(); + } + }, [externalPaletteToggle]); + + const handleSubmit = useCallback(() => { + const trimmed = text.trim(); + if (!trimmed) return; + historyRef.current.push(trimmed); + historyIndexRef.current = -1; + onSend(trimmed); + setText(""); + setShowPalette(false); + if (textareaRef.current) { + textareaRef.current.style.height = "auto"; + } + }, [text, onSend]); + + const handleSelectCommand = useCallback( + (name: string) => { + const cmd = `/${name}`; + onSend(cmd); + setText(""); + setShowPalette(false); + textareaRef.current?.focus(); + }, + [onSend], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSubmit(); + return; + } + // Command history: Ctrl+Up / Ctrl+Down + if (e.key === "ArrowUp" && e.ctrlKey) { + e.preventDefault(); + const hist = historyRef.current; + if (hist.length === 0) return; + const idx = historyIndexRef.current; + const newIdx = idx < 0 ? hist.length - 1 : Math.max(0, idx - 1); + historyIndexRef.current = newIdx; + setText(hist[newIdx]); + return; + } + if (e.key === "ArrowDown" && e.ctrlKey) { + e.preventDefault(); + const hist = historyRef.current; + const idx = historyIndexRef.current; + if (idx < 0) return; + if (idx >= hist.length - 1) { + historyIndexRef.current = -1; + setText(""); + } else { + historyIndexRef.current = idx + 1; + setText(hist[idx + 1]); + } + return; + } + }; + + const handleInput = (e: React.ChangeEvent) => { + const val = e.target.value; + setText(val); + + // Show palette when typing / at the start of a line + const lines = val.split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine.startsWith("/")) { + setShowPalette(true); + setSlashFilter(lastLine.slice(1)); + } else { + setShowPalette(false); + } + + const el = e.target; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 200) + "px"; + }; + + const handleClosePalette = useCallback(() => { + setShowPalette(false); + }, []); + + return ( +
+ {/* Toolbar */} +
+ + + {onScreenshot && ( + + )} + {children} +
+ + {/* Input area with command palette */} +
+ +