[
  {
    "path": ".gitignore",
    "content": "# Build output\nnode_modules/\ndist/\nout/\nbuild/\nrelease/\n*.tsbuildinfo\n\n# OS artifacts\n.DS_Store\nThumbs.db\nDesktop.ini\n\n# Editor / tool local state\n.cursor/\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# Claude Code project-scoped local settings\n.claude/settings.local.json\n\n# Environment (not needed for core flow, but excluded defensively)\n.env\n.env.*\n\n# Logs\n*.log\n~/.clui-debug.log\n\n# Runtime\n.clui.pid\n\n# Temporary files\n*.tmp\n*.bak\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment:\n\n- Using welcoming and inclusive language\n- Being respectful of differing viewpoints and experiences\n- Gracefully accepting constructive criticism\n- Focusing on what is best for the community\n- Showing empathy towards other community members\n\nExamples of unacceptable behavior:\n\n- The use of sexualized language or imagery and unwelcome sexual attention or advances\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information without explicit permission\n- Other conduct which could reasonably be considered inappropriate in a professional setting\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Clui CC\n\nThanks for your interest in contributing! Clui CC is a desktop overlay for Claude Code, and we welcome bug reports, feature ideas, and pull requests.\n\n## Getting Started\n\n1. Make sure you have the [prerequisites](README.md#prerequisites) installed (macOS, Xcode CLT, Node.js 18+, Claude Code CLI 2.1+)\n2. Fork and clone the repo:\n   ```bash\n   git clone https://github.com/<your-username>/clui-cc.git\n   cd clui-cc\n   ```\n3. Check your environment (optional but recommended):\n   ```bash\n   npm run doctor\n   ```\n4. Install dependencies:\n   ```bash\n   npm install\n   ```\n   > If `npm install` fails, run `npm run doctor` to see which dependency is missing.\n5. Start the dev server:\n   ```bash\n   npm run dev\n   ```\n6. Make your changes in `src/`\n7. Verify your changes build cleanly:\n   ```bash\n   npm run build\n   ```\n\n## Development Tips\n\n- **Main process** changes (`src/main/`) require a full restart (`Ctrl+C` then `npm run dev`).\n- **Renderer** changes (`src/renderer/`) hot-reload automatically.\n- Set `CLUI_DEBUG=1` to enable verbose main-process logging to `~/.clui-debug.log`.\n- The app creates a transparent, click-through window. Use `⌥ + Space` to toggle visibility (fallback: `Cmd+Shift+K`).\n\n## Code Style\n\n- TypeScript strict mode is enforced.\n- Use `useColors()` hook for all color references — never hardcode color values.\n- Zustand selectors should be narrow and use custom equality functions for performance.\n- Prefer editing existing files over creating new ones.\n\n## Pull Requests\n\n1. Create a feature branch from `main`.\n2. Keep PRs focused — one concern per PR.\n3. Include a brief description of what changed and why.\n4. Ensure `npm run build` passes with zero errors.\n\n## Reporting Bugs\n\nOpen an issue with:\n- macOS version\n- Node.js version (`node --version`)\n- Claude Code CLI version (`claude --version`)\n- Steps to reproduce\n- Expected vs. actual behavior\n\n## Security\n\nIf you discover a security vulnerability, please report it privately. See [SECURITY.md](SECURITY.md).\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025-2026 Lucas Couto\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Clui CC — Command Line User Interface for Claude Code\n\nA lightweight, transparent desktop overlay for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) on macOS. Clui CC wraps the Claude Code CLI in a floating pill interface with multi-tab sessions, a permission approval UI, voice input, and a skills marketplace.\n\n## Demo\n\n[![Watch the demo](https://img.youtube.com/vi/NqRBIpaA4Fk/maxresdefault.jpg)](https://www.youtube.com/watch?v=NqRBIpaA4Fk)\n\n<p align=\"center\"><a href=\"https://www.youtube.com/watch?v=NqRBIpaA4Fk\">▶ Watch the full demo on YouTube</a></p>\n\n## Features\n\n- **Floating overlay** — transparent, click-through window that stays on top. Toggle with `⌥ + Space` (fallback: `Cmd+Shift+K`).\n- **Multi-tab sessions** — each tab spawns its own `claude -p` process with independent session state.\n- **Permission approval UI** — intercepts tool calls via PreToolUse HTTP hooks so you can review and approve/deny from the UI.\n- **Conversation history** — browse and resume past Claude Code sessions.\n- **Skills marketplace** — install plugins from Anthropic's GitHub repos without leaving Clui CC.\n- **Voice input** — local speech-to-text via Whisper (required, installed automatically).\n- **File & screenshot attachments** — paste images or attach files directly.\n- **Dual theme** — dark/light mode with system-follow option.\n\n## Why Clui CC\n\n- **Claude Code, but visual** — keep CLI power while getting a fast desktop UX for approvals, history, and multitasking.\n- **Human-in-the-loop safety** — tool calls are reviewed and approved in-app before execution.\n- **Session-native workflow** — each tab runs an independent Claude session you can resume later.\n- **Local-first** — everything runs through your local Claude CLI. No telemetry, no cloud dependency.\n\n## How It Works\n\n```\nUI prompt → Main process spawns claude -p → NDJSON stream → live render\n                                         → tool call? → permission UI → approve/deny\n```\n\nSee [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full deep-dive.\n\n## Install App (Recommended)\n\nThe fastest way to get Clui CC running as a regular Mac app. This installs dependencies, voice support (Whisper), builds the app, copies it to `/Applications`, and launches it.\n\n**1) Clone the repo**\n\n```bash\ngit clone https://github.com/lcoutodemos/clui-cc.git\n```\n\n**2) Double-click `install-app.command`**\n\nOpen the `clui-cc` folder in Finder and double-click `install-app.command`.\n\n> **First launch:** macOS may block the app because it's unsigned. Go to **System Settings → Privacy & Security → Open Anyway**. You only need to do this once.\n> **Folder cleanup:** the installer removes temporary `dist/` and `release/` folders after a successful install to keep the repo tidy.\n\n<p align=\"center\"><img src=\"docs/shortcut.png\" width=\"520\" alt=\"Press Option + Space to show or hide Clui CC\" /></p>\n\nAfter the initial install, just open **Clui CC** from your Applications folder or Spotlight.\n\n<details>\n<summary><strong>Terminal / Developer Commands</strong></summary>\n\nOnly `install-app.command` is kept at root intentionally for non-technical users. Developer scripts live in `commands/`.\n\n### Quick Start (Terminal)\n\n```bash\ngit clone https://github.com/lcoutodemos/clui-cc.git\n```\n\n```bash\ncd clui-cc\n```\n\n```bash\n./commands/setup.command\n```\n\n```bash\n./commands/start.command\n```\n\n> Press **⌥ + Space** to show/hide the overlay. If your macOS input source claims that combo, use **Cmd+Shift+K**.\n\nTo stop:\n\n```bash\n./commands/stop.command\n```\n\n### Developer Workflow\n\n```bash\nnpm install\n```\n\n```bash\nnpm run dev\n```\n\nRenderer changes update instantly. Main-process changes require restarting `npm run dev`.\n\n### Other Commands\n\n| Command | Purpose |\n|---------|---------|\n| `./commands/setup.command` | Environment check + install dependencies |\n| `./commands/start.command` | Build and launch from source |\n| `./commands/stop.command` | Stop all Clui CC processes |\n| `npm run build` | Production build (no packaging) |\n| `npm run dist` | Package as macOS `.app` into `release/` |\n| `npm run doctor` | Run environment diagnostic |\n\n</details>\n\n<details>\n<summary><strong>Setup Prerequisites (Detailed)</strong></summary>\n\nYou need **macOS 13+**. Then install these one at a time — copy each command and paste it into Terminal.\n\n**Step 1.** Install Xcode Command Line Tools (needed to compile native modules):\n\n```bash\nxcode-select --install\n```\n\n**Step 2.** Install Node.js (recommended: current LTS such as 20 or 22; minimum supported: 18). Download from [nodejs.org](https://nodejs.org), or use Homebrew:\n\n```bash\nbrew install node\n```\n\nVerify it's on your PATH:\n\n```bash\nnode --version\n```\n\n**Step 3.** Make sure Python has `setuptools` (needed by the native module compiler). On Python 3.12+ this is missing by default:\n\n```bash\npython3 -m pip install --upgrade pip setuptools\n```\n\n**Step 4.** Install Claude Code CLI:\n\n```bash\nnpm install -g @anthropic-ai/claude-code\n```\n\n**Step 5.** Authenticate Claude Code (follow the prompts that appear):\n\n```bash\nclaude\n```\n\n**Step 6.** Install Whisper for voice input:\n\n```bash\n# Apple Silicon (M1/M2/M3/M4) — preferred:\nbrew install whisperkit-cli\n# Apple Silicon fallback, or Intel Mac:\nbrew install whisper-cpp\n```\n\n> **No API keys or `.env` file required.** Clui CC uses your existing Claude Code CLI authentication (Pro/Team/Enterprise subscription).\n\n</details>\n\n<details>\n<summary><strong>Architecture and Internals</strong></summary>\n\n### Project Structure\n\n```\nsrc/\n├── main/                   # Electron main process\n│   ├── claude/             # ControlPlane, RunManager, EventNormalizer\n│   ├── hooks/              # PermissionServer (PreToolUse HTTP hooks)\n│   ├── marketplace/        # Plugin catalog fetching + install\n│   ├── skills/             # Skill auto-installer\n│   └── index.ts            # Window creation, IPC handlers, tray\n├── renderer/               # React frontend\n│   ├── components/         # TabStrip, ConversationView, InputBar, etc.\n│   ├── stores/             # Zustand session store\n│   ├── hooks/              # Event listeners, health reconciliation\n│   └── theme.ts            # Dual palette + CSS custom properties\n├── preload/                # Secure IPC bridge (window.clui API)\n└── shared/                 # Canonical types, IPC channel definitions\n```\n\n### How It Works\n\n1. Each tab creates a `claude -p --output-format stream-json` subprocess.\n2. NDJSON events are parsed by `RunManager` and normalized by `EventNormalizer`.\n3. `ControlPlane` manages tab lifecycle (connecting → idle → running → completed/failed/dead).\n4. Tool permission requests arrive via HTTP hooks to `PermissionServer` (localhost only).\n5. The renderer polls backend health every 1.5s and reconciles tab state.\n6. Sessions are resumed with `--resume <session-id>` for continuity.\n\n### Network Behavior\n\nClui CC operates almost entirely offline. The only outbound network calls are:\n\n| Endpoint | Purpose | Required |\n|----------|---------|----------|\n| `raw.githubusercontent.com/anthropics/*` | Marketplace catalog (cached 5 min) | No — graceful fallback |\n| `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install on startup | No — skipped on failure |\n\nNo telemetry, analytics, or auto-update mechanisms. All core Claude Code interaction goes through the local CLI.\n\n</details>\n\n## Troubleshooting\n\nFor setup issues and recovery commands, see [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md).\n\nQuick self-check:\n\n```bash\nnpm run doctor\n```\n\n## Tested On\n\n| Component | Version |\n|-----------|---------|\n| macOS | 15.x (Sequoia) |\n| Node.js | 20.x LTS, 22.x |\n| Python | 3.12 (with setuptools installed) |\n| Electron | 33.x |\n| Claude Code CLI | 2.1.71 |\n\n## Known Limitations\n\n- **macOS only** — transparent overlay, tray icon, and node-pty are macOS-specific. Windows/Linux support is not currently implemented.\n- **Requires Claude Code CLI** — Clui CC is a UI layer, not a standalone AI client. You need an authenticated `claude` CLI.\n- **Permission mode** — uses `--permission-mode default`. The PTY interactive transport is legacy and disabled by default.\n\n## License\n\n[MIT](LICENSE)\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Reporting a Vulnerability\n\nIf you discover a security vulnerability in CLUI, please report it responsibly:\n\n1. **Do not** open a public GitHub issue.\n2. Email the maintainer directly or use GitHub's private vulnerability reporting feature.\n3. Include a description of the vulnerability, steps to reproduce, and potential impact.\n\nWe will acknowledge receipt within 48 hours and aim to provide a fix or mitigation within 7 days for critical issues.\n\n## Security Architecture\n\nCLUI runs entirely on your local machine. Key security properties:\n\n- **No cloud backend** — all Claude Code interaction goes through the local `claude` CLI.\n- **No telemetry or analytics** — zero outbound data collection.\n- **Permission hook server** binds to `127.0.0.1:19836` only (not exposed to the network).\n- **Per-launch secrets** — the hook server uses a random UUID as app secret, regenerated on every launch.\n- **Sensitive field masking** — tool inputs containing tokens, passwords, keys, or credentials are masked before display in the renderer.\n- **CLAUDECODE env var** is explicitly removed from all spawned subprocesses to prevent credential leakage.\n- **Preload isolation** — the renderer has no direct access to Node.js APIs; all IPC goes through a typed `window.clui` bridge.\n\n## Network Surface\n\n| Endpoint | Direction | Purpose |\n|----------|-----------|---------|\n| `127.0.0.1:19836` | Local only | Permission hook server (PreToolUse) |\n| `raw.githubusercontent.com` | Outbound | Marketplace catalog fetch (optional) |\n| `api.github.com` | Outbound | Skill tarball download (optional, pinned SHA) |\n\nNo other network connections are made by CLUI itself. The `claude` CLI may make its own connections as part of normal operation.\n\n## Supported Versions\n\n| Version | Supported |\n|---------|-----------|\n| 0.1.x   | Yes       |\n"
  },
  {
    "path": "commands/install-app.command",
    "content": "#!/bin/bash\n# ──────────────────────────────────────────────────────\n#  Clui CC — Install App\n#\n#  Double-click this file in Finder to:\n#   1. Set up dependencies\n#   2. Install voice support (Whisper)\n#   3. Build a standalone macOS app\n#   4. Copy it to /Applications\n#   5. Clean temporary build files\n#   6. Launch it\n# ──────────────────────────────────────────────────────\nset -e\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\nAPP_NAME=\"Clui CC\"\nDEST=\"/Applications/${APP_NAME}.app\"\n\nstep() { echo; echo \"═══ $1 ═══\"; echo; }\n\n# ── 1. Setup ──\n\nstep \"Step 1/6 — Setting up environment and dependencies\"\n\nif ! bash ./commands/setup.command; then\n  echo\n  echo \"Setup failed. Fix the issues above, then double-click this file again.\"\n  echo\n  exit 1\nfi\n\n# ── 2. Whisper (required for voice input) ──\n\nstep \"Step 2/6 — Checking voice support (Whisper)\"\n\nif command -v whisperkit-cli &>/dev/null || command -v whisper-cli &>/dev/null || command -v whisper &>/dev/null; then\n  echo \"Whisper is already installed.\"\nelse\n  echo \"Whisper is not installed. Voice input requires it.\"\n  echo\n\n  if ! command -v brew &>/dev/null; then\n    echo \"Homebrew is required to install Whisper but was not found.\"\n    echo\n    echo \"  Install Homebrew first:\"\n    echo \"    /bin/bash -c \\\"\\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\\\"\"\n    echo\n    echo \"  Then double-click this file again.\"\n    echo\n    exit 1\n  fi\n\n  ARCH=\"$(uname -m)\"\n  INSTALLED=\"\"\n\n  if [ \"$ARCH\" = \"arm64\" ]; then\n    # Apple Silicon: prefer whisperkit-cli, fall back to whisper-cpp\n    echo \"Installing Whisper via Homebrew (whisperkit-cli for $ARCH)...\"\n    echo\n    if brew install whisperkit-cli; then\n      INSTALLED=\"whisperkit-cli\"\n    else\n      echo\n      echo \"whisperkit-cli failed — falling back to whisper-cpp...\"\n      echo\n      if brew install whisper-cpp; then\n        INSTALLED=\"whisper-cpp\"\n      fi\n    fi\n  else\n    # Intel: whisper-cpp only (whisperkit-cli requires arm64)\n    echo \"Installing Whisper via Homebrew (whisper-cpp for $ARCH)...\"\n    echo\n    if brew install whisper-cpp; then\n      INSTALLED=\"whisper-cpp\"\n    fi\n  fi\n\n  if [ -z \"$INSTALLED\" ]; then\n    echo\n    echo \"Whisper installation failed.\"\n    echo\n    echo \"  Try running manually:\"\n    if [ \"$ARCH\" = \"arm64\" ]; then\n      echo \"    brew install whisperkit-cli\"\n      echo \"  or:\"\n      echo \"    brew install whisper-cpp\"\n    else\n      echo \"    brew install whisper-cpp\"\n    fi\n    echo\n    echo \"  Then double-click this file again.\"\n    echo\n    exit 1\n  fi\n\n  # Verify — check for the executable that the installed formula provides\n  if [ \"$INSTALLED\" = \"whisperkit-cli\" ]; then\n    VERIFY_BIN=\"whisperkit-cli\"\n  else\n    VERIFY_BIN=\"whisper-cli\"\n  fi\n\n  if ! command -v \"$VERIFY_BIN\" &>/dev/null; then\n    echo\n    echo \"Whisper was installed but the command is not available.\"\n    echo\n    echo \"  Try opening a new Terminal window and running:\"\n    echo \"    $VERIFY_BIN --help\"\n    echo\n    echo \"  If that works, double-click this file again.\"\n    echo\n    exit 1\n  fi\n\n  echo \"Whisper installed successfully ($INSTALLED).\"\nfi\n\n# ── 3. Build ──\n\nstep \"Step 3/6 — Building ${APP_NAME}.app\"\n\nif ! npm run dist; then\n  echo\n  echo \"Build failed.\"\n  echo\n  echo \"  Try these steps one at a time:\"\n  echo \"    rm -rf node_modules\"\n  echo \"    npm install\"\n  echo \"    npm run dist\"\n  echo\n  echo \"  If it still fails, see docs/TROUBLESHOOTING.md\"\n  echo\n  exit 1\nfi\n\n# ── 4. Detect and copy ──\n\nstep \"Step 4/6 — Installing to /Applications\"\n\nAPP_SOURCE=\"\"\nif [ -d \"release/mac-arm64/${APP_NAME}.app\" ]; then\n  APP_SOURCE=\"release/mac-arm64/${APP_NAME}.app\"\nelif [ -d \"release/mac/${APP_NAME}.app\" ]; then\n  APP_SOURCE=\"release/mac/${APP_NAME}.app\"\nfi\n\nif [ -z \"$APP_SOURCE\" ]; then\n  echo \"Could not find the built app.\"\n  echo\n  echo \"  Expected one of:\"\n  echo \"    release/mac-arm64/${APP_NAME}.app  (Apple Silicon)\"\n  echo \"    release/mac/${APP_NAME}.app        (Intel)\"\n  echo\n  echo \"  Check what was built:\"\n  echo \"    ls release/\"\n  echo\n  exit 1\nfi\n\necho \"Found: $APP_SOURCE\"\n\nif [ -d \"$DEST\" ]; then\n  echo \"Replacing existing ${APP_NAME} in /Applications...\"\n  rm -rf \"$DEST\"\nfi\n\ncp -R \"$APP_SOURCE\" \"$DEST\"\necho \"Copied to $DEST\"\n\n# ── 5. Cleanup ──\n\nstep \"Step 5/6 — Cleaning temporary build files\"\n\nif [ \"${KEEP_BUILD_ARTIFACTS:-0}\" = \"1\" ]; then\n  echo \"Keeping build artifacts (KEEP_BUILD_ARTIFACTS=1).\"\nelse\n  rm -rf ./dist ./release\n  echo \"Removed: dist/ and release/\"\nfi\n\n# ── 6. Launch ──\n\nstep \"Step 6/6 — Launching ${APP_NAME}\"\n\nopen \"$DEST\"\n\necho \"Done! ${APP_NAME} is running.\"\necho\necho \"  Show/hide the overlay:  ⌥ + Space  (Option + Space)\"\necho \"  Quit:                   Click the menu bar icon > Quit\"\necho\necho \"  First launch: if macOS shows a security warning, go to\"\necho \"  System Settings > Privacy & Security > Open Anyway\"\necho \"  You only need to do this once.\"\necho\n"
  },
  {
    "path": "commands/setup.command",
    "content": "#!/bin/bash\nset -e\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\n# ── Helpers ──\n\nfail=0\nSDK_PATH=\"\"\n\nstep() { echo; echo \"--- $1\"; }\npass() { echo \"  OK: $1\"; }\nfail() { echo \"  FAIL: $1\"; fail=1; }\nfix() {\n  echo\n  echo \"  To fix, copy and run this command:\"\n  echo\n  echo \"    $1\"\n  echo\n}\n\nversion_gte() {\n  [ \"$(printf '%s\\n%s' \"$1\" \"$2\" | sort -V | head -1)\" = \"$2\" ]\n}\n\n# ── Preflight Checks ──\n\nstep \"Checking environment\"\n\n# macOS\nif [ \"$(uname)\" != \"Darwin\" ]; then\n  fail \"Clui CC requires macOS 13+. Detected: $(uname). This project does not run on Linux or Windows.\"\nelse\n  macos_ver=$(sw_vers -productVersion 2>/dev/null || echo \"0\")\n  if version_gte \"$macos_ver\" \"13.0\"; then\n    pass \"macOS $macos_ver\"\n  else\n    fail \"macOS $macos_ver is too old. Clui CC requires macOS 13+.\"\n    echo \"  Update macOS in System Settings > General > Software Update.\"\n  fi\nfi\n\n# Node\nif command -v node &>/dev/null; then\n  node_ver=$(node --version | sed 's/^v//')\n  if version_gte \"$node_ver\" \"18.0.0\"; then\n    pass \"Node.js v$node_ver\"\n  else\n    fail \"Node.js v$node_ver is too old. Clui CC requires Node 18+.\"\n    fix \"brew install node\"\n  fi\nelse\n  fail \"Node.js is not installed.\"\n  fix \"brew install node\"\nfi\n\n# npm\nif command -v npm &>/dev/null; then\n  pass \"npm $(npm --version)\"\nelse\n  fail \"npm is not installed (should come with Node.js).\"\n  fix \"brew install node\"\nfi\n\n# Python 3 + distutils\nif command -v python3 &>/dev/null; then\n  pass \"Python $(python3 --version 2>&1 | awk '{print $2}')\"\n\n  if python3 -c \"import distutils\" 2>/dev/null; then\n    pass \"Python distutils available\"\n  else\n    fail \"Python is missing 'distutils' (needed by native module compiler).\"\n    fix \"python3 -m pip install --upgrade pip setuptools\"\n  fi\nelse\n  fail \"Python 3 is not installed.\"\n  fix \"brew install python@3.11\"\nfi\n\n# Xcode CLT\nif xcode-select -p &>/dev/null; then\n  pass \"Xcode CLT at $(xcode-select -p)\"\nelse\n  fail \"Xcode Command Line Tools are not installed.\"\n  fix \"xcode-select --install\"\nfi\n\n# macOS SDK\nif xcrun --sdk macosx --show-sdk-path &>/dev/null; then\n  SDK_PATH=$(xcrun --sdk macosx --show-sdk-path)\n  pass \"macOS SDK at $SDK_PATH\"\nelse\n  fail \"macOS SDK not found. Xcode Command Line Tools may be broken.\"\n  echo\n  echo \"  Try: xcode-select --install\"\n  echo \"  If that doesn't help:\"\n  echo \"    sudo rm -rf /Library/Developer/CommandLineTools\"\n  echo \"    xcode-select --install\"\n  echo\nfi\n\n# C++ compiler + headers\nif command -v clang++ &>/dev/null; then\n  pass \"clang++ available\"\n\n  PROBE_DIR=$(mktemp -d)\n  echo '#include <functional>' > \"$PROBE_DIR/probe.cpp\"\n  echo 'int main() { return 0; }' >> \"$PROBE_DIR/probe.cpp\"\n  if clang++ -std=c++17 -c \"$PROBE_DIR/probe.cpp\" -o \"$PROBE_DIR/probe.o\" 2>/dev/null; then\n    pass \"C++ standard headers OK\"\n  elif [ -n \"$SDK_PATH\" ] && clang++ -std=c++17 -isysroot \"$SDK_PATH\" -I\"$SDK_PATH/usr/include/c++/v1\" -c \"$PROBE_DIR/probe.cpp\" -o \"$PROBE_DIR/probe.o\" 2>/dev/null; then\n    pass \"C++ standard headers OK (using SDK include path)\"\n  else\n    fail \"C++ headers are broken (<functional> not found).\"\n    echo\n    echo \"  Try: xcode-select --install\"\n    echo \"  If that doesn't help:\"\n    echo \"    sudo rm -rf /Library/Developer/CommandLineTools\"\n    echo \"    xcode-select --install\"\n    echo\n  fi\n  rm -rf \"$PROBE_DIR\"\nelse\n  fail \"clang++ not found. Xcode Command Line Tools may be broken.\"\n  fix \"xcode-select --install\"\nfi\n\n# Claude CLI\nif command -v claude &>/dev/null; then\n  pass \"Claude Code CLI found\"\nelse\n  fail \"Claude Code CLI is not installed.\"\n  fix \"npm install -g @anthropic-ai/claude-code\"\nfi\n\n# Bail if any check failed\nif [ \"$fail\" -ne 0 ]; then\n  echo\n  echo \"Some checks failed. Fix them above, then rerun:\"\n  echo\n  echo \"  ./commands/setup.command\"\n  echo\n  exit 1\nfi\n\necho\necho \"All checks passed.\"\n\n# ── Install ──\n\nstep \"Installing dependencies\"\nif [ -n \"$SDK_PATH\" ]; then\n  export SDKROOT=\"$SDK_PATH\"\n  export CXXFLAGS=\"-isysroot $SDKROOT -I$SDKROOT/usr/include/c++/v1 ${CXXFLAGS:-}\"\nfi\nif ! npm install; then\n  echo\n  echo \"npm install failed. Most common fixes:\"\n  echo\n  echo \"  1. xcode-select --install\"\n  echo \"  2. python3 -m pip install --upgrade pip setuptools\"\n  echo \"  3. Rerun: ./commands/setup.command\"\n  echo\n  exit 1\nfi\n\n# Guard against stale lockfiles/dependency trees that keep vulnerable versions.\ninstalled_builder=$(node -p \"require('./node_modules/electron-builder/package.json').version\" 2>/dev/null || echo \"\")\ninstalled_electron=$(node -p \"require('./node_modules/electron/package.json').version\" 2>/dev/null || echo \"\")\n\nif [ -z \"$installed_builder\" ] || [ -z \"$installed_electron\" ]; then\n  echo\n  echo \"Could not verify installed Electron dependencies.\"\n  echo \"Try:\"\n  echo \"  rm -rf node_modules package-lock.json\"\n  echo \"  npm install\"\n  echo \"  ./commands/setup.command\"\n  echo\n  exit 1\nfi\n\nif ! version_gte \"$installed_builder\" \"26.8.1\" || ! version_gte \"$installed_electron\" \"35.7.5\"; then\n  echo\n  echo \"Detected outdated install (electron-builder $installed_builder, electron $installed_electron).\"\n  echo \"Applying required security baseline...\"\n  echo\n  npm install -D electron-builder@^26.8.1 electron@^35.7.5\nfi\n\nfinal_builder=$(node -p \"require('./node_modules/electron-builder/package.json').version\" 2>/dev/null || echo \"\")\nfinal_electron=$(node -p \"require('./node_modules/electron/package.json').version\" 2>/dev/null || echo \"\")\necho \"Installed: electron-builder $final_builder, electron $final_electron\"\n\necho\necho \"Setup complete. To launch the app, run:\"\necho\necho \"  ./commands/start.command\"\necho\n"
  },
  {
    "path": "commands/start.command",
    "content": "#!/bin/bash\nset -e\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\nif [ ! -d \"node_modules\" ]; then\n  echo \"Dependencies not installed.\"\n  echo\n  echo \"  If this is your first time, run:\"\n  echo \"    ./commands/setup.command\"\n  echo\n  echo \"  Or install manually:\"\n  echo \"    npm install\"\n  echo\n  exit 1\nfi\n\n# Clean stale PID file\nPID_FILE=\".clui.pid\"\nif [ -f \"$PID_FILE\" ]; then\n  old_pid=$(cat \"$PID_FILE\" 2>/dev/null)\n  if [ -n \"$old_pid\" ] && ! kill -0 \"$old_pid\" 2>/dev/null; then\n    rm -f \"$PID_FILE\"\n  fi\nfi\n\necho \"Building Clui CC...\"\nif ! npx electron-vite build --mode production; then\n  echo\n  echo \"Build failed. Try: rm -rf node_modules && npm install\"\n  exit 1\nfi\n\necho \"Clui CC running. ⌥ + Space to toggle. Use ./commands/stop.command or tray icon > Quit to close.\"\n\n# Launch in a new process group and record the PID\nnpx electron . &\nAPP_PID=$!\necho \"$APP_PID\" > \"$PID_FILE\"\n\n# Clean up PID file when the app exits\nwait \"$APP_PID\" 2>/dev/null\nrm -f \"$PID_FILE\"\n"
  },
  {
    "path": "commands/stop.command",
    "content": "#!/bin/bash\n\n# Resolve to repo root (one level up from commands/)\ncd \"$(dirname \"$0\")/..\"\n\nREPO_DIR=\"$(pwd)\"\nPID_FILE=\".clui.pid\"\nstopped=0\n\n# ── 1. Try tracked PID first ──\n\nif [ -f \"$PID_FILE\" ]; then\n  APP_PID=$(cat \"$PID_FILE\" 2>/dev/null)\n  if [ -n \"$APP_PID\" ] && kill -0 \"$APP_PID\" 2>/dev/null; then\n    # Kill the process group (app + all child helpers)\n    kill -TERM -\"$APP_PID\" 2>/dev/null || kill -TERM \"$APP_PID\" 2>/dev/null\n\n    # Wait up to 3 seconds for graceful shutdown\n    for i in 1 2 3; do\n      kill -0 \"$APP_PID\" 2>/dev/null || break\n      sleep 1\n    done\n\n    # Force kill if still alive\n    if kill -0 \"$APP_PID\" 2>/dev/null; then\n      kill -KILL -\"$APP_PID\" 2>/dev/null || kill -KILL \"$APP_PID\" 2>/dev/null\n      sleep 0.5\n    fi\n\n    stopped=1\n  fi\n  rm -f \"$PID_FILE\"\nfi\n\n# ── 2. Fallback: pattern-based kill for anything missed ──\n\nleftover_pids=$(pgrep -f \"$REPO_DIR/node_modules/electron\" 2>/dev/null || true)\nleftover_pids=\"$leftover_pids $(pgrep -f \"$REPO_DIR/dist/main\" 2>/dev/null || true)\"\nleftover_pids=$(echo \"$leftover_pids\" | xargs)\n\nif [ -n \"$leftover_pids\" ]; then\n  # Graceful first\n  kill -TERM $leftover_pids 2>/dev/null\n  sleep 2\n\n  # Force kill survivors\n  for pid in $leftover_pids; do\n    if kill -0 \"$pid\" 2>/dev/null; then\n      kill -KILL \"$pid\" 2>/dev/null\n    fi\n  done\n  stopped=1\nfi\n\n# ── 3. Verify ──\n\nsleep 0.5\nremaining=$(pgrep -f \"$REPO_DIR/node_modules/electron\" 2>/dev/null || true)\nremaining=\"$remaining $(pgrep -f \"$REPO_DIR/dist/main\" 2>/dev/null || true)\"\nremaining=$(echo \"$remaining\" | xargs)\n\nif [ -n \"$remaining\" ]; then\n  echo \"Warning: some processes could not be stopped:\"\n  echo \"  PIDs: $remaining\"\n  echo\n  echo \"  To force kill manually:\"\n  echo \"    kill -9 $remaining\"\nelse\n  if [ \"$stopped\" -eq 1 ]; then\n    echo \"Clui CC stopped.\"\n  else\n    echo \"Clui CC was not running.\"\n  fi\nfi\n"
  },
  {
    "path": "docs/AGENTS.md",
    "content": "# Agent Guide — Clui CC\n\n> This file is optimized for AI coding agents (Claude Code, Cursor, Copilot, etc.).\n> For human-readable docs see [ARCHITECTURE.md](ARCHITECTURE.md) and [CONTRIBUTING.md](../CONTRIBUTING.md).\n\n## What This Project Is\n\nClui CC is a **macOS-only Electron overlay** that wraps the Claude Code CLI (`claude -p --output-format stream-json`) in a floating pill UI. It is NOT a web app, NOT a VS Code extension, and does NOT call the Anthropic API directly — it spawns CLI subprocesses.\n\n## Quick Reference\n\n| Action | Command |\n|--------|---------|\n| Install deps | `npm install` |\n| Dev mode (hot-reload) | `npm run dev` |\n| Type-check / build | `npm run build` |\n| Toggle overlay | `⌥ + Space` (fallback: `Cmd+Shift+K`) |\n| Debug logging | `CLUI_DEBUG=1 npm run dev` (writes to `~/.clui-debug.log`) |\n\n**Main process changes require full restart.** Renderer changes hot-reload.\n\n## Architecture (3-Layer)\n\n```\nRenderer (React 19 + Zustand 5 + Tailwind CSS 4)\n    ↕  contextBridge IPC (src/preload/index.ts)\nMain Process (Node.js / Electron 33)\n    ↕  spawns subprocess\nClaude Code CLI (claude -p --output-format stream-json)\n```\n\n### Layer Responsibilities\n\n| Layer | Directory | Manages |\n|-------|-----------|---------|\n| **Renderer** | `src/renderer/` | UI state, theming, user input, message display |\n| **Preload** | `src/preload/` | Typed IPC bridge (`window.clui` API). Security boundary. |\n| **Main** | `src/main/` | Process lifecycle, tab state machine, permission server, marketplace |\n\n### Key Files by Concern\n\n| Concern | File(s) |\n|---------|---------|\n| Tab lifecycle & state machine | `src/main/claude/control-plane.ts` |\n| Spawning Claude CLI processes | `src/main/claude/run-manager.ts` |\n| Raw NDJSON → canonical events | `src/main/claude/event-normalizer.ts` |\n| Permission hook server | `src/main/hooks/permission-server.ts` |\n| All TypeScript types & IPC channels | `src/shared/types.ts` |\n| Zustand state store | `src/renderer/stores/sessionStore.ts` |\n| Theme / color system | `src/renderer/theme.ts` |\n| Main window & IPC handler setup | `src/main/index.ts` |\n| Marketplace catalog | `src/main/marketplace/catalog.ts` |\n| Skill installer | `src/main/skills/installer.ts` |\n\n## Data Flow: Prompt → Response\n\n```\nInputBar.tsx → window.clui.prompt(tabId, requestId, opts)\n  → ipcRenderer.invoke('clui:prompt')\n  → ControlPlane.prompt()\n  → RunManager spawns: claude -p --output-format stream-json --resume <sid>\n  → stdout emits NDJSON lines\n  → EventNormalizer → NormalizedEvent\n  → ControlPlane broadcasts via IPC\n  → useClaudeEvents hook → sessionStore.handleNormalizedEvent()\n  → React re-renders\n```\n\n## Canonical Types\n\nAll IPC and event types live in `src/shared/types.ts`. Key types:\n\n- **`NormalizedEvent`** — union of all events the main process emits to the renderer\n- **`TabState`** — full state of a single tab (status, messages, permissions, session metadata)\n- **`TabStatus`** — state machine: `connecting → idle → running → completed/failed/dead`\n- **`IPC`** — const object with all IPC channel names (use these, never raw strings)\n- **`RunOptions`** — options passed when spawning a Claude CLI run\n- **`CatalogPlugin`** — marketplace plugin metadata\n\n## Conventions & Rules\n\n### Must Follow\n\n1. **TypeScript strict mode** — zero errors required (`npm run build` must pass)\n2. **Use `IPC.*` constants** for all IPC channel names — never hardcode strings\n3. **Use `useColors()` hook** for all color references in renderer — never hardcode colors\n4. **Narrow Zustand selectors** with custom equality functions for performance\n5. **All new IPC channels** must be added to `src/shared/types.ts` AND wired in both `src/preload/index.ts` and `src/main/index.ts`\n6. **Tab state transitions** go through `ControlPlane` only — never mutate tab state directly\n\n### Security — Do Not Break\n\n- **Permission server** binds to `127.0.0.1` only (never `0.0.0.0`)\n- **Per-launch app secret** (random UUID) validates hook requests — do not weaken\n- **Per-run tokens** route permission responses to correct tab — do not bypass\n- **`CLAUDECODE` env var** is explicitly removed from spawned processes\n- **Sensitive fields** (tokens, passwords, secrets, keys, auth, credentials) are masked via `maskSensitiveFields()` before display\n- **5-minute auto-deny timeout** on unanswered permissions — do not remove\n\n### Don't\n\n- Don't import main-process modules from renderer (or vice versa) — the preload bridge is the only crossing point\n- Don't add network calls — the app is designed to be nearly offline (only marketplace fetches from GitHub)\n- Don't use `node-pty` for new features — it's legacy, prefer `RunManager` (stdio-based)\n- Don't add Electron `remote` module usage — it's disabled for security\n\n## Adding a New Feature — Checklist\n\n### New IPC channel\n1. Add channel name to `IPC` const in `src/shared/types.ts`\n2. Add handler in `src/main/index.ts` (`ipcMain.handle` or `ipcMain.on`)\n3. Expose via `contextBridge` in `src/preload/index.ts`\n4. Call from renderer via `window.clui.*`\n\n### New UI component\n1. Create in `src/renderer/components/`\n2. Use `useColors()` for all colors\n3. Use Phosphor icons (`@phosphor-icons/react`) — not other icon libraries\n4. Animations via Framer Motion\n\n### New event type from Claude CLI\n1. Add raw type to `ClaudeEvent` union in `src/shared/types.ts`\n2. Add normalized form to `NormalizedEvent` union\n3. Handle in `EventNormalizer.normalize()` (`src/main/claude/event-normalizer.ts`)\n4. Handle in `sessionStore.handleNormalizedEvent()` (`src/renderer/stores/sessionStore.ts`)\n\n### New tab state field\n1. Add to `TabState` interface in `src/shared/types.ts`\n2. Initialize in `createTab()` in both `ControlPlane` and `sessionStore`\n3. Update via `ControlPlane` events — never directly from renderer\n\n## Stack\n\n| Layer | Tech | Version |\n|-------|------|---------|\n| Desktop | Electron | 33 |\n| Build | electron-vite | 3 |\n| UI | React | 19 |\n| State | Zustand | 5 |\n| Styling | Tailwind CSS | 4 |\n| Animation | Framer Motion | 12 |\n| Icons | Phosphor Icons | 2 |\n| Markdown | react-markdown + remark-gfm | 9 / 4 |\n| PTY (legacy) | node-pty | 1.1 |\n\n## Network Surface\n\n| Endpoint | Purpose | Required |\n|----------|---------|----------|\n| `raw.githubusercontent.com/anthropics/*` | Marketplace catalog (cached 5 min) | No |\n| `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install | No |\n| `127.0.0.1:19836` | Permission hook server (local only) | Yes |\n\nNo telemetry. No analytics. No auto-update.\n\n## Common Pitfalls\n\n1. **Forgetting to restart dev server** after main-process changes — renderer hot-reloads but main does not\n2. **Adding raw color values** instead of using `useColors()` — breaks theming\n3. **Mutating tab state from renderer** instead of going through ControlPlane events\n4. **Hardcoding IPC strings** instead of using `IPC.*` constants\n5. **Testing on non-macOS** — this is macOS-only (transparent windows, node-pty bindings)\n6. **Not handling the `session_dead` event** — if a Claude process crashes, the tab must transition to `dead` status\n"
  },
  {
    "path": "docs/ARCHITECTURE.md",
    "content": "# CLUI Architecture\n\n## Overview\n\nCLUI is an Electron desktop application that provides a graphical interface for Claude Code CLI. It spawns `claude -p` subprocesses, parses their NDJSON output, and presents conversations in a floating overlay window.\n\n```\n┌──────────────────────────────────────────────────────────────┐\n│                     Renderer Process                         │\n│  React 19 + Zustand 5 + Tailwind CSS 4 + Framer Motion      │\n│                                                              │\n│  ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌────────────┐  │\n│  │ TabStrip  │ │Conversation  │ │ InputBar │ │ Marketplace│  │\n│  │          │ │   View       │ │          │ │   Panel    │  │\n│  └──────────┘ └──────────────┘ └──────────┘ └────────────┘  │\n│                         │                                    │\n│                    sessionStore (Zustand)                     │\n│                         │                                    │\n│              window.clui (preload bridge)                     │\n├──────────────────────────────────────────────────────────────┤\n│                     Preload Script                            │\n│  Typed IPC bridge — contextBridge.exposeInMainWorld          │\n├──────────────────────────────────────────────────────────────┤\n│                     Main Process                             │\n│                                                              │\n│  ┌──────────────────────────────────────────────────────┐    │\n│  │                   ControlPlane                        │    │\n│  │  Tab registry, session lifecycle, queue management    │    │\n│  │                                                       │    │\n│  │  ┌─────────────┐  ┌──────────────────┐               │    │\n│  │  │ RunManager   │  │ EventNormalizer  │               │    │\n│  │  │ Spawns       │  │ Raw stream-json  │               │    │\n│  │  │ claude -p    │──│ → canonical      │               │    │\n│  │  │ per prompt   │  │   events         │               │    │\n│  │  └─────────────┘  └──────────────────┘               │    │\n│  └──────────────────────────────────────────────────────┘    │\n│                                                              │\n│  ┌────────────────────┐  ┌────────────────────────────┐      │\n│  │ PermissionServer   │  │ Marketplace Catalog        │      │\n│  │ HTTP hooks on      │  │ GitHub raw fetch + cache   │      │\n│  │ 127.0.0.1:19836    │  │ TTL: 5 minutes             │      │\n│  └────────────────────┘  └────────────────────────────┘      │\n└──────────────────────────────────────────────────────────────┘\n         │                              │\n    claude -p (NDJSON)          raw.githubusercontent.com\n    (local subprocess)          (optional, cached)\n```\n\n## Main Process (`src/main/`)\n\n### ControlPlane (`claude/control-plane.ts`)\n\nSingle authority for all tab and session lifecycle. Manages:\n\n- **Tab registry** — maps tabId → session metadata, status, process PID.\n- **State machine** — each tab transitions through: `connecting → idle → running → completed → failed → dead`.\n- **Request routing** — maps requestIds to active RunManager instances.\n- **Queue + backpressure** — max 32 pending requests, prompts queue behind running tasks.\n- **Health reconciliation** — responds to renderer polls with tab status + process liveness.\n- **Session ID tracking** — maps Claude session IDs to tabs for permission routing.\n\n### RunManager (`claude/run-manager.ts`)\n\nSpawns one `claude -p --output-format stream-json` process per prompt. Responsibilities:\n\n- Constructs CLI arguments (`--resume`, `--permission-mode`, `--settings`, `--add-dir`, etc.)\n- Reads NDJSON from stdout line-by-line via `StreamParser`.\n- Passes raw events to `EventNormalizer` for canonicalization.\n- Maintains stderr ring buffer (100 lines) for error diagnostics.\n- Cleans up process on cancel, tab close, or unexpected exit.\n- Removes `CLAUDECODE` from spawned environment to prevent credential leakage.\n\n### EventNormalizer (`claude/event-normalizer.ts`)\n\nMaps raw Claude Code stream-json events to canonical `NormalizedEvent` types:\n\n| Raw Event | Normalized Event |\n|-----------|-----------------|\n| `system` (subtype: init) | `session_init` |\n| `stream_event` (content_block_delta, text_delta) | `text_chunk` |\n| `stream_event` (content_block_start, tool_use) | `tool_call` |\n| `stream_event` (content_block_delta, input_json_delta) | `tool_call_update` |\n| `stream_event` (content_block_stop) | `tool_call_complete` |\n| `assistant` | `task_update` |\n| `result` | `task_complete` |\n| `rate_limit_event` | `rate_limit` |\n\n### PermissionServer (`hooks/permission-server.ts`)\n\nHTTP server that intercepts Claude Code tool calls via PreToolUse hooks:\n\n1. ControlPlane starts PermissionServer on `127.0.0.1:19836`.\n2. `generateSettingsFile()` creates a temp JSON file with hook config pointing at the server.\n3. RunManager passes `--settings <path>` to each `claude -p` spawn.\n4. When Claude wants to use a tool, the CLI POSTs to the hook URL.\n5. PermissionServer emits a `permission-request` event to ControlPlane.\n6. ControlPlane routes it to the correct tab via `_findTabBySessionId()`.\n7. Renderer shows a `PermissionCard` with Allow/Deny buttons.\n8. User decision flows back: IPC → ControlPlane → PermissionServer → HTTP response.\n9. Claude Code proceeds or skips the tool based on the response.\n\nSecurity: per-launch app secret, per-run tokens, sensitive field masking, 5-minute auto-deny timeout.\n\n### Marketplace Catalog (`marketplace/catalog.ts`)\n\nFetches plugin metadata from three Anthropic GitHub repos:\n- `anthropics/skills` (Agent Skills)\n- `anthropics/knowledge-work-plugins` (Knowledge Work)\n- `anthropics/financial-services-plugins` (Financial Services)\n\nUses Electron's `net.request()` with a 5-minute TTL cache. Individual fetch failures are isolated — one broken repo doesn't block others.\n\n### Skill Installer (`skills/installer.ts`)\n\nAuto-installs bundled skills on startup (currently: `skill-creator`). Uses pinned commit SHAs for deterministic downloads. Atomic install: validates in temp dir before swapping into `~/.claude/skills/`. Respects user-managed skills (skips if no `.clui-version` marker).\n\n## Preload (`src/preload/`)\n\nThe preload script uses `contextBridge.exposeInMainWorld` to expose a typed `window.clui` API. This is the only communication surface between renderer and main process.\n\nAll methods map to `ipcRenderer.invoke()` (request-response) or `ipcRenderer.send()` (fire-and-forget). The full API surface is defined in `CluiAPI` interface.\n\n## Renderer (`src/renderer/`)\n\n### State Management\n\nSingle Zustand store (`stores/sessionStore.ts`) holds all application state:\n- Tab list with full `TabState` objects (messages, status, attachments, permissions, etc.)\n- Active tab selection\n- Marketplace state (catalog, search, filter, install progress)\n- UI state (expanded, marketplace open)\n\n### Theme System (`theme.ts`)\n\nDual color palette (dark + light) defined as JS objects. `useColors()` hook returns the active palette reactively. All tokens are synced to CSS custom properties via `syncTokensToCss()` so CSS files can reference `var(--clui-*)`.\n\nTheme mode state machine: `system | light | dark` with separate `_systemIsDark` tracking for OS value.\n\n### Key Components\n\n- **TabStrip** — tab bar with new tab, history picker, settings popover.\n- **ConversationView** — scrollable message timeline with markdown rendering (react-markdown + remark-gfm), tool call cards, permission cards.\n- **InputBar** — prompt input with attachment chips, voice recording, slash command menu, model picker.\n- **MarketplacePanel** — plugin browser with search, semantic tag filters, install confirmation.\n\n### Performance Patterns\n\n- Narrow Zustand selectors with custom equality functions (field-level comparison) to prevent re-renders during streaming.\n- RAF-throttled mousemove handler for click-through detection.\n- Debounced marketplace search (200ms).\n- Health reconciliation skips setState when no tabs changed.\n\n## IPC Channel Map\n\nAll channels are defined in `src/shared/types.ts` under the `IPC` const. Events flow through a single `clui:normalized-event` channel for all Claude Code stream events, with separate channels for tab status changes and enriched errors.\n\n## Data Flow: Prompt → Response\n\n```\nUser types prompt\n    → InputBar calls window.clui.prompt(tabId, requestId, options)\n    → ipcRenderer.invoke('clui:prompt', ...)\n    → Main: ControlPlane.prompt()\n    → RunManager spawns: claude -p --output-format stream-json --resume <sid>\n    → Claude CLI writes NDJSON to stdout\n    → StreamParser emits lines\n    → EventNormalizer maps to NormalizedEvent\n    → ControlPlane updates tab state + broadcasts via IPC\n    → Renderer: useClaudeEvents hook receives events\n    → sessionStore.handleNormalizedEvent() updates messages\n    → React re-renders ConversationView\n```\n"
  },
  {
    "path": "docs/TROUBLESHOOTING.md",
    "content": "# Troubleshooting\n\nIf setup fails, run this first:\n\n```bash\nnpm run doctor\n```\n\nThis checks your local environment and prints pass/fail status without changing your system.\n\n## Install Fails with \"gyp\" or \"make\" Errors\n\nInstall Xcode Command Line Tools, then retry:\n\n```bash\nxcode-select --install\n```\n\n```bash\nnpm install\n```\n\n## Install Fails with `ModuleNotFoundError: No module named 'distutils'`\n\nPython 3.12+ removed `distutils`. Install `setuptools`:\n\n```bash\npython3 -m pip install --upgrade pip setuptools\n```\n\n```bash\nnpm install\n```\n\nIf that still fails, install Python 3.11 and point npm to it:\n\n```bash\nbrew install python@3.11\n```\n\n```bash\nnpm config set python $(brew --prefix python@3.11)/bin/python3.11\n```\n\n```bash\nnpm install\n```\n\nTo undo that Python override later:\n\n```bash\nnpm config delete python\n```\n\n## Install Fails with `fatal error: 'functional' file not found`\n\nC++ headers are missing/broken, usually due to Xcode CLT issues.\n\nCheck toolchain first:\n\n```bash\nxcode-select -p\n```\n\n```bash\nxcrun --sdk macosx --show-sdk-path\n```\n\nIf either command fails (or the error persists), reinstall CLT:\n\n```bash\nsudo rm -rf /Library/Developer/CommandLineTools\n```\n\n```bash\nxcode-select --install\n```\n\nThen retry:\n\n```bash\nnpm install\n```\n\nIf CLT is installed but the error still appears on newer macOS versions, compile explicitly against the SDK include path:\n\n```bash\nSDK=$(xcrun --sdk macosx --show-sdk-path)\nclang++ -std=c++17 -isysroot \"$SDK\" -I\"$SDK/usr/include/c++/v1\" -x c++ - -o /dev/null <<'EOF'\n#include <functional>\nint main() { return 0; }\nEOF\n```\n\n## Install Fails on `node-pty`\n\n`node-pty` is native and requires macOS toolchains. Confirm:\n\n- macOS 13+\n- Xcode CLT installed\n- Python 3 with `setuptools`/`distutils` available\n\nThen retry `npm install`.\n\n## App Launches but No Claude Response\n\nVerify Claude CLI is installed and authenticated:\n\n```bash\nclaude --version\n```\n\n```bash\nclaude\n```\n\n## `⌥ + Space` Does Not Toggle\n\nGrant Accessibility permissions:\n\n- System Settings -> Privacy & Security -> Accessibility\n\nFallback shortcut:\n\n- `Cmd+Shift+K`\n\n## Packaged App Won't Open (Security Warning)\n\nThe `.app` built by `npm run dist` is unsigned. macOS Gatekeeper blocks unsigned apps by default.\n\nTo allow it:\n\n1. Open **System Settings → Privacy & Security**\n2. Scroll to the security section\n3. Click **Open Anyway** next to the Clui CC message\n\nYou only need to do this once. This is a local build, not App Store distribution.\n\n## Install Fails at Whisper Step\n\nThe installer requires Whisper for voice input. If it fails:\n\n1. Make sure Homebrew is installed:\n\n```bash\nbrew --version\n```\n\nIf not, install it:\n\n```bash\n/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\n```\n\n2. Install Whisper manually:\n\n```bash\n# Apple Silicon (M1/M2/M3/M4) — preferred:\nbrew install whisperkit-cli\n# Apple Silicon fallback, or Intel Mac:\nbrew install whisper-cpp\n```\n\n3. Rerun the installer:\n\n```bash\n./install-app.command\n```\n\n## Install Fails at Build Step\n\nRun the steps manually to see the detailed error:\n\n```bash\n./commands/setup.command\n```\n\n```bash\nnpm run dist\n```\n\nIf `npm run dist` fails, try a clean reinstall:\n\n```bash\nrm -rf node_modules\n```\n\n```bash\nnpm install\n```\n\n```bash\nnpm run dist\n```\n\n## Marketplace Shows \"Failed to Load\"\n\nExpected when offline. Marketplace needs internet access; core app features continue to work.\n\n## Window Is Invisible / No UI\n\nTry:\n\n- `⌥ + Space`\n- `Cmd+Shift+K`\n- Confirm app is running from the menu bar tray\n"
  },
  {
    "path": "docs/oss-readiness-report.md",
    "content": "# CLUI Open-Source Readiness Report\n\n**Date:** 2026-03-12\n**Branch:** `oss-prep`\n**Assessor:** Automated scan + manual review\n\n---\n\n## 1. Security\n\n### Secrets & Credentials\n| Check | Result | Severity |\n|-------|--------|----------|\n| Hardcoded API keys/tokens | None found | Safe |\n| .env files | None exist (not needed — app uses local CLI) | Safe |\n| CLAUDECODE env var | Explicitly deleted from spawned processes | Safe |\n| Private key / cert files | None found | Safe |\n| Database connection strings | None (no DB) | Safe |\n\n### Permission System\n- HTTP hook server binds **127.0.0.1:19836 only** (not exposed externally)\n- Per-launch app secret (randomUUID) prevents local spoofing\n- Per-run tokens for routing\n- Sensitive fields masked before sending to renderer (`/token|password|secret|key|auth|credential|api.?key/i`)\n- 5-minute auto-deny timeout for unanswered permission requests\n\n**Verdict:** No security blockers.\n\n---\n\n## 2. Privacy\n\n### Hardcoded Paths\n| Location | Contains User Paths | Action |\n|----------|-------------------|--------|\n| `src/**` | No | Safe |\n| `spike/**` | No | Safe |\n| `scripts/**` | No (uses `$(dirname \"$0\")`) | Safe |\n| `docs/protocol-captures/*.jsonl` | **Yes** — `/Users/<user>/...` in session CWD fields | **Must exclude from public repo** |\n| `docs/claude-permission-probe.md` | **Yes** — references local paths in examples | **Must exclude from public repo** |\n| `.claude/settings.local.json` | Yes — already gitignored | Safe |\n\n### Personal Information\n| Check | Result |\n|-------|--------|\n| Email addresses in source | None |\n| package.json author field | Not set (clean) |\n| Git commit author | Will be visible in public repo history — see cutover plan |\n\n**Verdict:** Exclude `docs/protocol-captures/` and `docs/claude-permission-probe.md` from public repo.\n\n---\n\n## 3. Licensing\n\n### Project License\n- **Current state:** No LICENSE file, no `license` field in package.json\n- **Action:** **MUST-FIX** — Add MIT license before publishing\n\n### Dependencies (all MIT-compatible)\n| Package | License | Copyleft Risk |\n|---------|---------|---------------|\n| electron | MIT | None |\n| react / react-dom | MIT | None |\n| zustand | MIT | None |\n| framer-motion | MIT | None |\n| node-pty | MIT | None |\n| react-markdown | MIT | None |\n| remark-gfm | MIT | None |\n| @phosphor-icons/react | MIT | None |\n| tailwindcss | MIT | None |\n| All devDependencies | MIT | None |\n\n**No GPL, AGPL, SSPL, or BUSL dependencies detected.**\n\n### Assets\n| Asset | Provenance | Action |\n|-------|-----------|--------|\n| `resources/icon.*` | Original (created for project) | Document in LICENSE |\n| `resources/notification.mp3` | Replaced with generated CC0 chime (embedded metadata) | Resolved |\n| `resources/trayTemplate*.png` | Original | Document in LICENSE |\n| Root marketing screenshots | Not included in current repo root | Optional to add later if needed for release collateral |\n\n**Verdict:** Add LICENSE file. ~~Verify notification.mp3 provenance~~ — resolved (replaced with CC0 generated chime).\n\n---\n\n## 4. Developer UX\n\n### Prerequisites for Contributors\n- Node.js 18+ (for Electron 33)\n- macOS (primary platform — Electron transparent window, tray, node-pty)\n- `claude` CLI installed and authenticated (core dependency)\n- Optional: `whisperkit-cli` (Apple Silicon preferred, CoreML) or `whisper-cpp` (Apple Silicon & Intel, ggml) or `whisper` (Python) for voice transcription\n\n### Build System\n- `npm install` → `npm run dev` (hot-reload) or `npm run build` (production)\n- Zero TypeScript errors confirmed\n- electron-vite handles main/preload/renderer bundling\n\n### Missing for OSS\n| Item | Status | Priority |\n|------|--------|----------|\n| README.md | Missing | **Must-fix** |\n| CONTRIBUTING.md | Missing | Must-fix |\n| SECURITY.md | Missing | Must-fix |\n| CODE_OF_CONDUCT.md | Missing | Must-fix |\n| Architecture docs | Missing | Must-fix |\n| .env.example | Not needed | N/A — document explicitly |\n\n---\n\n## 5. Repository Hygiene\n\n### Files to Exclude from Public Repo\n| Path | Reason |\n|------|--------|\n| `docs/protocol-captures/` | Contains local paths, session data |\n| `docs/claude-permission-probe.md` | Contains local path references |\n| `CLUI-PRD.md` | Internal product requirements |\n| `CODEX_REPORT_INTERACTIVE_COMMANDS.md` | Internal dev report |\n| `spike/` | Experimental probes, not production code |\n| `src/main/probe/` | Internal contract/permission test utilities |\n| `soft_and_brief_notif_#2-*.mp3` | Stray temp file in root |\n| `start-pty.command` | Legacy PTY mode launcher |\n| `.claude/` | Project-scoped Claude settings |\n\n### .gitignore Gaps\nCurrent `.gitignore` is minimal. Should add:\n- `out/` (electron-builder output)\n- `*.log`\n- `.env*`\n- `*.swp`, `*.swo`\n- OS artifacts beyond `.DS_Store`\n\n---\n\n## 6. Network Dependencies\n\n| Endpoint | Purpose | Required | Graceful Offline |\n|----------|---------|----------|-----------------|\n| `raw.githubusercontent.com/anthropics/*` | Marketplace catalog | Optional | Yes — cached 5min, error state shown |\n| `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install | Optional | Yes — skipped on failure |\n| `127.0.0.1:19836` | Permission hook server | Required (local only) | N/A |\n\nNo telemetry, analytics, auto-updater, or CDN dependencies.\n\n---\n\n## 7. Release Risk Summary\n\n| Risk | Severity | Status |\n|------|----------|--------|\n| No LICENSE file | **Critical** | Fix in this branch |\n| No README | **Critical** | Fix in this branch |\n| Protocol captures contain local paths | **High** | Exclude from public repo |\n| notification.mp3 unknown provenance | **Medium** | Resolved — replaced with CC0 generated chime |\n| No CONTRIBUTING/SECURITY/COC docs | **Medium** | Fix in this branch |\n| Internal docs (PRD, Codex reports) | **Low** | Exclude from public repo |\n| Probe utilities in src/main/probe/ | **Low** | Exclude from public repo |\n| macOS-only (no Windows/Linux) | **Low** | Document as known limitation |\n"
  },
  {
    "path": "docs/release-smoke-test.md",
    "content": "# Release Smoke Test\n\n## Build Verification\n\n### Fresh Clone Bootstrap\n\n```bash\ngit clone https://github.com/lcoutodemos/clui-cc.git\ncd clui-cc\nnpm run doctor     # verify environment — all checks should pass\nnpm install        # installs deps + runs postinstall (electron-builder install-app-deps + icon patch)\nnpm run build      # production build — must exit 0 with no errors\n```\n\n**Prerequisites check (verified by `npm run doctor`):**\n- macOS 13+\n- Xcode Command Line Tools installed (`xcode-select -p` returns a path)\n- macOS SDK available (`xcrun --sdk macosx --show-sdk-path` returns a path)\n- clang++ available with working C++ headers\n- `node --version` returns 18+\n- `python3` available with `distutils` importable\n- `claude --version` returns 2.1+\n\n**Expected output:**\n- `dist/main/index.js` — ~117 KB\n- `dist/preload/index.js` — ~6 KB\n- `dist/renderer/index.html` + `assets/index-*.js` (~1.5 MB) + `assets/index-*.css` (~25 KB)\n\n### TypeScript\n\n- `npm run build` — passes (uses esbuild, tolerant of some strict-mode warnings)\n- `npx tsc --noEmit` — has pre-existing warnings (68 as of v0.1.0, non-blocking)\n  - These are narrowing/equality warnings from Zustand selector patterns and a legacy PTY file\n  - Does NOT affect runtime behavior — electron-vite builds successfully\n\n## Runtime Smoke Test Checklist\n\n### Prerequisites\n- [ ] macOS 13+\n- [ ] Xcode Command Line Tools installed (`xcode-select -p` returns a path)\n- [ ] Node.js 18+\n- [ ] `claude` CLI installed and authenticated (`claude --version` returns 2.1+)\n\n### Startup\n- [ ] `npm run dev` or `./commands/start.command` launches the app\n- [ ] Floating pill appears at bottom-center of screen\n- [ ] `⌥ + Space` toggles visibility (fallback: `Cmd+Shift+K`)\n- [ ] Tray icon appears in menu bar\n- [ ] Tray menu shows Quit option\n\n### Tab Management\n- [ ] Default tab created on launch\n- [ ] Click `+` creates a new tab\n- [ ] Clicking tab switches active tab\n- [ ] Tab shows correct status dot (idle = gray, running = orange, completed = green)\n\n### Prompt & Response\n- [ ] Type a prompt and press Enter\n- [ ] Tab status changes to \"running\" (orange dot)\n- [ ] Text streams into conversation view\n- [ ] Tool calls appear as expandable cards\n- [ ] Task completes, status changes to \"completed\" (green dot)\n- [ ] Cost/tokens shown in status bar\n\n### Permission System\n- [ ] When Claude tries to use a tool, a permission card appears\n- [ ] \"Allow\" lets the tool run\n- [ ] \"Deny\" blocks the tool\n- [ ] Permission denial is reflected in task completion\n\n### Settings\n- [ ] Three-dot button in tab strip opens settings popover\n- [ ] Sound toggle works (on/off)\n- [ ] Theme picker works (System/Light/Dark)\n- [ ] UI size toggle works (Compact/Expanded)\n- [ ] Settings persist across restart (localStorage)\n\n### History\n- [ ] Clock icon opens session history picker\n- [ ] Previous sessions listed with timestamps\n- [ ] Clicking a session loads its messages\n\n### Marketplace\n- [ ] HeadCircuit (brain) button opens marketplace panel\n- [ ] Plugins load from GitHub (requires network)\n- [ ] Search filters by name/description/tags\n- [ ] Filter chips narrow results by semantic tag\n- [ ] \"Installed\" filter shows installed plugins\n- [ ] Install flow shows confirmation with exact CLI commands\n- [ ] Graceful error state when offline\n\n### Voice Input (Whisper required — installed by install-app.command)\n- [ ] Microphone button starts recording\n- [ ] Stop button ends recording and transcribes\n- [ ] Transcribed text appears in input bar\n\n### Attachments\n- [ ] Paperclip button opens file picker\n- [ ] Camera button takes screenshot\n- [ ] Pasting an image from clipboard works\n- [ ] Attachment chips appear below input\n\n### Theme\n- [ ] Dark mode: warm dark surfaces, orange accent\n- [ ] Light mode: light surfaces, same orange accent\n- [ ] System mode follows OS dark/light setting\n\n### Window Behavior\n- [ ] Window is transparent (click-through on non-UI areas)\n- [ ] Window stays on top of other windows\n- [ ] Expanded UI mode widens the panel\n- [ ] Collapsing back to compact restores original size\n- [ ] No shadow clipping at window edges\n\n## Offline Behavior\n\n- [ ] App launches and is usable without network\n- [ ] Marketplace shows error state with \"Retry\" button\n- [ ] Skill auto-install silently skips on failure\n- [ ] All prompt/response functionality works (uses local CLI)\n\n## Last Verified\n\n- **Date:** 2026-03-12\n- **Node:** v22.x\n- **Electron:** 33.x\n- **Claude CLI:** 2.1.71\n- **macOS:** 15.x (Sequoia)\n- **Build result:** Pass (zero build errors)\n"
  },
  {
    "path": "docs/slash-command-matrix.md",
    "content": "# Slash Command Capability Matrix\n\nCLI Version: 2.1.63 | Date: 2026-03-08\nTest session: 450d2d0f-4b03-4761-8ecd-8d179998127d\n\n## Protocol Finding\n\n`--input-format stream-json` is **completely broken** in CLI 2.1.63 (hangs forever, 0 events).\nThe only working mode is one-shot `claude -p` with stdin closed + `--resume` for multi-turn.\n\n## Command Matrix\n\n| Command | Fresh | With Session | Events | Result Preview | Verdict |\n|---------|-------|-------------|--------|---------------|---------|\n| `/help` | ✅ | ✅ | system/init, result/success | Unknown skill: help | **works_native** |\n| `/model` | ✅ | ✅ | system/init, result/success | Unknown skill: model | **works_native** |\n| `/mcp` | ✅ | ✅ | system/init, result/success | Unknown skill: mcp | **works_native** |\n| `/status` | ✅ | ✅ | system/init, result/success | Unknown skill: status | **works_native** |\n| `/clear` | ✅ | ✅ | system/init, result/success | Unknown skill: clear | **works_native** |\n| `/compact` | ✅ | ✅ | system/status, rate_limit_event, system/init, system/compact_boundary, user, result/success |  | **unsupported** |\n| `/doctor` | ✅ | ✅ | system/init, result/success | Unknown skill: doctor | **works_native** |\n| `/permissions` | ✅ | ✅ | system/init, result/success | Unknown skill: permissions | **works_native** |\n| `/cost` | ✅ | ✅ | system/init, assistant, result/success | You are currently using your subscription to power | **passthrough_to_model** |\n\n## Verdict Key\n\n- **works_native**: CLI intercepts the command and returns structured output (no model call)\n- **passthrough_to_model**: CLI sends it to the model as a regular prompt (model responds)\n- **silent_exit**: CLI handles it internally but produces no result event in stream-json\n- **unsupported**: Command not recognized or errors out\n\n## Detailed Results\n\n### `/help`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: help\n```\n\n### `/model`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: model\n```\n\n### `/mcp`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: mcp\n```\n\n### `/status`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: status\n```\n\n### `/clear`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: clear\n```\n\n### `/compact`\n- Verdict: **unsupported**\n- Exit code: 0\n- Events: system/status → rate_limit_event → system/status → system/init → system/compact_boundary → user → user → result/success\n- Is error: false\n- Result text:\n```\n(empty)\n```\n\n### `/doctor`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: doctor\n```\n\n### `/permissions`\n- Verdict: **works_native**\n- Exit code: 0\n- Events: system/init → result/success\n- Is error: false\n- Result text:\n```\nUnknown skill: permissions\n```\n\n### `/cost`\n- Verdict: **passthrough_to_model**\n- Exit code: 0\n- Events: system/init → assistant → result/success\n- Is error: false\n- Result text:\n```\nYou are currently using your subscription to power your Claude Code usage\n```\n"
  },
  {
    "path": "electron.vite.config.ts",
    "content": "import { resolve } from 'path'\nimport { defineConfig, externalizeDepsPlugin } from 'electron-vite'\nimport react from '@vitejs/plugin-react'\nimport tailwindcss from '@tailwindcss/vite'\n\nexport default defineConfig({\n  main: {\n    plugins: [externalizeDepsPlugin()],\n    build: {\n      outDir: 'dist/main',\n      rollupOptions: {\n        input: {\n          index: resolve(__dirname, 'src/main/index.ts')\n        }\n      }\n    }\n  },\n  preload: {\n    plugins: [externalizeDepsPlugin()],\n    build: {\n      outDir: 'dist/preload',\n      rollupOptions: {\n        input: {\n          index: resolve(__dirname, 'src/preload/index.ts')\n        }\n      }\n    }\n  },\n  renderer: {\n    root: resolve(__dirname, 'src/renderer'),\n    plugins: [react(), tailwindcss()],\n    build: {\n      outDir: resolve(__dirname, 'dist/renderer'),\n      rollupOptions: {\n        input: {\n          index: resolve(__dirname, 'src/renderer/index.html')\n        }\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "install-app.command",
    "content": "#!/bin/bash\ncd \"$(dirname \"$0\")\"\nexec bash ./commands/install-app.command \"$@\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"clui\",\n  \"version\": \"0.1.0\",\n  \"description\": \"Clui CC — Command Line User Interface for Claude Code\",\n  \"license\": \"MIT\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/lcoutodemos/clui-cc.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/lcoutodemos/clui-cc/issues\"\n  },\n  \"homepage\": \"https://github.com/lcoutodemos/clui-cc#readme\",\n  \"main\": \"dist/main/index.js\",\n  \"scripts\": {\n    \"dev\": \"electron-vite dev\",\n    \"build\": \"electron-vite build\",\n    \"preview\": \"electron-vite preview\",\n    \"dist\": \"electron-vite build --mode production && electron-builder --mac --dir\",\n    \"doctor\": \"bash scripts/doctor.sh\",\n    \"postinstall\": \"electron-builder install-app-deps && bash scripts/patch-dev-icon.sh\"\n  },\n  \"dependencies\": {\n    \"@phosphor-icons/react\": \"^2.1.10\",\n    \"framer-motion\": \"^12.35.1\",\n    \"node-pty\": \"^1.1.0\",\n    \"react-markdown\": \"^9.0.0\",\n    \"remark-gfm\": \"^4.0.0\",\n    \"zustand\": \"^5.0.0\"\n  },\n  \"build\": {\n    \"appId\": \"com.clui.app\",\n    \"productName\": \"Clui CC\",\n    \"directories\": {\n      \"output\": \"release\"\n    },\n    \"files\": [\n      \"dist/main/**/*\",\n      \"dist/preload/**/*\",\n      \"dist/renderer/**/*\",\n      \"resources/**/*\",\n      \"package.json\"\n    ],\n    \"mac\": {\n      \"icon\": \"resources/icon.icns\",\n      \"entitlements\": \"resources/entitlements.mac.plist\",\n      \"entitlementsInherit\": \"resources/entitlements.mac.plist\",\n      \"extendInfo\": {\n        \"NSMicrophoneUsageDescription\": \"Clui CC uses your microphone to transcribe voice input locally with Whisper.\"\n      }\n    }\n  },\n  \"devDependencies\": {\n    \"@tailwindcss/vite\": \"^4.2.1\",\n    \"@types/react\": \"^19.0.0\",\n    \"@types/react-dom\": \"^19.0.0\",\n    \"@vitejs/plugin-react\": \"^4.3.0\",\n    \"autoprefixer\": \"^10.4.0\",\n    \"electron\": \"^35.7.5\",\n    \"electron-builder\": \"^26.8.1\",\n    \"electron-vite\": \"^3.0.0\",\n    \"postcss\": \"^8.4.0\",\n    \"react\": \"^19.0.0\",\n    \"react-dom\": \"^19.0.0\",\n    \"tailwindcss\": \"^4.2.1\",\n    \"typescript\": \"^5.7.0\",\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "resources/entitlements.mac.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <!-- Required for V8 JIT compilation (Electron / Node.js) -->\n  <key>com.apple.security.cs.allow-jit</key>\n  <true/>\n  <!-- Required for Electron's renderer process memory model -->\n  <key>com.apple.security.cs.allow-unsigned-executable-memory</key>\n  <true/>\n  <!-- Required to load native Node addons (node-pty, etc.) -->\n  <key>com.apple.security.cs.disable-library-validation</key>\n  <true/>\n  <!-- Required for microphone access (voice input via Whisper) -->\n  <key>com.apple.security.device.audio-input</key>\n  <true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "scripts/doctor.sh",
    "content": "#!/bin/bash\n# Clui CC environment doctor — read-only diagnostics, no installs.\n\necho \"Clui CC Environment Check\"\necho \"=========================\"\necho\n\nfail=0\nSDK_PATH=\"\"\n\n# Compare two dotted versions: returns 0 if $1 >= $2\nversion_gte() {\n  [ \"$(printf '%s\\n%s' \"$1\" \"$2\" | sort -V | head -1)\" = \"$2\" ]\n}\n\ncheck() {\n  local label=\"$1\"\n  local ok=\"$2\"\n  local detail=\"$3\"\n  if [ \"$ok\" = \"1\" ]; then\n    printf \"  PASS  %s — %s\\n\" \"$label\" \"$detail\"\n  else\n    printf \"  FAIL  %s — %s\\n\" \"$label\" \"$detail\"\n    fail=1\n  fi\n}\n\n# macOS\nif [ \"$(uname)\" = \"Darwin\" ]; then\n  ver=$(sw_vers -productVersion 2>/dev/null || echo \"0\")\n  if version_gte \"$ver\" \"13.0\"; then\n    check \"macOS\" \"1\" \"$ver\"\n  else\n    check \"macOS\" \"0\" \"$ver — requires 13+\"\n  fi\nelse\n  check \"macOS\" \"0\" \"not macOS ($(uname)) — Clui CC requires macOS\"\nfi\n\n# Node\nif command -v node &>/dev/null; then\n  node_ver=$(node --version | sed 's/^v//')\n  if version_gte \"$node_ver\" \"18.0.0\"; then\n    check \"Node.js\" \"1\" \"v$node_ver\"\n  else\n    check \"Node.js\" \"0\" \"v$node_ver — requires 18+ — brew install node\"\n  fi\nelse\n  check \"Node.js\" \"0\" \"not found — brew install node\"\nfi\n\n# npm\nif command -v npm &>/dev/null; then\n  check \"npm\" \"1\" \"$(npm --version)\"\nelse\n  check \"npm\" \"0\" \"not found — brew install node\"\nfi\n\n# Python\nif command -v python3 &>/dev/null; then\n  pyver=$(python3 --version 2>&1 | awk '{print $2}')\n  check \"Python 3\" \"1\" \"$pyver\"\nelse\n  check \"Python 3\" \"0\" \"not found — brew install python@3.11\"\nfi\n\n# distutils\nif command -v python3 &>/dev/null; then\n  if python3 -c \"import distutils\" 2>/dev/null; then\n    check \"distutils\" \"1\" \"importable\"\n  else\n    check \"distutils\" \"0\" \"missing — python3 -m pip install --upgrade pip setuptools\"\n  fi\nelse\n  check \"distutils\" \"0\" \"skipped (no python3)\"\nfi\n\n# Xcode CLT\nif xcode-select -p &>/dev/null; then\n  check \"Xcode CLT\" \"1\" \"$(xcode-select -p)\"\nelse\n  check \"Xcode CLT\" \"0\" \"not installed — xcode-select --install\"\nfi\n\n# macOS SDK\nif xcrun --sdk macosx --show-sdk-path &>/dev/null; then\n  SDK_PATH=$(xcrun --sdk macosx --show-sdk-path)\n  check \"macOS SDK\" \"1\" \"$SDK_PATH\"\nelse\n  check \"macOS SDK\" \"0\" \"not found — reinstall Xcode CLT\"\nfi\n\n# clang++\nif command -v clang++ &>/dev/null; then\n  cver=$(clang++ --version 2>&1 | head -1)\n  check \"clang++\" \"1\" \"$cver\"\n\n  # C++ headers (only probe if clang++ exists)\n  PROBE_DIR=$(mktemp -d)\n  echo '#include <functional>' > \"$PROBE_DIR/probe.cpp\"\n  echo 'int main() { return 0; }' >> \"$PROBE_DIR/probe.cpp\"\n  if clang++ -std=c++17 -c \"$PROBE_DIR/probe.cpp\" -o \"$PROBE_DIR/probe.o\" 2>/dev/null; then\n    check \"C++ headers\" \"1\" \"<functional> compiles\"\n  elif [ -n \"$SDK_PATH\" ] && clang++ -std=c++17 -isysroot \"$SDK_PATH\" -I\"$SDK_PATH/usr/include/c++/v1\" -c \"$PROBE_DIR/probe.cpp\" -o \"$PROBE_DIR/probe.o\" 2>/dev/null; then\n    check \"C++ headers\" \"1\" \"<functional> compiles (using SDK include path)\"\n  else\n    check \"C++ headers\" \"0\" \"<functional> missing — reinstall Xcode CLT\"\n  fi\n  rm -rf \"$PROBE_DIR\"\nelse\n  check \"clang++\" \"0\" \"not found — xcode-select --install\"\n  check \"C++ headers\" \"0\" \"skipped (no clang++)\"\nfi\n\n# Claude CLI\nif command -v claude &>/dev/null; then\n  cver=$(claude --version 2>/dev/null || echo \"unknown\")\n  check \"Claude CLI\" \"1\" \"$cver\"\nelse\n  check \"Claude CLI\" \"0\" \"not found — npm install -g @anthropic-ai/claude-code\"\nfi\n\necho\nif [ \"$fail\" -ne 0 ]; then\n  echo \"Some checks failed. Fix them above, then rerun:\"\n  echo\n  echo \"  ./commands/setup.command\"\nelse\n  echo \"Environment looks good.\"\nfi\n"
  },
  {
    "path": "scripts/patch-dev-icon.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nELECTRON_APP=\"node_modules/electron/dist/Electron.app\"\nRESOURCES=\"$ELECTRON_APP/Contents/Resources\"\nICON_SRC=\"resources/icon.icns\"\n\n# Only run on macOS\n[[ \"$(uname)\" == \"Darwin\" ]] || exit 0\n\n# Only run if source icon exists\n[[ -f \"$ICON_SRC\" ]] || exit 0\n\n# Replace the icon\ncp \"$ICON_SRC\" \"$RESOURCES/electron.icns\"\n\n# Touch the bundle to invalidate macOS icon cache\ntouch \"$ELECTRON_APP\"\n\n# Re-sign with ad-hoc signature (required after modifying bundle contents)\ncodesign --force --deep --sign - \"$ELECTRON_APP\" 2>/dev/null || true\n"
  },
  {
    "path": "src/main/claude/control-plane.ts",
    "content": "import { EventEmitter } from 'events'\nimport { RunManager } from './run-manager'\nimport { PtyRunManager } from './pty-run-manager'\nimport { PermissionServer, maskSensitiveFields } from '../hooks/permission-server'\nimport type { HookToolRequest, PermissionOption } from '../hooks/permission-server'\nimport { log as _log } from '../logger'\nimport type {\n  TabStatus,\n  TabRegistryEntry,\n  HealthReport,\n  NormalizedEvent,\n  RunOptions,\n  EnrichedError,\n} from '../../shared/types'\n\nconst MAX_QUEUE_DEPTH = 32\n\nfunction log(msg: string): void {\n  _log('ControlPlane', msg)\n}\n\ninterface QueuedRequest {\n  requestId: string\n  tabId: string\n  options: RunOptions\n  resolve: (value: void) => void\n  reject: (reason: Error) => void\n  enqueuedAt: number\n  /** Additional waiters that called submitPrompt with the same requestId */\n  extraWaiters: Array<{ resolve: (value: void) => void; reject: (reason: Error) => void }>\n}\n\ninterface InflightRequest {\n  requestId: string\n  tabId: string\n  promise: Promise<void>\n  resolve: (value: void) => void\n  reject: (reason: Error) => void\n}\n\n/**\n * ControlPlane: the single backend authority for tab/session lifecycle.\n *\n * Responsibilities:\n *  1. Tab/session registry\n *  2. Request queue + backpressure\n *  3. RequestId idempotency\n *  4. Target session guard\n *  5. Run lifecycle state transitions\n *  6. Health reporting for renderer reconciliation\n *  7. Diagnostic data (delegated to RunManager ring buffers)\n *\n * Events emitted (forwarded from RunManager, tagged with tabId):\n *  - 'event' (tabId, NormalizedEvent)\n *  - 'tab-status-change' (tabId, newStatus, oldStatus)\n *  - 'error' (tabId, EnrichedError)\n */\nexport class ControlPlane extends EventEmitter {\n  private tabs = new Map<string, TabRegistryEntry>()\n  private inflightRequests = new Map<string, InflightRequest>()\n  private requestQueue: QueuedRequest[] = []\n  private runManager: RunManager\n  private ptyRunManager: PtyRunManager\n  /** Feature flag: use PTY transport for interactive permissions */\n  private interactivePty: boolean\n  /** Tracks which runs are using PTY transport (by requestId) */\n  private ptyRuns = new Set<string>()\n  /** Tracks requestIds that are warmup init requests (invisible to renderer) */\n  private initRequestIds = new Set<string>()\n  /** Permission hook server for PreToolUse HTTP hooks */\n  private permissionServer: PermissionServer\n  /** Per-run tokens: requestId → runToken (for cleanup on exit/error) */\n  private runTokens = new Map<string, string>()\n  /** Global permission mode: 'ask' shows cards, 'auto' auto-approves */\n  private permissionMode: 'ask' | 'auto' = 'ask'\n  /** Resolves when the permission server is ready (or failed). Dispatch awaits this. */\n  private hookServerReady: Promise<void>\n\n  constructor(interactivePty = false) {\n    super()\n    this.interactivePty = interactivePty\n    this.runManager = new RunManager()\n    this.ptyRunManager = new PtyRunManager()\n    this.permissionServer = new PermissionServer()\n\n    // Start the permission hook server. _dispatch awaits hookServerReady\n    // so early prompts don't silently fall back to the --allowedTools path.\n    this.hookServerReady = this.permissionServer.start()\n      .then((port) => {\n        log(`Permission hook server ready on port ${port}`)\n      })\n      .catch((err) => {\n        log(`Failed to start permission hook server: ${(err as Error).message}`)\n        // No hook server → dispatch falls back to --allowedTools\n      })\n\n    // Wire permission server events → normalized events for renderer.\n    // 4-arg signature: (questionId, toolRequest, tabId, options)\n    // tabId comes directly from per-run token registration — no session_id lookup needed.\n    this.permissionServer.on('permission-request', (questionId: string, toolRequest: HookToolRequest, tabId: string, options: PermissionOption[]) => {\n      // Verify tab still exists — deny immediately if closed (prevents 5-min timeout hang)\n      if (!this.tabs.has(tabId)) {\n        log(`Permission request for closed tab ${tabId.substring(0, 8)}… — auto-denying`)\n        this.permissionServer.respondToPermission(questionId, 'deny', 'Tab closed')\n        return\n      }\n\n      log(`Permission request [${questionId}]: tool=${toolRequest.tool_name} tab=${tabId.substring(0, 8)}… mode=${this.permissionMode}`)\n\n      // Auto mode: immediately allow without showing UI\n      if (this.permissionMode === 'auto') {\n        this.permissionServer.respondToPermission(questionId, 'allow', 'Auto mode')\n        return\n      }\n\n      // Mask sensitive fields before sending to renderer (defense-in-depth)\n      const safeInput = toolRequest.tool_input\n        ? maskSensitiveFields(toolRequest.tool_input)\n        : undefined\n\n      const permEvent: NormalizedEvent = {\n        type: 'permission_request',\n        questionId,\n        toolName: toolRequest.tool_name,\n        toolDescription: undefined,\n        toolInput: safeInput,\n        options,\n      }\n      this.emit('event', tabId, permEvent)\n    })\n\n    log(`Interactive PTY transport: ${interactivePty ? 'ENABLED' : 'disabled'}`)\n\n    // ─── Wire PtyRunManager events → ControlPlane routing ───\n    this._wirePtyEvents()\n\n    // ─── Wire RunManager events → ControlPlane routing ───\n\n    this.runManager.on('normalized', (requestId: string, event: NormalizedEvent) => {\n      const tabId = this._findTabByRequest(requestId)\n      if (!tabId) return\n\n      const tab = this.tabs.get(tabId)\n      if (!tab) return\n\n      tab.lastActivityAt = Date.now()\n\n      // Handle session init\n      if (event.type === 'session_init') {\n        tab.claudeSessionId = event.sessionId\n\n        if (this.initRequestIds.has(requestId)) {\n          // Warmup init — emit session_init with isWarmup flag, don't change status\n          this.emit('event', tabId, { ...event, isWarmup: true })\n          return\n        }\n\n        if (tab.status === 'connecting') {\n          this._setTabStatus(tabId, 'running')\n        }\n      }\n\n      // Suppress all events from init requests (session_init already handled above)\n      if (this.initRequestIds.has(requestId)) {\n        return\n      }\n\n      this.emit('event', tabId, event)\n    })\n\n    this.runManager.on('exit', (requestId: string, code: number | null, signal: string | null, sessionId: string | null) => {\n      // Clean up per-run token\n      const runToken = this.runTokens.get(requestId)\n      if (runToken) {\n        this.permissionServer.unregisterRun(runToken)\n        this.runTokens.delete(requestId)\n      }\n\n      const tabId = this._findTabByRequest(requestId)\n\n      // Always clean up inflight promise, even if tab was already closed.\n      // This prevents leaked promises when closeTab() races with process exit.\n      const inflight = this.inflightRequests.get(requestId)\n\n      if (!tabId || !this.tabs.get(tabId)) {\n        // Tab was already closed — just resolve/reject the orphaned promise\n        if (inflight) {\n          inflight.resolve()\n          this.inflightRequests.delete(requestId)\n        }\n        return\n      }\n\n      const tab = this.tabs.get(tabId)!\n\n      tab.activeRequestId = null\n      tab.runPid = null\n\n      if (sessionId) tab.claudeSessionId = sessionId\n\n      // Init request: silently transition to idle\n      if (this.initRequestIds.has(requestId)) {\n        this.initRequestIds.delete(requestId)\n        this._setTabStatus(tabId, 'idle')\n        if (inflight) {\n          inflight.resolve()\n          this.inflightRequests.delete(requestId)\n        }\n        this._processQueue(tabId)\n        return\n      }\n\n      if (code === 0) {\n        this._setTabStatus(tabId, 'completed')\n      } else if (signal === 'SIGINT' || signal === 'SIGKILL') {\n        // Cancelled by user\n        this._setTabStatus(tabId, 'failed')\n      } else {\n        // Unexpected exit — emit enriched error (includes stderr tail)\n        const enriched = this.runManager.getEnrichedError(requestId, code)\n        this.emit('error', tabId, enriched)\n        this._setTabStatus(tabId, code === null ? 'dead' : 'failed')\n      }\n\n      // Resolve the inflight promise\n      if (inflight) {\n        inflight.resolve()\n        this.inflightRequests.delete(requestId)\n      }\n\n      // Process next queued request for this tab\n      this._processQueue(tabId)\n    })\n\n    this.runManager.on('error', (requestId: string, err: Error) => {\n      // Clean up per-run token\n      const runToken = this.runTokens.get(requestId)\n      if (runToken) {\n        this.permissionServer.unregisterRun(runToken)\n        this.runTokens.delete(requestId)\n      }\n\n      const tabId = this._findTabByRequest(requestId)\n\n      // Always clean up inflight even if tab is gone\n      const inflight = this.inflightRequests.get(requestId)\n\n      if (!tabId || !this.tabs.get(tabId)) {\n        if (inflight) {\n          inflight.reject(err)\n          this.inflightRequests.delete(requestId)\n        }\n        return\n      }\n\n      const tab = this.tabs.get(tabId)!\n      tab.activeRequestId = null\n      tab.runPid = null\n\n      // Init request: silently fail, go idle so user can still use the tab\n      if (this.initRequestIds.has(requestId)) {\n        this.initRequestIds.delete(requestId)\n        log(`Init session error for tab ${tabId}: ${err.message}`)\n        this._setTabStatus(tabId, 'idle')\n        if (inflight) {\n          inflight.reject(err)\n          this.inflightRequests.delete(requestId)\n        }\n        this._processQueue(tabId)\n        return\n      }\n\n      this._setTabStatus(tabId, 'dead')\n\n      // Use enriched diagnostics — _finishedRuns holds the handle with\n      // stderr/stdout ring buffers even after the process errored out.\n      const enriched = this.runManager.getEnrichedError(requestId, null)\n      enriched.message = err.message\n      this.emit('error', tabId, enriched)\n\n      if (inflight) {\n        inflight.reject(err)\n        this.inflightRequests.delete(requestId)\n      }\n    })\n  }\n\n  /**\n   * Wire PtyRunManager events using the same routing logic as RunManager.\n   */\n  private _wirePtyEvents(): void {\n    // Normalized events → same routing as RunManager\n    this.ptyRunManager.on('normalized', (requestId: string, event: NormalizedEvent) => {\n      const tabId = this._findTabByRequest(requestId)\n      if (!tabId) return\n\n      const tab = this.tabs.get(tabId)\n      if (!tab) return\n\n      tab.lastActivityAt = Date.now()\n\n      // Handle session init\n      if (event.type === 'session_init') {\n        tab.claudeSessionId = event.sessionId\n\n        if (this.initRequestIds.has(requestId)) {\n          this.emit('event', tabId, { ...event, isWarmup: true })\n          return\n        }\n\n        if (tab.status === 'connecting') {\n          this._setTabStatus(tabId, 'running')\n        }\n      }\n\n      // Suppress events from init requests\n      if (this.initRequestIds.has(requestId)) return\n\n      this.emit('event', tabId, event)\n    })\n\n    // Exit events\n    this.ptyRunManager.on('exit', (requestId: string, code: number | null, signal: number | null, sessionId: string | null) => {\n      // Clean up per-run token\n      const runToken = this.runTokens.get(requestId)\n      if (runToken) {\n        this.permissionServer.unregisterRun(runToken)\n        this.runTokens.delete(requestId)\n      }\n\n      const tabId = this._findTabByRequest(requestId)\n      const inflight = this.inflightRequests.get(requestId)\n\n      // Clean up PTY run tracking\n      this.ptyRuns.delete(requestId)\n\n      if (!tabId || !this.tabs.get(tabId)) {\n        if (inflight) {\n          inflight.resolve()\n          this.inflightRequests.delete(requestId)\n        }\n        return\n      }\n\n      const tab = this.tabs.get(tabId)!\n      tab.activeRequestId = null\n      tab.runPid = null\n      if (sessionId) tab.claudeSessionId = sessionId\n\n      if (this.initRequestIds.has(requestId)) {\n        this.initRequestIds.delete(requestId)\n        this._setTabStatus(tabId, 'idle')\n        if (inflight) {\n          inflight.resolve()\n          this.inflightRequests.delete(requestId)\n        }\n        this._processQueue(tabId)\n        return\n      }\n\n      if (code === 0) {\n        this._setTabStatus(tabId, 'completed')\n      } else if (signal) {\n        this._setTabStatus(tabId, 'failed')\n      } else {\n        const enriched = this.ptyRunManager.getEnrichedError(requestId, code)\n        this.emit('error', tabId, enriched)\n        this._setTabStatus(tabId, code === null ? 'dead' : 'failed')\n      }\n\n      if (inflight) {\n        inflight.resolve()\n        this.inflightRequests.delete(requestId)\n      }\n\n      this._processQueue(tabId)\n    })\n\n    // Error events\n    this.ptyRunManager.on('error', (requestId: string, err: Error) => {\n      // Clean up per-run token\n      const runToken = this.runTokens.get(requestId)\n      if (runToken) {\n        this.permissionServer.unregisterRun(runToken)\n        this.runTokens.delete(requestId)\n      }\n\n      const tabId = this._findTabByRequest(requestId)\n      const inflight = this.inflightRequests.get(requestId)\n\n      this.ptyRuns.delete(requestId)\n\n      if (!tabId || !this.tabs.get(tabId)) {\n        if (inflight) {\n          inflight.reject(err)\n          this.inflightRequests.delete(requestId)\n        }\n        return\n      }\n\n      const tab = this.tabs.get(tabId)!\n      tab.activeRequestId = null\n      tab.runPid = null\n\n      if (this.initRequestIds.has(requestId)) {\n        this.initRequestIds.delete(requestId)\n        log(`PTY init session error for tab ${tabId}: ${err.message}`)\n        this._setTabStatus(tabId, 'idle')\n        if (inflight) {\n          inflight.reject(err)\n          this.inflightRequests.delete(requestId)\n        }\n        this._processQueue(tabId)\n        return\n      }\n\n      this._setTabStatus(tabId, 'dead')\n\n      const enriched = this.ptyRunManager.getEnrichedError(requestId, null)\n      enriched.message = err.message\n      this.emit('error', tabId, enriched)\n\n      if (inflight) {\n        inflight.reject(err)\n        this.inflightRequests.delete(requestId)\n      }\n    })\n  }\n\n  // ─── Tab Lifecycle ───\n\n  createTab(): string {\n    const tabId = crypto.randomUUID()\n    const entry: TabRegistryEntry = {\n      tabId,\n      claudeSessionId: null,\n      status: 'idle',\n      activeRequestId: null,\n      runPid: null,\n      createdAt: Date.now(),\n      lastActivityAt: Date.now(),\n      promptCount: 0,\n    }\n    this.tabs.set(tabId, entry)\n    log(`Tab created: ${tabId}`)\n    return tabId\n  }\n\n  /**\n   * Eagerly initialize a session for a tab by running a minimal prompt.\n   * Populates session metadata (model, MCP servers, tools) without visible messages.\n   */\n  initSession(tabId: string): void {\n    const tab = this.tabs.get(tabId)\n    if (!tab) return\n\n    const requestId = `init-${tabId}`\n    this.initRequestIds.add(requestId)\n\n    this.submitPrompt(tabId, requestId, {\n      prompt: 'hi',\n      projectPath: process.cwd(),\n      maxTurns: 1,\n    }).catch((err) => {\n      this.initRequestIds.delete(requestId)\n      log(`Init session failed for tab ${tabId}: ${(err as Error).message}`)\n    })\n  }\n\n  /**\n   * Clear stored session ID for a tab — used when working directory changes\n   * so _dispatch won't inject a stale --resume from the old directory.\n   */\n  resetTabSession(tabId: string): void {\n    const tab = this.tabs.get(tabId)\n    if (!tab) return\n    log(`Resetting session for tab ${tabId} (was: ${tab.claudeSessionId})`)\n    tab.claudeSessionId = null\n  }\n\n  /**\n   * Set global permission mode.\n   * 'ask' = show permission cards, 'auto' = auto-approve all tool calls.\n   */\n  setPermissionMode(mode: 'ask' | 'auto'): void {\n    log(`Permission mode set to: ${mode}`)\n    this.permissionMode = mode\n  }\n\n  closeTab(tabId: string): void {\n    const tab = this.tabs.get(tabId)\n    if (!tab) return\n\n    // Cancel active run if any\n    if (tab.activeRequestId) {\n      this.cancel(tab.activeRequestId)\n\n      // Resolve and clean up the inflight promise so it doesn't leak.\n      // The exit handler may never fire for this tab since we're deleting it.\n      const inflight = this.inflightRequests.get(tab.activeRequestId)\n      if (inflight) {\n        inflight.reject(new Error('Tab closed'))\n        this.inflightRequests.delete(tab.activeRequestId)\n      }\n    }\n\n    // Remove queued requests for this tab, rejecting all waiters\n    this.requestQueue = this.requestQueue.filter((r) => {\n      if (r.tabId === tabId) {\n        const reason = new Error('Tab closed')\n        r.reject(reason)\n        for (const w of r.extraWaiters) w.reject(reason)\n        return false\n      }\n      return true\n    })\n\n    this.tabs.delete(tabId)\n    log(`Tab closed: ${tabId}`)\n  }\n\n  // ─── Submit Prompt ───\n\n  /**\n   * Submit a prompt to a specific tab. Returns a promise that resolves\n   * when the run completes.\n   *\n   * Guards:\n   *  - Rejects without targetSession (tabId)\n   *  - Returns existing promise for duplicate requestId (idempotency)\n   *  - Queues if tab is busy, rejects if queue is full\n   */\n  async submitPrompt(\n    tabId: string,\n    requestId: string,\n    options: RunOptions,\n  ): Promise<void> {\n    // ─── Guard: target session required ───\n    if (!tabId) {\n      throw new Error('No targetSession (tabId) provided — rejecting to prevent misrouting')\n    }\n\n    const tab = this.tabs.get(tabId)\n    if (!tab) {\n      throw new Error(`Tab ${tabId} does not exist`)\n    }\n\n    // ─── Guard: requestId idempotency (check inflight AND queue) ───\n    const existing = this.inflightRequests.get(requestId)\n    if (existing) {\n      log(`Duplicate requestId ${requestId} — returning existing inflight promise`)\n      return existing.promise\n    }\n\n    const queued = this.requestQueue.find((r) => r.requestId === requestId)\n    if (queued) {\n      log(`Duplicate requestId ${requestId} — already queued, adding waiter`)\n      return new Promise<void>((resolve, reject) => {\n        queued.extraWaiters.push({ resolve, reject })\n      })\n    }\n\n    // ─── If tab has an active run, queue the request ───\n    if (tab.activeRequestId) {\n      if (this.requestQueue.length >= MAX_QUEUE_DEPTH) {\n        throw new Error('Request queue full — back-pressure')\n      }\n\n      log(`Tab ${tabId} busy — queuing request ${requestId} (queue depth: ${this.requestQueue.length + 1})`)\n      return new Promise<void>((resolve, reject) => {\n        this.requestQueue.push({\n          requestId,\n          tabId,\n          options,\n          resolve,\n          reject,\n          enqueuedAt: Date.now(),\n          extraWaiters: [],\n        })\n      })\n    }\n\n    // ─── Dispatch immediately ───\n    return this._dispatch(tabId, requestId, options)\n  }\n\n  private async _dispatch(tabId: string, requestId: string, options: RunOptions): Promise<void> {\n    const tab = this.tabs.get(tabId)\n    if (!tab) throw new Error(`Tab ${tabId} disappeared`)\n\n    // Wait for the permission hook server to be ready (or failed).\n    // This prevents early prompts from silently falling back to --allowedTools.\n    await this.hookServerReady\n\n    // Use stored session ID for resume if available and not overridden\n    if (tab.claudeSessionId && !options.sessionId) {\n      options = { ...options, sessionId: tab.claudeSessionId }\n    }\n\n    // Per-run token lifecycle: register run, generate per-run settings file\n    if (this.permissionServer.getPort()) {\n      const runToken = this.permissionServer.registerRun(tabId, requestId, options.sessionId || null)\n      this.runTokens.set(requestId, runToken)\n      const hookSettingsPath = this.permissionServer.generateSettingsFile(runToken)\n      options = { ...options, hookSettingsPath }\n    }\n\n    tab.activeRequestId = requestId\n    if (!this.initRequestIds.has(requestId)) tab.promptCount++\n    tab.lastActivityAt = Date.now()\n\n    // Set status to connecting (first run) or running (subsequent)\n    const newStatus: TabStatus = tab.claudeSessionId ? 'running' : 'connecting'\n    this._setTabStatus(tabId, newStatus)\n\n    // ─── Pick transport ───\n    // Stream-json is the stable transport for all regular messages.\n    // PTY is reserved for future interactive permission handling only.\n    const usePty = false\n\n    let pid: number | null = null\n    try {\n      if (usePty) {\n        log(`Dispatching via PTY transport: ${requestId}`)\n        const handle = this.ptyRunManager.startRun(requestId, options)\n        this.ptyRuns.add(requestId)\n        pid = handle.pid\n      } else {\n        const handle = this.runManager.startRun(requestId, options)\n        pid = handle.pid\n      }\n      tab.runPid = pid\n    } catch (err) {\n      // Start failure before inflight registration: rollback tab run state.\n      tab.activeRequestId = null\n      tab.runPid = null\n      this._setTabStatus(tabId, 'failed')\n      throw err\n    }\n\n    // Create inflight promise\n    let resolve!: (value: void) => void\n    let reject!: (reason: Error) => void\n    const promise = new Promise<void>((res, rej) => {\n      resolve = res\n      reject = rej\n    })\n\n    this.inflightRequests.set(requestId, { requestId, tabId, promise, resolve, reject })\n    return promise\n  }\n\n  // ─── Cancel ───\n\n  cancel(requestId: string): boolean {\n    // Check if it's in the queue first\n    const queueIdx = this.requestQueue.findIndex((r) => r.requestId === requestId)\n    if (queueIdx !== -1) {\n      const req = this.requestQueue.splice(queueIdx, 1)[0]\n      const reason = new Error('Request cancelled')\n      req.reject(reason)\n      for (const w of req.extraWaiters) w.reject(reason)\n      log(`Cancelled queued request ${requestId}`)\n      return true\n    }\n\n    // Cancel active run — route to correct transport\n    if (this.ptyRuns.has(requestId)) {\n      return this.ptyRunManager.cancel(requestId)\n    }\n    return this.runManager.cancel(requestId)\n  }\n\n  /**\n   * Cancel active run on a tab (by tabId instead of requestId).\n   */\n  cancelTab(tabId: string): boolean {\n    const tab = this.tabs.get(tabId)\n    if (!tab?.activeRequestId) return false\n    return this.cancel(tab.activeRequestId)\n  }\n\n  // ─── Retry ───\n\n  /**\n   * Retry: re-submit the same prompt on the same tab/session.\n   * If the tab is dead, creates a fresh session.\n   */\n  async retry(tabId: string, requestId: string, options: RunOptions): Promise<void> {\n    const tab = this.tabs.get(tabId)\n    if (!tab) throw new Error(`Tab ${tabId} does not exist`)\n\n    // If dead, clear session so a new one starts\n    if (tab.status === 'dead') {\n      tab.claudeSessionId = null\n      this._setTabStatus(tabId, 'idle')\n    }\n\n    return this.submitPrompt(tabId, requestId, options)\n  }\n\n  // ─── Permission Response ───\n\n  respondToPermission(tabId: string, questionId: string, optionId: string): boolean {\n    // Route to hook server if this is a hook-based permission request.\n    // Pass optionId directly — it matches the permission card option IDs\n    // (allow, allow-session, allow-domain, deny).\n    if (questionId.startsWith('hook-')) {\n      return this.permissionServer.respondToPermission(questionId, optionId)\n    }\n\n    const tab = this.tabs.get(tabId)\n    if (!tab?.activeRequestId) return false\n\n    // Route to correct transport\n    if (this.ptyRuns.has(tab.activeRequestId)) {\n      return this.ptyRunManager.respondToPermission(tab.activeRequestId, questionId, optionId)\n    }\n\n    // Print-json transport: send structured permission response via stdin\n    const msg = {\n      type: 'permission_response',\n      question_id: questionId,\n      option_id: optionId,\n    }\n\n    return this.runManager.writeToStdin(tab.activeRequestId, msg)\n  }\n\n  // ─── Health ───\n\n  getHealth(): HealthReport {\n    const tabEntries: HealthReport['tabs'] = []\n\n    for (const [tabId, tab] of this.tabs) {\n      let alive = false\n      if (tab.activeRequestId) {\n        alive = this.runManager.isRunning(tab.activeRequestId)\n          || this.ptyRunManager.isRunning(tab.activeRequestId)\n      }\n\n      tabEntries.push({\n        tabId,\n        status: tab.status,\n        activeRequestId: tab.activeRequestId,\n        claudeSessionId: tab.claudeSessionId,\n        alive,\n      })\n    }\n\n    return {\n      tabs: tabEntries,\n      queueDepth: this.requestQueue.length,\n    }\n  }\n\n  getTabStatus(tabId: string): TabRegistryEntry | undefined {\n    return this.tabs.get(tabId)\n  }\n\n  getEnrichedError(requestId: string, exitCode: number | null): EnrichedError {\n    if (this.ptyRuns.has(requestId)) {\n      return this.ptyRunManager.getEnrichedError(requestId, exitCode)\n    }\n    return this.runManager.getEnrichedError(requestId, exitCode)\n  }\n\n  // ─── Queue Processing ───\n\n  private _processQueue(tabId: string): void {\n    // Find next queued request for this specific tab\n    const idx = this.requestQueue.findIndex((r) => r.tabId === tabId)\n    if (idx === -1) return\n\n    const req = this.requestQueue.splice(idx, 1)[0]\n    log(`Processing queued request ${req.requestId} for tab ${tabId}`)\n\n    this._dispatch(tabId, req.requestId, req.options)\n      .then((v) => {\n        req.resolve(v)\n        for (const w of req.extraWaiters) w.resolve(v)\n      })\n      .catch((e) => {\n        req.reject(e)\n        for (const w of req.extraWaiters) w.reject(e)\n      })\n  }\n\n  // ─── Internal ───\n\n  private _findTabByRequest(requestId: string): string | null {\n    const inflight = this.inflightRequests.get(requestId)\n    if (inflight) return inflight.tabId\n\n    // Also check registry entries\n    for (const [tabId, tab] of this.tabs) {\n      if (tab.activeRequestId === requestId) return tabId\n    }\n\n    return null\n  }\n\n  private _setTabStatus(tabId: string, newStatus: TabStatus): void {\n    const tab = this.tabs.get(tabId)\n    if (!tab) return\n\n    const oldStatus = tab.status\n    if (oldStatus === newStatus) return\n\n    tab.status = newStatus\n    log(`Tab ${tabId}: ${oldStatus} → ${newStatus}`)\n    this.emit('tab-status-change', tabId, newStatus, oldStatus)\n  }\n\n  // ─── Shutdown ───\n\n  shutdown(): void {\n    log('Shutting down control plane')\n    this.permissionServer.stop()\n    for (const [tabId] of this.tabs) {\n      this.closeTab(tabId)\n    }\n  }\n}\n"
  },
  {
    "path": "src/main/claude/event-normalizer.ts",
    "content": "import type {\n  ClaudeEvent,\n  NormalizedEvent,\n  StreamEvent,\n  InitEvent,\n  AssistantEvent,\n  ResultEvent,\n  RateLimitEvent,\n  PermissionEvent,\n  ContentDelta,\n} from '../../shared/types'\n\n/**\n * Maps raw Claude stream-json events to canonical CLUI events.\n *\n * The normalizer is stateless — it takes one raw event and returns\n * zero or more normalized events. The caller (RunManager) is responsible\n * for sequencing and routing.\n */\nexport function normalize(raw: ClaudeEvent): NormalizedEvent[] {\n  switch (raw.type) {\n    case 'system':\n      return normalizeSystem(raw as InitEvent)\n\n    case 'stream_event':\n      return normalizeStreamEvent(raw as StreamEvent)\n\n    case 'assistant':\n      return normalizeAssistant(raw as AssistantEvent)\n\n    case 'result':\n      return normalizeResult(raw as ResultEvent)\n\n    case 'rate_limit_event':\n      return normalizeRateLimit(raw as RateLimitEvent)\n\n    case 'permission_request':\n      return normalizePermission(raw as PermissionEvent)\n\n    default:\n      // Unknown event type — skip silently (defensive)\n      return []\n  }\n}\n\nfunction normalizeSystem(event: InitEvent): NormalizedEvent[] {\n  if (event.subtype !== 'init') return []\n\n  return [{\n    type: 'session_init',\n    sessionId: event.session_id,\n    tools: event.tools || [],\n    model: event.model || 'unknown',\n    mcpServers: event.mcp_servers || [],\n    skills: event.skills || [],\n    version: event.claude_code_version || 'unknown',\n  }]\n}\n\nfunction normalizeStreamEvent(event: StreamEvent): NormalizedEvent[] {\n  const sub = event.event\n  if (!sub) return []\n\n  switch (sub.type) {\n    case 'content_block_start': {\n      if (sub.content_block.type === 'tool_use') {\n        return [{\n          type: 'tool_call',\n          toolName: sub.content_block.name || 'unknown',\n          toolId: sub.content_block.id || '',\n          index: sub.index,\n        }]\n      }\n      // text block start — no event needed, text comes via deltas\n      return []\n    }\n\n    case 'content_block_delta': {\n      const delta = sub.delta as ContentDelta\n      if (delta.type === 'text_delta') {\n        return [{ type: 'text_chunk', text: delta.text }]\n      }\n      if (delta.type === 'input_json_delta') {\n        return [{\n          type: 'tool_call_update',\n          toolId: '', // caller can associate via index tracking\n          partialInput: delta.partial_json,\n        }]\n      }\n      return []\n    }\n\n    case 'content_block_stop': {\n      return [{\n        type: 'tool_call_complete',\n        index: sub.index,\n      }]\n    }\n\n    case 'message_start':\n    case 'message_delta':\n    case 'message_stop':\n      // These are structural events — the assembled `assistant` event handles message completion\n      return []\n\n    default:\n      return []\n  }\n}\n\nfunction normalizeAssistant(event: AssistantEvent): NormalizedEvent[] {\n  return [{\n    type: 'task_update',\n    message: event.message,\n  }]\n}\n\nfunction normalizeResult(event: ResultEvent): NormalizedEvent[] {\n  if (event.is_error || event.subtype === 'error') {\n    return [{\n      type: 'error',\n      message: event.result || 'Unknown error',\n      isError: true,\n      sessionId: event.session_id,\n    }]\n  }\n\n  const denials = Array.isArray((event as any).permission_denials)\n    ? (event as any).permission_denials.map((d: any) => ({\n        toolName: d.tool_name || '',\n        toolUseId: d.tool_use_id || '',\n      }))\n    : undefined\n\n  return [{\n    type: 'task_complete',\n    result: event.result || '',\n    costUsd: event.total_cost_usd || 0,\n    durationMs: event.duration_ms || 0,\n    numTurns: event.num_turns || 0,\n    usage: event.usage || {},\n    sessionId: event.session_id,\n    ...(denials && denials.length > 0 ? { permissionDenials: denials } : {}),\n  }]\n}\n\nfunction normalizeRateLimit(event: RateLimitEvent): NormalizedEvent[] {\n  const info = event.rate_limit_info\n  if (!info) return []\n\n  return [{\n    type: 'rate_limit',\n    status: info.status,\n    resetsAt: info.resetsAt,\n    rateLimitType: info.rateLimitType,\n  }]\n}\n\nfunction normalizePermission(event: PermissionEvent): NormalizedEvent[] {\n  return [{\n    type: 'permission_request',\n    questionId: event.question_id,\n    toolName: event.tool?.name || 'unknown',\n    toolDescription: event.tool?.description,\n    toolInput: event.tool?.input,\n    options: (event.options || []).map((o) => ({\n      id: o.id,\n      label: o.label,\n      kind: o.kind,\n    })),\n  }]\n}\n"
  },
  {
    "path": "src/main/claude/pty-run-manager.ts",
    "content": "/**\n * PtyRunManager: Interactive PTY transport for Claude Code.\n *\n * Spawns `claude` (without -p) via node-pty to get the full interactive\n * terminal experience, including permission prompts. Parses the PTY output\n * to extract text, tool calls, and permission requests, then emits\n * normalized events identical to RunManager.\n *\n * This module is behind the `CLUI_INTERACTIVE_PERMISSIONS_PTY` feature flag.\n *\n * Known limitations:\n * - Parsing depends on Claude CLI's terminal output format (Ink-based)\n * - ANSI stripping may lose some formatting nuance\n * - Permission prompt detection uses heuristics, not a formal grammar\n * - If the CLI's UI changes significantly, the parser may break\n */\n\nimport { EventEmitter } from 'events'\nimport { homedir } from 'os'\nimport { join } from 'path'\nimport { execSync } from 'child_process'\nimport { appendFileSync, chmodSync, existsSync, statSync } from 'fs'\nimport type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'\nimport { getCliEnv } from '../cli-env'\n\n// node-pty is a native module — require at runtime to avoid Vite bundling issues\n// eslint-disable-next-line @typescript-eslint/no-var-requires\nlet pty: typeof import('node-pty')\ntry {\n  pty = require('node-pty')\n} catch (err) {\n  // Will be set when first needed — fail at startRun() time, not import time\n}\n\nconst LOG_FILE = join(homedir(), '.clui-debug.log')\nconst MAX_RING_LINES = 100\nconst PTY_BUFFER_SIZE = 50 // rolling window of cleaned lines for parser context\nconst PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes\nconst QUIESCENCE_MS = 2000\n\nfunction log(msg: string): void {\n  const line = `[${new Date().toISOString()}] [PtyRunManager] ${msg}\\n`\n  try { appendFileSync(LOG_FILE, line) } catch {}\n}\n\n// ─── ANSI Stripping ───\n\n/**\n * Strip ANSI escape sequences (colors, cursor movement, clear line, etc.)\n */\nfunction stripAnsi(str: string): string {\n  // Covers CSI sequences including private modes like ?2004h\n  return str.replace(/\\x1b\\[[0-9;?]*[ -/]*[@-~]/g, '')\n    .replace(/\\x1b\\][^\\x07]*\\x07/g, '')  // OSC sequences\n    .replace(/\\x1b[()][0-9A-Za-z]/g, '')  // character set selection\n    .replace(/\\x1b[#=>\\[\\]]/g, '')         // misc escapes\n    .replace(/[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f]/g, '') // control chars except \\n \\r \\t\n}\n\n// ─── Permission Prompt Detection ───\n\ninterface ParsedPermission {\n  toolName: string\n  rawPrompt: string\n  options: Array<{ optionId: string; label: string; terminalValue: string }>\n}\n\n/**\n * Confidence-scored permission prompt detector.\n * Looks at a window of cleaned terminal lines and tries to identify\n * a Claude permission prompt.\n */\nfunction detectPermissionPrompt(lines: string[]): ParsedPermission | null {\n  const joined = lines.join('\\n')\n\n  // ─── Pattern 1: \"Claude wants to use <ToolName>\" or \"Allow <ToolName>\" ───\n  // The interactive CLI typically shows something like:\n  //   \"Claude wants to use Bash\"\n  //   \"Command: ls -la\"\n  //   \"❯ Allow for this project  Allow once  Deny\"\n\n  let confidence = 0\n  let toolName = ''\n  let rawPrompt = ''\n\n  // Check for tool permission keywords\n  const toolMatch = joined.match(/(?:wants?\\s+to\\s+(?:use|run|execute)|Tool:\\s*|tool_name:\\s*)(\\w+)/i)\n  if (toolMatch) {\n    toolName = toolMatch[1]\n    confidence += 3\n  }\n\n  // Check for permission-specific keywords\n  const permissionKeywords = [\n    /\\ballow\\b/i,\n    /\\bdeny\\b/i,\n    /\\breject\\b/i,\n    /\\bpermission\\b/i,\n    /\\bapprove\\b/i,\n  ]\n  for (const kw of permissionKeywords) {\n    if (kw.test(joined)) confidence++\n  }\n\n  // Check for option-like patterns (numbered or arrow-selected)\n  const hasOptions = /(?:❯|›|>)\\s*(?:Allow|Deny|Yes|No)/i.test(joined)\n    || /\\b(?:Allow\\s+(?:once|always|for\\s+(?:this\\s+)?(?:project|session)))\\b/i.test(joined)\n  if (hasOptions) confidence += 2\n\n  // Need at least 4 confidence to declare a permission prompt\n  if (confidence < 4) return null\n\n  // ─── Extract options ───\n  const options: ParsedPermission['options'] = []\n\n  // Try to find option labels. The interactive CLI typically shows:\n  // ❯ Allow for this project  |  Allow once  |  Deny\n  // Or vertically:\n  // ❯ Allow for this project\n  //   Allow once\n  //   Deny\n\n  // Pattern: Look for Allow/Deny variants\n  const optionPatterns = [\n    { pattern: /Allow\\s+(?:for\\s+(?:this\\s+)?(?:project|session)|always)/i, label: 'Allow for this project', kind: 'allow' },\n    { pattern: /Allow\\s+once/i, label: 'Allow once', kind: 'allow' },\n    { pattern: /\\bAlways\\s+allow\\b/i, label: 'Always allow', kind: 'allow' },\n    { pattern: /(?:^|\\s)Allow(?:\\s|$)/i, label: 'Allow', kind: 'allow' },\n    { pattern: /\\bDeny\\b/i, label: 'Deny', kind: 'deny' },\n    { pattern: /\\bReject\\b/i, label: 'Reject', kind: 'deny' },\n  ]\n\n  let optIdx = 0\n  for (const op of optionPatterns) {\n    if (op.pattern.test(joined)) {\n      optIdx++\n      options.push({\n        optionId: `opt-${optIdx}`,\n        label: op.label,\n        // Terminal value: we'll use arrow key navigation + Enter\n        // The position in the list determines how many down arrows to press\n        terminalValue: String(optIdx),\n      })\n    }\n  }\n\n  // If we didn't find specific options but have high confidence,\n  // add default Allow/Deny options\n  if (options.length === 0 && confidence >= 4) {\n    options.push(\n      { optionId: 'opt-1', label: 'Allow', terminalValue: '1' },\n      { optionId: 'opt-2', label: 'Deny', terminalValue: '2' },\n    )\n  }\n\n  // Extract the raw prompt context (last 10 lines)\n  rawPrompt = lines.slice(-10).join('\\n')\n\n  return { toolName: toolName || 'Unknown', rawPrompt, options }\n}\n\n/**\n * Try to extract a session ID from terminal output.\n * The interactive CLI may print session info at startup.\n */\nfunction extractSessionId(text: string): string | null {\n  // Pattern: \"Session: <uuid>\" or \"session_id: <uuid>\" or just a UUID in init context\n  const match = text.match(/(?:session[_ ]?id|Session|Resuming session)[:\\s]+([a-f0-9-]{36})/i)\n  return match ? match[1] : null\n}\n\n/**\n * Detect if the CLI is showing its input prompt (ready for next message).\n * This indicates the current response is complete.\n *\n * The Ink-based CLI renders the prompt line as something like:\n *   \"❯ \"  or  \"❯ ? for shortcuts\"  or  \"> \"\n * After proper \\r handling, the prompt should be a clean line.\n */\nfunction isInputPrompt(line: string): boolean {\n  const cleaned = line.trim()\n  if (cleaned === '❯' || cleaned === '>' || cleaned === '$') return true\n  // Match prompt with trailing hint text (e.g. \"❯ ? for shortcuts\")\n  if (/^[❯>]\\s*(?:\\?\\s*for\\s*shortcuts)?$/.test(cleaned)) return true\n  return false\n}\n\nfunction isUiChrome(line: string): boolean {\n  const cleaned = line.trim()\n  if (!cleaned) return true\n  if (/^[╭│╰─┌└┃┏┗┐┘┤├┬┴┼]/.test(cleaned)) return true\n  if (/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✢✳✶✻✽]/.test(cleaned)) return true\n  if (/^\\s*(?:Medium|Low|High)\\s/.test(cleaned) && /model/i.test(cleaned)) return true\n  if (/\\/mcp|MCP server/i.test(cleaned)) return true\n  if (/Claude\\s*Code\\s*v/i.test(cleaned) || /ClaudeCodev/i.test(cleaned)) return true\n  if (/^[❯>$]\\s*$/.test(cleaned)) return true\n  if (/^\\$[\\d.]+\\s+·/.test(cleaned)) return true\n  if (/for\\s*shortcuts/i.test(cleaned)) return true\n  if (/zigzagging|thinking|processing|nebulizing|Boondoggling/i.test(cleaned)) return true\n  if (/^esctointerrupt/i.test(cleaned)) return true\n  // Prompt line with hint\n  if (/^[❯>]\\s*\\?\\s*for\\s*shortcuts/i.test(cleaned)) return true\n  // Status bar fragments: \"Opus 4.6 · Claude Max\" etc.\n  if (/Opus\\s*[\\d.]+\\s*·/i.test(cleaned)) return true\n  if (/Claude\\s*Max/i.test(cleaned)) return true\n  // Settings issue / doctor notice\n  if (/settings?\\s*issue|\\/doctor/i.test(cleaned)) return true\n  // Horizontal rules (all dashes/box chars)\n  if (/^[─━▪\\-=]{4,}/.test(cleaned)) return true\n  // Only box-drawing / decoration chars\n  if (/^[▗▖▘▝▀▄▌▐█░▒▓■□▪▫●○◆◇◈]+$/.test(cleaned)) return true\n  return false\n}\n\n/**\n * Detect if a line looks like a tool call header from the interactive CLI.\n * Example: \"⏳ Bash ls -la\" or \"✓ Read file.ts\"\n */\nfunction parseToolCallLine(line: string): { toolName: string; input: string } | null {\n  // Pattern: emoji/spinner + tool name + optional input\n  const match = line.match(/(?:⏳|⏳|✓|✗|⚡|🔧|Running|Executing)\\s+(\\w+)\\s*(.*)/i)\n    || line.match(/(?:Tool|Using):\\s*(\\w+)\\s*(.*)/i)\n  if (match) {\n    return { toolName: match[1], input: match[2].trim() }\n  }\n  return null\n}\n\n// ─── Run Handle ───\n\nexport interface PtyRunHandle {\n  runId: string\n  sessionId: string | null\n  pty: import('node-pty').IPty\n  pid: number\n  startedAt: number\n  /** Ring buffer of raw PTY output for diagnostics */\n  rawOutputTail: string[]\n  /** Ring buffer of stderr-like error lines */\n  stderrTail: string[]\n  /** Count of tool calls seen */\n  toolCallCount: number\n  /** Current pending permission prompt */\n  pendingPermission: ParsedPermission | null\n  /** Permission flow phase */\n  permissionPhase: 'idle' | 'detecting' | 'waiting_user' | 'answered'\n  /** Rolling window of cleaned lines for parser context */\n  ptyBuffer: string[]\n  /** Timer for permission timeout */\n  permissionTimeout: ReturnType<typeof setTimeout> | null\n  /** Accumulated text since last flush (for debounced text_chunk emission) */\n  textAccumulator: string\n  /** Whether we've seen the initial welcome/init output */\n  pastInit: boolean\n  /** Whether we've emitted session_init */\n  emittedSessionInit: boolean\n  /** Track which options are in the current selector for arrow-key navigation */\n  selectorOptions: string[]\n  /** Currently highlighted option index in the terminal selector */\n  currentOptionIndex: number\n  /** Whether task_complete has already been emitted for this run */\n  runCompleteEmitted: boolean\n  /** Quiescence timer used to avoid premature completion */\n  quiescenceTimer: ReturnType<typeof setTimeout> | null\n  /** Last PTY output timestamp */\n  lastOutputAt: number\n  /** Current prompt snippet used to detect the echoed user input */\n  promptSnippet: string\n  /** Whether we saw an echoed prompt for current request */\n  sawPromptEcho: boolean\n}\n\n// ─── PtyRunManager ───\n\nexport class PtyRunManager extends EventEmitter {\n  private activeRuns = new Map<string, PtyRunHandle>()\n  private _finishedRuns = new Map<string, PtyRunHandle>()\n  private claudeBinary: string\n\n  constructor() {\n    super()\n    this.claudeBinary = this._findClaudeBinary()\n    this._ensureSpawnHelperExecutable()\n    log(`Claude binary: ${this.claudeBinary}`)\n  }\n\n  /**\n   * node-pty prebuilt spawn-helper may lose execute bit depending on install/archive flow.\n   * Ensure it's executable at runtime to avoid \"posix_spawnp failed\".\n   */\n  private _ensureSpawnHelperExecutable(): void {\n    try {\n      const pkgPath = require.resolve('node-pty/package.json')\n      const path = require('path') as typeof import('path')\n      const helperPath = path.join(\n        path.dirname(pkgPath),\n        'prebuilds',\n        `${process.platform}-${process.arch}`,\n        'spawn-helper',\n      )\n      if (!existsSync(helperPath)) return\n      const st = statSync(helperPath)\n      const isExecutable = (st.mode & 0o111) !== 0\n      if (!isExecutable) {\n        chmodSync(helperPath, 0o755)\n        log(`Fixed spawn-helper permissions: ${helperPath}`)\n      }\n    } catch (err) {\n      log(`spawn-helper permission check failed: ${(err as Error).message}`)\n    }\n  }\n\n  private _findClaudeBinary(): string {\n    const candidates = [\n      '/usr/local/bin/claude',\n      '/opt/homebrew/bin/claude',\n      join(homedir(), '.npm-global/bin/claude'),\n    ]\n\n    for (const c of candidates) {\n      try {\n        execSync(`test -x \"${c}\"`, { stdio: 'ignore' })\n        return c\n      } catch {}\n    }\n\n    try {\n      return execSync('/bin/zsh -ilc \"whence -p claude\"', { encoding: 'utf-8', env: getCliEnv() }).trim()\n    } catch {}\n\n    try {\n      return execSync('/bin/bash -lc \"which claude\"', { encoding: 'utf-8', env: getCliEnv() }).trim()\n    } catch {}\n\n    return 'claude'\n  }\n\n  private _getEnv(): NodeJS.ProcessEnv {\n    const env = getCliEnv()\n    const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/'))\n    if (env.PATH && !env.PATH.includes(binDir)) {\n      env.PATH = `${binDir}:${env.PATH}`\n    }\n\n    return env\n  }\n\n  startRun(requestId: string, options: RunOptions): PtyRunHandle {\n    if (!pty) {\n      throw new Error('node-pty is not available — cannot use PTY transport')\n    }\n\n    const cwd = options.projectPath === '~' ? homedir() : options.projectPath\n\n    // Build args for interactive mode (no -p flag)\n    const args: string[] = [\n      '--permission-mode', 'default',\n    ]\n\n    if (options.sessionId) {\n      args.push('--resume', options.sessionId)\n    }\n    if (options.model) {\n      args.push('--model', options.model)\n    }\n    if (options.allowedTools?.length) {\n      args.push('--allowedTools', options.allowedTools.join(','))\n    }\n    if (options.systemPrompt) {\n      args.push('--system-prompt', options.systemPrompt)\n    }\n\n    // Pass prompt as positional argument\n    args.push(options.prompt)\n\n    log(`Starting PTY run ${requestId}: ${this.claudeBinary} ${args.join(' ')}`)\n    log(`Prompt: ${options.prompt.substring(0, 200)}`)\n\n    const ptyProcess = pty.spawn(this.claudeBinary, args, {\n      name: 'xterm-256color',\n      cols: 120,\n      rows: 40,\n      cwd,\n      env: this._getEnv(),\n    })\n\n    log(`Spawned PTY PID: ${ptyProcess.pid}`)\n\n    const handle: PtyRunHandle = {\n      runId: requestId,\n      sessionId: options.sessionId || null,\n      pty: ptyProcess,\n      pid: ptyProcess.pid,\n      startedAt: Date.now(),\n      rawOutputTail: [],\n      stderrTail: [],\n      toolCallCount: 0,\n      pendingPermission: null,\n      permissionPhase: 'idle',\n      ptyBuffer: [],\n      permissionTimeout: null,\n      textAccumulator: '',\n      pastInit: false,\n      emittedSessionInit: false,\n      selectorOptions: [],\n      currentOptionIndex: 0,\n      runCompleteEmitted: false,\n      quiescenceTimer: null,\n      lastOutputAt: Date.now(),\n      promptSnippet: options.prompt.trim().toLowerCase().slice(0, 24),\n      sawPromptEcho: false,\n    }\n\n    // ─── PTY output parser pipeline ───\n    let lineBuffer = ''\n\n    ptyProcess.onData((data: string) => {\n      // Raw diagnostics\n      this._ringPush(handle.rawOutputTail, data.substring(0, 500))\n\n      handle.lastOutputAt = Date.now()\n      if (handle.quiescenceTimer) clearTimeout(handle.quiescenceTimer)\n      handle.quiescenceTimer = setTimeout(() => this._checkQuiescenceCompletion(requestId, handle), QUIESCENCE_MS)\n\n      // Ink/TUI uses \\r to redraw the current line (cursor back to col 0).\n      // PTY output commonly uses \\r\\r\\n as line endings (Ink reset + newline).\n      // Strategy: scan for \\n to emit completed lines; treat \\r immediately\n      // before \\n (or \\r\\n) as part of the line ending, not a redraw.\n      // Only a \\r followed by printable text is a true Ink redraw.\n      const chars = data\n      for (let ci = 0; ci < chars.length; ci++) {\n        const ch = chars[ci]\n        if (ch === '\\n') {\n          // Emit completed line (strip any trailing \\r that was buffered)\n          const completed = lineBuffer.endsWith('\\r')\n            ? lineBuffer.slice(0, -1)\n            : lineBuffer\n          lineBuffer = ''\n          this._processLine(requestId, handle, completed)\n        } else if (ch === '\\r') {\n          // Look ahead: if next char is \\n or \\r (part of \\r\\r\\n), just\n          // append \\r to buffer so the \\n branch can strip it.\n          const next = ci + 1 < chars.length ? chars[ci + 1] : null\n          if (next === '\\n' || next === '\\r') {\n            // Part of line ending sequence — keep in buffer for \\n to strip\n            lineBuffer += '\\r'\n          } else if (next === null) {\n            // End of chunk — we don't know what comes next, buffer it\n            lineBuffer += '\\r'\n          } else {\n            // \\r followed by printable text → Ink redraw: reset line\n            lineBuffer = ''\n          }\n        } else {\n          lineBuffer += ch\n        }\n      }\n\n      // Also process the current incomplete line for permission detection\n      // (permission prompts may not end with newline)\n      if (lineBuffer.length > 0) {\n        const cleaned = stripAnsi(lineBuffer).trim()\n        if (cleaned.length > 0) {\n          this._checkPermissionInBuffer(requestId, handle, cleaned)\n        }\n      }\n    })\n\n    ptyProcess.onExit(({ exitCode, signal }) => {\n      log(`PTY exited [${requestId}]: code=${exitCode} signal=${signal}`)\n\n      // Clear permission timeout\n      if (handle.permissionTimeout) {\n        clearTimeout(handle.permissionTimeout)\n        handle.permissionTimeout = null\n      }\n      if (handle.quiescenceTimer) {\n        clearTimeout(handle.quiescenceTimer)\n        handle.quiescenceTimer = null\n      }\n\n      // Flush any accumulated text\n      this._flushText(requestId, handle)\n\n      // Emit task_complete if we haven't already\n      if (!handle.runCompleteEmitted) {\n        handle.runCompleteEmitted = true\n        this.emit('normalized', requestId, {\n          type: 'task_complete',\n          result: '',\n          costUsd: 0,\n          durationMs: Date.now() - handle.startedAt,\n          numTurns: 1,\n          usage: {},\n          sessionId: handle.sessionId || '',\n        } as NormalizedEvent)\n      }\n\n      // Move to finished runs\n      this._finishedRuns.set(requestId, handle)\n      this.activeRuns.delete(requestId)\n      this.emit('exit', requestId, exitCode, signal, handle.sessionId)\n\n      setTimeout(() => this._finishedRuns.delete(requestId), 5000)\n    })\n\n    this.activeRuns.set(requestId, handle)\n    return handle\n  }\n\n  /**\n   * Process a single line of PTY output.\n   */\n  private _processLine(requestId: string, handle: PtyRunHandle, rawLine: string): void {\n    const cleaned = stripAnsi(rawLine).trim()\n    if (cleaned.length === 0) return\n\n    // Ignore terminal mode toggles and redraw control fragments.\n    if (/^(?:\\?[0-9;?]*[a-zA-Z])+$/i.test(cleaned)) return\n\n    // Deduplicate exact redraw duplicates.\n    if (handle.ptyBuffer.length > 0 && handle.ptyBuffer[handle.ptyBuffer.length - 1] === cleaned) return\n\n    // Push to rolling buffer\n    this._ringPushBuffer(handle.ptyBuffer, cleaned)\n\n    log(`PTY line [${requestId}]: ${cleaned.substring(0, 200)}`)\n\n    // ─── Try to extract session ID ───\n    if (!handle.emittedSessionInit) {\n      const sid = extractSessionId(cleaned)\n      if (sid) {\n        handle.sessionId = sid\n        handle.emittedSessionInit = true\n        this.emit('normalized', requestId, {\n          type: 'session_init',\n          sessionId: sid,\n          tools: [],\n          model: '',\n          mcpServers: [],\n          skills: [],\n          version: '',\n        } as NormalizedEvent)\n      }\n    }\n\n    // ─── Skip init/welcome output ───\n    if (!handle.pastInit) {\n      // Wait until we see the echoed prompt for this request.\n      if (/^[❯>]\\s+/.test(cleaned)) {\n        // Resume sessions may echo prior context, not the exact current prompt text.\n        // Any echoed input prompt means init shell is ready.\n        handle.sawPromptEcho = true\n      }\n      // Start parsing actual response only after a message bullet appears post-echo.\n      if (handle.sawPromptEcho && cleaned.startsWith('⏺')) {\n        handle.pastInit = true\n      } else {\n        return\n      }\n    }\n\n    // ─── Permission phase: collecting detection context ───\n    if (handle.permissionPhase === 'detecting' || handle.permissionPhase === 'idle') {\n      this._checkPermissionInBuffer(requestId, handle, cleaned)\n      if (handle.permissionPhase === 'waiting_user') {\n        return // Permission prompt detected and emitted\n      }\n    }\n\n    // ─── Detect tool calls ───\n    const toolCall = parseToolCallLine(cleaned)\n    if (toolCall) {\n      handle.toolCallCount++\n      this._flushText(requestId, handle)\n      this.emit('normalized', requestId, {\n        type: 'tool_call',\n        toolName: toolCall.toolName,\n        toolId: `pty-tool-${handle.toolCallCount}`,\n        index: handle.toolCallCount - 1,\n      } as NormalizedEvent)\n\n      // Also emit tool_call_complete shortly after (we can't know exact timing from PTY)\n      setTimeout(() => {\n        this.emit('normalized', requestId, {\n          type: 'tool_call_complete',\n          index: handle.toolCallCount - 1,\n        } as NormalizedEvent)\n      }, 100)\n      return\n    }\n\n    // ─── Accumulate text output ───\n    if (isUiChrome(cleaned)) return\n\n    // Accumulate text for debounced emission\n    if (handle.textAccumulator.length > 0) {\n      handle.textAccumulator += '\\n'\n    }\n    const textLine = cleaned.startsWith('⏺') ? cleaned.replace(/^⏺\\s*/, '') : cleaned\n    handle.textAccumulator += textLine\n\n    // Emit text chunks periodically (debounce 50ms)\n    this._scheduleTextFlush(requestId, handle)\n  }\n\n  private _checkQuiescenceCompletion(requestId: string, handle: PtyRunHandle): void {\n    if (!this.activeRuns.has(requestId)) return\n    if (!handle.pastInit || handle.permissionPhase === 'waiting_user') return\n    if (Date.now() - handle.lastOutputAt < QUIESCENCE_MS - 50) return\n\n    const lastLines = handle.ptyBuffer.slice(-3)\n    const hasPromptMarker = lastLines.some((l) => isInputPrompt(l))\n    if (!hasPromptMarker) return\n\n    this._flushText(requestId, handle)\n    if (!handle.runCompleteEmitted) {\n      handle.runCompleteEmitted = true\n      this.emit('normalized', requestId, {\n        type: 'task_complete',\n        result: '',\n        costUsd: 0,\n        durationMs: Date.now() - handle.startedAt,\n        numTurns: 1,\n        usage: {},\n        sessionId: handle.sessionId || '',\n      } as NormalizedEvent)\n    }\n\n    try { handle.pty.write('/exit\\n') } catch {}\n    setTimeout(() => {\n      if (this.activeRuns.has(requestId)) {\n        try { handle.pty.kill() } catch {}\n      }\n    }, 3000)\n  }\n\n  private _textFlushTimers = new Map<string, ReturnType<typeof setTimeout>>()\n\n  private _scheduleTextFlush(requestId: string, handle: PtyRunHandle): void {\n    if (this._textFlushTimers.has(requestId)) return\n\n    const timer = setTimeout(() => {\n      this._textFlushTimers.delete(requestId)\n      this._flushText(requestId, handle)\n    }, 50)\n\n    this._textFlushTimers.set(requestId, timer)\n  }\n\n  private _flushText(requestId: string, handle: PtyRunHandle): void {\n    const timer = this._textFlushTimers.get(requestId)\n    if (timer) {\n      clearTimeout(timer)\n      this._textFlushTimers.delete(requestId)\n    }\n\n    if (handle.textAccumulator.length > 0) {\n      this.emit('normalized', requestId, {\n        type: 'text_chunk',\n        text: handle.textAccumulator,\n      } as NormalizedEvent)\n      handle.textAccumulator = ''\n    }\n  }\n\n  /**\n   * Check the current buffer for permission prompt patterns.\n   */\n  private _checkPermissionInBuffer(requestId: string, handle: PtyRunHandle, currentLine: string): void {\n    // Add current line to detection context\n    const detectionWindow = [...handle.ptyBuffer.slice(-10), currentLine]\n\n    const permission = detectPermissionPrompt(detectionWindow)\n    if (!permission) {\n      // Check for permission-adjacent keywords to enter detecting phase\n      const hasKeyword = /\\b(?:permission|approve|allow|deny)\\b/i.test(currentLine)\n      if (hasKeyword && handle.permissionPhase === 'idle') {\n        handle.permissionPhase = 'detecting'\n      }\n      return\n    }\n\n    // Permission prompt detected!\n    log(`Permission prompt detected [${requestId}]: tool=${permission.toolName}, options=${permission.options.length}`)\n\n    handle.pendingPermission = permission\n    handle.permissionPhase = 'waiting_user'\n\n    // Flush any accumulated text first\n    this._flushText(requestId, handle)\n\n    // Generate a unique question ID\n    const questionId = `pty-perm-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`\n\n    // Emit permission_request event\n    this.emit('normalized', requestId, {\n      type: 'permission_request',\n      questionId,\n      toolName: permission.toolName,\n      toolDescription: permission.rawPrompt,\n      options: permission.options.map((o) => ({\n        id: o.optionId,\n        label: o.label,\n        kind: o.label.toLowerCase().includes('deny') || o.label.toLowerCase().includes('reject') ? 'deny' : 'allow',\n      })),\n    } as NormalizedEvent)\n\n    // Set timeout for user response\n    handle.permissionTimeout = setTimeout(() => {\n      if (handle.permissionPhase === 'waiting_user') {\n        log(`Permission timeout [${requestId}] — auto-denying`)\n        this.emit('normalized', requestId, {\n          type: 'text_chunk',\n          text: '\\n[Permission timed out — automatically denied after 5 minutes]\\n',\n        } as NormalizedEvent)\n        // Send Escape to dismiss the prompt\n        try {\n          handle.pty.write('\\x1b')\n        } catch {}\n        handle.permissionPhase = 'idle'\n        handle.pendingPermission = null\n      }\n    }, PERMISSION_TIMEOUT_MS)\n  }\n\n  /**\n   * Respond to a permission prompt by sending keystrokes to the PTY.\n   */\n  respondToPermission(requestId: string, _questionId: string, optionId: string): boolean {\n    const handle = this.activeRuns.get(requestId)\n    if (!handle) {\n      log(`respondToPermission: no active run for ${requestId}`)\n      return false\n    }\n\n    if (handle.permissionPhase !== 'waiting_user' || !handle.pendingPermission) {\n      log(`respondToPermission: not waiting for permission (phase=${handle.permissionPhase})`)\n      return false\n    }\n\n    // Clear timeout\n    if (handle.permissionTimeout) {\n      clearTimeout(handle.permissionTimeout)\n      handle.permissionTimeout = null\n    }\n\n    const option = handle.pendingPermission.options.find((o) => o.optionId === optionId)\n    if (!option) {\n      log(`respondToPermission: option ${optionId} not found`)\n      return false\n    }\n\n    log(`respondToPermission [${requestId}]: optionId=${optionId}, label=${option.label}`)\n\n    // ─── Send keystrokes to PTY ───\n    // The Claude interactive CLI uses Ink's Select component.\n    // The first option is typically \"Allow for this project\" and is pre-selected.\n    // To select a different option, we press Down arrow keys then Enter.\n\n    const optionIndex = handle.pendingPermission.options.indexOf(option)\n    const isAllow = option.label.toLowerCase().includes('allow') || option.label.toLowerCase().includes('yes')\n    const isDeny = option.label.toLowerCase().includes('deny') || option.label.toLowerCase().includes('reject')\n\n    try {\n      if (isDeny) {\n        // Try sending 'n' first (common shortcut for deny)\n        // If that doesn't work, navigate with arrow keys\n        // Send Escape first to clear any state, then 'n'\n        handle.pty.write('n')\n      } else if (isAllow && optionIndex === 0) {\n        // First option (typically already selected) — just press Enter\n        handle.pty.write('\\r')\n      } else {\n        // Navigate to the option with arrow keys then press Enter\n        for (let i = 0; i < optionIndex; i++) {\n          handle.pty.write('\\x1b[B') // Down arrow\n        }\n        // Small delay then Enter\n        setTimeout(() => {\n          try { handle.pty.write('\\r') } catch {}\n        }, 50)\n      }\n    } catch (err) {\n      log(`respondToPermission: write error: ${(err as Error).message}`)\n      return false\n    }\n\n    handle.permissionPhase = 'answered'\n    handle.pendingPermission = null\n\n    // After answering, reset to idle for next potential permission\n    setTimeout(() => {\n      if (handle.permissionPhase === 'answered') {\n        handle.permissionPhase = 'idle'\n      }\n    }, 500)\n\n    return true\n  }\n\n  /**\n   * Cancel a running PTY process.\n   */\n  cancel(requestId: string): boolean {\n    const handle = this.activeRuns.get(requestId)\n    if (!handle) return false\n\n    log(`Cancelling PTY run ${requestId}`)\n\n    // Clear permission timeout\n    if (handle.permissionTimeout) {\n      clearTimeout(handle.permissionTimeout)\n      handle.permissionTimeout = null\n    }\n\n    // Send SIGINT (Ctrl+C)\n    try {\n      handle.pty.write('\\x03') // Ctrl+C\n    } catch {}\n\n    // Fallback: kill after 5s\n    setTimeout(() => {\n      if (this.activeRuns.has(requestId)) {\n        log(`Force killing PTY run ${requestId}`)\n        try {\n          handle.pty.kill()\n        } catch {}\n      }\n    }, 5000)\n\n    return true\n  }\n\n  /**\n   * Write arbitrary data to PTY stdin (for follow-up messages, etc.)\n   */\n  writeToStdin(requestId: string, message: string): boolean {\n    const handle = this.activeRuns.get(requestId)\n    if (!handle) return false\n\n    log(`Writing to PTY stdin [${requestId}]: ${message.substring(0, 200)}`)\n    try {\n      handle.pty.write(message)\n      return true\n    } catch {\n      return false\n    }\n  }\n\n  /**\n   * Get an enriched error object for a failed PTY run.\n   */\n  getEnrichedError(requestId: string, exitCode: number | null): EnrichedError {\n    const handle = this.activeRuns.get(requestId) || this._finishedRuns.get(requestId)\n    return {\n      message: `PTY run failed with exit code ${exitCode}`,\n      stderrTail: handle?.stderrTail.slice(-20) || [],\n      stdoutTail: handle?.rawOutputTail.slice(-20) || [],\n      exitCode,\n      elapsedMs: handle ? Date.now() - handle.startedAt : 0,\n      toolCallCount: handle?.toolCallCount || 0,\n      sawPermissionRequest: handle?.permissionPhase !== 'idle' || false,\n      permissionDenials: [],\n    }\n  }\n\n  isRunning(requestId: string): boolean {\n    return this.activeRuns.has(requestId)\n  }\n\n  getHandle(requestId: string): PtyRunHandle | undefined {\n    return this.activeRuns.get(requestId)\n  }\n\n  getActiveRunIds(): string[] {\n    return Array.from(this.activeRuns.keys())\n  }\n\n  private _ringPush(buffer: string[], line: string): void {\n    buffer.push(line)\n    if (buffer.length > MAX_RING_LINES) buffer.shift()\n  }\n\n  private _ringPushBuffer(buffer: string[], line: string): void {\n    buffer.push(line)\n    if (buffer.length > PTY_BUFFER_SIZE) buffer.shift()\n  }\n}\n"
  },
  {
    "path": "src/main/claude/run-manager.ts",
    "content": "import { spawn, execSync, ChildProcess } from 'child_process'\nimport { EventEmitter } from 'events'\nimport { homedir } from 'os'\nimport { join } from 'path'\nimport { StreamParser } from '../stream-parser'\nimport { normalize } from './event-normalizer'\nimport { log as _log } from '../logger'\nimport { getCliEnv } from '../cli-env'\nimport type { ClaudeEvent, NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types'\n\nconst MAX_RING_LINES = 100\nconst DEBUG = process.env.CLUI_DEBUG === '1'\n\n// Appended to Claude's default system prompt so it knows it's running inside CLUI.\n// Uses --append-system-prompt (additive) not --system-prompt (replacement).\nconst CLUI_SYSTEM_HINT = [\n  'IMPORTANT: You are NOT running in a terminal. You are running inside CLUI,',\n  'a desktop chat application with a rich UI that renders full markdown.',\n  'CLUI is a GUI wrapper around Claude Code — the user sees your output in a',\n  'styled conversation view, not a raw terminal.',\n  '',\n  'Because CLUI renders markdown natively, you MUST use rich formatting when it helps:',\n  '- Always use clickable markdown links: [label](https://url) — they render as real buttons.',\n  '- When the user asks for images, and public web images are appropriate, proactively find and render them in CLUI.',\n  '- Workflow: WebSearch for relevant public pages -> WebFetch those pages -> extract real image URLs -> render with markdown ![alt](url).',\n  '- Do not guess, fabricate, or construct image URLs from memory.',\n  '- Only embed images when the URL is a real publicly accessible image URL found through tools or explicitly provided by the user.',\n  '- If real image URLs cannot be obtained confidently, fall back to clickable links and briefly say so.',\n  '- Do not ask whether CLUI can render images; assume it can.',\n  '- Use tables, bold, headers, and bullet lists freely — they all render beautifully.',\n  '- Use code blocks with language tags for syntax highlighting.',\n  '',\n  'You are still a software engineering assistant. Keep using your tools (Read, Edit, Bash, etc.)',\n  'normally. But when presenting information, links, resources, or explanations to the user,',\n  'take full advantage of the rich UI. The user expects a polished chat experience, not raw terminal text.',\n].join('\\n')\n\n// Tools auto-approved via --allowedTools (never trigger the permission card).\n// Includes routine internal agent mechanics (Agent, Task, TaskOutput, TodoWrite,\n// Notebook) — prompting for these would make UX terrible without adding meaningful\n// safety. This is a deliberate CLUI policy choice, not native Claude parity.\n// If runtime evidence shows any of these create real user-facing approval moments,\n// they should be moved to the hook matcher in permission-server.ts instead.\nconst SAFE_TOOLS = [\n  'Read', 'Glob', 'Grep', 'LS',\n  'TodoRead', 'TodoWrite',\n  'Agent', 'Task', 'TaskOutput',\n  'Notebook',\n  'WebSearch', 'WebFetch',\n]\n\n// All tools to pre-approve when NO hook server is available (fallback path).\n// Includes safe + dangerous tools so nothing is silently denied.\nconst DEFAULT_ALLOWED_TOOLS = [\n  'Bash', 'Edit', 'Write', 'MultiEdit',\n  ...SAFE_TOOLS,\n]\n\nfunction log(msg: string): void {\n  _log('RunManager', msg)\n}\n\nexport interface RunHandle {\n  runId: string\n  sessionId: string | null\n  process: ChildProcess\n  pid: number | null\n  startedAt: number\n  /** Ring buffer of last N stderr lines */\n  stderrTail: string[]\n  /** Ring buffer of last N stdout lines */\n  stdoutTail: string[]\n  /** Count of tool calls seen during this run */\n  toolCallCount: number\n  /** Whether any permission_request event was seen during this run */\n  sawPermissionRequest: boolean\n  /** Permission denials from result event */\n  permissionDenials: Array<{ tool_name: string; tool_use_id: string }>\n}\n\n/**\n * RunManager: spawns one `claude -p` process per run, parses NDJSON,\n * emits normalized events, handles cancel, and keeps diagnostic ring buffers.\n *\n * Events emitted:\n *  - 'normalized' (runId, NormalizedEvent)\n *  - 'raw' (runId, ClaudeEvent)  — for logging/debugging\n *  - 'exit' (runId, code, signal, sessionId)\n *  - 'error' (runId, Error)\n */\nexport class RunManager extends EventEmitter {\n  private activeRuns = new Map<string, RunHandle>()\n  /** Holds recently-finished runs so diagnostics survive past process exit */\n  private _finishedRuns = new Map<string, RunHandle>()\n  private claudeBinary: string\n\n  constructor() {\n    super()\n    this.claudeBinary = this._findClaudeBinary()\n    log(`Claude binary: ${this.claudeBinary}`)\n  }\n\n  private _findClaudeBinary(): string {\n    const candidates = [\n      '/usr/local/bin/claude',\n      '/opt/homebrew/bin/claude',\n      join(homedir(), '.npm-global/bin/claude'),\n    ]\n\n    for (const c of candidates) {\n      try {\n        execSync(`test -x \"${c}\"`, { stdio: 'ignore' })\n        return c\n      } catch {}\n    }\n\n    try {\n      return execSync('/bin/zsh -ilc \"whence -p claude\"', { encoding: 'utf-8', env: getCliEnv() }).trim()\n    } catch {}\n\n    try {\n      return execSync('/bin/bash -lc \"which claude\"', { encoding: 'utf-8', env: getCliEnv() }).trim()\n    } catch {}\n\n    return 'claude'\n  }\n\n  private _getEnv(): NodeJS.ProcessEnv {\n    const env = getCliEnv()\n    const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/'))\n    if (env.PATH && !env.PATH.includes(binDir)) {\n      env.PATH = `${binDir}:${env.PATH}`\n    }\n\n    return env\n  }\n\n  startRun(requestId: string, options: RunOptions): RunHandle {\n    const cwd = options.projectPath === '~' ? homedir() : options.projectPath\n\n    const args: string[] = [\n      '-p',\n      '--input-format', 'stream-json',\n      '--output-format', 'stream-json',\n      '--verbose',\n      '--include-partial-messages',\n      '--permission-mode', 'default',\n    ]\n\n    if (options.sessionId) {\n      args.push('--resume', options.sessionId)\n    }\n    if (options.model) {\n      args.push('--model', options.model)\n    }\n    if (options.addDirs && options.addDirs.length > 0) {\n      for (const dir of options.addDirs) {\n        args.push('--add-dir', dir)\n      }\n    }\n\n    if (options.hookSettingsPath) {\n      // CLUI-scoped hook settings: the PreToolUse HTTP hook handles permissions\n      // for dangerous tools (Bash, Edit, Write, MultiEdit).\n      // Auto-approve safe tools so they don't trigger the permission card.\n      args.push('--settings', options.hookSettingsPath)\n      const safeAllowed = [\n        ...SAFE_TOOLS,\n        ...(options.allowedTools || []),\n      ]\n      args.push('--allowedTools', safeAllowed.join(','))\n    } else {\n      // Fallback: no hook server available.\n      // Pre-approve common tools so they run without being silently denied.\n      const allAllowed = [\n        ...DEFAULT_ALLOWED_TOOLS,\n        ...(options.allowedTools || []),\n      ]\n      args.push('--allowedTools', allAllowed.join(','))\n    }\n    if (options.maxTurns) {\n      args.push('--max-turns', String(options.maxTurns))\n    }\n    if (options.maxBudgetUsd) {\n      args.push('--max-budget-usd', String(options.maxBudgetUsd))\n    }\n    if (options.systemPrompt) {\n      args.push('--system-prompt', options.systemPrompt)\n    }\n    // Always tell Claude it's inside CLUI (additive, doesn't replace base prompt)\n    args.push('--append-system-prompt', CLUI_SYSTEM_HINT)\n\n    if (DEBUG) {\n      log(`Starting run ${requestId}: ${this.claudeBinary} ${args.join(' ')}`)\n      log(`Prompt: ${options.prompt.substring(0, 200)}`)\n    } else {\n      log(`Starting run ${requestId}`)\n    }\n\n    const child = spawn(this.claudeBinary, args, {\n      stdio: ['pipe', 'pipe', 'pipe'],\n      cwd,\n      env: this._getEnv(),\n    })\n\n    log(`Spawned PID: ${child.pid}`)\n\n    const handle: RunHandle = {\n      runId: requestId,\n      sessionId: options.sessionId || null,\n      process: child,\n      pid: child.pid || null,\n      startedAt: Date.now(),\n      stderrTail: [],\n      stdoutTail: [],\n      toolCallCount: 0,\n      sawPermissionRequest: false,\n      permissionDenials: [],\n    }\n\n    // ─── stdout → NDJSON parser → normalizer → events ───\n    const parser = StreamParser.fromStream(child.stdout!)\n\n    parser.on('event', (raw: ClaudeEvent) => {\n      // Track session ID\n      if (raw.type === 'system' && 'subtype' in raw && raw.subtype === 'init') {\n        handle.sessionId = (raw as any).session_id\n      }\n\n      // Track permission_request events\n      if (raw.type === 'permission_request' || (raw.type === 'system' && 'subtype' in raw && (raw as any).subtype === 'permission_request')) {\n        handle.sawPermissionRequest = true\n        log(`Permission request seen [${requestId}]`)\n      }\n\n      // Extract permission_denials from result event\n      if (raw.type === 'result') {\n        const denials = (raw as any).permission_denials\n        if (Array.isArray(denials) && denials.length > 0) {\n          handle.permissionDenials = denials.map((d: any) => ({\n            tool_name: d.tool_name || '',\n            tool_use_id: d.tool_use_id || '',\n          }))\n          log(`Permission denials [${requestId}]: ${JSON.stringify(handle.permissionDenials)}`)\n        }\n      }\n\n      // Ring buffer stdout lines (raw JSON for diagnostics)\n      this._ringPush(handle.stdoutTail, JSON.stringify(raw).substring(0, 300))\n\n      // Emit raw event for debugging\n      this.emit('raw', requestId, raw)\n\n      // Normalize and emit canonical events\n      const normalized = normalize(raw)\n      for (const evt of normalized) {\n        if (evt.type === 'tool_call') handle.toolCallCount++\n        this.emit('normalized', requestId, evt)\n      }\n\n      // Close stdin after result event — with stream-json input the process\n      // stays alive waiting for more input; closing stdin triggers clean exit.\n      if (raw.type === 'result') {\n        log(`Run complete [${requestId}]: sawPermissionRequest=${handle.sawPermissionRequest}, denials=${handle.permissionDenials.length}`)\n        try { child.stdin?.end() } catch {}\n      }\n    })\n\n    parser.on('parse-error', (line: string) => {\n      log(`Parse error [${requestId}]: ${line.substring(0, 200)}`)\n      this._ringPush(handle.stderrTail, `[parse-error] ${line.substring(0, 200)}`)\n    })\n\n    // ─── stderr ring buffer ───\n    child.stderr?.setEncoding('utf-8')\n    child.stderr?.on('data', (data: string) => {\n      const lines = data.split('\\n').filter((l: string) => l.trim())\n      for (const line of lines) {\n        this._ringPush(handle.stderrTail, line)\n      }\n      log(`Stderr [${requestId}]: ${data.trim().substring(0, 500)}`)\n    })\n\n    // ─── Process lifecycle ───\n    // Snapshot diagnostics BEFORE deleting the handle so callers can still read them.\n    child.on('close', (code, signal) => {\n      log(`Process closed [${requestId}]: code=${code} signal=${signal}`)\n      // Move handle to finished map so getEnrichedError still works after exit\n      this._finishedRuns.set(requestId, handle)\n      this.activeRuns.delete(requestId)\n      this.emit('exit', requestId, code, signal, handle.sessionId)\n      // Clean up finished run after a short delay (gives callers time to read diagnostics)\n      setTimeout(() => this._finishedRuns.delete(requestId), 5000)\n    })\n\n    child.on('error', (err) => {\n      log(`Process error [${requestId}]: ${err.message}`)\n      this._finishedRuns.set(requestId, handle)\n      this.activeRuns.delete(requestId)\n      this.emit('error', requestId, err)\n      setTimeout(() => this._finishedRuns.delete(requestId), 5000)\n    })\n\n    // ─── Write prompt to stdin (stream-json format, keep open) ───\n    // Using --input-format stream-json for bidirectional communication.\n    // Stdin stays open so follow-up messages can be sent.\n    const userMessage = JSON.stringify({\n      type: 'user',\n      message: {\n        role: 'user',\n        content: [{ type: 'text', text: options.prompt }],\n      },\n    })\n    child.stdin!.write(userMessage + '\\n')\n\n    this.activeRuns.set(requestId, handle)\n    return handle\n  }\n\n  /**\n   * Write a message to a running process's stdin (for follow-up prompts, etc.)\n   */\n  writeToStdin(requestId: string, message: object): boolean {\n    const handle = this.activeRuns.get(requestId)\n    if (!handle) return false\n    if (!handle.process.stdin || handle.process.stdin.destroyed) return false\n\n    const json = JSON.stringify(message)\n    log(`Writing to stdin [${requestId}]: ${json.substring(0, 200)}`)\n    handle.process.stdin.write(json + '\\n')\n    return true\n  }\n\n  /**\n   * Cancel a running process: SIGINT, then SIGKILL after 5s.\n   */\n  cancel(requestId: string): boolean {\n    const handle = this.activeRuns.get(requestId)\n    if (!handle) return false\n\n    log(`Cancelling run ${requestId}`)\n    handle.process.kill('SIGINT')\n\n    // Fallback: SIGKILL if process hasn't exited after 5s.\n    // Only check exitCode — process.killed is set true by the SIGINT call above,\n    // so checking !killed would prevent the fallback from ever firing.\n    setTimeout(() => {\n      if (handle.process.exitCode === null) {\n        log(`Force killing run ${requestId} (SIGINT did not terminate)`)\n        handle.process.kill('SIGKILL')\n      }\n    }, 5000)\n\n    return true\n  }\n\n  /**\n   * Get an enriched error object for a failed run.\n   */\n  getEnrichedError(requestId: string, exitCode: number | null): EnrichedError {\n    const handle = this.activeRuns.get(requestId) || this._finishedRuns.get(requestId)\n    return {\n      message: `Run failed with exit code ${exitCode}`,\n      stderrTail: handle?.stderrTail.slice(-20) || [],\n      stdoutTail: handle?.stdoutTail.slice(-20) || [],\n      exitCode,\n      elapsedMs: handle ? Date.now() - handle.startedAt : 0,\n      toolCallCount: handle?.toolCallCount || 0,\n      sawPermissionRequest: handle?.sawPermissionRequest || false,\n      permissionDenials: handle?.permissionDenials || [],\n    }\n  }\n\n  isRunning(requestId: string): boolean {\n    return this.activeRuns.has(requestId)\n  }\n\n  getHandle(requestId: string): RunHandle | undefined {\n    return this.activeRuns.get(requestId)\n  }\n\n  getActiveRunIds(): string[] {\n    return Array.from(this.activeRuns.keys())\n  }\n\n  private _ringPush(buffer: string[], line: string): void {\n    buffer.push(line)\n    if (buffer.length > MAX_RING_LINES) {\n      buffer.shift()\n    }\n  }\n}\n"
  },
  {
    "path": "src/main/cli-env.ts",
    "content": "import { execSync } from 'child_process'\n\nlet cachedPath: string | null = null\n\nfunction appendPathEntries(target: string[], seen: Set<string>, rawPath: string | undefined): void {\n  if (!rawPath) return\n  for (const entry of rawPath.split(':')) {\n    const p = entry.trim()\n    if (!p || seen.has(p)) continue\n    seen.add(p)\n    target.push(p)\n  }\n}\n\nexport function getCliPath(): string {\n  if (cachedPath) return cachedPath\n\n  const ordered: string[] = []\n  const seen = new Set<string>()\n\n  // Start from current process PATH.\n  appendPathEntries(ordered, seen, process.env.PATH)\n\n  // Add common binary locations used on macOS (Homebrew + system).\n  appendPathEntries(ordered, seen, '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')\n\n  // Try interactive login shell first so nvm/asdf/etc. PATH hooks are loaded.\n  const pathCommands = [\n    '/bin/zsh -ilc \"echo $PATH\"',\n    '/bin/zsh -lc \"echo $PATH\"',\n    '/bin/bash -lc \"echo $PATH\"',\n  ]\n\n  for (const cmd of pathCommands) {\n    try {\n      const discovered = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim()\n      appendPathEntries(ordered, seen, discovered)\n    } catch {\n      // Keep trying fallbacks.\n    }\n  }\n\n  cachedPath = ordered.join(':')\n  return cachedPath\n}\n\nexport function getCliEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {\n  const env: NodeJS.ProcessEnv = {\n    ...process.env,\n    ...extraEnv,\n    PATH: getCliPath(),\n  }\n  delete env.CLAUDECODE\n  return env\n}\n\n"
  },
  {
    "path": "src/main/hooks/permission-server.ts",
    "content": "/**\n * Permission Hook Server\n *\n * A local HTTP server that acts as a Claude Code PreToolUse hook handler.\n * When Claude Code wants to use a tool, it POSTs the tool request here.\n * The server forwards it to the renderer (PermissionCard), waits for the\n * user's decision, and returns the structured hook response.\n *\n * This is a CLUI-owned permission broker that approximates Claude Code's\n * practical permission cadence. It does not reproduce native permission\n * semantics exactly — it intercepts the small set of tool classes that\n * map to real, user-meaningful approval moments.\n *\n * Security:\n *   - Per-launch app secret in URL path (prevents local spoofing)\n *   - Per-run token in URL path (prevents cross-run confusion)\n *   - Deny-by-default on every failure path\n *   - Per-run settings files (only CLUI-spawned sessions see the hook)\n */\n\nimport { createServer, IncomingMessage, ServerResponse } from 'http'\nimport { EventEmitter } from 'events'\nimport { writeFileSync, mkdirSync, unlinkSync } from 'fs'\nimport { tmpdir } from 'os'\nimport { join } from 'path'\nimport { randomUUID } from 'crypto'\nimport { log as _log } from '../logger'\nconst PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes\nconst DEFAULT_PORT = 19836\nconst MAX_BODY_SIZE = 1024 * 1024 // 1MB\n\nconst DEBUG = process.env.CLUI_DEBUG === '1'\n\n// Tools that need explicit user approval via the permission card.\n// This is the small set of tool classes that map to real, user-meaningful\n// approval moments. Routine internal agent mechanics (Read, Glob, Grep, etc.)\n// are auto-approved via --allowedTools to avoid noisy UX.\nconst PERMISSION_REQUIRED_TOOLS = ['Bash', 'Edit', 'Write', 'MultiEdit']\n\n// Bash commands that are clearly read-only and safe to auto-approve.\n// Matches the leading command (before any pipes, semicolons, or &&).\nconst SAFE_BASH_COMMANDS = new Set([\n  // Info / help\n  'cat', 'head', 'tail', 'less', 'more', 'wc', 'file', 'stat',\n  'ls', 'pwd', 'echo', 'printf', 'date', 'whoami', 'hostname', 'uname',\n  'which', 'whence', 'where', 'type', 'command',\n  'man', 'help', 'info',\n  // Search\n  'find', 'grep', 'rg', 'ag', 'ack', 'fd', 'fzf', 'locate',\n  // Git read-only\n  'git', // further checked: only read-only subcommands\n  // Env / config\n  'env', 'printenv', 'set',\n  // Package info (read-only)\n  'npm', 'yarn', 'pnpm', 'bun', 'cargo', 'pip', 'pip3', 'go', 'rustup',\n  'node', 'python', 'python3', 'ruby', 'java', 'javac',\n  // Claude CLI (read-only subcommands)\n  'claude',\n  // Disk / system info\n  'df', 'du', 'free', 'top', 'htop', 'ps', 'uptime', 'lsof',\n  'tree', 'realpath', 'dirname', 'basename',\n  // macOS\n  'sw_vers', 'system_profiler', 'defaults', 'mdls', 'mdfind',\n  // Diff / compare\n  'diff', 'cmp', 'comm', 'sort', 'uniq', 'cut', 'awk', 'sed',\n  'jq', 'yq', 'xargs', 'tr',\n])\n\n// Git subcommands that mutate state (not safe to auto-approve)\nconst GIT_MUTATING_SUBCOMMANDS = new Set([\n  'push', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',\n  'branch', 'tag', 'stash', 'cherry-pick', 'revert', 'am', 'apply',\n  'clean', 'rm', 'mv', 'restore', 'bisect', 'pull', 'fetch', 'clone',\n  'init', 'submodule', 'worktree', 'gc', 'prune', 'filter-branch',\n])\n\n// Claude subcommands that mutate state\nconst CLAUDE_MUTATING_SUBCOMMANDS = new Set([\n  'config', 'login', 'logout',\n])\n\n/** Check if a Bash command string is safe (read-only) */\nfunction isSafeBashCommand(command: unknown): boolean {\n  if (typeof command !== 'string') return false\n  const trimmed = command.trim()\n  if (!trimmed) return false\n\n  // Extract the first command (before any chaining operators)\n  // Split on ;, &&, ||, | and check each segment\n  const segments = trimmed.split(/\\s*(?:;|&&|\\|\\||[|])\\s*/)\n  for (const segment of segments) {\n    const parts = segment.trim().split(/\\s+/)\n    const cmd = parts[0]\n    if (!cmd) continue\n\n    // Handle env prefix patterns like: VAR=val command\n    const actualCmd = cmd.includes('=') ? parts[1] : cmd\n    if (!actualCmd) continue\n\n    // Strip path prefix (e.g., /usr/bin/git → git)\n    const base = actualCmd.split('/').pop() || actualCmd\n\n    if (!SAFE_BASH_COMMANDS.has(base)) return false\n\n    // Extra check for git: only allow read-only subcommands\n    if (base === 'git') {\n      const subIdx = cmd.includes('=') ? 2 : 1\n      const sub = parts[subIdx]\n      if (sub && GIT_MUTATING_SUBCOMMANDS.has(sub)) return false\n    }\n\n    // Extra check for claude: only allow read-only subcommands\n    if (base === 'claude') {\n      const subIdx = cmd.includes('=') ? 2 : 1\n      const sub = parts[subIdx]\n      // claude mcp remove, claude config set, etc.\n      if (sub && CLAUDE_MUTATING_SUBCOMMANDS.has(sub)) return false\n      // claude mcp remove specifically\n      if (sub === 'mcp') {\n        const mcpSub = parts[subIdx + 1]\n        if (mcpSub && mcpSub !== 'list' && mcpSub !== 'get' && mcpSub !== '--help') return false\n      }\n    }\n\n    // Extra check for npm/yarn/pnpm/bun: block install/publish/run\n    if (['npm', 'yarn', 'pnpm', 'bun'].includes(base)) {\n      const subIdx = cmd.includes('=') ? 2 : 1\n      const sub = parts[subIdx]\n      if (sub && ['install', 'i', 'add', 'remove', 'uninstall', 'publish', 'run', 'exec', 'dlx', 'npx', 'create', 'init', 'link', 'unlink', 'pack', 'deprecate'].includes(sub)) return false\n    }\n\n    // Block redirections that write to files\n    if (segment.includes('>') && !segment.includes('>/dev/null') && !segment.includes('2>/dev/null') && !segment.includes('2>&1')) return false\n  }\n\n  return true\n}\n\n// Regex matcher for the hook config — intercept dangerous tools + external MCP tools.\nconst HOOK_MATCHER = `^(${PERMISSION_REQUIRED_TOOLS.join('|')}|mcp__.*)$`\n\n// Fields in tool_input that should be redacted in logs\nconst SENSITIVE_FIELD_RE = /token|password|secret|key|auth|credential|api.?key/i\n\n// Exhaustive whitelist of valid decision IDs from permission card options.\n// Any decision not in this set is denied (fail-closed).\nconst VALID_ALLOW_DECISIONS = new Set(['allow', 'allow-session', 'allow-domain'])\nconst VALID_DECISIONS = new Set([...VALID_ALLOW_DECISIONS, 'deny'])\n\nfunction log(msg: string): void {\n  _log('PermissionServer', msg)\n}\n\n/** Extract domain from a URL string, returns null on failure */\nfunction extractDomain(url: unknown): string | null {\n  if (typeof url !== 'string') return null\n  try {\n    return new URL(url).hostname\n  } catch {\n    return null\n  }\n}\n\n/** Build a deny hook response */\nfunction denyResponse(reason: string) {\n  return {\n    hookSpecificOutput: {\n      hookEventName: 'PreToolUse',\n      permissionDecision: 'deny',\n      permissionDecisionReason: reason,\n    },\n  }\n}\n\n/** Build an allow hook response */\nfunction allowResponse(reason: string) {\n  return {\n    hookSpecificOutput: {\n      hookEventName: 'PreToolUse',\n      permissionDecision: 'allow',\n      permissionDecisionReason: reason,\n    },\n  }\n}\n\nexport interface HookToolRequest {\n  session_id: string\n  transcript_path: string\n  cwd: string\n  permission_mode: string\n  hook_event_name: string\n  tool_name: string\n  tool_input: Record<string, unknown>\n  tool_use_id: string\n}\n\nexport interface PermissionDecision {\n  decision: 'allow' | 'deny'\n  reason?: string\n}\n\nexport interface PermissionOption {\n  id: string\n  label: string\n  kind: 'allow' | 'deny'\n}\n\ninterface PendingRequest {\n  toolRequest: HookToolRequest\n  resolve: (decision: PermissionDecision) => void\n  timeout: ReturnType<typeof setTimeout>\n  questionId: string\n  runToken: string\n}\n\ninterface RunRegistration {\n  tabId: string\n  requestId: string\n  sessionId: string | null\n}\n\n/**\n * PermissionServer: HTTP server for Claude Code PreToolUse hooks.\n *\n * Events:\n *  - 'permission-request' (questionId, toolRequest, tabId, options) — forward to renderer\n */\nexport class PermissionServer extends EventEmitter {\n  private server: ReturnType<typeof createServer> | null = null\n  private pendingRequests = new Map<string, PendingRequest>()\n  private port: number\n  private _actualPort: number | null = null\n\n  /** Per-launch secret — validates that requests come from our hooks */\n  private appSecret: string\n\n  /** Per-run tokens → run registration (tabId, requestId, sessionId) */\n  private runTokens = new Map<string, RunRegistration>()\n\n  /** Scoped \"allow always\" keys. Format varies by tool type. */\n  private scopedAllows = new Set<string>()\n\n  /** Tracked generated settings files: runToken → filePath */\n  private settingsFiles = new Map<string, string>()\n\n  constructor(port = DEFAULT_PORT) {\n    super()\n    this.port = port\n    this.appSecret = randomUUID()\n  }\n\n  async start(): Promise<number> {\n    if (this.server) {\n      log('Server already running')\n      return this._actualPort || this.port\n    }\n\n    return new Promise((resolve, reject) => {\n      this.server = createServer((req, res) => this._handleRequest(req, res))\n\n      this.server.on('error', (err: NodeJS.ErrnoException) => {\n        if (err.code === 'EADDRINUSE') {\n          log(`Port ${this.port} in use, trying ${this.port + 1}`)\n          this.port++\n          this.server!.listen(this.port, '127.0.0.1')\n        } else {\n          log(`Server error: ${err.message}`)\n          reject(err)\n        }\n      })\n\n      this.server.listen(this.port, '127.0.0.1', () => {\n        this._actualPort = this.port\n        log(`Permission server listening on 127.0.0.1:${this.port}`)\n        resolve(this.port)\n      })\n    })\n  }\n\n  stop(): void {\n    // Deny all pending requests\n    for (const [qid, pending] of this.pendingRequests) {\n      clearTimeout(pending.timeout)\n      pending.resolve({ decision: 'deny', reason: 'Server shutting down' })\n      this.pendingRequests.delete(qid)\n    }\n\n    // Clean up all remaining settings files (best-effort)\n    for (const [, filePath] of this.settingsFiles) {\n      try { unlinkSync(filePath) } catch {}\n    }\n    this.settingsFiles.clear()\n\n    if (this.server) {\n      this.server.close()\n      this.server = null\n      log('Permission server stopped')\n    }\n  }\n\n  getPort(): number | null {\n    return this._actualPort\n  }\n\n  // ─── Run Registration ───\n\n  /**\n   * Register a new run. Returns a unique run token.\n   * The run token is embedded in the hook URL for per-run routing.\n   */\n  registerRun(tabId: string, requestId: string, sessionId: string | null): string {\n    const runToken = randomUUID()\n    this.runTokens.set(runToken, { tabId, requestId, sessionId })\n    log(`Registered run: token=${runToken.substring(0, 8)}… tab=${tabId.substring(0, 8)}…`)\n    return runToken\n  }\n\n  /**\n   * Unregister a run. Denies any pending requests for this run and cleans up its settings file.\n   */\n  unregisterRun(runToken: string): void {\n    const reg = this.runTokens.get(runToken)\n    if (!reg) return\n\n    // Deny any pending requests associated with this run\n    for (const [qid, pending] of this.pendingRequests) {\n      if (pending.runToken === runToken) {\n        clearTimeout(pending.timeout)\n        pending.resolve({ decision: 'deny', reason: 'Run ended' })\n        this.pendingRequests.delete(qid)\n      }\n    }\n\n    // Clean up settings file for this run\n    const filePath = this.settingsFiles.get(runToken)\n    if (filePath) {\n      try { unlinkSync(filePath) } catch {}\n      this.settingsFiles.delete(runToken)\n    }\n\n    this.runTokens.delete(runToken)\n    log(`Unregistered run: token=${runToken.substring(0, 8)}…`)\n  }\n\n  // ─── Permission Response ───\n\n  /**\n   * Respond to a pending permission request.\n   * decision: 'allow' (once), 'allow-session' (for session), 'allow-domain' (WebFetch domain), 'deny'\n   */\n  respondToPermission(questionId: string, decision: string, reason?: string): boolean {\n    const pending = this.pendingRequests.get(questionId)\n    if (!pending) {\n      log(`respondToPermission: no pending request for ${questionId}`)\n      return false\n    }\n\n    clearTimeout(pending.timeout)\n    this.pendingRequests.delete(questionId)\n\n    // Fail-closed: reject unknown decision IDs immediately\n    if (!VALID_DECISIONS.has(decision)) {\n      log(`Unknown decision \"${decision}\" for [${questionId}] — denying (fail-closed)`)\n      pending.resolve({ decision: 'deny', reason: `Unknown decision: ${decision}` })\n      return true\n    }\n\n    const toolName = pending.toolRequest.tool_name\n    const sessionId = pending.toolRequest.session_id\n\n    // Handle scoped \"allow always\" decisions\n    if (decision === 'allow-session') {\n      const key = `session:${sessionId}:tool:${toolName}`\n      this.scopedAllows.add(key)\n      log(`Session-allowed ${toolName} for session ${sessionId.substring(0, 8)}…`)\n    } else if (decision === 'allow-domain') {\n      const domain = extractDomain(pending.toolRequest.tool_input?.url)\n      if (domain) {\n        const key = `session:${sessionId}:webfetch:${domain}`\n        this.scopedAllows.add(key)\n        log(`Domain-allowed ${domain} for session ${sessionId.substring(0, 8)}…`)\n      }\n    }\n\n    const hookDecision: 'allow' | 'deny' = VALID_ALLOW_DECISIONS.has(decision) ? 'allow' : 'deny'\n    if (DEBUG) {\n      log(`respondToPermission [${questionId}]: ${decision} (tool=${toolName})`)\n    } else {\n      log(`Permission: ${toolName} → ${hookDecision}`)\n    }\n    pending.resolve({ decision: hookDecision, reason })\n    return true\n  }\n\n  // ─── Dynamic Options ───\n\n  /**\n   * Get permission card options for a given tool + input.\n   * WebFetch gets domain-scoped options; all others get session-scoped.\n   */\n  getOptionsForTool(toolName: string, toolInput?: Record<string, unknown>): PermissionOption[] {\n    // Bash commands are too diverse for session-scoped blanket allow —\n    // each command should be individually reviewed.\n    if (toolName === 'Bash') {\n      return [\n        { id: 'allow', label: 'Allow Once', kind: 'allow' },\n        { id: 'deny', label: 'Deny', kind: 'deny' },\n      ]\n    }\n\n    // Edit, Write, MultiEdit, mcp__* — session-scoped allow is safe\n    return [\n      { id: 'allow', label: 'Allow Once', kind: 'allow' },\n      { id: 'allow-session', label: 'Allow for Session', kind: 'allow' },\n      { id: 'deny', label: 'Deny', kind: 'deny' },\n    ]\n  }\n\n  // ─── Settings File Generation ───\n\n  /**\n   * Generate a per-run settings file with the PreToolUse HTTP hook.\n   * The URL includes both appSecret and runToken for authentication.\n   */\n  generateSettingsFile(runToken: string): string {\n    const port = this._actualPort || this.port\n    const settings = {\n      hooks: {\n        PreToolUse: [\n          {\n            matcher: HOOK_MATCHER,\n            hooks: [\n              {\n                type: 'http',\n                url: `http://127.0.0.1:${port}/hook/pre-tool-use/${this.appSecret}/${runToken}`,\n                timeout: 300,\n              },\n            ],\n          },\n        ],\n      },\n    }\n\n    const dir = join(tmpdir(), 'clui-hook-config')\n    try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch {}\n\n    const filePath = join(dir, `clui-hook-${runToken}.json`)\n    writeFileSync(filePath, JSON.stringify(settings, null, 2), { mode: 0o600 })\n    this.settingsFiles.set(runToken, filePath)\n    if (DEBUG) {\n      log(`Generated settings file: ${filePath}`)\n    }\n    return filePath\n  }\n\n  // ─── HTTP Request Handling ───\n\n  private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {\n    // POST only — deny everything else\n    if (req.method !== 'POST') {\n      res.writeHead(404, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Not found')))\n      return\n    }\n\n    // Parse URL: /hook/pre-tool-use/<appSecret>/<runToken>\n    const segments = (req.url || '').split('/').filter(Boolean)\n    if (segments.length !== 4 || segments[0] !== 'hook' || segments[1] !== 'pre-tool-use') {\n      res.writeHead(404, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Invalid path')))\n      return\n    }\n\n    const urlSecret = segments[2]\n    const urlToken = segments[3]\n\n    // Validate app secret\n    if (urlSecret !== this.appSecret) {\n      log('Rejected request: invalid app secret')\n      res.writeHead(403, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Invalid credentials')))\n      return\n    }\n\n    // Validate run token\n    const registration = this.runTokens.get(urlToken)\n    if (!registration) {\n      log(`Rejected request: unknown run token ${urlToken.substring(0, 8)}…`)\n      res.writeHead(403, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Unknown run')))\n      return\n    }\n\n    // Read body with size limit\n    let body = ''\n    let bodySize = 0\n    for await (const chunk of req) {\n      bodySize += (chunk as Buffer).length\n      if (bodySize > MAX_BODY_SIZE) {\n        log('Rejected request: body too large')\n        res.writeHead(413, { 'Content-Type': 'application/json' })\n        res.end(JSON.stringify(denyResponse('Request too large')))\n        return\n      }\n      body += chunk\n    }\n\n    // Parse JSON\n    let toolRequest: HookToolRequest\n    try {\n      toolRequest = JSON.parse(body) as HookToolRequest\n    } catch {\n      log('Rejected request: invalid JSON')\n      res.writeHead(400, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Invalid JSON')))\n      return\n    }\n\n    // Validate required fields\n    if (!toolRequest.tool_name || !toolRequest.session_id || !toolRequest.hook_event_name) {\n      log('Rejected request: missing required fields')\n      res.writeHead(400, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Missing required fields')))\n      return\n    }\n\n    // Validate hook event name\n    if (toolRequest.hook_event_name !== 'PreToolUse') {\n      log(`Rejected request: unexpected hook event ${toolRequest.hook_event_name}`)\n      res.writeHead(400, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(denyResponse('Unexpected hook event')))\n      return\n    }\n\n    if (DEBUG) {\n      log(`Hook request: tool=${toolRequest.tool_name} id=${toolRequest.tool_use_id} session=${toolRequest.session_id} tab=${registration.tabId.substring(0, 8)}…`)\n    } else {\n      log(`Hook: ${toolRequest.tool_name} → tab=${registration.tabId.substring(0, 8)}…`)\n    }\n\n    // Check scoped allows\n    const sessionId = toolRequest.session_id\n    const toolName = toolRequest.tool_name\n\n    // Check session-scoped allow\n    if (this.scopedAllows.has(`session:${sessionId}:tool:${toolName}`)) {\n      if (DEBUG) log(`Auto-allowing ${toolName} (session-allowed)`)\n      res.writeHead(200, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(allowResponse('Allowed for session by user')))\n      return\n    }\n\n    // Check domain-scoped allow (WebFetch)\n    if (toolName === 'WebFetch') {\n      const domain = extractDomain(toolRequest.tool_input?.url)\n      if (domain && this.scopedAllows.has(`session:${sessionId}:webfetch:${domain}`)) {\n        if (DEBUG) log(`Auto-allowing WebFetch to ${domain} (domain-allowed)`)\n        res.writeHead(200, { 'Content-Type': 'application/json' })\n        res.end(JSON.stringify(allowResponse(`Domain ${domain} allowed by user`)))\n        return\n      }\n    }\n\n    // Auto-approve safe (read-only) Bash commands without prompting\n    if (toolName === 'Bash' && isSafeBashCommand(toolRequest.tool_input?.command)) {\n      if (DEBUG) log(`Auto-allowing safe Bash: ${String(toolRequest.tool_input?.command).substring(0, 80)}`)\n      res.writeHead(200, { 'Content-Type': 'application/json' })\n      res.end(JSON.stringify(allowResponse('Safe read-only command')))\n      return\n    }\n\n    // Generate question ID and wait for user decision\n    const questionId = `hook-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`\n\n    const decision = await new Promise<PermissionDecision>((resolve) => {\n      const timeout = setTimeout(() => {\n        log(`Permission timeout [${questionId}] — auto-denying`)\n        this.pendingRequests.delete(questionId)\n        resolve({ decision: 'deny', reason: 'Permission timed out after 5 minutes' })\n      }, PERMISSION_TIMEOUT_MS)\n\n      this.pendingRequests.set(questionId, {\n        toolRequest,\n        resolve,\n        timeout,\n        questionId,\n        runToken: urlToken,\n      })\n\n      // Get tool-specific options for the permission card\n      const options = this.getOptionsForTool(toolName, toolRequest.tool_input)\n\n      // Emit with direct tabId from registration — no session_id lookup needed\n      this.emit('permission-request', questionId, toolRequest, registration.tabId, options)\n    })\n\n    // Return structured hook response\n    const hookResponse = decision.decision === 'allow'\n      ? allowResponse(decision.reason || 'Approved by user')\n      : denyResponse(decision.reason || 'Denied by user')\n\n    if (DEBUG) {\n      log(`Hook response [${questionId}]: ${decision.decision}`)\n    }\n    res.writeHead(200, { 'Content-Type': 'application/json' })\n    res.end(JSON.stringify(hookResponse))\n  }\n}\n\n/** Mask sensitive fields in tool_input (recursive). Exported for defense-in-depth use by control-plane. */\nexport function maskSensitiveFields(input: Record<string, unknown>): Record<string, unknown> {\n  const masked: Record<string, unknown> = {}\n  for (const [key, value] of Object.entries(input)) {\n    if (SENSITIVE_FIELD_RE.test(key)) {\n      masked[key] = '***'\n    } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {\n      masked[key] = maskSensitiveFields(value as Record<string, unknown>)\n    } else if (Array.isArray(value)) {\n      masked[key] = value.map(item =>\n        item !== null && typeof item === 'object' && !Array.isArray(item)\n          ? maskSensitiveFields(item as Record<string, unknown>)\n          : item\n      )\n    } else {\n      masked[key] = value\n    }\n  }\n  return masked\n}\n"
  },
  {
    "path": "src/main/index.ts",
    "content": "import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences, session } from 'electron'\nimport { join } from 'path'\nimport { existsSync, readdirSync, statSync, createReadStream } from 'fs'\nimport { createInterface } from 'readline'\nimport { homedir } from 'os'\nimport { ControlPlane } from './claude/control-plane'\nimport { ensureSkills, type SkillStatus } from './skills/installer'\nimport { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog'\nimport { log as _log, LOG_FILE, flushLogs } from './logger'\nimport { getCliEnv } from './cli-env'\nimport { IPC } from '../shared/types'\nimport type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types'\n\nconst DEBUG_MODE = process.env.CLUI_DEBUG === '1'\nconst SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1'\n\nfunction getContentSecurityPolicy(): string {\n  const isDev = !!process.env.ELECTRON_RENDERER_URL\n  const connectSrc = isDev\n    ? \"connect-src 'self' ws://localhost:* http://localhost:*;\"\n    : \"connect-src 'self';\"\n  const scriptSrc = isDev\n    ? \"script-src 'self' 'unsafe-inline' 'unsafe-eval';\"\n    : \"script-src 'self';\"\n\n  return [\n    \"default-src 'none'\",\n    scriptSrc,\n    \"style-src 'self' 'unsafe-inline'\",\n    \"img-src 'self' data: blob:\",\n    \"media-src 'self' data: blob:\",\n    \"font-src 'self'\",\n    connectSrc,\n    \"object-src 'none'\",\n    \"base-uri 'none'\",\n    \"frame-src 'none'\",\n  ].join('; ')\n}\n\nfunction installContentSecurityPolicy(): void {\n  const csp = getContentSecurityPolicy()\n  session.defaultSession.webRequest.onHeadersReceived((details, callback) => {\n    callback({\n      responseHeaders: {\n        ...details.responseHeaders,\n        'Content-Security-Policy': [csp],\n      },\n    })\n  })\n}\n\nfunction log(msg: string): void {\n  _log('main', msg)\n}\n\nlet mainWindow: BrowserWindow | null = null\nlet tray: Tray | null = null\nlet screenshotCounter = 0\nlet toggleSequence = 0\nlet lastWindowBounds: Electron.Rectangle | null = null\n\n// Feature flag: enable PTY interactive permissions transport\nconst INTERACTIVE_PTY = process.env.CLUI_INTERACTIVE_PERMISSIONS_PTY === '1'\n\nconst controlPlane = new ControlPlane(INTERACTIVE_PTY)\n\n// Keep native width fixed to avoid renderer animation vs setBounds race.\n// The UI itself still launches in compact mode; extra width is transparent/click-through.\nconst BAR_WIDTH = 1040\nconst PILL_HEIGHT = 720  // Fixed native window height — extra room for expanded UI + shadow buffers\nconst PILL_BOTTOM_MARGIN = 24\n\n// ─── Broadcast to renderer ───\n\nfunction broadcast(channel: string, ...args: unknown[]): void {\n  if (mainWindow && !mainWindow.isDestroyed()) {\n    mainWindow.webContents.send(channel, ...args)\n  }\n}\n\nfunction snapshotWindowState(reason: string): void {\n  if (!SPACES_DEBUG) return\n  if (!mainWindow || mainWindow.isDestroyed()) {\n    log(`[spaces] ${reason} window=none`)\n    return\n  }\n\n  const b = mainWindow.getBounds()\n  const cursor = screen.getCursorScreenPoint()\n  const display = screen.getDisplayNearestPoint(cursor)\n  const visibleOnAll = mainWindow.isVisibleOnAllWorkspaces()\n  const wcFocused = mainWindow.webContents.isFocused()\n\n  log(\n    `[spaces] ${reason} ` +\n    `vis=${mainWindow.isVisible()} focused=${mainWindow.isFocused()} wcFocused=${wcFocused} ` +\n    `alwaysOnTop=${mainWindow.isAlwaysOnTop()} allWs=${visibleOnAll} ` +\n    `bounds=(${b.x},${b.y},${b.width}x${b.height}) ` +\n    `cursor=(${cursor.x},${cursor.y}) display=${display.id} ` +\n    `workArea=(${display.workArea.x},${display.workArea.y},${display.workArea.width}x${display.workArea.height})`\n  )\n}\n\nfunction scheduleToggleSnapshots(toggleId: number, phase: 'show' | 'hide'): void {\n  if (!SPACES_DEBUG) return\n  const probes = [0, 100, 400, 1200]\n  for (const delay of probes) {\n    setTimeout(() => {\n      snapshotWindowState(`toggle#${toggleId} ${phase} +${delay}ms`)\n    }, delay)\n  }\n}\n\n\n// ─── Wire ControlPlane events → renderer ───\n\ncontrolPlane.on('event', (tabId: string, event: NormalizedEvent) => {\n  broadcast('clui:normalized-event', tabId, event)\n})\n\ncontrolPlane.on('tab-status-change', (tabId: string, newStatus: string, oldStatus: string) => {\n  broadcast('clui:tab-status-change', tabId, newStatus, oldStatus)\n})\n\ncontrolPlane.on('error', (tabId: string, error: EnrichedError) => {\n  broadcast('clui:enriched-error', tabId, error)\n})\n\n// ─── Window Creation ───\n\nfunction createWindow(): void {\n  const cursor = screen.getCursorScreenPoint()\n  const display = screen.getDisplayNearestPoint(cursor)\n  const { width: screenWidth, height: screenHeight } = display.workAreaSize\n  const { x: dx, y: dy } = display.workArea\n\n  const x = dx + Math.round((screenWidth - BAR_WIDTH) / 2)\n  const y = dy + screenHeight - PILL_HEIGHT - PILL_BOTTOM_MARGIN\n\n  mainWindow = new BrowserWindow({\n    width: BAR_WIDTH,\n    height: PILL_HEIGHT,\n    x,\n    y,\n    ...(process.platform === 'darwin' ? { type: 'panel' as const } : {}),  // NSPanel — non-activating, joins all spaces\n    frame: false,\n    transparent: true,\n    resizable: false,\n    movable: true,\n    alwaysOnTop: true,\n    skipTaskbar: true,\n    hasShadow: false,\n    roundedCorners: true,\n    backgroundColor: '#00000000',\n    show: false,\n    icon: join(__dirname, '../../resources/icon.icns'),\n    webPreferences: {\n      preload: join(__dirname, '../preload/index.js'),\n      sandbox: true,\n      contextIsolation: true,\n      nodeIntegration: false,\n      webSecurity: true,\n      allowRunningInsecureContent: false,\n    },\n  })\n  lastWindowBounds = mainWindow.getBounds()\n\n  // Belt-and-suspenders: panel already joins all spaces and floats,\n  // but explicit flags ensure correct behavior on older Electron builds.\n  mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })\n  mainWindow.setAlwaysOnTop(true, 'screen-saver')\n  mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))\n  mainWindow.webContents.on('will-navigate', (event) => {\n    event.preventDefault()\n  })\n\n  mainWindow.once('ready-to-show', () => {\n    mainWindow?.show()\n    // Enable OS-level click-through for transparent regions.\n    // { forward: true } ensures mousemove events still reach the renderer\n    // so it can toggle click-through off when cursor enters interactive UI.\n    mainWindow?.setIgnoreMouseEvents(true, { forward: true })\n    if (process.env.ELECTRON_RENDERER_URL) {\n      mainWindow?.webContents.openDevTools({ mode: 'detach' })\n    }\n  })\n\n  let forceQuit = false\n  app.on('before-quit', () => { forceQuit = true })\n  mainWindow.on('close', (e) => {\n    if (!forceQuit) {\n      e.preventDefault()\n      mainWindow?.hide()\n    }\n  })\n\n  if (process.env.ELECTRON_RENDERER_URL) {\n    mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)\n  } else {\n    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))\n  }\n}\n\nfunction showWindow(source = 'unknown'): void {\n  if (!mainWindow) return\n  const toggleId = ++toggleSequence\n\n  if (lastWindowBounds) {\n    mainWindow.setBounds(lastWindowBounds)\n  }\n\n  // Always re-assert space membership — the flag can be lost after hide/show cycles\n  // and must be set before show() so the window joins the active Space, not its\n  // last-known Space.\n  mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })\n\n  if (SPACES_DEBUG) {\n    const b = mainWindow.getBounds()\n    log(`[spaces] showWindow#${toggleId} source=${source} preserve-bounds=(${b.x},${b.y},${b.width}x${b.height})`)\n    snapshotWindowState(`showWindow#${toggleId} pre-show`)\n  }\n  // As an accessory app (app.dock.hide), show() + focus gives keyboard\n  // without deactivating the active app — hover preserved everywhere.\n  mainWindow.show()\n  if (lastWindowBounds) {\n    mainWindow.setBounds(lastWindowBounds)\n  }\n  mainWindow.webContents.focus()\n  broadcast(IPC.WINDOW_SHOWN)\n  if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show')\n}\n\nfunction resetWindowPosition(): void {\n  if (!mainWindow) return\n\n  const cursor = screen.getCursorScreenPoint()\n  const display = screen.getDisplayNearestPoint(cursor)\n  const { width: sw, height: sh } = display.workAreaSize\n  const { x: dx, y: dy } = display.workArea\n\n  mainWindow.setBounds({\n    x: dx + Math.round((sw - BAR_WIDTH) / 2),\n    y: dy + sh - PILL_HEIGHT - PILL_BOTTOM_MARGIN,\n    width: BAR_WIDTH,\n    height: PILL_HEIGHT,\n  })\n  lastWindowBounds = mainWindow.getBounds()\n}\n\nfunction toggleWindow(source = 'unknown'): void {\n  if (!mainWindow) return\n  const toggleId = ++toggleSequence\n  if (SPACES_DEBUG) {\n    log(`[spaces] toggle#${toggleId} source=${source} start`)\n    snapshotWindowState(`toggle#${toggleId} pre`)\n  }\n\n  if (mainWindow.isVisible()) {\n    mainWindow.hide()\n    if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'hide')\n  } else {\n    showWindow(source)\n  }\n}\n\n// ─── Resize ───\n// Fixed-height mode: ignore renderer resize events to prevent jank.\n// The native window stays at PILL_HEIGHT; all expand/collapse happens inside the renderer.\n\nipcMain.on(IPC.RESIZE_HEIGHT, () => {\n  // No-op — fixed height window, no dynamic resize\n})\n\nipcMain.on(IPC.SET_WINDOW_WIDTH, () => {\n  // No-op — native width is fixed to keep expand/collapse animation smooth.\n})\n\nipcMain.handle(IPC.ANIMATE_HEIGHT, () => {\n  // No-op — kept for API compat, animation handled purely in renderer\n})\n\nipcMain.on(IPC.HIDE_WINDOW, () => {\n  mainWindow?.hide()\n})\n\nipcMain.handle(IPC.IS_VISIBLE, () => {\n  return mainWindow?.isVisible() ?? false\n})\n\n// OS-level click-through toggle — renderer calls this on mousemove\n// to enable clicks on interactive UI while passing through transparent areas\nipcMain.on(IPC.SET_IGNORE_MOUSE_EVENTS, (event, ignore: boolean, options?: { forward?: boolean }) => {\n  const win = BrowserWindow.fromWebContents(event.sender)\n  if (win && !win.isDestroyed()) {\n    win.setIgnoreMouseEvents(ignore, options || {})\n  }\n})\n\n// Manual window drag — works reliably with frameless + setIgnoreMouseEvents\nipcMain.on(IPC.START_WINDOW_DRAG, (event, deltaX: number, deltaY: number) => {\n  const win = BrowserWindow.fromWebContents(event.sender)\n  if (win && !win.isDestroyed()) {\n    const [x, y] = win.getPosition()\n    // Vertical is handled in two phases in the renderer: window first (until macOS clamps),\n    // then CSS translateY within the window — so deltaY here is always within allowed range\n    win.setPosition(Math.round(x + deltaX), Math.round(y + deltaY))\n    lastWindowBounds = win.getBounds()\n  }\n})\n\nipcMain.on(IPC.RESET_WINDOW_POSITION, () => {\n  resetWindowPosition()\n})\n\n// ─── IPC Handlers (typed, strict) ───\n\nipcMain.handle(IPC.START, async () => {\n  log('IPC START — fetching static CLI info')\n  const { execSync } = require('child_process')\n\n  let version = 'unknown'\n  try {\n    version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()\n  } catch {}\n\n  let auth: { email?: string; subscriptionType?: string; authMethod?: string } = {}\n  try {\n    const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()\n    auth = JSON.parse(raw)\n  } catch {}\n\n  let mcpServers: string[] = []\n  try {\n    const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim()\n    if (raw) mcpServers = raw.split('\\n').filter(Boolean)\n  } catch {}\n\n  return { version, auth, mcpServers, projectPath: process.cwd(), homePath: require('os').homedir() }\n})\n\nipcMain.handle(IPC.CREATE_TAB, () => {\n  const tabId = controlPlane.createTab()\n  log(`IPC CREATE_TAB → ${tabId}`)\n  return { tabId }\n})\n\nipcMain.on(IPC.INIT_SESSION, (_event, tabId: string) => {\n  log(`IPC INIT_SESSION: ${tabId}`)\n  controlPlane.initSession(tabId)\n})\n\nipcMain.on(IPC.RESET_TAB_SESSION, (_event, tabId: string) => {\n  log(`IPC RESET_TAB_SESSION: ${tabId}`)\n  controlPlane.resetTabSession(tabId)\n})\n\nipcMain.handle(IPC.PROMPT, async (_event, { tabId, requestId, options }: { tabId: string; requestId: string; options: RunOptions }) => {\n  if (DEBUG_MODE) {\n    log(`IPC PROMPT: tab=${tabId} req=${requestId} prompt=\"${options.prompt.substring(0, 100)}\"`)\n  } else {\n    log(`IPC PROMPT: tab=${tabId} req=${requestId}`)\n  }\n\n  if (!tabId) {\n    throw new Error('No tabId provided — prompt rejected')\n  }\n  if (!requestId) {\n    throw new Error('No requestId provided — prompt rejected')\n  }\n\n  try {\n    await controlPlane.submitPrompt(tabId, requestId, options)\n  } catch (err: unknown) {\n    const msg = err instanceof Error ? err.message : String(err)\n    log(`PROMPT error: ${msg}`)\n    throw err\n  }\n})\n\nipcMain.handle(IPC.CANCEL, (_event, requestId: string) => {\n  log(`IPC CANCEL: ${requestId}`)\n  return controlPlane.cancel(requestId)\n})\n\nipcMain.handle(IPC.STOP_TAB, (_event, tabId: string) => {\n  log(`IPC STOP_TAB: ${tabId}`)\n  return controlPlane.cancelTab(tabId)\n})\n\nipcMain.handle(IPC.RETRY, async (_event, { tabId, requestId, options }: { tabId: string; requestId: string; options: RunOptions }) => {\n  log(`IPC RETRY: tab=${tabId} req=${requestId}`)\n  return controlPlane.retry(tabId, requestId, options)\n})\n\nipcMain.handle(IPC.STATUS, () => {\n  return controlPlane.getHealth()\n})\n\nipcMain.handle(IPC.TAB_HEALTH, () => {\n  return controlPlane.getHealth()\n})\n\nipcMain.handle(IPC.CLOSE_TAB, (_event, tabId: string) => {\n  log(`IPC CLOSE_TAB: ${tabId}`)\n  controlPlane.closeTab(tabId)\n})\n\nipcMain.on(IPC.SET_PERMISSION_MODE, (_event, mode: string) => {\n  if (mode !== 'ask' && mode !== 'auto') {\n    log(`IPC SET_PERMISSION_MODE: invalid mode \"${mode}\" — ignoring`)\n    return\n  }\n  log(`IPC SET_PERMISSION_MODE: ${mode}`)\n  controlPlane.setPermissionMode(mode)\n})\n\nipcMain.handle(IPC.RESPOND_PERMISSION, (_event, { tabId, questionId, optionId }: { tabId: string; questionId: string; optionId: string }) => {\n  log(`IPC RESPOND_PERMISSION: tab=${tabId} question=${questionId} option=${optionId}`)\n  return controlPlane.respondToPermission(tabId, questionId, optionId)\n})\n\nipcMain.handle(IPC.LIST_SESSIONS, async (_e, projectPath?: string) => {\n  log(`IPC LIST_SESSIONS ${projectPath ? `(path=${projectPath})` : ''}`)\n  try {\n    const cwd = projectPath || process.cwd()\n    // Validate projectPath — reject null bytes, newlines, non-absolute paths\n    if (/[\\0\\r\\n]/.test(cwd) || !cwd.startsWith('/')) {\n      log(`LIST_SESSIONS: rejected invalid projectPath: ${cwd}`)\n      return []\n    }\n    // Claude stores project sessions at ~/.claude/projects/<encoded-path>/\n    // Path encoding: replace all '/' with '-' (leading '/' becomes leading '-')\n    const encodedPath = cwd.replace(/\\//g, '-')\n    const sessionsDir = join(homedir(), '.claude', 'projects', encodedPath)\n    if (!existsSync(sessionsDir)) {\n      log(`LIST_SESSIONS: directory not found: ${sessionsDir}`)\n      return []\n    }\n    const files = readdirSync(sessionsDir).filter((f: string) => f.endsWith('.jsonl'))\n\n    const sessions: Array<{ sessionId: string; slug: string | null; firstMessage: string | null; lastTimestamp: string; size: number }> = []\n\n    // UUID v4 regex — only consider files named as valid UUIDs\n    const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n    for (const file of files) {\n      // The filename (without .jsonl) IS the canonical resume ID for `claude --resume`\n      const fileSessionId = file.replace(/\\.jsonl$/, '')\n      if (!UUID_RE.test(fileSessionId)) continue // skip non-UUID files\n\n      const filePath = join(sessionsDir, file)\n      const stat = statSync(filePath)\n      if (stat.size < 100) continue // skip trivially small files\n\n      // Read lines to extract metadata and validate transcript schema\n      const meta: { validated: boolean; slug: string | null; firstMessage: string | null; lastTimestamp: string | null } = {\n        validated: false, slug: null, firstMessage: null, lastTimestamp: null,\n      }\n\n      await new Promise<void>((resolve) => {\n        const rl = createInterface({ input: createReadStream(filePath) })\n        rl.on('line', (line: string) => {\n          try {\n            const obj = JSON.parse(line)\n            // Validate: must have expected Claude transcript fields\n            if (!meta.validated && obj.type && obj.uuid && obj.timestamp) {\n              meta.validated = true\n            }\n            if (obj.slug && !meta.slug) meta.slug = obj.slug\n            if (obj.timestamp) meta.lastTimestamp = obj.timestamp\n            if (obj.type === 'user' && !meta.firstMessage) {\n              const content = obj.message?.content\n              if (typeof content === 'string') {\n                meta.firstMessage = content.substring(0, 100)\n              } else if (Array.isArray(content)) {\n                const textPart = content.find((p: any) => p.type === 'text')\n                meta.firstMessage = textPart?.text?.substring(0, 100) || null\n              }\n            }\n          } catch {}\n          // Read all lines to get the last timestamp\n        })\n        rl.on('close', () => resolve())\n      })\n\n      if (meta.validated) {\n        sessions.push({\n          sessionId: fileSessionId,\n          slug: meta.slug,\n          firstMessage: meta.firstMessage,\n          lastTimestamp: meta.lastTimestamp || stat.mtime.toISOString(),\n          size: stat.size,\n        })\n      }\n    }\n\n    // Sort by last timestamp, most recent first\n    sessions.sort((a, b) => new Date(b.lastTimestamp).getTime() - new Date(a.lastTimestamp).getTime())\n    return sessions.slice(0, 20) // Return top 20\n  } catch (err) {\n    log(`LIST_SESSIONS error: ${err}`)\n    return []\n  }\n})\n\n// Load conversation history from a session's JSONL file\nipcMain.handle(IPC.LOAD_SESSION, async (_e, arg: { sessionId: string; projectPath?: string } | string) => {\n  const sessionId = typeof arg === 'string' ? arg : arg.sessionId\n  const projectPath = typeof arg === 'string' ? undefined : arg.projectPath\n  log(`IPC LOAD_SESSION ${sessionId}${projectPath ? ` (path=${projectPath})` : ''}`)\n\n  // Validate sessionId — must be strict UUID to prevent path traversal via crafted filenames\n  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n  if (!UUID_RE.test(sessionId)) {\n    log(`LOAD_SESSION: rejected invalid sessionId: ${sessionId}`)\n    return []\n  }\n\n  try {\n    const cwd = projectPath || process.cwd()\n    // Validate projectPath — reject null bytes, newlines, non-absolute paths\n    if (/[\\0\\r\\n]/.test(cwd) || !cwd.startsWith('/')) {\n      log(`LOAD_SESSION: rejected invalid projectPath: ${cwd}`)\n      return []\n    }\n    const encodedPath = cwd.replace(/\\//g, '-')\n    const filePath = join(homedir(), '.claude', 'projects', encodedPath, `${sessionId}.jsonl`)\n    if (!existsSync(filePath)) return []\n\n    const messages: Array<{ role: string; content: string; toolName?: string; timestamp: number }> = []\n    await new Promise<void>((resolve) => {\n      const rl = createInterface({ input: createReadStream(filePath) })\n      rl.on('line', (line: string) => {\n        try {\n          const obj = JSON.parse(line)\n          if (obj.type === 'user') {\n            const content = obj.message?.content\n            let text = ''\n            if (typeof content === 'string') {\n              text = content\n            } else if (Array.isArray(content)) {\n              text = content\n                .filter((b: any) => b.type === 'text')\n                .map((b: any) => b.text)\n                .join('\\n')\n            }\n            if (text) {\n              messages.push({ role: 'user', content: text, timestamp: new Date(obj.timestamp).getTime() })\n            }\n          } else if (obj.type === 'assistant') {\n            const content = obj.message?.content\n            if (Array.isArray(content)) {\n              for (const block of content) {\n                if (block.type === 'text' && block.text) {\n                  messages.push({ role: 'assistant', content: block.text, timestamp: new Date(obj.timestamp).getTime() })\n                } else if (block.type === 'tool_use' && block.name) {\n                  messages.push({\n                    role: 'tool',\n                    content: '',\n                    toolName: block.name,\n                    timestamp: new Date(obj.timestamp).getTime(),\n                  })\n                }\n              }\n            }\n          }\n        } catch {}\n      })\n      rl.on('close', () => resolve())\n    })\n    return messages\n  } catch (err) {\n    log(`LOAD_SESSION error: ${err}`)\n    return []\n  }\n})\n\nipcMain.handle(IPC.SELECT_DIRECTORY, async () => {\n  if (!mainWindow) return null\n  // macOS: activate app so unparented dialog appears on top (not behind other apps).\n  // Unparented avoids modal dimming on the transparent overlay.\n  // Activation is fine here — user is actively interacting with CLUI.\n  if (process.platform === 'darwin') app.focus()\n  const options = { properties: ['openDirectory'] as const }\n  const result = process.platform === 'darwin'\n    ? await dialog.showOpenDialog(options)\n    : await dialog.showOpenDialog(mainWindow, options)\n  return result.canceled ? null : result.filePaths[0]\n})\n\nipcMain.handle(IPC.OPEN_EXTERNAL, async (_event, url: string) => {\n  try {\n    // Parse with URL constructor to reject malformed/ambiguous payloads\n    const parsed = new URL(url)\n    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false\n    if (!parsed.hostname) return false\n    await shell.openExternal(parsed.href)\n    return true\n  } catch {\n    return false\n  }\n})\n\nipcMain.handle(IPC.ATTACH_FILES, async () => {\n  if (!mainWindow) return null\n  // macOS: activate app so unparented dialog appears on top\n  if (process.platform === 'darwin') app.focus()\n  const options = {\n    properties: ['openFile', 'multiSelections'],\n    filters: [\n      { name: 'All Files', extensions: ['*'] },\n      { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] },\n      { name: 'Code', extensions: ['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'md', 'json', 'yaml', 'toml'] },\n    ],\n  }\n  const result = process.platform === 'darwin'\n    ? await dialog.showOpenDialog(options)\n    : await dialog.showOpenDialog(mainWindow, options)\n  if (result.canceled || result.filePaths.length === 0) return null\n\n  const { basename, extname } = require('path')\n  const { readFileSync, statSync } = require('fs')\n\n  const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'])\n  const mimeMap: Record<string, string> = {\n    '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',\n    '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',\n    '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',\n    '.json': 'application/json', '.yaml': 'text/yaml', '.toml': 'text/toml',\n  }\n\n  return result.filePaths.map((fp: string) => {\n    const ext = extname(fp).toLowerCase()\n    const mime = mimeMap[ext] || 'application/octet-stream'\n    const stat = statSync(fp)\n    let dataUrl: string | undefined\n\n    // Generate preview data URL for images (max 2MB to keep IPC fast)\n    if (IMAGE_EXTS.has(ext) && stat.size < 2 * 1024 * 1024) {\n      try {\n        const buf = readFileSync(fp)\n        dataUrl = `data:${mime};base64,${buf.toString('base64')}`\n      } catch {}\n    }\n\n    return {\n      id: crypto.randomUUID(),\n      type: IMAGE_EXTS.has(ext) ? 'image' : 'file',\n      name: basename(fp),\n      path: fp,\n      mimeType: mime,\n      dataUrl,\n      size: stat.size,\n    }\n  })\n})\n\nipcMain.handle(IPC.TAKE_SCREENSHOT, async () => {\n  if (!mainWindow) return null\n\n  if (SPACES_DEBUG) snapshotWindowState('screenshot pre-hide')\n  mainWindow.hide()\n  await new Promise((r) => setTimeout(r, 300))\n\n  try {\n    const { execSync } = require('child_process')\n    const { join } = require('path')\n    const { tmpdir } = require('os')\n    const { readFileSync, existsSync } = require('fs')\n\n    const timestamp = Date.now()\n    const screenshotPath = join(tmpdir(), `clui-screenshot-${timestamp}.png`)\n\n    execSync(`/usr/sbin/screencapture -i \"${screenshotPath}\"`, {\n      timeout: 30000,\n      stdio: 'ignore',\n    })\n\n    if (!existsSync(screenshotPath)) {\n      return null\n    }\n\n    // Return structured attachment with data URL preview\n    const buf = readFileSync(screenshotPath)\n    return {\n      id: crypto.randomUUID(),\n      type: 'image',\n      name: `screenshot ${++screenshotCounter}.png`,\n      path: screenshotPath,\n      mimeType: 'image/png',\n      dataUrl: `data:image/png;base64,${buf.toString('base64')}`,\n      size: buf.length,\n    }\n  } catch {\n    return null\n  } finally {\n    if (mainWindow) {\n      mainWindow.show()\n      mainWindow.webContents.focus()\n    }\n    broadcast(IPC.WINDOW_SHOWN)\n    if (SPACES_DEBUG) {\n      log('[spaces] screenshot restore show+focus')\n      snapshotWindowState('screenshot restore immediate')\n      setTimeout(() => snapshotWindowState('screenshot restore +200ms'), 200)\n    }\n  }\n})\n\nlet pasteCounter = 0\nipcMain.handle(IPC.PASTE_IMAGE, async (_event, dataUrl: string) => {\n  try {\n    const { writeFileSync } = require('fs')\n    const { join } = require('path')\n    const { tmpdir } = require('os')\n\n    // Parse data URL: \"data:image/png;base64,...\"\n    const match = dataUrl.match(/^data:(image\\/(\\w+));base64,(.+)$/)\n    if (!match) return null\n\n    const [, mimeType, ext, base64Data] = match\n    const buf = Buffer.from(base64Data, 'base64')\n    const timestamp = Date.now()\n    const filePath = join(tmpdir(), `clui-paste-${timestamp}.${ext}`)\n    writeFileSync(filePath, buf)\n\n    return {\n      id: crypto.randomUUID(),\n      type: 'image',\n      name: `pasted image ${++pasteCounter}.${ext}`,\n      path: filePath,\n      mimeType,\n      dataUrl,\n      size: buf.length,\n    }\n  } catch {\n    return null\n  }\n})\n\nipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => {\n  const { writeFileSync, existsSync, unlinkSync, readFileSync } = require('fs')\n  const { execFile } = require('child_process')\n  const { join, basename } = require('path')\n  const { tmpdir } = require('os')\n\n  const startedAt = Date.now()\n  const phaseMs: Record<string, number> = {}\n  const mark = (name: string, t0: number) => { phaseMs[name] = Date.now() - t0 }\n\n  const tmpWav = join(tmpdir(), `clui-voice-${Date.now()}.wav`)\n  try {\n    const runExecFile = (bin: string, args: string[], timeout: number): Promise<string> =>\n      new Promise((resolve, reject) => {\n        execFile(bin, args, { encoding: 'utf-8', timeout }, (err: any, stdout: string, stderr: string) => {\n          if (err) {\n            const detail = stderr?.trim() || stdout?.trim() || err.message\n            reject(new Error(detail))\n            return\n          }\n          resolve(stdout || '')\n        })\n      })\n\n    let t0 = Date.now()\n    const buf = Buffer.from(audioBase64, 'base64')\n    writeFileSync(tmpWav, buf)\n    mark('decode+write_wav', t0)\n\n    // Find whisper backend in priority order: whisperkit-cli (Apple Silicon CoreML) → whisper-cli (whisper-cpp) → whisper (python)\n    t0 = Date.now()\n    const candidates = [\n      '/opt/homebrew/bin/whisperkit-cli',\n      '/usr/local/bin/whisperkit-cli',\n      '/opt/homebrew/bin/whisper-cli',\n      '/usr/local/bin/whisper-cli',\n      '/opt/homebrew/bin/whisper',\n      '/usr/local/bin/whisper',\n      join(homedir(), '.local/bin/whisper'),\n    ]\n\n    let whisperBin = ''\n    for (const c of candidates) {\n      if (existsSync(c)) { whisperBin = c; break }\n    }\n    mark('probe_binary_paths', t0)\n\n    if (!whisperBin) {\n      t0 = Date.now()\n      for (const name of ['whisperkit-cli', 'whisper-cli', 'whisper']) {\n        try {\n          whisperBin = await runExecFile('/bin/zsh', ['-lc', `whence -p ${name}`], 5000).then((s) => s.trim())\n          if (whisperBin) break\n        } catch {}\n      }\n      mark('probe_binary_whence', t0)\n    }\n\n    if (!whisperBin) {\n      const hint = process.arch === 'arm64'\n        ? 'brew install whisperkit-cli   (or: brew install whisper-cpp)'\n        : 'brew install whisper-cpp'\n      return {\n        error: `Whisper not found. Install with:\\n  ${hint}`,\n        transcript: null,\n      }\n    }\n\n    const isWhisperKit = whisperBin.includes('whisperkit-cli')\n    const isWhisperCpp = !isWhisperKit && whisperBin.includes('whisper-cli')\n\n    log(`Transcribing with: ${whisperBin} (backend: ${isWhisperKit ? 'WhisperKit' : isWhisperCpp ? 'whisper-cpp' : 'Python whisper'})`)\n\n    let output: string\n    if (isWhisperKit) {\n      // WhisperKit (Apple Silicon CoreML) — auto-downloads models on first run\n      // Use --report to produce a JSON file with a top-level \"text\" field for deterministic parsing\n      const reportDir = tmpdir()\n      t0 = Date.now()\n      output = await runExecFile(\n        whisperBin,\n        ['transcribe', '--audio-path', tmpWav, '--model', 'tiny', '--without-timestamps', '--skip-special-tokens', '--report', '--report-path', reportDir],\n        60000\n      )\n      mark('whisperkit_transcribe_report', t0)\n\n      // WhisperKit writes <audioFileName>.json (filename without extension)\n      const wavBasename = basename(tmpWav, '.wav')\n      const reportPath = join(reportDir, `${wavBasename}.json`)\n      if (existsSync(reportPath)) {\n        try {\n          t0 = Date.now()\n          const report = JSON.parse(readFileSync(reportPath, 'utf-8'))\n          const transcript = (report.text || '').trim()\n          mark('whisperkit_parse_report_json', t0)\n          try { unlinkSync(reportPath) } catch {}\n          // Also clean up .srt that --report creates\n          const srtPath = join(reportDir, `${wavBasename}.srt`)\n          try { unlinkSync(srtPath) } catch {}\n          log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`)\n          return { error: null, transcript }\n        } catch (parseErr: any) {\n          log(`WhisperKit JSON parse failed: ${parseErr.message}, falling back to stdout`)\n          try { unlinkSync(reportPath) } catch {}\n        }\n      }\n\n      // Performance fallback: avoid a second full transcription if report file is missing/invalid.\n      // Use stdout from the first run to keep latency close to pre-report behavior.\n      if (!output || !output.trim()) {\n        t0 = Date.now()\n        output = await runExecFile(\n          whisperBin,\n          ['transcribe', '--audio-path', tmpWav, '--model', 'tiny', '--without-timestamps', '--skip-special-tokens'],\n          60000\n        )\n        mark('whisperkit_transcribe_stdout_rerun', t0)\n      }\n    } else if (isWhisperCpp) {\n      // whisper-cpp: whisper-cli -m model -f file --no-timestamps\n      // Find model file — prefer multilingual (auto-detect language) over .en (English-only)\n      const modelCandidates = [\n        join(homedir(), '.local/share/whisper/ggml-base.bin'),\n        join(homedir(), '.local/share/whisper/ggml-tiny.bin'),\n        '/opt/homebrew/share/whisper-cpp/models/ggml-base.bin',\n        '/opt/homebrew/share/whisper-cpp/models/ggml-tiny.bin',\n        join(homedir(), '.local/share/whisper/ggml-base.en.bin'),\n        join(homedir(), '.local/share/whisper/ggml-tiny.en.bin'),\n        '/opt/homebrew/share/whisper-cpp/models/ggml-base.en.bin',\n        '/opt/homebrew/share/whisper-cpp/models/ggml-tiny.en.bin',\n      ]\n\n      let modelPath = ''\n      for (const m of modelCandidates) {\n        if (existsSync(m)) { modelPath = m; break }\n      }\n\n      if (!modelPath) {\n        return {\n          error: 'Whisper model not found. Download with:\\n  mkdir -p ~/.local/share/whisper && curl -L -o ~/.local/share/whisper/ggml-tiny.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',\n          transcript: null,\n        }\n      }\n\n      const isEnglishOnly = modelPath.includes('.en.')\n      const langFlag = isEnglishOnly ? '-l en' : '-l auto'\n      t0 = Date.now()\n      output = await runExecFile(\n        whisperBin,\n        ['-m', modelPath, '-f', tmpWav, '--no-timestamps', '-l', isEnglishOnly ? 'en' : 'auto'],\n        30000\n      )\n      mark('whisper_cpp_transcribe', t0)\n    } else {\n      // Python whisper\n      t0 = Date.now()\n      output = await runExecFile(\n        whisperBin,\n        [tmpWav, '--model', 'tiny', '--output_format', 'txt', '--output_dir', tmpdir()],\n        30000\n      )\n      mark('python_whisper_transcribe', t0)\n      // Python whisper writes .txt file\n      const txtPath = tmpWav.replace('.wav', '.txt')\n      if (existsSync(txtPath)) {\n        t0 = Date.now()\n        const transcript = readFileSync(txtPath, 'utf-8').trim()\n        mark('python_whisper_read_txt', t0)\n        try { unlinkSync(txtPath) } catch {}\n        log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`)\n        return { error: null, transcript }\n      }\n      // File not created — Python whisper failed silently\n      return {\n        error: `Whisper output file not found at ${txtPath}. Check disk space and permissions.`,\n        transcript: null,\n      }\n    }\n\n    // WhisperKit (stdout fallback) and whisper-cpp print to stdout directly\n    // Strip timestamp patterns and known hallucination outputs\n    const HALLUCINATIONS = /^\\s*(\\[BLANK_AUDIO\\]|you\\.?|thank you\\.?|thanks\\.?)\\s*$/i\n    const transcript = output\n      .replace(/\\[[\\d:.]+\\s*-->\\s*[\\d:.]+\\]\\s*/g, '')\n      .trim()\n\n    if (HALLUCINATIONS.test(transcript)) {\n      log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`)\n      return { error: null, transcript: '' }\n    }\n\n    log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`)\n    return { error: null, transcript: transcript || '' }\n  } catch (err: any) {\n    log(`Transcription error: ${err.message}`)\n    log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt, failed: true })}`)\n    return {\n      error: `Transcription failed: ${err.message}`,\n      transcript: null,\n    }\n  } finally {\n    try { unlinkSync(tmpWav) } catch {}\n  }\n})\n\nipcMain.handle(IPC.GET_DIAGNOSTICS, () => {\n  const { readFileSync, existsSync } = require('fs')\n  const health = controlPlane.getHealth()\n\n  let recentLogs = ''\n  if (existsSync(LOG_FILE)) {\n    try {\n      const content = readFileSync(LOG_FILE, 'utf-8')\n      const lines = content.split('\\n')\n      recentLogs = lines.slice(-100).join('\\n')\n    } catch {}\n  }\n\n  return {\n    health,\n    logPath: LOG_FILE,\n    recentLogs,\n    platform: process.platform,\n    arch: process.arch,\n    electronVersion: process.versions.electron,\n    nodeVersion: process.versions.node,\n    appVersion: app.getVersion(),\n    transport: INTERACTIVE_PTY ? 'pty' : 'stream-json',\n  }\n})\n\nipcMain.handle(IPC.OPEN_IN_TERMINAL, (_event, arg: string | null | { sessionId?: string | null; projectPath?: string }) => {\n  const { execFile } = require('child_process')\n  const claudeBin = 'claude'\n\n  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n  // Support both old (string) and new ({ sessionId, projectPath }) calling convention\n  let sessionId: string | null = null\n  let projectPath: string = process.cwd()\n  if (typeof arg === 'string') {\n    sessionId = arg\n  } else if (arg && typeof arg === 'object') {\n    sessionId = arg.sessionId ?? null\n    projectPath = arg.projectPath && arg.projectPath !== '~' ? arg.projectPath : process.cwd()\n  }\n\n  // Validate sessionId — must be a strict UUID to prevent injection into the shell command\n  if (sessionId && !UUID_RE.test(sessionId)) {\n    log(`OPEN_IN_TERMINAL: rejected invalid sessionId: ${sessionId}`)\n    return false\n  }\n\n  // Sanitize projectPath — reject null bytes, newlines, and non-absolute paths\n  if (/[\\0\\r\\n]/.test(projectPath) || !projectPath.startsWith('/')) {\n    log(`OPEN_IN_TERMINAL: rejected invalid projectPath: ${projectPath}`)\n    return false\n  }\n\n  // Shell-safe single-quote escaping: replace ' with '\\'' (end quote, escaped literal quote, reopen quote)\n  // Single quotes block all shell expansion ($, `, \\, etc.) — unlike double quotes which allow $() and backticks\n  const shellSingleQuote = (s: string): string => \"'\" + s.replace(/'/g, \"'\\\\''\") + \"'\"\n  // AppleScript string escaping: backslashes doubled, double quotes escaped\n  const escapeAppleScript = (s: string): string => s.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n\n  const safeDir = escapeAppleScript(shellSingleQuote(projectPath))\n\n  let cmd: string\n  if (sessionId) {\n    // sessionId is UUID-validated above, safe to embed directly\n    cmd = `cd ${safeDir} && ${claudeBin} --resume ${sessionId}`\n  } else {\n    cmd = `cd ${safeDir} && ${claudeBin}`\n  }\n\n  const script = `tell application \"Terminal\"\n  activate\n  do script \"${cmd}\"\nend tell`\n\n  try {\n    execFile('/usr/bin/osascript', ['-e', script], (err: Error | null) => {\n      if (err) log(`Failed to open terminal: ${err.message}`)\n      else log(`Opened terminal with: ${cmd}`)\n    })\n    return true\n  } catch (err: unknown) {\n    log(`Failed to open terminal: ${err}`)\n    return false\n  }\n})\n\n// ─── Marketplace IPC ───\n\nipcMain.handle(IPC.MARKETPLACE_FETCH, async (_event, { forceRefresh } = {}) => {\n  log('IPC MARKETPLACE_FETCH')\n  return fetchCatalog(forceRefresh)\n})\n\nipcMain.handle(IPC.MARKETPLACE_INSTALLED, async () => {\n  log('IPC MARKETPLACE_INSTALLED')\n  return listInstalled()\n})\n\nipcMain.handle(IPC.MARKETPLACE_INSTALL, async (_event, { repo, pluginName, marketplace, sourcePath, isSkillMd }: { repo: string; pluginName: string; marketplace: string; sourcePath?: string; isSkillMd?: boolean }) => {\n  log(`IPC MARKETPLACE_INSTALL: ${pluginName} from ${repo} (isSkillMd=${isSkillMd})`)\n  return installPlugin(repo, pluginName, marketplace, sourcePath, isSkillMd)\n})\n\nipcMain.handle(IPC.MARKETPLACE_UNINSTALL, async (_event, { pluginName }: { pluginName: string }) => {\n  log(`IPC MARKETPLACE_UNINSTALL: ${pluginName}`)\n  return uninstallPlugin(pluginName)\n})\n\n// ─── Theme Detection ───\n\nipcMain.handle(IPC.GET_THEME, () => {\n  return { isDark: nativeTheme.shouldUseDarkColors }\n})\n\nnativeTheme.on('updated', () => {\n  broadcast(IPC.THEME_CHANGED, nativeTheme.shouldUseDarkColors)\n})\n\n// ─── Permission Preflight ───\n// Request all required macOS permissions upfront on first launch so the user\n// is never interrupted mid-session by a permission prompt.\n\nasync function requestPermissions(): Promise<void> {\n  if (process.platform !== 'darwin') return\n\n  // ── Microphone (for voice input via Whisper) ──\n  try {\n    const micStatus = systemPreferences.getMediaAccessStatus('microphone')\n    if (micStatus === 'not-determined') {\n      await systemPreferences.askForMediaAccess('microphone')\n    }\n  } catch (err: any) {\n    log(`Permission preflight: microphone check failed — ${err.message}`)\n  }\n\n  // ── Accessibility (for global ⌥+Space shortcut) ──\n  // globalShortcut works without it on modern macOS; Cmd+Shift+K is always the fallback.\n  // Screen Recording: not requested upfront — macOS 15 Sequoia shows an alarming\n  // \"bypass private window picker\" dialog. Let the OS prompt naturally if/when\n  // the screenshot feature is actually used.\n}\n\n// ─── App Lifecycle ───\n\napp.whenReady().then(async () => {\n  // macOS: become an accessory app. Accessory apps can have key windows (keyboard works)\n  // without deactivating the currently active app (hover preserved in browsers).\n  // This is how Spotlight, Alfred, Raycast work.\n  if (process.platform === 'darwin' && app.dock) {\n    app.dock.hide()\n  }\n\n  // Request permissions upfront so the user is never interrupted mid-session.\n  await requestPermissions()\n\n  installContentSecurityPolicy()\n\n  // Skill provisioning — non-blocking, streams status to renderer\n  ensureSkills((status: SkillStatus) => {\n    log(`Skill ${status.name}: ${status.state}${status.error ? ` — ${status.error}` : ''}`)\n    broadcast(IPC.SKILL_STATUS, status)\n  }).catch((err: Error) => log(`Skill provisioning error: ${err.message}`))\n\n  createWindow()\n  snapshotWindowState('after createWindow')\n\n  if (SPACES_DEBUG) {\n    mainWindow?.on('show', () => snapshotWindowState('event window show'))\n    mainWindow?.on('hide', () => snapshotWindowState('event window hide'))\n    mainWindow?.on('focus', () => snapshotWindowState('event window focus'))\n    mainWindow?.on('blur', () => snapshotWindowState('event window blur'))\n    mainWindow?.webContents.on('focus', () => snapshotWindowState('event webContents focus'))\n    mainWindow?.webContents.on('blur', () => snapshotWindowState('event webContents blur'))\n\n    app.on('browser-window-focus', () => snapshotWindowState('event app browser-window-focus'))\n    app.on('browser-window-blur', () => snapshotWindowState('event app browser-window-blur'))\n\n    screen.on('display-added', (_e, display) => {\n      log(`[spaces] event display-added id=${display.id}`)\n      snapshotWindowState('event display-added')\n    })\n    screen.on('display-removed', (_e, display) => {\n      log(`[spaces] event display-removed id=${display.id}`)\n      snapshotWindowState('event display-removed')\n    })\n    screen.on('display-metrics-changed', (_e, display, changedMetrics) => {\n      log(`[spaces] event display-metrics-changed id=${display.id} changed=${changedMetrics.join(',')}`)\n      snapshotWindowState('event display-metrics-changed')\n    })\n  }\n\n\n  // Primary: Option+Space (2 keys, doesn't conflict with shell)\n  // Fallback: Cmd+Shift+K kept as secondary shortcut\n  const registered = globalShortcut.register('Alt+Space', () => toggleWindow('shortcut Alt+Space'))\n  if (!registered) {\n    log('Alt+Space shortcut registration failed — macOS input sources may claim it')\n  }\n  globalShortcut.register('CommandOrControl+Shift+K', () => toggleWindow('shortcut Cmd/Ctrl+Shift+K'))\n\n  const trayIconPath = join(__dirname, '../../resources/trayTemplate.png')\n  const trayIcon = nativeImage.createFromPath(trayIconPath)\n  trayIcon.setTemplateImage(true)\n  tray = new Tray(trayIcon)\n  tray.setToolTip('Clui CC — Claude Code UI')\n  tray.on('click', () => toggleWindow('tray click'))\n  tray.setContextMenu(\n    Menu.buildFromTemplate([\n      { label: 'Show Clui CC', click: () => showWindow('tray menu') },\n      { label: 'Quit', click: () => { app.quit() } },\n    ])\n  )\n\n  // app 'activate' fires when macOS brings the app to the foreground (e.g. after\n  // webContents.focus() triggers applicationDidBecomeActive on some macOS versions).\n  // Using showWindow here instead of toggleWindow prevents the re-entry race where\n  // a summon immediately hides itself because activate fires mid-show.\n  app.on('activate', () => showWindow('app activate'))\n})\n\napp.on('will-quit', () => {\n  globalShortcut.unregisterAll()\n  controlPlane.shutdown()\n  flushLogs()\n})\n\napp.on('window-all-closed', () => {\n  if (process.platform !== 'darwin') {\n    app.quit()\n  }\n})\n"
  },
  {
    "path": "src/main/logger.ts",
    "content": "import { appendFile, appendFileSync } from 'fs'\nimport { homedir } from 'os'\nimport { join } from 'path'\n\nconst LOG_FILE = join(homedir(), '.clui-debug.log')\nconst FLUSH_INTERVAL_MS = 500\nconst MAX_BUFFER_SIZE = 64\n\nlet buffer: string[] = []\nlet timer: ReturnType<typeof setInterval> | null = null\n/** All chunks handed to async appendFile not yet confirmed written */\nconst inFlight = new Map<number, string>()\nlet nextChunkId = 1\n\nfunction flush(): void {\n  if (buffer.length === 0) return\n  const chunk = buffer.join('')\n  buffer = []\n  const chunkId = nextChunkId++\n  inFlight.set(chunkId, chunk)\n  appendFile(LOG_FILE, chunk, () => { inFlight.delete(chunkId) })\n}\n\nfunction ensureTimer(): void {\n  if (timer) return\n  timer = setInterval(flush, FLUSH_INTERVAL_MS)\n  if (timer && typeof timer === 'object' && 'unref' in timer) {\n    timer.unref()\n  }\n}\n\nexport function log(tag: string, msg: string): void {\n  buffer.push(`[${new Date().toISOString()}] [${tag}] ${msg}\\n`)\n  if (buffer.length >= MAX_BUFFER_SIZE) flush()\n  ensureTimer()\n}\n\n/**\n * Synchronously drain all pending logs. Call on shutdown to guarantee\n * every buffered or in-flight line is persisted before the process exits.\n */\nexport function flushLogs(): void {\n  if (timer) { clearInterval(timer); timer = null }\n  // Re-write all in-flight chunks synchronously (async writes may not have landed)\n  const pendingInflight = Array.from(inFlight.values()).join('')\n  const pending = pendingInflight + buffer.join('')\n  inFlight.clear()\n  buffer = []\n  if (pending) {\n    try { appendFileSync(LOG_FILE, pending) } catch {}\n  }\n}\n\nexport { LOG_FILE }\n"
  },
  {
    "path": "src/main/marketplace/catalog.ts",
    "content": "import { net } from 'electron'\nimport { execFile } from 'child_process'\nimport { readFile, readdir, mkdir, writeFile, rm } from 'fs/promises'\nimport { join, resolve } from 'path'\nimport { homedir } from 'os'\nimport type { CatalogPlugin } from '../../shared/types'\nimport { log as _log } from '../logger'\nimport { getCliEnv } from '../cli-env'\n\n// ─── Input Validation ───\n\n// Strict safe charset for plugin names: alphanumeric, hyphens, underscores, dots\nconst SAFE_PLUGIN_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/\n// Strict owner/repo format\nconst SAFE_REPO = /^[a-zA-Z0-9_.-]+\\/[a-zA-Z0-9_.-]+$/\n\nfunction validatePluginName(name: string): boolean {\n  return SAFE_PLUGIN_NAME.test(name) && !name.includes('..')\n}\n\nfunction validateRepo(repo: string): boolean {\n  return SAFE_REPO.test(repo)\n}\n\nfunction validateSourcePath(p: string): boolean {\n  // Reject absolute paths, null bytes, backslashes, and traversal\n  if (!p || /[\\0\\\\]/.test(p) || p.startsWith('/') || p.includes('..')) return false\n  return true\n}\n\nfunction assertSkillDirContained(skillsDir: string, base: string): void {\n  const resolved = resolve(skillsDir)\n  if (!resolved.startsWith(base + '/') && resolved !== base) {\n    throw new Error(`Path escapes skills directory: ${resolved}`)\n  }\n}\n\nfunction log(msg: string): void {\n  _log('marketplace', msg)\n}\n\n// ─── Sources ───\n\nconst SOURCES = [\n  { repo: 'anthropics/skills', category: 'Agent Skills' },\n  { repo: 'anthropics/knowledge-work-plugins', category: 'Knowledge Work' },\n  { repo: 'anthropics/financial-services-plugins', category: 'Financial Services' },\n] as const\n\n// ─── TTL Cache ───\n\nlet cachedPlugins: CatalogPlugin[] | null = null\nlet cacheTimestamp = 0\nconst CACHE_TTL = 5 * 60 * 1000 // 5 minutes\n\n// Cache raw SKILL.md content keyed by skill name for direct installation\nconst skillContentCache = new Map<string, string>()\n\n// ─── fetchCatalog ───\n\nexport async function fetchCatalog(forceRefresh?: boolean): Promise<{ plugins: CatalogPlugin[]; error: string | null }> {\n  if (!forceRefresh && cachedPlugins && Date.now() - cacheTimestamp < CACHE_TTL) {\n    return { plugins: cachedPlugins, error: null }\n  }\n\n  const allPlugins: CatalogPlugin[] = []\n  const errors: string[] = []\n\n  const results = await Promise.allSettled(\n    SOURCES.map(async (source) => {\n      const marketplaceUrl = `https://raw.githubusercontent.com/${source.repo}/main/.claude-plugin/marketplace.json`\n      log(`Fetching marketplace: ${marketplaceUrl}`)\n\n      const marketplaceRes = await netFetch(marketplaceUrl)\n      if (!marketplaceRes.ok) {\n        throw new Error(`Failed to fetch marketplace for ${source.repo}: ${marketplaceRes.status}`)\n      }\n\n      const marketplaceData = JSON.parse(marketplaceRes.body) as {\n        name: string\n        plugins: Array<{\n          name: string\n          source: string\n          description?: string\n          author?: { name: string } | string\n          skills?: string[]\n        }>\n      }\n\n      const safeMarketplaceName = typeof marketplaceData.name === 'string' && marketplaceData.name.trim().length > 0\n        ? marketplaceData.name.trim()\n        : source.repo\n\n      // Flatten: for entries with a skills[] array, expand each skill as its own catalog item.\n      // For entries without skills[] (knowledge-work, financial-services), use plugin.json as before.\n      type FetchJob = { installName: string; skillPath: string; entryDescription: string; entryAuthor: string; useSkillMd: boolean }\n      const jobs: FetchJob[] = []\n\n      for (const entry of marketplaceData.plugins) {\n        let entryAuthor = ''\n        if (entry.author) {\n          entryAuthor = typeof entry.author === 'string' ? entry.author : entry.author.name || ''\n        }\n\n        if (entry.skills && entry.skills.length > 0) {\n          // Skills repo: each skill path (e.g. \"./skills/xlsx\") becomes its own entry\n          for (const skillRef of entry.skills) {\n            const skillPath = skillRef.replace(/^\\.\\//, '').replace(/\\/$/, '')\n            // Use the individual skill directory name as installName (not the bundle name)\n            const individualName = skillPath.split('/').pop() || entry.name\n            jobs.push({\n              installName: individualName,\n              skillPath,\n              entryDescription: entry.description || '',\n              entryAuthor,\n              useSkillMd: true,\n            })\n          }\n        } else {\n          // Standard plugin: source points to a directory with .claude-plugin/plugin.json\n          const normalizedSource = entry.source.replace(/^\\.\\//, '').replace(/\\/$/, '')\n          jobs.push({\n            installName: entry.name,\n            skillPath: normalizedSource || entry.name,\n            entryDescription: entry.description || '',\n            entryAuthor,\n            useSkillMd: false,\n          })\n        }\n      }\n\n      const jobResults = await Promise.allSettled(\n        jobs.map(async (job) => {\n          let name = ''\n          let description = ''\n          let version = '0.0.0'\n          let author = job.entryAuthor || 'Anthropic'\n\n          if (job.useSkillMd) {\n            // Fetch SKILL.md and parse frontmatter for name/description\n            const skillUrl = `https://raw.githubusercontent.com/${source.repo}/main/${job.skillPath}/SKILL.md`\n            try {\n              const res = await netFetch(skillUrl)\n              if (res.ok) {\n                const parsed = parseSkillFrontmatter(res.body)\n                name = parsed.name\n                description = parsed.description\n                // Cache raw content for direct installation\n                skillContentCache.set(job.installName, res.body)\n              }\n            } catch (e) {\n              log(`SKILL.md fetch failed for ${job.skillPath}`)\n            }\n          } else {\n            // Fetch plugin.json\n            const pluginUrl = `https://raw.githubusercontent.com/${source.repo}/main/${job.skillPath}/.claude-plugin/plugin.json`\n            try {\n              const res = await netFetch(pluginUrl)\n              if (res.ok) {\n                const data = JSON.parse(res.body) as { name?: string; version?: string; description?: string; author?: string }\n                name = data.name?.trim() || ''\n                description = data.description || ''\n                version = data.version?.trim() || '0.0.0'\n                author = data.author?.trim() || author\n              }\n            } catch (e) {\n              log(`plugin.json fetch failed for ${job.skillPath}`)\n            }\n          }\n\n          // Fallbacks\n          const dirName = job.skillPath.split('/').pop() || job.installName\n          if (!name) name = dirName\n          if (!description) description = job.entryDescription\n\n          const plugin: CatalogPlugin = {\n            id: `${source.repo}/${job.skillPath}`,\n            name,\n            description,\n            version,\n            author,\n            marketplace: safeMarketplaceName,\n            repo: source.repo,\n            sourcePath: job.skillPath,\n            installName: job.installName,\n            category: source.category,\n            tags: deriveSemanticTags(name, description, job.skillPath),\n            isSkillMd: job.useSkillMd,\n          }\n          return plugin\n        })\n      )\n\n      for (const r of jobResults) {\n        if (r.status === 'fulfilled') {\n          allPlugins.push(r.value)\n        } else {\n          log(`Plugin fetch warning: ${r.reason}`)\n        }\n      }\n    })\n  )\n\n  for (const r of results) {\n    if (r.status === 'rejected') {\n      log(`Source fetch error: ${r.reason}`)\n      errors.push(String(r.reason))\n    }\n  }\n\n  // Only error if ALL sources failed and we got no plugins\n  if (allPlugins.length === 0 && errors.length > 0) {\n    return { plugins: [], error: errors.join('; ') }\n  }\n\n  // Sort by name\n  allPlugins.sort((a, b) => a.name.localeCompare(b.name))\n\n  // Update cache\n  cachedPlugins = allPlugins\n  cacheTimestamp = Date.now()\n\n  return { plugins: allPlugins, error: null }\n}\n\n// ─── listInstalled ───\n// Reads directly from ~/.claude filesystem for reliable detection:\n// - Plugins: ~/.claude/plugins/installed_plugins.json (keys are \"name@marketplace\")\n// - Skills: ~/.claude/skills/ (each subdirectory is an installed skill)\n\nexport async function listInstalled(): Promise<string[]> {\n  const claudeDir = join(homedir(), '.claude')\n  const names: string[] = []\n\n  // 1. Installed plugins from JSON registry\n  try {\n    const raw = await readFile(join(claudeDir, 'plugins', 'installed_plugins.json'), 'utf-8')\n    const data = JSON.parse(raw) as { plugins?: Record<string, unknown> }\n    if (data.plugins) {\n      for (const key of Object.keys(data.plugins)) {\n        // Keys are \"name@marketplace\" e.g. \"design@knowledge-work-plugins\"\n        const pluginName = key.split('@')[0]\n        if (pluginName) names.push(pluginName)\n        // Also push the full key for exact matching\n        names.push(key)\n      }\n    }\n  } catch (e) {\n    log(`listInstalled: no installed_plugins.json or parse error: ${e}`)\n  }\n\n  // 2. Installed skills from ~/.claude/skills/\n  try {\n    const entries = await readdir(join(claudeDir, 'skills'), { withFileTypes: true })\n    for (const entry of entries) {\n      if (entry.isDirectory()) {\n        names.push(entry.name)\n      }\n    }\n  } catch (e) {\n    log(`listInstalled: no skills dir or read error: ${e}`)\n  }\n\n  return [...new Set(names)]\n}\n\n// ─── installPlugin ───\n// For SKILL.md skills: writes directly to ~/.claude/skills/<name>/\n// For CLI plugins: falls back to `claude plugin install`\n\nexport async function installPlugin(\n  repo: string,\n  pluginName: string,\n  marketplace: string,\n  sourcePath?: string,\n  isSkillMd?: boolean\n): Promise<{ ok: boolean; error?: string }> {\n  try {\n    // Validate all external inputs before any filesystem or network operations\n    if (!validatePluginName(pluginName)) {\n      return { ok: false, error: `Invalid plugin name: ${pluginName}` }\n    }\n    if (!validateRepo(repo)) {\n      return { ok: false, error: `Invalid repo format: ${repo}` }\n    }\n    if (sourcePath && !validateSourcePath(sourcePath)) {\n      return { ok: false, error: `Invalid source path: ${sourcePath}` }\n    }\n\n    if (isSkillMd !== false) {\n      // Direct SKILL.md install\n      const skillsBase = join(homedir(), '.claude', 'skills')\n      const skillsDir = join(skillsBase, pluginName)\n      assertSkillDirContained(skillsDir, skillsBase)\n\n      // Check if we have cached content from the catalog fetch\n      let content = skillContentCache.get(pluginName)\n\n      if (!content) {\n        const path = sourcePath || `skills/${pluginName}`\n        const url = `https://raw.githubusercontent.com/${repo}/main/${path}/SKILL.md`\n        log(`installPlugin: fetching ${url}`)\n        const res = await netFetch(url)\n        if (!res.ok) {\n          return { ok: false, error: `Failed to fetch SKILL.md (${res.status})` }\n        }\n        content = res.body\n      }\n\n      await mkdir(skillsDir, { recursive: true })\n      await writeFile(join(skillsDir, 'SKILL.md'), content, 'utf-8')\n      log(`installPlugin: wrote ${skillsDir}/SKILL.md`)\n    } else {\n      // CLI plugin install (knowledge-work, financial-services bundles)\n      const addResult = await execAsync('claude', ['plugin', 'marketplace', 'add', repo], 15000)\n      if (addResult.exitCode !== 0 && !addResult.stdout.includes('already added') && !addResult.stderr.includes('already added')) {\n        return { ok: false, error: addResult.stderr || 'Failed to add marketplace' }\n      }\n      const installResult = await execAsync('claude', ['plugin', 'install', `${pluginName}@${marketplace}`], 15000)\n      if (installResult.exitCode !== 0) {\n        return { ok: false, error: installResult.stderr || 'Failed to install plugin' }\n      }\n    }\n\n    return { ok: true }\n  } catch (err: unknown) {\n    const msg = err instanceof Error ? err.message : String(err)\n    log(`installPlugin error: ${msg}`)\n    return { ok: false, error: msg }\n  }\n}\n\n// ─── uninstallPlugin ───\n\nexport async function uninstallPlugin(\n  pluginName: string\n): Promise<{ ok: boolean; error?: string }> {\n  try {\n    if (!validatePluginName(pluginName)) {\n      return { ok: false, error: `Invalid plugin name: ${pluginName}` }\n    }\n    const skillsBase = join(homedir(), '.claude', 'skills')\n    const skillsDir = join(skillsBase, pluginName)\n    assertSkillDirContained(skillsDir, skillsBase)\n    await rm(skillsDir, { recursive: true, force: true })\n    log(`uninstallPlugin: removed ${skillsDir}`)\n    return { ok: true }\n  } catch (err: unknown) {\n    const msg = err instanceof Error ? err.message : String(err)\n    log(`uninstallPlugin error: ${msg}`)\n    return { ok: false, error: msg }\n  }\n}\n\n// ─── Helpers ───\n\nfunction netFetch(url: string): Promise<{ ok: boolean; status: number; body: string }> {\n  return new Promise((resolve, reject) => {\n    const request = net.request(url)\n    request.on('response', (response) => {\n      let body = ''\n      response.on('data', (chunk) => { body += chunk.toString() })\n      response.on('end', () => {\n        resolve({\n          ok: response.statusCode >= 200 && response.statusCode < 300,\n          status: response.statusCode,\n          body,\n        })\n      })\n    })\n    request.on('error', (err) => reject(err))\n    request.end()\n  })\n}\n\n/** Parse YAML-like frontmatter from SKILL.md (name: ..., description: \"...\") */\nfunction parseSkillFrontmatter(content: string): { name: string; description: string } {\n  let name = ''\n  let description = ''\n  // Frontmatter is at the top, no --- delimiters — just key: value lines\n  const lines = content.split('\\n')\n  for (const line of lines) {\n    const nameMatch = line.match(/^name:\\s*(.+)/)\n    if (nameMatch && !name) {\n      name = nameMatch[1].replace(/^[\"']|[\"']$/g, '').trim()\n    }\n    const descMatch = line.match(/^description:\\s*(.+)/)\n    if (descMatch && !description) {\n      // Description may be quoted and span conceptually one line\n      description = descMatch[1].replace(/^[\"']|[\"']$/g, '').trim()\n      // Truncate long descriptions for display\n      if (description.length > 200) {\n        description = description.substring(0, 197) + '...'\n      }\n    }\n    // Stop after we have both, or after hitting a markdown heading (end of frontmatter)\n    if (name && description) break\n    if (line.startsWith('# ')) break\n  }\n  return { name, description }\n}\n\n// ─── Semantic tag derivation ───\n// Maps plugin meaning (name, description, path) to discoverable use-case tags.\n// Provenance (repo, author, marketplace) stays in metadata, not tags.\n\nconst TAG_RULES: Array<{ tag: string; patterns: RegExp }> = [\n  { tag: 'Design',       patterns: /\\b(figma|ui|ux|design|sketch|prototype|wireframe|layout|css|style|visual)\\b/i },\n  { tag: 'Product',      patterns: /\\b(prd|roadmap|strategy|product|backlog|prioriti[sz]|feature\\s*request|user\\s*stor)\\b/i },\n  { tag: 'Research',     patterns: /\\b(research|interview|insights?|survey|user\\s*study|ethnograph|discover)\\b/i },\n  { tag: 'Docs',         patterns: /\\b(doc(ument)?s?|writing|spec(ification)?|readme|markdown|technical\\s*writ|content)\\b/i },\n  { tag: 'Spreadsheet',  patterns: /\\b(sheet|spreadsheet|xlsx?|csv|tabular|pivot|formula)\\b/i },\n  { tag: 'Slides',       patterns: /\\b(slides?|presentation|deck|pptx?|keynote|pitch)\\b/i },\n  { tag: 'Analysis',     patterns: /\\b(analy[sz](is|e|ing)|insight|metric|dashboard|report(ing)?|data\\s*viz|statistic)\\b/i },\n  { tag: 'Finance',      patterns: /\\b(financ|accounting|budget|revenue|forecast|valuation|portfolio|investment)\\b/i },\n  { tag: 'Compliance',   patterns: /\\b(risk|audit|policy|compliance|regulat|governance|sox|gdpr|hipaa)\\b/i },\n  { tag: 'Management',   patterns: /\\b(manag|planning|meeting|ops|operations|team|workflow|project\\s*plan)\\b/i },\n  { tag: 'Automation',   patterns: /\\b(automat|workflow|pipeline|ci\\s*cd|deploy|integrat|orchestrat|script)\\b/i },\n  { tag: 'Code',         patterns: /\\b(code|coding|program|develop|engineer|debug|refactor|test(ing)?|linter?)\\b/i },\n  { tag: 'Creative',     patterns: /\\b(creative|brainstorm|ideation|copywriting|storytelling|narrative)\\b/i },\n  { tag: 'Sales',        patterns: /\\b(sales|crm|prospect|lead|deal|pipeline|outreach|cold\\s*(call|email))\\b/i },\n  { tag: 'Support',      patterns: /\\b(support|customer|helpdesk|ticket|troubleshoot|faq|knowledge\\s*base)\\b/i },\n  { tag: 'Security',     patterns: /\\b(secur|vulnerabilit|pentest|threat|encrypt|auth(enticat|ori[sz]))\\b/i },\n  { tag: 'Data',         patterns: /\\b(data|database|sql|etl|warehouse|lake|ingest|transform|schema)\\b/i },\n  { tag: 'AI/ML',        patterns: /\\b(ai|ml|machine\\s*learn|model|train|inference|llm|prompt|embed)\\b/i },\n]\n\nfunction deriveSemanticTags(name: string, description: string, skillPath: string): string[] {\n  const text = `${name} ${description} ${skillPath}`.toLowerCase()\n  const matched: string[] = []\n  for (const rule of TAG_RULES) {\n    if (rule.patterns.test(text)) {\n      matched.push(rule.tag)\n    }\n    if (matched.length >= 2) break // Cap at 2 semantic tags\n  }\n  return matched\n}\n\nfunction execAsync(cmd: string, args: string[], timeout: number): Promise<{ exitCode: number; stdout: string; stderr: string }> {\n  return new Promise((resolve) => {\n    execFile(cmd, args, { timeout, env: getCliEnv() }, (err, stdout, stderr) => {\n      resolve({\n        exitCode: err ? 1 : 0,\n        stdout: stdout || '',\n        stderr: stderr || '',\n      })\n    })\n  })\n}\n"
  },
  {
    "path": "src/main/process-manager.ts",
    "content": "import { spawn, execSync, ChildProcess } from 'child_process'\nimport { EventEmitter } from 'events'\nimport { homedir } from 'os'\nimport { appendFileSync } from 'fs'\nimport { join } from 'path'\nimport { StreamParser } from './stream-parser'\nimport { getCliEnv } from './cli-env'\nimport type { ClaudeEvent, RunOptions } from '../shared/types'\n\nconst LOG_FILE = join(homedir(), '.clui-debug.log')\n\nfunction log(msg: string): void {\n  const line = `[${new Date().toISOString()}] ${msg}\\n`\n  try { appendFileSync(LOG_FILE, line) } catch {}\n}\n\nexport interface RunHandle {\n  runId: string\n  sessionId: string | null\n  process: ChildProcess\n  parser: StreamParser\n}\n\n/**\n * Manages Claude Code subprocesses.\n */\nexport class ProcessManager extends EventEmitter {\n  private activeRuns = new Map<string, RunHandle>()\n  private claudeBinary: string\n\n  constructor() {\n    super()\n    // Find the real claude binary — Electron doesn't inherit shell aliases or full PATH\n    this.claudeBinary = this.findClaudeBinary()\n    log(`Claude binary: ${this.claudeBinary}`)\n  }\n\n  private findClaudeBinary(): string {\n    // Try common locations\n    const candidates = [\n      '/usr/local/bin/claude',\n      '/opt/homebrew/bin/claude',\n      join(homedir(), '.npm-global/bin/claude'),\n      join(homedir(), '.nvm/versions/node', '**', 'bin/claude'),\n    ]\n\n    for (const c of candidates) {\n      try {\n        execSync(`test -x \"${c}\"`, { stdio: 'ignore' })\n        return c\n      } catch {}\n    }\n\n    // Fallback: ask a login shell\n    try {\n      const result = execSync('/bin/zsh -ilc \"whence -p claude\"', { encoding: 'utf-8', env: getCliEnv() }).trim()\n      if (result) return result\n    } catch {}\n\n    try {\n      const result = execSync('/bin/bash -lc \"which claude\"', { encoding: 'utf-8', env: getCliEnv() }).trim()\n      if (result) return result\n    } catch {}\n\n    // Last resort\n    return 'claude'\n  }\n\n  startRun(options: RunOptions): RunHandle {\n    const runId = crypto.randomUUID()\n    const cwd = options.projectPath === '~' ? homedir() : options.projectPath\n\n    const args: string[] = [\n      '-p',\n      '--output-format', 'stream-json',\n      '--verbose',\n      '--include-partial-messages',\n      '--permission-mode', 'acceptEdits',\n      '--chrome',\n    ]\n\n    if (options.sessionId) {\n      args.push('--resume', options.sessionId)\n    }\n\n    if (options.allowedTools?.length) {\n      args.push('--allowedTools', options.allowedTools.join(','))\n    }\n\n    if (options.maxTurns) {\n      args.push('--max-turns', String(options.maxTurns))\n    }\n\n    if (options.maxBudgetUsd) {\n      args.push('--max-budget-usd', String(options.maxBudgetUsd))\n    }\n\n    if (options.systemPrompt) {\n      args.push('--system-prompt', options.systemPrompt)\n    }\n\n    log(`Starting run ${runId}: ${this.claudeBinary} ${args.join(' ')}`)\n    log(`Prompt: ${options.prompt.substring(0, 200)}`)\n\n    // Build environment: merge login shell PATH with Electron's env\n    // Electron doesn't source ~/.zshrc so PATH is often incomplete\n    const env = getCliEnv()\n\n    // Ensure our claude binary's directory is in PATH\n    const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/'))\n    if (env.PATH && !env.PATH.includes(binDir)) {\n      env.PATH = `${binDir}:${env.PATH}`\n    }\n\n    const child = spawn(this.claudeBinary, args, {\n      stdio: ['pipe', 'pipe', 'pipe'],\n      cwd,\n      env,\n    })\n\n    log(`Spawned PID: ${child.pid}`)\n\n    const parser = StreamParser.fromStream(child.stdout!)\n\n    const handle: RunHandle = {\n      runId,\n      sessionId: null,\n      process: child,\n      parser,\n    }\n\n    parser.on('event', (event: ClaudeEvent) => {\n      log(`Event [${runId}]: ${event.type}`)\n      if (event.type === 'system' && 'subtype' in event && event.subtype === 'init') {\n        handle.sessionId = (event as any).session_id\n      }\n      this.emit('event', runId, event)\n    })\n\n    parser.on('parse-error', (line: string) => {\n      log(`Parse error [${runId}]: ${line.substring(0, 200)}`)\n      this.emit('parse-error', runId, line)\n    })\n\n    child.on('close', (code) => {\n      log(`Process closed [${runId}]: code=${code}`)\n      this.activeRuns.delete(runId)\n      this.emit('exit', runId, code, handle.sessionId)\n    })\n\n    child.on('error', (err) => {\n      log(`Process error [${runId}]: ${err.message}`)\n      this.activeRuns.delete(runId)\n      this.emit('error', runId, err)\n    })\n\n    child.stderr?.setEncoding('utf-8')\n    child.stderr?.on('data', (data: string) => {\n      log(`Stderr [${runId}]: ${data.trim().substring(0, 500)}`)\n      this.emit('stderr', runId, data)\n    })\n\n    child.stdin!.write(options.prompt)\n    child.stdin!.end()\n\n    this.activeRuns.set(runId, handle)\n    return handle\n  }\n\n  cancelRun(runId: string): boolean {\n    const handle = this.activeRuns.get(runId)\n    if (!handle) return false\n\n    log(`Cancelling run ${runId}`)\n    handle.process.kill('SIGINT')\n\n    setTimeout(() => {\n      if (handle.process.exitCode === null) {\n        handle.process.kill('SIGTERM')\n      }\n    }, 5000)\n\n    return true\n  }\n\n  isRunning(runId: string): boolean {\n    return this.activeRuns.has(runId)\n  }\n\n  getActiveRunIds(): string[] {\n    return Array.from(this.activeRuns.keys())\n  }\n}\n"
  },
  {
    "path": "src/main/skills/installer.ts",
    "content": "/**\n * Skill installer — ensures manifest skills are present in ~/.claude/skills/.\n *\n * Runs on app startup (non-blocking). Uses atomic install:\n *   tmp dir → validate → rename into place.\n *\n * Respects user-managed skills: if a skill dir exists without .clui-version,\n * it was placed there by the user and we don't touch it.\n */\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, cpSync } from 'fs'\nimport { join, dirname } from 'path'\nimport { homedir } from 'os'\nimport { execSync } from 'child_process'\nimport { randomUUID } from 'crypto'\nimport { SKILLS, type SkillEntry } from './manifest'\n\n/** Directory containing bundled skill sources (relative to main process __dirname) */\nconst BUNDLED_SKILLS_DIR = join(__dirname, '../../skills')\n\nconst SKILLS_DIR = join(homedir(), '.claude', 'skills')\nconst VERSION_FILE = '.clui-version'\n\nexport type SkillState = 'pending' | 'downloading' | 'validating' | 'installed' | 'failed' | 'skipped'\n\nexport interface SkillStatus {\n  name: string\n  state: SkillState\n  error?: string\n  reason?: 'up-to-date' | 'user-managed'\n}\n\ninterface VersionMeta {\n  version: string\n  source: string\n  installedBy: string\n  installedAt: string\n}\n\nfunction log(msg: string): void {\n  const { appendFileSync } = require('fs')\n  const line = `[${new Date().toISOString()}] [skills] ${msg}\\n`\n  try { appendFileSync(join(homedir(), '.clui-debug.log'), line) } catch {}\n}\n\nfunction readVersionFile(skillDir: string): VersionMeta | null {\n  const fp = join(skillDir, VERSION_FILE)\n  if (!existsSync(fp)) return null\n  try {\n    return JSON.parse(readFileSync(fp, 'utf-8'))\n  } catch {\n    return null\n  }\n}\n\nfunction writeVersionFile(skillDir: string, entry: SkillEntry): void {\n  const meta: VersionMeta = {\n    version: entry.version,\n    source: entry.source.type === 'github'\n      ? `github:${entry.source.repo}@${entry.source.commitSha}`\n      : 'bundled',\n    installedBy: 'clui',\n    installedAt: new Date().toISOString(),\n  }\n  writeFileSync(join(skillDir, VERSION_FILE), JSON.stringify(meta, null, 2) + '\\n')\n}\n\nfunction validateSkill(dir: string, requiredFiles: string[]): string | null {\n  for (const f of requiredFiles) {\n    if (!existsSync(join(dir, f))) {\n      return `Missing required file: ${f}`\n    }\n  }\n  return null\n}\n\nasync function installGithubSkill(\n  entry: SkillEntry & { source: { type: 'github'; repo: string; path: string; commitSha: string } },\n  onStatus: (s: SkillStatus) => void,\n): Promise<void> {\n  const targetDir = join(SKILLS_DIR, entry.name)\n  const tmpDir = join(SKILLS_DIR, `.tmp-${entry.name}-${randomUUID().slice(0, 8)}`)\n\n  onStatus({ name: entry.name, state: 'downloading' })\n  log(`Downloading ${entry.name} from ${entry.source.repo}@${entry.source.commitSha}`)\n\n  try {\n    mkdirSync(tmpDir, { recursive: true })\n\n    // Download pinned tarball and extract only the skill subdirectory.\n    // GitHub tarballs have a top-level directory like \"anthropics-skills-<sha>/\".\n    // We strip the top-level + intermediate path components to get just the skill files.\n    const { repo, path, commitSha } = entry.source\n    const pathDepth = path.split('/').length + 1 // +1 for the github top-level dir\n    const tarballUrl = `https://api.github.com/repos/${repo}/tarball/${commitSha}`\n\n    // Use curl + tar — both always available on macOS\n    const cmd = [\n      `curl -sL \"${tarballUrl}\"`,\n      '|',\n      `tar -xz --strip-components=${pathDepth} -C \"${tmpDir}\" \"*/${path}\"`,\n    ].join(' ')\n\n    execSync(cmd, { timeout: 60000, stdio: 'pipe' })\n\n    // Validate extracted files\n    onStatus({ name: entry.name, state: 'validating' })\n    const err = validateSkill(tmpDir, entry.requiredFiles)\n    if (err) {\n      throw new Error(`Validation failed: ${err}`)\n    }\n\n    // Atomic swap: remove old (if CLUI-managed), rename tmp into place\n    if (existsSync(targetDir)) {\n      const existing = readVersionFile(targetDir)\n      if (existing?.installedBy === 'clui') {\n        rmSync(targetDir, { recursive: true, force: true })\n      } else {\n        // User-managed — shouldn't reach here (checked earlier), but be safe\n        rmSync(tmpDir, { recursive: true, force: true })\n        onStatus({ name: entry.name, state: 'skipped', reason: 'user-managed' })\n        return\n      }\n    }\n\n    renameSync(tmpDir, targetDir)\n    writeVersionFile(targetDir, entry)\n\n    log(`Installed ${entry.name} v${entry.version}`)\n    onStatus({ name: entry.name, state: 'installed' })\n  } catch (err: unknown) {\n    const msg = err instanceof Error ? err.message : String(err)\n    log(`Failed to install ${entry.name}: ${msg}`)\n\n    // Clean up tmp dir on failure\n    try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}\n\n    onStatus({ name: entry.name, state: 'failed', error: msg })\n  }\n}\n\nasync function installBundledSkill(\n  entry: SkillEntry,\n  onStatus: (s: SkillStatus) => void,\n): Promise<void> {\n  const sourceDir = join(BUNDLED_SKILLS_DIR, entry.name)\n  const targetDir = join(SKILLS_DIR, entry.name)\n  const tmpDir = join(SKILLS_DIR, `.tmp-${entry.name}-${randomUUID().slice(0, 8)}`)\n\n  onStatus({ name: entry.name, state: 'downloading' }) // \"downloading\" reused for copy\n  log(`Copying bundled skill ${entry.name} from ${sourceDir}`)\n\n  try {\n    if (!existsSync(sourceDir)) {\n      throw new Error(`Bundled skill source not found: ${sourceDir}`)\n    }\n\n    mkdirSync(tmpDir, { recursive: true })\n    cpSync(sourceDir, tmpDir, { recursive: true })\n\n    // Validate\n    onStatus({ name: entry.name, state: 'validating' })\n    const err = validateSkill(tmpDir, entry.requiredFiles)\n    if (err) {\n      throw new Error(`Validation failed: ${err}`)\n    }\n\n    // Atomic swap\n    if (existsSync(targetDir)) {\n      const existing = readVersionFile(targetDir)\n      if (existing?.installedBy === 'clui') {\n        rmSync(targetDir, { recursive: true, force: true })\n      } else {\n        rmSync(tmpDir, { recursive: true, force: true })\n        onStatus({ name: entry.name, state: 'skipped', reason: 'user-managed' })\n        return\n      }\n    }\n\n    renameSync(tmpDir, targetDir)\n    writeVersionFile(targetDir, entry)\n\n    log(`Installed bundled skill ${entry.name} v${entry.version}`)\n    onStatus({ name: entry.name, state: 'installed' })\n  } catch (err: unknown) {\n    const msg = err instanceof Error ? err.message : String(err)\n    log(`Failed to install bundled skill ${entry.name}: ${msg}`)\n    try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}\n    onStatus({ name: entry.name, state: 'failed', error: msg })\n  }\n}\n\nasync function installSkill(\n  entry: SkillEntry,\n  onStatus: (s: SkillStatus) => void,\n): Promise<void> {\n  const targetDir = join(SKILLS_DIR, entry.name)\n\n  // Check if already installed and up-to-date\n  if (existsSync(targetDir)) {\n    const meta = readVersionFile(targetDir)\n\n    if (!meta) {\n      // Dir exists but no .clui-version — user-managed, don't touch\n      log(`Skipping ${entry.name}: user-managed (no ${VERSION_FILE})`)\n      onStatus({ name: entry.name, state: 'skipped', reason: 'user-managed' })\n      return\n    }\n\n    if (meta.version === entry.version && meta.installedBy === 'clui') {\n      // Re-validate required files to detect corrupt/partial installs\n      const validationErr = validateSkill(targetDir, entry.requiredFiles)\n      if (!validationErr) {\n        log(`Skipping ${entry.name}: already at v${entry.version}`)\n        onStatus({ name: entry.name, state: 'skipped', reason: 'up-to-date' })\n        return\n      }\n      log(`Repairing ${entry.name}: version matches but ${validationErr}`)\n    }\n\n    // Version mismatch — needs update\n    log(`Updating ${entry.name}: v${meta.version} → v${entry.version}`)\n  }\n\n  // Ensure parent dir exists\n  mkdirSync(SKILLS_DIR, { recursive: true })\n\n  if (entry.source.type === 'github') {\n    await installGithubSkill(\n      entry as SkillEntry & { source: { type: 'github'; repo: string; path: string; commitSha: string } },\n      onStatus,\n    )\n  } else {\n    await installBundledSkill(entry, onStatus)\n  }\n}\n\n/**\n * Ensure all manifest skills are installed. Non-blocking, non-crashing.\n * Calls onStatus for each skill as it progresses through states.\n */\nexport async function ensureSkills(\n  onStatus: (s: SkillStatus) => void = () => {},\n): Promise<void> {\n  log(`Checking ${SKILLS.length} skill(s)`)\n\n  for (const entry of SKILLS) {\n    onStatus({ name: entry.name, state: 'pending' })\n    try {\n      await installSkill(entry, onStatus)\n    } catch (err: unknown) {\n      const msg = err instanceof Error ? err.message : String(err)\n      log(`Unexpected error installing ${entry.name}: ${msg}`)\n      onStatus({ name: entry.name, state: 'failed', error: msg })\n    }\n  }\n\n  log('Skill provisioning complete')\n}\n"
  },
  {
    "path": "src/main/skills/manifest.ts",
    "content": "/**\n * Skill manifest — defines which skills CLUI auto-installs into ~/.claude/skills/.\n *\n * Two source types:\n *   - github: downloaded from a pinned commit SHA (deterministic, not branch tip)\n *   - bundled: copied from CLUI's own resources (for skills we author ourselves)\n *\n * To add a new skill, append an entry here. The installer handles the rest.\n */\n\nexport interface SkillEntry {\n  name: string\n  source:\n    | { type: 'github'; repo: string; path: string; commitSha: string }\n    | { type: 'bundled' }\n  version: string\n  /** Files that must exist after install for validation */\n  requiredFiles: string[]\n}\n\nexport const SKILLS: SkillEntry[] = [\n  {\n    name: 'skill-creator',\n    source: {\n      type: 'github',\n      repo: 'anthropics/skills',\n      path: 'skills/skill-creator',\n      commitSha: 'b0cbd3df1533b396d281a6886d5132f623393a9c',\n    },\n    version: '1.0.0',\n    requiredFiles: [\n      'SKILL.md',\n      'agents/grader.md',\n      'agents/comparator.md',\n      'agents/analyzer.md',\n      'references/schemas.md',\n      'scripts/run_loop.py',\n      'scripts/run_eval.py',\n      'scripts/package_skill.py',\n    ],\n  },\n]\n"
  },
  {
    "path": "src/main/stream-parser.ts",
    "content": "import { Readable } from 'stream'\nimport { EventEmitter } from 'events'\nimport type { ClaudeEvent } from '../shared/types'\n\n/**\n * Parses NDJSON output from `claude -p --output-format stream-json`.\n * Each line is a JSON object. Unknown event types are emitted but never crash.\n */\nexport class StreamParser extends EventEmitter {\n  private buffer = ''\n\n  /**\n   * Feed a chunk of data (from stdout) into the parser.\n   * Emits 'event' for each parsed JSON line.\n   */\n  feed(chunk: string): void {\n    this.buffer += chunk\n    const lines = this.buffer.split('\\n')\n    // Keep the last (possibly incomplete) line in the buffer\n    this.buffer = lines.pop() || ''\n\n    for (const line of lines) {\n      const trimmed = line.trim()\n      if (!trimmed) continue\n      try {\n        const parsed = JSON.parse(trimmed) as ClaudeEvent\n        this.emit('event', parsed)\n      } catch {\n        // Non-JSON line (e.g. stderr mixed in) — log but don't crash\n        this.emit('parse-error', trimmed)\n      }\n    }\n  }\n\n  /**\n   * Flush any remaining data in the buffer (call when stream ends).\n   */\n  flush(): void {\n    const trimmed = this.buffer.trim()\n    if (trimmed) {\n      try {\n        const parsed = JSON.parse(trimmed) as ClaudeEvent\n        this.emit('event', parsed)\n      } catch {\n        this.emit('parse-error', trimmed)\n      }\n    }\n    this.buffer = ''\n  }\n\n  /**\n   * Convenience: pipe a readable stream through the parser.\n   */\n  static fromStream(stream: Readable): StreamParser {\n    const parser = new StreamParser()\n    stream.setEncoding('utf-8')\n    stream.on('data', (chunk: string) => parser.feed(chunk))\n    stream.on('end', () => parser.flush())\n    return parser\n  }\n}\n"
  },
  {
    "path": "src/preload/index.ts",
    "content": "import { contextBridge, ipcRenderer } from 'electron'\nimport { IPC } from '../shared/types'\nimport type { RunOptions, NormalizedEvent, HealthReport, EnrichedError, Attachment, SessionMeta, CatalogPlugin, SessionLoadMessage } from '../shared/types'\n\nexport interface CluiAPI {\n  // ─── Request-response (renderer → main) ───\n  start(): Promise<{ version: string; auth: { email?: string; subscriptionType?: string; authMethod?: string }; mcpServers: string[]; projectPath: string; homePath: string }>\n  createTab(): Promise<{ tabId: string }>\n  prompt(tabId: string, requestId: string, options: RunOptions): Promise<void>\n  cancel(requestId: string): Promise<boolean>\n  stopTab(tabId: string): Promise<boolean>\n  retry(tabId: string, requestId: string, options: RunOptions): Promise<void>\n  status(): Promise<HealthReport>\n  tabHealth(): Promise<HealthReport>\n  closeTab(tabId: string): Promise<void>\n  selectDirectory(): Promise<string | null>\n  openExternal(url: string): Promise<boolean>\n  openInTerminal(sessionId: string | null, projectPath?: string): Promise<boolean>\n  attachFiles(): Promise<Attachment[] | null>\n  takeScreenshot(): Promise<Attachment | null>\n  pasteImage(dataUrl: string): Promise<Attachment | null>\n  transcribeAudio(audioBase64: string): Promise<{ error: string | null; transcript: string | null }>\n  getDiagnostics(): Promise<any>\n  respondPermission(tabId: string, questionId: string, optionId: string): Promise<boolean>\n  initSession(tabId: string): void\n  resetTabSession(tabId: string): void\n  listSessions(projectPath?: string): Promise<SessionMeta[]>\n  loadSession(sessionId: string, projectPath?: string): Promise<SessionLoadMessage[]>\n  fetchMarketplace(forceRefresh?: boolean): Promise<{ plugins: CatalogPlugin[]; error: string | null }>\n  listInstalledPlugins(): Promise<string[]>\n  installPlugin(repo: string, pluginName: string, marketplace: string, sourcePath?: string, isSkillMd?: boolean): Promise<{ ok: boolean; error?: string }>\n  uninstallPlugin(pluginName: string): Promise<{ ok: boolean; error?: string }>\n  setPermissionMode(mode: string): void\n  getTheme(): Promise<{ isDark: boolean }>\n  onThemeChange(callback: (isDark: boolean) => void): () => void\n\n  // ─── Window management ───\n  resizeHeight(height: number): void\n  setWindowWidth(width: number): void\n  animateHeight(from: number, to: number, durationMs: number): Promise<void>\n  hideWindow(): void\n  isVisible(): Promise<boolean>\n  /** OS-level click-through for transparent window regions */\n  setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void\n  /** Manual window drag for frameless windows */\n  startWindowDrag(deltaX: number, deltaY: number): void\n  /** Reset overlay to its default bottom-center position */\n  resetWindowPosition(): void\n\n  // ─── Event listeners (main → renderer) ───\n  onEvent(callback: (tabId: string, event: NormalizedEvent) => void): () => void\n  onTabStatusChange(callback: (tabId: string, newStatus: string, oldStatus: string) => void): () => void\n  onError(callback: (tabId: string, error: EnrichedError) => void): () => void\n  onSkillStatus(callback: (status: { name: string; state: string; error?: string; reason?: string }) => void): () => void\n  onWindowShown(callback: () => void): () => void\n}\n\nconst api: CluiAPI = {\n  // ─── Request-response ───\n  start: () => ipcRenderer.invoke(IPC.START),\n  createTab: () => ipcRenderer.invoke(IPC.CREATE_TAB),\n  prompt: (tabId, requestId, options) => ipcRenderer.invoke(IPC.PROMPT, { tabId, requestId, options }),\n  cancel: (requestId) => ipcRenderer.invoke(IPC.CANCEL, requestId),\n  stopTab: (tabId) => ipcRenderer.invoke(IPC.STOP_TAB, tabId),\n  retry: (tabId, requestId, options) => ipcRenderer.invoke(IPC.RETRY, { tabId, requestId, options }),\n  status: () => ipcRenderer.invoke(IPC.STATUS),\n  tabHealth: () => ipcRenderer.invoke(IPC.TAB_HEALTH),\n  closeTab: (tabId) => ipcRenderer.invoke(IPC.CLOSE_TAB, tabId),\n  selectDirectory: () => ipcRenderer.invoke(IPC.SELECT_DIRECTORY),\n  openExternal: (url) => ipcRenderer.invoke(IPC.OPEN_EXTERNAL, url),\n  openInTerminal: (sessionId, projectPath) => ipcRenderer.invoke(IPC.OPEN_IN_TERMINAL, { sessionId, projectPath }),\n  attachFiles: () => ipcRenderer.invoke(IPC.ATTACH_FILES),\n  takeScreenshot: () => ipcRenderer.invoke(IPC.TAKE_SCREENSHOT),\n  pasteImage: (dataUrl) => ipcRenderer.invoke(IPC.PASTE_IMAGE, dataUrl),\n  transcribeAudio: (audioBase64) => ipcRenderer.invoke(IPC.TRANSCRIBE_AUDIO, audioBase64),\n  getDiagnostics: () => ipcRenderer.invoke(IPC.GET_DIAGNOSTICS),\n  respondPermission: (tabId, questionId, optionId) =>\n    ipcRenderer.invoke(IPC.RESPOND_PERMISSION, { tabId, questionId, optionId }),\n  initSession: (tabId) => ipcRenderer.send(IPC.INIT_SESSION, tabId),\n  resetTabSession: (tabId) => ipcRenderer.send(IPC.RESET_TAB_SESSION, tabId),\n  listSessions: (projectPath?: string) => ipcRenderer.invoke(IPC.LIST_SESSIONS, projectPath),\n  loadSession: (sessionId: string, projectPath?: string) => ipcRenderer.invoke(IPC.LOAD_SESSION, { sessionId, projectPath }),\n  fetchMarketplace: (forceRefresh) => ipcRenderer.invoke(IPC.MARKETPLACE_FETCH, { forceRefresh }),\n  listInstalledPlugins: () => ipcRenderer.invoke(IPC.MARKETPLACE_INSTALLED),\n  installPlugin: (repo, pluginName, marketplace, sourcePath, isSkillMd) =>\n    ipcRenderer.invoke(IPC.MARKETPLACE_INSTALL, { repo, pluginName, marketplace, sourcePath, isSkillMd }),\n  uninstallPlugin: (pluginName) =>\n    ipcRenderer.invoke(IPC.MARKETPLACE_UNINSTALL, { pluginName }),\n  setPermissionMode: (mode) => ipcRenderer.send(IPC.SET_PERMISSION_MODE, mode),\n  getTheme: () => ipcRenderer.invoke(IPC.GET_THEME),\n  onThemeChange: (callback) => {\n    const handler = (_e: Electron.IpcRendererEvent, isDark: boolean) => callback(isDark)\n    ipcRenderer.on(IPC.THEME_CHANGED, handler)\n    return () => ipcRenderer.removeListener(IPC.THEME_CHANGED, handler)\n  },\n\n  // ─── Window management ───\n  resizeHeight: (height) => ipcRenderer.send(IPC.RESIZE_HEIGHT, height),\n  animateHeight: (from, to, durationMs) =>\n    ipcRenderer.invoke(IPC.ANIMATE_HEIGHT, { from, to, durationMs }),\n  hideWindow: () => ipcRenderer.send(IPC.HIDE_WINDOW),\n  isVisible: () => ipcRenderer.invoke(IPC.IS_VISIBLE),\n  setIgnoreMouseEvents: (ignore, options) =>\n    ipcRenderer.send(IPC.SET_IGNORE_MOUSE_EVENTS, ignore, options || {}),\n  startWindowDrag: (deltaX, deltaY) =>\n    ipcRenderer.send(IPC.START_WINDOW_DRAG, deltaX, deltaY),\n  resetWindowPosition: () => ipcRenderer.send(IPC.RESET_WINDOW_POSITION),\n  setWindowWidth: (width) => ipcRenderer.send(IPC.SET_WINDOW_WIDTH, width),\n\n  // ─── Event listeners ───\n  onEvent: (callback) => {\n    const channels = [\n      IPC.TEXT_CHUNK, IPC.TOOL_CALL, IPC.TOOL_CALL_UPDATE,\n      IPC.TOOL_CALL_COMPLETE, IPC.TASK_UPDATE, IPC.TASK_COMPLETE,\n      IPC.SESSION_DEAD, IPC.SESSION_INIT, IPC.ERROR, IPC.RATE_LIMIT,\n    ]\n    // Single unified handler — all normalized events come through one channel\n    const handler = (_e: Electron.IpcRendererEvent, tabId: string, event: NormalizedEvent) => callback(tabId, event)\n    ipcRenderer.on('clui:normalized-event', handler)\n    return () => ipcRenderer.removeListener('clui:normalized-event', handler)\n  },\n\n  onTabStatusChange: (callback) => {\n    const handler = (_e: Electron.IpcRendererEvent, tabId: string, newStatus: string, oldStatus: string) =>\n      callback(tabId, newStatus, oldStatus)\n    ipcRenderer.on('clui:tab-status-change', handler)\n    return () => ipcRenderer.removeListener('clui:tab-status-change', handler)\n  },\n\n  onError: (callback) => {\n    const handler = (_e: Electron.IpcRendererEvent, tabId: string, error: EnrichedError) =>\n      callback(tabId, error)\n    ipcRenderer.on('clui:enriched-error', handler)\n    return () => ipcRenderer.removeListener('clui:enriched-error', handler)\n  },\n\n  onSkillStatus: (callback) => {\n    const handler = (_e: Electron.IpcRendererEvent, status: any) => callback(status)\n    ipcRenderer.on(IPC.SKILL_STATUS, handler)\n    return () => ipcRenderer.removeListener(IPC.SKILL_STATUS, handler)\n  },\n\n  onWindowShown: (callback) => {\n    const handler = () => callback()\n    ipcRenderer.on(IPC.WINDOW_SHOWN, handler)\n    return () => ipcRenderer.removeListener(IPC.WINDOW_SHOWN, handler)\n  },\n}\n\ncontextBridge.exposeInMainWorld('clui', api)\n"
  },
  {
    "path": "src/renderer/App.tsx",
    "content": "import React, { useEffect, useCallback, useRef } from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { Paperclip, Camera, HeadCircuit } from '@phosphor-icons/react'\nimport { TabStrip } from './components/TabStrip'\nimport { ConversationView } from './components/ConversationView'\nimport { InputBar } from './components/InputBar'\nimport { StatusBar } from './components/StatusBar'\nimport { MarketplacePanel } from './components/MarketplacePanel'\nimport { PopoverLayerProvider } from './components/PopoverLayer'\nimport { useClaudeEvents } from './hooks/useClaudeEvents'\nimport { useHealthReconciliation } from './hooks/useHealthReconciliation'\nimport { useSessionStore } from './stores/sessionStore'\nimport { useColors, useThemeStore, spacing } from './theme'\n\nconst TRANSITION = { duration: 0.26, ease: [0.4, 0, 0.1, 1] as const }\n\nexport default function App() {\n  useClaudeEvents()\n  useHealthReconciliation()\n\n  const activeTabStatus = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId)?.status)\n  const addAttachments = useSessionStore((s) => s.addAttachments)\n  const colors = useColors()\n  const setSystemTheme = useThemeStore((s) => s.setSystemTheme)\n  const expandedUI = useThemeStore((s) => s.expandedUI)\n\n  // ─── Theme initialization ───\n  useEffect(() => {\n    // Get initial OS theme — setSystemTheme respects themeMode (system/light/dark)\n    window.clui.getTheme().then(({ isDark }) => {\n      setSystemTheme(isDark)\n    }).catch(() => {})\n\n    // Listen for OS theme changes\n    const unsub = window.clui.onThemeChange((isDark) => {\n      setSystemTheme(isDark)\n    })\n    return unsub\n  }, [setSystemTheme])\n\n  useEffect(() => {\n    useSessionStore.getState().initStaticInfo().then(() => {\n      const homeDir = useSessionStore.getState().staticInfo?.homePath || '~'\n      const tab = useSessionStore.getState().tabs[0]\n      if (tab) {\n        // Set working directory to home by default (user hasn't chosen yet)\n        useSessionStore.setState((s) => ({\n          tabs: s.tabs.map((t, i) => (i === 0 ? { ...t, workingDirectory: homeDir, hasChosenDirectory: false } : t)),\n        }))\n        window.clui.createTab().then(({ tabId }) => {\n          useSessionStore.setState((s) => ({\n            tabs: s.tabs.map((t, i) => (i === 0 ? { ...t, id: tabId } : t)),\n            activeTabId: tabId,\n          }))\n        }).catch(() => {})\n      }\n    })\n  }, [])\n\n  // Shared drag ref — must be declared before the setIgnoreMouseEvents effect so both closures can read it\n  const dragRef = useRef<{ startX: number; startY: number } | null>(null)\n\n  // Vertical position tracking — window moves first (until macOS clamps it), then CSS overflows\n  const PILL_HEIGHT_CONST = 720\n  const PILL_BOTTOM_MARGIN_CONST = 24\n  const minWindowY = window.screen.availTop   // top of work area (below menu bar)\n  const initialWindowY = window.screen.availTop + window.screen.availHeight - PILL_HEIGHT_CONST - PILL_BOTTOM_MARGIN_CONST\n  const windowYRef = useRef(initialWindowY)\n  const cardYRef = useRef(0) // CSS translateY offset (only used after window hits its y constraint)\n\n  // OS-level click-through (RAF-throttled to avoid per-pixel IPC)\n  useEffect(() => {\n    if (!window.clui?.setIgnoreMouseEvents) return\n    let lastIgnored: boolean | null = null\n\n    const onMouseMove = (e: MouseEvent) => {\n      // While dragging, keep full mouse capture — don't toggle ignore-events\n      if (dragRef.current) return\n      const el = document.elementFromPoint(e.clientX, e.clientY)\n      const isUI = !!(el && el.closest('[data-clui-ui]'))\n      const shouldIgnore = !isUI\n      if (shouldIgnore !== lastIgnored) {\n        lastIgnored = shouldIgnore\n        if (shouldIgnore) {\n          window.clui.setIgnoreMouseEvents(true, { forward: true })\n        } else {\n          window.clui.setIgnoreMouseEvents(false)\n        }\n      }\n    }\n\n    const onMouseLeave = () => {\n      if (dragRef.current) return\n      if (lastIgnored !== true) {\n        lastIgnored = true\n        window.clui.setIgnoreMouseEvents(true, { forward: true })\n      }\n    }\n\n    document.addEventListener('mousemove', onMouseMove)\n    document.addEventListener('mouseleave', onMouseLeave)\n    return () => {\n      document.removeEventListener('mousemove', onMouseMove)\n      document.removeEventListener('mouseleave', onMouseLeave)\n    }\n  }, [])\n\n  // Manual window drag — bypasses -webkit-app-region conflicts with setIgnoreMouseEvents\n  useEffect(() => {\n    if (!window.clui?.startWindowDrag) return\n\n    const onMouseDown = (e: MouseEvent) => {\n      const el = e.target as HTMLElement\n      // Skip interactive elements — everything else on the card is draggable\n      if (el.closest('button, input, textarea, a, select, [role=\"button\"], [contenteditable], .cm-editor')) return\n      if (!el.closest('[data-clui-ui]')) return\n      e.preventDefault()\n      // Double-click: snap back to default position\n      if (e.detail >= 2) {\n        window.clui.resetWindowPosition()\n        windowYRef.current = initialWindowY\n        cardYRef.current = 0\n        document.documentElement.style.setProperty('--clui-card-y', '0px')\n        return\n      }\n      // Ensure full mouse capture for the duration of the drag\n      window.clui.setIgnoreMouseEvents(false)\n      dragRef.current = { startX: e.screenX, startY: e.screenY }\n    }\n\n    const onMouseMove = (e: MouseEvent) => {\n      if (!dragRef.current) return\n      const dx = e.screenX - dragRef.current.startX\n      const dy = e.screenY - dragRef.current.startY\n      if (dx !== 0 || dy !== 0) {\n        // Horizontal: always native window movement (full screen width range)\n        if (dx !== 0) window.clui.startWindowDrag(dx, 0)\n        // Vertical: move window first (until macOS y constraint), then CSS within window\n        if (dy !== 0) {\n          if (dy < 0) {\n            // Moving up — window first, then CSS overflow\n            const windowCanMove = windowYRef.current - minWindowY\n            const windowDy = Math.max(-windowCanMove, dy)\n            const cssDy = dy - windowDy\n            if (windowDy !== 0) {\n              window.clui.startWindowDrag(0, windowDy)\n              windowYRef.current += windowDy\n            }\n            if (cssDy !== 0) {\n              cardYRef.current += cssDy\n              document.documentElement.style.setProperty('--clui-card-y', `${cardYRef.current}px`)\n            }\n          } else {\n            // Moving down — undo CSS first, then move window\n            const cssUndo = Math.min(-cardYRef.current, dy)\n            const windowDy = dy - cssUndo\n            if (cssUndo !== 0) {\n              cardYRef.current += cssUndo\n              document.documentElement.style.setProperty('--clui-card-y', `${cardYRef.current}px`)\n            }\n            if (windowDy !== 0) {\n              window.clui.startWindowDrag(0, windowDy)\n              windowYRef.current += windowDy\n            }\n          }\n        }\n        dragRef.current.startX = e.screenX\n        dragRef.current.startY = e.screenY\n      }\n    }\n\n    const onMouseUp = () => {\n      dragRef.current = null\n    }\n\n    document.addEventListener('mousedown', onMouseDown)\n    document.addEventListener('mousemove', onMouseMove)\n    document.addEventListener('mouseup', onMouseUp)\n    return () => {\n      document.removeEventListener('mousedown', onMouseDown)\n      document.removeEventListener('mousemove', onMouseMove)\n      document.removeEventListener('mouseup', onMouseUp)\n    }\n  }, [])\n\n  const isExpanded = useSessionStore((s) => s.isExpanded)\n  const marketplaceOpen = useSessionStore((s) => s.marketplaceOpen)\n  const isRunning = activeTabStatus === 'running' || activeTabStatus === 'connecting'\n\n  // Layout dimensions — expandedUI widens and heightens the panel\n  const contentWidth = expandedUI ? 700 : spacing.contentWidth\n  const cardExpandedWidth = expandedUI ? 700 : 460\n  const cardCollapsedWidth = expandedUI ? 670 : 430\n  const cardCollapsedMargin = expandedUI ? 15 : 15\n  const bodyMaxHeight = expandedUI ? 520 : 400\n\n  const handleScreenshot = useCallback(async () => {\n    const result = await window.clui.takeScreenshot()\n    if (!result) return\n    addAttachments([result])\n  }, [addAttachments])\n\n  const handleAttachFile = useCallback(async () => {\n    const files = await window.clui.attachFiles()\n    if (!files || files.length === 0) return\n    addAttachments(files)\n  }, [addAttachments])\n\n  return (\n    <PopoverLayerProvider>\n      <div className=\"flex flex-col justify-end h-full\" style={{ background: 'transparent' }}>\n\n        {/* ─── 460px content column, centered. Circles overflow left. ─── */}\n        <div style={{ width: contentWidth, position: 'relative', margin: '0 auto', transition: 'width 0.26s cubic-bezier(0.4, 0, 0.1, 1)', transform: 'translateY(var(--clui-card-y, 0px))' }}>\n\n          <AnimatePresence initial={false}>\n            {marketplaceOpen && (\n              <div\n                data-clui-ui\n                style={{\n                  width: 720,\n                  maxWidth: 720,\n                  marginLeft: '50%',\n                  transform: 'translateX(-50%)',\n                  marginBottom: 14,\n                  position: 'relative',\n                  zIndex: 30,\n                }}\n              >\n                <motion.div\n                  initial={{ opacity: 0, y: 14, scale: 0.98 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={{ opacity: 0, y: 10, scale: 0.985 }}\n                  transition={TRANSITION}\n                >\n                  <div\n                    data-clui-ui\n                    className=\"glass-surface overflow-hidden no-drag\"\n                    style={{\n                      borderRadius: 24,\n                      maxHeight: 470,\n                    }}\n                  >\n                    <MarketplacePanel />\n                  </div>\n                </motion.div>\n              </div>\n            )}\n          </AnimatePresence>\n\n          {/*\n            ─── Tabs / message shell ───\n            This always remains the chat shell. The marketplace is a separate\n            panel rendered above it, never inside it.\n          */}\n          <motion.div\n            data-clui-ui\n            className=\"overflow-hidden flex flex-col drag-region\"\n            animate={{\n              width: isExpanded ? cardExpandedWidth : cardCollapsedWidth,\n              marginBottom: isExpanded ? 10 : -14,\n              marginLeft: isExpanded ? 0 : cardCollapsedMargin,\n              marginRight: isExpanded ? 0 : cardCollapsedMargin,\n              background: isExpanded ? colors.containerBg : colors.containerBgCollapsed,\n              borderColor: colors.containerBorder,\n              boxShadow: isExpanded ? colors.cardShadow : colors.cardShadowCollapsed,\n            }}\n            transition={TRANSITION}\n            style={{\n              borderWidth: 1,\n              borderStyle: 'solid',\n              borderRadius: 20,\n              position: 'relative',\n              zIndex: isExpanded ? 20 : 10,\n            }}\n          >\n            {/* Tab strip — always mounted */}\n            <div className=\"no-drag\">\n              <TabStrip />\n            </div>\n\n            {/* Body — chat history only; the marketplace is a separate overlay above */}\n            <motion.div\n              initial={false}\n              animate={{\n                height: isExpanded ? 'auto' : 0,\n                opacity: isExpanded ? 1 : 0,\n              }}\n              transition={TRANSITION}\n              className=\"overflow-hidden no-drag\"\n            >\n              <div style={{ maxHeight: bodyMaxHeight }}>\n                <ConversationView />\n                <StatusBar />\n              </div>\n            </motion.div>\n          </motion.div>\n\n          {/* ─── Input row — circles float outside left ─── */}\n          {/* marginBottom: shadow buffer so the glass-surface drop shadow isn't clipped at the native window edge */}\n          <div data-clui-ui className=\"relative\" style={{ minHeight: 46, zIndex: 15, marginBottom: 10 }}>\n            {/* Stacked circle buttons — expand on hover */}\n            <div\n              data-clui-ui\n              className=\"circles-out\"\n            >\n              <div className=\"btn-stack\">\n                {/* btn-1: Attach (front, rightmost) */}\n                <button\n                  className=\"stack-btn stack-btn-1 glass-surface\"\n                  title=\"Attach file\"\n                  onClick={handleAttachFile}\n                  disabled={isRunning}\n                >\n                  <Paperclip size={17} />\n                </button>\n                {/* btn-2: Screenshot (middle) */}\n                <button\n                  className=\"stack-btn stack-btn-2 glass-surface\"\n                  title=\"Take screenshot\"\n                  onClick={handleScreenshot}\n                  disabled={isRunning}\n                >\n                  <Camera size={17} />\n                </button>\n                {/* btn-3: Skills (back, leftmost) */}\n                <button\n                  className=\"stack-btn stack-btn-3 glass-surface\"\n                  title=\"Skills & Plugins\"\n                  onClick={() => useSessionStore.getState().toggleMarketplace()}\n                  disabled={isRunning}\n                >\n                  <HeadCircuit size={17} />\n                </button>\n              </div>\n            </div>\n\n            {/* Input pill */}\n            <div\n              data-clui-ui\n              className=\"glass-surface w-full\"\n              style={{ minHeight: 50, borderRadius: 25, padding: '0 6px 0 16px', background: colors.inputPillBg }}\n            >\n              <InputBar />\n            </div>\n          </div>\n        </div>\n      </div>\n    </PopoverLayerProvider>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/AttachmentChips.tsx",
    "content": "import React from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { X, FileText, Image, FileCode, File } from '@phosphor-icons/react'\nimport { useColors } from '../theme'\nimport type { Attachment } from '../../shared/types'\n\nconst FILE_ICONS: Record<string, React.ReactNode> = {\n  'image/png': <Image size={14} />,\n  'image/jpeg': <Image size={14} />,\n  'image/gif': <Image size={14} />,\n  'image/webp': <Image size={14} />,\n  'image/svg+xml': <Image size={14} />,\n  'text/plain': <FileText size={14} />,\n  'text/markdown': <FileText size={14} />,\n  'application/json': <FileCode size={14} />,\n  'text/yaml': <FileCode size={14} />,\n  'text/toml': <FileCode size={14} />,\n}\n\nexport function AttachmentChips({\n  attachments,\n  onRemove,\n}: {\n  attachments: Attachment[]\n  onRemove: (id: string) => void\n}) {\n  const colors = useColors()\n\n  if (attachments.length === 0) return null\n\n  return (\n    <div data-clui-ui className=\"flex gap-1.5 pb-1\" style={{ overflowX: 'auto', scrollbarWidth: 'none' }}>\n      <AnimatePresence mode=\"popLayout\">\n        {attachments.map((a) => (\n          <motion.div\n            key={a.id}\n            layout\n            initial={{ opacity: 0, scale: 0.85 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.85 }}\n            transition={{ duration: 0.12 }}\n            className=\"flex items-center gap-1.5 group flex-shrink-0\"\n            style={{\n              background: colors.surfacePrimary,\n              border: `1px solid ${colors.surfaceSecondary}`,\n              borderRadius: 14,\n              padding: a.dataUrl ? '3px 8px 3px 3px' : '4px 8px',\n              maxWidth: 200,\n            }}\n          >\n            {/* Image preview thumbnail */}\n            {a.dataUrl ? (\n              <img\n                src={a.dataUrl}\n                alt={a.name}\n                className=\"rounded-[10px] object-cover flex-shrink-0\"\n                style={{ width: 24, height: 24 }}\n              />\n            ) : (\n              <span className=\"flex-shrink-0\" style={{ color: colors.textTertiary }}>\n                {FILE_ICONS[a.mimeType || ''] || <File size={14} />}\n              </span>\n            )}\n\n            {/* File name */}\n            <span\n              className=\"text-[11px] font-medium truncate min-w-0 flex-1\"\n              style={{ color: colors.textPrimary }}\n            >\n              {a.name}\n            </span>\n\n            {/* Remove button */}\n            <button\n              onClick={() => onRemove(a.id)}\n              className=\"flex-shrink-0 w-4 h-4 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity\"\n              style={{ color: colors.textTertiary }}\n            >\n              <X size={10} />\n            </button>\n          </motion.div>\n        ))}\n      </AnimatePresence>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/ConversationView.tsx",
    "content": "import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport Markdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport {\n  FileText, PencilSimple, FileArrowUp, Terminal, MagnifyingGlass, Globe,\n  Robot, Question, Wrench, FolderOpen, Copy, Check, CaretRight, CaretDown,\n  SpinnerGap, ArrowCounterClockwise, Square,\n} from '@phosphor-icons/react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport { PermissionCard } from './PermissionCard'\nimport { PermissionDeniedCard } from './PermissionDeniedCard'\nimport { useColors, useThemeStore } from '../theme'\nimport type { Message } from '../../shared/types'\n\n// ─── Constants ───\n\nconst INITIAL_RENDER_CAP = 100\nconst PAGE_SIZE = 100\nconst REMARK_PLUGINS = [remarkGfm] // Hoisted — prevents re-parse on every render\n\n// ─── Types ───\n\ntype GroupedItem =\n  | { kind: 'user'; message: Message }\n  | { kind: 'assistant'; message: Message }\n  | { kind: 'system'; message: Message }\n  | { kind: 'tool-group'; messages: Message[] }\n\n// ─── Helpers ───\n\nfunction groupMessages(messages: Message[]): GroupedItem[] {\n  const result: GroupedItem[] = []\n  let toolBuf: Message[] = []\n\n  const flushTools = () => {\n    if (toolBuf.length > 0) {\n      result.push({ kind: 'tool-group', messages: [...toolBuf] })\n      toolBuf = []\n    }\n  }\n\n  for (const msg of messages) {\n    if (msg.role === 'tool') {\n      toolBuf.push(msg)\n    } else {\n      flushTools()\n      if (msg.role === 'user') result.push({ kind: 'user', message: msg })\n      else if (msg.role === 'assistant') result.push({ kind: 'assistant', message: msg })\n      else result.push({ kind: 'system', message: msg })\n    }\n  }\n  flushTools()\n  return result\n}\n\n// ─── Main Component ───\n\nexport function ConversationView() {\n  const tabs = useSessionStore((s) => s.tabs)\n  const activeTabId = useSessionStore((s) => s.activeTabId)\n  const sendMessage = useSessionStore((s) => s.sendMessage)\n  const staticInfo = useSessionStore((s) => s.staticInfo)\n  const scrollRef = useRef<HTMLDivElement>(null)\n  const bottomRef = useRef<HTMLDivElement>(null)\n  const [hovered, setHovered] = useState(false)\n  const [renderOffset, setRenderOffset] = useState(0) // 0 = show from tail\n  const isNearBottomRef = useRef(true)\n  const prevTabIdRef = useRef(activeTabId)\n  const colors = useColors()\n  const expandedUI = useThemeStore((s) => s.expandedUI)\n\n  const tab = tabs.find((t) => t.id === activeTabId)\n\n  // Reset render offset and scroll state when switching tabs\n  useEffect(() => {\n    if (activeTabId !== prevTabIdRef.current) {\n      prevTabIdRef.current = activeTabId\n      setRenderOffset(0)\n      isNearBottomRef.current = true\n    }\n  }, [activeTabId])\n\n  // Track whether user is scrolled near the bottom\n  const handleScroll = useCallback(() => {\n    const el = scrollRef.current\n    if (!el) return\n    isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60\n  }, [])\n\n  // Auto-scroll when content changes and user is near bottom.\n  const msgCount = tab?.messages.length ?? 0\n  const lastMsg = tab?.messages[tab.messages.length - 1]\n  const permissionQueueLen = tab?.permissionQueue?.length ?? 0\n  const queuedCount = tab?.queuedPrompts?.length ?? 0\n  const scrollTrigger = `${msgCount}:${lastMsg?.content?.length ?? 0}:${permissionQueueLen}:${queuedCount}`\n\n  useEffect(() => {\n    if (isNearBottomRef.current && scrollRef.current) {\n      scrollRef.current.scrollTop = scrollRef.current.scrollHeight\n    }\n  }, [scrollTrigger])\n\n  // Group only the visible slice of messages\n  const allMessages = tab?.messages ?? []\n  const totalCount = allMessages.length\n  const startIndex = Math.max(0, totalCount - INITIAL_RENDER_CAP - renderOffset * PAGE_SIZE)\n  const visibleMessages = startIndex > 0 ? allMessages.slice(startIndex) : allMessages\n  const hasOlder = startIndex > 0\n\n  const grouped = useMemo(\n    () => groupMessages(visibleMessages),\n    [visibleMessages],\n  )\n\n  const hiddenCount = totalCount - visibleMessages.length\n\n  const handleLoadOlder = useCallback(() => {\n    setRenderOffset((o) => o + 1)\n  }, [])\n\n  if (!tab) return null\n\n  const isRunning = tab.status === 'running' || tab.status === 'connecting'\n  const isDead = tab.status === 'dead'\n  const isFailed = tab.status === 'failed'\n  const showInterrupt = isRunning && tab.messages.some((m) => m.role === 'user')\n\n  if (tab.messages.length === 0) {\n    return <EmptyState />\n  }\n\n  // Messages from before initial render cap are \"historical\" — no motion\n  const historicalThreshold = Math.max(0, totalCount - 20)\n\n  const handleRetry = () => {\n    const lastUserMsg = [...tab.messages].reverse().find((m) => m.role === 'user')\n    if (lastUserMsg) {\n      sendMessage(lastUserMsg.content)\n    }\n  }\n\n  return (\n    <div\n      data-clui-ui\n      onMouseEnter={() => setHovered(true)}\n      onMouseLeave={() => setHovered(false)}\n    >\n      {/* Scrollable messages area */}\n      <div\n        ref={scrollRef}\n        className=\"overflow-y-auto overflow-x-hidden px-4 pt-2 conversation-selectable\"\n        style={{ maxHeight: expandedUI ? 460 : 336, paddingBottom: 28 }}\n        onScroll={handleScroll}\n      >\n        {/* Load older button */}\n        {hasOlder && (\n          <div className=\"flex justify-center py-2\">\n            <button\n              onClick={handleLoadOlder}\n              className=\"text-[11px] px-3 py-1 rounded-full transition-colors\"\n              style={{ color: colors.textTertiary, border: `1px solid ${colors.toolBorder}` }}\n            >\n              Load {Math.min(PAGE_SIZE, hiddenCount)} older messages ({hiddenCount} hidden)\n            </button>\n          </div>\n        )}\n\n        <div className=\"space-y-1 relative\">\n          {grouped.map((item, idx) => {\n            const msgIndex = startIndex + idx\n            const isHistorical = msgIndex < historicalThreshold\n\n            switch (item.kind) {\n              case 'user':\n                return <UserMessage key={item.message.id} message={item.message} skipMotion={isHistorical} />\n              case 'assistant':\n                return <AssistantMessage key={item.message.id} message={item.message} skipMotion={isHistorical} />\n              case 'tool-group':\n                return <ToolGroup key={`tg-${item.messages[0].id}`} tools={item.messages} skipMotion={isHistorical} />\n              case 'system':\n                return <SystemMessage key={item.message.id} message={item.message} skipMotion={isHistorical} />\n              default:\n                return null\n            }\n          })}\n        </div>\n\n        {/* Permission card (shows first item from queue) */}\n        <AnimatePresence>\n          {tab.permissionQueue.length > 0 && (\n            <PermissionCard\n              tabId={tab.id}\n              permission={tab.permissionQueue[0]}\n              queueLength={tab.permissionQueue.length}\n            />\n          )}\n        </AnimatePresence>\n\n        {/* Permission denied fallback card */}\n        <AnimatePresence>\n          {tab.permissionDenied && (\n            <PermissionDeniedCard\n              tools={tab.permissionDenied.tools}\n              sessionId={tab.claudeSessionId}\n              projectPath={staticInfo?.projectPath || process.cwd()}\n              onDismiss={() => {\n                useSessionStore.setState((s) => ({\n                  tabs: s.tabs.map((t) =>\n                    t.id === tab.id ? { ...t, permissionDenied: null } : t\n                  ),\n                }))\n              }}\n            />\n          )}\n        </AnimatePresence>\n\n        {/* Queued prompts */}\n        <AnimatePresence>\n          {tab.queuedPrompts.map((prompt, i) => (\n            <QueuedMessage key={`queued-${i}`} content={prompt} />\n          ))}\n        </AnimatePresence>\n\n        <div ref={bottomRef} />\n      </div>\n\n      {/* Activity row — overlaps bottom of scroll area as a fade strip */}\n      <div\n        className=\"flex items-center justify-between px-4 relative\"\n        style={{\n          height: 28,\n          minHeight: 28,\n          marginTop: -28,\n          background: `linear-gradient(to bottom, transparent, ${colors.containerBg} 70%)`,\n          zIndex: 2,\n        }}\n      >\n        {/* Left: status indicator */}\n        <div className=\"flex items-center gap-1.5 text-[11px] min-w-0\">\n          {isRunning && (\n            <span className=\"flex items-center gap-1.5\">\n              <span className=\"flex gap-[3px]\">\n                <span className=\"w-[4px] h-[4px] rounded-full animate-bounce-dot\" style={{ background: colors.statusRunning, animationDelay: '0ms' }} />\n                <span className=\"w-[4px] h-[4px] rounded-full animate-bounce-dot\" style={{ background: colors.statusRunning, animationDelay: '150ms' }} />\n                <span className=\"w-[4px] h-[4px] rounded-full animate-bounce-dot\" style={{ background: colors.statusRunning, animationDelay: '300ms' }} />\n              </span>\n              <span style={{ color: colors.textSecondary }}>{tab.currentActivity || 'Working...'}</span>\n            </span>\n          )}\n\n          {isDead && (\n            <span style={{ color: colors.statusError, fontSize: 11 }}>Session ended unexpectedly</span>\n          )}\n\n          {isFailed && (\n            <span className=\"flex items-center gap-1.5\">\n              <span style={{ color: colors.statusError, fontSize: 11 }}>Failed</span>\n              <button\n                onClick={handleRetry}\n                className=\"flex items-center gap-1 rounded-full px-2 py-0.5 transition-colors\"\n                style={{ color: colors.accent, fontSize: 11 }}\n              >\n                <ArrowCounterClockwise size={10} />\n                Retry\n              </button>\n            </span>\n          )}\n        </div>\n\n        {/* Right: interrupt button when running */}\n        <div className=\"flex items-center flex-shrink-0\">\n          <AnimatePresence>\n            {showInterrupt && (\n              <InterruptButton tabId={tab.id} />\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n    </div>\n  )\n}\n\n// ─── Empty State (directory picker before first message) ───\n\nfunction EmptyState() {\n  const setBaseDirectory = useSessionStore((s) => s.setBaseDirectory)\n  const colors = useColors()\n\n  const handleChooseFolder = async () => {\n    const dir = await window.clui.selectDirectory()\n    if (dir) {\n      setBaseDirectory(dir)\n    }\n  }\n\n  return (\n    <div\n      className=\"flex flex-col items-center justify-center px-4 py-3 gap-1.5\"\n      style={{ minHeight: 80 }}\n    >\n      <button\n        onClick={handleChooseFolder}\n        className=\"flex items-center gap-1.5 text-[12px] px-3 py-1.5 rounded-lg transition-colors\"\n        style={{\n          color: colors.accent,\n          background: colors.surfaceHover,\n          border: 'none',\n          cursor: 'pointer',\n        }}\n      >\n        <FolderOpen size={13} />\n        Choose folder\n      </button>\n      <span className=\"text-[11px]\" style={{ color: colors.textTertiary }}>\n        Press <strong style={{ color: colors.textSecondary }}>⌥ + Space</strong> to show/hide this overlay\n      </span>\n    </div>\n  )\n}\n\n// ─── Copy Button ───\n\nfunction CopyButton({ text }: { text: string }) {\n  const [copied, setCopied] = useState(false)\n  const colors = useColors()\n\n  const handleCopy = async () => {\n    try {\n      await navigator.clipboard.writeText(text)\n      setCopied(true)\n      setTimeout(() => setCopied(false), 1500)\n    } catch {}\n  }\n\n  return (\n    <motion.button\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0 }}\n      transition={{ duration: 0.12 }}\n      onClick={handleCopy}\n      className=\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[11px] cursor-pointer flex-shrink-0\"\n      style={{\n        background: copied ? colors.statusCompleteBg : 'transparent',\n        color: copied ? colors.statusComplete : colors.textTertiary,\n        border: 'none',\n      }}\n      title=\"Copy response\"\n    >\n      {copied ? <Check size={11} /> : <Copy size={11} />}\n      <span>{copied ? 'Copied' : 'Copy'}</span>\n    </motion.button>\n  )\n}\n\n// ─── Interrupt Button ───\n\nfunction InterruptButton({ tabId }: { tabId: string }) {\n  const colors = useColors()\n\n  const handleStop = () => {\n    window.clui.stopTab(tabId)\n  }\n\n  return (\n    <motion.button\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0 }}\n      transition={{ duration: 0.12 }}\n      onClick={handleStop}\n      className=\"inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[11px] cursor-pointer flex-shrink-0 transition-colors\"\n      style={{\n        background: 'transparent',\n        color: colors.statusError,\n        border: 'none',\n      }}\n      onMouseEnter={(e) => { e.currentTarget.style.background = colors.statusErrorBg }}\n      onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}\n      title=\"Stop current task\"\n    >\n      <Square size={9} weight=\"fill\" />\n      <span>Interrupt</span>\n    </motion.button>\n  )\n}\n\n// ─── User Message ───\n\nfunction UserMessage({ message, skipMotion }: { message: Message; skipMotion?: boolean }) {\n  const colors = useColors()\n  const content = (\n    <div\n      className=\"text-[13px] leading-[1.5] px-3 py-1.5 max-w-[85%]\"\n      style={{\n        background: colors.userBubble,\n        color: colors.userBubbleText,\n        border: `1px solid ${colors.userBubbleBorder}`,\n        borderRadius: '14px 14px 4px 14px',\n      }}\n    >\n      {message.content}\n    </div>\n  )\n\n  if (skipMotion) {\n    return <div className=\"flex justify-end py-1.5\">{content}</div>\n  }\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.15 }}\n      className=\"flex justify-end py-1.5\"\n    >\n      {content}\n    </motion.div>\n  )\n}\n\n// ─── Queued Message (waiting at bottom until processed) ───\n\nfunction QueuedMessage({ content }: { content: string }) {\n  const colors = useColors()\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 8 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, scale: 0.95 }}\n      transition={{ duration: 0.15 }}\n      className=\"flex justify-end py-1.5\"\n    >\n      <div\n        className=\"text-[13px] leading-[1.5] px-3 py-1.5 max-w-[85%]\"\n        style={{\n          background: colors.userBubble,\n          color: colors.userBubbleText,\n          border: `1px dashed ${colors.userBubbleBorder}`,\n          borderRadius: '14px 14px 4px 14px',\n          opacity: 0.6,\n        }}\n      >\n        {content}\n      </div>\n    </motion.div>\n  )\n}\n\n// ─── Table scroll wrapper — fade edges when horizontally scrollable ───\n\nfunction TableScrollWrapper({ children }: { children: React.ReactNode }) {\n  const ref = useRef<HTMLDivElement>(null)\n  const [fade, setFade] = useState<string | undefined>(undefined)\n  const prevFade = useRef<string | undefined>(undefined)\n\n  const update = useCallback(() => {\n    const el = ref.current\n    if (!el) return\n    const { scrollLeft, scrollWidth, clientWidth } = el\n    let next: string | undefined\n    if (scrollWidth <= clientWidth + 1) {\n      next = undefined\n    } else {\n      const l = scrollLeft > 1\n      const r = scrollLeft + clientWidth < scrollWidth - 1\n      next = l && r\n        ? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'\n        : l\n          ? 'linear-gradient(to right, transparent, black 24px)'\n          : r\n            ? 'linear-gradient(to right, black calc(100% - 24px), transparent)'\n            : undefined\n    }\n    if (next !== prevFade.current) {\n      prevFade.current = next\n      setFade(next)\n    }\n  }, [])\n\n  useEffect(() => {\n    update()\n    const el = ref.current\n    if (!el) return\n    const ro = new ResizeObserver(update)\n    ro.observe(el)\n    const table = el.querySelector('table')\n    if (table) ro.observe(table)\n    return () => ro.disconnect()\n  }, [update])\n\n  return (\n    <div\n      ref={ref}\n      onScroll={update}\n      style={{\n        overflowX: 'auto',\n        scrollbarWidth: 'thin',\n        maskImage: fade,\n        WebkitMaskImage: fade,\n      }}\n    >\n      <table>{children}</table>\n    </div>\n  )\n}\n\n// ─── Image card — graceful fallback when src returns 404 ───\n\nfunction ImageCard({ src, alt, colors }: { src?: string; alt?: string; colors: ReturnType<typeof useColors> }) {\n  const [failed, setFailed] = useState(false)\n  // Reset failed state when src changes (e.g. during streaming)\n  useEffect(() => { setFailed(false) }, [src])\n  const label = alt || 'Image'\n  const open = () => { if (src) window.clui.openExternal(String(src)) }\n\n  if (failed || !src) {\n    return (\n      <button\n        type=\"button\"\n        className=\"inline-flex items-center gap-1.5 my-1 px-2.5 py-1.5 rounded-md text-[12px] cursor-pointer\"\n        style={{ background: colors.surfacePrimary, color: colors.accent, border: `1px solid ${colors.toolBorder}` }}\n        onClick={open}\n        title={src}\n      >\n        <Globe size={12} />\n        Image unavailable{alt ? ` — ${alt}` : ''}\n      </button>\n    )\n  }\n\n  return (\n    <button\n      type=\"button\"\n      className=\"block my-2 rounded-lg overflow-hidden border text-left cursor-pointer\"\n      style={{ borderColor: colors.toolBorder, background: colors.surfacePrimary }}\n      onClick={open}\n      title={src}\n    >\n      <img\n        src={src}\n        alt={label}\n        className=\"block w-full max-h-[260px] object-cover\"\n        loading=\"lazy\"\n        onError={() => setFailed(true)}\n      />\n      {alt && (\n        <div className=\"px-2 py-1 text-[11px]\" style={{ color: colors.textTertiary }}>\n          {alt}\n        </div>\n      )}\n    </button>\n  )\n}\n\n// ─── Assistant Message (memoized — only re-renders when content changes) ───\n\nconst AssistantMessage = React.memo(function AssistantMessage({\n  message,\n  skipMotion,\n}: {\n  message: Message\n  skipMotion?: boolean\n}) {\n  const colors = useColors()\n\n  const markdownComponents = useMemo(() => ({\n    table: ({ children }: any) => <TableScrollWrapper>{children}</TableScrollWrapper>,\n    a: ({ href, children }: any) => (\n      <button\n        type=\"button\"\n        className=\"underline decoration-dotted underline-offset-2 cursor-pointer\"\n        style={{ color: colors.accent }}\n        onClick={() => {\n          if (href) window.clui.openExternal(String(href))\n        }}\n      >\n        {children}\n      </button>\n    ),\n    img: ({ src, alt }: any) => <ImageCard src={src} alt={alt} colors={colors} />,\n  }), [colors])\n\n  const inner = (\n    <div className=\"group/msg relative\">\n      <div className=\"text-[13px] leading-[1.6] prose-cloud min-w-0 max-w-[92%]\">\n        <Markdown remarkPlugins={REMARK_PLUGINS} components={markdownComponents}>\n          {message.content}\n        </Markdown>\n      </div>\n      {/* Copy button — always in DOM, shown via CSS :hover (no React state needed).\n          Absolute positioning so it never shifts the text layout. */}\n      {message.content.trim() && (\n        <div className=\"absolute bottom-0 right-0 opacity-0 group-hover/msg:opacity-100 transition-opacity duration-100\">\n          <CopyButton text={message.content} />\n        </div>\n      )}\n    </div>\n  )\n\n  if (skipMotion) {\n    return <div className=\"py-1\">{inner}</div>\n  }\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.15 }}\n      className=\"py-1\"\n    >\n      {inner}\n    </motion.div>\n  )\n}, (prev, next) => prev.message.content === next.message.content && prev.skipMotion === next.skipMotion)\n\n// ─── Tool Group (collapsible timeline — Claude Code style) ───\n\n/** Build a short description from tool name + input for the collapsed summary */\nfunction toolSummary(tools: Message[]): string {\n  if (tools.length === 0) return ''\n  // Use first tool's context for summary\n  const first = tools[0]\n  const desc = getToolDescription(first.toolName || 'Tool', first.toolInput)\n  if (tools.length === 1) return desc\n  return `${desc} and ${tools.length - 1} more tool${tools.length > 2 ? 's' : ''}`\n}\n\n/** Short human-readable description from tool name + already-parsed input */\nfunction getToolDescriptionFromParsed(name: string, parsed: Record<string, unknown>): string {\n  const s = (v: unknown) => (typeof v === 'string' ? v : '')\n  switch (name) {\n    case 'Read': return `Read ${s(parsed.file_path) || s(parsed.path) || 'file'}`\n    case 'Edit': return `Edit ${s(parsed.file_path) || 'file'}`\n    case 'Write': return `Write ${s(parsed.file_path) || 'file'}`\n    case 'Glob': return `Search files: ${s(parsed.pattern)}`\n    case 'Grep': return `Search: ${s(parsed.pattern)}`\n    case 'Bash': {\n      const cmd = s(parsed.command)\n      return cmd.length > 60 ? `${cmd.substring(0, 57)}...` : cmd || 'Bash'\n    }\n    case 'WebSearch': return `Search: ${s(parsed.query) || s(parsed.search_query)}`\n    case 'WebFetch': return `Fetch: ${s(parsed.url)}`\n    case 'Agent': return `Agent: ${(s(parsed.prompt) || s(parsed.description)).substring(0, 50)}`\n    default: return name\n  }\n}\n\n/** Short human-readable description from tool name + input */\nfunction getToolDescription(name: string, input?: string): string {\n  if (!input) return name\n\n  try {\n    return getToolDescriptionFromParsed(name, JSON.parse(input))\n  } catch {\n    // Input is not JSON or is partial — show truncated raw\n    const trimmed = input.trim()\n    if (trimmed.length > 60) return `${name}: ${trimmed.substring(0, 57)}...`\n    return trimmed ? `${name}: ${trimmed}` : name\n  }\n}\n\nfunction ToolGroup({ tools, skipMotion }: { tools: Message[]; skipMotion?: boolean }) {\n  const hasRunning = tools.some((t) => t.toolStatus === 'running')\n  const [expanded, setExpanded] = useState(false)\n  const colors = useColors()\n\n  const isOpen = expanded || hasRunning\n\n  if (isOpen) {\n    const inner = (\n      <div className=\"py-1\">\n        {/* Collapse header — click to close */}\n        {!hasRunning && (\n          <div\n            className=\"flex items-center gap-1 cursor-pointer mb-1.5\"\n            onClick={() => setExpanded(false)}\n          >\n            <CaretDown size={10} style={{ color: colors.textMuted }} />\n            <span className=\"text-[11px]\" style={{ color: colors.textMuted }}>\n              Used {tools.length} tool{tools.length !== 1 ? 's' : ''}\n            </span>\n          </div>\n        )}\n\n        {/* Timeline */}\n        <div className=\"relative pl-6\">\n          {/* Vertical line */}\n          <div\n            className=\"absolute left-[10px] top-1 bottom-1 w-px\"\n            style={{ background: colors.timelineLine }}\n          />\n\n          <div className=\"space-y-3\">\n            {tools.map((tool) => {\n              const isRunning = tool.toolStatus === 'running'\n              const toolName = tool.toolName || 'Tool'\n              // Parse tool input once for both description and detail content\n              let parsedInput: Record<string, unknown> | null = null\n              if (tool.toolInput) {\n                try { parsedInput = JSON.parse(tool.toolInput) } catch { /* partial JSON */ }\n              }\n              const desc = parsedInput\n                ? getToolDescriptionFromParsed(toolName, parsedInput)\n                : getToolDescription(toolName, tool.toolInput)\n\n              return (\n                <div key={tool.id} className=\"relative\">\n                  {/* Timeline node */}\n                  <div\n                    className=\"absolute -left-6 top-[1px] w-[20px] h-[20px] rounded-full flex items-center justify-center\"\n                    style={{\n                      background: isRunning ? colors.toolRunningBg : colors.toolBg,\n                      border: `1px solid ${isRunning ? colors.toolRunningBorder : colors.toolBorder}`,\n                    }}\n                  >\n                    {isRunning\n                      ? <SpinnerGap size={10} className=\"animate-spin\" style={{ color: colors.statusRunning }} />\n                      : <ToolIcon name={toolName} size={10} />\n                    }\n                  </div>\n\n                  {/* Tool description */}\n                  <div className=\"min-w-0\">\n                    <span\n                      className=\"text-[12px] leading-[1.4] block truncate\"\n                      style={{ color: isRunning ? colors.textSecondary : colors.textTertiary }}\n                    >\n                      {desc}\n                    </span>\n\n                    {/* Tool detail content for Edit/Write */}\n                    {!isRunning && parsedInput && (() => {\n                      const monoFont = 'ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, monospace'\n                      if (toolName === 'Edit' && ('old_string' in parsedInput || 'new_string' in parsedInput)) {\n                        const oldStr = typeof parsedInput.old_string === 'string' ? parsedInput.old_string : null\n                        const newStr = typeof parsedInput.new_string === 'string' ? parsedInput.new_string : null\n                        if (oldStr === null && newStr === null) return null\n                        return (\n                          <div\n                            className=\"mt-1 text-[11px] leading-[1.5] rounded overflow-hidden\"\n                            style={{ border: `1px solid ${colors.toolBorder}` }}\n                            role=\"group\"\n                            aria-label=\"Edit diff\"\n                          >\n                            {oldStr !== null && (\n                              <pre\n                                className=\"px-2 py-1 whitespace-pre-wrap break-all overflow-y-auto\"\n                                aria-label=\"Removed\"\n                                style={{\n                                  background: colors.diffRemovedBg,\n                                  color: colors.textSecondary,\n                                  maxHeight: 120,\n                                  margin: 0,\n                                  fontFamily: monoFont,\n                                  fontSize: 10,\n                                }}\n                              ><span style={{ color: colors.textMuted, userSelect: 'none' }}>- </span>{oldStr.length > 300 ? oldStr.slice(0, 297) + '...' : oldStr}</pre>\n                            )}\n                            {newStr !== null && (\n                              <pre\n                                className=\"px-2 py-1 whitespace-pre-wrap break-all overflow-y-auto\"\n                                aria-label=\"Added\"\n                                style={{\n                                  background: colors.diffAddedBg,\n                                  color: colors.textSecondary,\n                                  maxHeight: 120,\n                                  margin: 0,\n                                  fontFamily: monoFont,\n                                  fontSize: 10,\n                                }}\n                              ><span style={{ color: colors.textMuted, userSelect: 'none' }}>+ </span>{newStr.length > 300 ? newStr.slice(0, 297) + '...' : newStr}</pre>\n                            )}\n                          </div>\n                        )\n                      }\n                      if (toolName === 'Write' && typeof parsedInput.content === 'string') {\n                        const content = parsedInput.content\n                        const snippet = content.length > 200 ? content.slice(0, 197) + '...' : content\n                        return (\n                          <pre\n                            className=\"mt-1 px-2 py-1 text-[10px] leading-[1.5] rounded whitespace-pre-wrap break-all overflow-y-auto\"\n                            aria-label=\"File content\"\n                            style={{\n                              background: colors.surfaceHover,\n                              color: colors.textSecondary,\n                              maxHeight: 120,\n                              margin: 0,\n                              marginTop: 4,\n                              fontFamily: monoFont,\n                              border: `1px solid ${colors.toolBorder}`,\n                            }}\n                          >{snippet}</pre>\n                        )\n                      }\n                      return null\n                    })()}\n\n                    {/* Result badge */}\n                    {!isRunning && (\n                      <span\n                        className=\"inline-block text-[10px] mt-0.5 px-1.5 py-[1px] rounded\"\n                        style={{\n                          background: tool.toolStatus === 'error' ? colors.statusErrorBg : colors.surfaceHover,\n                          color: tool.toolStatus === 'error' ? colors.statusError : colors.textMuted,\n                        }}\n                      >\n                        Result\n                      </span>\n                    )}\n\n                    {isRunning && (\n                      <span className=\"text-[10px] mt-0.5 block\" style={{ color: colors.textMuted }}>\n                        running...\n                      </span>\n                    )}\n                  </div>\n                </div>\n              )\n            })}\n          </div>\n        </div>\n      </div>\n    )\n\n    if (skipMotion) return inner\n\n    return (\n      <motion.div\n        initial={{ opacity: 0, height: 0 }}\n        animate={{ opacity: 1, height: 'auto' }}\n        exit={{ opacity: 0, height: 0 }}\n        transition={{ duration: 0.15 }}\n      >\n        {inner}\n      </motion.div>\n    )\n  }\n\n  // Collapsed state — summary text + chevron, no container\n  const summary = toolSummary(tools)\n\n  const inner = (\n    <div\n      className=\"flex items-start gap-1 cursor-pointer py-[2px]\"\n      onClick={() => setExpanded(true)}\n    >\n      <CaretRight size={10} className=\"flex-shrink-0 mt-[2px]\" style={{ color: colors.textTertiary }} />\n      <span className=\"text-[11px] leading-[1.4]\" style={{ color: colors.textTertiary }}>\n        {summary}\n      </span>\n    </div>\n  )\n\n  if (skipMotion) return <div className=\"py-0.5\">{inner}</div>\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.12 }}\n      className=\"py-0.5\"\n    >\n      {inner}\n    </motion.div>\n  )\n}\n\n// ─── System Message ───\n\nfunction SystemMessage({ message, skipMotion }: { message: Message; skipMotion?: boolean }) {\n  const isError = message.content.startsWith('Error:') || message.content.includes('unexpectedly')\n  const colors = useColors()\n\n  const inner = (\n    <div\n      className=\"text-[11px] leading-[1.5] px-2.5 py-1 rounded-lg inline-block whitespace-pre-wrap\"\n      style={{\n        background: isError ? colors.statusErrorBg : colors.surfaceHover,\n        color: isError ? colors.statusError : colors.textTertiary,\n      }}\n    >\n      {message.content}\n    </div>\n  )\n\n  if (skipMotion) return <div className=\"py-0.5\">{inner}</div>\n\n  return (\n    <motion.div\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      transition={{ duration: 0.15 }}\n      className=\"py-0.5\"\n    >\n      {inner}\n    </motion.div>\n  )\n}\n\n// ─── Tool Icon mapping ───\n\nfunction ToolIcon({ name, size = 12 }: { name: string; size?: number }) {\n  const colors = useColors()\n  const ICONS: Record<string, React.ReactNode> = {\n    Read: <FileText size={size} />,\n    Edit: <PencilSimple size={size} />,\n    Write: <FileArrowUp size={size} />,\n    Bash: <Terminal size={size} />,\n    Glob: <FolderOpen size={size} />,\n    Grep: <MagnifyingGlass size={size} />,\n    WebSearch: <Globe size={size} />,\n    WebFetch: <Globe size={size} />,\n    Agent: <Robot size={size} />,\n    AskUserQuestion: <Question size={size} />,\n  }\n\n  return (\n    <span className=\"flex items-center\" style={{ color: colors.textTertiary }}>\n      {ICONS[name] || <Wrench size={size} />}\n    </span>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/HistoryPicker.tsx",
    "content": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { motion } from 'framer-motion'\nimport { Clock, ChatCircle } from '@phosphor-icons/react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport { usePopoverLayer } from './PopoverLayer'\nimport { useColors } from '../theme'\nimport type { SessionMeta } from '../../shared/types'\n\nfunction formatTimeAgo(isoDate: string): string {\n  const diff = Date.now() - new Date(isoDate).getTime()\n  const mins = Math.floor(diff / 60000)\n  if (mins < 1) return 'just now'\n  if (mins < 60) return `${mins}m ago`\n  const hours = Math.floor(mins / 60)\n  if (hours < 24) return `${hours}h ago`\n  const days = Math.floor(hours / 24)\n  if (days < 7) return `${days}d ago`\n  return new Date(isoDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })\n}\n\nfunction formatSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes}B`\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`\n  return `${(bytes / (1024 * 1024)).toFixed(1)}M`\n}\n\nexport function HistoryPicker() {\n  const resumeSession = useSessionStore((s) => s.resumeSession)\n  const isExpanded = useSessionStore((s) => s.isExpanded)\n  const activeTab = useSessionStore(\n    (s) => s.tabs.find((t) => t.id === s.activeTabId),\n    (a, b) => a === b || (!!a && !!b && a.hasChosenDirectory === b.hasChosenDirectory && a.workingDirectory === b.workingDirectory),\n  )\n  const staticInfo = useSessionStore((s) => s.staticInfo)\n  const popoverLayer = usePopoverLayer()\n  const colors = useColors()\n  const effectiveProjectPath = activeTab?.hasChosenDirectory\n    ? activeTab.workingDirectory\n    : (staticInfo?.homePath || activeTab?.workingDirectory || '~')\n\n  const [open, setOpen] = useState(false)\n  const [sessions, setSessions] = useState<SessionMeta[]>([])\n  const [loading, setLoading] = useState(false)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const popoverRef = useRef<HTMLDivElement>(null)\n  const [pos, setPos] = useState<{ right: number; top?: number; bottom?: number; maxHeight?: number }>({ right: 0 })\n\n  const updatePos = useCallback(() => {\n    if (!triggerRef.current) return\n    const rect = triggerRef.current.getBoundingClientRect()\n    if (isExpanded) {\n      const top = rect.bottom + 6\n      setPos({\n        top,\n        right: window.innerWidth - rect.right,\n        maxHeight: window.innerHeight - top - 12,\n      })\n    } else {\n      setPos({\n        bottom: window.innerHeight - rect.top + 6,\n        right: window.innerWidth - rect.right,\n      })\n    }\n  }, [isExpanded])\n\n  const loadSessions = useCallback(async () => {\n    setLoading(true)\n    try {\n      const result = await window.clui.listSessions(effectiveProjectPath)\n      setSessions(result)\n    } catch {\n      setSessions([])\n    }\n    setLoading(false)\n  }, [effectiveProjectPath])\n\n  useEffect(() => {\n    if (!open) return\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Node\n      if (triggerRef.current?.contains(target)) return\n      if (popoverRef.current?.contains(target)) return\n      setOpen(false)\n    }\n    document.addEventListener('mousedown', handler)\n    return () => document.removeEventListener('mousedown', handler)\n  }, [open])\n\n  const handleToggle = () => {\n    if (!open) {\n      updatePos()\n      void loadSessions()\n    }\n    setOpen((o) => !o)\n  }\n\n  const handleSelect = (session: SessionMeta) => {\n    setOpen(false)\n    const title = session.firstMessage\n      ? (session.firstMessage.length > 30 ? session.firstMessage.substring(0, 27) + '...' : session.firstMessage)\n      : session.slug || 'Resumed'\n    void resumeSession(session.sessionId, title, effectiveProjectPath)\n  }\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        onClick={handleToggle}\n        className=\"flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full transition-colors\"\n        style={{ color: colors.textTertiary }}\n        title=\"Resume a previous session\"\n      >\n        <Clock size={13} />\n      </button>\n\n      {popoverLayer && open && createPortal(\n        <motion.div\n          ref={popoverRef}\n          data-clui-ui\n          initial={{ opacity: 0, y: isExpanded ? -4 : 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: isExpanded ? -4 : 4 }}\n          transition={{ duration: 0.12 }}\n          className=\"rounded-xl\"\n          style={{\n            position: 'fixed',\n            ...(pos.top != null ? { top: pos.top } : {}),\n            ...(pos.bottom != null ? { bottom: pos.bottom } : {}),\n            right: pos.right,\n            width: 280,\n            pointerEvents: 'auto',\n            background: colors.popoverBg,\n            backdropFilter: 'blur(20px)',\n            WebkitBackdropFilter: 'blur(20px)',\n            boxShadow: colors.popoverShadow,\n            border: `1px solid ${colors.popoverBorder}`,\n            ...(pos.maxHeight != null ? { maxHeight: pos.maxHeight } : {}),\n            overflow: 'hidden',\n            display: 'flex',\n            flexDirection: 'column' as const,\n          }}\n        >\n          <div className=\"px-3 py-2 text-[11px] font-medium flex-shrink-0\" style={{ color: colors.textTertiary, borderBottom: `1px solid ${colors.popoverBorder}` }}>\n            Recent Sessions\n          </div>\n\n          <div className=\"overflow-y-auto py-1\" style={{ maxHeight: pos.maxHeight != null ? undefined : 180 }}>\n            {loading && (\n              <div className=\"px-3 py-4 text-center text-[11px]\" style={{ color: colors.textTertiary }}>\n                Loading...\n              </div>\n            )}\n\n            {!loading && sessions.length === 0 && (\n              <div className=\"px-3 py-4 text-center text-[11px]\" style={{ color: colors.textTertiary }}>\n                No previous sessions found\n              </div>\n            )}\n\n            {!loading && sessions.map((session) => (\n              <button\n                key={session.sessionId}\n                onClick={() => handleSelect(session)}\n                className=\"w-full flex items-start gap-2.5 px-3 py-2 text-left transition-colors\"\n              >\n                <ChatCircle size={13} className=\"flex-shrink-0 mt-0.5\" style={{ color: colors.textTertiary }} />\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"text-[11px] truncate\" style={{ color: colors.textPrimary }}>\n                    {session.firstMessage || session.slug || session.sessionId.substring(0, 8)}\n                  </div>\n                  <div className=\"flex items-center gap-2 text-[10px] mt-0.5\" style={{ color: colors.textTertiary }}>\n                    <span>{formatTimeAgo(session.lastTimestamp)}</span>\n                    <span>{formatSize(session.size)}</span>\n                    {session.slug && <span className=\"truncate\">{session.slug}</span>}\n                  </div>\n                </div>\n              </button>\n            ))}\n          </div>\n        </motion.div>,\n        popoverLayer,\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/InputBar.tsx",
    "content": "import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { Microphone, ArrowUp, SpinnerGap, X, Check } from '@phosphor-icons/react'\nimport { useSessionStore, AVAILABLE_MODELS } from '../stores/sessionStore'\nimport { AttachmentChips } from './AttachmentChips'\nimport { SlashCommandMenu, getFilteredCommandsWithExtras, type SlashCommand } from './SlashCommandMenu'\nimport { useColors } from '../theme'\n\nconst INPUT_MIN_HEIGHT = 20\nconst INPUT_MAX_HEIGHT = 140\nconst MULTILINE_ENTER_HEIGHT = 52\nconst MULTILINE_EXIT_HEIGHT = 50\nconst INLINE_CONTROLS_RESERVED_WIDTH = 104\n\ntype VoiceState = 'idle' | 'recording' | 'transcribing'\n\n/**\n * InputBar renders inside a glass-surface rounded-full pill provided by App.tsx.\n * It provides: textarea + mic/send buttons. Attachment chips render above when present.\n */\nexport function InputBar() {\n  const [input, setInput] = useState('')\n  const [voiceState, setVoiceState] = useState<VoiceState>('idle')\n  const [voiceError, setVoiceError] = useState<string | null>(null)\n  const [slashFilter, setSlashFilter] = useState<string | null>(null)\n  const [slashIndex, setSlashIndex] = useState(0)\n  const [isMultiLine, setIsMultiLine] = useState(false)\n  const textareaRef = useRef<HTMLTextAreaElement>(null)\n  const wrapperRef = useRef<HTMLDivElement>(null)\n  const measureRef = useRef<HTMLTextAreaElement | null>(null)\n  const mediaRecorderRef = useRef<MediaRecorder | null>(null)\n  const chunksRef = useRef<Blob[]>([])\n\n  const sendMessage = useSessionStore((s) => s.sendMessage)\n  const clearTab = useSessionStore((s) => s.clearTab)\n  const addSystemMessage = useSessionStore((s) => s.addSystemMessage)\n  const addAttachments = useSessionStore((s) => s.addAttachments)\n  const removeAttachment = useSessionStore((s) => s.removeAttachment)\n\n  const setPreferredModel = useSessionStore((s) => s.setPreferredModel)\n  const staticInfo = useSessionStore((s) => s.staticInfo)\n  const preferredModel = useSessionStore((s) => s.preferredModel)\n  const activeTabId = useSessionStore((s) => s.activeTabId)\n  const tab = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId))\n  const colors = useColors()\n  const isBusy = tab?.status === 'running' || tab?.status === 'connecting'\n  const isConnecting = tab?.status === 'connecting'\n  const hasContent = input.trim().length > 0 || (tab?.attachments?.length ?? 0) > 0\n  const canSend = !!tab && !isConnecting && hasContent\n  const attachments = tab?.attachments || []\n  const showSlashMenu = slashFilter !== null && !isConnecting\n  const skillCommands: SlashCommand[] = (tab?.sessionSkills || []).map((skill) => ({\n    command: `/${skill}`,\n    description: `Run skill: ${skill}`,\n    icon: <span className=\"text-[11px]\">✦</span>,\n  }))\n\n  useEffect(() => {\n    textareaRef.current?.focus()\n  }, [activeTabId])\n\n  // Focus textarea when window is shown (shortcut toggle, screenshot return)\n  useEffect(() => {\n    const unsub = window.clui.onWindowShown(() => {\n      textareaRef.current?.focus()\n    })\n    return unsub\n  }, [])\n\n  const measureInlineHeight = useCallback((value: string): number => {\n    if (typeof document === 'undefined') return 0\n    if (!measureRef.current) {\n      const m = document.createElement('textarea')\n      m.setAttribute('aria-hidden', 'true')\n      m.tabIndex = -1\n      m.style.position = 'absolute'\n      m.style.top = '-99999px'\n      m.style.left = '0'\n      m.style.height = '0'\n      m.style.minHeight = '0'\n      m.style.overflow = 'hidden'\n      m.style.visibility = 'hidden'\n      m.style.pointerEvents = 'none'\n      m.style.zIndex = '-1'\n      m.style.resize = 'none'\n      m.style.border = '0'\n      m.style.outline = '0'\n      m.style.boxSizing = 'border-box'\n      document.body.appendChild(m)\n      measureRef.current = m\n    }\n\n    const m = measureRef.current\n    const hostWidth = wrapperRef.current?.clientWidth ?? 0\n    const inlineWidth = Math.max(120, hostWidth - INLINE_CONTROLS_RESERVED_WIDTH)\n    m.style.width = `${inlineWidth}px`\n    m.style.fontSize = '14px'\n    m.style.lineHeight = '20px'\n    m.style.paddingTop = '15px'\n    m.style.paddingBottom = '15px'\n    m.style.paddingLeft = '0'\n    m.style.paddingRight = '0'\n\n    const computed = textareaRef.current ? window.getComputedStyle(textareaRef.current) : null\n    if (computed) {\n      m.style.fontFamily = computed.fontFamily\n      m.style.letterSpacing = computed.letterSpacing\n      m.style.fontWeight = computed.fontWeight\n    }\n\n    m.value = value || ' '\n    return m.scrollHeight\n  }, [])\n\n  const autoResize = useCallback(() => {\n    const el = textareaRef.current\n    if (!el) return\n    el.style.height = `${INPUT_MIN_HEIGHT}px`\n    const naturalHeight = el.scrollHeight\n    const clampedHeight = Math.min(naturalHeight, INPUT_MAX_HEIGHT)\n    el.style.height = `${clampedHeight}px`\n    el.style.overflowY = naturalHeight > INPUT_MAX_HEIGHT ? 'auto' : 'hidden'\n    if (naturalHeight <= INPUT_MAX_HEIGHT) {\n      el.scrollTop = 0\n    }\n    // Decide multiline mode against fixed inline-width measurement to avoid\n    // expand/collapse bounce when layout switches between modes.\n    const inlineHeight = measureInlineHeight(input)\n    setIsMultiLine((prev) => {\n      if (!prev) return inlineHeight > MULTILINE_ENTER_HEIGHT\n      return inlineHeight > MULTILINE_EXIT_HEIGHT\n    })\n  }, [input, measureInlineHeight])\n\n  useLayoutEffect(() => { autoResize() }, [input, isMultiLine, autoResize])\n\n  useEffect(() => {\n    return () => {\n      if (mediaRecorderRef.current?.state === 'recording') {\n        mediaRecorderRef.current.stop()\n      }\n      if (measureRef.current) {\n        measureRef.current.remove()\n        measureRef.current = null\n      }\n    }\n  }, [])\n\n  // ─── Slash command detection ───\n  const updateSlashFilter = useCallback((value: string) => {\n    const match = value.match(/^(\\/[a-zA-Z-]*)$/)\n    if (match) {\n      setSlashFilter(match[1])\n      setSlashIndex(0)\n    } else {\n      setSlashFilter(null)\n    }\n  }, [])\n\n  // ─── Handle slash commands ───\n  const executeCommand = useCallback((cmd: SlashCommand) => {\n    switch (cmd.command) {\n      case '/clear':\n        clearTab()\n        addSystemMessage('Conversation cleared.')\n        break\n      case '/cost': {\n        if (tab?.lastResult) {\n          const r = tab.lastResult\n          const parts = [`$${r.totalCostUsd.toFixed(4)}`, `${(r.durationMs / 1000).toFixed(1)}s`, `${r.numTurns} turn${r.numTurns !== 1 ? 's' : ''}`]\n          if (r.usage.input_tokens) {\n            parts.push(`${r.usage.input_tokens.toLocaleString()} in / ${(r.usage.output_tokens || 0).toLocaleString()} out`)\n          }\n          addSystemMessage(parts.join(' · '))\n        } else {\n          addSystemMessage('No cost data yet — send a message first.')\n        }\n        break\n      }\n      case '/model': {\n        const model = tab?.sessionModel || null\n        const version = tab?.sessionVersion || staticInfo?.version || null\n        const current = preferredModel || model || 'default'\n        const lines = AVAILABLE_MODELS.map((m) => {\n          const active = m.id === current || (!preferredModel && m.id === model)\n          return `  ${active ? '\\u25CF' : '\\u25CB'} ${m.label} (${m.id})`\n        })\n        const header = version ? `Claude Code ${version}` : 'Claude Code'\n        addSystemMessage(`${header}\\n\\n${lines.join('\\n')}\\n\\nSwitch model: type /model <name>\\n  e.g. /model sonnet`)\n        break\n      }\n      case '/mcp': {\n        if (tab?.sessionMcpServers && tab.sessionMcpServers.length > 0) {\n          const lines = tab.sessionMcpServers.map((s) => {\n            const icon = s.status === 'connected' ? '\\u2713' : s.status === 'failed' ? '\\u2717' : '\\u25CB'\n            return `  ${icon} ${s.name} — ${s.status}`\n          })\n          addSystemMessage(`MCP Servers (${tab.sessionMcpServers.length}):\\n${lines.join('\\n')}`)\n        } else if (tab?.claudeSessionId) {\n          addSystemMessage('No MCP servers connected in this session.')\n        } else {\n          addSystemMessage('No MCP data yet — send a message to start a session.')\n        }\n        break\n      }\n      case '/skills': {\n        if (tab?.sessionSkills && tab.sessionSkills.length > 0) {\n          const lines = tab.sessionSkills.map((s) => `/${s}`)\n          addSystemMessage(`Available skills (${tab.sessionSkills.length}):\\n${lines.join('\\n')}`)\n        } else if (tab?.claudeSessionId) {\n          addSystemMessage('No skills available in this session.')\n        } else {\n          addSystemMessage('No session metadata yet — send a message first.')\n        }\n        break\n      }\n      case '/help': {\n        const lines = [\n          '/clear — Clear conversation history',\n          '/cost — Show token usage and cost',\n          '/model — Show model info & switch models',\n          '/mcp — Show MCP server status',\n          '/skills — Show available skills',\n          '/help — Show this list',\n        ]\n        addSystemMessage(lines.join('\\n'))\n        break\n      }\n    }\n  }, [tab, clearTab, addSystemMessage, staticInfo, preferredModel])\n\n  const handleSlashSelect = useCallback((cmd: SlashCommand) => {\n    const isSkillCommand = !!tab?.sessionSkills?.includes(cmd.command.replace(/^\\//, ''))\n    if (isSkillCommand) {\n      setInput(`${cmd.command} `)\n      setSlashFilter(null)\n      requestAnimationFrame(() => textareaRef.current?.focus())\n      return\n    }\n    setInput('')\n    setSlashFilter(null)\n    executeCommand(cmd)\n  }, [executeCommand, tab?.sessionSkills])\n\n  // ─── Send ───\n  const handleSend = useCallback(() => {\n    if (showSlashMenu) {\n      const filtered = getFilteredCommandsWithExtras(slashFilter!, skillCommands)\n      if (filtered.length > 0) {\n        handleSlashSelect(filtered[slashIndex])\n        return\n      }\n    }\n    const prompt = input.trim()\n    const modelMatch = prompt.match(/^\\/model\\s+(\\S+)/i)\n    if (modelMatch) {\n      const query = modelMatch[1].toLowerCase()\n      const match = AVAILABLE_MODELS.find((m: { id: string; label: string }) =>\n        m.id.toLowerCase().includes(query) || m.label.toLowerCase().includes(query)\n      )\n      if (match) {\n        setPreferredModel(match.id)\n        setInput('')\n        setSlashFilter(null)\n        addSystemMessage(`Model switched to ${match.label} (${match.id})`)\n      } else {\n        setInput('')\n        setSlashFilter(null)\n        addSystemMessage(`Unknown model \"${modelMatch[1]}\". Available: opus, sonnet, haiku`)\n      }\n      return\n    }\n    if (!prompt && attachments.length === 0) return\n    if (isConnecting) return\n    setInput('')\n    setSlashFilter(null)\n    if (textareaRef.current) {\n      textareaRef.current.style.height = `${INPUT_MIN_HEIGHT}px`\n    }\n    sendMessage(prompt || 'See attached files')\n    // Refocus after React re-renders from the state update\n    requestAnimationFrame(() => textareaRef.current?.focus())\n  }, [input, isBusy, sendMessage, attachments.length, showSlashMenu, slashFilter, slashIndex, handleSlashSelect])\n\n  // ─── Keyboard ───\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (showSlashMenu) {\n      const filtered = getFilteredCommandsWithExtras(slashFilter!, skillCommands)\n      if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % filtered.length); return }\n      if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + filtered.length) % filtered.length); return }\n      if (e.key === 'Tab') { e.preventDefault(); if (filtered.length > 0) handleSlashSelect(filtered[slashIndex]); return }\n      if (e.key === 'Escape') { e.preventDefault(); setSlashFilter(null); return }\n    }\n    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }\n    if (e.key === 'Escape' && !showSlashMenu) { window.clui.hideWindow() }\n  }\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n    const value = e.target.value\n    setInput(value)\n    updateSlashFilter(value)\n  }\n\n  // ─── Paste image ───\n  const handlePaste = useCallback(async (e: React.ClipboardEvent) => {\n    const items = e.clipboardData?.items\n    if (!items) return\n    for (const item of Array.from(items)) {\n      if (item.type.startsWith('image/')) {\n        e.preventDefault()\n        const blob = item.getAsFile()\n        if (!blob) return\n        const reader = new FileReader()\n        reader.onload = async () => {\n          const dataUrl = reader.result as string\n          const attachment = await window.clui.pasteImage(dataUrl)\n          if (attachment) addAttachments([attachment])\n        }\n        reader.readAsDataURL(blob)\n        return\n      }\n    }\n  }, [addAttachments])\n\n  // ─── Voice ───\n  const cancelledRef = useRef(false)\n\n  const stopRecording = useCallback(() => {\n    cancelledRef.current = false\n    if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop()\n  }, [])\n\n  const cancelRecording = useCallback(() => {\n    cancelledRef.current = true\n    if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop()\n  }, [])\n\n  const startRecording = useCallback(async () => {\n    setVoiceError(null)\n    chunksRef.current = []\n    let stream: MediaStream\n    try {\n      stream = await navigator.mediaDevices.getUserMedia({ audio: true })\n    } catch {\n      setVoiceError('Microphone permission denied.')\n      return\n    }\n    const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'\n    const recorder = new MediaRecorder(stream, { mimeType })\n    recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data) }\n    recorder.onstop = async () => {\n      stream.getTracks().forEach((t) => t.stop())\n      if (cancelledRef.current) { cancelledRef.current = false; setVoiceState('idle'); return }\n      if (chunksRef.current.length === 0) { setVoiceState('idle'); return }\n      setVoiceState('transcribing')\n      try {\n        const blob = new Blob(chunksRef.current, { type: mimeType })\n        const wavBase64 = await blobToWavBase64(blob)\n        const result = await window.clui.transcribeAudio(wavBase64)\n        if (result.error) setVoiceError(result.error)\n        else if (result.transcript) setInput((prev) => (prev ? `${prev} ${result.transcript}` : result.transcript!))\n      } catch (err: any) { setVoiceError(`Voice failed: ${err.message}`) }\n      finally { setVoiceState('idle') }\n    }\n    recorder.onerror = () => { stream.getTracks().forEach((t) => t.stop()); setVoiceError('Recording failed.'); setVoiceState('idle') }\n    mediaRecorderRef.current = recorder\n    setVoiceState('recording')\n    recorder.start()\n  }, [])\n\n  const handleVoiceToggle = useCallback(() => {\n    if (voiceState === 'recording') stopRecording()\n    else if (voiceState === 'idle') void startRecording()\n  }, [voiceState, startRecording, stopRecording])\n\n  const hasAttachments = attachments.length > 0\n\n  return (\n    <div ref={wrapperRef} data-clui-ui className=\"flex flex-col w-full relative\">\n      {/* Slash command menu */}\n      <AnimatePresence>\n        {showSlashMenu && (\n          <SlashCommandMenu\n            filter={slashFilter!}\n            selectedIndex={slashIndex}\n            onSelect={handleSlashSelect}\n            anchorRect={wrapperRef.current?.getBoundingClientRect() ?? null}\n            extraCommands={skillCommands}\n          />\n        )}\n      </AnimatePresence>\n\n      {/* Attachment chips — renders inside the pill, above textarea */}\n      {hasAttachments && (\n        <div style={{ paddingTop: 6, marginLeft: -6 }}>\n          <AttachmentChips attachments={attachments} onRemove={removeAttachment} />\n        </div>\n      )}\n\n      {/* Single-line: inline controls. Multi-line: controls in bottom row */}\n      <div className=\"w-full\" style={{ minHeight: 50 }}>\n        {isMultiLine ? (\n          <div className=\"w-full\">\n            <textarea\n              ref={textareaRef}\n              value={input}\n              onChange={handleInputChange}\n              onKeyDown={handleKeyDown}\n              onPaste={handlePaste}\n              placeholder={\n                isConnecting\n                  ? 'Initializing...'\n                  : voiceState === 'recording'\n                    ? 'Recording... ✓ to confirm, ✕ to cancel'\n                    : voiceState === 'transcribing'\n                      ? 'Transcribing...'\n                      : isBusy\n                        ? 'Type to queue a message...'\n                        : 'Ask Claude Code anything...'\n              }\n              rows={1}\n              className=\"w-full bg-transparent resize-none\"\n              style={{\n                fontSize: 14,\n                lineHeight: '20px',\n                color: colors.textPrimary,\n                minHeight: 20,\n                maxHeight: INPUT_MAX_HEIGHT,\n                paddingTop: 11,\n                paddingBottom: 2,\n              }}\n            />\n\n            <div className=\"flex items-center justify-end gap-1\" style={{ marginTop: 0, paddingBottom: 4 }}>\n              <VoiceButtons\n                voiceState={voiceState}\n                isConnecting={isConnecting}\n                colors={colors}\n                onToggle={handleVoiceToggle}\n                onCancel={cancelRecording}\n                onStop={stopRecording}\n              />\n              <AnimatePresence>\n                {canSend && voiceState !== 'recording' && (\n                  <motion.div key=\"send\" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.1 }}>\n                    <button\n                      onMouseDown={(e) => e.preventDefault()}\n                      onClick={handleSend}\n                      className=\"w-9 h-9 rounded-full flex items-center justify-center transition-colors\"\n                      style={{ background: colors.sendBg, color: colors.textOnAccent }}\n                      title={isBusy ? 'Queue message' : 'Send (Enter)'}\n                    >\n                      <ArrowUp size={16} weight=\"bold\" />\n                    </button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </div>\n        ) : (\n          <div className=\"flex items-center w-full\" style={{ minHeight: 50 }}>\n            <textarea\n              ref={textareaRef}\n              value={input}\n              onChange={handleInputChange}\n              onKeyDown={handleKeyDown}\n              onPaste={handlePaste}\n              placeholder={\n                isConnecting\n                  ? 'Initializing...'\n                  : voiceState === 'recording'\n                    ? 'Recording... ✓ to confirm, ✕ to cancel'\n                    : voiceState === 'transcribing'\n                      ? 'Transcribing...'\n                      : isBusy\n                        ? 'Type to queue a message...'\n                        : 'Ask Claude Code anything...'\n              }\n              rows={1}\n              className=\"flex-1 bg-transparent resize-none\"\n              style={{\n                fontSize: 14,\n                lineHeight: '20px',\n                color: colors.textPrimary,\n                minHeight: 20,\n                maxHeight: INPUT_MAX_HEIGHT,\n                paddingTop: 15,\n                paddingBottom: 15,\n              }}\n            />\n\n            <div className=\"flex items-center gap-1 shrink-0 ml-2\">\n              <VoiceButtons\n                voiceState={voiceState}\n                isConnecting={isConnecting}\n                colors={colors}\n                onToggle={handleVoiceToggle}\n                onCancel={cancelRecording}\n                onStop={stopRecording}\n              />\n              <AnimatePresence>\n                {canSend && voiceState !== 'recording' && (\n                  <motion.div key=\"send\" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.1 }}>\n                    <button\n                      onMouseDown={(e) => e.preventDefault()}\n                      onClick={handleSend}\n                      className=\"w-9 h-9 rounded-full flex items-center justify-center transition-colors\"\n                      style={{ background: colors.sendBg, color: colors.textOnAccent }}\n                      title={isBusy ? 'Queue message' : 'Send (Enter)'}\n                    >\n                      <ArrowUp size={16} weight=\"bold\" />\n                    </button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Voice error */}\n      {voiceError && (\n        <div className=\"px-1 pb-2 text-[11px]\" style={{ color: colors.statusError }}>\n          {voiceError}\n        </div>\n      )}\n    </div>\n  )\n}\n\n// ─── Voice Buttons (extracted to avoid duplication) ───\n\nfunction VoiceButtons({ voiceState, isConnecting, colors, onToggle, onCancel, onStop }: {\n  voiceState: VoiceState\n  isConnecting: boolean\n  colors: ReturnType<typeof useColors>\n  onToggle: () => void\n  onCancel: () => void\n  onStop: () => void\n}) {\n  return (\n    <AnimatePresence mode=\"wait\">\n      {voiceState === 'recording' ? (\n        <motion.div\n          key=\"voice-controls\"\n          initial={{ opacity: 0, scale: 0.8 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={{ opacity: 0, scale: 0.8 }}\n          transition={{ duration: 0.12 }}\n          className=\"flex items-center gap-1\"\n        >\n          <button\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={onCancel}\n            className=\"w-9 h-9 rounded-full flex items-center justify-center transition-colors\"\n            style={{ background: colors.surfaceHover, color: colors.textTertiary }}\n            title=\"Cancel recording\"\n          >\n            <X size={15} weight=\"bold\" />\n          </button>\n          <button\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={onStop}\n            className=\"w-9 h-9 rounded-full flex items-center justify-center transition-colors\"\n            style={{ background: colors.accent, color: colors.textOnAccent }}\n            title=\"Confirm recording\"\n          >\n            <Check size={15} weight=\"bold\" />\n          </button>\n        </motion.div>\n      ) : voiceState === 'transcribing' ? (\n        <motion.div key=\"transcribing\" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.1 }}>\n          <button\n            disabled\n            className=\"w-9 h-9 rounded-full flex items-center justify-center\"\n            style={{ background: colors.micBg, color: colors.micColor }}\n          >\n            <SpinnerGap size={16} className=\"animate-spin\" />\n          </button>\n        </motion.div>\n      ) : (\n        <motion.div key=\"mic\" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.8 }} transition={{ duration: 0.1 }}>\n          <button\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={onToggle}\n            disabled={isConnecting}\n            className=\"w-9 h-9 rounded-full flex items-center justify-center transition-colors\"\n            style={{\n              background: colors.micBg,\n              color: isConnecting ? colors.micDisabled : colors.micColor,\n            }}\n            title=\"Voice input\"\n          >\n            <Microphone size={16} />\n          </button>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  )\n}\n\n// ─── Audio conversion: WebM blob → WAV base64 ───\n\nasync function blobToWavBase64(blob: Blob): Promise<string> {\n  const arrayBuffer = await blob.arrayBuffer()\n  const audioCtx = new AudioContext()\n  const decoded = await audioCtx.decodeAudioData(arrayBuffer)\n  audioCtx.close()\n  const mono = mixToMono(decoded)\n  const inputRms = rmsLevel(mono)\n  if (inputRms < 0.003) {\n    throw new Error('No voice detected. Check microphone permission and speak closer to the mic.')\n  }\n  const resampled = resampleLinear(mono, decoded.sampleRate, 16000)\n  const normalized = normalizePcm(resampled)\n  const wavBuffer = encodeWav(normalized, 16000)\n  return bufferToBase64(wavBuffer)\n}\n\nfunction mixToMono(buffer: AudioBuffer): Float32Array {\n  const { numberOfChannels, length } = buffer\n  if (numberOfChannels <= 1) return buffer.getChannelData(0)\n\n  const mono = new Float32Array(length)\n  for (let ch = 0; ch < numberOfChannels; ch++) {\n    const channel = buffer.getChannelData(ch)\n    for (let i = 0; i < length; i++) mono[i] += channel[i]\n  }\n  const inv = 1 / numberOfChannels\n  for (let i = 0; i < length; i++) mono[i] *= inv\n  return mono\n}\n\nfunction resampleLinear(input: Float32Array, inRate: number, outRate: number): Float32Array {\n  if (inRate === outRate) return input\n  const ratio = inRate / outRate\n  const outLength = Math.max(1, Math.floor(input.length / ratio))\n  const output = new Float32Array(outLength)\n  for (let i = 0; i < outLength; i++) {\n    const pos = i * ratio\n    const i0 = Math.floor(pos)\n    const i1 = Math.min(i0 + 1, input.length - 1)\n    const t = pos - i0\n    output[i] = input[i0] * (1 - t) + input[i1] * t\n  }\n  return output\n}\n\nfunction normalizePcm(samples: Float32Array): Float32Array {\n  let peak = 0\n  for (let i = 0; i < samples.length; i++) {\n    const a = Math.abs(samples[i])\n    if (a > peak) peak = a\n  }\n  if (peak < 1e-4 || peak > 0.95) return samples\n\n  const gain = Math.min(0.95 / peak, 8)\n  const out = new Float32Array(samples.length)\n  for (let i = 0; i < samples.length; i++) out[i] = samples[i] * gain\n  return out\n}\n\nfunction rmsLevel(samples: Float32Array): number {\n  if (samples.length === 0) return 0\n  let sumSq = 0\n  for (let i = 0; i < samples.length; i++) sumSq += samples[i] * samples[i]\n  return Math.sqrt(sumSq / samples.length)\n}\n\nfunction encodeWav(samples: Float32Array, sampleRate: number): ArrayBuffer {\n  const numSamples = samples.length\n  const buffer = new ArrayBuffer(44 + numSamples * 2)\n  const view = new DataView(buffer)\n  writeString(view, 0, 'RIFF')\n  view.setUint32(4, 36 + numSamples * 2, true)\n  writeString(view, 8, 'WAVE')\n  writeString(view, 12, 'fmt ')\n  view.setUint32(16, 16, true)\n  view.setUint16(20, 1, true)\n  view.setUint16(22, 1, true)\n  view.setUint32(24, sampleRate, true)\n  view.setUint32(28, sampleRate * 2, true)\n  view.setUint16(32, 2, true)\n  view.setUint16(34, 16, true)\n  writeString(view, 36, 'data')\n  view.setUint32(40, numSamples * 2, true)\n  let offset = 44\n  for (let i = 0; i < numSamples; i++) {\n    const s = Math.max(-1, Math.min(1, samples[i]))\n    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)\n    offset += 2\n  }\n  return buffer\n}\n\nfunction writeString(view: DataView, offset: number, str: string) {\n  for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i))\n}\n\nfunction bufferToBase64(buffer: ArrayBuffer): string {\n  const bytes = new Uint8Array(buffer)\n  let binary = ''\n  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])\n  return btoa(binary)\n}\n"
  },
  {
    "path": "src/renderer/components/MarketplacePanel.tsx",
    "content": "import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { X, MagnifyingGlass, SpinnerGap, ArrowClockwise, HeadCircuit, Compass, GithubLogo } from '@phosphor-icons/react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport { useColors } from '../theme'\nimport type { CatalogPlugin, PluginStatus } from '../../shared/types'\n\nexport function MarketplacePanel() {\n  const colors = useColors()\n  const catalog = useSessionStore((s) => s.marketplaceCatalog)\n  const loading = useSessionStore((s) => s.marketplaceLoading)\n  const error = useSessionStore((s) => s.marketplaceError)\n  const pluginStates = useSessionStore((s) => s.marketplacePluginStates)\n  const search = useSessionStore((s) => s.marketplaceSearch)\n  const filter = useSessionStore((s) => s.marketplaceFilter)\n  const closeMarketplace = useSessionStore((s) => s.closeMarketplace)\n  const setSearch = useSessionStore((s) => s.setMarketplaceSearch)\n  const setFilter = useSessionStore((s) => s.setMarketplaceFilter)\n  const loadMarketplace = useSessionStore((s) => s.loadMarketplace)\n  const buildYourOwn = useSessionStore((s) => s.buildYourOwn)\n\n  const [expandedId, setExpandedId] = useState<string | null>(null)\n  const scrollContainerRef = useRef<HTMLDivElement>(null)\n\n  // Derive filter chips dynamically from catalog semantic tags, sorted by frequency\n  const filters = useMemo(() => {\n    const tagCounts = new Map<string, number>()\n    for (const p of catalog) {\n      for (const t of (p.tags || [])) {\n        tagCounts.set(t, (tagCounts.get(t) || 0) + 1)\n      }\n    }\n    // Sort by frequency (descending), then alphabetically\n    const sorted = [...tagCounts.entries()]\n      .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))\n      .map(([tag]) => tag)\n    return ['All', ...sorted, 'Installed']\n  }, [catalog])\n\n  // Debounced search\n  const [localSearch, setLocalSearch] = useState(search)\n  const debounceRef = useRef<ReturnType<typeof setTimeout>>()\n  const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\n    const val = e.target.value\n    setLocalSearch(val)\n    clearTimeout(debounceRef.current)\n    debounceRef.current = setTimeout(() => setSearch(val), 200)\n  }, [setSearch])\n\n  useEffect(() => () => clearTimeout(debounceRef.current), [])\n\n  // Filtered plugins\n  const lowerSearch = localSearch.toLowerCase()\n  const filtered = useMemo(() => {\n    return catalog.filter((p) => {\n      const pluginName = (p.name || '').toLowerCase()\n      const pluginDescription = (p.description || '').toLowerCase()\n      const pluginTags = Array.isArray(p.tags) ? p.tags : []\n      const matchesSearch = !lowerSearch ||\n        pluginName.includes(lowerSearch) ||\n        pluginDescription.includes(lowerSearch) ||\n        pluginTags.some((t) => String(t).toLowerCase().includes(lowerSearch)) ||\n        (p.author || '').toLowerCase().includes(lowerSearch) ||\n        (p.repo || '').toLowerCase().includes(lowerSearch) ||\n        (p.marketplace || '').toLowerCase().includes(lowerSearch)\n      const matchesFilter =\n        filter === 'All' ||\n        (filter === 'Installed' && pluginStates[p.id] === 'installed') ||\n        pluginTags.includes(filter)\n      return matchesSearch && matchesFilter\n    })\n  }, [catalog, lowerSearch, filter, pluginStates])\n\n  // Reorder cards so expanded card sits on a full-width row with no grid gaps.\n  // If the expanded card was in the right column (odd index), its left neighbor\n  // drops below it to fill the next row — no empty cells.\n  const displayOrder = useMemo(() => {\n    if (expandedId === null) return filtered\n    const idx = filtered.findIndex((p) => p.id === expandedId)\n    if (idx === -1) return filtered\n    const expanded = filtered[idx]\n    const before = filtered.slice(0, idx)\n    const after = filtered.slice(idx + 1)\n    if (idx % 2 === 1 && before.length > 0) {\n      // Odd index (right column): move left neighbor to after the expanded card\n      const leftNeighbor = before.pop()!\n      return [...before, expanded, leftNeighbor, ...after]\n    }\n    return [...before, expanded, ...after]\n  }, [filtered, expandedId])\n\n  return (\n    <div\n      data-clui-ui\n      style={{\n        height: 470,\n        display: 'flex',\n        flexDirection: 'column',\n      }}\n    >\n      {/* Header */}\n      <div style={{\n        display: 'flex', alignItems: 'center', justifyContent: 'space-between',\n        padding: '16px 18px 10px',\n        borderBottom: `1px solid ${colors.containerBorder}`,\n      }}>\n        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>\n          <HeadCircuit size={20} weight=\"regular\" style={{ color: colors.accent }} />\n          <div>\n            <div style={{ fontSize: 13, fontWeight: 700, color: colors.textPrimary }}>\n              Skills Marketplace\n            </div>\n            <div style={{ fontSize: 11, color: colors.textTertiary, marginTop: 2 }}>\n              Install skills and plugins without leaving CLUI\n            </div>\n          </div>\n        </div>\n        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>\n          <span style={{ fontSize: 11, color: colors.textTertiary }}>\n            {filtered.length} result{filtered.length === 1 ? '' : 's'}\n          </span>\n          <button\n            onClick={() => loadMarketplace(true)}\n            style={{\n              background: 'none',\n              border: 'none',\n              cursor: 'pointer',\n              color: colors.textTertiary,\n              padding: 2,\n              display: 'flex',\n              borderRadius: 4,\n            }}\n            title=\"Refresh marketplace\"\n            onMouseEnter={(e) => (e.currentTarget.style.color = colors.textPrimary)}\n            onMouseLeave={(e) => (e.currentTarget.style.color = colors.textTertiary)}\n          >\n            <ArrowClockwise size={14} />\n          </button>\n          <button\n            onClick={closeMarketplace}\n            style={{\n              background: 'none', border: 'none', cursor: 'pointer',\n              color: colors.textTertiary, padding: 2, display: 'flex',\n              borderRadius: 4,\n            }}\n            onMouseEnter={(e) => (e.currentTarget.style.color = colors.textPrimary)}\n            onMouseLeave={(e) => (e.currentTarget.style.color = colors.textTertiary)}\n          >\n            <X size={14} />\n          </button>\n        </div>\n      </div>\n\n      {/* Search + Build your own */}\n      <div style={{ padding: '12px 18px 10px', display: 'flex', gap: 8, alignItems: 'center' }}>\n        <div style={{\n          display: 'flex',\n          alignItems: 'center',\n          gap: 6,\n          background: colors.inputPillBg,\n          borderRadius: 12,\n          padding: '9px 12px',\n          border: `1px solid ${colors.containerBorder}`,\n          minWidth: 0,\n          flex: 1,\n        }}>\n          <MagnifyingGlass size={13} style={{ color: colors.textTertiary, flexShrink: 0 }} />\n          <input\n            type=\"text\"\n            placeholder=\"Search skills, tags, authors...\"\n            value={localSearch}\n            onChange={handleSearchChange}\n            style={{\n              flex: 1, background: 'none', border: 'none', outline: 'none',\n              color: colors.textPrimary, fontSize: 12, fontFamily: 'inherit',\n            }}\n          />\n        </div>\n        <button\n          onClick={buildYourOwn}\n          style={{\n            flexShrink: 0,\n            height: 36,\n            padding: '0 12px',\n            borderRadius: 9999,\n            border: `1px dashed ${colors.accentBorderMedium}`,\n            background: colors.accentLight,\n            cursor: 'pointer',\n            display: 'inline-flex',\n            alignItems: 'center',\n            gap: 6,\n            transition: 'all 0.15s',\n            color: colors.accent,\n            fontSize: 11,\n            fontWeight: 600,\n            fontFamily: 'inherit',\n            whiteSpace: 'nowrap',\n          }}\n          onMouseEnter={(e) => { e.currentTarget.style.borderColor = colors.accent }}\n          onMouseLeave={(e) => { e.currentTarget.style.borderColor = colors.accentBorderMedium }}\n        >\n          <Compass size={12} weight=\"regular\" />\n          Build your own\n        </button>\n      </div>\n\n      {/* Filter chips */}\n      <div style={{\n        display: 'flex',\n        gap: 8,\n        padding: '0 18px 12px',\n        overflowX: 'auto',\n        scrollbarWidth: 'none',\n      }}>\n        {filters.map((f) => (\n          <button\n            key={f}\n            onClick={() => setFilter(f)}\n            style={{\n              fontSize: 11,\n              fontWeight: 600,\n              padding: '6px 11px',\n              borderRadius: 999,\n              border: `1px solid ${filter === f ? colors.accent : colors.containerBorder}`,\n              background: filter === f ? colors.accentLight : 'transparent',\n              color: filter === f ? colors.accent : colors.textSecondary,\n              cursor: 'pointer',\n              fontFamily: 'inherit',\n              transition: 'all 0.15s',\n              whiteSpace: 'nowrap',\n            }}\n          >\n            {f}\n          </button>\n        ))}\n      </div>\n\n      {/* Body */}\n      <div ref={scrollContainerRef} style={{ flex: 1, overflowY: 'auto', padding: '0 18px', scrollbarWidth: 'thin' }}>\n        {loading ? (\n          <LoadingState colors={colors} />\n        ) : error ? (\n          <ErrorState error={error} colors={colors} onRetry={() => loadMarketplace(true)} />\n        ) : filtered.length === 0 ? (\n          <EmptyState colors={colors} />\n        ) : (\n          <div\n            style={{\n              display: 'flex',\n              flexWrap: 'wrap',\n              gap: 10,\n              paddingBottom: 6,\n            }}\n          >\n            {displayOrder.map((plugin) => (\n              <PluginCard\n                key={plugin.id}\n                plugin={plugin}\n                status={pluginStates[plugin.id] || 'not_installed'}\n                colors={colors}\n                expanded={expandedId === plugin.id}\n                scrollContainerRef={scrollContainerRef}\n                onToggleExpand={() => {\n                  setExpandedId(expandedId === plugin.id ? null : plugin.id)\n                }}\n              />\n            ))}\n          </div>\n        )}\n      </div>\n\n    </div>\n  )\n}\n\n// ─── PluginCard ───\n\nfunction PluginCard({ plugin, status, colors, expanded, onToggleExpand, scrollContainerRef }: {\n  plugin: CatalogPlugin\n  status: PluginStatus\n  colors: ReturnType<typeof useColors>\n  expanded: boolean\n  onToggleExpand: () => void\n  scrollContainerRef: React.RefObject<HTMLDivElement | null>\n}) {\n  const [showConfirm, setShowConfirm] = useState(false)\n  const installPlugin = useSessionStore((s) => s.installMarketplacePlugin)\n  const uninstallPlugin = useSessionStore((s) => s.uninstallMarketplacePlugin)\n  const cardRef = useRef<HTMLDivElement>(null)\n  const needsScrollRef = useRef(false)\n\n  useEffect(() => {\n    if (expanded) needsScrollRef.current = true\n  }, [expanded])\n\n  const handleLayoutComplete = useCallback(() => {\n    if (!needsScrollRef.current || !expanded || !cardRef.current || !scrollContainerRef.current) return\n    needsScrollRef.current = false\n    const container = scrollContainerRef.current\n    const card = cardRef.current\n    const containerRect = container.getBoundingClientRect()\n    const cardRect = card.getBoundingClientRect()\n    // Scroll so the card is vertically centered in the scroll container\n    const cardTopRelative = cardRect.top - containerRect.top + container.scrollTop\n    const targetScroll = cardTopRelative - (containerRect.height - cardRect.height) / 2\n    container.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' })\n  }, [expanded, scrollContainerRef])\n\n  const handleInstallClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    if (status === 'failed') {\n      installPlugin(plugin)\n    } else {\n      setShowConfirm(true)\n      if (!expanded) onToggleExpand()\n    }\n  }\n\n  const handleConfirm = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    setShowConfirm(false)\n    installPlugin(plugin)\n  }\n\n  const handleCancel = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    setShowConfirm(false)\n  }\n\n  const handleGithubClick = (e: React.MouseEvent) => {\n    e.stopPropagation()\n    const url = `https://github.com/${plugin.repo || 'unknown/repo'}/tree/main/${plugin.sourcePath || ''}`\n    window.clui.openExternal(url)\n  }\n\n  // Collapse → clear confirm\n  useEffect(() => {\n    if (!expanded) setShowConfirm(false)\n  }, [expanded])\n\n  const safeName = plugin.name || 'Unnamed plugin'\n  const safeDescription = plugin.description || 'No description provided.'\n  const safeCategory = plugin.category || 'Other'\n  const safeMarketplace = plugin.marketplace || 'Marketplace'\n  const safeAuthor = plugin.author || 'Unknown'\n  const safeRepo = plugin.repo || 'unknown/repo'\n  const safeVersion = plugin.version || 'n/a'\n\n  const githubButton = (\n    <button\n      onClick={handleGithubClick}\n      style={{\n        background: 'none',\n        border: 'none',\n        cursor: 'pointer',\n        color: colors.textTertiary,\n        padding: 2,\n        display: 'flex',\n        borderRadius: 4,\n      }}\n      title=\"View source on GitHub\"\n      onMouseEnter={(e) => (e.currentTarget.style.color = colors.textPrimary)}\n      onMouseLeave={(e) => (e.currentTarget.style.color = colors.textTertiary)}\n    >\n      <GithubLogo size={14} />\n    </button>\n  )\n\n  return (\n    <motion.div\n      ref={cardRef}\n      layout\n      transition={{ duration: 0.22, ease: [0.25, 0.1, 0.25, 1] }}\n      onLayoutAnimationComplete={handleLayoutComplete}\n      onClick={onToggleExpand}\n      style={{\n        padding: '12px',\n        borderRadius: 14,\n        border: `1px solid ${expanded ? colors.surfaceSecondary : colors.containerBorder}`,\n        background: expanded ? colors.surfaceActive : colors.surfaceHover,\n        minHeight: expanded ? undefined : 154,\n        width: expanded ? '100%' : 'calc(50% - 5px)',\n        cursor: 'pointer',\n      }}\n      onMouseEnter={(e) => {\n        if (!expanded) {\n          e.currentTarget.style.background = colors.surfaceActive\n          e.currentTarget.style.borderColor = colors.surfaceSecondary\n        }\n      }}\n      onMouseLeave={(e) => {\n        if (!expanded) {\n          e.currentTarget.style.background = colors.surfaceHover\n          e.currentTarget.style.borderColor = colors.containerBorder\n        }\n      }}\n    >\n      {expanded ? (\n        /* ── Expanded: full-width single column ── */\n        <div>\n          {/* Header row: tags + actions */}\n          <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>\n            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>\n              <Tag label={safeCategory} colors={colors} emphasis=\"accent\" />\n              {(plugin.tags || []).map((tag) => (\n                <Tag key={tag} label={tag} colors={colors} />\n              ))}\n            </div>\n            <div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6 }}>\n              {githubButton}\n              <StatusButton status={status} colors={colors} onClick={handleInstallClick} onUninstall={(e) => { e.stopPropagation(); uninstallPlugin(plugin) }} />\n            </div>\n          </div>\n\n          <div style={{ fontSize: 13, fontWeight: 600, color: colors.textPrimary }}>\n            {safeName}\n          </div>\n          <div style={{\n            fontSize: 11,\n            color: colors.textSecondary,\n            marginTop: 5,\n            lineHeight: 1.5,\n          }}>\n            {safeDescription}\n          </div>\n          <div style={{ fontSize: 10, color: colors.textTertiary, marginTop: 8 }}>\n            {safeRepo} · by {safeAuthor} · v{safeVersion}\n          </div>\n\n          {/* Confirm panel or installing status */}\n          {showConfirm && status === 'not_installed' && (\n            <div style={{\n              padding: '10px 12px', borderRadius: 10, marginTop: 10,\n              background: colors.surfacePrimary, border: `1px solid ${colors.containerBorder}`,\n            }}>\n              <div style={{ fontSize: 10, color: colors.textTertiary, marginBottom: 4 }}>\n                {plugin.isSkillMd ? 'Will install to:' : 'Will run:'}\n              </div>\n              <div style={{\n                fontSize: 10, fontFamily: 'monospace', color: colors.textSecondary,\n                background: colors.codeBg, padding: '4px 6px', borderRadius: 4,\n                lineHeight: 1.6,\n              }}>\n                {plugin.isSkillMd\n                  ? <>~/.claude/skills/{plugin.installName}/SKILL.md</>\n                  : <>claude plugin install {plugin.installName}@{safeMarketplace}</>\n                }\n              </div>\n              <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>\n                <button\n                  onClick={handleConfirm}\n                  style={{\n                    fontSize: 10, fontWeight: 600, padding: '4px 10px', borderRadius: 6,\n                    background: colors.accent, color: colors.textOnAccent, border: 'none',\n                    cursor: 'pointer', fontFamily: 'inherit',\n                  }}\n                >\n                  Confirm Install\n                </button>\n                <button\n                  onClick={handleCancel}\n                  style={{\n                    fontSize: 10, fontWeight: 500, padding: '4px 10px', borderRadius: 6,\n                    background: 'transparent', color: colors.textSecondary,\n                    border: `1px solid ${colors.containerBorder}`,\n                    cursor: 'pointer', fontFamily: 'inherit',\n                  }}\n                >\n                  Cancel\n                </button>\n              </div>\n            </div>\n          )}\n\n          {status === 'installing' && (\n            <div style={{\n              padding: '10px 12px', borderRadius: 10, marginTop: 10,\n              background: colors.surfacePrimary, border: `1px solid ${colors.containerBorder}`,\n              display: 'flex', alignItems: 'center', gap: 8,\n            }}>\n              <motion.div\n                animate={{ rotate: 360 }}\n                transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}\n                style={{ display: 'flex' }}\n              >\n                <SpinnerGap size={14} style={{ color: colors.accent }} />\n              </motion.div>\n              <span style={{ fontSize: 11, color: colors.textSecondary }}>Installing plugin...</span>\n            </div>\n          )}\n        </div>\n      ) : (\n        /* ── Collapsed: original layout ── */\n        <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>\n          <div style={{ flex: 1, minWidth: 0 }}>\n            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>\n              <Tag label={safeCategory} colors={colors} emphasis=\"accent\" />\n              {(plugin.tags || []).slice(0, 2).map((tag) => (\n                <Tag key={tag} label={tag} colors={colors} />\n              ))}\n            </div>\n            <div style={{ fontSize: 13, fontWeight: 600, color: colors.textPrimary }}>\n              {safeName}\n            </div>\n            <div style={{\n              fontSize: 11,\n              color: colors.textSecondary,\n              marginTop: 5,\n              lineHeight: 1.45,\n              display: '-webkit-box',\n              WebkitLineClamp: 3,\n              WebkitBoxOrient: 'vertical',\n              overflow: 'hidden',\n            }}>\n              {safeDescription}\n            </div>\n            <div style={{ fontSize: 10, color: colors.textTertiary, marginTop: 8 }}>\n              {safeRepo} · by {safeAuthor} · v{safeVersion}\n            </div>\n          </div>\n          <div style={{ flexShrink: 0, display: 'flex', alignItems: 'center', gap: 6 }}>\n            {githubButton}\n            <StatusButton status={status} colors={colors} onClick={handleInstallClick} onUninstall={(e) => { e.stopPropagation(); uninstallPlugin(plugin) }} />\n          </div>\n        </div>\n      )}\n    </motion.div>\n  )\n}\n\n// ─── StatusButton ───\n\nfunction StatusButton({ status, colors, onClick, onUninstall }: {\n  status: PluginStatus\n  colors: ReturnType<typeof useColors>\n  onClick: (e: React.MouseEvent) => void\n  onUninstall?: (e: React.MouseEvent) => void\n}) {\n  const [hovered, setHovered] = useState(false)\n  switch (status) {\n    case 'installed':\n      return (\n        <button\n          onClick={onUninstall}\n          onMouseEnter={() => setHovered(true)}\n          onMouseLeave={() => setHovered(false)}\n          style={{\n            fontSize: 10, fontWeight: 500, padding: '2px 8px', borderRadius: 8,\n            background: hovered ? colors.statusErrorBg : colors.statusCompleteBg,\n            color: hovered ? colors.statusError : colors.statusComplete,\n            whiteSpace: 'nowrap',\n            border: 'none', cursor: 'pointer', fontFamily: 'inherit',\n            transition: 'all 0.15s',\n          }}\n        >\n          {hovered ? 'Uninstall' : 'Installed'}\n        </button>\n      )\n    case 'installing':\n      return (\n        <span style={{\n          fontSize: 10, fontWeight: 500, padding: '2px 8px', borderRadius: 8,\n          background: colors.accentLight, color: colors.accent,\n          display: 'flex', alignItems: 'center', gap: 4,\n          whiteSpace: 'nowrap',\n        }}>\n          <motion.div\n            animate={{ rotate: 360 }}\n            transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}\n            style={{ display: 'flex' }}\n          >\n            <SpinnerGap size={10} />\n          </motion.div>\n          Installing...\n        </span>\n      )\n    case 'failed':\n      return (\n        <button\n          onClick={onClick}\n          style={{\n            fontSize: 10, fontWeight: 500, padding: '2px 8px', borderRadius: 8,\n            background: colors.statusErrorBg, color: colors.statusError,\n            border: 'none', cursor: 'pointer', fontFamily: 'inherit',\n            whiteSpace: 'nowrap',\n          }}\n        >\n          Failed — Retry\n        </button>\n      )\n    default:\n      return (\n        <button\n          onClick={onClick}\n          style={{\n            fontSize: 10, fontWeight: 600, padding: '2px 8px', borderRadius: 8,\n            background: colors.accentLight, color: colors.accent,\n            border: `1px solid ${colors.accentBorder}`,\n            cursor: 'pointer', fontFamily: 'inherit',\n            transition: 'all 0.15s',\n            whiteSpace: 'nowrap',\n          }}\n          onMouseEnter={(e) => (e.currentTarget.style.background = colors.accentSoft)}\n          onMouseLeave={(e) => (e.currentTarget.style.background = colors.accentLight)}\n        >\n          Install\n        </button>\n      )\n  }\n}\n\nfunction Tag({ label, colors, emphasis }: {\n  label: string\n  colors: ReturnType<typeof useColors>\n  emphasis?: 'accent'\n}) {\n  const isAccent = emphasis === 'accent'\n  return (\n    <span\n      style={{\n        fontSize: 10,\n        fontWeight: 600,\n        lineHeight: 1,\n        padding: '5px 8px',\n        borderRadius: 999,\n        whiteSpace: 'nowrap',\n        border: `1px solid ${isAccent ? colors.accentBorderMedium : colors.containerBorder}`,\n        background: isAccent ? colors.accentLight : colors.surfacePrimary,\n        color: isAccent ? colors.accent : colors.textSecondary,\n      }}\n    >\n      {label}\n    </span>\n  )\n}\n\n// ─── States ───\n\nfunction LoadingState({ colors }: { colors: ReturnType<typeof useColors> }) {\n  return (\n    <div style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '4px 0' }}>\n      {[0, 1, 2].map((i) => (\n        <div key={i} style={{ padding: '8px 10px' }}>\n          <motion.div\n            animate={{ opacity: [0.3, 0.6, 0.3] }}\n            transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.15 }}\n            style={{\n              height: 12, width: '60%', borderRadius: 4,\n              background: colors.surfacePrimary, marginBottom: 4,\n            }}\n          />\n          <motion.div\n            animate={{ opacity: [0.3, 0.6, 0.3] }}\n            transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.15 + 0.1 }}\n            style={{\n              height: 10, width: '90%', borderRadius: 4,\n              background: colors.surfacePrimary,\n            }}\n          />\n        </div>\n      ))}\n    </div>\n  )\n}\n\nfunction ErrorState({ error, colors, onRetry }: {\n  error: string\n  colors: ReturnType<typeof useColors>\n  onRetry: () => void\n}) {\n  return (\n    <div style={{ padding: '20px 10px', textAlign: 'center' }}>\n      <div style={{ fontSize: 11, color: colors.statusError, marginBottom: 8 }}>\n        {error.length > 100 ? error.substring(0, 100) + '...' : error}\n      </div>\n      <button\n        onClick={onRetry}\n        style={{\n          fontSize: 10, fontWeight: 600, padding: '4px 12px', borderRadius: 6,\n          background: colors.accentLight, color: colors.accent,\n          border: `1px solid ${colors.accentBorder}`,\n          cursor: 'pointer', fontFamily: 'inherit',\n          display: 'inline-flex', alignItems: 'center', gap: 4,\n        }}\n      >\n        <ArrowClockwise size={11} /> Retry\n      </button>\n    </div>\n  )\n}\n\nfunction EmptyState({ colors }: { colors: ReturnType<typeof useColors> }) {\n  return (\n    <div style={{\n      padding: '24px 10px', textAlign: 'center',\n      fontSize: 11, color: colors.textTertiary,\n    }}>\n      No plugins match your search\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/PermissionCard.tsx",
    "content": "import React from 'react'\nimport { motion } from 'framer-motion'\nimport { ShieldWarning, Terminal, PencilSimple, Globe, Wrench } from '@phosphor-icons/react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport { useColors } from '../theme'\nimport type { PermissionRequest } from '../../shared/types'\n\ninterface Props {\n  tabId: string\n  permission: PermissionRequest\n  queueLength?: number\n}\n\nconst TOOL_ICONS: Record<string, React.ReactNode> = {\n  Bash: <Terminal size={14} />,\n  Edit: <PencilSimple size={14} />,\n  Write: <PencilSimple size={14} />,\n  WebSearch: <Globe size={14} />,\n  WebFetch: <Globe size={14} />,\n}\n\nfunction getToolIcon(name: string) {\n  return TOOL_ICONS[name] || <Wrench size={14} />\n}\n\nconst SENSITIVE_FIELD_RE = /token|password|secret|key|auth|credential|api.?key/i\n\nfunction formatInput(input?: Record<string, unknown>): string | null {\n  if (!input) return null\n  const entries = Object.entries(input)\n  if (entries.length === 0) return null\n\n  const parts: string[] = []\n  for (const [key, value] of entries) {\n    // Defense-in-depth: mask sensitive fields (backend already masks too)\n    if (SENSITIVE_FIELD_RE.test(key)) {\n      parts.push(`${key}: ***`)\n      continue\n    }\n    const val = typeof value === 'string' ? value : JSON.stringify(value)\n    const truncated = val.length > 120 ? val.substring(0, 117) + '...' : val\n    parts.push(`${key}: ${truncated}`)\n  }\n  return parts.join('\\n')\n}\n\nexport function PermissionCard({ tabId, permission, queueLength = 1 }: Props) {\n  const respondPermission = useSessionStore((s) => s.respondPermission)\n  const colors = useColors()\n  const [responded, setResponded] = React.useState(false)\n\n  // Reset responded flag when the displayed permission changes (queue advancing)\n  React.useEffect(() => {\n    setResponded(false)\n  }, [permission.questionId])\n\n  const handleOption = (optionId: string) => {\n    if (responded) return // Prevent double-send\n    setResponded(true)\n    respondPermission(tabId, permission.questionId, optionId)\n  }\n\n  const inputPreview = formatInput(permission.toolInput)\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 8, scale: 0.97 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={{ opacity: 0, y: -4, scale: 0.97 }}\n      transition={{ duration: 0.2 }}\n      className=\"mx-4 mt-2 mb-2\"\n    >\n      <div\n        style={{\n          background: colors.containerBg,\n          border: `1px solid ${colors.permissionBorder}`,\n          borderRadius: 12,\n          boxShadow: colors.permissionShadow,\n        }}\n        className=\"overflow-hidden\"\n      >\n        {/* Header */}\n        <div\n          className=\"flex items-center gap-1.5 px-3 py-1.5\"\n          style={{\n            background: colors.permissionHeaderBg,\n            borderBottom: `1px solid ${colors.permissionHeaderBorder}`,\n          }}\n        >\n          <ShieldWarning size={12} style={{ color: colors.statusPermission }} />\n          <span className=\"text-[11px] font-semibold\" style={{ color: colors.statusPermission }}>\n            Permission Required\n          </span>\n        </div>\n\n        <div className=\"px-3 py-2.5\">\n          <div className=\"flex items-center gap-1.5 mb-1\">\n            <span style={{ color: colors.textTertiary }}>{getToolIcon(permission.toolTitle)}</span>\n            <span className=\"text-[12px] font-medium\" style={{ color: colors.textPrimary }}>\n              {permission.toolTitle}\n            </span>\n          </div>\n\n          {permission.toolDescription && (\n            <p className=\"text-[11px] leading-[1.4] mb-1.5\" style={{ color: colors.textSecondary }}>\n              {permission.toolDescription}\n            </p>\n          )}\n\n          {inputPreview && (\n            <pre\n              className=\"text-[10px] leading-[1.4] px-2 py-1.5 rounded-md overflow-x-auto whitespace-pre-wrap break-all mb-2\"\n              style={{\n                background: colors.codeBg,\n                color: colors.textSecondary,\n                maxHeight: 80,\n              }}\n            >\n              {inputPreview}\n            </pre>\n          )}\n\n          <div className=\"flex items-center gap-2 flex-wrap\">\n            {permission.options.map((opt) => {\n              const isAllow = opt.kind === 'allow' || opt.label.toLowerCase().includes('allow')\n                || opt.label.toLowerCase().includes('yes')\n              const isDeny = opt.kind === 'deny' || opt.label.toLowerCase().includes('deny')\n                || opt.label.toLowerCase().includes('no') || opt.label.toLowerCase().includes('reject')\n\n              let bg: string\n              let hoverBg: string\n              let textColor: string\n              let borderColor: string\n\n              if (isAllow) {\n                bg = colors.permissionAllowBg\n                hoverBg = colors.permissionAllowHoverBg\n                textColor = colors.statusComplete\n                borderColor = colors.permissionAllowBorder\n              } else if (isDeny) {\n                bg = colors.permissionDenyBg\n                hoverBg = colors.permissionDenyHoverBg\n                textColor = colors.statusError\n                borderColor = colors.permissionDenyBorder\n              } else {\n                bg = colors.accentLight\n                hoverBg = colors.accentSoft\n                textColor = colors.accent\n                borderColor = colors.accentSoft\n              }\n\n              return (\n                <button\n                  key={opt.optionId}\n                  onClick={() => handleOption(opt.optionId)}\n                  disabled={responded}\n                  className=\"text-[11px] font-medium px-3 py-1.5 rounded-full transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed\"\n                  style={{\n                    background: bg,\n                    color: textColor,\n                    border: `1px solid ${borderColor}`,\n                  }}\n                  onMouseEnter={(e) => {\n                    if (!responded) e.currentTarget.style.background = hoverBg\n                  }}\n                  onMouseLeave={(e) => {\n                    if (!responded) e.currentTarget.style.background = bg\n                  }}\n                >\n                  {opt.label}\n                </button>\n              )\n            })}\n\n            {queueLength > 1 && (\n              <span\n                className=\"text-[10px] px-2 py-0.5 rounded-full\"\n                style={{\n                  background: colors.accentLight,\n                  color: colors.accent,\n                }}\n              >\n                +{queueLength - 1} more\n              </span>\n            )}\n          </div>\n        </div>\n      </div>\n    </motion.div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/PermissionDeniedCard.tsx",
    "content": "import React from 'react'\nimport { motion } from 'framer-motion'\nimport { ShieldWarning, Terminal, ArrowSquareOut } from '@phosphor-icons/react'\nimport { useColors } from '../theme'\n\ninterface Props {\n  tools: Array<{ toolName: string; toolUseId: string }>\n  sessionId: string | null\n  projectPath: string\n  onDismiss: () => void\n}\n\nexport function PermissionDeniedCard({ tools, sessionId, projectPath, onDismiss }: Props) {\n  const colors = useColors()\n\n  const handleOpenInCli = () => {\n    if (sessionId) {\n      window.clui.openInTerminal(sessionId, projectPath)\n    }\n    onDismiss()\n  }\n\n  const toolNames = [...new Set(tools.map((t) => t.toolName))]\n\n  return (\n    <motion.div\n      initial={{ opacity: 0, y: 8, scale: 0.97 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={{ opacity: 0, y: -4, scale: 0.97 }}\n      transition={{ duration: 0.2 }}\n      className=\"mx-4 mb-2\"\n    >\n      <div\n        style={{\n          background: colors.containerBg,\n          border: `1px solid ${colors.permissionDeniedBorder}`,\n          borderRadius: 14,\n          boxShadow: `0 2px 12px ${colors.statusErrorBg}`,\n        }}\n        className=\"overflow-hidden\"\n      >\n        {/* Header */}\n        <div\n          className=\"flex items-center gap-2 px-3 py-2\"\n          style={{\n            background: colors.statusErrorBg,\n            borderBottom: `1px solid ${colors.permissionDeniedHeaderBorder}`,\n          }}\n        >\n          <ShieldWarning size={14} style={{ color: colors.statusError }} />\n          <span className=\"text-[12px] font-semibold\" style={{ color: colors.statusError }}>\n            Tools Denied by Permission Settings\n          </span>\n        </div>\n\n        {/* Body */}\n        <div className=\"px-3 py-2\">\n          <p className=\"text-[11px] leading-[1.5] mb-2\" style={{ color: colors.textSecondary }}>\n            Interactive approvals are not supported in the current CLI mode.\n            {toolNames.length > 0 && (\n              <> Denied: <span style={{ color: colors.textPrimary }}>{toolNames.join(', ')}</span>.</>\n            )}\n          </p>\n\n          {/* Tool list */}\n          {tools.length > 0 && (\n            <div className=\"flex flex-wrap gap-1 mb-2\">\n              {toolNames.map((name) => (\n                <span\n                  key={name}\n                  className=\"inline-flex items-center gap-1 text-[10px] font-mono px-2 py-0.5 rounded-md\"\n                  style={{\n                    background: colors.surfacePrimary,\n                    color: colors.textTertiary,\n                    border: `1px solid ${colors.surfaceSecondary}`,\n                  }}\n                >\n                  <Terminal size={10} />\n                  {name}\n                </span>\n              ))}\n            </div>\n          )}\n\n          {/* Actions */}\n          <div className=\"flex gap-1.5\">\n            {sessionId && (\n              <button\n                onClick={handleOpenInCli}\n                className=\"text-[11px] font-medium px-3 py-1.5 rounded-full transition-colors cursor-pointer flex items-center gap-1.5\"\n                style={{\n                  background: colors.accentLight,\n                  color: colors.accent,\n                  border: `1px solid ${colors.accentBorderMedium}`,\n                }}\n                onMouseEnter={(e) => {\n                  e.currentTarget.style.background = colors.accentSoft\n                }}\n                onMouseLeave={(e) => {\n                  e.currentTarget.style.background = colors.accentLight\n                }}\n              >\n                <ArrowSquareOut size={12} />\n                Open in CLI\n              </button>\n            )}\n            <button\n              onClick={onDismiss}\n              className=\"text-[11px] font-medium px-3 py-1.5 rounded-full transition-colors cursor-pointer\"\n              style={{\n                background: colors.surfaceHover,\n                color: colors.textTertiary,\n                border: `1px solid ${colors.surfaceSecondary}`,\n              }}\n              onMouseEnter={(e) => {\n                e.currentTarget.style.background = colors.surfaceActive\n              }}\n              onMouseLeave={(e) => {\n                e.currentTarget.style.background = colors.surfaceHover\n              }}\n            >\n              Dismiss\n            </button>\n          </div>\n        </div>\n      </div>\n    </motion.div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/PopoverLayer.tsx",
    "content": "import React, { createContext, useContext, useState, useCallback } from 'react'\n\n/**\n * Popover layer — sits outside the glass pill (no overflow:hidden clipping)\n * but inside the app root (no Electron click-through issues with body portals).\n *\n * The layer itself is pointer-events:none so transparent areas stay click-through.\n * Individual popovers must set pointer-events:auto on themselves.\n */\n\nconst PopoverLayerContext = createContext<HTMLDivElement | null>(null)\n\nexport function usePopoverLayer(): HTMLDivElement | null {\n  return useContext(PopoverLayerContext)\n}\n\nexport function PopoverLayerProvider({ children }: { children: React.ReactNode }) {\n  const [layerEl, setLayerEl] = useState<HTMLDivElement | null>(null)\n\n  const refCallback = useCallback((el: HTMLDivElement | null) => {\n    setLayerEl(el)\n  }, [])\n\n  return (\n    <PopoverLayerContext.Provider value={layerEl}>\n      {children}\n      <div\n        ref={refCallback}\n        style={{\n          position: 'fixed',\n          inset: 0,\n          pointerEvents: 'none',\n          zIndex: 9999,\n        }}\n      />\n    </PopoverLayerContext.Provider>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/SettingsPopover.tsx",
    "content": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { motion } from 'framer-motion'\nimport { DotsThree, Bell, ArrowsOutSimple, Moon } from '@phosphor-icons/react'\nimport { useThemeStore } from '../theme'\nimport { useSessionStore } from '../stores/sessionStore'\nimport { usePopoverLayer } from './PopoverLayer'\nimport { useColors } from '../theme'\n\nfunction RowToggle({\n  checked,\n  onChange,\n  colors,\n  label,\n}: {\n  checked: boolean\n  onChange: (next: boolean) => void\n  colors: ReturnType<typeof useColors>\n  label: string\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-pressed={checked}\n      onClick={() => onChange(!checked)}\n      className=\"relative w-9 h-5 rounded-full transition-colors\"\n      style={{\n        background: checked ? colors.accent : colors.surfaceSecondary,\n        border: `1px solid ${checked ? colors.accent : colors.containerBorder}`,\n      }}\n    >\n      <span\n        className=\"absolute top-1/2 -translate-y-1/2 w-4 h-4 rounded-full transition-all\"\n        style={{\n          left: checked ? 18 : 2,\n          background: '#fff',\n        }}\n      />\n    </button>\n  )\n}\n\n/* ─── Settings popover ─── */\n\nexport function SettingsPopover() {\n  const soundEnabled = useThemeStore((s) => s.soundEnabled)\n  const setSoundEnabled = useThemeStore((s) => s.setSoundEnabled)\n  const themeMode = useThemeStore((s) => s.themeMode)\n  const setThemeMode = useThemeStore((s) => s.setThemeMode)\n  const expandedUI = useThemeStore((s) => s.expandedUI)\n  const setExpandedUI = useThemeStore((s) => s.setExpandedUI)\n  const isExpanded = useSessionStore((s) => s.isExpanded)\n  const popoverLayer = usePopoverLayer()\n  const colors = useColors()\n\n  const [open, setOpen] = useState(false)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const popoverRef = useRef<HTMLDivElement>(null)\n  const [pos, setPos] = useState<{ right: number; top?: number; bottom?: number; maxHeight?: number }>({ right: 0 })\n\n  const updatePos = useCallback(() => {\n    if (!triggerRef.current) return\n    const rect = triggerRef.current.getBoundingClientRect()\n    const gap = 6 // Match HistoryPicker spacing exactly.\n    const margin = 8\n    const right = window.innerWidth - rect.right\n\n    if (isExpanded) {\n      // Keep anchored below trigger (so it never covers the dots button),\n      // and shrink if needed instead of shifting upward onto the trigger.\n      const top = rect.bottom + gap\n      setPos({\n        top,\n        right,\n        maxHeight: Math.max(120, window.innerHeight - top - margin),\n      })\n      return\n    }\n\n    // Same logic as HistoryPicker for collapsed mode: open upward from trigger.\n    setPos({\n      bottom: window.innerHeight - rect.top + gap,\n      right,\n      maxHeight: undefined,\n    })\n  }, [isExpanded])\n\n  useEffect(() => {\n    if (!open) return\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Node\n      if (triggerRef.current?.contains(target)) return\n      if (popoverRef.current?.contains(target)) return\n      setOpen(false)\n    }\n    document.addEventListener('mousedown', handler)\n    return () => document.removeEventListener('mousedown', handler)\n  }, [open])\n\n  useEffect(() => {\n    if (!open) return\n    const onResize = () => updatePos()\n    window.addEventListener('resize', onResize)\n    return () => window.removeEventListener('resize', onResize)\n  }, [open, updatePos])\n\n  // Keep panel tracking the trigger continuously while open so it follows\n  // width/position animations of the top bar without feeling \"stuck in space.\"\n  useEffect(() => {\n    if (!open) return\n    let raf = 0\n    const tick = () => {\n      updatePos()\n      raf = requestAnimationFrame(tick)\n    }\n    raf = requestAnimationFrame(tick)\n    return () => {\n      if (raf) cancelAnimationFrame(raf)\n    }\n  }, [open, expandedUI, isExpanded, updatePos])\n\n  const handleToggle = () => {\n    if (!open) updatePos()\n    setOpen((o) => !o)\n  }\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        onClick={handleToggle}\n        className=\"flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full transition-colors\"\n        style={{ color: colors.textTertiary }}\n        title=\"Settings\"\n      >\n        <DotsThree size={16} weight=\"bold\" />\n      </button>\n\n      {popoverLayer && open && createPortal(\n        <motion.div\n          ref={popoverRef}\n          data-clui-ui\n          initial={{ opacity: 0, y: isExpanded ? -4 : 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: isExpanded ? -4 : 4 }}\n          transition={{ duration: 0.12 }}\n          className=\"rounded-xl\"\n          style={{\n            position: 'fixed',\n            ...(pos.top != null ? { top: pos.top } : {}),\n            ...(pos.bottom != null ? { bottom: pos.bottom } : {}),\n            right: pos.right,\n            width: 240,\n            pointerEvents: 'auto',\n            background: colors.popoverBg,\n            backdropFilter: 'blur(20px)',\n            WebkitBackdropFilter: 'blur(20px)',\n            boxShadow: colors.popoverShadow,\n            border: `1px solid ${colors.popoverBorder}`,\n            ...(pos.maxHeight != null ? { maxHeight: pos.maxHeight, overflowY: 'auto' as const } : {}),\n          }}\n        >\n          <div className=\"p-3 flex flex-col gap-2.5\">\n            {/* Full width */}\n            <div>\n              <div className=\"flex items-center justify-between gap-3\">\n                <div className=\"flex items-center gap-2 min-w-0\">\n                  <ArrowsOutSimple size={14} style={{ color: colors.textTertiary }} />\n                  <div className=\"text-[12px] font-medium\" style={{ color: colors.textPrimary }}>\n                    Full width\n                  </div>\n                </div>\n                <RowToggle\n                  checked={expandedUI}\n                  onChange={(next) => {\n                    setExpandedUI(next)\n                  }}\n                  colors={colors}\n                  label=\"Toggle full width panel\"\n                />\n              </div>\n            </div>\n\n            <div style={{ height: 1, background: colors.popoverBorder }} />\n\n            {/* Notification sound */}\n            <div>\n              <div className=\"flex items-center justify-between gap-3\">\n                <div className=\"flex items-center gap-2 min-w-0\">\n                  <Bell size={14} style={{ color: colors.textTertiary }} />\n                  <div className=\"text-[12px] font-medium\" style={{ color: colors.textPrimary }}>\n                    Notification sound\n                  </div>\n                </div>\n                <RowToggle\n                  checked={soundEnabled}\n                  onChange={setSoundEnabled}\n                  colors={colors}\n                  label=\"Toggle notification sound\"\n                />\n              </div>\n            </div>\n\n            <div style={{ height: 1, background: colors.popoverBorder }} />\n\n            {/* Theme */}\n            <div>\n              <div className=\"flex items-center justify-between gap-3\">\n                <div className=\"flex items-center gap-2 min-w-0\">\n                  <Moon size={14} style={{ color: colors.textTertiary }} />\n                  <div className=\"text-[12px] font-medium\" style={{ color: colors.textPrimary }}>\n                    Dark theme\n                  </div>\n                </div>\n                <RowToggle\n                  checked={themeMode === 'dark'}\n                  onChange={(next) => setThemeMode(next ? 'dark' : 'light')}\n                  colors={colors}\n                  label=\"Toggle dark theme\"\n                />\n              </div>\n            </div>\n          </div>\n        </motion.div>,\n        popoverLayer,\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/SlashCommandMenu.tsx",
    "content": "import React, { useEffect, useRef } from 'react'\nimport { createPortal } from 'react-dom'\nimport { motion } from 'framer-motion'\nimport {\n  Trash, Cpu, CurrencyDollar, Question, HardDrives, Sparkle,\n} from '@phosphor-icons/react'\nimport { usePopoverLayer } from './PopoverLayer'\nimport { useColors } from '../theme'\n\nexport interface SlashCommand {\n  command: string\n  description: string\n  icon: React.ReactNode\n}\n\nexport const SLASH_COMMANDS: SlashCommand[] = [\n  { command: '/clear', description: 'Clear conversation history', icon: <Trash size={13} /> },\n  { command: '/cost', description: 'Show token usage and cost', icon: <CurrencyDollar size={13} /> },\n  { command: '/model', description: 'Show current model info', icon: <Cpu size={13} /> },\n  { command: '/mcp', description: 'Show MCP server status', icon: <HardDrives size={13} /> },\n  { command: '/skills', description: 'Show available skills', icon: <Sparkle size={13} /> },\n  { command: '/help', description: 'Show available commands', icon: <Question size={13} /> },\n]\n\ninterface Props {\n  filter: string\n  selectedIndex: number\n  onSelect: (cmd: SlashCommand) => void\n  anchorRect: DOMRect | null\n  extraCommands?: SlashCommand[]\n}\n\nexport function getFilteredCommands(filter: string): SlashCommand[] {\n  return getFilteredCommandsWithExtras(filter, [])\n}\n\nexport function getFilteredCommandsWithExtras(filter: string, extraCommands: SlashCommand[]): SlashCommand[] {\n  const q = filter.toLowerCase()\n  const merged: SlashCommand[] = [...SLASH_COMMANDS]\n  for (const cmd of extraCommands) {\n    if (!merged.some((c) => c.command === cmd.command)) {\n      merged.push(cmd)\n    }\n  }\n  return merged.filter((c) => c.command.startsWith(q))\n}\n\nexport function SlashCommandMenu({ filter, selectedIndex, onSelect, anchorRect, extraCommands = [] }: Props) {\n  const listRef = useRef<HTMLDivElement>(null)\n  const popoverLayer = usePopoverLayer()\n  const filtered = getFilteredCommandsWithExtras(filter, extraCommands)\n  const colors = useColors()\n\n  useEffect(() => {\n    if (!listRef.current) return\n    const item = listRef.current.children[selectedIndex] as HTMLElement | undefined\n    item?.scrollIntoView({ block: 'nearest' })\n  }, [selectedIndex])\n\n  if (filtered.length === 0 || !anchorRect || !popoverLayer) return null\n\n  return createPortal(\n    <motion.div\n      data-clui-ui\n      initial={{ opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, y: 4 }}\n      transition={{ duration: 0.12 }}\n      style={{\n        position: 'fixed',\n        bottom: window.innerHeight - anchorRect.top + 4,\n        left: anchorRect.left + 12,\n        right: window.innerWidth - anchorRect.right + 12,\n        pointerEvents: 'auto',\n      }}\n    >\n      <div\n        ref={listRef}\n        className=\"overflow-y-auto rounded-xl py-1\"\n        style={{\n          maxHeight: 220,\n          background: colors.popoverBg,\n          backdropFilter: 'blur(20px)',\n          border: `1px solid ${colors.popoverBorder}`,\n          boxShadow: colors.popoverShadow,\n        }}\n      >\n        {filtered.map((cmd, i) => {\n          const isSelected = i === selectedIndex\n          return (\n            <button\n              key={cmd.command}\n              onClick={() => onSelect(cmd)}\n              className=\"w-full flex items-center gap-2.5 px-3 py-1.5 text-left transition-colors\"\n              style={{\n                background: isSelected ? colors.accentLight : 'transparent',\n              }}\n              onMouseEnter={(e) => {\n                (e.currentTarget as HTMLElement).style.background = colors.accentLight\n              }}\n              onMouseLeave={(e) => {\n                if (!isSelected) {\n                  (e.currentTarget as HTMLElement).style.background = 'transparent'\n                }\n              }}\n            >\n              <span\n                className=\"flex items-center justify-center w-6 h-6 rounded-md flex-shrink-0\"\n                style={{\n                  background: isSelected ? colors.accentSoft : colors.surfaceHover,\n                  color: isSelected ? colors.accent : colors.textTertiary,\n                }}\n              >\n                {cmd.icon}\n              </span>\n              <div className=\"min-w-0 flex-1\">\n                <span\n                  className=\"text-[12px] font-mono font-medium\"\n                  style={{ color: isSelected ? colors.accent : colors.textPrimary }}\n                >\n                  {cmd.command}\n                </span>\n                <span\n                  className=\"text-[11px] ml-2\"\n                  style={{ color: colors.textTertiary }}\n                >\n                  {cmd.description}\n                </span>\n              </div>\n            </button>\n          )\n        })}\n      </div>\n    </motion.div>,\n    popoverLayer,\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/StatusBar.tsx",
    "content": "import React, { useState, useRef, useEffect, useCallback } from 'react'\nimport { createPortal } from 'react-dom'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { Terminal, CaretDown, Check, FolderOpen, Plus, X, ShieldCheck } from '@phosphor-icons/react'\nimport { useSessionStore, AVAILABLE_MODELS, getModelDisplayLabel } from '../stores/sessionStore'\nimport { usePopoverLayer } from './PopoverLayer'\nimport { useColors } from '../theme'\n\n/* ─── Model Picker (inline — tightly coupled to StatusBar) ─── */\n\nfunction ModelPicker() {\n  const preferredModel = useSessionStore((s) => s.preferredModel)\n  const setPreferredModel = useSessionStore((s) => s.setPreferredModel)\n  const tab = useSessionStore(\n    (s) => s.tabs.find((t) => t.id === s.activeTabId),\n    (a, b) => a === b || (!!a && !!b && a.status === b.status && a.sessionModel === b.sessionModel),\n  )\n  const popoverLayer = usePopoverLayer()\n  const colors = useColors()\n\n  const [open, setOpen] = useState(false)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const popoverRef = useRef<HTMLDivElement>(null)\n  const [pos, setPos] = useState({ bottom: 0, left: 0 })\n\n  const isBusy = tab?.status === 'running' || tab?.status === 'connecting'\n\n  const updatePos = useCallback(() => {\n    if (!triggerRef.current) return\n    const rect = triggerRef.current.getBoundingClientRect()\n    setPos({\n      bottom: window.innerHeight - rect.top + 6,\n      left: rect.left,\n    })\n  }, [])\n\n  useEffect(() => {\n    if (!open) return\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Node\n      if (triggerRef.current?.contains(target)) return\n      if (popoverRef.current?.contains(target)) return\n      setOpen(false)\n    }\n    document.addEventListener('mousedown', handler)\n    return () => document.removeEventListener('mousedown', handler)\n  }, [open])\n\n  const handleToggle = () => {\n    if (isBusy) return\n    if (!open) updatePos()\n    setOpen((o) => !o)\n  }\n\n  const activeLabel = (() => {\n    if (preferredModel) {\n      const m = AVAILABLE_MODELS.find((m) => m.id === preferredModel)\n      return m?.label || getModelDisplayLabel(preferredModel)\n    }\n    if (tab?.sessionModel) {\n      return getModelDisplayLabel(tab.sessionModel)\n    }\n    return AVAILABLE_MODELS[0].label\n  })()\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        onClick={handleToggle}\n        className=\"flex items-center gap-0.5 text-[10px] rounded-full px-1.5 py-0.5 transition-colors\"\n        style={{\n          color: colors.textTertiary,\n          cursor: isBusy ? 'not-allowed' : 'pointer',\n        }}\n        title={isBusy ? 'Stop the task to change model' : 'Switch model'}\n      >\n        {activeLabel}\n        <CaretDown size={10} style={{ opacity: 0.6 }} />\n      </button>\n\n      {popoverLayer && open && createPortal(\n        <motion.div\n          ref={popoverRef}\n          data-clui-ui\n          initial={{ opacity: 0, y: 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 4 }}\n          transition={{ duration: 0.12 }}\n          className=\"rounded-xl\"\n          style={{\n            position: 'fixed',\n            bottom: pos.bottom,\n            left: pos.left,\n            width: 192,\n            pointerEvents: 'auto',\n            background: colors.popoverBg,\n            backdropFilter: 'blur(20px)',\n            WebkitBackdropFilter: 'blur(20px)',\n            boxShadow: colors.popoverShadow,\n            border: `1px solid ${colors.popoverBorder}`,\n          }}\n        >\n          <div className=\"py-1\">\n            {AVAILABLE_MODELS.map((m) => {\n              const isSelected = preferredModel === m.id || (!preferredModel && m.id === AVAILABLE_MODELS[0].id)\n              return (\n                <button\n                  key={m.id}\n                  onClick={() => { setPreferredModel(m.id); setOpen(false) }}\n                  className=\"w-full flex items-center justify-between px-3 py-1.5 text-[11px] transition-colors\"\n                  style={{\n                    color: isSelected ? colors.textPrimary : colors.textSecondary,\n                    fontWeight: isSelected ? 600 : 400,\n                  }}\n                >\n                  {m.label}\n                  {isSelected && <Check size={12} style={{ color: colors.accent }} />}\n                </button>\n              )\n            })}\n          </div>\n        </motion.div>,\n        popoverLayer,\n      )}\n    </>\n  )\n}\n\n/* ─── Permission Mode Picker (global — affects all tabs) ─── */\n\nfunction PermissionModePicker() {\n  const permissionMode = useSessionStore((s) => s.permissionMode)\n  const setPermissionMode = useSessionStore((s) => s.setPermissionMode)\n  const popoverLayer = usePopoverLayer()\n  const colors = useColors()\n\n  const [open, setOpen] = useState(false)\n  const triggerRef = useRef<HTMLButtonElement>(null)\n  const popoverRef = useRef<HTMLDivElement>(null)\n  const [pos, setPos] = useState({ bottom: 0, left: 0 })\n\n  const updatePos = useCallback(() => {\n    if (!triggerRef.current) return\n    const rect = triggerRef.current.getBoundingClientRect()\n    setPos({\n      bottom: window.innerHeight - rect.top + 6,\n      left: rect.left,\n    })\n  }, [])\n\n  useEffect(() => {\n    if (!open) return\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Node\n      if (triggerRef.current?.contains(target)) return\n      if (popoverRef.current?.contains(target)) return\n      setOpen(false)\n    }\n    document.addEventListener('mousedown', handler)\n    return () => document.removeEventListener('mousedown', handler)\n  }, [open])\n\n  const handleToggle = () => {\n    if (!open) updatePos()\n    setOpen((o) => !o)\n  }\n\n  const isAuto = permissionMode === 'auto'\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        onClick={handleToggle}\n        className=\"flex items-center gap-0.5 text-[10px] rounded-full px-1.5 py-0.5 transition-colors\"\n        style={{\n          color: colors.textTertiary,\n          cursor: 'pointer',\n        }}\n        title=\"Permission mode (global)\"\n      >\n        <ShieldCheck size={11} weight={isAuto ? 'fill' : 'regular'} />\n        {isAuto ? 'Auto' : 'Ask'}\n        <CaretDown size={10} style={{ opacity: 0.6 }} />\n      </button>\n\n      {popoverLayer && open && createPortal(\n        <motion.div\n          ref={popoverRef}\n          data-clui-ui\n          initial={{ opacity: 0, y: 4 }}\n          animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, y: 4 }}\n          transition={{ duration: 0.12 }}\n          className=\"rounded-xl\"\n          style={{\n            position: 'fixed',\n            bottom: pos.bottom,\n            left: pos.left,\n            width: 180,\n            pointerEvents: 'auto',\n            background: colors.popoverBg,\n            backdropFilter: 'blur(20px)',\n            WebkitBackdropFilter: 'blur(20px)',\n            boxShadow: colors.popoverShadow,\n            border: `1px solid ${colors.popoverBorder}`,\n          }}\n        >\n          <div className=\"py-1\">\n            <button\n              onClick={() => { setPermissionMode('ask'); setOpen(false) }}\n              className=\"w-full flex items-center justify-between px-3 py-1.5 text-[11px] transition-colors\"\n              style={{\n                color: !isAuto ? colors.textPrimary : colors.textSecondary,\n                fontWeight: !isAuto ? 600 : 400,\n              }}\n            >\n              <span className=\"flex items-center gap-1.5\">\n                <ShieldCheck size={12} />\n                Ask\n              </span>\n              {!isAuto && <Check size={12} style={{ color: colors.accent }} />}\n            </button>\n\n            <div className=\"mx-2 my-0.5\" style={{ height: 1, background: colors.popoverBorder }} />\n\n            <button\n              onClick={() => { setPermissionMode('auto'); setOpen(false) }}\n              className=\"w-full flex items-center justify-between px-3 py-1.5 text-[11px] transition-colors\"\n              style={{\n                color: isAuto ? colors.textPrimary : colors.textSecondary,\n                fontWeight: isAuto ? 600 : 400,\n              }}\n            >\n              <span className=\"flex items-center gap-1.5\">\n                <ShieldCheck size={12} weight=\"fill\" />\n                Auto\n              </span>\n              {isAuto && <Check size={12} style={{ color: colors.accent }} />}\n            </button>\n          </div>\n        </motion.div>,\n        popoverLayer,\n      )}\n    </>\n  )\n}\n\n/* ─── StatusBar ─── */\n\n/** Get a compact display path: basename for deep paths, ~ for home */\nfunction compactPath(fullPath: string): string {\n  if (fullPath === '~') return '~'\n  const parts = fullPath.replace(/\\/$/, '').split('/')\n  return parts[parts.length - 1] || fullPath\n}\n\nexport function StatusBar() {\n  const tab = useSessionStore(\n    (s) => s.tabs.find((t) => t.id === s.activeTabId),\n    (a, b) => a === b || (!!a && !!b\n      && a.status === b.status\n      && a.additionalDirs === b.additionalDirs\n      && a.hasChosenDirectory === b.hasChosenDirectory\n      && a.workingDirectory === b.workingDirectory\n      && a.claudeSessionId === b.claudeSessionId\n    ),\n  )\n  const addDirectory = useSessionStore((s) => s.addDirectory)\n  const removeDirectory = useSessionStore((s) => s.removeDirectory)\n  const popoverLayer = usePopoverLayer()\n  const colors = useColors()\n\n  const [dirOpen, setDirOpen] = useState(false)\n  const dirRef = useRef<HTMLButtonElement>(null)\n  const dirPopRef = useRef<HTMLDivElement>(null)\n  const [dirPos, setDirPos] = useState({ bottom: 0, left: 0 })\n\n  // Close popover on outside click\n  useEffect(() => {\n    if (!dirOpen) return\n    const handler = (e: MouseEvent) => {\n      const target = e.target as Node\n      if (dirRef.current?.contains(target)) return\n      if (dirPopRef.current?.contains(target)) return\n      setDirOpen(false)\n    }\n    document.addEventListener('mousedown', handler)\n    return () => document.removeEventListener('mousedown', handler)\n  }, [dirOpen])\n\n  if (!tab) return null\n\n  const isRunning = tab.status === 'running' || tab.status === 'connecting'\n  const isEmpty = tab.messages.length === 0\n  const hasExtraDirs = tab.additionalDirs.length > 0\n\n  const handleOpenInTerminal = () => {\n    window.clui.openInTerminal(tab.claudeSessionId, tab.workingDirectory)\n  }\n\n  const handleDirClick = () => {\n    if (isRunning) return\n    if (!dirOpen && dirRef.current) {\n      const rect = dirRef.current.getBoundingClientRect()\n      setDirPos({\n        bottom: window.innerHeight - rect.top + 6,\n        left: rect.left,\n      })\n    }\n    setDirOpen((o) => !o)\n  }\n\n  const handleAddDir = async () => {\n    const dir = await window.clui.selectDirectory()\n    if (dir) {\n      addDirectory(dir)\n    }\n  }\n\n  const dirTooltip = tab.hasChosenDirectory\n    ? [tab.workingDirectory, ...tab.additionalDirs].join('\\n')\n    : 'Using home directory by default — click to choose a folder'\n\n  return (\n    <div\n      className=\"flex items-center justify-between px-4 py-1.5\"\n      style={{ minHeight: 28 }}\n    >\n      {/* Left — directory + model picker */}\n      <div className=\"flex items-center gap-2 text-[11px] min-w-0\" style={{ color: colors.textTertiary }}>\n        {/* Directory button */}\n        <button\n          ref={dirRef}\n          onClick={handleDirClick}\n          className=\"flex items-center gap-1 rounded-full px-1.5 py-0.5 transition-colors flex-shrink-0\"\n          style={{\n            color: colors.textTertiary,\n            cursor: isRunning ? 'not-allowed' : 'pointer',\n            maxWidth: 140,\n          }}\n          title={dirTooltip}\n          disabled={isRunning}\n        >\n          <FolderOpen size={11} className=\"flex-shrink-0\" />\n          <span className=\"truncate\">{tab.hasChosenDirectory ? compactPath(tab.workingDirectory) : '—'}</span>\n          {hasExtraDirs && (\n            <span style={{ color: colors.textTertiary, fontWeight: 600 }}>+{tab.additionalDirs.length}</span>\n          )}\n        </button>\n\n        {/* Directory popover */}\n        {popoverLayer && dirOpen && createPortal(\n          <motion.div\n            ref={dirPopRef}\n            data-clui-ui\n            initial={{ opacity: 0, y: 4 }}\n            animate={{ opacity: 1, y: 0 }}\n            transition={{ duration: 0.12 }}\n            className=\"rounded-xl\"\n            style={{\n              position: 'fixed',\n              bottom: dirPos.bottom,\n              left: dirPos.left,\n              width: 220,\n              pointerEvents: 'auto',\n              background: colors.popoverBg,\n              backdropFilter: 'blur(20px)',\n              WebkitBackdropFilter: 'blur(20px)',\n              boxShadow: colors.popoverShadow,\n              border: `1px solid ${colors.popoverBorder}`,\n            }}\n          >\n            <div className=\"py-1.5 px-1\">\n              {/* Base directory */}\n              <div className=\"px-2 py-1\">\n                <div className=\"text-[9px] uppercase tracking-wider mb-1\" style={{ color: colors.textTertiary }}>\n                  Base directory\n                </div>\n                <div className=\"text-[11px] truncate\" style={{ color: tab.hasChosenDirectory ? colors.textSecondary : colors.textMuted }} title={tab.hasChosenDirectory ? tab.workingDirectory : 'No folder selected — defaults to home directory'}>\n                  {tab.hasChosenDirectory ? tab.workingDirectory : 'None (defaults to ~)'}\n                </div>\n              </div>\n\n              {/* Additional directories */}\n              {hasExtraDirs && (\n                <>\n                  <div className=\"mx-2 my-1\" style={{ height: 1, background: colors.popoverBorder }} />\n                  <div className=\"px-2 py-1\">\n                    <div className=\"text-[9px] uppercase tracking-wider mb-1\" style={{ color: colors.textTertiary }}>\n                      Added directories\n                    </div>\n                    {tab.additionalDirs.map((dir) => (\n                      <div key={dir} className=\"flex items-center justify-between py-0.5 group\">\n                        <span className=\"text-[11px] truncate mr-2\" style={{ color: colors.textSecondary }} title={dir}>\n                          {compactPath(dir)}\n                        </span>\n                        <button\n                          onClick={() => removeDirectory(dir)}\n                          className=\"flex-shrink-0 opacity-50 hover:opacity-100 transition-opacity\"\n                          style={{ color: colors.textTertiary }}\n                          title=\"Remove directory\"\n                        >\n                          <X size={10} />\n                        </button>\n                      </div>\n                    ))}\n                  </div>\n                </>\n              )}\n\n              <div className=\"mx-2 my-1\" style={{ height: 1, background: colors.popoverBorder }} />\n\n              {/* Add directory button */}\n              <button\n                onClick={handleAddDir}\n                className=\"w-full flex items-center gap-1.5 px-2 py-1.5 text-[11px] transition-colors rounded-lg\"\n                style={{ color: colors.accent }}\n              >\n                <Plus size={10} />\n                Add directory...\n              </button>\n            </div>\n          </motion.div>,\n          popoverLayer,\n        )}\n\n        <span style={{ color: colors.textMuted, fontSize: 10 }}>|</span>\n\n        <ModelPicker />\n\n        <span style={{ color: colors.textMuted, fontSize: 10 }}>|</span>\n\n        <PermissionModePicker />\n      </div>\n\n      {/* Right — Open in CLI */}\n      <div className=\"flex items-center gap-1.5 flex-shrink-0\">\n        <button\n          onClick={handleOpenInTerminal}\n          className=\"flex items-center gap-1 text-[11px] rounded-full px-2 py-0.5 transition-colors\"\n          style={{ color: colors.textTertiary }}\n          title=\"Open this session in Terminal\"\n        >\n          Open in CLI\n          <Terminal size={11} />\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/components/TabStrip.tsx",
    "content": "import React from 'react'\nimport { motion, AnimatePresence } from 'framer-motion'\nimport { Plus, X } from '@phosphor-icons/react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport { HistoryPicker } from './HistoryPicker'\nimport { SettingsPopover } from './SettingsPopover'\nimport { useColors } from '../theme'\nimport type { TabStatus } from '../../shared/types'\n\nfunction StatusDot({ status, hasUnread, hasPermission }: { status: TabStatus; hasUnread: boolean; hasPermission: boolean }) {\n  const colors = useColors()\n  let bg: string = colors.statusIdle\n  let pulse = false\n  let glow = false\n\n  if (status === 'dead' || status === 'failed') {\n    bg = colors.statusError\n  } else if (hasPermission) {\n    bg = colors.statusPermission\n    glow = true\n  } else if (status === 'connecting' || status === 'running') {\n    bg = colors.statusRunning\n    pulse = true\n  } else if (hasUnread) {\n    bg = colors.statusComplete\n  }\n\n  return (\n    <span\n      className={`w-[6px] h-[6px] rounded-full flex-shrink-0 ${pulse ? 'animate-pulse-dot' : ''}`}\n      style={{\n        background: bg,\n        ...(glow ? { boxShadow: `0 0 6px 2px ${colors.statusPermissionGlow}` } : {}),\n      }}\n    />\n  )\n}\n\nexport function TabStrip() {\n  const tabs = useSessionStore((s) => s.tabs)\n  const activeTabId = useSessionStore((s) => s.activeTabId)\n  const selectTab = useSessionStore((s) => s.selectTab)\n  const createTab = useSessionStore((s) => s.createTab)\n  const closeTab = useSessionStore((s) => s.closeTab)\n  const colors = useColors()\n\n  return (\n    <div\n      data-clui-ui\n      className=\"flex items-center no-drag\"\n      style={{ padding: '8px 0' }}\n    >\n      {/* Scrollable tabs area — clipped by master card edge */}\n      <div className=\"relative min-w-0 flex-1\">\n        <div\n          className=\"flex items-center gap-1 overflow-x-auto min-w-0\"\n          style={{\n            scrollbarWidth: 'none',\n            paddingLeft: 8,\n            // Extra right breathing room so clipped tabs fade out before the edge.\n            paddingRight: 14,\n            // Right-only content fade so the parent card's own animated background\n            // shows through cleanly in both collapsed and expanded states.\n            maskImage: 'linear-gradient(to right, black 0%, black calc(100% - 40px), transparent 100%)',\n            WebkitMaskImage: 'linear-gradient(to right, black 0%, black calc(100% - 40px), transparent 100%)',\n          }}\n        >\n          <AnimatePresence mode=\"popLayout\">\n            {tabs.map((tab) => {\n              const isActive = tab.id === activeTabId\n              return (\n                <motion.div\n                  key={tab.id}\n                  layout\n                  initial={{ opacity: 0, scale: 0.9 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: 0.9 }}\n                  transition={{ duration: 0.15 }}\n                  onClick={() => selectTab(tab.id)}\n                  className=\"group flex items-center gap-1.5 cursor-pointer select-none flex-shrink-0 max-w-[160px] transition-all duration-150\"\n                  style={{\n                    background: isActive ? colors.tabActive : 'transparent',\n                    border: isActive ? `1px solid ${colors.tabActiveBorder}` : '1px solid transparent',\n                    borderRadius: 9999,\n                    padding: '4px 10px',\n                    fontSize: 12,\n                    color: isActive ? colors.textPrimary : colors.textTertiary,\n                    fontWeight: isActive ? 500 : 400,\n                  }}\n                >\n                  <StatusDot status={tab.status} hasUnread={tab.hasUnread} hasPermission={tab.permissionQueue.length > 0} />\n                  <span className=\"truncate flex-1\">{tab.title}</span>\n                  {tabs.length > 1 && (\n                    <button\n                      onClick={(e) => { e.stopPropagation(); closeTab(tab.id) }}\n                      className=\"flex-shrink-0 rounded-full w-4 h-4 flex items-center justify-center transition-opacity\"\n                      style={{\n                        opacity: isActive ? 0.5 : 0,\n                        color: colors.textSecondary,\n                      }}\n                      onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1' }}\n                      onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = isActive ? '0.5' : '0' }}\n                    >\n                      <X size={10} />\n                    </button>\n                  )}\n                </motion.div>\n              )\n            })}\n          </AnimatePresence>\n        </div>\n      </div>\n\n      {/* Pinned action buttons — always visible on the right */}\n      <div className=\"flex items-center gap-0.5 flex-shrink-0 ml-1 pr-2\">\n        <button\n          onClick={() => createTab()}\n          className=\"flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full transition-colors\"\n          style={{ color: colors.textTertiary }}\n          title=\"New tab\"\n        >\n          <Plus size={14} />\n        </button>\n\n        <HistoryPicker />\n\n        <SettingsPopover />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/renderer/env.d.ts",
    "content": "import type { CluiAPI } from '../preload/index'\n\ndeclare module '*.mp3' {\n  const src: string\n  export default src\n}\n\ndeclare global {\n  interface Window {\n    clui: CluiAPI\n  }\n}\n"
  },
  {
    "path": "src/renderer/hooks/useClaudeEvents.ts",
    "content": "import { useEffect, useRef } from 'react'\nimport { useSessionStore } from '../stores/sessionStore'\nimport type { NormalizedEvent } from '../../shared/types'\n\n/**\n * Subscribes to all ControlPlane events via IPC and routes them\n * to the Zustand store.\n *\n * text_chunk events are batched per animation frame to avoid\n * flooding React with one state update per chunk during streaming.\n */\nexport function useClaudeEvents() {\n  const handleNormalizedEvent = useSessionStore((s) => s.handleNormalizedEvent)\n  const handleStatusChange = useSessionStore((s) => s.handleStatusChange)\n  const handleError = useSessionStore((s) => s.handleError)\n\n  // RAF batching for text_chunk events\n  const chunkBufferRef = useRef<Map<string, string>>(new Map())\n  const rafIdRef = useRef<number>(0)\n\n  useEffect(() => {\n    const flushChunks = () => {\n      rafIdRef.current = 0\n      const buffer = chunkBufferRef.current\n      if (buffer.size === 0) return\n\n      // Flush all accumulated text per tab in one go\n      for (const [tabId, text] of buffer) {\n        handleNormalizedEvent(tabId, { type: 'text_chunk', text } as NormalizedEvent)\n      }\n      buffer.clear()\n    }\n\n    const unsubEvent = window.clui.onEvent((tabId, event) => {\n      if (event.type === 'text_chunk') {\n        // Buffer text chunks and flush on next animation frame\n        const buffer = chunkBufferRef.current\n        const existing = buffer.get(tabId) || ''\n        buffer.set(tabId, existing + (event as any).text)\n\n        if (!rafIdRef.current) {\n          rafIdRef.current = requestAnimationFrame(flushChunks)\n        }\n      } else {\n        // task_update and task_complete contain fallback text logic that checks\n        // whether any assistant text has already been rendered. If a RAF flush is\n        // pending, those checks would see stale state and incorrectly conclude\n        // \"no text yet\" — causing duplicate messages once the RAF fires.\n        // Flush synchronously before handling these events so the store sees the\n        // correct message state.\n        if (\n          (event.type === 'task_update' || event.type === 'task_complete') &&\n          rafIdRef.current\n        ) {\n          cancelAnimationFrame(rafIdRef.current)\n          flushChunks()\n        }\n        handleNormalizedEvent(tabId, event)\n      }\n    })\n\n    const unsubStatus = window.clui.onTabStatusChange((tabId, newStatus, oldStatus) => {\n      handleStatusChange(tabId, newStatus, oldStatus)\n    })\n\n    const unsubError = window.clui.onError((tabId, error) => {\n      handleError(tabId, error)\n    })\n\n    const unsubSkill = window.clui.onSkillStatus((status) => {\n      if (status.state === 'failed') {\n        console.warn(`[CLUI] Skill install failed: ${status.name} — ${status.error}`)\n      }\n    })\n\n    return () => {\n      unsubEvent()\n      unsubStatus()\n      unsubError()\n      unsubSkill()\n      if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)\n      chunkBufferRef.current.clear()\n    }\n  }, [handleNormalizedEvent, handleStatusChange, handleError])\n\n  // Note: window.clui.start() is called via sessionStore.initStaticInfo() in App.tsx.\n  // No duplicate call needed here.\n}\n"
  },
  {
    "path": "src/renderer/hooks/useHealthReconciliation.ts",
    "content": "import { useEffect } from 'react'\nimport { useSessionStore } from '../stores/sessionStore'\n\nconst HEALTH_POLL_INTERVAL_MS = 1500\n\n/**\n * Health reconciliation loop: periodically compares running tabs\n * against backend health and unsticks UI when external CLI/session\n * changes happen.\n *\n * Copied from reference architecture (CopilotPill.tsx lines 1242-1271).\n */\nexport function useHealthReconciliation() {\n  useEffect(() => {\n    const timer = setInterval(async () => {\n      const { tabs } = useSessionStore.getState()\n      const runningTabs = tabs.filter(\n        (t) => (t.status === 'running' || t.status === 'connecting') && t.activeRequestId\n      )\n      if (runningTabs.length === 0) return\n\n      try {\n        const health = await window.clui.tabHealth()\n        if (!health?.tabs || !Array.isArray(health.tabs)) return\n\n        const stateByTab = new Map(\n          health.tabs.map((h) => [h.tabId, h])\n        )\n\n        // Build updated tabs, tracking whether anything actually changed\n        const { tabs: currentTabs } = useSessionStore.getState()\n        let changed = false\n        const newTabs = currentTabs.map((t) => {\n          if (t.status !== 'running' && t.status !== 'connecting') return t\n\n          const healthEntry = stateByTab.get(t.id)\n          if (!healthEntry) return t\n\n          // Backend says dead but UI thinks it's running → unstick\n          if (healthEntry.status === 'dead') {\n            changed = true\n            return { ...t, status: 'dead' as const, currentActivity: 'Session ended', activeRequestId: null }\n          }\n\n          // Backend says idle but UI thinks it's running → unstick\n          if (healthEntry.status === 'idle' && !healthEntry.alive) {\n            changed = true\n            return { ...t, status: 'completed' as const, currentActivity: '', activeRequestId: null }\n          }\n\n          // Backend says failed → unstick\n          if (healthEntry.status === 'failed') {\n            changed = true\n            return { ...t, status: 'failed' as const, currentActivity: '', activeRequestId: null }\n          }\n\n          return t\n        })\n\n        // Only write state when something actually changed\n        if (changed) {\n          useSessionStore.setState({ tabs: newTabs })\n        }\n      } catch {\n        // Ignore transient health check errors\n      }\n    }, HEALTH_POLL_INTERVAL_MS)\n\n    return () => clearInterval(timer)\n  }, [])\n}\n"
  },
  {
    "path": "src/renderer/index.css",
    "content": "@import \"tailwindcss\";\n\n/*\n * ─── Theme CSS Variables ───\n * Generated at runtime by syncTokensToCss() in theme.ts.\n * Do NOT define --clui-* vars here — JS tokens are the single source of truth.\n */\n\nhtml, body, #root {\n  height: 100%;\n  margin: 0;\n  background: transparent !important;\n  overflow: hidden;\n  user-select: none;\n  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;\n  -webkit-font-smoothing: antialiased;\n}\n\n/* ─── Scrollbar — thin & subtle ─── */\n::-webkit-scrollbar {\n  width: 4px;\n}\n::-webkit-scrollbar-track {\n  background: transparent;\n}\n::-webkit-scrollbar-thumb {\n  background: var(--clui-scroll-thumb);\n  border-radius: 4px;\n}\n::-webkit-scrollbar-thumb:hover {\n  background: var(--clui-scroll-thumb-hover);\n}\n\n/* ─── Glass surface — shared card/pill style ─── */\n.glass-surface {\n  background: var(--clui-container-bg);\n  border: 1px solid var(--clui-container-border);\n  box-shadow: var(--clui-card-shadow);\n}\n\n/* ─── Textarea reset ─── */\ntextarea {\n  font-family: inherit;\n  font-size: inherit;\n  cursor: text;\n}\ntextarea:focus {\n  outline: none;\n}\ntextarea::placeholder,\ninput::placeholder {\n  color: var(--clui-placeholder);\n}\n\n/* ─── Allow text selection in conversation content ─── */\n.prose-cloud,\n.conversation-selectable {\n  user-select: text;\n  -webkit-user-select: text;\n}\n\n/* ─── Prose overrides for markdown ─── */\n.prose-cloud {\n  color: var(--clui-text-primary);\n  line-height: 1.6;\n  overflow-wrap: break-word;\n  word-break: break-word;\n}\n.prose-cloud p {\n  margin: 0.25em 0;\n}\n.prose-cloud pre {\n  background: var(--clui-code-bg);\n  border: 1px solid var(--clui-container-border);\n  border-radius: 10px;\n  padding: 0.75em 1em;\n  font-size: 12px;\n  overflow-x: auto;\n}\n.prose-cloud code {\n  background: var(--clui-accent-light);\n  color: var(--clui-accent);\n  padding: 0.15em 0.35em;\n  border-radius: 4px;\n  font-size: 0.9em;\n}\n.prose-cloud pre code {\n  background: none;\n  color: inherit;\n  padding: 0;\n}\n.prose-cloud a {\n  color: var(--clui-accent);\n  text-decoration: none;\n}\n.prose-cloud a:hover {\n  text-decoration: underline;\n}\n.prose-cloud ul, .prose-cloud ol {\n  padding-left: 1.2em;\n  margin: 0.25em 0;\n}\n.prose-cloud li {\n  margin: 0.1em 0;\n}\n.prose-cloud h1, .prose-cloud h2, .prose-cloud h3, .prose-cloud h4 {\n  margin: 0.5em 0 0.25em;\n  font-weight: 600;\n  color: var(--clui-text-primary);\n}\n.prose-cloud blockquote {\n  border-left: 3px solid var(--clui-tool-running-border);\n  padding-left: 0.75em;\n  color: var(--clui-text-secondary);\n  margin: 0.5em 0;\n}\n.prose-cloud table {\n  border-collapse: collapse;\n  width: max-content;\n  min-width: 100%;\n  font-size: 12px;\n}\n.prose-cloud th, .prose-cloud td {\n  border: 1px solid var(--clui-container-border);\n  padding: 0.4em 0.6em;\n  overflow-wrap: anywhere;\n}\n.prose-cloud th:first-child, .prose-cloud td:first-child {\n  white-space: nowrap;\n}\n.prose-cloud th {\n  background: var(--clui-surface-primary);\n  font-weight: 600;\n}\n\n/* ─── Pulsing dot animation ─── */\n@keyframes pulse-dot {\n  0%, 100% { opacity: 1; transform: scale(1); }\n  50% { opacity: 0.5; transform: scale(0.85); }\n}\n.animate-pulse-dot {\n  animation: pulse-dot 1.5s ease-in-out infinite;\n}\n\n/* ─── Bouncing dots for thinking ─── */\n@keyframes bounce-dot {\n  0%, 80%, 100% { transform: translateY(0); }\n  40% { transform: translateY(-4px); }\n}\n.animate-bounce-dot {\n  animation: bounce-dot 1.2s ease-in-out infinite;\n}\n\n/* ─── Stacking circle buttons ─── */\n.circles-out {\n  position: absolute;\n  right: calc(100% + 10px);\n  bottom: 0;\n  height: 46px;\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n}\n\n.btn-stack {\n  position: relative;\n  width: 160px;\n  height: 46px;\n  overflow: visible;\n}\n\n.stack-btn {\n  position: absolute;\n  width: 46px;\n  height: 46px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--clui-text-tertiary);\n  cursor: pointer;\n  transition: right 0.4s cubic-bezier(0.34, 1.3, 0.64, 1),\n              color 0.15s ease,\n              background 0.15s ease;\n}\n.stack-btn:hover:not(:disabled) { color: var(--clui-btn-hover-color); background: var(--clui-btn-hover-bg); }\n.stack-btn:disabled { color: var(--clui-btn-disabled); cursor: default; }\n\n/* Collapsed: stacked on the right, overlapping */\n.stack-btn-1 { right: 0;    z-index: 3; }\n.stack-btn-2 { right: 28px; z-index: 2; }\n.stack-btn-3 { right: 56px; z-index: 1; }\n\n/* Expanded on hover: spread evenly */\n.btn-stack:hover .stack-btn-1 { right: 0; }\n.btn-stack:hover .stack-btn-2 { right: 56px; }\n.btn-stack:hover .stack-btn-3 { right: 112px; }\n\n/* ─── Drag region for frameless window ─── */\n/* Manual drag via IPC replaces -webkit-app-region which conflicts with setIgnoreMouseEvents */\n.drag-region {\n  cursor: grab;\n}\n.drag-region:active {\n  cursor: grabbing;\n}\n.no-drag {\n  cursor: default;\n}\n"
  },
  {
    "path": "src/renderer/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\" class=\"dark\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Clui CC</title>\n  </head>\n  <body style=\"background: transparent !important;\">\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"./main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/renderer/main.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\nimport './index.css'\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>\n)\n"
  },
  {
    "path": "src/renderer/stores/sessionStore.ts",
    "content": "import { create } from 'zustand'\nimport type { TabStatus, NormalizedEvent, EnrichedError, Message, TabState, Attachment, CatalogPlugin, PluginStatus } from '../../shared/types'\nimport { useThemeStore } from '../theme'\nimport notificationSrc from '../../../resources/notification.mp3'\n\n// ─── Known models ───\n\nexport const AVAILABLE_MODELS = [\n  { id: 'claude-opus-4-6', label: 'Opus 4.6' },\n  { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },\n  { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },\n] as const\n\nfunction normalizeModelId(modelId: string): string {\n  // Claude sometimes appends context window hints like \"[1m]\" to model IDs.\n  return modelId.replace(/\\[[^\\]]+\\]/g, '').trim()\n}\n\nexport function getModelDisplayLabel(modelId: string): string {\n  const normalizedId = normalizeModelId(modelId)\n  const has1MContext = /\\[\\s*1m\\s*\\]/i.test(modelId)\n\n  const known = AVAILABLE_MODELS.find((m) => m.id === normalizedId)\n  if (known) {\n    return has1MContext ? `${known.label} (1M)` : known.label\n  }\n\n  // Fallback for future model IDs not yet listed in AVAILABLE_MODELS.\n  const compact = normalizedId\n    .replace(/^claude-/, '')\n    .replace(/-\\d{8}$/, '')\n  const familyMatch = compact.match(/^(opus|sonnet|haiku)-(\\d+)-(\\d+)$/i)\n  if (familyMatch) {\n    const family = familyMatch[1][0].toUpperCase() + familyMatch[1].slice(1).toLowerCase()\n    const label = `${family} ${familyMatch[2]}.${familyMatch[3]}`\n    return has1MContext ? `${label} (1M)` : label\n  }\n\n  return has1MContext ? `${normalizedId} (1M)` : normalizedId\n}\n\n// ─── Store ───\n\ninterface StaticInfo {\n  version: string\n  email: string | null\n  subscriptionType: string | null\n  projectPath: string\n  homePath: string\n}\n\ninterface State {\n  tabs: TabState[]\n  activeTabId: string\n  /** Global expand/collapse — user-controlled, not per-tab */\n  isExpanded: boolean\n  /** Global info fetched on startup (not per-session) */\n  staticInfo: StaticInfo | null\n  /** User's preferred model override (null = use default) */\n  preferredModel: string | null\n  /** Global permission mode: 'ask' shows cards, 'auto' auto-approves all tool calls */\n  permissionMode: 'ask' | 'auto'\n\n  // Marketplace state\n  marketplaceOpen: boolean\n  marketplaceCatalog: CatalogPlugin[]\n  marketplaceLoading: boolean\n  marketplaceError: string | null\n  marketplaceInstalledNames: string[]\n  marketplacePluginStates: Record<string, PluginStatus>\n  marketplaceSearch: string\n  marketplaceFilter: string\n\n  // Actions\n  initStaticInfo: () => Promise<void>\n  setPreferredModel: (model: string | null) => void\n  setPermissionMode: (mode: 'ask' | 'auto') => void\n  createTab: () => Promise<string>\n  selectTab: (tabId: string) => void\n  closeTab: (tabId: string) => void\n  clearTab: () => void\n  toggleExpanded: () => void\n  toggleMarketplace: () => void\n  closeMarketplace: () => void\n  loadMarketplace: (forceRefresh?: boolean) => Promise<void>\n  setMarketplaceSearch: (query: string) => void\n  setMarketplaceFilter: (filter: string) => void\n  installMarketplacePlugin: (plugin: CatalogPlugin) => Promise<void>\n  uninstallMarketplacePlugin: (plugin: CatalogPlugin) => Promise<void>\n  buildYourOwn: () => void\n  resumeSession: (sessionId: string, title?: string, projectPath?: string) => Promise<string>\n  addSystemMessage: (content: string) => void\n  sendMessage: (prompt: string, projectPath?: string) => void\n  respondPermission: (tabId: string, questionId: string, optionId: string) => void\n  addDirectory: (dir: string) => void\n  removeDirectory: (dir: string) => void\n  setBaseDirectory: (dir: string) => void\n  addAttachments: (attachments: Attachment[]) => void\n  removeAttachment: (attachmentId: string) => void\n  clearAttachments: () => void\n  handleNormalizedEvent: (tabId: string, event: NormalizedEvent) => void\n  handleStatusChange: (tabId: string, newStatus: string, oldStatus: string) => void\n  handleError: (tabId: string, error: EnrichedError) => void\n}\n\nlet msgCounter = 0\nconst nextMsgId = () => `msg-${++msgCounter}`\n\n// ─── Notification sound (plays when task completes while window is hidden) ───\nconst notificationAudio = new Audio(notificationSrc)\nnotificationAudio.volume = 1.0\n\nasync function playNotificationIfHidden(): Promise<void> {\n  if (!useThemeStore.getState().soundEnabled) return\n  try {\n    const visible = await window.clui.isVisible()\n    if (!visible) {\n      notificationAudio.currentTime = 0\n      notificationAudio.play().catch(() => {})\n    }\n  } catch {}\n}\n\nfunction makeLocalTab(): TabState {\n  return {\n    id: crypto.randomUUID(),\n    claudeSessionId: null,\n    status: 'idle',\n    activeRequestId: null,\n    hasUnread: false,\n    currentActivity: '',\n    permissionQueue: [],\n    permissionDenied: null,\n    attachments: [],\n    messages: [],\n    title: 'New Tab',\n    lastResult: null,\n    sessionModel: null,\n    sessionTools: [],\n    sessionMcpServers: [],\n    sessionSkills: [],\n    sessionVersion: null,\n    queuedPrompts: [],\n    workingDirectory: '~',\n    hasChosenDirectory: false,\n    additionalDirs: [],\n  }\n}\n\nconst initialTab = makeLocalTab()\n\nexport const useSessionStore = create<State>((set, get) => ({\n  tabs: [initialTab],\n  activeTabId: initialTab.id,\n  isExpanded: false,\n  staticInfo: null,\n  preferredModel: null,\n  permissionMode: 'ask',\n\n  // Marketplace\n  marketplaceOpen: false,\n  marketplaceCatalog: [],\n  marketplaceLoading: false,\n  marketplaceError: null,\n  marketplaceInstalledNames: [],\n  marketplacePluginStates: {},\n  marketplaceSearch: '',\n  marketplaceFilter: 'All',\n\n  initStaticInfo: async () => {\n    try {\n      const result = await window.clui.start()\n      set({\n        staticInfo: {\n          version: result.version || 'unknown',\n          email: result.auth?.email || null,\n          subscriptionType: result.auth?.subscriptionType || null,\n          projectPath: result.projectPath || '~',\n          homePath: result.homePath || '~',\n        },\n      })\n    } catch {}\n  },\n\n  setPreferredModel: (model) => {\n    set({ preferredModel: model })\n  },\n\n  setPermissionMode: (mode) => {\n    set({ permissionMode: mode })\n    window.clui.setPermissionMode(mode)\n  },\n\n  createTab: async () => {\n    const homeDir = get().staticInfo?.homePath || '~'\n    try {\n      const { tabId } = await window.clui.createTab()\n      const tab: TabState = {\n        ...makeLocalTab(),\n        id: tabId,\n        workingDirectory: homeDir,\n      }\n      set((s) => ({\n        tabs: [...s.tabs, tab],\n        activeTabId: tab.id,\n      }))\n      return tabId\n    } catch {\n      const tab = makeLocalTab()\n      tab.workingDirectory = homeDir\n      set((s) => ({\n        tabs: [...s.tabs, tab],\n        activeTabId: tab.id,\n      }))\n      return tab.id\n    }\n  },\n\n  selectTab: (tabId) => {\n    const s = get()\n    if (tabId === s.activeTabId) {\n      // Clicking the already-active tab: toggle global expand/collapse\n      const willExpand = !s.isExpanded\n      set((prev) => ({\n        isExpanded: willExpand,\n        marketplaceOpen: false,\n        // Expanding = reading: clear unread flag\n        tabs: willExpand\n          ? prev.tabs.map((t) => t.id === tabId ? { ...t, hasUnread: false } : t)\n          : prev.tabs,\n      }))\n    } else {\n      // Switching to a different tab: mark as read\n      set((prev) => ({\n        activeTabId: tabId,\n        marketplaceOpen: false,\n        tabs: prev.tabs.map((t) =>\n          t.id === tabId ? { ...t, hasUnread: false } : t\n        ),\n      }))\n    }\n  },\n\n  toggleExpanded: () => {\n    const { activeTabId, isExpanded } = get()\n    const willExpand = !isExpanded\n    set((s) => ({\n      isExpanded: willExpand,\n      marketplaceOpen: false,\n      // Expanding = reading: clear unread flag for the active tab\n      tabs: willExpand\n        ? s.tabs.map((t) => t.id === activeTabId ? { ...t, hasUnread: false } : t)\n        : s.tabs,\n    }))\n  },\n\n  toggleMarketplace: () => {\n    const s = get()\n    if (s.marketplaceOpen) {\n      set({ marketplaceOpen: false })\n    } else {\n      set({ isExpanded: false, marketplaceOpen: true })\n      get().loadMarketplace()\n    }\n  },\n\n  closeMarketplace: () => {\n    set({ marketplaceOpen: false })\n  },\n\n  loadMarketplace: async (forceRefresh) => {\n    set({ marketplaceLoading: true, marketplaceError: null })\n    try {\n      const [catalog, installed] = await Promise.all([\n        window.clui.fetchMarketplace(forceRefresh),\n        window.clui.listInstalledPlugins(),\n      ])\n      if (catalog.error && catalog.plugins.length === 0) {\n        set({ marketplaceError: catalog.error, marketplaceLoading: false })\n        return\n      }\n      const installedSet = new Set(installed.map((n) => n.toLowerCase()))\n      const pluginStates: Record<string, PluginStatus> = {}\n      for (const p of catalog.plugins) {\n        // For SKILL.md skills: match individual name against ~/.claude/skills/ dirs\n        // For CLI plugins: match installName or \"installName@marketplace\" against installed_plugins.json\n        const candidates = p.isSkillMd\n          ? [p.installName]\n          : [p.installName, `${p.installName}@${p.marketplace}`]\n        const isInstalled = candidates.some((c) => installedSet.has(c.toLowerCase()))\n        pluginStates[p.id] = isInstalled ? 'installed' : 'not_installed'\n      }\n      set({\n        marketplaceCatalog: catalog.plugins,\n        marketplaceInstalledNames: installed,\n        marketplacePluginStates: pluginStates,\n        marketplaceLoading: false,\n      })\n    } catch (err: unknown) {\n      set({\n        marketplaceError: err instanceof Error ? err.message : String(err),\n        marketplaceLoading: false,\n      })\n    }\n  },\n\n  setMarketplaceSearch: (query) => {\n    set({ marketplaceSearch: query })\n  },\n\n  setMarketplaceFilter: (filter) => {\n    set({ marketplaceFilter: filter })\n  },\n\n  installMarketplacePlugin: async (plugin) => {\n    set((s) => ({\n      marketplacePluginStates: { ...s.marketplacePluginStates, [plugin.id]: 'installing' },\n    }))\n    const result = await window.clui.installPlugin(plugin.repo, plugin.installName, plugin.marketplace, plugin.sourcePath, plugin.isSkillMd)\n    if (result.ok) {\n      set((s) => ({\n        marketplacePluginStates: { ...s.marketplacePluginStates, [plugin.id]: 'installed' as PluginStatus },\n        marketplaceInstalledNames: [...s.marketplaceInstalledNames, plugin.installName],\n      }))\n    } else {\n      set((s) => ({\n        marketplacePluginStates: { ...s.marketplacePluginStates, [plugin.id]: 'failed' },\n      }))\n    }\n  },\n\n  uninstallMarketplacePlugin: async (plugin) => {\n    const result = await window.clui.uninstallPlugin(plugin.installName)\n    if (result.ok) {\n      set((s) => ({\n        marketplacePluginStates: { ...s.marketplacePluginStates, [plugin.id]: 'not_installed' as PluginStatus },\n        marketplaceInstalledNames: s.marketplaceInstalledNames.filter((n) => n !== plugin.installName),\n      }))\n    }\n  },\n\n  buildYourOwn: () => {\n    set({ marketplaceOpen: false, isExpanded: true })\n    // Small delay to let the UI transition\n    setTimeout(() => {\n      get().sendMessage('Help me create a new Claude Code skill')\n    }, 100)\n  },\n\n  closeTab: (tabId) => {\n    window.clui.closeTab(tabId).catch(() => {})\n\n    const s = get()\n    const remaining = s.tabs.filter((t) => t.id !== tabId)\n\n    if (s.activeTabId === tabId) {\n      if (remaining.length === 0) {\n        const newTab = makeLocalTab()\n        set({ tabs: [newTab], activeTabId: newTab.id })\n        return\n      }\n      const closedIndex = s.tabs.findIndex((t) => t.id === tabId)\n      const newActive = remaining[Math.min(closedIndex, remaining.length - 1)]\n      set({ tabs: remaining, activeTabId: newActive.id })\n    } else {\n      set({ tabs: remaining })\n    }\n  },\n\n  clearTab: () => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? { ...t, messages: [], lastResult: null, currentActivity: '', permissionQueue: [], permissionDenied: null, queuedPrompts: [] }\n          : t\n      ),\n    }))\n  },\n\n  resumeSession: async (sessionId, title, projectPath) => {\n    const defaultDir = projectPath || get().staticInfo?.homePath || '~'\n    try {\n      const { tabId } = await window.clui.createTab()\n\n      // Load previous conversation messages from the JSONL file\n      const history = await window.clui.loadSession(sessionId, defaultDir).catch(() => [])\n      const messages: Message[] = history.map((m) => ({\n        id: nextMsgId(),\n        role: m.role as Message['role'],\n        content: m.content,\n        toolName: m.toolName,\n        toolStatus: m.toolName ? 'completed' as const : undefined,\n        timestamp: m.timestamp,\n      }))\n\n      const tab: TabState = {\n        ...makeLocalTab(),\n        id: tabId,\n        claudeSessionId: sessionId,\n        title: title || 'Resumed Session',\n        workingDirectory: defaultDir,\n        hasChosenDirectory: !!projectPath,\n        messages,\n      }\n      set((s) => ({\n        tabs: [...s.tabs, tab],\n        activeTabId: tab.id,\n        isExpanded: true,\n      }))\n      // Don't call initSession — the first real prompt will use --resume with the sessionId\n      return tabId\n    } catch {\n      const tab = makeLocalTab()\n      tab.claudeSessionId = sessionId\n      tab.title = title || 'Resumed Session'\n      tab.workingDirectory = defaultDir\n      tab.hasChosenDirectory = !!projectPath\n      set((s) => ({\n        tabs: [...s.tabs, tab],\n        activeTabId: tab.id,\n        isExpanded: true,\n      }))\n      return tab.id\n    }\n  },\n\n  addSystemMessage: (content) => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? {\n              ...t,\n              messages: [\n                ...t.messages,\n                { id: nextMsgId(), role: 'system' as const, content, timestamp: Date.now() },\n              ],\n            }\n          : t\n      ),\n    }))\n  },\n\n  // ─── Permission response ───\n\n  respondPermission: (tabId, questionId, optionId) => {\n    // Send to backend\n    window.clui.respondPermission(tabId, questionId, optionId).catch(() => {})\n\n    // Remove answered item from queue; show next tool's activity or clear\n    set((s) => ({\n      tabs: s.tabs.map((t) => {\n        if (t.id !== tabId) return t\n        const remaining = t.permissionQueue.filter((p) => p.questionId !== questionId)\n        return {\n          ...t,\n          permissionQueue: remaining,\n          currentActivity: remaining.length > 0\n            ? `Waiting for permission: ${remaining[0].toolTitle}`\n            : 'Working...',\n        }\n      }),\n    }))\n  },\n\n  // ─── Directory management ───\n\n  addDirectory: (dir) => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? {\n              ...t,\n              additionalDirs: t.additionalDirs.includes(dir)\n                ? t.additionalDirs\n                : [...t.additionalDirs, dir],\n            }\n          : t\n      ),\n    }))\n  },\n\n  removeDirectory: (dir) => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? { ...t, additionalDirs: t.additionalDirs.filter((d) => d !== dir) }\n          : t\n      ),\n    }))\n  },\n\n  setBaseDirectory: (dir) => {\n    const { activeTabId } = get()\n    window.clui.resetTabSession(activeTabId)\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? {\n              ...t,\n              workingDirectory: dir,\n              hasChosenDirectory: true,\n              claudeSessionId: null,\n              additionalDirs: [],\n            }\n          : t\n      ),\n    }))\n  },\n\n  // ─── Attachment management ───\n\n  addAttachments: (attachments) => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? { ...t, attachments: [...t.attachments, ...attachments] }\n          : t\n      ),\n    }))\n  },\n\n  removeAttachment: (attachmentId) => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId\n          ? { ...t, attachments: t.attachments.filter((a) => a.id !== attachmentId) }\n          : t\n      ),\n    }))\n  },\n\n  clearAttachments: () => {\n    const { activeTabId } = get()\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === activeTabId ? { ...t, attachments: [] } : t\n      ),\n    }))\n  },\n\n  // ─── Send ───\n\n  sendMessage: (prompt, projectPath) => {\n    const { activeTabId, tabs, staticInfo } = get()\n    const tab = tabs.find((t) => t.id === activeTabId)\n    // Use explicitly chosen directory, otherwise fall back to user home\n    const resolvedPath = projectPath || (tab?.hasChosenDirectory ? tab.workingDirectory : (staticInfo?.homePath || tab?.workingDirectory || '~'))\n    if (!tab) return\n\n    // Guard: don't send while connecting (warmup in progress)\n    if (tab.status === 'connecting') return\n\n    const isBusy = tab.status === 'running'\n    const requestId = crypto.randomUUID()\n\n    // Build full prompt with attachment context\n    let fullPrompt = prompt\n    if (tab.attachments.length > 0) {\n      const attachmentCtx = tab.attachments\n        .map((a) => `[Attached ${a.type}: ${a.path}]`)\n        .join('\\n')\n      fullPrompt = `${attachmentCtx}\\n\\n${prompt}`\n    }\n\n    const title = tab.messages.length === 0\n      ? (prompt.length > 30 ? prompt.substring(0, 27) + '...' : prompt)\n      : tab.title\n\n    // Optimistic update: clear attachments\n    // If busy, add to queuedPrompts (shown at bottom); otherwise add to messages and set connecting\n    set((s) => ({\n      tabs: s.tabs.map((t) => {\n        if (t.id !== activeTabId) return t\n        const withEffectiveBase = t.hasChosenDirectory\n          ? t\n          : {\n              ...t,\n              // Once the user sends the first message, lock in the effective\n              // base directory (home by default) so the footer no longer shows \"—\".\n              hasChosenDirectory: true,\n              workingDirectory: resolvedPath,\n            }\n        if (isBusy) {\n          return {\n            ...withEffectiveBase,\n            title,\n            attachments: [],\n            queuedPrompts: [...withEffectiveBase.queuedPrompts, prompt],\n          }\n        }\n        return {\n          ...withEffectiveBase,\n          status: 'connecting' as TabStatus,\n          activeRequestId: requestId,\n          currentActivity: 'Starting...',\n          title,\n          attachments: [],\n          messages: [\n            ...withEffectiveBase.messages,\n            { id: nextMsgId(), role: 'user' as const, content: prompt, timestamp: Date.now() },\n          ],\n        }\n      }),\n    }))\n\n    // Send to backend — ControlPlane will queue if a run is active\n    const { preferredModel } = get()\n    window.clui.prompt(activeTabId, requestId, {\n      prompt: fullPrompt,\n      projectPath: resolvedPath,\n      sessionId: tab.claudeSessionId || undefined,\n      model: preferredModel || undefined,\n      addDirs: tab.additionalDirs.length > 0 ? tab.additionalDirs : undefined,\n    }).catch((err: Error) => {\n      get().handleError(activeTabId, {\n        message: err.message,\n        stderrTail: [],\n        exitCode: null,\n        elapsedMs: 0,\n        toolCallCount: 0,\n      })\n    })\n  },\n\n  // ─── Event handlers ───\n\n  handleNormalizedEvent: (tabId, event) => {\n    set((s) => {\n      const { activeTabId } = s\n      const tabs = s.tabs.map((tab) => {\n        if (tab.id !== tabId) return tab\n        const updated = { ...tab }\n\n        switch (event.type) {\n          case 'session_init':\n            updated.claudeSessionId = event.sessionId\n            updated.sessionModel = event.model\n            updated.sessionTools = event.tools\n            updated.sessionMcpServers = event.mcpServers\n            updated.sessionSkills = event.skills\n            updated.sessionVersion = event.version\n            // Don't change status/activity for warmup inits — they're invisible\n            if (!event.isWarmup) {\n              updated.status = 'running'\n              updated.currentActivity = 'Thinking...'\n              // Move the first queued prompt into the timeline (it's now being processed)\n              if (updated.queuedPrompts.length > 0) {\n                const [nextPrompt, ...rest] = updated.queuedPrompts\n                updated.queuedPrompts = rest\n                updated.messages = [\n                  ...updated.messages,\n                  { id: nextMsgId(), role: 'user' as const, content: nextPrompt, timestamp: Date.now() },\n                ]\n              }\n            }\n            break\n\n          case 'text_chunk': {\n            updated.currentActivity = 'Writing...'\n            const lastMsg = updated.messages[updated.messages.length - 1]\n            if (lastMsg?.role === 'assistant' && !lastMsg.toolName) {\n              updated.messages = [\n                ...updated.messages.slice(0, -1),\n                { ...lastMsg, content: lastMsg.content + event.text },\n              ]\n            } else {\n              updated.messages = [\n                ...updated.messages,\n                { id: nextMsgId(), role: 'assistant', content: event.text, timestamp: Date.now() },\n              ]\n            }\n            break\n          }\n\n          case 'tool_call':\n            updated.currentActivity = `Running ${event.toolName}...`\n            updated.messages = [\n              ...updated.messages,\n              {\n                id: nextMsgId(),\n                role: 'tool',\n                content: '',\n                toolName: event.toolName,\n                toolInput: '',\n                toolStatus: 'running',\n                timestamp: Date.now(),\n              },\n            ]\n            break\n\n          case 'tool_call_update': {\n            const msgs = [...updated.messages]\n            const lastTool = [...msgs].reverse().find((m) => m.role === 'tool' && m.toolStatus === 'running')\n            if (lastTool) {\n              lastTool.toolInput = (lastTool.toolInput || '') + event.partialInput\n            }\n            updated.messages = msgs\n            break\n          }\n\n          case 'tool_call_complete': {\n            const msgs2 = [...updated.messages]\n            const runningTool = [...msgs2].reverse().find((m) => m.role === 'tool' && m.toolStatus === 'running')\n            if (runningTool) {\n              runningTool.toolStatus = 'completed'\n            }\n            updated.messages = msgs2\n            break\n          }\n\n          case 'task_update': {\n            // ── Text fallback ──\n            // text_chunk events (from stream_event deltas) are the primary render path.\n            // If they didn't arrive for this run (timing, partial stream, etc.), the\n            // assembled assistant event still has the full text — extract it here.\n            // \"This run\" = everything after the last user message.\n            if (event.message?.content) {\n              const lastUserIdx = (() => {\n                for (let i = updated.messages.length - 1; i >= 0; i--) {\n                  if (updated.messages[i].role === 'user') return i\n                }\n                return -1\n              })()\n              const hasStreamedText = updated.messages\n                .slice(lastUserIdx + 1)\n                .some((m) => m.role === 'assistant' && !m.toolName)\n\n              if (!hasStreamedText) {\n                const textContent = event.message.content\n                  .filter((b) => b.type === 'text' && b.text)\n                  .map((b) => b.text!)\n                  .join('')\n                if (textContent) {\n                  updated.messages = [\n                    ...updated.messages,\n                    { id: nextMsgId(), role: 'assistant' as const, content: textContent, timestamp: Date.now() },\n                  ]\n                }\n              }\n\n              // ── Tool card deduplication (unchanged) ──\n              for (const block of event.message.content) {\n                if (block.type === 'tool_use' && block.name) {\n                  const exists = updated.messages.find(\n                    (m) => m.role === 'tool' && m.toolName === block.name && !m.content\n                  )\n                  if (!exists) {\n                    updated.messages = [\n                      ...updated.messages,\n                      {\n                        id: nextMsgId(),\n                        role: 'tool',\n                        content: '',\n                        toolName: block.name,\n                        toolInput: JSON.stringify(block.input, null, 2),\n                        toolStatus: 'completed',\n                        timestamp: Date.now(),\n                      },\n                    ]\n                  }\n                }\n              }\n            }\n            break\n          }\n\n          case 'task_complete':\n            updated.status = 'completed'\n            updated.activeRequestId = null\n            updated.currentActivity = ''\n            updated.permissionQueue = []\n            updated.lastResult = {\n              totalCostUsd: event.costUsd,\n              durationMs: event.durationMs,\n              numTurns: event.numTurns,\n              usage: event.usage,\n              sessionId: event.sessionId,\n            }\n            // ── Final text fallback ──\n            // If neither text_chunks nor task_update text produced an assistant message,\n            // use event.result (the CLI's assembled final output) as last resort.\n            if (event.result) {\n              const lastUserIdx2 = (() => {\n                for (let i = updated.messages.length - 1; i >= 0; i--) {\n                  if (updated.messages[i].role === 'user') return i\n                }\n                return -1\n              })()\n              const hasAnyText = updated.messages\n                .slice(lastUserIdx2 + 1)\n                .some((m) => m.role === 'assistant' && !m.toolName)\n              if (!hasAnyText) {\n                updated.messages = [\n                  ...updated.messages,\n                  { id: nextMsgId(), role: 'assistant' as const, content: event.result, timestamp: Date.now() },\n                ]\n              }\n            }\n            // Mark as unread unless the user is actively viewing this tab\n            // (active tab with card expanded). A collapsed active tab still\n            // counts as \"unread\" — the user hasn't seen the response yet.\n            if (tabId !== activeTabId || !s.isExpanded) {\n              updated.hasUnread = true\n            }\n            // Show fallback card when tools were denied by permission settings\n            if (event.permissionDenials && event.permissionDenials.length > 0) {\n              updated.permissionDenied = { tools: event.permissionDenials }\n            } else {\n              updated.permissionDenied = null\n            }\n            // Play notification sound if window is hidden\n            playNotificationIfHidden()\n            break\n\n          case 'error':\n            updated.status = 'failed'\n            updated.activeRequestId = null\n            updated.currentActivity = ''\n            updated.permissionQueue = []\n            updated.permissionDenied = null\n            updated.messages = [\n              ...updated.messages,\n              { id: nextMsgId(), role: 'system', content: `Error: ${event.message}`, timestamp: Date.now() },\n            ]\n            break\n\n          case 'session_dead':\n            updated.status = 'dead'\n            updated.activeRequestId = null\n            updated.currentActivity = ''\n            updated.permissionQueue = []\n            updated.permissionDenied = null\n            updated.messages = [\n              ...updated.messages,\n              {\n                id: nextMsgId(),\n                role: 'system',\n                content: `Session ended unexpectedly (exit ${event.exitCode})`,\n                timestamp: Date.now(),\n              },\n            ]\n            break\n\n          case 'permission_request': {\n            const newReq: import('../../shared/types').PermissionRequest = {\n              questionId: event.questionId,\n              toolTitle: event.toolName,\n              toolDescription: event.toolDescription,\n              toolInput: event.toolInput,\n              options: event.options.map((o) => ({\n                optionId: o.id,\n                kind: o.kind,\n                label: o.label,\n              })),\n            }\n            updated.permissionQueue = [...updated.permissionQueue, newReq]\n            updated.currentActivity = `Waiting for permission: ${event.toolName}`\n            break\n          }\n\n          case 'rate_limit':\n            if (event.status !== 'allowed') {\n              updated.messages = [\n                ...updated.messages,\n                {\n                  id: nextMsgId(),\n                  role: 'system',\n                  content: `Rate limited (${event.rateLimitType}). Resets at ${new Date(event.resetsAt).toLocaleTimeString()}.`,\n                  timestamp: Date.now(),\n                },\n              ]\n            }\n            break\n        }\n\n        return updated\n      })\n\n      return { tabs }\n    })\n  },\n\n  handleStatusChange: (tabId, newStatus) => {\n    set((s) => ({\n      tabs: s.tabs.map((t) =>\n        t.id === tabId\n          ? {\n              ...t,\n              status: newStatus as TabStatus,\n              // Clear activity when transitioning to idle (e.g., after warmup init)\n              ...(newStatus === 'idle' ? { currentActivity: '', permissionQueue: [] as import('../../shared/types').PermissionRequest[], permissionDenied: null } : {}),\n            }\n          : t\n      ),\n    }))\n  },\n\n  handleError: (tabId, error) => {\n    set((s) => ({\n      tabs: s.tabs.map((t) => {\n        if (t.id !== tabId) return t\n\n        // Deduplicate: skip if the last message is already an error for this failure\n        const lastMsg = t.messages[t.messages.length - 1]\n        const alreadyHasError = lastMsg?.role === 'system' && lastMsg.content.startsWith('Error:')\n\n        return {\n          ...t,\n          status: 'failed' as TabStatus,\n          activeRequestId: null,\n          currentActivity: '',\n          permissionQueue: [],\n          messages: alreadyHasError\n            ? t.messages\n            : [\n                ...t.messages,\n                {\n                  id: nextMsgId(),\n                  role: 'system' as const,\n                  content: `Error: ${error.message}${error.stderrTail.length > 0 ? '\\n\\n' + error.stderrTail.slice(-5).join('\\n') : ''}`,\n                  timestamp: Date.now(),\n                },\n              ],\n        }\n      }),\n    }))\n  },\n}))\n"
  },
  {
    "path": "src/renderer/theme.ts",
    "content": "/**\n * CLUI Design Tokens — Dual theme (dark + light)\n * Colors derived from ChatCN oklch system and design-fixed.html reference.\n */\nimport { create } from 'zustand'\n\n// ─── Color palettes ───\n\nconst darkColors = {\n  // Container (glass surfaces)\n  containerBg: '#242422',\n  containerBgCollapsed: '#21211e',\n  containerBorder: '#3b3b36',\n  containerShadow: '0 8px 28px rgba(0, 0, 0, 0.35), 0 1px 6px rgba(0, 0, 0, 0.25)',\n  cardShadow: '0 2px 8px rgba(0,0,0,0.35)',\n  cardShadowCollapsed: '0 2px 6px rgba(0,0,0,0.4)',\n\n  // Surface layers\n  surfacePrimary: '#353530',\n  surfaceSecondary: '#42423d',\n  surfaceHover: 'rgba(255, 255, 255, 0.05)',\n  surfaceActive: 'rgba(255, 255, 255, 0.08)',\n\n  // Input\n  inputBg: 'transparent',\n  inputBorder: '#3b3b36',\n  inputFocusBorder: 'rgba(217, 119, 87, 0.4)',\n  inputPillBg: '#2a2a27',\n\n  // Text\n  textPrimary: '#ccc9c0',\n  textSecondary: '#c0bdb2',\n  textTertiary: '#76766e',\n  textMuted: '#353530',\n\n  // Accent — orange\n  accent: '#d97757',\n  accentLight: 'rgba(217, 119, 87, 0.1)',\n  accentSoft: 'rgba(217, 119, 87, 0.15)',\n\n  // Status dots\n  statusIdle: '#8a8a80',\n  statusRunning: '#d97757',\n  statusRunningBg: 'rgba(217, 119, 87, 0.1)',\n  statusComplete: '#7aac8c',\n  statusCompleteBg: 'rgba(122, 172, 140, 0.1)',\n  statusError: '#c47060',\n  statusErrorBg: 'rgba(196, 112, 96, 0.08)',\n  statusDead: '#c47060',\n  statusPermission: '#d97757',\n  statusPermissionGlow: 'rgba(217, 119, 87, 0.4)',\n\n  // Tab\n  tabActive: '#353530',\n  tabActiveBorder: '#4a4a45',\n  tabInactive: 'transparent',\n  tabHover: 'rgba(255, 255, 255, 0.05)',\n\n  // User message bubble\n  userBubble: '#353530',\n  userBubbleBorder: '#4a4a45',\n  userBubbleText: '#ccc9c0',\n\n  // Tool card\n  toolBg: '#353530',\n  toolBorder: '#4a4a45',\n  toolRunningBorder: 'rgba(217, 119, 87, 0.3)',\n  toolRunningBg: 'rgba(217, 119, 87, 0.05)',\n\n  // Timeline\n  timelineLine: '#353530',\n  timelineNode: 'rgba(217, 119, 87, 0.2)',\n  timelineNodeActive: '#d97757',\n\n  // Scrollbar\n  scrollThumb: 'rgba(255, 255, 255, 0.15)',\n  scrollThumbHover: 'rgba(255, 255, 255, 0.25)',\n\n  // Stop button\n  stopBg: '#ef4444',\n  stopHover: '#dc2626',\n\n  // Send button\n  sendBg: '#d97757',\n  sendHover: '#c96442',\n  sendDisabled: 'rgba(217, 119, 87, 0.3)',\n\n  // Popover\n  popoverBg: '#292927',\n  popoverBorder: '#3b3b36',\n  popoverShadow: '0 4px 20px rgba(0,0,0,0.3), 0 1px 4px rgba(0,0,0,0.2)',\n\n  // Code block\n  codeBg: '#1a1a18',\n\n  // Mic button\n  micBg: '#353530',\n  micColor: '#c0bdb2',\n  micDisabled: '#42423d',\n\n  // Placeholder\n  placeholder: '#6b6b60',\n\n  // Disabled button color\n  btnDisabled: '#42423d',\n\n  // Text on accent backgrounds\n  textOnAccent: '#ffffff',\n\n  // Button hover (CSS-only stack buttons)\n  btnHoverColor: '#c0bdb2',\n  btnHoverBg: '#302f2d',\n\n  // Accent border variants (replaces hex-alpha concatenation antipattern)\n  accentBorder: 'rgba(217, 119, 87, 0.19)',\n  accentBorderMedium: 'rgba(217, 119, 87, 0.25)',\n\n  // Permission card (amber)\n  permissionBorder: 'rgba(245, 158, 11, 0.3)',\n  permissionShadow: '0 2px 12px rgba(245, 158, 11, 0.08)',\n  permissionHeaderBg: 'rgba(245, 158, 11, 0.06)',\n  permissionHeaderBorder: 'rgba(245, 158, 11, 0.12)',\n\n  // Permission allow (green)\n  permissionAllowBg: 'rgba(34, 197, 94, 0.1)',\n  permissionAllowHoverBg: 'rgba(34, 197, 94, 0.22)',\n  permissionAllowBorder: 'rgba(34, 197, 94, 0.25)',\n\n  // Permission deny (red)\n  permissionDenyBg: 'rgba(239, 68, 68, 0.08)',\n  permissionDenyHoverBg: 'rgba(239, 68, 68, 0.18)',\n  permissionDenyBorder: 'rgba(239, 68, 68, 0.22)',\n\n  // Permission denied card\n  permissionDeniedBorder: 'rgba(196, 112, 96, 0.3)',\n  permissionDeniedHeaderBorder: 'rgba(196, 112, 96, 0.12)',\n\n  // Diff (Edit tool inline diff)\n  diffRemovedBg: 'rgba(248, 81, 73, 0.1)',\n  diffAddedBg: 'rgba(63, 185, 80, 0.1)',\n} as const\n\nconst lightColors = {\n  // Container (glass surfaces)\n  containerBg: '#f9f8f5',\n  containerBgCollapsed: '#f4f2ed',\n  containerBorder: '#dddad2',\n  containerShadow: '0 8px 28px rgba(0, 0, 0, 0.08), 0 1px 6px rgba(0, 0, 0, 0.04)',\n  cardShadow: '0 2px 8px rgba(0,0,0,0.06)',\n  cardShadowCollapsed: '0 2px 6px rgba(0,0,0,0.08)',\n\n  // Surface layers\n  surfacePrimary: '#edeae0',\n  surfaceSecondary: '#dddad2',\n  surfaceHover: 'rgba(0, 0, 0, 0.04)',\n  surfaceActive: 'rgba(0, 0, 0, 0.06)',\n\n  // Input\n  inputBg: 'transparent',\n  inputBorder: '#dddad2',\n  inputFocusBorder: 'rgba(217, 119, 87, 0.4)',\n  inputPillBg: '#ffffff',\n\n  // Text\n  textPrimary: '#3c3929',\n  textSecondary: '#5a5749',\n  textTertiary: '#8a8a80',\n  textMuted: '#dddad2',\n\n  // Accent — orange (same)\n  accent: '#d97757',\n  accentLight: 'rgba(217, 119, 87, 0.1)',\n  accentSoft: 'rgba(217, 119, 87, 0.12)',\n\n  // Status dots\n  statusIdle: '#8a8a80',\n  statusRunning: '#d97757',\n  statusRunningBg: 'rgba(217, 119, 87, 0.1)',\n  statusComplete: '#5a9e6f',\n  statusCompleteBg: 'rgba(90, 158, 111, 0.1)',\n  statusError: '#c47060',\n  statusErrorBg: 'rgba(196, 112, 96, 0.06)',\n  statusDead: '#c47060',\n  statusPermission: '#d97757',\n  statusPermissionGlow: 'rgba(217, 119, 87, 0.3)',\n\n  // Tab\n  tabActive: '#edeae0',\n  tabActiveBorder: '#dddad2',\n  tabInactive: 'transparent',\n  tabHover: 'rgba(0, 0, 0, 0.04)',\n\n  // User message bubble\n  userBubble: '#edeae0',\n  userBubbleBorder: '#dddad2',\n  userBubbleText: '#3c3929',\n\n  // Tool card\n  toolBg: '#edeae0',\n  toolBorder: '#dddad2',\n  toolRunningBorder: 'rgba(217, 119, 87, 0.3)',\n  toolRunningBg: 'rgba(217, 119, 87, 0.05)',\n\n  // Timeline\n  timelineLine: '#dddad2',\n  timelineNode: 'rgba(217, 119, 87, 0.2)',\n  timelineNodeActive: '#d97757',\n\n  // Scrollbar\n  scrollThumb: 'rgba(0, 0, 0, 0.1)',\n  scrollThumbHover: 'rgba(0, 0, 0, 0.18)',\n\n  // Stop button\n  stopBg: '#ef4444',\n  stopHover: '#dc2626',\n\n  // Send button\n  sendBg: '#d97757',\n  sendHover: '#c96442',\n  sendDisabled: 'rgba(217, 119, 87, 0.3)',\n\n  // Popover\n  popoverBg: '#f9f8f5',\n  popoverBorder: '#dddad2',\n  popoverShadow: '0 4px 20px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',\n\n  // Code block\n  codeBg: '#f0eee8',\n\n  // Mic button\n  micBg: '#edeae0',\n  micColor: '#5a5749',\n  micDisabled: '#c8c5bc',\n\n  // Placeholder\n  placeholder: '#b0ada4',\n\n  // Disabled button color\n  btnDisabled: '#c8c5bc',\n\n  // Text on accent backgrounds\n  textOnAccent: '#ffffff',\n\n  // Button hover (CSS-only stack buttons)\n  btnHoverColor: '#3c3929',\n  btnHoverBg: '#edeae0',\n\n  // Accent border variants (replaces hex-alpha concatenation antipattern)\n  accentBorder: 'rgba(217, 119, 87, 0.19)',\n  accentBorderMedium: 'rgba(217, 119, 87, 0.25)',\n\n  // Permission card (amber)\n  permissionBorder: 'rgba(245, 158, 11, 0.3)',\n  permissionShadow: '0 2px 12px rgba(245, 158, 11, 0.08)',\n  permissionHeaderBg: 'rgba(245, 158, 11, 0.06)',\n  permissionHeaderBorder: 'rgba(245, 158, 11, 0.12)',\n\n  // Permission allow (green)\n  permissionAllowBg: 'rgba(34, 197, 94, 0.1)',\n  permissionAllowHoverBg: 'rgba(34, 197, 94, 0.22)',\n  permissionAllowBorder: 'rgba(34, 197, 94, 0.25)',\n\n  // Permission deny (red)\n  permissionDenyBg: 'rgba(239, 68, 68, 0.08)',\n  permissionDenyHoverBg: 'rgba(239, 68, 68, 0.18)',\n  permissionDenyBorder: 'rgba(239, 68, 68, 0.22)',\n\n  // Permission denied card\n  permissionDeniedBorder: 'rgba(196, 112, 96, 0.3)',\n  permissionDeniedHeaderBorder: 'rgba(196, 112, 96, 0.12)',\n\n  // Diff (Edit tool inline diff)\n  diffRemovedBg: 'rgba(248, 81, 73, 0.15)',\n  diffAddedBg: 'rgba(63, 185, 80, 0.15)',\n} as const\n\nexport type ColorPalette = { [K in keyof typeof darkColors]: string }\n\n// ─── Theme store ───\n\nexport type ThemeMode = 'system' | 'light' | 'dark'\n\ninterface ThemeState {\n  isDark: boolean\n  themeMode: ThemeMode\n  soundEnabled: boolean\n  expandedUI: boolean\n  /** OS-reported dark mode — used when themeMode is 'system' */\n  _systemIsDark: boolean\n  setIsDark: (isDark: boolean) => void\n  setThemeMode: (mode: ThemeMode) => void\n  setSoundEnabled: (enabled: boolean) => void\n  setExpandedUI: (expanded: boolean) => void\n  /** Called by OS theme change listener — updates system value */\n  setSystemTheme: (isDark: boolean) => void\n}\n\n/** Convert camelCase token name to --clui-kebab-case CSS custom property */\nfunction camelToKebab(s: string): string {\n  return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)\n}\n\n/** Sync all JS design tokens to CSS custom properties on :root */\nfunction syncTokensToCss(tokens: ColorPalette): void {\n  const style = document.documentElement.style\n  for (const [key, value] of Object.entries(tokens)) {\n    style.setProperty(`--clui-${camelToKebab(key)}`, value)\n  }\n}\n\nfunction applyTheme(isDark: boolean): void {\n  document.documentElement.classList.toggle('dark', isDark)\n  document.documentElement.classList.toggle('light', !isDark)\n  syncTokensToCss(isDark ? darkColors : lightColors)\n}\n\nconst SETTINGS_KEY = 'clui-settings'\n\nfunction loadSettings(): { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean } {\n  try {\n    const raw = localStorage.getItem(SETTINGS_KEY)\n    if (raw) {\n      const parsed = JSON.parse(raw)\n      return {\n        themeMode: ['light', 'dark'].includes(parsed.themeMode) ? parsed.themeMode : 'dark',\n        soundEnabled: typeof parsed.soundEnabled === 'boolean' ? parsed.soundEnabled : true,\n        expandedUI: typeof parsed.expandedUI === 'boolean' ? parsed.expandedUI : false,\n      }\n    }\n  } catch {}\n  return { themeMode: 'dark', soundEnabled: true, expandedUI: false }\n}\n\nfunction saveSettings(s: { themeMode: ThemeMode; soundEnabled: boolean; expandedUI: boolean }): void {\n  try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)) } catch {}\n}\n\n// Always start in compact UI mode on launch.\nconst saved = { ...loadSettings(), expandedUI: false }\n\nexport const useThemeStore = create<ThemeState>((set, get) => ({\n  isDark: saved.themeMode === 'dark' ? true : saved.themeMode === 'light' ? false : true,\n  themeMode: saved.themeMode,\n  soundEnabled: saved.soundEnabled,\n  expandedUI: saved.expandedUI,\n  _systemIsDark: true,\n  setIsDark: (isDark) => {\n    set({ isDark })\n    applyTheme(isDark)\n  },\n  setThemeMode: (mode) => {\n    const resolved = mode === 'system' ? get()._systemIsDark : mode === 'dark'\n    set({ themeMode: mode, isDark: resolved })\n    applyTheme(resolved)\n    saveSettings({ themeMode: mode, soundEnabled: get().soundEnabled, expandedUI: get().expandedUI })\n  },\n  setSoundEnabled: (enabled) => {\n    set({ soundEnabled: enabled })\n    saveSettings({ themeMode: get().themeMode, soundEnabled: enabled, expandedUI: get().expandedUI })\n  },\n  setExpandedUI: (expanded) => {\n    set({ expandedUI: expanded })\n    saveSettings({ themeMode: get().themeMode, soundEnabled: get().soundEnabled, expandedUI: expanded })\n  },\n  setSystemTheme: (isDark) => {\n    set({ _systemIsDark: isDark })\n    // Only apply if following system\n    if (get().themeMode === 'system') {\n      set({ isDark })\n      applyTheme(isDark)\n    }\n  },\n}))\n\n// Initialize CSS vars with saved theme\nsyncTokensToCss(saved.themeMode === 'light' ? lightColors : darkColors)\n\n/** Reactive hook — returns the active color palette */\nexport function useColors(): ColorPalette {\n  const isDark = useThemeStore((s) => s.isDark)\n  return isDark ? darkColors : lightColors\n}\n\n/** Non-reactive getter — use outside React components */\nexport function getColors(isDark: boolean): ColorPalette {\n  return isDark ? darkColors : lightColors\n}\n\n// ─── Backward compatibility ───\n// Legacy static export — components being migrated should use useColors() instead\nexport const colors = darkColors\n\n// ─── Spacing ───\n\nexport const spacing = {\n  contentWidth: 460,\n  containerRadius: 20,\n  containerPadding: 12,\n  tabHeight: 32,\n  inputMinHeight: 44,\n  inputMaxHeight: 160,\n  conversationMaxHeight: 380,\n  pillRadius: 9999,\n  circleSize: 36,\n  circleGap: 8,\n} as const\n\n// ─── Animation ───\n\nexport const motion = {\n  spring: { type: 'spring' as const, stiffness: 500, damping: 30 },\n  easeOut: { duration: 0.2, ease: [0.25, 0.46, 0.45, 0.94] as const },\n  fadeIn: {\n    initial: { opacity: 0, y: 8 },\n    animate: { opacity: 1, y: 0 },\n    exit: { opacity: 0, y: -4 },\n    transition: { duration: 0.15 },\n  },\n} as const\n"
  },
  {
    "path": "src/shared/types.ts",
    "content": "// ─── Claude Code Stream Event Types (verified from v2.1.63) ───\n\nexport interface InitEvent {\n  type: 'system'\n  subtype: 'init'\n  cwd: string\n  session_id: string\n  tools: string[]\n  mcp_servers: Array<{ name: string; status: string }>\n  model: string\n  permissionMode: string\n  agents: string[]\n  skills: string[]\n  plugins: string[]\n  claude_code_version: string\n  fast_mode_state: string\n  uuid: string\n}\n\nexport interface StreamEvent {\n  type: 'stream_event'\n  event: StreamSubEvent\n  session_id: string\n  parent_tool_use_id: string | null\n  uuid: string\n}\n\nexport type StreamSubEvent =\n  | { type: 'message_start'; message: AssistantMessagePayload }\n  | { type: 'content_block_start'; index: number; content_block: ContentBlock }\n  | { type: 'content_block_delta'; index: number; delta: ContentDelta }\n  | { type: 'content_block_stop'; index: number }\n  | { type: 'message_delta'; delta: { stop_reason: string | null }; usage: UsageData; context_management?: unknown }\n  | { type: 'message_stop' }\n\nexport interface ContentBlock {\n  type: 'text' | 'tool_use'\n  text?: string\n  id?: string\n  name?: string\n  input?: Record<string, unknown>\n}\n\nexport type ContentDelta =\n  | { type: 'text_delta'; text: string }\n  | { type: 'input_json_delta'; partial_json: string }\n\nexport interface AssistantEvent {\n  type: 'assistant'\n  message: AssistantMessagePayload\n  parent_tool_use_id: string | null\n  session_id: string\n  uuid: string\n}\n\nexport interface AssistantMessagePayload {\n  model: string\n  id: string\n  role: 'assistant'\n  content: ContentBlock[]\n  stop_reason: string | null\n  usage: UsageData\n}\n\nexport interface RateLimitEvent {\n  type: 'rate_limit_event'\n  rate_limit_info: {\n    status: string\n    resetsAt: number\n    rateLimitType: string\n  }\n  session_id: string\n  uuid: string\n}\n\nexport interface ResultEvent {\n  type: 'result'\n  subtype: 'success' | 'error'\n  is_error: boolean\n  duration_ms: number\n  num_turns: number\n  result: string\n  total_cost_usd: number\n  session_id: string\n  usage: UsageData & {\n    input_tokens: number\n    output_tokens: number\n    cache_read_input_tokens?: number\n    cache_creation_input_tokens?: number\n  }\n  permission_denials: string[]\n  uuid: string\n}\n\nexport interface UsageData {\n  input_tokens?: number\n  output_tokens?: number\n  cache_read_input_tokens?: number\n  cache_creation_input_tokens?: number\n  service_tier?: string\n}\n\nexport interface PermissionEvent {\n  type: 'permission_request'\n  tool: { name: string; description?: string; input?: Record<string, unknown> }\n  question_id: string\n  options: Array<{ id: string; label: string; kind?: string }>\n  session_id: string\n  uuid: string\n}\n\n// Union of all possible top-level events\nexport type ClaudeEvent = InitEvent | StreamEvent | AssistantEvent | RateLimitEvent | ResultEvent | PermissionEvent | UnknownEvent\n\nexport interface UnknownEvent {\n  type: string\n  [key: string]: unknown\n}\n\n// ─── Tab State Machine (v2 — from execution plan) ───\n\nexport type TabStatus = 'connecting' | 'idle' | 'running' | 'completed' | 'failed' | 'dead'\n\nexport interface PermissionRequest {\n  questionId: string\n  toolTitle: string\n  toolDescription?: string\n  toolInput?: Record<string, unknown>\n  options: Array<{ optionId: string; kind?: string; label: string }>\n}\n\nexport interface Attachment {\n  id: string\n  type: 'image' | 'file'\n  name: string\n  path: string\n  mimeType?: string\n  /** Base64 data URL for image previews */\n  dataUrl?: string\n  /** File size in bytes */\n  size?: number\n}\n\nexport interface TabState {\n  id: string\n  claudeSessionId: string | null\n  status: TabStatus\n  activeRequestId: string | null\n  hasUnread: boolean\n  currentActivity: string\n  permissionQueue: PermissionRequest[]\n  /** Fallback card when tools were denied and no interactive permission is available */\n  permissionDenied: { tools: Array<{ toolName: string; toolUseId: string }> } | null\n  attachments: Attachment[]\n  messages: Message[]\n  title: string\n  /** Last run's result data (cost, tokens, duration) */\n  lastResult: RunResult | null\n  /** Session metadata from init event */\n  sessionModel: string | null\n  sessionTools: string[]\n  sessionMcpServers: Array<{ name: string; status: string }>\n  sessionSkills: string[]\n  sessionVersion: string | null\n  /** Prompts waiting behind the current run (display text only) */\n  queuedPrompts: string[]\n  /** Working directory for this tab's Claude sessions */\n  workingDirectory: string\n  /** Whether the user explicitly chose a directory (vs. using default home) */\n  hasChosenDirectory: boolean\n  /** Extra directories accessible via --add-dir (session-preserving) */\n  additionalDirs: string[]\n}\n\nexport interface Message {\n  id: string\n  role: 'user' | 'assistant' | 'tool' | 'system'\n  content: string\n  toolName?: string\n  toolInput?: string\n  toolStatus?: 'running' | 'completed' | 'error'\n  timestamp: number\n}\n\nexport interface RunResult {\n  totalCostUsd: number\n  durationMs: number\n  numTurns: number\n  usage: UsageData\n  sessionId: string\n}\n\n// ─── Canonical Events (normalized from raw stream) ───\n\nexport type NormalizedEvent =\n  | { type: 'session_init'; sessionId: string; tools: string[]; model: string; mcpServers: Array<{ name: string; status: string }>; skills: string[]; version: string; isWarmup?: boolean }\n  | { type: 'text_chunk'; text: string }\n  | { type: 'tool_call'; toolName: string; toolId: string; index: number }\n  | { type: 'tool_call_update'; toolId: string; partialInput: string }\n  | { type: 'tool_call_complete'; index: number }\n  | { type: 'task_update'; message: AssistantMessagePayload }\n  | { type: 'task_complete'; result: string; costUsd: number; durationMs: number; numTurns: number; usage: UsageData; sessionId: string; permissionDenials?: Array<{ toolName: string; toolUseId: string }> }\n  | { type: 'error'; message: string; isError: boolean; sessionId?: string }\n  | { type: 'session_dead'; exitCode: number | null; signal: string | null; stderrTail: string[] }\n  | { type: 'rate_limit'; status: string; resetsAt: number; rateLimitType: string }\n  | { type: 'usage'; usage: UsageData }\n  | { type: 'permission_request'; questionId: string; toolName: string; toolDescription?: string; toolInput?: Record<string, unknown>; options: Array<{ id: string; label: string; kind?: string }> }\n\n// ─── Run Options ───\n\nexport interface RunOptions {\n  prompt: string\n  projectPath: string\n  sessionId?: string\n  allowedTools?: string[]\n  maxTurns?: number\n  maxBudgetUsd?: number\n  systemPrompt?: string\n  model?: string\n  /** Path to CLUI-scoped settings file with hook config (passed via --settings) */\n  hookSettingsPath?: string\n  /** Extra directories to add via --add-dir (session-preserving) */\n  addDirs?: string[]\n}\n\n// ─── Control Plane Types ───\n\nexport interface TabRegistryEntry {\n  tabId: string\n  claudeSessionId: string | null\n  status: TabStatus\n  activeRequestId: string | null\n  runPid: number | null\n  createdAt: number\n  lastActivityAt: number\n  promptCount: number\n}\n\nexport interface HealthReport {\n  tabs: Array<{\n    tabId: string\n    status: TabStatus\n    activeRequestId: string | null\n    claudeSessionId: string | null\n    alive: boolean\n  }>\n  queueDepth: number\n}\n\nexport interface EnrichedError {\n  message: string\n  stderrTail: string[]\n  stdoutTail?: string[]\n  exitCode: number | null\n  elapsedMs: number\n  toolCallCount: number\n  sawPermissionRequest?: boolean\n  permissionDenials?: Array<{ tool_name: string; tool_use_id: string }>\n}\n\n// ─── Session History ───\n\nexport interface SessionMeta {\n  sessionId: string\n  slug: string | null\n  firstMessage: string | null\n  lastTimestamp: string\n  size: number\n}\n\nexport interface SessionLoadMessage {\n  role: string\n  content: string\n  toolName?: string\n  timestamp: number\n}\n\n// ─── Marketplace / Plugin Types ───\n\nexport type PluginStatus = 'not_installed' | 'checking' | 'installing' | 'installed' | 'failed'\n\nexport interface CatalogPlugin {\n  id: string              // unique: `${repo}/${skillPath}` e.g. 'anthropics/skills/skills/xlsx'\n  name: string            // from SKILL.md or plugin.json\n  description: string     // from SKILL.md or plugin.json\n  version: string         // from plugin.json or '0.0.0'\n  author: string          // from plugin.json or marketplace entry\n  marketplace: string     // marketplace name from marketplace.json\n  repo: string            // 'anthropics/skills'\n  sourcePath: string      // path within repo, e.g. 'skills/xlsx'\n  installName: string     // individual skill name for SKILL.md skills, bundle name for CLI plugins\n  category: string        // 'Agent Skills' | 'Knowledge Work' | 'Financial Services'\n  tags: string[]          // Semantic use-case tags derived from name/description (e.g. 'Design', 'Finance')\n  isSkillMd: boolean      // true = individual SKILL.md (direct install), false = CLI plugin (bundle install)\n}\n\n// ─── IPC Channel Names ───\n\nexport const IPC = {\n  // Request-response (renderer → main)\n  START: 'clui:start',\n  CREATE_TAB: 'clui:create-tab',\n  PROMPT: 'clui:prompt',\n  CANCEL: 'clui:cancel',\n  STOP_TAB: 'clui:stop-tab',\n  RETRY: 'clui:retry',\n  STATUS: 'clui:status',\n  TAB_HEALTH: 'clui:tab-health',\n  CLOSE_TAB: 'clui:close-tab',\n  SELECT_DIRECTORY: 'clui:select-directory',\n  OPEN_EXTERNAL: 'clui:open-external',\n  OPEN_IN_TERMINAL: 'clui:open-in-terminal',\n  ATTACH_FILES: 'clui:attach-files',\n  TAKE_SCREENSHOT: 'clui:take-screenshot',\n  TRANSCRIBE_AUDIO: 'clui:transcribe-audio',\n  PASTE_IMAGE: 'clui:paste-image',\n  GET_DIAGNOSTICS: 'clui:get-diagnostics',\n  RESPOND_PERMISSION: 'clui:respond-permission',\n  INIT_SESSION: 'clui:init-session',\n  RESET_TAB_SESSION: 'clui:reset-tab-session',\n  ANIMATE_HEIGHT: 'clui:animate-height',\n  LIST_SESSIONS: 'clui:list-sessions',\n  LOAD_SESSION: 'clui:load-session',\n\n  // One-way events (main → renderer)\n  TEXT_CHUNK: 'clui:text-chunk',\n  TOOL_CALL: 'clui:tool-call',\n  TOOL_CALL_UPDATE: 'clui:tool-call-update',\n  TOOL_CALL_COMPLETE: 'clui:tool-call-complete',\n  TASK_UPDATE: 'clui:task-update',\n  TASK_COMPLETE: 'clui:task-complete',\n  SESSION_DEAD: 'clui:session-dead',\n  SESSION_INIT: 'clui:session-init',\n  ERROR: 'clui:error',\n  RATE_LIMIT: 'clui:rate-limit',\n\n  // Window management\n  RESIZE_HEIGHT: 'clui:resize-height',\n  SET_WINDOW_WIDTH: 'clui:set-window-width',\n  HIDE_WINDOW: 'clui:hide-window',\n  WINDOW_SHOWN: 'clui:window-shown',\n  SET_IGNORE_MOUSE_EVENTS: 'clui:set-ignore-mouse-events',\n  START_WINDOW_DRAG: 'clui:start-window-drag',\n  RESET_WINDOW_POSITION: 'clui:reset-window-position',\n  IS_VISIBLE: 'clui:is-visible',\n\n  // Skill provisioning (main → renderer)\n  SKILL_STATUS: 'clui:skill-status',\n\n  // Theme\n  GET_THEME: 'clui:get-theme',\n  THEME_CHANGED: 'clui:theme-changed',\n\n  // Marketplace\n  MARKETPLACE_FETCH: 'clui:marketplace-fetch',\n  MARKETPLACE_INSTALLED: 'clui:marketplace-installed',\n  MARKETPLACE_INSTALL: 'clui:marketplace-install',\n  MARKETPLACE_UNINSTALL: 'clui:marketplace-uninstall',\n\n  // Permission mode\n  SET_PERMISSION_MODE: 'clui:set-permission-mode',\n\n  // Legacy (kept for backward compat during migration)\n  STREAM_EVENT: 'clui:stream-event',\n  RUN_COMPLETE: 'clui:run-complete',\n  RUN_ERROR: 'clui:run-error',\n} as const\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"declaration\": true,\n    \"jsx\": \"react-jsx\"\n  },\n  \"include\": [\"src/**/*\"],\n  \"exclude\": [\"node_modules\", \"dist\"]\n}\n"
  }
]