Repository: lcoutodemos/clui-cc Branch: main Commit: 58d18bb7a58f Files: 60 Total size: 463.4 KB Directory structure: gitextract_jg4oi3wy/ ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── commands/ │ ├── install-app.command │ ├── setup.command │ ├── start.command │ └── stop.command ├── docs/ │ ├── AGENTS.md │ ├── ARCHITECTURE.md │ ├── TROUBLESHOOTING.md │ ├── oss-readiness-report.md │ ├── release-smoke-test.md │ └── slash-command-matrix.md ├── electron.vite.config.ts ├── install-app.command ├── package.json ├── resources/ │ ├── entitlements.mac.plist │ └── icon.icns ├── scripts/ │ ├── doctor.sh │ └── patch-dev-icon.sh ├── src/ │ ├── main/ │ │ ├── claude/ │ │ │ ├── control-plane.ts │ │ │ ├── event-normalizer.ts │ │ │ ├── pty-run-manager.ts │ │ │ └── run-manager.ts │ │ ├── cli-env.ts │ │ ├── hooks/ │ │ │ └── permission-server.ts │ │ ├── index.ts │ │ ├── logger.ts │ │ ├── marketplace/ │ │ │ └── catalog.ts │ │ ├── process-manager.ts │ │ ├── skills/ │ │ │ ├── installer.ts │ │ │ └── manifest.ts │ │ └── stream-parser.ts │ ├── preload/ │ │ └── index.ts │ ├── renderer/ │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── AttachmentChips.tsx │ │ │ ├── ConversationView.tsx │ │ │ ├── HistoryPicker.tsx │ │ │ ├── InputBar.tsx │ │ │ ├── MarketplacePanel.tsx │ │ │ ├── PermissionCard.tsx │ │ │ ├── PermissionDeniedCard.tsx │ │ │ ├── PopoverLayer.tsx │ │ │ ├── SettingsPopover.tsx │ │ │ ├── SlashCommandMenu.tsx │ │ │ ├── StatusBar.tsx │ │ │ └── TabStrip.tsx │ │ ├── env.d.ts │ │ ├── hooks/ │ │ │ ├── useClaudeEvents.ts │ │ │ └── useHealthReconciliation.ts │ │ ├── index.css │ │ ├── index.html │ │ ├── main.tsx │ │ ├── stores/ │ │ │ └── sessionStore.ts │ │ └── theme.ts │ └── shared/ │ └── types.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Build output node_modules/ dist/ out/ build/ release/ *.tsbuildinfo # OS artifacts .DS_Store Thumbs.db Desktop.ini # Editor / tool local state .cursor/ .vscode/ .idea/ *.swp *.swo *~ # Claude Code project-scoped local settings .claude/settings.local.json # Environment (not needed for core flow, but excluded defensively) .env .env.* # Logs *.log ~/.clui-debug.log # Runtime .clui.pid # Temporary files *.tmp *.bak ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We 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. ## Our Standards Examples of behavior that contributes to a positive environment: - Using welcoming and inclusive language - Being respectful of differing viewpoints and experiences - Gracefully accepting constructive criticism - Focusing on what is best for the community - Showing empathy towards other community members Examples of unacceptable behavior: - The use of sexualized language or imagery and unwelcome sexual attention or advances - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information without explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Instances 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. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Clui CC Thanks for your interest in contributing! Clui CC is a desktop overlay for Claude Code, and we welcome bug reports, feature ideas, and pull requests. ## Getting Started 1. Make sure you have the [prerequisites](README.md#prerequisites) installed (macOS, Xcode CLT, Node.js 18+, Claude Code CLI 2.1+) 2. Fork and clone the repo: ```bash git clone https://github.com//clui-cc.git cd clui-cc ``` 3. Check your environment (optional but recommended): ```bash npm run doctor ``` 4. Install dependencies: ```bash npm install ``` > If `npm install` fails, run `npm run doctor` to see which dependency is missing. 5. Start the dev server: ```bash npm run dev ``` 6. Make your changes in `src/` 7. Verify your changes build cleanly: ```bash npm run build ``` ## Development Tips - **Main process** changes (`src/main/`) require a full restart (`Ctrl+C` then `npm run dev`). - **Renderer** changes (`src/renderer/`) hot-reload automatically. - Set `CLUI_DEBUG=1` to enable verbose main-process logging to `~/.clui-debug.log`. - The app creates a transparent, click-through window. Use `⌥ + Space` to toggle visibility (fallback: `Cmd+Shift+K`). ## Code Style - TypeScript strict mode is enforced. - Use `useColors()` hook for all color references — never hardcode color values. - Zustand selectors should be narrow and use custom equality functions for performance. - Prefer editing existing files over creating new ones. ## Pull Requests 1. Create a feature branch from `main`. 2. Keep PRs focused — one concern per PR. 3. Include a brief description of what changed and why. 4. Ensure `npm run build` passes with zero errors. ## Reporting Bugs Open an issue with: - macOS version - Node.js version (`node --version`) - Claude Code CLI version (`claude --version`) - Steps to reproduce - Expected vs. actual behavior ## Security If you discover a security vulnerability, please report it privately. See [SECURITY.md](SECURITY.md). ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025-2026 Lucas Couto Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Clui CC — Command Line User Interface for Claude Code A 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. ## Demo [![Watch the demo](https://img.youtube.com/vi/NqRBIpaA4Fk/maxresdefault.jpg)](https://www.youtube.com/watch?v=NqRBIpaA4Fk)

▶ Watch the full demo on YouTube

## Features - **Floating overlay** — transparent, click-through window that stays on top. Toggle with `⌥ + Space` (fallback: `Cmd+Shift+K`). - **Multi-tab sessions** — each tab spawns its own `claude -p` process with independent session state. - **Permission approval UI** — intercepts tool calls via PreToolUse HTTP hooks so you can review and approve/deny from the UI. - **Conversation history** — browse and resume past Claude Code sessions. - **Skills marketplace** — install plugins from Anthropic's GitHub repos without leaving Clui CC. - **Voice input** — local speech-to-text via Whisper (required, installed automatically). - **File & screenshot attachments** — paste images or attach files directly. - **Dual theme** — dark/light mode with system-follow option. ## Why Clui CC - **Claude Code, but visual** — keep CLI power while getting a fast desktop UX for approvals, history, and multitasking. - **Human-in-the-loop safety** — tool calls are reviewed and approved in-app before execution. - **Session-native workflow** — each tab runs an independent Claude session you can resume later. - **Local-first** — everything runs through your local Claude CLI. No telemetry, no cloud dependency. ## How It Works ``` UI prompt → Main process spawns claude -p → NDJSON stream → live render → tool call? → permission UI → approve/deny ``` See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full deep-dive. ## Install App (Recommended) The 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. **1) Clone the repo** ```bash git clone https://github.com/lcoutodemos/clui-cc.git ``` **2) Double-click `install-app.command`** Open the `clui-cc` folder in Finder and double-click `install-app.command`. > **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. > **Folder cleanup:** the installer removes temporary `dist/` and `release/` folders after a successful install to keep the repo tidy.

Press Option + Space to show or hide Clui CC

After the initial install, just open **Clui CC** from your Applications folder or Spotlight.
Terminal / Developer Commands Only `install-app.command` is kept at root intentionally for non-technical users. Developer scripts live in `commands/`. ### Quick Start (Terminal) ```bash git clone https://github.com/lcoutodemos/clui-cc.git ``` ```bash cd clui-cc ``` ```bash ./commands/setup.command ``` ```bash ./commands/start.command ``` > Press **⌥ + Space** to show/hide the overlay. If your macOS input source claims that combo, use **Cmd+Shift+K**. To stop: ```bash ./commands/stop.command ``` ### Developer Workflow ```bash npm install ``` ```bash npm run dev ``` Renderer changes update instantly. Main-process changes require restarting `npm run dev`. ### Other Commands | Command | Purpose | |---------|---------| | `./commands/setup.command` | Environment check + install dependencies | | `./commands/start.command` | Build and launch from source | | `./commands/stop.command` | Stop all Clui CC processes | | `npm run build` | Production build (no packaging) | | `npm run dist` | Package as macOS `.app` into `release/` | | `npm run doctor` | Run environment diagnostic |
Setup Prerequisites (Detailed) You need **macOS 13+**. Then install these one at a time — copy each command and paste it into Terminal. **Step 1.** Install Xcode Command Line Tools (needed to compile native modules): ```bash xcode-select --install ``` **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: ```bash brew install node ``` Verify it's on your PATH: ```bash node --version ``` **Step 3.** Make sure Python has `setuptools` (needed by the native module compiler). On Python 3.12+ this is missing by default: ```bash python3 -m pip install --upgrade pip setuptools ``` **Step 4.** Install Claude Code CLI: ```bash npm install -g @anthropic-ai/claude-code ``` **Step 5.** Authenticate Claude Code (follow the prompts that appear): ```bash claude ``` **Step 6.** Install Whisper for voice input: ```bash # Apple Silicon (M1/M2/M3/M4) — preferred: brew install whisperkit-cli # Apple Silicon fallback, or Intel Mac: brew install whisper-cpp ``` > **No API keys or `.env` file required.** Clui CC uses your existing Claude Code CLI authentication (Pro/Team/Enterprise subscription).
Architecture and Internals ### Project Structure ``` src/ ├── main/ # Electron main process │ ├── claude/ # ControlPlane, RunManager, EventNormalizer │ ├── hooks/ # PermissionServer (PreToolUse HTTP hooks) │ ├── marketplace/ # Plugin catalog fetching + install │ ├── skills/ # Skill auto-installer │ └── index.ts # Window creation, IPC handlers, tray ├── renderer/ # React frontend │ ├── components/ # TabStrip, ConversationView, InputBar, etc. │ ├── stores/ # Zustand session store │ ├── hooks/ # Event listeners, health reconciliation │ └── theme.ts # Dual palette + CSS custom properties ├── preload/ # Secure IPC bridge (window.clui API) └── shared/ # Canonical types, IPC channel definitions ``` ### How It Works 1. Each tab creates a `claude -p --output-format stream-json` subprocess. 2. NDJSON events are parsed by `RunManager` and normalized by `EventNormalizer`. 3. `ControlPlane` manages tab lifecycle (connecting → idle → running → completed/failed/dead). 4. Tool permission requests arrive via HTTP hooks to `PermissionServer` (localhost only). 5. The renderer polls backend health every 1.5s and reconciles tab state. 6. Sessions are resumed with `--resume ` for continuity. ### Network Behavior Clui CC operates almost entirely offline. The only outbound network calls are: | Endpoint | Purpose | Required | |----------|---------|----------| | `raw.githubusercontent.com/anthropics/*` | Marketplace catalog (cached 5 min) | No — graceful fallback | | `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install on startup | No — skipped on failure | No telemetry, analytics, or auto-update mechanisms. All core Claude Code interaction goes through the local CLI.
## Troubleshooting For setup issues and recovery commands, see [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md). Quick self-check: ```bash npm run doctor ``` ## Tested On | Component | Version | |-----------|---------| | macOS | 15.x (Sequoia) | | Node.js | 20.x LTS, 22.x | | Python | 3.12 (with setuptools installed) | | Electron | 33.x | | Claude Code CLI | 2.1.71 | ## Known Limitations - **macOS only** — transparent overlay, tray icon, and node-pty are macOS-specific. Windows/Linux support is not currently implemented. - **Requires Claude Code CLI** — Clui CC is a UI layer, not a standalone AI client. You need an authenticated `claude` CLI. - **Permission mode** — uses `--permission-mode default`. The PTY interactive transport is legacy and disabled by default. ## License [MIT](LICENSE) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability If you discover a security vulnerability in CLUI, please report it responsibly: 1. **Do not** open a public GitHub issue. 2. Email the maintainer directly or use GitHub's private vulnerability reporting feature. 3. Include a description of the vulnerability, steps to reproduce, and potential impact. We will acknowledge receipt within 48 hours and aim to provide a fix or mitigation within 7 days for critical issues. ## Security Architecture CLUI runs entirely on your local machine. Key security properties: - **No cloud backend** — all Claude Code interaction goes through the local `claude` CLI. - **No telemetry or analytics** — zero outbound data collection. - **Permission hook server** binds to `127.0.0.1:19836` only (not exposed to the network). - **Per-launch secrets** — the hook server uses a random UUID as app secret, regenerated on every launch. - **Sensitive field masking** — tool inputs containing tokens, passwords, keys, or credentials are masked before display in the renderer. - **CLAUDECODE env var** is explicitly removed from all spawned subprocesses to prevent credential leakage. - **Preload isolation** — the renderer has no direct access to Node.js APIs; all IPC goes through a typed `window.clui` bridge. ## Network Surface | Endpoint | Direction | Purpose | |----------|-----------|---------| | `127.0.0.1:19836` | Local only | Permission hook server (PreToolUse) | | `raw.githubusercontent.com` | Outbound | Marketplace catalog fetch (optional) | | `api.github.com` | Outbound | Skill tarball download (optional, pinned SHA) | No other network connections are made by CLUI itself. The `claude` CLI may make its own connections as part of normal operation. ## Supported Versions | Version | Supported | |---------|-----------| | 0.1.x | Yes | ================================================ FILE: commands/install-app.command ================================================ #!/bin/bash # ────────────────────────────────────────────────────── # Clui CC — Install App # # Double-click this file in Finder to: # 1. Set up dependencies # 2. Install voice support (Whisper) # 3. Build a standalone macOS app # 4. Copy it to /Applications # 5. Clean temporary build files # 6. Launch it # ────────────────────────────────────────────────────── set -e # Resolve to repo root (one level up from commands/) cd "$(dirname "$0")/.." APP_NAME="Clui CC" DEST="/Applications/${APP_NAME}.app" step() { echo; echo "═══ $1 ═══"; echo; } # ── 1. Setup ── step "Step 1/6 — Setting up environment and dependencies" if ! bash ./commands/setup.command; then echo echo "Setup failed. Fix the issues above, then double-click this file again." echo exit 1 fi # ── 2. Whisper (required for voice input) ── step "Step 2/6 — Checking voice support (Whisper)" if command -v whisperkit-cli &>/dev/null || command -v whisper-cli &>/dev/null || command -v whisper &>/dev/null; then echo "Whisper is already installed." else echo "Whisper is not installed. Voice input requires it." echo if ! command -v brew &>/dev/null; then echo "Homebrew is required to install Whisper but was not found." echo echo " Install Homebrew first:" echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" echo echo " Then double-click this file again." echo exit 1 fi ARCH="$(uname -m)" INSTALLED="" if [ "$ARCH" = "arm64" ]; then # Apple Silicon: prefer whisperkit-cli, fall back to whisper-cpp echo "Installing Whisper via Homebrew (whisperkit-cli for $ARCH)..." echo if brew install whisperkit-cli; then INSTALLED="whisperkit-cli" else echo echo "whisperkit-cli failed — falling back to whisper-cpp..." echo if brew install whisper-cpp; then INSTALLED="whisper-cpp" fi fi else # Intel: whisper-cpp only (whisperkit-cli requires arm64) echo "Installing Whisper via Homebrew (whisper-cpp for $ARCH)..." echo if brew install whisper-cpp; then INSTALLED="whisper-cpp" fi fi if [ -z "$INSTALLED" ]; then echo echo "Whisper installation failed." echo echo " Try running manually:" if [ "$ARCH" = "arm64" ]; then echo " brew install whisperkit-cli" echo " or:" echo " brew install whisper-cpp" else echo " brew install whisper-cpp" fi echo echo " Then double-click this file again." echo exit 1 fi # Verify — check for the executable that the installed formula provides if [ "$INSTALLED" = "whisperkit-cli" ]; then VERIFY_BIN="whisperkit-cli" else VERIFY_BIN="whisper-cli" fi if ! command -v "$VERIFY_BIN" &>/dev/null; then echo echo "Whisper was installed but the command is not available." echo echo " Try opening a new Terminal window and running:" echo " $VERIFY_BIN --help" echo echo " If that works, double-click this file again." echo exit 1 fi echo "Whisper installed successfully ($INSTALLED)." fi # ── 3. Build ── step "Step 3/6 — Building ${APP_NAME}.app" if ! npm run dist; then echo echo "Build failed." echo echo " Try these steps one at a time:" echo " rm -rf node_modules" echo " npm install" echo " npm run dist" echo echo " If it still fails, see docs/TROUBLESHOOTING.md" echo exit 1 fi # ── 4. Detect and copy ── step "Step 4/6 — Installing to /Applications" APP_SOURCE="" if [ -d "release/mac-arm64/${APP_NAME}.app" ]; then APP_SOURCE="release/mac-arm64/${APP_NAME}.app" elif [ -d "release/mac/${APP_NAME}.app" ]; then APP_SOURCE="release/mac/${APP_NAME}.app" fi if [ -z "$APP_SOURCE" ]; then echo "Could not find the built app." echo echo " Expected one of:" echo " release/mac-arm64/${APP_NAME}.app (Apple Silicon)" echo " release/mac/${APP_NAME}.app (Intel)" echo echo " Check what was built:" echo " ls release/" echo exit 1 fi echo "Found: $APP_SOURCE" if [ -d "$DEST" ]; then echo "Replacing existing ${APP_NAME} in /Applications..." rm -rf "$DEST" fi cp -R "$APP_SOURCE" "$DEST" echo "Copied to $DEST" # ── 5. Cleanup ── step "Step 5/6 — Cleaning temporary build files" if [ "${KEEP_BUILD_ARTIFACTS:-0}" = "1" ]; then echo "Keeping build artifacts (KEEP_BUILD_ARTIFACTS=1)." else rm -rf ./dist ./release echo "Removed: dist/ and release/" fi # ── 6. Launch ── step "Step 6/6 — Launching ${APP_NAME}" open "$DEST" echo "Done! ${APP_NAME} is running." echo echo " Show/hide the overlay: ⌥ + Space (Option + Space)" echo " Quit: Click the menu bar icon > Quit" echo echo " First launch: if macOS shows a security warning, go to" echo " System Settings > Privacy & Security > Open Anyway" echo " You only need to do this once." echo ================================================ FILE: commands/setup.command ================================================ #!/bin/bash set -e # Resolve to repo root (one level up from commands/) cd "$(dirname "$0")/.." # ── Helpers ── fail=0 SDK_PATH="" step() { echo; echo "--- $1"; } pass() { echo " OK: $1"; } fail() { echo " FAIL: $1"; fail=1; } fix() { echo echo " To fix, copy and run this command:" echo echo " $1" echo } version_gte() { [ "$(printf '%s\n%s' "$1" "$2" | sort -V | head -1)" = "$2" ] } # ── Preflight Checks ── step "Checking environment" # macOS if [ "$(uname)" != "Darwin" ]; then fail "Clui CC requires macOS 13+. Detected: $(uname). This project does not run on Linux or Windows." else macos_ver=$(sw_vers -productVersion 2>/dev/null || echo "0") if version_gte "$macos_ver" "13.0"; then pass "macOS $macos_ver" else fail "macOS $macos_ver is too old. Clui CC requires macOS 13+." echo " Update macOS in System Settings > General > Software Update." fi fi # Node if command -v node &>/dev/null; then node_ver=$(node --version | sed 's/^v//') if version_gte "$node_ver" "18.0.0"; then pass "Node.js v$node_ver" else fail "Node.js v$node_ver is too old. Clui CC requires Node 18+." fix "brew install node" fi else fail "Node.js is not installed." fix "brew install node" fi # npm if command -v npm &>/dev/null; then pass "npm $(npm --version)" else fail "npm is not installed (should come with Node.js)." fix "brew install node" fi # Python 3 + distutils if command -v python3 &>/dev/null; then pass "Python $(python3 --version 2>&1 | awk '{print $2}')" if python3 -c "import distutils" 2>/dev/null; then pass "Python distutils available" else fail "Python is missing 'distutils' (needed by native module compiler)." fix "python3 -m pip install --upgrade pip setuptools" fi else fail "Python 3 is not installed." fix "brew install python@3.11" fi # Xcode CLT if xcode-select -p &>/dev/null; then pass "Xcode CLT at $(xcode-select -p)" else fail "Xcode Command Line Tools are not installed." fix "xcode-select --install" fi # macOS SDK if xcrun --sdk macosx --show-sdk-path &>/dev/null; then SDK_PATH=$(xcrun --sdk macosx --show-sdk-path) pass "macOS SDK at $SDK_PATH" else fail "macOS SDK not found. Xcode Command Line Tools may be broken." echo echo " Try: xcode-select --install" echo " If that doesn't help:" echo " sudo rm -rf /Library/Developer/CommandLineTools" echo " xcode-select --install" echo fi # C++ compiler + headers if command -v clang++ &>/dev/null; then pass "clang++ available" PROBE_DIR=$(mktemp -d) echo '#include ' > "$PROBE_DIR/probe.cpp" echo 'int main() { return 0; }' >> "$PROBE_DIR/probe.cpp" if clang++ -std=c++17 -c "$PROBE_DIR/probe.cpp" -o "$PROBE_DIR/probe.o" 2>/dev/null; then pass "C++ standard headers OK" 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 pass "C++ standard headers OK (using SDK include path)" else fail "C++ headers are broken ( not found)." echo echo " Try: xcode-select --install" echo " If that doesn't help:" echo " sudo rm -rf /Library/Developer/CommandLineTools" echo " xcode-select --install" echo fi rm -rf "$PROBE_DIR" else fail "clang++ not found. Xcode Command Line Tools may be broken." fix "xcode-select --install" fi # Claude CLI if command -v claude &>/dev/null; then pass "Claude Code CLI found" else fail "Claude Code CLI is not installed." fix "npm install -g @anthropic-ai/claude-code" fi # Bail if any check failed if [ "$fail" -ne 0 ]; then echo echo "Some checks failed. Fix them above, then rerun:" echo echo " ./commands/setup.command" echo exit 1 fi echo echo "All checks passed." # ── Install ── step "Installing dependencies" if [ -n "$SDK_PATH" ]; then export SDKROOT="$SDK_PATH" export CXXFLAGS="-isysroot $SDKROOT -I$SDKROOT/usr/include/c++/v1 ${CXXFLAGS:-}" fi if ! npm install; then echo echo "npm install failed. Most common fixes:" echo echo " 1. xcode-select --install" echo " 2. python3 -m pip install --upgrade pip setuptools" echo " 3. Rerun: ./commands/setup.command" echo exit 1 fi # Guard against stale lockfiles/dependency trees that keep vulnerable versions. installed_builder=$(node -p "require('./node_modules/electron-builder/package.json').version" 2>/dev/null || echo "") installed_electron=$(node -p "require('./node_modules/electron/package.json').version" 2>/dev/null || echo "") if [ -z "$installed_builder" ] || [ -z "$installed_electron" ]; then echo echo "Could not verify installed Electron dependencies." echo "Try:" echo " rm -rf node_modules package-lock.json" echo " npm install" echo " ./commands/setup.command" echo exit 1 fi if ! version_gte "$installed_builder" "26.8.1" || ! version_gte "$installed_electron" "35.7.5"; then echo echo "Detected outdated install (electron-builder $installed_builder, electron $installed_electron)." echo "Applying required security baseline..." echo npm install -D electron-builder@^26.8.1 electron@^35.7.5 fi final_builder=$(node -p "require('./node_modules/electron-builder/package.json').version" 2>/dev/null || echo "") final_electron=$(node -p "require('./node_modules/electron/package.json').version" 2>/dev/null || echo "") echo "Installed: electron-builder $final_builder, electron $final_electron" echo echo "Setup complete. To launch the app, run:" echo echo " ./commands/start.command" echo ================================================ FILE: commands/start.command ================================================ #!/bin/bash set -e # Resolve to repo root (one level up from commands/) cd "$(dirname "$0")/.." if [ ! -d "node_modules" ]; then echo "Dependencies not installed." echo echo " If this is your first time, run:" echo " ./commands/setup.command" echo echo " Or install manually:" echo " npm install" echo exit 1 fi # Clean stale PID file PID_FILE=".clui.pid" if [ -f "$PID_FILE" ]; then old_pid=$(cat "$PID_FILE" 2>/dev/null) if [ -n "$old_pid" ] && ! kill -0 "$old_pid" 2>/dev/null; then rm -f "$PID_FILE" fi fi echo "Building Clui CC..." if ! npx electron-vite build --mode production; then echo echo "Build failed. Try: rm -rf node_modules && npm install" exit 1 fi echo "Clui CC running. ⌥ + Space to toggle. Use ./commands/stop.command or tray icon > Quit to close." # Launch in a new process group and record the PID npx electron . & APP_PID=$! echo "$APP_PID" > "$PID_FILE" # Clean up PID file when the app exits wait "$APP_PID" 2>/dev/null rm -f "$PID_FILE" ================================================ FILE: commands/stop.command ================================================ #!/bin/bash # Resolve to repo root (one level up from commands/) cd "$(dirname "$0")/.." REPO_DIR="$(pwd)" PID_FILE=".clui.pid" stopped=0 # ── 1. Try tracked PID first ── if [ -f "$PID_FILE" ]; then APP_PID=$(cat "$PID_FILE" 2>/dev/null) if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then # Kill the process group (app + all child helpers) kill -TERM -"$APP_PID" 2>/dev/null || kill -TERM "$APP_PID" 2>/dev/null # Wait up to 3 seconds for graceful shutdown for i in 1 2 3; do kill -0 "$APP_PID" 2>/dev/null || break sleep 1 done # Force kill if still alive if kill -0 "$APP_PID" 2>/dev/null; then kill -KILL -"$APP_PID" 2>/dev/null || kill -KILL "$APP_PID" 2>/dev/null sleep 0.5 fi stopped=1 fi rm -f "$PID_FILE" fi # ── 2. Fallback: pattern-based kill for anything missed ── leftover_pids=$(pgrep -f "$REPO_DIR/node_modules/electron" 2>/dev/null || true) leftover_pids="$leftover_pids $(pgrep -f "$REPO_DIR/dist/main" 2>/dev/null || true)" leftover_pids=$(echo "$leftover_pids" | xargs) if [ -n "$leftover_pids" ]; then # Graceful first kill -TERM $leftover_pids 2>/dev/null sleep 2 # Force kill survivors for pid in $leftover_pids; do if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null fi done stopped=1 fi # ── 3. Verify ── sleep 0.5 remaining=$(pgrep -f "$REPO_DIR/node_modules/electron" 2>/dev/null || true) remaining="$remaining $(pgrep -f "$REPO_DIR/dist/main" 2>/dev/null || true)" remaining=$(echo "$remaining" | xargs) if [ -n "$remaining" ]; then echo "Warning: some processes could not be stopped:" echo " PIDs: $remaining" echo echo " To force kill manually:" echo " kill -9 $remaining" else if [ "$stopped" -eq 1 ]; then echo "Clui CC stopped." else echo "Clui CC was not running." fi fi ================================================ FILE: docs/AGENTS.md ================================================ # Agent Guide — Clui CC > This file is optimized for AI coding agents (Claude Code, Cursor, Copilot, etc.). > For human-readable docs see [ARCHITECTURE.md](ARCHITECTURE.md) and [CONTRIBUTING.md](../CONTRIBUTING.md). ## What This Project Is Clui 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. ## Quick Reference | Action | Command | |--------|---------| | Install deps | `npm install` | | Dev mode (hot-reload) | `npm run dev` | | Type-check / build | `npm run build` | | Toggle overlay | `⌥ + Space` (fallback: `Cmd+Shift+K`) | | Debug logging | `CLUI_DEBUG=1 npm run dev` (writes to `~/.clui-debug.log`) | **Main process changes require full restart.** Renderer changes hot-reload. ## Architecture (3-Layer) ``` Renderer (React 19 + Zustand 5 + Tailwind CSS 4) ↕ contextBridge IPC (src/preload/index.ts) Main Process (Node.js / Electron 33) ↕ spawns subprocess Claude Code CLI (claude -p --output-format stream-json) ``` ### Layer Responsibilities | Layer | Directory | Manages | |-------|-----------|---------| | **Renderer** | `src/renderer/` | UI state, theming, user input, message display | | **Preload** | `src/preload/` | Typed IPC bridge (`window.clui` API). Security boundary. | | **Main** | `src/main/` | Process lifecycle, tab state machine, permission server, marketplace | ### Key Files by Concern | Concern | File(s) | |---------|---------| | Tab lifecycle & state machine | `src/main/claude/control-plane.ts` | | Spawning Claude CLI processes | `src/main/claude/run-manager.ts` | | Raw NDJSON → canonical events | `src/main/claude/event-normalizer.ts` | | Permission hook server | `src/main/hooks/permission-server.ts` | | All TypeScript types & IPC channels | `src/shared/types.ts` | | Zustand state store | `src/renderer/stores/sessionStore.ts` | | Theme / color system | `src/renderer/theme.ts` | | Main window & IPC handler setup | `src/main/index.ts` | | Marketplace catalog | `src/main/marketplace/catalog.ts` | | Skill installer | `src/main/skills/installer.ts` | ## Data Flow: Prompt → Response ``` InputBar.tsx → window.clui.prompt(tabId, requestId, opts) → ipcRenderer.invoke('clui:prompt') → ControlPlane.prompt() → RunManager spawns: claude -p --output-format stream-json --resume → stdout emits NDJSON lines → EventNormalizer → NormalizedEvent → ControlPlane broadcasts via IPC → useClaudeEvents hook → sessionStore.handleNormalizedEvent() → React re-renders ``` ## Canonical Types All IPC and event types live in `src/shared/types.ts`. Key types: - **`NormalizedEvent`** — union of all events the main process emits to the renderer - **`TabState`** — full state of a single tab (status, messages, permissions, session metadata) - **`TabStatus`** — state machine: `connecting → idle → running → completed/failed/dead` - **`IPC`** — const object with all IPC channel names (use these, never raw strings) - **`RunOptions`** — options passed when spawning a Claude CLI run - **`CatalogPlugin`** — marketplace plugin metadata ## Conventions & Rules ### Must Follow 1. **TypeScript strict mode** — zero errors required (`npm run build` must pass) 2. **Use `IPC.*` constants** for all IPC channel names — never hardcode strings 3. **Use `useColors()` hook** for all color references in renderer — never hardcode colors 4. **Narrow Zustand selectors** with custom equality functions for performance 5. **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` 6. **Tab state transitions** go through `ControlPlane` only — never mutate tab state directly ### Security — Do Not Break - **Permission server** binds to `127.0.0.1` only (never `0.0.0.0`) - **Per-launch app secret** (random UUID) validates hook requests — do not weaken - **Per-run tokens** route permission responses to correct tab — do not bypass - **`CLAUDECODE` env var** is explicitly removed from spawned processes - **Sensitive fields** (tokens, passwords, secrets, keys, auth, credentials) are masked via `maskSensitiveFields()` before display - **5-minute auto-deny timeout** on unanswered permissions — do not remove ### Don't - Don't import main-process modules from renderer (or vice versa) — the preload bridge is the only crossing point - Don't add network calls — the app is designed to be nearly offline (only marketplace fetches from GitHub) - Don't use `node-pty` for new features — it's legacy, prefer `RunManager` (stdio-based) - Don't add Electron `remote` module usage — it's disabled for security ## Adding a New Feature — Checklist ### New IPC channel 1. Add channel name to `IPC` const in `src/shared/types.ts` 2. Add handler in `src/main/index.ts` (`ipcMain.handle` or `ipcMain.on`) 3. Expose via `contextBridge` in `src/preload/index.ts` 4. Call from renderer via `window.clui.*` ### New UI component 1. Create in `src/renderer/components/` 2. Use `useColors()` for all colors 3. Use Phosphor icons (`@phosphor-icons/react`) — not other icon libraries 4. Animations via Framer Motion ### New event type from Claude CLI 1. Add raw type to `ClaudeEvent` union in `src/shared/types.ts` 2. Add normalized form to `NormalizedEvent` union 3. Handle in `EventNormalizer.normalize()` (`src/main/claude/event-normalizer.ts`) 4. Handle in `sessionStore.handleNormalizedEvent()` (`src/renderer/stores/sessionStore.ts`) ### New tab state field 1. Add to `TabState` interface in `src/shared/types.ts` 2. Initialize in `createTab()` in both `ControlPlane` and `sessionStore` 3. Update via `ControlPlane` events — never directly from renderer ## Stack | Layer | Tech | Version | |-------|------|---------| | Desktop | Electron | 33 | | Build | electron-vite | 3 | | UI | React | 19 | | State | Zustand | 5 | | Styling | Tailwind CSS | 4 | | Animation | Framer Motion | 12 | | Icons | Phosphor Icons | 2 | | Markdown | react-markdown + remark-gfm | 9 / 4 | | PTY (legacy) | node-pty | 1.1 | ## Network Surface | Endpoint | Purpose | Required | |----------|---------|----------| | `raw.githubusercontent.com/anthropics/*` | Marketplace catalog (cached 5 min) | No | | `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install | No | | `127.0.0.1:19836` | Permission hook server (local only) | Yes | No telemetry. No analytics. No auto-update. ## Common Pitfalls 1. **Forgetting to restart dev server** after main-process changes — renderer hot-reloads but main does not 2. **Adding raw color values** instead of using `useColors()` — breaks theming 3. **Mutating tab state from renderer** instead of going through ControlPlane events 4. **Hardcoding IPC strings** instead of using `IPC.*` constants 5. **Testing on non-macOS** — this is macOS-only (transparent windows, node-pty bindings) 6. **Not handling the `session_dead` event** — if a Claude process crashes, the tab must transition to `dead` status ================================================ FILE: docs/ARCHITECTURE.md ================================================ # CLUI Architecture ## Overview CLUI 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. ``` ┌──────────────────────────────────────────────────────────────┐ │ Renderer Process │ │ React 19 + Zustand 5 + Tailwind CSS 4 + Framer Motion │ │ │ │ ┌──────────┐ ┌──────────────┐ ┌──────────┐ ┌────────────┐ │ │ │ TabStrip │ │Conversation │ │ InputBar │ │ Marketplace│ │ │ │ │ │ View │ │ │ │ Panel │ │ │ └──────────┘ └──────────────┘ └──────────┘ └────────────┘ │ │ │ │ │ sessionStore (Zustand) │ │ │ │ │ window.clui (preload bridge) │ ├──────────────────────────────────────────────────────────────┤ │ Preload Script │ │ Typed IPC bridge — contextBridge.exposeInMainWorld │ ├──────────────────────────────────────────────────────────────┤ │ Main Process │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ ControlPlane │ │ │ │ Tab registry, session lifecycle, queue management │ │ │ │ │ │ │ │ ┌─────────────┐ ┌──────────────────┐ │ │ │ │ │ RunManager │ │ EventNormalizer │ │ │ │ │ │ Spawns │ │ Raw stream-json │ │ │ │ │ │ claude -p │──│ → canonical │ │ │ │ │ │ per prompt │ │ events │ │ │ │ │ └─────────────┘ └──────────────────┘ │ │ │ └──────────────────────────────────────────────────────┘ │ │ │ │ ┌────────────────────┐ ┌────────────────────────────┐ │ │ │ PermissionServer │ │ Marketplace Catalog │ │ │ │ HTTP hooks on │ │ GitHub raw fetch + cache │ │ │ │ 127.0.0.1:19836 │ │ TTL: 5 minutes │ │ │ └────────────────────┘ └────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ │ │ claude -p (NDJSON) raw.githubusercontent.com (local subprocess) (optional, cached) ``` ## Main Process (`src/main/`) ### ControlPlane (`claude/control-plane.ts`) Single authority for all tab and session lifecycle. Manages: - **Tab registry** — maps tabId → session metadata, status, process PID. - **State machine** — each tab transitions through: `connecting → idle → running → completed → failed → dead`. - **Request routing** — maps requestIds to active RunManager instances. - **Queue + backpressure** — max 32 pending requests, prompts queue behind running tasks. - **Health reconciliation** — responds to renderer polls with tab status + process liveness. - **Session ID tracking** — maps Claude session IDs to tabs for permission routing. ### RunManager (`claude/run-manager.ts`) Spawns one `claude -p --output-format stream-json` process per prompt. Responsibilities: - Constructs CLI arguments (`--resume`, `--permission-mode`, `--settings`, `--add-dir`, etc.) - Reads NDJSON from stdout line-by-line via `StreamParser`. - Passes raw events to `EventNormalizer` for canonicalization. - Maintains stderr ring buffer (100 lines) for error diagnostics. - Cleans up process on cancel, tab close, or unexpected exit. - Removes `CLAUDECODE` from spawned environment to prevent credential leakage. ### EventNormalizer (`claude/event-normalizer.ts`) Maps raw Claude Code stream-json events to canonical `NormalizedEvent` types: | Raw Event | Normalized Event | |-----------|-----------------| | `system` (subtype: init) | `session_init` | | `stream_event` (content_block_delta, text_delta) | `text_chunk` | | `stream_event` (content_block_start, tool_use) | `tool_call` | | `stream_event` (content_block_delta, input_json_delta) | `tool_call_update` | | `stream_event` (content_block_stop) | `tool_call_complete` | | `assistant` | `task_update` | | `result` | `task_complete` | | `rate_limit_event` | `rate_limit` | ### PermissionServer (`hooks/permission-server.ts`) HTTP server that intercepts Claude Code tool calls via PreToolUse hooks: 1. ControlPlane starts PermissionServer on `127.0.0.1:19836`. 2. `generateSettingsFile()` creates a temp JSON file with hook config pointing at the server. 3. RunManager passes `--settings ` to each `claude -p` spawn. 4. When Claude wants to use a tool, the CLI POSTs to the hook URL. 5. PermissionServer emits a `permission-request` event to ControlPlane. 6. ControlPlane routes it to the correct tab via `_findTabBySessionId()`. 7. Renderer shows a `PermissionCard` with Allow/Deny buttons. 8. User decision flows back: IPC → ControlPlane → PermissionServer → HTTP response. 9. Claude Code proceeds or skips the tool based on the response. Security: per-launch app secret, per-run tokens, sensitive field masking, 5-minute auto-deny timeout. ### Marketplace Catalog (`marketplace/catalog.ts`) Fetches plugin metadata from three Anthropic GitHub repos: - `anthropics/skills` (Agent Skills) - `anthropics/knowledge-work-plugins` (Knowledge Work) - `anthropics/financial-services-plugins` (Financial Services) Uses Electron's `net.request()` with a 5-minute TTL cache. Individual fetch failures are isolated — one broken repo doesn't block others. ### Skill Installer (`skills/installer.ts`) Auto-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). ## Preload (`src/preload/`) The preload script uses `contextBridge.exposeInMainWorld` to expose a typed `window.clui` API. This is the only communication surface between renderer and main process. All methods map to `ipcRenderer.invoke()` (request-response) or `ipcRenderer.send()` (fire-and-forget). The full API surface is defined in `CluiAPI` interface. ## Renderer (`src/renderer/`) ### State Management Single Zustand store (`stores/sessionStore.ts`) holds all application state: - Tab list with full `TabState` objects (messages, status, attachments, permissions, etc.) - Active tab selection - Marketplace state (catalog, search, filter, install progress) - UI state (expanded, marketplace open) ### Theme System (`theme.ts`) Dual 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-*)`. Theme mode state machine: `system | light | dark` with separate `_systemIsDark` tracking for OS value. ### Key Components - **TabStrip** — tab bar with new tab, history picker, settings popover. - **ConversationView** — scrollable message timeline with markdown rendering (react-markdown + remark-gfm), tool call cards, permission cards. - **InputBar** — prompt input with attachment chips, voice recording, slash command menu, model picker. - **MarketplacePanel** — plugin browser with search, semantic tag filters, install confirmation. ### Performance Patterns - Narrow Zustand selectors with custom equality functions (field-level comparison) to prevent re-renders during streaming. - RAF-throttled mousemove handler for click-through detection. - Debounced marketplace search (200ms). - Health reconciliation skips setState when no tabs changed. ## IPC Channel Map All 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. ## Data Flow: Prompt → Response ``` User types prompt → InputBar calls window.clui.prompt(tabId, requestId, options) → ipcRenderer.invoke('clui:prompt', ...) → Main: ControlPlane.prompt() → RunManager spawns: claude -p --output-format stream-json --resume → Claude CLI writes NDJSON to stdout → StreamParser emits lines → EventNormalizer maps to NormalizedEvent → ControlPlane updates tab state + broadcasts via IPC → Renderer: useClaudeEvents hook receives events → sessionStore.handleNormalizedEvent() updates messages → React re-renders ConversationView ``` ================================================ FILE: docs/TROUBLESHOOTING.md ================================================ # Troubleshooting If setup fails, run this first: ```bash npm run doctor ``` This checks your local environment and prints pass/fail status without changing your system. ## Install Fails with "gyp" or "make" Errors Install Xcode Command Line Tools, then retry: ```bash xcode-select --install ``` ```bash npm install ``` ## Install Fails with `ModuleNotFoundError: No module named 'distutils'` Python 3.12+ removed `distutils`. Install `setuptools`: ```bash python3 -m pip install --upgrade pip setuptools ``` ```bash npm install ``` If that still fails, install Python 3.11 and point npm to it: ```bash brew install python@3.11 ``` ```bash npm config set python $(brew --prefix python@3.11)/bin/python3.11 ``` ```bash npm install ``` To undo that Python override later: ```bash npm config delete python ``` ## Install Fails with `fatal error: 'functional' file not found` C++ headers are missing/broken, usually due to Xcode CLT issues. Check toolchain first: ```bash xcode-select -p ``` ```bash xcrun --sdk macosx --show-sdk-path ``` If either command fails (or the error persists), reinstall CLT: ```bash sudo rm -rf /Library/Developer/CommandLineTools ``` ```bash xcode-select --install ``` Then retry: ```bash npm install ``` If CLT is installed but the error still appears on newer macOS versions, compile explicitly against the SDK include path: ```bash SDK=$(xcrun --sdk macosx --show-sdk-path) clang++ -std=c++17 -isysroot "$SDK" -I"$SDK/usr/include/c++/v1" -x c++ - -o /dev/null <<'EOF' #include int main() { return 0; } EOF ``` ## Install Fails on `node-pty` `node-pty` is native and requires macOS toolchains. Confirm: - macOS 13+ - Xcode CLT installed - Python 3 with `setuptools`/`distutils` available Then retry `npm install`. ## App Launches but No Claude Response Verify Claude CLI is installed and authenticated: ```bash claude --version ``` ```bash claude ``` ## `⌥ + Space` Does Not Toggle Grant Accessibility permissions: - System Settings -> Privacy & Security -> Accessibility Fallback shortcut: - `Cmd+Shift+K` ## Packaged App Won't Open (Security Warning) The `.app` built by `npm run dist` is unsigned. macOS Gatekeeper blocks unsigned apps by default. To allow it: 1. Open **System Settings → Privacy & Security** 2. Scroll to the security section 3. Click **Open Anyway** next to the Clui CC message You only need to do this once. This is a local build, not App Store distribution. ## Install Fails at Whisper Step The installer requires Whisper for voice input. If it fails: 1. Make sure Homebrew is installed: ```bash brew --version ``` If not, install it: ```bash /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` 2. Install Whisper manually: ```bash # Apple Silicon (M1/M2/M3/M4) — preferred: brew install whisperkit-cli # Apple Silicon fallback, or Intel Mac: brew install whisper-cpp ``` 3. Rerun the installer: ```bash ./install-app.command ``` ## Install Fails at Build Step Run the steps manually to see the detailed error: ```bash ./commands/setup.command ``` ```bash npm run dist ``` If `npm run dist` fails, try a clean reinstall: ```bash rm -rf node_modules ``` ```bash npm install ``` ```bash npm run dist ``` ## Marketplace Shows "Failed to Load" Expected when offline. Marketplace needs internet access; core app features continue to work. ## Window Is Invisible / No UI Try: - `⌥ + Space` - `Cmd+Shift+K` - Confirm app is running from the menu bar tray ================================================ FILE: docs/oss-readiness-report.md ================================================ # CLUI Open-Source Readiness Report **Date:** 2026-03-12 **Branch:** `oss-prep` **Assessor:** Automated scan + manual review --- ## 1. Security ### Secrets & Credentials | Check | Result | Severity | |-------|--------|----------| | Hardcoded API keys/tokens | None found | Safe | | .env files | None exist (not needed — app uses local CLI) | Safe | | CLAUDECODE env var | Explicitly deleted from spawned processes | Safe | | Private key / cert files | None found | Safe | | Database connection strings | None (no DB) | Safe | ### Permission System - HTTP hook server binds **127.0.0.1:19836 only** (not exposed externally) - Per-launch app secret (randomUUID) prevents local spoofing - Per-run tokens for routing - Sensitive fields masked before sending to renderer (`/token|password|secret|key|auth|credential|api.?key/i`) - 5-minute auto-deny timeout for unanswered permission requests **Verdict:** No security blockers. --- ## 2. Privacy ### Hardcoded Paths | Location | Contains User Paths | Action | |----------|-------------------|--------| | `src/**` | No | Safe | | `spike/**` | No | Safe | | `scripts/**` | No (uses `$(dirname "$0")`) | Safe | | `docs/protocol-captures/*.jsonl` | **Yes** — `/Users//...` in session CWD fields | **Must exclude from public repo** | | `docs/claude-permission-probe.md` | **Yes** — references local paths in examples | **Must exclude from public repo** | | `.claude/settings.local.json` | Yes — already gitignored | Safe | ### Personal Information | Check | Result | |-------|--------| | Email addresses in source | None | | package.json author field | Not set (clean) | | Git commit author | Will be visible in public repo history — see cutover plan | **Verdict:** Exclude `docs/protocol-captures/` and `docs/claude-permission-probe.md` from public repo. --- ## 3. Licensing ### Project License - **Current state:** No LICENSE file, no `license` field in package.json - **Action:** **MUST-FIX** — Add MIT license before publishing ### Dependencies (all MIT-compatible) | Package | License | Copyleft Risk | |---------|---------|---------------| | electron | MIT | None | | react / react-dom | MIT | None | | zustand | MIT | None | | framer-motion | MIT | None | | node-pty | MIT | None | | react-markdown | MIT | None | | remark-gfm | MIT | None | | @phosphor-icons/react | MIT | None | | tailwindcss | MIT | None | | All devDependencies | MIT | None | **No GPL, AGPL, SSPL, or BUSL dependencies detected.** ### Assets | Asset | Provenance | Action | |-------|-----------|--------| | `resources/icon.*` | Original (created for project) | Document in LICENSE | | `resources/notification.mp3` | Replaced with generated CC0 chime (embedded metadata) | Resolved | | `resources/trayTemplate*.png` | Original | Document in LICENSE | | Root marketing screenshots | Not included in current repo root | Optional to add later if needed for release collateral | **Verdict:** Add LICENSE file. ~~Verify notification.mp3 provenance~~ — resolved (replaced with CC0 generated chime). --- ## 4. Developer UX ### Prerequisites for Contributors - Node.js 18+ (for Electron 33) - macOS (primary platform — Electron transparent window, tray, node-pty) - `claude` CLI installed and authenticated (core dependency) - Optional: `whisperkit-cli` (Apple Silicon preferred, CoreML) or `whisper-cpp` (Apple Silicon & Intel, ggml) or `whisper` (Python) for voice transcription ### Build System - `npm install` → `npm run dev` (hot-reload) or `npm run build` (production) - Zero TypeScript errors confirmed - electron-vite handles main/preload/renderer bundling ### Missing for OSS | Item | Status | Priority | |------|--------|----------| | README.md | Missing | **Must-fix** | | CONTRIBUTING.md | Missing | Must-fix | | SECURITY.md | Missing | Must-fix | | CODE_OF_CONDUCT.md | Missing | Must-fix | | Architecture docs | Missing | Must-fix | | .env.example | Not needed | N/A — document explicitly | --- ## 5. Repository Hygiene ### Files to Exclude from Public Repo | Path | Reason | |------|--------| | `docs/protocol-captures/` | Contains local paths, session data | | `docs/claude-permission-probe.md` | Contains local path references | | `CLUI-PRD.md` | Internal product requirements | | `CODEX_REPORT_INTERACTIVE_COMMANDS.md` | Internal dev report | | `spike/` | Experimental probes, not production code | | `src/main/probe/` | Internal contract/permission test utilities | | `soft_and_brief_notif_#2-*.mp3` | Stray temp file in root | | `start-pty.command` | Legacy PTY mode launcher | | `.claude/` | Project-scoped Claude settings | ### .gitignore Gaps Current `.gitignore` is minimal. Should add: - `out/` (electron-builder output) - `*.log` - `.env*` - `*.swp`, `*.swo` - OS artifacts beyond `.DS_Store` --- ## 6. Network Dependencies | Endpoint | Purpose | Required | Graceful Offline | |----------|---------|----------|-----------------| | `raw.githubusercontent.com/anthropics/*` | Marketplace catalog | Optional | Yes — cached 5min, error state shown | | `api.github.com/repos/anthropics/*/tarball/*` | Skill auto-install | Optional | Yes — skipped on failure | | `127.0.0.1:19836` | Permission hook server | Required (local only) | N/A | No telemetry, analytics, auto-updater, or CDN dependencies. --- ## 7. Release Risk Summary | Risk | Severity | Status | |------|----------|--------| | No LICENSE file | **Critical** | Fix in this branch | | No README | **Critical** | Fix in this branch | | Protocol captures contain local paths | **High** | Exclude from public repo | | notification.mp3 unknown provenance | **Medium** | Resolved — replaced with CC0 generated chime | | No CONTRIBUTING/SECURITY/COC docs | **Medium** | Fix in this branch | | Internal docs (PRD, Codex reports) | **Low** | Exclude from public repo | | Probe utilities in src/main/probe/ | **Low** | Exclude from public repo | | macOS-only (no Windows/Linux) | **Low** | Document as known limitation | ================================================ FILE: docs/release-smoke-test.md ================================================ # Release Smoke Test ## Build Verification ### Fresh Clone Bootstrap ```bash git clone https://github.com/lcoutodemos/clui-cc.git cd clui-cc npm run doctor # verify environment — all checks should pass npm install # installs deps + runs postinstall (electron-builder install-app-deps + icon patch) npm run build # production build — must exit 0 with no errors ``` **Prerequisites check (verified by `npm run doctor`):** - macOS 13+ - Xcode Command Line Tools installed (`xcode-select -p` returns a path) - macOS SDK available (`xcrun --sdk macosx --show-sdk-path` returns a path) - clang++ available with working C++ headers - `node --version` returns 18+ - `python3` available with `distutils` importable - `claude --version` returns 2.1+ **Expected output:** - `dist/main/index.js` — ~117 KB - `dist/preload/index.js` — ~6 KB - `dist/renderer/index.html` + `assets/index-*.js` (~1.5 MB) + `assets/index-*.css` (~25 KB) ### TypeScript - `npm run build` — passes (uses esbuild, tolerant of some strict-mode warnings) - `npx tsc --noEmit` — has pre-existing warnings (68 as of v0.1.0, non-blocking) - These are narrowing/equality warnings from Zustand selector patterns and a legacy PTY file - Does NOT affect runtime behavior — electron-vite builds successfully ## Runtime Smoke Test Checklist ### Prerequisites - [ ] macOS 13+ - [ ] Xcode Command Line Tools installed (`xcode-select -p` returns a path) - [ ] Node.js 18+ - [ ] `claude` CLI installed and authenticated (`claude --version` returns 2.1+) ### Startup - [ ] `npm run dev` or `./commands/start.command` launches the app - [ ] Floating pill appears at bottom-center of screen - [ ] `⌥ + Space` toggles visibility (fallback: `Cmd+Shift+K`) - [ ] Tray icon appears in menu bar - [ ] Tray menu shows Quit option ### Tab Management - [ ] Default tab created on launch - [ ] Click `+` creates a new tab - [ ] Clicking tab switches active tab - [ ] Tab shows correct status dot (idle = gray, running = orange, completed = green) ### Prompt & Response - [ ] Type a prompt and press Enter - [ ] Tab status changes to "running" (orange dot) - [ ] Text streams into conversation view - [ ] Tool calls appear as expandable cards - [ ] Task completes, status changes to "completed" (green dot) - [ ] Cost/tokens shown in status bar ### Permission System - [ ] When Claude tries to use a tool, a permission card appears - [ ] "Allow" lets the tool run - [ ] "Deny" blocks the tool - [ ] Permission denial is reflected in task completion ### Settings - [ ] Three-dot button in tab strip opens settings popover - [ ] Sound toggle works (on/off) - [ ] Theme picker works (System/Light/Dark) - [ ] UI size toggle works (Compact/Expanded) - [ ] Settings persist across restart (localStorage) ### History - [ ] Clock icon opens session history picker - [ ] Previous sessions listed with timestamps - [ ] Clicking a session loads its messages ### Marketplace - [ ] HeadCircuit (brain) button opens marketplace panel - [ ] Plugins load from GitHub (requires network) - [ ] Search filters by name/description/tags - [ ] Filter chips narrow results by semantic tag - [ ] "Installed" filter shows installed plugins - [ ] Install flow shows confirmation with exact CLI commands - [ ] Graceful error state when offline ### Voice Input (Whisper required — installed by install-app.command) - [ ] Microphone button starts recording - [ ] Stop button ends recording and transcribes - [ ] Transcribed text appears in input bar ### Attachments - [ ] Paperclip button opens file picker - [ ] Camera button takes screenshot - [ ] Pasting an image from clipboard works - [ ] Attachment chips appear below input ### Theme - [ ] Dark mode: warm dark surfaces, orange accent - [ ] Light mode: light surfaces, same orange accent - [ ] System mode follows OS dark/light setting ### Window Behavior - [ ] Window is transparent (click-through on non-UI areas) - [ ] Window stays on top of other windows - [ ] Expanded UI mode widens the panel - [ ] Collapsing back to compact restores original size - [ ] No shadow clipping at window edges ## Offline Behavior - [ ] App launches and is usable without network - [ ] Marketplace shows error state with "Retry" button - [ ] Skill auto-install silently skips on failure - [ ] All prompt/response functionality works (uses local CLI) ## Last Verified - **Date:** 2026-03-12 - **Node:** v22.x - **Electron:** 33.x - **Claude CLI:** 2.1.71 - **macOS:** 15.x (Sequoia) - **Build result:** Pass (zero build errors) ================================================ FILE: docs/slash-command-matrix.md ================================================ # Slash Command Capability Matrix CLI Version: 2.1.63 | Date: 2026-03-08 Test session: 450d2d0f-4b03-4761-8ecd-8d179998127d ## Protocol Finding `--input-format stream-json` is **completely broken** in CLI 2.1.63 (hangs forever, 0 events). The only working mode is one-shot `claude -p` with stdin closed + `--resume` for multi-turn. ## Command Matrix | Command | Fresh | With Session | Events | Result Preview | Verdict | |---------|-------|-------------|--------|---------------|---------| | `/help` | ✅ | ✅ | system/init, result/success | Unknown skill: help | **works_native** | | `/model` | ✅ | ✅ | system/init, result/success | Unknown skill: model | **works_native** | | `/mcp` | ✅ | ✅ | system/init, result/success | Unknown skill: mcp | **works_native** | | `/status` | ✅ | ✅ | system/init, result/success | Unknown skill: status | **works_native** | | `/clear` | ✅ | ✅ | system/init, result/success | Unknown skill: clear | **works_native** | | `/compact` | ✅ | ✅ | system/status, rate_limit_event, system/init, system/compact_boundary, user, result/success | | **unsupported** | | `/doctor` | ✅ | ✅ | system/init, result/success | Unknown skill: doctor | **works_native** | | `/permissions` | ✅ | ✅ | system/init, result/success | Unknown skill: permissions | **works_native** | | `/cost` | ✅ | ✅ | system/init, assistant, result/success | You are currently using your subscription to power | **passthrough_to_model** | ## Verdict Key - **works_native**: CLI intercepts the command and returns structured output (no model call) - **passthrough_to_model**: CLI sends it to the model as a regular prompt (model responds) - **silent_exit**: CLI handles it internally but produces no result event in stream-json - **unsupported**: Command not recognized or errors out ## Detailed Results ### `/help` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: help ``` ### `/model` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: model ``` ### `/mcp` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: mcp ``` ### `/status` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: status ``` ### `/clear` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: clear ``` ### `/compact` - Verdict: **unsupported** - Exit code: 0 - Events: system/status → rate_limit_event → system/status → system/init → system/compact_boundary → user → user → result/success - Is error: false - Result text: ``` (empty) ``` ### `/doctor` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: doctor ``` ### `/permissions` - Verdict: **works_native** - Exit code: 0 - Events: system/init → result/success - Is error: false - Result text: ``` Unknown skill: permissions ``` ### `/cost` - Verdict: **passthrough_to_model** - Exit code: 0 - Events: system/init → assistant → result/success - Is error: false - Result text: ``` You are currently using your subscription to power your Claude Code usage ``` ================================================ FILE: electron.vite.config.ts ================================================ import { resolve } from 'path' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], build: { outDir: 'dist/main', rollupOptions: { input: { index: resolve(__dirname, 'src/main/index.ts') } } } }, preload: { plugins: [externalizeDepsPlugin()], build: { outDir: 'dist/preload', rollupOptions: { input: { index: resolve(__dirname, 'src/preload/index.ts') } } } }, renderer: { root: resolve(__dirname, 'src/renderer'), plugins: [react(), tailwindcss()], build: { outDir: resolve(__dirname, 'dist/renderer'), rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html') } } } } }) ================================================ FILE: install-app.command ================================================ #!/bin/bash cd "$(dirname "$0")" exec bash ./commands/install-app.command "$@" ================================================ FILE: package.json ================================================ { "name": "clui", "version": "0.1.0", "description": "Clui CC — Command Line User Interface for Claude Code", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/lcoutodemos/clui-cc.git" }, "bugs": { "url": "https://github.com/lcoutodemos/clui-cc/issues" }, "homepage": "https://github.com/lcoutodemos/clui-cc#readme", "main": "dist/main/index.js", "scripts": { "dev": "electron-vite dev", "build": "electron-vite build", "preview": "electron-vite preview", "dist": "electron-vite build --mode production && electron-builder --mac --dir", "doctor": "bash scripts/doctor.sh", "postinstall": "electron-builder install-app-deps && bash scripts/patch-dev-icon.sh" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", "framer-motion": "^12.35.1", "node-pty": "^1.1.0", "react-markdown": "^9.0.0", "remark-gfm": "^4.0.0", "zustand": "^5.0.0" }, "build": { "appId": "com.clui.app", "productName": "Clui CC", "directories": { "output": "release" }, "files": [ "dist/main/**/*", "dist/preload/**/*", "dist/renderer/**/*", "resources/**/*", "package.json" ], "mac": { "icon": "resources/icon.icns", "entitlements": "resources/entitlements.mac.plist", "entitlementsInherit": "resources/entitlements.mac.plist", "extendInfo": { "NSMicrophoneUsageDescription": "Clui CC uses your microphone to transcribe voice input locally with Whisper." } } }, "devDependencies": { "@tailwindcss/vite": "^4.2.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.0", "electron": "^35.7.5", "electron-builder": "^26.8.1", "electron-vite": "^3.0.0", "postcss": "^8.4.0", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.2.1", "typescript": "^5.7.0", "vite": "^6.0.0" } } ================================================ FILE: resources/entitlements.mac.plist ================================================ com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory com.apple.security.cs.disable-library-validation com.apple.security.device.audio-input ================================================ FILE: scripts/doctor.sh ================================================ #!/bin/bash # Clui CC environment doctor — read-only diagnostics, no installs. echo "Clui CC Environment Check" echo "=========================" echo fail=0 SDK_PATH="" # Compare two dotted versions: returns 0 if $1 >= $2 version_gte() { [ "$(printf '%s\n%s' "$1" "$2" | sort -V | head -1)" = "$2" ] } check() { local label="$1" local ok="$2" local detail="$3" if [ "$ok" = "1" ]; then printf " PASS %s — %s\n" "$label" "$detail" else printf " FAIL %s — %s\n" "$label" "$detail" fail=1 fi } # macOS if [ "$(uname)" = "Darwin" ]; then ver=$(sw_vers -productVersion 2>/dev/null || echo "0") if version_gte "$ver" "13.0"; then check "macOS" "1" "$ver" else check "macOS" "0" "$ver — requires 13+" fi else check "macOS" "0" "not macOS ($(uname)) — Clui CC requires macOS" fi # Node if command -v node &>/dev/null; then node_ver=$(node --version | sed 's/^v//') if version_gte "$node_ver" "18.0.0"; then check "Node.js" "1" "v$node_ver" else check "Node.js" "0" "v$node_ver — requires 18+ — brew install node" fi else check "Node.js" "0" "not found — brew install node" fi # npm if command -v npm &>/dev/null; then check "npm" "1" "$(npm --version)" else check "npm" "0" "not found — brew install node" fi # Python if command -v python3 &>/dev/null; then pyver=$(python3 --version 2>&1 | awk '{print $2}') check "Python 3" "1" "$pyver" else check "Python 3" "0" "not found — brew install python@3.11" fi # distutils if command -v python3 &>/dev/null; then if python3 -c "import distutils" 2>/dev/null; then check "distutils" "1" "importable" else check "distutils" "0" "missing — python3 -m pip install --upgrade pip setuptools" fi else check "distutils" "0" "skipped (no python3)" fi # Xcode CLT if xcode-select -p &>/dev/null; then check "Xcode CLT" "1" "$(xcode-select -p)" else check "Xcode CLT" "0" "not installed — xcode-select --install" fi # macOS SDK if xcrun --sdk macosx --show-sdk-path &>/dev/null; then SDK_PATH=$(xcrun --sdk macosx --show-sdk-path) check "macOS SDK" "1" "$SDK_PATH" else check "macOS SDK" "0" "not found — reinstall Xcode CLT" fi # clang++ if command -v clang++ &>/dev/null; then cver=$(clang++ --version 2>&1 | head -1) check "clang++" "1" "$cver" # C++ headers (only probe if clang++ exists) PROBE_DIR=$(mktemp -d) echo '#include ' > "$PROBE_DIR/probe.cpp" echo 'int main() { return 0; }' >> "$PROBE_DIR/probe.cpp" if clang++ -std=c++17 -c "$PROBE_DIR/probe.cpp" -o "$PROBE_DIR/probe.o" 2>/dev/null; then check "C++ headers" "1" " compiles" 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 check "C++ headers" "1" " compiles (using SDK include path)" else check "C++ headers" "0" " missing — reinstall Xcode CLT" fi rm -rf "$PROBE_DIR" else check "clang++" "0" "not found — xcode-select --install" check "C++ headers" "0" "skipped (no clang++)" fi # Claude CLI if command -v claude &>/dev/null; then cver=$(claude --version 2>/dev/null || echo "unknown") check "Claude CLI" "1" "$cver" else check "Claude CLI" "0" "not found — npm install -g @anthropic-ai/claude-code" fi echo if [ "$fail" -ne 0 ]; then echo "Some checks failed. Fix them above, then rerun:" echo echo " ./commands/setup.command" else echo "Environment looks good." fi ================================================ FILE: scripts/patch-dev-icon.sh ================================================ #!/usr/bin/env bash set -euo pipefail ELECTRON_APP="node_modules/electron/dist/Electron.app" RESOURCES="$ELECTRON_APP/Contents/Resources" ICON_SRC="resources/icon.icns" # Only run on macOS [[ "$(uname)" == "Darwin" ]] || exit 0 # Only run if source icon exists [[ -f "$ICON_SRC" ]] || exit 0 # Replace the icon cp "$ICON_SRC" "$RESOURCES/electron.icns" # Touch the bundle to invalidate macOS icon cache touch "$ELECTRON_APP" # Re-sign with ad-hoc signature (required after modifying bundle contents) codesign --force --deep --sign - "$ELECTRON_APP" 2>/dev/null || true ================================================ FILE: src/main/claude/control-plane.ts ================================================ import { EventEmitter } from 'events' import { RunManager } from './run-manager' import { PtyRunManager } from './pty-run-manager' import { PermissionServer, maskSensitiveFields } from '../hooks/permission-server' import type { HookToolRequest, PermissionOption } from '../hooks/permission-server' import { log as _log } from '../logger' import type { TabStatus, TabRegistryEntry, HealthReport, NormalizedEvent, RunOptions, EnrichedError, } from '../../shared/types' const MAX_QUEUE_DEPTH = 32 function log(msg: string): void { _log('ControlPlane', msg) } interface QueuedRequest { requestId: string tabId: string options: RunOptions resolve: (value: void) => void reject: (reason: Error) => void enqueuedAt: number /** Additional waiters that called submitPrompt with the same requestId */ extraWaiters: Array<{ resolve: (value: void) => void; reject: (reason: Error) => void }> } interface InflightRequest { requestId: string tabId: string promise: Promise resolve: (value: void) => void reject: (reason: Error) => void } /** * ControlPlane: the single backend authority for tab/session lifecycle. * * Responsibilities: * 1. Tab/session registry * 2. Request queue + backpressure * 3. RequestId idempotency * 4. Target session guard * 5. Run lifecycle state transitions * 6. Health reporting for renderer reconciliation * 7. Diagnostic data (delegated to RunManager ring buffers) * * Events emitted (forwarded from RunManager, tagged with tabId): * - 'event' (tabId, NormalizedEvent) * - 'tab-status-change' (tabId, newStatus, oldStatus) * - 'error' (tabId, EnrichedError) */ export class ControlPlane extends EventEmitter { private tabs = new Map() private inflightRequests = new Map() private requestQueue: QueuedRequest[] = [] private runManager: RunManager private ptyRunManager: PtyRunManager /** Feature flag: use PTY transport for interactive permissions */ private interactivePty: boolean /** Tracks which runs are using PTY transport (by requestId) */ private ptyRuns = new Set() /** Tracks requestIds that are warmup init requests (invisible to renderer) */ private initRequestIds = new Set() /** Permission hook server for PreToolUse HTTP hooks */ private permissionServer: PermissionServer /** Per-run tokens: requestId → runToken (for cleanup on exit/error) */ private runTokens = new Map() /** Global permission mode: 'ask' shows cards, 'auto' auto-approves */ private permissionMode: 'ask' | 'auto' = 'ask' /** Resolves when the permission server is ready (or failed). Dispatch awaits this. */ private hookServerReady: Promise constructor(interactivePty = false) { super() this.interactivePty = interactivePty this.runManager = new RunManager() this.ptyRunManager = new PtyRunManager() this.permissionServer = new PermissionServer() // Start the permission hook server. _dispatch awaits hookServerReady // so early prompts don't silently fall back to the --allowedTools path. this.hookServerReady = this.permissionServer.start() .then((port) => { log(`Permission hook server ready on port ${port}`) }) .catch((err) => { log(`Failed to start permission hook server: ${(err as Error).message}`) // No hook server → dispatch falls back to --allowedTools }) // Wire permission server events → normalized events for renderer. // 4-arg signature: (questionId, toolRequest, tabId, options) // tabId comes directly from per-run token registration — no session_id lookup needed. this.permissionServer.on('permission-request', (questionId: string, toolRequest: HookToolRequest, tabId: string, options: PermissionOption[]) => { // Verify tab still exists — deny immediately if closed (prevents 5-min timeout hang) if (!this.tabs.has(tabId)) { log(`Permission request for closed tab ${tabId.substring(0, 8)}… — auto-denying`) this.permissionServer.respondToPermission(questionId, 'deny', 'Tab closed') return } log(`Permission request [${questionId}]: tool=${toolRequest.tool_name} tab=${tabId.substring(0, 8)}… mode=${this.permissionMode}`) // Auto mode: immediately allow without showing UI if (this.permissionMode === 'auto') { this.permissionServer.respondToPermission(questionId, 'allow', 'Auto mode') return } // Mask sensitive fields before sending to renderer (defense-in-depth) const safeInput = toolRequest.tool_input ? maskSensitiveFields(toolRequest.tool_input) : undefined const permEvent: NormalizedEvent = { type: 'permission_request', questionId, toolName: toolRequest.tool_name, toolDescription: undefined, toolInput: safeInput, options, } this.emit('event', tabId, permEvent) }) log(`Interactive PTY transport: ${interactivePty ? 'ENABLED' : 'disabled'}`) // ─── Wire PtyRunManager events → ControlPlane routing ─── this._wirePtyEvents() // ─── Wire RunManager events → ControlPlane routing ─── this.runManager.on('normalized', (requestId: string, event: NormalizedEvent) => { const tabId = this._findTabByRequest(requestId) if (!tabId) return const tab = this.tabs.get(tabId) if (!tab) return tab.lastActivityAt = Date.now() // Handle session init if (event.type === 'session_init') { tab.claudeSessionId = event.sessionId if (this.initRequestIds.has(requestId)) { // Warmup init — emit session_init with isWarmup flag, don't change status this.emit('event', tabId, { ...event, isWarmup: true }) return } if (tab.status === 'connecting') { this._setTabStatus(tabId, 'running') } } // Suppress all events from init requests (session_init already handled above) if (this.initRequestIds.has(requestId)) { return } this.emit('event', tabId, event) }) this.runManager.on('exit', (requestId: string, code: number | null, signal: string | null, sessionId: string | null) => { // Clean up per-run token const runToken = this.runTokens.get(requestId) if (runToken) { this.permissionServer.unregisterRun(runToken) this.runTokens.delete(requestId) } const tabId = this._findTabByRequest(requestId) // Always clean up inflight promise, even if tab was already closed. // This prevents leaked promises when closeTab() races with process exit. const inflight = this.inflightRequests.get(requestId) if (!tabId || !this.tabs.get(tabId)) { // Tab was already closed — just resolve/reject the orphaned promise if (inflight) { inflight.resolve() this.inflightRequests.delete(requestId) } return } const tab = this.tabs.get(tabId)! tab.activeRequestId = null tab.runPid = null if (sessionId) tab.claudeSessionId = sessionId // Init request: silently transition to idle if (this.initRequestIds.has(requestId)) { this.initRequestIds.delete(requestId) this._setTabStatus(tabId, 'idle') if (inflight) { inflight.resolve() this.inflightRequests.delete(requestId) } this._processQueue(tabId) return } if (code === 0) { this._setTabStatus(tabId, 'completed') } else if (signal === 'SIGINT' || signal === 'SIGKILL') { // Cancelled by user this._setTabStatus(tabId, 'failed') } else { // Unexpected exit — emit enriched error (includes stderr tail) const enriched = this.runManager.getEnrichedError(requestId, code) this.emit('error', tabId, enriched) this._setTabStatus(tabId, code === null ? 'dead' : 'failed') } // Resolve the inflight promise if (inflight) { inflight.resolve() this.inflightRequests.delete(requestId) } // Process next queued request for this tab this._processQueue(tabId) }) this.runManager.on('error', (requestId: string, err: Error) => { // Clean up per-run token const runToken = this.runTokens.get(requestId) if (runToken) { this.permissionServer.unregisterRun(runToken) this.runTokens.delete(requestId) } const tabId = this._findTabByRequest(requestId) // Always clean up inflight even if tab is gone const inflight = this.inflightRequests.get(requestId) if (!tabId || !this.tabs.get(tabId)) { if (inflight) { inflight.reject(err) this.inflightRequests.delete(requestId) } return } const tab = this.tabs.get(tabId)! tab.activeRequestId = null tab.runPid = null // Init request: silently fail, go idle so user can still use the tab if (this.initRequestIds.has(requestId)) { this.initRequestIds.delete(requestId) log(`Init session error for tab ${tabId}: ${err.message}`) this._setTabStatus(tabId, 'idle') if (inflight) { inflight.reject(err) this.inflightRequests.delete(requestId) } this._processQueue(tabId) return } this._setTabStatus(tabId, 'dead') // Use enriched diagnostics — _finishedRuns holds the handle with // stderr/stdout ring buffers even after the process errored out. const enriched = this.runManager.getEnrichedError(requestId, null) enriched.message = err.message this.emit('error', tabId, enriched) if (inflight) { inflight.reject(err) this.inflightRequests.delete(requestId) } }) } /** * Wire PtyRunManager events using the same routing logic as RunManager. */ private _wirePtyEvents(): void { // Normalized events → same routing as RunManager this.ptyRunManager.on('normalized', (requestId: string, event: NormalizedEvent) => { const tabId = this._findTabByRequest(requestId) if (!tabId) return const tab = this.tabs.get(tabId) if (!tab) return tab.lastActivityAt = Date.now() // Handle session init if (event.type === 'session_init') { tab.claudeSessionId = event.sessionId if (this.initRequestIds.has(requestId)) { this.emit('event', tabId, { ...event, isWarmup: true }) return } if (tab.status === 'connecting') { this._setTabStatus(tabId, 'running') } } // Suppress events from init requests if (this.initRequestIds.has(requestId)) return this.emit('event', tabId, event) }) // Exit events this.ptyRunManager.on('exit', (requestId: string, code: number | null, signal: number | null, sessionId: string | null) => { // Clean up per-run token const runToken = this.runTokens.get(requestId) if (runToken) { this.permissionServer.unregisterRun(runToken) this.runTokens.delete(requestId) } const tabId = this._findTabByRequest(requestId) const inflight = this.inflightRequests.get(requestId) // Clean up PTY run tracking this.ptyRuns.delete(requestId) if (!tabId || !this.tabs.get(tabId)) { if (inflight) { inflight.resolve() this.inflightRequests.delete(requestId) } return } const tab = this.tabs.get(tabId)! tab.activeRequestId = null tab.runPid = null if (sessionId) tab.claudeSessionId = sessionId if (this.initRequestIds.has(requestId)) { this.initRequestIds.delete(requestId) this._setTabStatus(tabId, 'idle') if (inflight) { inflight.resolve() this.inflightRequests.delete(requestId) } this._processQueue(tabId) return } if (code === 0) { this._setTabStatus(tabId, 'completed') } else if (signal) { this._setTabStatus(tabId, 'failed') } else { const enriched = this.ptyRunManager.getEnrichedError(requestId, code) this.emit('error', tabId, enriched) this._setTabStatus(tabId, code === null ? 'dead' : 'failed') } if (inflight) { inflight.resolve() this.inflightRequests.delete(requestId) } this._processQueue(tabId) }) // Error events this.ptyRunManager.on('error', (requestId: string, err: Error) => { // Clean up per-run token const runToken = this.runTokens.get(requestId) if (runToken) { this.permissionServer.unregisterRun(runToken) this.runTokens.delete(requestId) } const tabId = this._findTabByRequest(requestId) const inflight = this.inflightRequests.get(requestId) this.ptyRuns.delete(requestId) if (!tabId || !this.tabs.get(tabId)) { if (inflight) { inflight.reject(err) this.inflightRequests.delete(requestId) } return } const tab = this.tabs.get(tabId)! tab.activeRequestId = null tab.runPid = null if (this.initRequestIds.has(requestId)) { this.initRequestIds.delete(requestId) log(`PTY init session error for tab ${tabId}: ${err.message}`) this._setTabStatus(tabId, 'idle') if (inflight) { inflight.reject(err) this.inflightRequests.delete(requestId) } this._processQueue(tabId) return } this._setTabStatus(tabId, 'dead') const enriched = this.ptyRunManager.getEnrichedError(requestId, null) enriched.message = err.message this.emit('error', tabId, enriched) if (inflight) { inflight.reject(err) this.inflightRequests.delete(requestId) } }) } // ─── Tab Lifecycle ─── createTab(): string { const tabId = crypto.randomUUID() const entry: TabRegistryEntry = { tabId, claudeSessionId: null, status: 'idle', activeRequestId: null, runPid: null, createdAt: Date.now(), lastActivityAt: Date.now(), promptCount: 0, } this.tabs.set(tabId, entry) log(`Tab created: ${tabId}`) return tabId } /** * Eagerly initialize a session for a tab by running a minimal prompt. * Populates session metadata (model, MCP servers, tools) without visible messages. */ initSession(tabId: string): void { const tab = this.tabs.get(tabId) if (!tab) return const requestId = `init-${tabId}` this.initRequestIds.add(requestId) this.submitPrompt(tabId, requestId, { prompt: 'hi', projectPath: process.cwd(), maxTurns: 1, }).catch((err) => { this.initRequestIds.delete(requestId) log(`Init session failed for tab ${tabId}: ${(err as Error).message}`) }) } /** * Clear stored session ID for a tab — used when working directory changes * so _dispatch won't inject a stale --resume from the old directory. */ resetTabSession(tabId: string): void { const tab = this.tabs.get(tabId) if (!tab) return log(`Resetting session for tab ${tabId} (was: ${tab.claudeSessionId})`) tab.claudeSessionId = null } /** * Set global permission mode. * 'ask' = show permission cards, 'auto' = auto-approve all tool calls. */ setPermissionMode(mode: 'ask' | 'auto'): void { log(`Permission mode set to: ${mode}`) this.permissionMode = mode } closeTab(tabId: string): void { const tab = this.tabs.get(tabId) if (!tab) return // Cancel active run if any if (tab.activeRequestId) { this.cancel(tab.activeRequestId) // Resolve and clean up the inflight promise so it doesn't leak. // The exit handler may never fire for this tab since we're deleting it. const inflight = this.inflightRequests.get(tab.activeRequestId) if (inflight) { inflight.reject(new Error('Tab closed')) this.inflightRequests.delete(tab.activeRequestId) } } // Remove queued requests for this tab, rejecting all waiters this.requestQueue = this.requestQueue.filter((r) => { if (r.tabId === tabId) { const reason = new Error('Tab closed') r.reject(reason) for (const w of r.extraWaiters) w.reject(reason) return false } return true }) this.tabs.delete(tabId) log(`Tab closed: ${tabId}`) } // ─── Submit Prompt ─── /** * Submit a prompt to a specific tab. Returns a promise that resolves * when the run completes. * * Guards: * - Rejects without targetSession (tabId) * - Returns existing promise for duplicate requestId (idempotency) * - Queues if tab is busy, rejects if queue is full */ async submitPrompt( tabId: string, requestId: string, options: RunOptions, ): Promise { // ─── Guard: target session required ─── if (!tabId) { throw new Error('No targetSession (tabId) provided — rejecting to prevent misrouting') } const tab = this.tabs.get(tabId) if (!tab) { throw new Error(`Tab ${tabId} does not exist`) } // ─── Guard: requestId idempotency (check inflight AND queue) ─── const existing = this.inflightRequests.get(requestId) if (existing) { log(`Duplicate requestId ${requestId} — returning existing inflight promise`) return existing.promise } const queued = this.requestQueue.find((r) => r.requestId === requestId) if (queued) { log(`Duplicate requestId ${requestId} — already queued, adding waiter`) return new Promise((resolve, reject) => { queued.extraWaiters.push({ resolve, reject }) }) } // ─── If tab has an active run, queue the request ─── if (tab.activeRequestId) { if (this.requestQueue.length >= MAX_QUEUE_DEPTH) { throw new Error('Request queue full — back-pressure') } log(`Tab ${tabId} busy — queuing request ${requestId} (queue depth: ${this.requestQueue.length + 1})`) return new Promise((resolve, reject) => { this.requestQueue.push({ requestId, tabId, options, resolve, reject, enqueuedAt: Date.now(), extraWaiters: [], }) }) } // ─── Dispatch immediately ─── return this._dispatch(tabId, requestId, options) } private async _dispatch(tabId: string, requestId: string, options: RunOptions): Promise { const tab = this.tabs.get(tabId) if (!tab) throw new Error(`Tab ${tabId} disappeared`) // Wait for the permission hook server to be ready (or failed). // This prevents early prompts from silently falling back to --allowedTools. await this.hookServerReady // Use stored session ID for resume if available and not overridden if (tab.claudeSessionId && !options.sessionId) { options = { ...options, sessionId: tab.claudeSessionId } } // Per-run token lifecycle: register run, generate per-run settings file if (this.permissionServer.getPort()) { const runToken = this.permissionServer.registerRun(tabId, requestId, options.sessionId || null) this.runTokens.set(requestId, runToken) const hookSettingsPath = this.permissionServer.generateSettingsFile(runToken) options = { ...options, hookSettingsPath } } tab.activeRequestId = requestId if (!this.initRequestIds.has(requestId)) tab.promptCount++ tab.lastActivityAt = Date.now() // Set status to connecting (first run) or running (subsequent) const newStatus: TabStatus = tab.claudeSessionId ? 'running' : 'connecting' this._setTabStatus(tabId, newStatus) // ─── Pick transport ─── // Stream-json is the stable transport for all regular messages. // PTY is reserved for future interactive permission handling only. const usePty = false let pid: number | null = null try { if (usePty) { log(`Dispatching via PTY transport: ${requestId}`) const handle = this.ptyRunManager.startRun(requestId, options) this.ptyRuns.add(requestId) pid = handle.pid } else { const handle = this.runManager.startRun(requestId, options) pid = handle.pid } tab.runPid = pid } catch (err) { // Start failure before inflight registration: rollback tab run state. tab.activeRequestId = null tab.runPid = null this._setTabStatus(tabId, 'failed') throw err } // Create inflight promise let resolve!: (value: void) => void let reject!: (reason: Error) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) this.inflightRequests.set(requestId, { requestId, tabId, promise, resolve, reject }) return promise } // ─── Cancel ─── cancel(requestId: string): boolean { // Check if it's in the queue first const queueIdx = this.requestQueue.findIndex((r) => r.requestId === requestId) if (queueIdx !== -1) { const req = this.requestQueue.splice(queueIdx, 1)[0] const reason = new Error('Request cancelled') req.reject(reason) for (const w of req.extraWaiters) w.reject(reason) log(`Cancelled queued request ${requestId}`) return true } // Cancel active run — route to correct transport if (this.ptyRuns.has(requestId)) { return this.ptyRunManager.cancel(requestId) } return this.runManager.cancel(requestId) } /** * Cancel active run on a tab (by tabId instead of requestId). */ cancelTab(tabId: string): boolean { const tab = this.tabs.get(tabId) if (!tab?.activeRequestId) return false return this.cancel(tab.activeRequestId) } // ─── Retry ─── /** * Retry: re-submit the same prompt on the same tab/session. * If the tab is dead, creates a fresh session. */ async retry(tabId: string, requestId: string, options: RunOptions): Promise { const tab = this.tabs.get(tabId) if (!tab) throw new Error(`Tab ${tabId} does not exist`) // If dead, clear session so a new one starts if (tab.status === 'dead') { tab.claudeSessionId = null this._setTabStatus(tabId, 'idle') } return this.submitPrompt(tabId, requestId, options) } // ─── Permission Response ─── respondToPermission(tabId: string, questionId: string, optionId: string): boolean { // Route to hook server if this is a hook-based permission request. // Pass optionId directly — it matches the permission card option IDs // (allow, allow-session, allow-domain, deny). if (questionId.startsWith('hook-')) { return this.permissionServer.respondToPermission(questionId, optionId) } const tab = this.tabs.get(tabId) if (!tab?.activeRequestId) return false // Route to correct transport if (this.ptyRuns.has(tab.activeRequestId)) { return this.ptyRunManager.respondToPermission(tab.activeRequestId, questionId, optionId) } // Print-json transport: send structured permission response via stdin const msg = { type: 'permission_response', question_id: questionId, option_id: optionId, } return this.runManager.writeToStdin(tab.activeRequestId, msg) } // ─── Health ─── getHealth(): HealthReport { const tabEntries: HealthReport['tabs'] = [] for (const [tabId, tab] of this.tabs) { let alive = false if (tab.activeRequestId) { alive = this.runManager.isRunning(tab.activeRequestId) || this.ptyRunManager.isRunning(tab.activeRequestId) } tabEntries.push({ tabId, status: tab.status, activeRequestId: tab.activeRequestId, claudeSessionId: tab.claudeSessionId, alive, }) } return { tabs: tabEntries, queueDepth: this.requestQueue.length, } } getTabStatus(tabId: string): TabRegistryEntry | undefined { return this.tabs.get(tabId) } getEnrichedError(requestId: string, exitCode: number | null): EnrichedError { if (this.ptyRuns.has(requestId)) { return this.ptyRunManager.getEnrichedError(requestId, exitCode) } return this.runManager.getEnrichedError(requestId, exitCode) } // ─── Queue Processing ─── private _processQueue(tabId: string): void { // Find next queued request for this specific tab const idx = this.requestQueue.findIndex((r) => r.tabId === tabId) if (idx === -1) return const req = this.requestQueue.splice(idx, 1)[0] log(`Processing queued request ${req.requestId} for tab ${tabId}`) this._dispatch(tabId, req.requestId, req.options) .then((v) => { req.resolve(v) for (const w of req.extraWaiters) w.resolve(v) }) .catch((e) => { req.reject(e) for (const w of req.extraWaiters) w.reject(e) }) } // ─── Internal ─── private _findTabByRequest(requestId: string): string | null { const inflight = this.inflightRequests.get(requestId) if (inflight) return inflight.tabId // Also check registry entries for (const [tabId, tab] of this.tabs) { if (tab.activeRequestId === requestId) return tabId } return null } private _setTabStatus(tabId: string, newStatus: TabStatus): void { const tab = this.tabs.get(tabId) if (!tab) return const oldStatus = tab.status if (oldStatus === newStatus) return tab.status = newStatus log(`Tab ${tabId}: ${oldStatus} → ${newStatus}`) this.emit('tab-status-change', tabId, newStatus, oldStatus) } // ─── Shutdown ─── shutdown(): void { log('Shutting down control plane') this.permissionServer.stop() for (const [tabId] of this.tabs) { this.closeTab(tabId) } } } ================================================ FILE: src/main/claude/event-normalizer.ts ================================================ import type { ClaudeEvent, NormalizedEvent, StreamEvent, InitEvent, AssistantEvent, ResultEvent, RateLimitEvent, PermissionEvent, ContentDelta, } from '../../shared/types' /** * Maps raw Claude stream-json events to canonical CLUI events. * * The normalizer is stateless — it takes one raw event and returns * zero or more normalized events. The caller (RunManager) is responsible * for sequencing and routing. */ export function normalize(raw: ClaudeEvent): NormalizedEvent[] { switch (raw.type) { case 'system': return normalizeSystem(raw as InitEvent) case 'stream_event': return normalizeStreamEvent(raw as StreamEvent) case 'assistant': return normalizeAssistant(raw as AssistantEvent) case 'result': return normalizeResult(raw as ResultEvent) case 'rate_limit_event': return normalizeRateLimit(raw as RateLimitEvent) case 'permission_request': return normalizePermission(raw as PermissionEvent) default: // Unknown event type — skip silently (defensive) return [] } } function normalizeSystem(event: InitEvent): NormalizedEvent[] { if (event.subtype !== 'init') return [] return [{ type: 'session_init', sessionId: event.session_id, tools: event.tools || [], model: event.model || 'unknown', mcpServers: event.mcp_servers || [], skills: event.skills || [], version: event.claude_code_version || 'unknown', }] } function normalizeStreamEvent(event: StreamEvent): NormalizedEvent[] { const sub = event.event if (!sub) return [] switch (sub.type) { case 'content_block_start': { if (sub.content_block.type === 'tool_use') { return [{ type: 'tool_call', toolName: sub.content_block.name || 'unknown', toolId: sub.content_block.id || '', index: sub.index, }] } // text block start — no event needed, text comes via deltas return [] } case 'content_block_delta': { const delta = sub.delta as ContentDelta if (delta.type === 'text_delta') { return [{ type: 'text_chunk', text: delta.text }] } if (delta.type === 'input_json_delta') { return [{ type: 'tool_call_update', toolId: '', // caller can associate via index tracking partialInput: delta.partial_json, }] } return [] } case 'content_block_stop': { return [{ type: 'tool_call_complete', index: sub.index, }] } case 'message_start': case 'message_delta': case 'message_stop': // These are structural events — the assembled `assistant` event handles message completion return [] default: return [] } } function normalizeAssistant(event: AssistantEvent): NormalizedEvent[] { return [{ type: 'task_update', message: event.message, }] } function normalizeResult(event: ResultEvent): NormalizedEvent[] { if (event.is_error || event.subtype === 'error') { return [{ type: 'error', message: event.result || 'Unknown error', isError: true, sessionId: event.session_id, }] } const denials = Array.isArray((event as any).permission_denials) ? (event as any).permission_denials.map((d: any) => ({ toolName: d.tool_name || '', toolUseId: d.tool_use_id || '', })) : undefined return [{ type: 'task_complete', result: event.result || '', costUsd: event.total_cost_usd || 0, durationMs: event.duration_ms || 0, numTurns: event.num_turns || 0, usage: event.usage || {}, sessionId: event.session_id, ...(denials && denials.length > 0 ? { permissionDenials: denials } : {}), }] } function normalizeRateLimit(event: RateLimitEvent): NormalizedEvent[] { const info = event.rate_limit_info if (!info) return [] return [{ type: 'rate_limit', status: info.status, resetsAt: info.resetsAt, rateLimitType: info.rateLimitType, }] } function normalizePermission(event: PermissionEvent): NormalizedEvent[] { return [{ type: 'permission_request', questionId: event.question_id, toolName: event.tool?.name || 'unknown', toolDescription: event.tool?.description, toolInput: event.tool?.input, options: (event.options || []).map((o) => ({ id: o.id, label: o.label, kind: o.kind, })), }] } ================================================ FILE: src/main/claude/pty-run-manager.ts ================================================ /** * PtyRunManager: Interactive PTY transport for Claude Code. * * Spawns `claude` (without -p) via node-pty to get the full interactive * terminal experience, including permission prompts. Parses the PTY output * to extract text, tool calls, and permission requests, then emits * normalized events identical to RunManager. * * This module is behind the `CLUI_INTERACTIVE_PERMISSIONS_PTY` feature flag. * * Known limitations: * - Parsing depends on Claude CLI's terminal output format (Ink-based) * - ANSI stripping may lose some formatting nuance * - Permission prompt detection uses heuristics, not a formal grammar * - If the CLI's UI changes significantly, the parser may break */ import { EventEmitter } from 'events' import { homedir } from 'os' import { join } from 'path' import { execSync } from 'child_process' import { appendFileSync, chmodSync, existsSync, statSync } from 'fs' import type { NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types' import { getCliEnv } from '../cli-env' // node-pty is a native module — require at runtime to avoid Vite bundling issues // eslint-disable-next-line @typescript-eslint/no-var-requires let pty: typeof import('node-pty') try { pty = require('node-pty') } catch (err) { // Will be set when first needed — fail at startRun() time, not import time } const LOG_FILE = join(homedir(), '.clui-debug.log') const MAX_RING_LINES = 100 const PTY_BUFFER_SIZE = 50 // rolling window of cleaned lines for parser context const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const QUIESCENCE_MS = 2000 function log(msg: string): void { const line = `[${new Date().toISOString()}] [PtyRunManager] ${msg}\n` try { appendFileSync(LOG_FILE, line) } catch {} } // ─── ANSI Stripping ─── /** * Strip ANSI escape sequences (colors, cursor movement, clear line, etc.) */ function stripAnsi(str: string): string { // Covers CSI sequences including private modes like ?2004h return str.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') .replace(/\x1b\][^\x07]*\x07/g, '') // OSC sequences .replace(/\x1b[()][0-9A-Za-z]/g, '') // character set selection .replace(/\x1b[#=>\[\]]/g, '') // misc escapes .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // control chars except \n \r \t } // ─── Permission Prompt Detection ─── interface ParsedPermission { toolName: string rawPrompt: string options: Array<{ optionId: string; label: string; terminalValue: string }> } /** * Confidence-scored permission prompt detector. * Looks at a window of cleaned terminal lines and tries to identify * a Claude permission prompt. */ function detectPermissionPrompt(lines: string[]): ParsedPermission | null { const joined = lines.join('\n') // ─── Pattern 1: "Claude wants to use " or "Allow " ─── // The interactive CLI typically shows something like: // "Claude wants to use Bash" // "Command: ls -la" // "❯ Allow for this project Allow once Deny" let confidence = 0 let toolName = '' let rawPrompt = '' // Check for tool permission keywords const toolMatch = joined.match(/(?:wants?\s+to\s+(?:use|run|execute)|Tool:\s*|tool_name:\s*)(\w+)/i) if (toolMatch) { toolName = toolMatch[1] confidence += 3 } // Check for permission-specific keywords const permissionKeywords = [ /\ballow\b/i, /\bdeny\b/i, /\breject\b/i, /\bpermission\b/i, /\bapprove\b/i, ] for (const kw of permissionKeywords) { if (kw.test(joined)) confidence++ } // Check for option-like patterns (numbered or arrow-selected) const hasOptions = /(?:❯|›|>)\s*(?:Allow|Deny|Yes|No)/i.test(joined) || /\b(?:Allow\s+(?:once|always|for\s+(?:this\s+)?(?:project|session)))\b/i.test(joined) if (hasOptions) confidence += 2 // Need at least 4 confidence to declare a permission prompt if (confidence < 4) return null // ─── Extract options ─── const options: ParsedPermission['options'] = [] // Try to find option labels. The interactive CLI typically shows: // ❯ Allow for this project | Allow once | Deny // Or vertically: // ❯ Allow for this project // Allow once // Deny // Pattern: Look for Allow/Deny variants const optionPatterns = [ { pattern: /Allow\s+(?:for\s+(?:this\s+)?(?:project|session)|always)/i, label: 'Allow for this project', kind: 'allow' }, { pattern: /Allow\s+once/i, label: 'Allow once', kind: 'allow' }, { pattern: /\bAlways\s+allow\b/i, label: 'Always allow', kind: 'allow' }, { pattern: /(?:^|\s)Allow(?:\s|$)/i, label: 'Allow', kind: 'allow' }, { pattern: /\bDeny\b/i, label: 'Deny', kind: 'deny' }, { pattern: /\bReject\b/i, label: 'Reject', kind: 'deny' }, ] let optIdx = 0 for (const op of optionPatterns) { if (op.pattern.test(joined)) { optIdx++ options.push({ optionId: `opt-${optIdx}`, label: op.label, // Terminal value: we'll use arrow key navigation + Enter // The position in the list determines how many down arrows to press terminalValue: String(optIdx), }) } } // If we didn't find specific options but have high confidence, // add default Allow/Deny options if (options.length === 0 && confidence >= 4) { options.push( { optionId: 'opt-1', label: 'Allow', terminalValue: '1' }, { optionId: 'opt-2', label: 'Deny', terminalValue: '2' }, ) } // Extract the raw prompt context (last 10 lines) rawPrompt = lines.slice(-10).join('\n') return { toolName: toolName || 'Unknown', rawPrompt, options } } /** * Try to extract a session ID from terminal output. * The interactive CLI may print session info at startup. */ function extractSessionId(text: string): string | null { // Pattern: "Session: " or "session_id: " or just a UUID in init context const match = text.match(/(?:session[_ ]?id|Session|Resuming session)[:\s]+([a-f0-9-]{36})/i) return match ? match[1] : null } /** * Detect if the CLI is showing its input prompt (ready for next message). * This indicates the current response is complete. * * The Ink-based CLI renders the prompt line as something like: * "❯ " or "❯ ? for shortcuts" or "> " * After proper \r handling, the prompt should be a clean line. */ function isInputPrompt(line: string): boolean { const cleaned = line.trim() if (cleaned === '❯' || cleaned === '>' || cleaned === '$') return true // Match prompt with trailing hint text (e.g. "❯ ? for shortcuts") if (/^[❯>]\s*(?:\?\s*for\s*shortcuts)?$/.test(cleaned)) return true return false } function isUiChrome(line: string): boolean { const cleaned = line.trim() if (!cleaned) return true if (/^[╭│╰─┌└┃┏┗┐┘┤├┬┴┼]/.test(cleaned)) return true if (/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏✢✳✶✻✽]/.test(cleaned)) return true if (/^\s*(?:Medium|Low|High)\s/.test(cleaned) && /model/i.test(cleaned)) return true if (/\/mcp|MCP server/i.test(cleaned)) return true if (/Claude\s*Code\s*v/i.test(cleaned) || /ClaudeCodev/i.test(cleaned)) return true if (/^[❯>$]\s*$/.test(cleaned)) return true if (/^\$[\d.]+\s+·/.test(cleaned)) return true if (/for\s*shortcuts/i.test(cleaned)) return true if (/zigzagging|thinking|processing|nebulizing|Boondoggling/i.test(cleaned)) return true if (/^esctointerrupt/i.test(cleaned)) return true // Prompt line with hint if (/^[❯>]\s*\?\s*for\s*shortcuts/i.test(cleaned)) return true // Status bar fragments: "Opus 4.6 · Claude Max" etc. if (/Opus\s*[\d.]+\s*·/i.test(cleaned)) return true if (/Claude\s*Max/i.test(cleaned)) return true // Settings issue / doctor notice if (/settings?\s*issue|\/doctor/i.test(cleaned)) return true // Horizontal rules (all dashes/box chars) if (/^[─━▪\-=]{4,}/.test(cleaned)) return true // Only box-drawing / decoration chars if (/^[▗▖▘▝▀▄▌▐█░▒▓■□▪▫●○◆◇◈]+$/.test(cleaned)) return true return false } /** * Detect if a line looks like a tool call header from the interactive CLI. * Example: "⏳ Bash ls -la" or "✓ Read file.ts" */ function parseToolCallLine(line: string): { toolName: string; input: string } | null { // Pattern: emoji/spinner + tool name + optional input const match = line.match(/(?:⏳|⏳|✓|✗|⚡|🔧|Running|Executing)\s+(\w+)\s*(.*)/i) || line.match(/(?:Tool|Using):\s*(\w+)\s*(.*)/i) if (match) { return { toolName: match[1], input: match[2].trim() } } return null } // ─── Run Handle ─── export interface PtyRunHandle { runId: string sessionId: string | null pty: import('node-pty').IPty pid: number startedAt: number /** Ring buffer of raw PTY output for diagnostics */ rawOutputTail: string[] /** Ring buffer of stderr-like error lines */ stderrTail: string[] /** Count of tool calls seen */ toolCallCount: number /** Current pending permission prompt */ pendingPermission: ParsedPermission | null /** Permission flow phase */ permissionPhase: 'idle' | 'detecting' | 'waiting_user' | 'answered' /** Rolling window of cleaned lines for parser context */ ptyBuffer: string[] /** Timer for permission timeout */ permissionTimeout: ReturnType | null /** Accumulated text since last flush (for debounced text_chunk emission) */ textAccumulator: string /** Whether we've seen the initial welcome/init output */ pastInit: boolean /** Whether we've emitted session_init */ emittedSessionInit: boolean /** Track which options are in the current selector for arrow-key navigation */ selectorOptions: string[] /** Currently highlighted option index in the terminal selector */ currentOptionIndex: number /** Whether task_complete has already been emitted for this run */ runCompleteEmitted: boolean /** Quiescence timer used to avoid premature completion */ quiescenceTimer: ReturnType | null /** Last PTY output timestamp */ lastOutputAt: number /** Current prompt snippet used to detect the echoed user input */ promptSnippet: string /** Whether we saw an echoed prompt for current request */ sawPromptEcho: boolean } // ─── PtyRunManager ─── export class PtyRunManager extends EventEmitter { private activeRuns = new Map() private _finishedRuns = new Map() private claudeBinary: string constructor() { super() this.claudeBinary = this._findClaudeBinary() this._ensureSpawnHelperExecutable() log(`Claude binary: ${this.claudeBinary}`) } /** * node-pty prebuilt spawn-helper may lose execute bit depending on install/archive flow. * Ensure it's executable at runtime to avoid "posix_spawnp failed". */ private _ensureSpawnHelperExecutable(): void { try { const pkgPath = require.resolve('node-pty/package.json') const path = require('path') as typeof import('path') const helperPath = path.join( path.dirname(pkgPath), 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper', ) if (!existsSync(helperPath)) return const st = statSync(helperPath) const isExecutable = (st.mode & 0o111) !== 0 if (!isExecutable) { chmodSync(helperPath, 0o755) log(`Fixed spawn-helper permissions: ${helperPath}`) } } catch (err) { log(`spawn-helper permission check failed: ${(err as Error).message}`) } } private _findClaudeBinary(): string { const candidates = [ '/usr/local/bin/claude', '/opt/homebrew/bin/claude', join(homedir(), '.npm-global/bin/claude'), ] for (const c of candidates) { try { execSync(`test -x "${c}"`, { stdio: 'ignore' }) return c } catch {} } try { return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() } catch {} try { return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() } catch {} return 'claude' } private _getEnv(): NodeJS.ProcessEnv { const env = getCliEnv() const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/')) if (env.PATH && !env.PATH.includes(binDir)) { env.PATH = `${binDir}:${env.PATH}` } return env } startRun(requestId: string, options: RunOptions): PtyRunHandle { if (!pty) { throw new Error('node-pty is not available — cannot use PTY transport') } const cwd = options.projectPath === '~' ? homedir() : options.projectPath // Build args for interactive mode (no -p flag) const args: string[] = [ '--permission-mode', 'default', ] if (options.sessionId) { args.push('--resume', options.sessionId) } if (options.model) { args.push('--model', options.model) } if (options.allowedTools?.length) { args.push('--allowedTools', options.allowedTools.join(',')) } if (options.systemPrompt) { args.push('--system-prompt', options.systemPrompt) } // Pass prompt as positional argument args.push(options.prompt) log(`Starting PTY run ${requestId}: ${this.claudeBinary} ${args.join(' ')}`) log(`Prompt: ${options.prompt.substring(0, 200)}`) const ptyProcess = pty.spawn(this.claudeBinary, args, { name: 'xterm-256color', cols: 120, rows: 40, cwd, env: this._getEnv(), }) log(`Spawned PTY PID: ${ptyProcess.pid}`) const handle: PtyRunHandle = { runId: requestId, sessionId: options.sessionId || null, pty: ptyProcess, pid: ptyProcess.pid, startedAt: Date.now(), rawOutputTail: [], stderrTail: [], toolCallCount: 0, pendingPermission: null, permissionPhase: 'idle', ptyBuffer: [], permissionTimeout: null, textAccumulator: '', pastInit: false, emittedSessionInit: false, selectorOptions: [], currentOptionIndex: 0, runCompleteEmitted: false, quiescenceTimer: null, lastOutputAt: Date.now(), promptSnippet: options.prompt.trim().toLowerCase().slice(0, 24), sawPromptEcho: false, } // ─── PTY output parser pipeline ─── let lineBuffer = '' ptyProcess.onData((data: string) => { // Raw diagnostics this._ringPush(handle.rawOutputTail, data.substring(0, 500)) handle.lastOutputAt = Date.now() if (handle.quiescenceTimer) clearTimeout(handle.quiescenceTimer) handle.quiescenceTimer = setTimeout(() => this._checkQuiescenceCompletion(requestId, handle), QUIESCENCE_MS) // Ink/TUI uses \r to redraw the current line (cursor back to col 0). // PTY output commonly uses \r\r\n as line endings (Ink reset + newline). // Strategy: scan for \n to emit completed lines; treat \r immediately // before \n (or \r\n) as part of the line ending, not a redraw. // Only a \r followed by printable text is a true Ink redraw. const chars = data for (let ci = 0; ci < chars.length; ci++) { const ch = chars[ci] if (ch === '\n') { // Emit completed line (strip any trailing \r that was buffered) const completed = lineBuffer.endsWith('\r') ? lineBuffer.slice(0, -1) : lineBuffer lineBuffer = '' this._processLine(requestId, handle, completed) } else if (ch === '\r') { // Look ahead: if next char is \n or \r (part of \r\r\n), just // append \r to buffer so the \n branch can strip it. const next = ci + 1 < chars.length ? chars[ci + 1] : null if (next === '\n' || next === '\r') { // Part of line ending sequence — keep in buffer for \n to strip lineBuffer += '\r' } else if (next === null) { // End of chunk — we don't know what comes next, buffer it lineBuffer += '\r' } else { // \r followed by printable text → Ink redraw: reset line lineBuffer = '' } } else { lineBuffer += ch } } // Also process the current incomplete line for permission detection // (permission prompts may not end with newline) if (lineBuffer.length > 0) { const cleaned = stripAnsi(lineBuffer).trim() if (cleaned.length > 0) { this._checkPermissionInBuffer(requestId, handle, cleaned) } } }) ptyProcess.onExit(({ exitCode, signal }) => { log(`PTY exited [${requestId}]: code=${exitCode} signal=${signal}`) // Clear permission timeout if (handle.permissionTimeout) { clearTimeout(handle.permissionTimeout) handle.permissionTimeout = null } if (handle.quiescenceTimer) { clearTimeout(handle.quiescenceTimer) handle.quiescenceTimer = null } // Flush any accumulated text this._flushText(requestId, handle) // Emit task_complete if we haven't already if (!handle.runCompleteEmitted) { handle.runCompleteEmitted = true this.emit('normalized', requestId, { type: 'task_complete', result: '', costUsd: 0, durationMs: Date.now() - handle.startedAt, numTurns: 1, usage: {}, sessionId: handle.sessionId || '', } as NormalizedEvent) } // Move to finished runs this._finishedRuns.set(requestId, handle) this.activeRuns.delete(requestId) this.emit('exit', requestId, exitCode, signal, handle.sessionId) setTimeout(() => this._finishedRuns.delete(requestId), 5000) }) this.activeRuns.set(requestId, handle) return handle } /** * Process a single line of PTY output. */ private _processLine(requestId: string, handle: PtyRunHandle, rawLine: string): void { const cleaned = stripAnsi(rawLine).trim() if (cleaned.length === 0) return // Ignore terminal mode toggles and redraw control fragments. if (/^(?:\?[0-9;?]*[a-zA-Z])+$/i.test(cleaned)) return // Deduplicate exact redraw duplicates. if (handle.ptyBuffer.length > 0 && handle.ptyBuffer[handle.ptyBuffer.length - 1] === cleaned) return // Push to rolling buffer this._ringPushBuffer(handle.ptyBuffer, cleaned) log(`PTY line [${requestId}]: ${cleaned.substring(0, 200)}`) // ─── Try to extract session ID ─── if (!handle.emittedSessionInit) { const sid = extractSessionId(cleaned) if (sid) { handle.sessionId = sid handle.emittedSessionInit = true this.emit('normalized', requestId, { type: 'session_init', sessionId: sid, tools: [], model: '', mcpServers: [], skills: [], version: '', } as NormalizedEvent) } } // ─── Skip init/welcome output ─── if (!handle.pastInit) { // Wait until we see the echoed prompt for this request. if (/^[❯>]\s+/.test(cleaned)) { // Resume sessions may echo prior context, not the exact current prompt text. // Any echoed input prompt means init shell is ready. handle.sawPromptEcho = true } // Start parsing actual response only after a message bullet appears post-echo. if (handle.sawPromptEcho && cleaned.startsWith('⏺')) { handle.pastInit = true } else { return } } // ─── Permission phase: collecting detection context ─── if (handle.permissionPhase === 'detecting' || handle.permissionPhase === 'idle') { this._checkPermissionInBuffer(requestId, handle, cleaned) if (handle.permissionPhase === 'waiting_user') { return // Permission prompt detected and emitted } } // ─── Detect tool calls ─── const toolCall = parseToolCallLine(cleaned) if (toolCall) { handle.toolCallCount++ this._flushText(requestId, handle) this.emit('normalized', requestId, { type: 'tool_call', toolName: toolCall.toolName, toolId: `pty-tool-${handle.toolCallCount}`, index: handle.toolCallCount - 1, } as NormalizedEvent) // Also emit tool_call_complete shortly after (we can't know exact timing from PTY) setTimeout(() => { this.emit('normalized', requestId, { type: 'tool_call_complete', index: handle.toolCallCount - 1, } as NormalizedEvent) }, 100) return } // ─── Accumulate text output ─── if (isUiChrome(cleaned)) return // Accumulate text for debounced emission if (handle.textAccumulator.length > 0) { handle.textAccumulator += '\n' } const textLine = cleaned.startsWith('⏺') ? cleaned.replace(/^⏺\s*/, '') : cleaned handle.textAccumulator += textLine // Emit text chunks periodically (debounce 50ms) this._scheduleTextFlush(requestId, handle) } private _checkQuiescenceCompletion(requestId: string, handle: PtyRunHandle): void { if (!this.activeRuns.has(requestId)) return if (!handle.pastInit || handle.permissionPhase === 'waiting_user') return if (Date.now() - handle.lastOutputAt < QUIESCENCE_MS - 50) return const lastLines = handle.ptyBuffer.slice(-3) const hasPromptMarker = lastLines.some((l) => isInputPrompt(l)) if (!hasPromptMarker) return this._flushText(requestId, handle) if (!handle.runCompleteEmitted) { handle.runCompleteEmitted = true this.emit('normalized', requestId, { type: 'task_complete', result: '', costUsd: 0, durationMs: Date.now() - handle.startedAt, numTurns: 1, usage: {}, sessionId: handle.sessionId || '', } as NormalizedEvent) } try { handle.pty.write('/exit\n') } catch {} setTimeout(() => { if (this.activeRuns.has(requestId)) { try { handle.pty.kill() } catch {} } }, 3000) } private _textFlushTimers = new Map>() private _scheduleTextFlush(requestId: string, handle: PtyRunHandle): void { if (this._textFlushTimers.has(requestId)) return const timer = setTimeout(() => { this._textFlushTimers.delete(requestId) this._flushText(requestId, handle) }, 50) this._textFlushTimers.set(requestId, timer) } private _flushText(requestId: string, handle: PtyRunHandle): void { const timer = this._textFlushTimers.get(requestId) if (timer) { clearTimeout(timer) this._textFlushTimers.delete(requestId) } if (handle.textAccumulator.length > 0) { this.emit('normalized', requestId, { type: 'text_chunk', text: handle.textAccumulator, } as NormalizedEvent) handle.textAccumulator = '' } } /** * Check the current buffer for permission prompt patterns. */ private _checkPermissionInBuffer(requestId: string, handle: PtyRunHandle, currentLine: string): void { // Add current line to detection context const detectionWindow = [...handle.ptyBuffer.slice(-10), currentLine] const permission = detectPermissionPrompt(detectionWindow) if (!permission) { // Check for permission-adjacent keywords to enter detecting phase const hasKeyword = /\b(?:permission|approve|allow|deny)\b/i.test(currentLine) if (hasKeyword && handle.permissionPhase === 'idle') { handle.permissionPhase = 'detecting' } return } // Permission prompt detected! log(`Permission prompt detected [${requestId}]: tool=${permission.toolName}, options=${permission.options.length}`) handle.pendingPermission = permission handle.permissionPhase = 'waiting_user' // Flush any accumulated text first this._flushText(requestId, handle) // Generate a unique question ID const questionId = `pty-perm-${Date.now()}-${Math.random().toString(36).substring(2, 8)}` // Emit permission_request event this.emit('normalized', requestId, { type: 'permission_request', questionId, toolName: permission.toolName, toolDescription: permission.rawPrompt, options: permission.options.map((o) => ({ id: o.optionId, label: o.label, kind: o.label.toLowerCase().includes('deny') || o.label.toLowerCase().includes('reject') ? 'deny' : 'allow', })), } as NormalizedEvent) // Set timeout for user response handle.permissionTimeout = setTimeout(() => { if (handle.permissionPhase === 'waiting_user') { log(`Permission timeout [${requestId}] — auto-denying`) this.emit('normalized', requestId, { type: 'text_chunk', text: '\n[Permission timed out — automatically denied after 5 minutes]\n', } as NormalizedEvent) // Send Escape to dismiss the prompt try { handle.pty.write('\x1b') } catch {} handle.permissionPhase = 'idle' handle.pendingPermission = null } }, PERMISSION_TIMEOUT_MS) } /** * Respond to a permission prompt by sending keystrokes to the PTY. */ respondToPermission(requestId: string, _questionId: string, optionId: string): boolean { const handle = this.activeRuns.get(requestId) if (!handle) { log(`respondToPermission: no active run for ${requestId}`) return false } if (handle.permissionPhase !== 'waiting_user' || !handle.pendingPermission) { log(`respondToPermission: not waiting for permission (phase=${handle.permissionPhase})`) return false } // Clear timeout if (handle.permissionTimeout) { clearTimeout(handle.permissionTimeout) handle.permissionTimeout = null } const option = handle.pendingPermission.options.find((o) => o.optionId === optionId) if (!option) { log(`respondToPermission: option ${optionId} not found`) return false } log(`respondToPermission [${requestId}]: optionId=${optionId}, label=${option.label}`) // ─── Send keystrokes to PTY ─── // The Claude interactive CLI uses Ink's Select component. // The first option is typically "Allow for this project" and is pre-selected. // To select a different option, we press Down arrow keys then Enter. const optionIndex = handle.pendingPermission.options.indexOf(option) const isAllow = option.label.toLowerCase().includes('allow') || option.label.toLowerCase().includes('yes') const isDeny = option.label.toLowerCase().includes('deny') || option.label.toLowerCase().includes('reject') try { if (isDeny) { // Try sending 'n' first (common shortcut for deny) // If that doesn't work, navigate with arrow keys // Send Escape first to clear any state, then 'n' handle.pty.write('n') } else if (isAllow && optionIndex === 0) { // First option (typically already selected) — just press Enter handle.pty.write('\r') } else { // Navigate to the option with arrow keys then press Enter for (let i = 0; i < optionIndex; i++) { handle.pty.write('\x1b[B') // Down arrow } // Small delay then Enter setTimeout(() => { try { handle.pty.write('\r') } catch {} }, 50) } } catch (err) { log(`respondToPermission: write error: ${(err as Error).message}`) return false } handle.permissionPhase = 'answered' handle.pendingPermission = null // After answering, reset to idle for next potential permission setTimeout(() => { if (handle.permissionPhase === 'answered') { handle.permissionPhase = 'idle' } }, 500) return true } /** * Cancel a running PTY process. */ cancel(requestId: string): boolean { const handle = this.activeRuns.get(requestId) if (!handle) return false log(`Cancelling PTY run ${requestId}`) // Clear permission timeout if (handle.permissionTimeout) { clearTimeout(handle.permissionTimeout) handle.permissionTimeout = null } // Send SIGINT (Ctrl+C) try { handle.pty.write('\x03') // Ctrl+C } catch {} // Fallback: kill after 5s setTimeout(() => { if (this.activeRuns.has(requestId)) { log(`Force killing PTY run ${requestId}`) try { handle.pty.kill() } catch {} } }, 5000) return true } /** * Write arbitrary data to PTY stdin (for follow-up messages, etc.) */ writeToStdin(requestId: string, message: string): boolean { const handle = this.activeRuns.get(requestId) if (!handle) return false log(`Writing to PTY stdin [${requestId}]: ${message.substring(0, 200)}`) try { handle.pty.write(message) return true } catch { return false } } /** * Get an enriched error object for a failed PTY run. */ getEnrichedError(requestId: string, exitCode: number | null): EnrichedError { const handle = this.activeRuns.get(requestId) || this._finishedRuns.get(requestId) return { message: `PTY run failed with exit code ${exitCode}`, stderrTail: handle?.stderrTail.slice(-20) || [], stdoutTail: handle?.rawOutputTail.slice(-20) || [], exitCode, elapsedMs: handle ? Date.now() - handle.startedAt : 0, toolCallCount: handle?.toolCallCount || 0, sawPermissionRequest: handle?.permissionPhase !== 'idle' || false, permissionDenials: [], } } isRunning(requestId: string): boolean { return this.activeRuns.has(requestId) } getHandle(requestId: string): PtyRunHandle | undefined { return this.activeRuns.get(requestId) } getActiveRunIds(): string[] { return Array.from(this.activeRuns.keys()) } private _ringPush(buffer: string[], line: string): void { buffer.push(line) if (buffer.length > MAX_RING_LINES) buffer.shift() } private _ringPushBuffer(buffer: string[], line: string): void { buffer.push(line) if (buffer.length > PTY_BUFFER_SIZE) buffer.shift() } } ================================================ FILE: src/main/claude/run-manager.ts ================================================ import { spawn, execSync, ChildProcess } from 'child_process' import { EventEmitter } from 'events' import { homedir } from 'os' import { join } from 'path' import { StreamParser } from '../stream-parser' import { normalize } from './event-normalizer' import { log as _log } from '../logger' import { getCliEnv } from '../cli-env' import type { ClaudeEvent, NormalizedEvent, RunOptions, EnrichedError } from '../../shared/types' const MAX_RING_LINES = 100 const DEBUG = process.env.CLUI_DEBUG === '1' // Appended to Claude's default system prompt so it knows it's running inside CLUI. // Uses --append-system-prompt (additive) not --system-prompt (replacement). const CLUI_SYSTEM_HINT = [ 'IMPORTANT: You are NOT running in a terminal. You are running inside CLUI,', 'a desktop chat application with a rich UI that renders full markdown.', 'CLUI is a GUI wrapper around Claude Code — the user sees your output in a', 'styled conversation view, not a raw terminal.', '', 'Because CLUI renders markdown natively, you MUST use rich formatting when it helps:', '- Always use clickable markdown links: [label](https://url) — they render as real buttons.', '- When the user asks for images, and public web images are appropriate, proactively find and render them in CLUI.', '- Workflow: WebSearch for relevant public pages -> WebFetch those pages -> extract real image URLs -> render with markdown ![alt](url).', '- Do not guess, fabricate, or construct image URLs from memory.', '- Only embed images when the URL is a real publicly accessible image URL found through tools or explicitly provided by the user.', '- If real image URLs cannot be obtained confidently, fall back to clickable links and briefly say so.', '- Do not ask whether CLUI can render images; assume it can.', '- Use tables, bold, headers, and bullet lists freely — they all render beautifully.', '- Use code blocks with language tags for syntax highlighting.', '', 'You are still a software engineering assistant. Keep using your tools (Read, Edit, Bash, etc.)', 'normally. But when presenting information, links, resources, or explanations to the user,', 'take full advantage of the rich UI. The user expects a polished chat experience, not raw terminal text.', ].join('\n') // Tools auto-approved via --allowedTools (never trigger the permission card). // Includes routine internal agent mechanics (Agent, Task, TaskOutput, TodoWrite, // Notebook) — prompting for these would make UX terrible without adding meaningful // safety. This is a deliberate CLUI policy choice, not native Claude parity. // If runtime evidence shows any of these create real user-facing approval moments, // they should be moved to the hook matcher in permission-server.ts instead. const SAFE_TOOLS = [ 'Read', 'Glob', 'Grep', 'LS', 'TodoRead', 'TodoWrite', 'Agent', 'Task', 'TaskOutput', 'Notebook', 'WebSearch', 'WebFetch', ] // All tools to pre-approve when NO hook server is available (fallback path). // Includes safe + dangerous tools so nothing is silently denied. const DEFAULT_ALLOWED_TOOLS = [ 'Bash', 'Edit', 'Write', 'MultiEdit', ...SAFE_TOOLS, ] function log(msg: string): void { _log('RunManager', msg) } export interface RunHandle { runId: string sessionId: string | null process: ChildProcess pid: number | null startedAt: number /** Ring buffer of last N stderr lines */ stderrTail: string[] /** Ring buffer of last N stdout lines */ stdoutTail: string[] /** Count of tool calls seen during this run */ toolCallCount: number /** Whether any permission_request event was seen during this run */ sawPermissionRequest: boolean /** Permission denials from result event */ permissionDenials: Array<{ tool_name: string; tool_use_id: string }> } /** * RunManager: spawns one `claude -p` process per run, parses NDJSON, * emits normalized events, handles cancel, and keeps diagnostic ring buffers. * * Events emitted: * - 'normalized' (runId, NormalizedEvent) * - 'raw' (runId, ClaudeEvent) — for logging/debugging * - 'exit' (runId, code, signal, sessionId) * - 'error' (runId, Error) */ export class RunManager extends EventEmitter { private activeRuns = new Map() /** Holds recently-finished runs so diagnostics survive past process exit */ private _finishedRuns = new Map() private claudeBinary: string constructor() { super() this.claudeBinary = this._findClaudeBinary() log(`Claude binary: ${this.claudeBinary}`) } private _findClaudeBinary(): string { const candidates = [ '/usr/local/bin/claude', '/opt/homebrew/bin/claude', join(homedir(), '.npm-global/bin/claude'), ] for (const c of candidates) { try { execSync(`test -x "${c}"`, { stdio: 'ignore' }) return c } catch {} } try { return execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() } catch {} try { return execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() } catch {} return 'claude' } private _getEnv(): NodeJS.ProcessEnv { const env = getCliEnv() const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/')) if (env.PATH && !env.PATH.includes(binDir)) { env.PATH = `${binDir}:${env.PATH}` } return env } startRun(requestId: string, options: RunOptions): RunHandle { const cwd = options.projectPath === '~' ? homedir() : options.projectPath const args: string[] = [ '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', 'default', ] if (options.sessionId) { args.push('--resume', options.sessionId) } if (options.model) { args.push('--model', options.model) } if (options.addDirs && options.addDirs.length > 0) { for (const dir of options.addDirs) { args.push('--add-dir', dir) } } if (options.hookSettingsPath) { // CLUI-scoped hook settings: the PreToolUse HTTP hook handles permissions // for dangerous tools (Bash, Edit, Write, MultiEdit). // Auto-approve safe tools so they don't trigger the permission card. args.push('--settings', options.hookSettingsPath) const safeAllowed = [ ...SAFE_TOOLS, ...(options.allowedTools || []), ] args.push('--allowedTools', safeAllowed.join(',')) } else { // Fallback: no hook server available. // Pre-approve common tools so they run without being silently denied. const allAllowed = [ ...DEFAULT_ALLOWED_TOOLS, ...(options.allowedTools || []), ] args.push('--allowedTools', allAllowed.join(',')) } if (options.maxTurns) { args.push('--max-turns', String(options.maxTurns)) } if (options.maxBudgetUsd) { args.push('--max-budget-usd', String(options.maxBudgetUsd)) } if (options.systemPrompt) { args.push('--system-prompt', options.systemPrompt) } // Always tell Claude it's inside CLUI (additive, doesn't replace base prompt) args.push('--append-system-prompt', CLUI_SYSTEM_HINT) if (DEBUG) { log(`Starting run ${requestId}: ${this.claudeBinary} ${args.join(' ')}`) log(`Prompt: ${options.prompt.substring(0, 200)}`) } else { log(`Starting run ${requestId}`) } const child = spawn(this.claudeBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd, env: this._getEnv(), }) log(`Spawned PID: ${child.pid}`) const handle: RunHandle = { runId: requestId, sessionId: options.sessionId || null, process: child, pid: child.pid || null, startedAt: Date.now(), stderrTail: [], stdoutTail: [], toolCallCount: 0, sawPermissionRequest: false, permissionDenials: [], } // ─── stdout → NDJSON parser → normalizer → events ─── const parser = StreamParser.fromStream(child.stdout!) parser.on('event', (raw: ClaudeEvent) => { // Track session ID if (raw.type === 'system' && 'subtype' in raw && raw.subtype === 'init') { handle.sessionId = (raw as any).session_id } // Track permission_request events if (raw.type === 'permission_request' || (raw.type === 'system' && 'subtype' in raw && (raw as any).subtype === 'permission_request')) { handle.sawPermissionRequest = true log(`Permission request seen [${requestId}]`) } // Extract permission_denials from result event if (raw.type === 'result') { const denials = (raw as any).permission_denials if (Array.isArray(denials) && denials.length > 0) { handle.permissionDenials = denials.map((d: any) => ({ tool_name: d.tool_name || '', tool_use_id: d.tool_use_id || '', })) log(`Permission denials [${requestId}]: ${JSON.stringify(handle.permissionDenials)}`) } } // Ring buffer stdout lines (raw JSON for diagnostics) this._ringPush(handle.stdoutTail, JSON.stringify(raw).substring(0, 300)) // Emit raw event for debugging this.emit('raw', requestId, raw) // Normalize and emit canonical events const normalized = normalize(raw) for (const evt of normalized) { if (evt.type === 'tool_call') handle.toolCallCount++ this.emit('normalized', requestId, evt) } // Close stdin after result event — with stream-json input the process // stays alive waiting for more input; closing stdin triggers clean exit. if (raw.type === 'result') { log(`Run complete [${requestId}]: sawPermissionRequest=${handle.sawPermissionRequest}, denials=${handle.permissionDenials.length}`) try { child.stdin?.end() } catch {} } }) parser.on('parse-error', (line: string) => { log(`Parse error [${requestId}]: ${line.substring(0, 200)}`) this._ringPush(handle.stderrTail, `[parse-error] ${line.substring(0, 200)}`) }) // ─── stderr ring buffer ─── child.stderr?.setEncoding('utf-8') child.stderr?.on('data', (data: string) => { const lines = data.split('\n').filter((l: string) => l.trim()) for (const line of lines) { this._ringPush(handle.stderrTail, line) } log(`Stderr [${requestId}]: ${data.trim().substring(0, 500)}`) }) // ─── Process lifecycle ─── // Snapshot diagnostics BEFORE deleting the handle so callers can still read them. child.on('close', (code, signal) => { log(`Process closed [${requestId}]: code=${code} signal=${signal}`) // Move handle to finished map so getEnrichedError still works after exit this._finishedRuns.set(requestId, handle) this.activeRuns.delete(requestId) this.emit('exit', requestId, code, signal, handle.sessionId) // Clean up finished run after a short delay (gives callers time to read diagnostics) setTimeout(() => this._finishedRuns.delete(requestId), 5000) }) child.on('error', (err) => { log(`Process error [${requestId}]: ${err.message}`) this._finishedRuns.set(requestId, handle) this.activeRuns.delete(requestId) this.emit('error', requestId, err) setTimeout(() => this._finishedRuns.delete(requestId), 5000) }) // ─── Write prompt to stdin (stream-json format, keep open) ─── // Using --input-format stream-json for bidirectional communication. // Stdin stays open so follow-up messages can be sent. const userMessage = JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: options.prompt }], }, }) child.stdin!.write(userMessage + '\n') this.activeRuns.set(requestId, handle) return handle } /** * Write a message to a running process's stdin (for follow-up prompts, etc.) */ writeToStdin(requestId: string, message: object): boolean { const handle = this.activeRuns.get(requestId) if (!handle) return false if (!handle.process.stdin || handle.process.stdin.destroyed) return false const json = JSON.stringify(message) log(`Writing to stdin [${requestId}]: ${json.substring(0, 200)}`) handle.process.stdin.write(json + '\n') return true } /** * Cancel a running process: SIGINT, then SIGKILL after 5s. */ cancel(requestId: string): boolean { const handle = this.activeRuns.get(requestId) if (!handle) return false log(`Cancelling run ${requestId}`) handle.process.kill('SIGINT') // Fallback: SIGKILL if process hasn't exited after 5s. // Only check exitCode — process.killed is set true by the SIGINT call above, // so checking !killed would prevent the fallback from ever firing. setTimeout(() => { if (handle.process.exitCode === null) { log(`Force killing run ${requestId} (SIGINT did not terminate)`) handle.process.kill('SIGKILL') } }, 5000) return true } /** * Get an enriched error object for a failed run. */ getEnrichedError(requestId: string, exitCode: number | null): EnrichedError { const handle = this.activeRuns.get(requestId) || this._finishedRuns.get(requestId) return { message: `Run failed with exit code ${exitCode}`, stderrTail: handle?.stderrTail.slice(-20) || [], stdoutTail: handle?.stdoutTail.slice(-20) || [], exitCode, elapsedMs: handle ? Date.now() - handle.startedAt : 0, toolCallCount: handle?.toolCallCount || 0, sawPermissionRequest: handle?.sawPermissionRequest || false, permissionDenials: handle?.permissionDenials || [], } } isRunning(requestId: string): boolean { return this.activeRuns.has(requestId) } getHandle(requestId: string): RunHandle | undefined { return this.activeRuns.get(requestId) } getActiveRunIds(): string[] { return Array.from(this.activeRuns.keys()) } private _ringPush(buffer: string[], line: string): void { buffer.push(line) if (buffer.length > MAX_RING_LINES) { buffer.shift() } } } ================================================ FILE: src/main/cli-env.ts ================================================ import { execSync } from 'child_process' let cachedPath: string | null = null function appendPathEntries(target: string[], seen: Set, rawPath: string | undefined): void { if (!rawPath) return for (const entry of rawPath.split(':')) { const p = entry.trim() if (!p || seen.has(p)) continue seen.add(p) target.push(p) } } export function getCliPath(): string { if (cachedPath) return cachedPath const ordered: string[] = [] const seen = new Set() // Start from current process PATH. appendPathEntries(ordered, seen, process.env.PATH) // Add common binary locations used on macOS (Homebrew + system). appendPathEntries(ordered, seen, '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin') // Try interactive login shell first so nvm/asdf/etc. PATH hooks are loaded. const pathCommands = [ '/bin/zsh -ilc "echo $PATH"', '/bin/zsh -lc "echo $PATH"', '/bin/bash -lc "echo $PATH"', ] for (const cmd of pathCommands) { try { const discovered = execSync(cmd, { encoding: 'utf-8', timeout: 3000 }).trim() appendPathEntries(ordered, seen, discovered) } catch { // Keep trying fallbacks. } } cachedPath = ordered.join(':') return cachedPath } export function getCliEnv(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, ...extraEnv, PATH: getCliPath(), } delete env.CLAUDECODE return env } ================================================ FILE: src/main/hooks/permission-server.ts ================================================ /** * Permission Hook Server * * A local HTTP server that acts as a Claude Code PreToolUse hook handler. * When Claude Code wants to use a tool, it POSTs the tool request here. * The server forwards it to the renderer (PermissionCard), waits for the * user's decision, and returns the structured hook response. * * This is a CLUI-owned permission broker that approximates Claude Code's * practical permission cadence. It does not reproduce native permission * semantics exactly — it intercepts the small set of tool classes that * map to real, user-meaningful approval moments. * * Security: * - Per-launch app secret in URL path (prevents local spoofing) * - Per-run token in URL path (prevents cross-run confusion) * - Deny-by-default on every failure path * - Per-run settings files (only CLUI-spawned sessions see the hook) */ import { createServer, IncomingMessage, ServerResponse } from 'http' import { EventEmitter } from 'events' import { writeFileSync, mkdirSync, unlinkSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { randomUUID } from 'crypto' import { log as _log } from '../logger' const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes const DEFAULT_PORT = 19836 const MAX_BODY_SIZE = 1024 * 1024 // 1MB const DEBUG = process.env.CLUI_DEBUG === '1' // Tools that need explicit user approval via the permission card. // This is the small set of tool classes that map to real, user-meaningful // approval moments. Routine internal agent mechanics (Read, Glob, Grep, etc.) // are auto-approved via --allowedTools to avoid noisy UX. const PERMISSION_REQUIRED_TOOLS = ['Bash', 'Edit', 'Write', 'MultiEdit'] // Bash commands that are clearly read-only and safe to auto-approve. // Matches the leading command (before any pipes, semicolons, or &&). const SAFE_BASH_COMMANDS = new Set([ // Info / help 'cat', 'head', 'tail', 'less', 'more', 'wc', 'file', 'stat', 'ls', 'pwd', 'echo', 'printf', 'date', 'whoami', 'hostname', 'uname', 'which', 'whence', 'where', 'type', 'command', 'man', 'help', 'info', // Search 'find', 'grep', 'rg', 'ag', 'ack', 'fd', 'fzf', 'locate', // Git read-only 'git', // further checked: only read-only subcommands // Env / config 'env', 'printenv', 'set', // Package info (read-only) 'npm', 'yarn', 'pnpm', 'bun', 'cargo', 'pip', 'pip3', 'go', 'rustup', 'node', 'python', 'python3', 'ruby', 'java', 'javac', // Claude CLI (read-only subcommands) 'claude', // Disk / system info 'df', 'du', 'free', 'top', 'htop', 'ps', 'uptime', 'lsof', 'tree', 'realpath', 'dirname', 'basename', // macOS 'sw_vers', 'system_profiler', 'defaults', 'mdls', 'mdfind', // Diff / compare 'diff', 'cmp', 'comm', 'sort', 'uniq', 'cut', 'awk', 'sed', 'jq', 'yq', 'xargs', 'tr', ]) // Git subcommands that mutate state (not safe to auto-approve) const GIT_MUTATING_SUBCOMMANDS = new Set([ 'push', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch', 'branch', 'tag', 'stash', 'cherry-pick', 'revert', 'am', 'apply', 'clean', 'rm', 'mv', 'restore', 'bisect', 'pull', 'fetch', 'clone', 'init', 'submodule', 'worktree', 'gc', 'prune', 'filter-branch', ]) // Claude subcommands that mutate state const CLAUDE_MUTATING_SUBCOMMANDS = new Set([ 'config', 'login', 'logout', ]) /** Check if a Bash command string is safe (read-only) */ function isSafeBashCommand(command: unknown): boolean { if (typeof command !== 'string') return false const trimmed = command.trim() if (!trimmed) return false // Extract the first command (before any chaining operators) // Split on ;, &&, ||, | and check each segment const segments = trimmed.split(/\s*(?:;|&&|\|\||[|])\s*/) for (const segment of segments) { const parts = segment.trim().split(/\s+/) const cmd = parts[0] if (!cmd) continue // Handle env prefix patterns like: VAR=val command const actualCmd = cmd.includes('=') ? parts[1] : cmd if (!actualCmd) continue // Strip path prefix (e.g., /usr/bin/git → git) const base = actualCmd.split('/').pop() || actualCmd if (!SAFE_BASH_COMMANDS.has(base)) return false // Extra check for git: only allow read-only subcommands if (base === 'git') { const subIdx = cmd.includes('=') ? 2 : 1 const sub = parts[subIdx] if (sub && GIT_MUTATING_SUBCOMMANDS.has(sub)) return false } // Extra check for claude: only allow read-only subcommands if (base === 'claude') { const subIdx = cmd.includes('=') ? 2 : 1 const sub = parts[subIdx] // claude mcp remove, claude config set, etc. if (sub && CLAUDE_MUTATING_SUBCOMMANDS.has(sub)) return false // claude mcp remove specifically if (sub === 'mcp') { const mcpSub = parts[subIdx + 1] if (mcpSub && mcpSub !== 'list' && mcpSub !== 'get' && mcpSub !== '--help') return false } } // Extra check for npm/yarn/pnpm/bun: block install/publish/run if (['npm', 'yarn', 'pnpm', 'bun'].includes(base)) { const subIdx = cmd.includes('=') ? 2 : 1 const sub = parts[subIdx] if (sub && ['install', 'i', 'add', 'remove', 'uninstall', 'publish', 'run', 'exec', 'dlx', 'npx', 'create', 'init', 'link', 'unlink', 'pack', 'deprecate'].includes(sub)) return false } // Block redirections that write to files if (segment.includes('>') && !segment.includes('>/dev/null') && !segment.includes('2>/dev/null') && !segment.includes('2>&1')) return false } return true } // Regex matcher for the hook config — intercept dangerous tools + external MCP tools. const HOOK_MATCHER = `^(${PERMISSION_REQUIRED_TOOLS.join('|')}|mcp__.*)$` // Fields in tool_input that should be redacted in logs const SENSITIVE_FIELD_RE = /token|password|secret|key|auth|credential|api.?key/i // Exhaustive whitelist of valid decision IDs from permission card options. // Any decision not in this set is denied (fail-closed). const VALID_ALLOW_DECISIONS = new Set(['allow', 'allow-session', 'allow-domain']) const VALID_DECISIONS = new Set([...VALID_ALLOW_DECISIONS, 'deny']) function log(msg: string): void { _log('PermissionServer', msg) } /** Extract domain from a URL string, returns null on failure */ function extractDomain(url: unknown): string | null { if (typeof url !== 'string') return null try { return new URL(url).hostname } catch { return null } } /** Build a deny hook response */ function denyResponse(reason: string) { return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason, }, } } /** Build an allow hook response */ function allowResponse(reason: string) { return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: reason, }, } } export interface HookToolRequest { session_id: string transcript_path: string cwd: string permission_mode: string hook_event_name: string tool_name: string tool_input: Record tool_use_id: string } export interface PermissionDecision { decision: 'allow' | 'deny' reason?: string } export interface PermissionOption { id: string label: string kind: 'allow' | 'deny' } interface PendingRequest { toolRequest: HookToolRequest resolve: (decision: PermissionDecision) => void timeout: ReturnType questionId: string runToken: string } interface RunRegistration { tabId: string requestId: string sessionId: string | null } /** * PermissionServer: HTTP server for Claude Code PreToolUse hooks. * * Events: * - 'permission-request' (questionId, toolRequest, tabId, options) — forward to renderer */ export class PermissionServer extends EventEmitter { private server: ReturnType | null = null private pendingRequests = new Map() private port: number private _actualPort: number | null = null /** Per-launch secret — validates that requests come from our hooks */ private appSecret: string /** Per-run tokens → run registration (tabId, requestId, sessionId) */ private runTokens = new Map() /** Scoped "allow always" keys. Format varies by tool type. */ private scopedAllows = new Set() /** Tracked generated settings files: runToken → filePath */ private settingsFiles = new Map() constructor(port = DEFAULT_PORT) { super() this.port = port this.appSecret = randomUUID() } async start(): Promise { if (this.server) { log('Server already running') return this._actualPort || this.port } return new Promise((resolve, reject) => { this.server = createServer((req, res) => this._handleRequest(req, res)) this.server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { log(`Port ${this.port} in use, trying ${this.port + 1}`) this.port++ this.server!.listen(this.port, '127.0.0.1') } else { log(`Server error: ${err.message}`) reject(err) } }) this.server.listen(this.port, '127.0.0.1', () => { this._actualPort = this.port log(`Permission server listening on 127.0.0.1:${this.port}`) resolve(this.port) }) }) } stop(): void { // Deny all pending requests for (const [qid, pending] of this.pendingRequests) { clearTimeout(pending.timeout) pending.resolve({ decision: 'deny', reason: 'Server shutting down' }) this.pendingRequests.delete(qid) } // Clean up all remaining settings files (best-effort) for (const [, filePath] of this.settingsFiles) { try { unlinkSync(filePath) } catch {} } this.settingsFiles.clear() if (this.server) { this.server.close() this.server = null log('Permission server stopped') } } getPort(): number | null { return this._actualPort } // ─── Run Registration ─── /** * Register a new run. Returns a unique run token. * The run token is embedded in the hook URL for per-run routing. */ registerRun(tabId: string, requestId: string, sessionId: string | null): string { const runToken = randomUUID() this.runTokens.set(runToken, { tabId, requestId, sessionId }) log(`Registered run: token=${runToken.substring(0, 8)}… tab=${tabId.substring(0, 8)}…`) return runToken } /** * Unregister a run. Denies any pending requests for this run and cleans up its settings file. */ unregisterRun(runToken: string): void { const reg = this.runTokens.get(runToken) if (!reg) return // Deny any pending requests associated with this run for (const [qid, pending] of this.pendingRequests) { if (pending.runToken === runToken) { clearTimeout(pending.timeout) pending.resolve({ decision: 'deny', reason: 'Run ended' }) this.pendingRequests.delete(qid) } } // Clean up settings file for this run const filePath = this.settingsFiles.get(runToken) if (filePath) { try { unlinkSync(filePath) } catch {} this.settingsFiles.delete(runToken) } this.runTokens.delete(runToken) log(`Unregistered run: token=${runToken.substring(0, 8)}…`) } // ─── Permission Response ─── /** * Respond to a pending permission request. * decision: 'allow' (once), 'allow-session' (for session), 'allow-domain' (WebFetch domain), 'deny' */ respondToPermission(questionId: string, decision: string, reason?: string): boolean { const pending = this.pendingRequests.get(questionId) if (!pending) { log(`respondToPermission: no pending request for ${questionId}`) return false } clearTimeout(pending.timeout) this.pendingRequests.delete(questionId) // Fail-closed: reject unknown decision IDs immediately if (!VALID_DECISIONS.has(decision)) { log(`Unknown decision "${decision}" for [${questionId}] — denying (fail-closed)`) pending.resolve({ decision: 'deny', reason: `Unknown decision: ${decision}` }) return true } const toolName = pending.toolRequest.tool_name const sessionId = pending.toolRequest.session_id // Handle scoped "allow always" decisions if (decision === 'allow-session') { const key = `session:${sessionId}:tool:${toolName}` this.scopedAllows.add(key) log(`Session-allowed ${toolName} for session ${sessionId.substring(0, 8)}…`) } else if (decision === 'allow-domain') { const domain = extractDomain(pending.toolRequest.tool_input?.url) if (domain) { const key = `session:${sessionId}:webfetch:${domain}` this.scopedAllows.add(key) log(`Domain-allowed ${domain} for session ${sessionId.substring(0, 8)}…`) } } const hookDecision: 'allow' | 'deny' = VALID_ALLOW_DECISIONS.has(decision) ? 'allow' : 'deny' if (DEBUG) { log(`respondToPermission [${questionId}]: ${decision} (tool=${toolName})`) } else { log(`Permission: ${toolName} → ${hookDecision}`) } pending.resolve({ decision: hookDecision, reason }) return true } // ─── Dynamic Options ─── /** * Get permission card options for a given tool + input. * WebFetch gets domain-scoped options; all others get session-scoped. */ getOptionsForTool(toolName: string, toolInput?: Record): PermissionOption[] { // Bash commands are too diverse for session-scoped blanket allow — // each command should be individually reviewed. if (toolName === 'Bash') { return [ { id: 'allow', label: 'Allow Once', kind: 'allow' }, { id: 'deny', label: 'Deny', kind: 'deny' }, ] } // Edit, Write, MultiEdit, mcp__* — session-scoped allow is safe return [ { id: 'allow', label: 'Allow Once', kind: 'allow' }, { id: 'allow-session', label: 'Allow for Session', kind: 'allow' }, { id: 'deny', label: 'Deny', kind: 'deny' }, ] } // ─── Settings File Generation ─── /** * Generate a per-run settings file with the PreToolUse HTTP hook. * The URL includes both appSecret and runToken for authentication. */ generateSettingsFile(runToken: string): string { const port = this._actualPort || this.port const settings = { hooks: { PreToolUse: [ { matcher: HOOK_MATCHER, hooks: [ { type: 'http', url: `http://127.0.0.1:${port}/hook/pre-tool-use/${this.appSecret}/${runToken}`, timeout: 300, }, ], }, ], }, } const dir = join(tmpdir(), 'clui-hook-config') try { mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch {} const filePath = join(dir, `clui-hook-${runToken}.json`) writeFileSync(filePath, JSON.stringify(settings, null, 2), { mode: 0o600 }) this.settingsFiles.set(runToken, filePath) if (DEBUG) { log(`Generated settings file: ${filePath}`) } return filePath } // ─── HTTP Request Handling ─── private async _handleRequest(req: IncomingMessage, res: ServerResponse): Promise { // POST only — deny everything else if (req.method !== 'POST') { res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Not found'))) return } // Parse URL: /hook/pre-tool-use// const segments = (req.url || '').split('/').filter(Boolean) if (segments.length !== 4 || segments[0] !== 'hook' || segments[1] !== 'pre-tool-use') { res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Invalid path'))) return } const urlSecret = segments[2] const urlToken = segments[3] // Validate app secret if (urlSecret !== this.appSecret) { log('Rejected request: invalid app secret') res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Invalid credentials'))) return } // Validate run token const registration = this.runTokens.get(urlToken) if (!registration) { log(`Rejected request: unknown run token ${urlToken.substring(0, 8)}…`) res.writeHead(403, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Unknown run'))) return } // Read body with size limit let body = '' let bodySize = 0 for await (const chunk of req) { bodySize += (chunk as Buffer).length if (bodySize > MAX_BODY_SIZE) { log('Rejected request: body too large') res.writeHead(413, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Request too large'))) return } body += chunk } // Parse JSON let toolRequest: HookToolRequest try { toolRequest = JSON.parse(body) as HookToolRequest } catch { log('Rejected request: invalid JSON') res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Invalid JSON'))) return } // Validate required fields if (!toolRequest.tool_name || !toolRequest.session_id || !toolRequest.hook_event_name) { log('Rejected request: missing required fields') res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Missing required fields'))) return } // Validate hook event name if (toolRequest.hook_event_name !== 'PreToolUse') { log(`Rejected request: unexpected hook event ${toolRequest.hook_event_name}`) res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(denyResponse('Unexpected hook event'))) return } if (DEBUG) { log(`Hook request: tool=${toolRequest.tool_name} id=${toolRequest.tool_use_id} session=${toolRequest.session_id} tab=${registration.tabId.substring(0, 8)}…`) } else { log(`Hook: ${toolRequest.tool_name} → tab=${registration.tabId.substring(0, 8)}…`) } // Check scoped allows const sessionId = toolRequest.session_id const toolName = toolRequest.tool_name // Check session-scoped allow if (this.scopedAllows.has(`session:${sessionId}:tool:${toolName}`)) { if (DEBUG) log(`Auto-allowing ${toolName} (session-allowed)`) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(allowResponse('Allowed for session by user'))) return } // Check domain-scoped allow (WebFetch) if (toolName === 'WebFetch') { const domain = extractDomain(toolRequest.tool_input?.url) if (domain && this.scopedAllows.has(`session:${sessionId}:webfetch:${domain}`)) { if (DEBUG) log(`Auto-allowing WebFetch to ${domain} (domain-allowed)`) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(allowResponse(`Domain ${domain} allowed by user`))) return } } // Auto-approve safe (read-only) Bash commands without prompting if (toolName === 'Bash' && isSafeBashCommand(toolRequest.tool_input?.command)) { if (DEBUG) log(`Auto-allowing safe Bash: ${String(toolRequest.tool_input?.command).substring(0, 80)}`) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(allowResponse('Safe read-only command'))) return } // Generate question ID and wait for user decision const questionId = `hook-${Date.now()}-${Math.random().toString(36).substring(2, 8)}` const decision = await new Promise((resolve) => { const timeout = setTimeout(() => { log(`Permission timeout [${questionId}] — auto-denying`) this.pendingRequests.delete(questionId) resolve({ decision: 'deny', reason: 'Permission timed out after 5 minutes' }) }, PERMISSION_TIMEOUT_MS) this.pendingRequests.set(questionId, { toolRequest, resolve, timeout, questionId, runToken: urlToken, }) // Get tool-specific options for the permission card const options = this.getOptionsForTool(toolName, toolRequest.tool_input) // Emit with direct tabId from registration — no session_id lookup needed this.emit('permission-request', questionId, toolRequest, registration.tabId, options) }) // Return structured hook response const hookResponse = decision.decision === 'allow' ? allowResponse(decision.reason || 'Approved by user') : denyResponse(decision.reason || 'Denied by user') if (DEBUG) { log(`Hook response [${questionId}]: ${decision.decision}`) } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(hookResponse)) } } /** Mask sensitive fields in tool_input (recursive). Exported for defense-in-depth use by control-plane. */ export function maskSensitiveFields(input: Record): Record { const masked: Record = {} for (const [key, value] of Object.entries(input)) { if (SENSITIVE_FIELD_RE.test(key)) { masked[key] = '***' } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) { masked[key] = maskSensitiveFields(value as Record) } else if (Array.isArray(value)) { masked[key] = value.map(item => item !== null && typeof item === 'object' && !Array.isArray(item) ? maskSensitiveFields(item as Record) : item ) } else { masked[key] = value } } return masked } ================================================ FILE: src/main/index.ts ================================================ import { app, BrowserWindow, ipcMain, dialog, screen, globalShortcut, Tray, Menu, nativeImage, nativeTheme, shell, systemPreferences, session } from 'electron' import { join } from 'path' import { existsSync, readdirSync, statSync, createReadStream } from 'fs' import { createInterface } from 'readline' import { homedir } from 'os' import { ControlPlane } from './claude/control-plane' import { ensureSkills, type SkillStatus } from './skills/installer' import { fetchCatalog, listInstalled, installPlugin, uninstallPlugin } from './marketplace/catalog' import { log as _log, LOG_FILE, flushLogs } from './logger' import { getCliEnv } from './cli-env' import { IPC } from '../shared/types' import type { RunOptions, NormalizedEvent, EnrichedError } from '../shared/types' const DEBUG_MODE = process.env.CLUI_DEBUG === '1' const SPACES_DEBUG = DEBUG_MODE || process.env.CLUI_SPACES_DEBUG === '1' function getContentSecurityPolicy(): string { const isDev = !!process.env.ELECTRON_RENDERER_URL const connectSrc = isDev ? "connect-src 'self' ws://localhost:* http://localhost:*;" : "connect-src 'self';" const scriptSrc = isDev ? "script-src 'self' 'unsafe-inline' 'unsafe-eval';" : "script-src 'self';" return [ "default-src 'none'", scriptSrc, "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "media-src 'self' data: blob:", "font-src 'self'", connectSrc, "object-src 'none'", "base-uri 'none'", "frame-src 'none'", ].join('; ') } function installContentSecurityPolicy(): void { const csp = getContentSecurityPolicy() session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [csp], }, }) }) } function log(msg: string): void { _log('main', msg) } let mainWindow: BrowserWindow | null = null let tray: Tray | null = null let screenshotCounter = 0 let toggleSequence = 0 let lastWindowBounds: Electron.Rectangle | null = null // Feature flag: enable PTY interactive permissions transport const INTERACTIVE_PTY = process.env.CLUI_INTERACTIVE_PERMISSIONS_PTY === '1' const controlPlane = new ControlPlane(INTERACTIVE_PTY) // Keep native width fixed to avoid renderer animation vs setBounds race. // The UI itself still launches in compact mode; extra width is transparent/click-through. const BAR_WIDTH = 1040 const PILL_HEIGHT = 720 // Fixed native window height — extra room for expanded UI + shadow buffers const PILL_BOTTOM_MARGIN = 24 // ─── Broadcast to renderer ─── function broadcast(channel: string, ...args: unknown[]): void { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, ...args) } } function snapshotWindowState(reason: string): void { if (!SPACES_DEBUG) return if (!mainWindow || mainWindow.isDestroyed()) { log(`[spaces] ${reason} window=none`) return } const b = mainWindow.getBounds() const cursor = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursor) const visibleOnAll = mainWindow.isVisibleOnAllWorkspaces() const wcFocused = mainWindow.webContents.isFocused() log( `[spaces] ${reason} ` + `vis=${mainWindow.isVisible()} focused=${mainWindow.isFocused()} wcFocused=${wcFocused} ` + `alwaysOnTop=${mainWindow.isAlwaysOnTop()} allWs=${visibleOnAll} ` + `bounds=(${b.x},${b.y},${b.width}x${b.height}) ` + `cursor=(${cursor.x},${cursor.y}) display=${display.id} ` + `workArea=(${display.workArea.x},${display.workArea.y},${display.workArea.width}x${display.workArea.height})` ) } function scheduleToggleSnapshots(toggleId: number, phase: 'show' | 'hide'): void { if (!SPACES_DEBUG) return const probes = [0, 100, 400, 1200] for (const delay of probes) { setTimeout(() => { snapshotWindowState(`toggle#${toggleId} ${phase} +${delay}ms`) }, delay) } } // ─── Wire ControlPlane events → renderer ─── controlPlane.on('event', (tabId: string, event: NormalizedEvent) => { broadcast('clui:normalized-event', tabId, event) }) controlPlane.on('tab-status-change', (tabId: string, newStatus: string, oldStatus: string) => { broadcast('clui:tab-status-change', tabId, newStatus, oldStatus) }) controlPlane.on('error', (tabId: string, error: EnrichedError) => { broadcast('clui:enriched-error', tabId, error) }) // ─── Window Creation ─── function createWindow(): void { const cursor = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursor) const { width: screenWidth, height: screenHeight } = display.workAreaSize const { x: dx, y: dy } = display.workArea const x = dx + Math.round((screenWidth - BAR_WIDTH) / 2) const y = dy + screenHeight - PILL_HEIGHT - PILL_BOTTOM_MARGIN mainWindow = new BrowserWindow({ width: BAR_WIDTH, height: PILL_HEIGHT, x, y, ...(process.platform === 'darwin' ? { type: 'panel' as const } : {}), // NSPanel — non-activating, joins all spaces frame: false, transparent: true, resizable: false, movable: true, alwaysOnTop: true, skipTaskbar: true, hasShadow: false, roundedCorners: true, backgroundColor: '#00000000', show: false, icon: join(__dirname, '../../resources/icon.icns'), webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, allowRunningInsecureContent: false, }, }) lastWindowBounds = mainWindow.getBounds() // Belt-and-suspenders: panel already joins all spaces and floats, // but explicit flags ensure correct behavior on older Electron builds. mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) mainWindow.setAlwaysOnTop(true, 'screen-saver') mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) mainWindow.webContents.on('will-navigate', (event) => { event.preventDefault() }) mainWindow.once('ready-to-show', () => { mainWindow?.show() // Enable OS-level click-through for transparent regions. // { forward: true } ensures mousemove events still reach the renderer // so it can toggle click-through off when cursor enters interactive UI. mainWindow?.setIgnoreMouseEvents(true, { forward: true }) if (process.env.ELECTRON_RENDERER_URL) { mainWindow?.webContents.openDevTools({ mode: 'detach' }) } }) let forceQuit = false app.on('before-quit', () => { forceQuit = true }) mainWindow.on('close', (e) => { if (!forceQuit) { e.preventDefault() mainWindow?.hide() } }) if (process.env.ELECTRON_RENDERER_URL) { mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } function showWindow(source = 'unknown'): void { if (!mainWindow) return const toggleId = ++toggleSequence if (lastWindowBounds) { mainWindow.setBounds(lastWindowBounds) } // Always re-assert space membership — the flag can be lost after hide/show cycles // and must be set before show() so the window joins the active Space, not its // last-known Space. mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) if (SPACES_DEBUG) { const b = mainWindow.getBounds() log(`[spaces] showWindow#${toggleId} source=${source} preserve-bounds=(${b.x},${b.y},${b.width}x${b.height})`) snapshotWindowState(`showWindow#${toggleId} pre-show`) } // As an accessory app (app.dock.hide), show() + focus gives keyboard // without deactivating the active app — hover preserved everywhere. mainWindow.show() if (lastWindowBounds) { mainWindow.setBounds(lastWindowBounds) } mainWindow.webContents.focus() broadcast(IPC.WINDOW_SHOWN) if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'show') } function resetWindowPosition(): void { if (!mainWindow) return const cursor = screen.getCursorScreenPoint() const display = screen.getDisplayNearestPoint(cursor) const { width: sw, height: sh } = display.workAreaSize const { x: dx, y: dy } = display.workArea mainWindow.setBounds({ x: dx + Math.round((sw - BAR_WIDTH) / 2), y: dy + sh - PILL_HEIGHT - PILL_BOTTOM_MARGIN, width: BAR_WIDTH, height: PILL_HEIGHT, }) lastWindowBounds = mainWindow.getBounds() } function toggleWindow(source = 'unknown'): void { if (!mainWindow) return const toggleId = ++toggleSequence if (SPACES_DEBUG) { log(`[spaces] toggle#${toggleId} source=${source} start`) snapshotWindowState(`toggle#${toggleId} pre`) } if (mainWindow.isVisible()) { mainWindow.hide() if (SPACES_DEBUG) scheduleToggleSnapshots(toggleId, 'hide') } else { showWindow(source) } } // ─── Resize ─── // Fixed-height mode: ignore renderer resize events to prevent jank. // The native window stays at PILL_HEIGHT; all expand/collapse happens inside the renderer. ipcMain.on(IPC.RESIZE_HEIGHT, () => { // No-op — fixed height window, no dynamic resize }) ipcMain.on(IPC.SET_WINDOW_WIDTH, () => { // No-op — native width is fixed to keep expand/collapse animation smooth. }) ipcMain.handle(IPC.ANIMATE_HEIGHT, () => { // No-op — kept for API compat, animation handled purely in renderer }) ipcMain.on(IPC.HIDE_WINDOW, () => { mainWindow?.hide() }) ipcMain.handle(IPC.IS_VISIBLE, () => { return mainWindow?.isVisible() ?? false }) // OS-level click-through toggle — renderer calls this on mousemove // to enable clicks on interactive UI while passing through transparent areas ipcMain.on(IPC.SET_IGNORE_MOUSE_EVENTS, (event, ignore: boolean, options?: { forward?: boolean }) => { const win = BrowserWindow.fromWebContents(event.sender) if (win && !win.isDestroyed()) { win.setIgnoreMouseEvents(ignore, options || {}) } }) // Manual window drag — works reliably with frameless + setIgnoreMouseEvents ipcMain.on(IPC.START_WINDOW_DRAG, (event, deltaX: number, deltaY: number) => { const win = BrowserWindow.fromWebContents(event.sender) if (win && !win.isDestroyed()) { const [x, y] = win.getPosition() // Vertical is handled in two phases in the renderer: window first (until macOS clamps), // then CSS translateY within the window — so deltaY here is always within allowed range win.setPosition(Math.round(x + deltaX), Math.round(y + deltaY)) lastWindowBounds = win.getBounds() } }) ipcMain.on(IPC.RESET_WINDOW_POSITION, () => { resetWindowPosition() }) // ─── IPC Handlers (typed, strict) ─── ipcMain.handle(IPC.START, async () => { log('IPC START — fetching static CLI info') const { execSync } = require('child_process') let version = 'unknown' try { version = execSync('claude -v', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim() } catch {} let auth: { email?: string; subscriptionType?: string; authMethod?: string } = {} try { const raw = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim() auth = JSON.parse(raw) } catch {} let mcpServers: string[] = [] try { const raw = execSync('claude mcp list', { encoding: 'utf-8', timeout: 5000, env: getCliEnv() }).trim() if (raw) mcpServers = raw.split('\n').filter(Boolean) } catch {} return { version, auth, mcpServers, projectPath: process.cwd(), homePath: require('os').homedir() } }) ipcMain.handle(IPC.CREATE_TAB, () => { const tabId = controlPlane.createTab() log(`IPC CREATE_TAB → ${tabId}`) return { tabId } }) ipcMain.on(IPC.INIT_SESSION, (_event, tabId: string) => { log(`IPC INIT_SESSION: ${tabId}`) controlPlane.initSession(tabId) }) ipcMain.on(IPC.RESET_TAB_SESSION, (_event, tabId: string) => { log(`IPC RESET_TAB_SESSION: ${tabId}`) controlPlane.resetTabSession(tabId) }) ipcMain.handle(IPC.PROMPT, async (_event, { tabId, requestId, options }: { tabId: string; requestId: string; options: RunOptions }) => { if (DEBUG_MODE) { log(`IPC PROMPT: tab=${tabId} req=${requestId} prompt="${options.prompt.substring(0, 100)}"`) } else { log(`IPC PROMPT: tab=${tabId} req=${requestId}`) } if (!tabId) { throw new Error('No tabId provided — prompt rejected') } if (!requestId) { throw new Error('No requestId provided — prompt rejected') } try { await controlPlane.submitPrompt(tabId, requestId, options) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) log(`PROMPT error: ${msg}`) throw err } }) ipcMain.handle(IPC.CANCEL, (_event, requestId: string) => { log(`IPC CANCEL: ${requestId}`) return controlPlane.cancel(requestId) }) ipcMain.handle(IPC.STOP_TAB, (_event, tabId: string) => { log(`IPC STOP_TAB: ${tabId}`) return controlPlane.cancelTab(tabId) }) ipcMain.handle(IPC.RETRY, async (_event, { tabId, requestId, options }: { tabId: string; requestId: string; options: RunOptions }) => { log(`IPC RETRY: tab=${tabId} req=${requestId}`) return controlPlane.retry(tabId, requestId, options) }) ipcMain.handle(IPC.STATUS, () => { return controlPlane.getHealth() }) ipcMain.handle(IPC.TAB_HEALTH, () => { return controlPlane.getHealth() }) ipcMain.handle(IPC.CLOSE_TAB, (_event, tabId: string) => { log(`IPC CLOSE_TAB: ${tabId}`) controlPlane.closeTab(tabId) }) ipcMain.on(IPC.SET_PERMISSION_MODE, (_event, mode: string) => { if (mode !== 'ask' && mode !== 'auto') { log(`IPC SET_PERMISSION_MODE: invalid mode "${mode}" — ignoring`) return } log(`IPC SET_PERMISSION_MODE: ${mode}`) controlPlane.setPermissionMode(mode) }) ipcMain.handle(IPC.RESPOND_PERMISSION, (_event, { tabId, questionId, optionId }: { tabId: string; questionId: string; optionId: string }) => { log(`IPC RESPOND_PERMISSION: tab=${tabId} question=${questionId} option=${optionId}`) return controlPlane.respondToPermission(tabId, questionId, optionId) }) ipcMain.handle(IPC.LIST_SESSIONS, async (_e, projectPath?: string) => { log(`IPC LIST_SESSIONS ${projectPath ? `(path=${projectPath})` : ''}`) try { const cwd = projectPath || process.cwd() // Validate projectPath — reject null bytes, newlines, non-absolute paths if (/[\0\r\n]/.test(cwd) || !cwd.startsWith('/')) { log(`LIST_SESSIONS: rejected invalid projectPath: ${cwd}`) return [] } // Claude stores project sessions at ~/.claude/projects// // Path encoding: replace all '/' with '-' (leading '/' becomes leading '-') const encodedPath = cwd.replace(/\//g, '-') const sessionsDir = join(homedir(), '.claude', 'projects', encodedPath) if (!existsSync(sessionsDir)) { log(`LIST_SESSIONS: directory not found: ${sessionsDir}`) return [] } const files = readdirSync(sessionsDir).filter((f: string) => f.endsWith('.jsonl')) const sessions: Array<{ sessionId: string; slug: string | null; firstMessage: string | null; lastTimestamp: string; size: number }> = [] // UUID v4 regex — only consider files named as valid UUIDs const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i for (const file of files) { // The filename (without .jsonl) IS the canonical resume ID for `claude --resume` const fileSessionId = file.replace(/\.jsonl$/, '') if (!UUID_RE.test(fileSessionId)) continue // skip non-UUID files const filePath = join(sessionsDir, file) const stat = statSync(filePath) if (stat.size < 100) continue // skip trivially small files // Read lines to extract metadata and validate transcript schema const meta: { validated: boolean; slug: string | null; firstMessage: string | null; lastTimestamp: string | null } = { validated: false, slug: null, firstMessage: null, lastTimestamp: null, } await new Promise((resolve) => { const rl = createInterface({ input: createReadStream(filePath) }) rl.on('line', (line: string) => { try { const obj = JSON.parse(line) // Validate: must have expected Claude transcript fields if (!meta.validated && obj.type && obj.uuid && obj.timestamp) { meta.validated = true } if (obj.slug && !meta.slug) meta.slug = obj.slug if (obj.timestamp) meta.lastTimestamp = obj.timestamp if (obj.type === 'user' && !meta.firstMessage) { const content = obj.message?.content if (typeof content === 'string') { meta.firstMessage = content.substring(0, 100) } else if (Array.isArray(content)) { const textPart = content.find((p: any) => p.type === 'text') meta.firstMessage = textPart?.text?.substring(0, 100) || null } } } catch {} // Read all lines to get the last timestamp }) rl.on('close', () => resolve()) }) if (meta.validated) { sessions.push({ sessionId: fileSessionId, slug: meta.slug, firstMessage: meta.firstMessage, lastTimestamp: meta.lastTimestamp || stat.mtime.toISOString(), size: stat.size, }) } } // Sort by last timestamp, most recent first sessions.sort((a, b) => new Date(b.lastTimestamp).getTime() - new Date(a.lastTimestamp).getTime()) return sessions.slice(0, 20) // Return top 20 } catch (err) { log(`LIST_SESSIONS error: ${err}`) return [] } }) // Load conversation history from a session's JSONL file ipcMain.handle(IPC.LOAD_SESSION, async (_e, arg: { sessionId: string; projectPath?: string } | string) => { const sessionId = typeof arg === 'string' ? arg : arg.sessionId const projectPath = typeof arg === 'string' ? undefined : arg.projectPath log(`IPC LOAD_SESSION ${sessionId}${projectPath ? ` (path=${projectPath})` : ''}`) // Validate sessionId — must be strict UUID to prevent path traversal via crafted filenames const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i if (!UUID_RE.test(sessionId)) { log(`LOAD_SESSION: rejected invalid sessionId: ${sessionId}`) return [] } try { const cwd = projectPath || process.cwd() // Validate projectPath — reject null bytes, newlines, non-absolute paths if (/[\0\r\n]/.test(cwd) || !cwd.startsWith('/')) { log(`LOAD_SESSION: rejected invalid projectPath: ${cwd}`) return [] } const encodedPath = cwd.replace(/\//g, '-') const filePath = join(homedir(), '.claude', 'projects', encodedPath, `${sessionId}.jsonl`) if (!existsSync(filePath)) return [] const messages: Array<{ role: string; content: string; toolName?: string; timestamp: number }> = [] await new Promise((resolve) => { const rl = createInterface({ input: createReadStream(filePath) }) rl.on('line', (line: string) => { try { const obj = JSON.parse(line) if (obj.type === 'user') { const content = obj.message?.content let text = '' if (typeof content === 'string') { text = content } else if (Array.isArray(content)) { text = content .filter((b: any) => b.type === 'text') .map((b: any) => b.text) .join('\n') } if (text) { messages.push({ role: 'user', content: text, timestamp: new Date(obj.timestamp).getTime() }) } } else if (obj.type === 'assistant') { const content = obj.message?.content if (Array.isArray(content)) { for (const block of content) { if (block.type === 'text' && block.text) { messages.push({ role: 'assistant', content: block.text, timestamp: new Date(obj.timestamp).getTime() }) } else if (block.type === 'tool_use' && block.name) { messages.push({ role: 'tool', content: '', toolName: block.name, timestamp: new Date(obj.timestamp).getTime(), }) } } } } } catch {} }) rl.on('close', () => resolve()) }) return messages } catch (err) { log(`LOAD_SESSION error: ${err}`) return [] } }) ipcMain.handle(IPC.SELECT_DIRECTORY, async () => { if (!mainWindow) return null // macOS: activate app so unparented dialog appears on top (not behind other apps). // Unparented avoids modal dimming on the transparent overlay. // Activation is fine here — user is actively interacting with CLUI. if (process.platform === 'darwin') app.focus() const options = { properties: ['openDirectory'] as const } const result = process.platform === 'darwin' ? await dialog.showOpenDialog(options) : await dialog.showOpenDialog(mainWindow, options) return result.canceled ? null : result.filePaths[0] }) ipcMain.handle(IPC.OPEN_EXTERNAL, async (_event, url: string) => { try { // Parse with URL constructor to reject malformed/ambiguous payloads const parsed = new URL(url) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false if (!parsed.hostname) return false await shell.openExternal(parsed.href) return true } catch { return false } }) ipcMain.handle(IPC.ATTACH_FILES, async () => { if (!mainWindow) return null // macOS: activate app so unparented dialog appears on top if (process.platform === 'darwin') app.focus() const options = { properties: ['openFile', 'multiSelections'], filters: [ { name: 'All Files', extensions: ['*'] }, { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'] }, { name: 'Code', extensions: ['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'md', 'json', 'yaml', 'toml'] }, ], } const result = process.platform === 'darwin' ? await dialog.showOpenDialog(options) : await dialog.showOpenDialog(mainWindow, options) if (result.canceled || result.filePaths.length === 0) return null const { basename, extname } = require('path') const { readFileSync, statSync } = require('fs') const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']) const mimeMap: Record = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json', '.yaml': 'text/yaml', '.toml': 'text/toml', } return result.filePaths.map((fp: string) => { const ext = extname(fp).toLowerCase() const mime = mimeMap[ext] || 'application/octet-stream' const stat = statSync(fp) let dataUrl: string | undefined // Generate preview data URL for images (max 2MB to keep IPC fast) if (IMAGE_EXTS.has(ext) && stat.size < 2 * 1024 * 1024) { try { const buf = readFileSync(fp) dataUrl = `data:${mime};base64,${buf.toString('base64')}` } catch {} } return { id: crypto.randomUUID(), type: IMAGE_EXTS.has(ext) ? 'image' : 'file', name: basename(fp), path: fp, mimeType: mime, dataUrl, size: stat.size, } }) }) ipcMain.handle(IPC.TAKE_SCREENSHOT, async () => { if (!mainWindow) return null if (SPACES_DEBUG) snapshotWindowState('screenshot pre-hide') mainWindow.hide() await new Promise((r) => setTimeout(r, 300)) try { const { execSync } = require('child_process') const { join } = require('path') const { tmpdir } = require('os') const { readFileSync, existsSync } = require('fs') const timestamp = Date.now() const screenshotPath = join(tmpdir(), `clui-screenshot-${timestamp}.png`) execSync(`/usr/sbin/screencapture -i "${screenshotPath}"`, { timeout: 30000, stdio: 'ignore', }) if (!existsSync(screenshotPath)) { return null } // Return structured attachment with data URL preview const buf = readFileSync(screenshotPath) return { id: crypto.randomUUID(), type: 'image', name: `screenshot ${++screenshotCounter}.png`, path: screenshotPath, mimeType: 'image/png', dataUrl: `data:image/png;base64,${buf.toString('base64')}`, size: buf.length, } } catch { return null } finally { if (mainWindow) { mainWindow.show() mainWindow.webContents.focus() } broadcast(IPC.WINDOW_SHOWN) if (SPACES_DEBUG) { log('[spaces] screenshot restore show+focus') snapshotWindowState('screenshot restore immediate') setTimeout(() => snapshotWindowState('screenshot restore +200ms'), 200) } } }) let pasteCounter = 0 ipcMain.handle(IPC.PASTE_IMAGE, async (_event, dataUrl: string) => { try { const { writeFileSync } = require('fs') const { join } = require('path') const { tmpdir } = require('os') // Parse data URL: "data:image/png;base64,..." const match = dataUrl.match(/^data:(image\/(\w+));base64,(.+)$/) if (!match) return null const [, mimeType, ext, base64Data] = match const buf = Buffer.from(base64Data, 'base64') const timestamp = Date.now() const filePath = join(tmpdir(), `clui-paste-${timestamp}.${ext}`) writeFileSync(filePath, buf) return { id: crypto.randomUUID(), type: 'image', name: `pasted image ${++pasteCounter}.${ext}`, path: filePath, mimeType, dataUrl, size: buf.length, } } catch { return null } }) ipcMain.handle(IPC.TRANSCRIBE_AUDIO, async (_event, audioBase64: string) => { const { writeFileSync, existsSync, unlinkSync, readFileSync } = require('fs') const { execFile } = require('child_process') const { join, basename } = require('path') const { tmpdir } = require('os') const startedAt = Date.now() const phaseMs: Record = {} const mark = (name: string, t0: number) => { phaseMs[name] = Date.now() - t0 } const tmpWav = join(tmpdir(), `clui-voice-${Date.now()}.wav`) try { const runExecFile = (bin: string, args: string[], timeout: number): Promise => new Promise((resolve, reject) => { execFile(bin, args, { encoding: 'utf-8', timeout }, (err: any, stdout: string, stderr: string) => { if (err) { const detail = stderr?.trim() || stdout?.trim() || err.message reject(new Error(detail)) return } resolve(stdout || '') }) }) let t0 = Date.now() const buf = Buffer.from(audioBase64, 'base64') writeFileSync(tmpWav, buf) mark('decode+write_wav', t0) // Find whisper backend in priority order: whisperkit-cli (Apple Silicon CoreML) → whisper-cli (whisper-cpp) → whisper (python) t0 = Date.now() const candidates = [ '/opt/homebrew/bin/whisperkit-cli', '/usr/local/bin/whisperkit-cli', '/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli', '/opt/homebrew/bin/whisper', '/usr/local/bin/whisper', join(homedir(), '.local/bin/whisper'), ] let whisperBin = '' for (const c of candidates) { if (existsSync(c)) { whisperBin = c; break } } mark('probe_binary_paths', t0) if (!whisperBin) { t0 = Date.now() for (const name of ['whisperkit-cli', 'whisper-cli', 'whisper']) { try { whisperBin = await runExecFile('/bin/zsh', ['-lc', `whence -p ${name}`], 5000).then((s) => s.trim()) if (whisperBin) break } catch {} } mark('probe_binary_whence', t0) } if (!whisperBin) { const hint = process.arch === 'arm64' ? 'brew install whisperkit-cli (or: brew install whisper-cpp)' : 'brew install whisper-cpp' return { error: `Whisper not found. Install with:\n ${hint}`, transcript: null, } } const isWhisperKit = whisperBin.includes('whisperkit-cli') const isWhisperCpp = !isWhisperKit && whisperBin.includes('whisper-cli') log(`Transcribing with: ${whisperBin} (backend: ${isWhisperKit ? 'WhisperKit' : isWhisperCpp ? 'whisper-cpp' : 'Python whisper'})`) let output: string if (isWhisperKit) { // WhisperKit (Apple Silicon CoreML) — auto-downloads models on first run // Use --report to produce a JSON file with a top-level "text" field for deterministic parsing const reportDir = tmpdir() t0 = Date.now() output = await runExecFile( whisperBin, ['transcribe', '--audio-path', tmpWav, '--model', 'tiny', '--without-timestamps', '--skip-special-tokens', '--report', '--report-path', reportDir], 60000 ) mark('whisperkit_transcribe_report', t0) // WhisperKit writes .json (filename without extension) const wavBasename = basename(tmpWav, '.wav') const reportPath = join(reportDir, `${wavBasename}.json`) if (existsSync(reportPath)) { try { t0 = Date.now() const report = JSON.parse(readFileSync(reportPath, 'utf-8')) const transcript = (report.text || '').trim() mark('whisperkit_parse_report_json', t0) try { unlinkSync(reportPath) } catch {} // Also clean up .srt that --report creates const srtPath = join(reportDir, `${wavBasename}.srt`) try { unlinkSync(srtPath) } catch {} log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`) return { error: null, transcript } } catch (parseErr: any) { log(`WhisperKit JSON parse failed: ${parseErr.message}, falling back to stdout`) try { unlinkSync(reportPath) } catch {} } } // Performance fallback: avoid a second full transcription if report file is missing/invalid. // Use stdout from the first run to keep latency close to pre-report behavior. if (!output || !output.trim()) { t0 = Date.now() output = await runExecFile( whisperBin, ['transcribe', '--audio-path', tmpWav, '--model', 'tiny', '--without-timestamps', '--skip-special-tokens'], 60000 ) mark('whisperkit_transcribe_stdout_rerun', t0) } } else if (isWhisperCpp) { // whisper-cpp: whisper-cli -m model -f file --no-timestamps // Find model file — prefer multilingual (auto-detect language) over .en (English-only) const modelCandidates = [ join(homedir(), '.local/share/whisper/ggml-base.bin'), join(homedir(), '.local/share/whisper/ggml-tiny.bin'), '/opt/homebrew/share/whisper-cpp/models/ggml-base.bin', '/opt/homebrew/share/whisper-cpp/models/ggml-tiny.bin', join(homedir(), '.local/share/whisper/ggml-base.en.bin'), join(homedir(), '.local/share/whisper/ggml-tiny.en.bin'), '/opt/homebrew/share/whisper-cpp/models/ggml-base.en.bin', '/opt/homebrew/share/whisper-cpp/models/ggml-tiny.en.bin', ] let modelPath = '' for (const m of modelCandidates) { if (existsSync(m)) { modelPath = m; break } } if (!modelPath) { return { 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', transcript: null, } } const isEnglishOnly = modelPath.includes('.en.') const langFlag = isEnglishOnly ? '-l en' : '-l auto' t0 = Date.now() output = await runExecFile( whisperBin, ['-m', modelPath, '-f', tmpWav, '--no-timestamps', '-l', isEnglishOnly ? 'en' : 'auto'], 30000 ) mark('whisper_cpp_transcribe', t0) } else { // Python whisper t0 = Date.now() output = await runExecFile( whisperBin, [tmpWav, '--model', 'tiny', '--output_format', 'txt', '--output_dir', tmpdir()], 30000 ) mark('python_whisper_transcribe', t0) // Python whisper writes .txt file const txtPath = tmpWav.replace('.wav', '.txt') if (existsSync(txtPath)) { t0 = Date.now() const transcript = readFileSync(txtPath, 'utf-8').trim() mark('python_whisper_read_txt', t0) try { unlinkSync(txtPath) } catch {} log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`) return { error: null, transcript } } // File not created — Python whisper failed silently return { error: `Whisper output file not found at ${txtPath}. Check disk space and permissions.`, transcript: null, } } // WhisperKit (stdout fallback) and whisper-cpp print to stdout directly // Strip timestamp patterns and known hallucination outputs const HALLUCINATIONS = /^\s*(\[BLANK_AUDIO\]|you\.?|thank you\.?|thanks\.?)\s*$/i const transcript = output .replace(/\[[\d:.]+\s*-->\s*[\d:.]+\]\s*/g, '') .trim() if (HALLUCINATIONS.test(transcript)) { log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`) return { error: null, transcript: '' } } log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt })}`) return { error: null, transcript: transcript || '' } } catch (err: any) { log(`Transcription error: ${err.message}`) log(`Transcription timing(ms): ${JSON.stringify({ ...phaseMs, total: Date.now() - startedAt, failed: true })}`) return { error: `Transcription failed: ${err.message}`, transcript: null, } } finally { try { unlinkSync(tmpWav) } catch {} } }) ipcMain.handle(IPC.GET_DIAGNOSTICS, () => { const { readFileSync, existsSync } = require('fs') const health = controlPlane.getHealth() let recentLogs = '' if (existsSync(LOG_FILE)) { try { const content = readFileSync(LOG_FILE, 'utf-8') const lines = content.split('\n') recentLogs = lines.slice(-100).join('\n') } catch {} } return { health, logPath: LOG_FILE, recentLogs, platform: process.platform, arch: process.arch, electronVersion: process.versions.electron, nodeVersion: process.versions.node, appVersion: app.getVersion(), transport: INTERACTIVE_PTY ? 'pty' : 'stream-json', } }) ipcMain.handle(IPC.OPEN_IN_TERMINAL, (_event, arg: string | null | { sessionId?: string | null; projectPath?: string }) => { const { execFile } = require('child_process') const claudeBin = 'claude' const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i // Support both old (string) and new ({ sessionId, projectPath }) calling convention let sessionId: string | null = null let projectPath: string = process.cwd() if (typeof arg === 'string') { sessionId = arg } else if (arg && typeof arg === 'object') { sessionId = arg.sessionId ?? null projectPath = arg.projectPath && arg.projectPath !== '~' ? arg.projectPath : process.cwd() } // Validate sessionId — must be a strict UUID to prevent injection into the shell command if (sessionId && !UUID_RE.test(sessionId)) { log(`OPEN_IN_TERMINAL: rejected invalid sessionId: ${sessionId}`) return false } // Sanitize projectPath — reject null bytes, newlines, and non-absolute paths if (/[\0\r\n]/.test(projectPath) || !projectPath.startsWith('/')) { log(`OPEN_IN_TERMINAL: rejected invalid projectPath: ${projectPath}`) return false } // Shell-safe single-quote escaping: replace ' with '\'' (end quote, escaped literal quote, reopen quote) // Single quotes block all shell expansion ($, `, \, etc.) — unlike double quotes which allow $() and backticks const shellSingleQuote = (s: string): string => "'" + s.replace(/'/g, "'\\''") + "'" // AppleScript string escaping: backslashes doubled, double quotes escaped const escapeAppleScript = (s: string): string => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') const safeDir = escapeAppleScript(shellSingleQuote(projectPath)) let cmd: string if (sessionId) { // sessionId is UUID-validated above, safe to embed directly cmd = `cd ${safeDir} && ${claudeBin} --resume ${sessionId}` } else { cmd = `cd ${safeDir} && ${claudeBin}` } const script = `tell application "Terminal" activate do script "${cmd}" end tell` try { execFile('/usr/bin/osascript', ['-e', script], (err: Error | null) => { if (err) log(`Failed to open terminal: ${err.message}`) else log(`Opened terminal with: ${cmd}`) }) return true } catch (err: unknown) { log(`Failed to open terminal: ${err}`) return false } }) // ─── Marketplace IPC ─── ipcMain.handle(IPC.MARKETPLACE_FETCH, async (_event, { forceRefresh } = {}) => { log('IPC MARKETPLACE_FETCH') return fetchCatalog(forceRefresh) }) ipcMain.handle(IPC.MARKETPLACE_INSTALLED, async () => { log('IPC MARKETPLACE_INSTALLED') return listInstalled() }) ipcMain.handle(IPC.MARKETPLACE_INSTALL, async (_event, { repo, pluginName, marketplace, sourcePath, isSkillMd }: { repo: string; pluginName: string; marketplace: string; sourcePath?: string; isSkillMd?: boolean }) => { log(`IPC MARKETPLACE_INSTALL: ${pluginName} from ${repo} (isSkillMd=${isSkillMd})`) return installPlugin(repo, pluginName, marketplace, sourcePath, isSkillMd) }) ipcMain.handle(IPC.MARKETPLACE_UNINSTALL, async (_event, { pluginName }: { pluginName: string }) => { log(`IPC MARKETPLACE_UNINSTALL: ${pluginName}`) return uninstallPlugin(pluginName) }) // ─── Theme Detection ─── ipcMain.handle(IPC.GET_THEME, () => { return { isDark: nativeTheme.shouldUseDarkColors } }) nativeTheme.on('updated', () => { broadcast(IPC.THEME_CHANGED, nativeTheme.shouldUseDarkColors) }) // ─── Permission Preflight ─── // Request all required macOS permissions upfront on first launch so the user // is never interrupted mid-session by a permission prompt. async function requestPermissions(): Promise { if (process.platform !== 'darwin') return // ── Microphone (for voice input via Whisper) ── try { const micStatus = systemPreferences.getMediaAccessStatus('microphone') if (micStatus === 'not-determined') { await systemPreferences.askForMediaAccess('microphone') } } catch (err: any) { log(`Permission preflight: microphone check failed — ${err.message}`) } // ── Accessibility (for global ⌥+Space shortcut) ── // globalShortcut works without it on modern macOS; Cmd+Shift+K is always the fallback. // Screen Recording: not requested upfront — macOS 15 Sequoia shows an alarming // "bypass private window picker" dialog. Let the OS prompt naturally if/when // the screenshot feature is actually used. } // ─── App Lifecycle ─── app.whenReady().then(async () => { // macOS: become an accessory app. Accessory apps can have key windows (keyboard works) // without deactivating the currently active app (hover preserved in browsers). // This is how Spotlight, Alfred, Raycast work. if (process.platform === 'darwin' && app.dock) { app.dock.hide() } // Request permissions upfront so the user is never interrupted mid-session. await requestPermissions() installContentSecurityPolicy() // Skill provisioning — non-blocking, streams status to renderer ensureSkills((status: SkillStatus) => { log(`Skill ${status.name}: ${status.state}${status.error ? ` — ${status.error}` : ''}`) broadcast(IPC.SKILL_STATUS, status) }).catch((err: Error) => log(`Skill provisioning error: ${err.message}`)) createWindow() snapshotWindowState('after createWindow') if (SPACES_DEBUG) { mainWindow?.on('show', () => snapshotWindowState('event window show')) mainWindow?.on('hide', () => snapshotWindowState('event window hide')) mainWindow?.on('focus', () => snapshotWindowState('event window focus')) mainWindow?.on('blur', () => snapshotWindowState('event window blur')) mainWindow?.webContents.on('focus', () => snapshotWindowState('event webContents focus')) mainWindow?.webContents.on('blur', () => snapshotWindowState('event webContents blur')) app.on('browser-window-focus', () => snapshotWindowState('event app browser-window-focus')) app.on('browser-window-blur', () => snapshotWindowState('event app browser-window-blur')) screen.on('display-added', (_e, display) => { log(`[spaces] event display-added id=${display.id}`) snapshotWindowState('event display-added') }) screen.on('display-removed', (_e, display) => { log(`[spaces] event display-removed id=${display.id}`) snapshotWindowState('event display-removed') }) screen.on('display-metrics-changed', (_e, display, changedMetrics) => { log(`[spaces] event display-metrics-changed id=${display.id} changed=${changedMetrics.join(',')}`) snapshotWindowState('event display-metrics-changed') }) } // Primary: Option+Space (2 keys, doesn't conflict with shell) // Fallback: Cmd+Shift+K kept as secondary shortcut const registered = globalShortcut.register('Alt+Space', () => toggleWindow('shortcut Alt+Space')) if (!registered) { log('Alt+Space shortcut registration failed — macOS input sources may claim it') } globalShortcut.register('CommandOrControl+Shift+K', () => toggleWindow('shortcut Cmd/Ctrl+Shift+K')) const trayIconPath = join(__dirname, '../../resources/trayTemplate.png') const trayIcon = nativeImage.createFromPath(trayIconPath) trayIcon.setTemplateImage(true) tray = new Tray(trayIcon) tray.setToolTip('Clui CC — Claude Code UI') tray.on('click', () => toggleWindow('tray click')) tray.setContextMenu( Menu.buildFromTemplate([ { label: 'Show Clui CC', click: () => showWindow('tray menu') }, { label: 'Quit', click: () => { app.quit() } }, ]) ) // app 'activate' fires when macOS brings the app to the foreground (e.g. after // webContents.focus() triggers applicationDidBecomeActive on some macOS versions). // Using showWindow here instead of toggleWindow prevents the re-entry race where // a summon immediately hides itself because activate fires mid-show. app.on('activate', () => showWindow('app activate')) }) app.on('will-quit', () => { globalShortcut.unregisterAll() controlPlane.shutdown() flushLogs() }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) ================================================ FILE: src/main/logger.ts ================================================ import { appendFile, appendFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' const LOG_FILE = join(homedir(), '.clui-debug.log') const FLUSH_INTERVAL_MS = 500 const MAX_BUFFER_SIZE = 64 let buffer: string[] = [] let timer: ReturnType | null = null /** All chunks handed to async appendFile not yet confirmed written */ const inFlight = new Map() let nextChunkId = 1 function flush(): void { if (buffer.length === 0) return const chunk = buffer.join('') buffer = [] const chunkId = nextChunkId++ inFlight.set(chunkId, chunk) appendFile(LOG_FILE, chunk, () => { inFlight.delete(chunkId) }) } function ensureTimer(): void { if (timer) return timer = setInterval(flush, FLUSH_INTERVAL_MS) if (timer && typeof timer === 'object' && 'unref' in timer) { timer.unref() } } export function log(tag: string, msg: string): void { buffer.push(`[${new Date().toISOString()}] [${tag}] ${msg}\n`) if (buffer.length >= MAX_BUFFER_SIZE) flush() ensureTimer() } /** * Synchronously drain all pending logs. Call on shutdown to guarantee * every buffered or in-flight line is persisted before the process exits. */ export function flushLogs(): void { if (timer) { clearInterval(timer); timer = null } // Re-write all in-flight chunks synchronously (async writes may not have landed) const pendingInflight = Array.from(inFlight.values()).join('') const pending = pendingInflight + buffer.join('') inFlight.clear() buffer = [] if (pending) { try { appendFileSync(LOG_FILE, pending) } catch {} } } export { LOG_FILE } ================================================ FILE: src/main/marketplace/catalog.ts ================================================ import { net } from 'electron' import { execFile } from 'child_process' import { readFile, readdir, mkdir, writeFile, rm } from 'fs/promises' import { join, resolve } from 'path' import { homedir } from 'os' import type { CatalogPlugin } from '../../shared/types' import { log as _log } from '../logger' import { getCliEnv } from '../cli-env' // ─── Input Validation ─── // Strict safe charset for plugin names: alphanumeric, hyphens, underscores, dots const SAFE_PLUGIN_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/ // Strict owner/repo format const SAFE_REPO = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/ function validatePluginName(name: string): boolean { return SAFE_PLUGIN_NAME.test(name) && !name.includes('..') } function validateRepo(repo: string): boolean { return SAFE_REPO.test(repo) } function validateSourcePath(p: string): boolean { // Reject absolute paths, null bytes, backslashes, and traversal if (!p || /[\0\\]/.test(p) || p.startsWith('/') || p.includes('..')) return false return true } function assertSkillDirContained(skillsDir: string, base: string): void { const resolved = resolve(skillsDir) if (!resolved.startsWith(base + '/') && resolved !== base) { throw new Error(`Path escapes skills directory: ${resolved}`) } } function log(msg: string): void { _log('marketplace', msg) } // ─── Sources ─── const SOURCES = [ { repo: 'anthropics/skills', category: 'Agent Skills' }, { repo: 'anthropics/knowledge-work-plugins', category: 'Knowledge Work' }, { repo: 'anthropics/financial-services-plugins', category: 'Financial Services' }, ] as const // ─── TTL Cache ─── let cachedPlugins: CatalogPlugin[] | null = null let cacheTimestamp = 0 const CACHE_TTL = 5 * 60 * 1000 // 5 minutes // Cache raw SKILL.md content keyed by skill name for direct installation const skillContentCache = new Map() // ─── fetchCatalog ─── export async function fetchCatalog(forceRefresh?: boolean): Promise<{ plugins: CatalogPlugin[]; error: string | null }> { if (!forceRefresh && cachedPlugins && Date.now() - cacheTimestamp < CACHE_TTL) { return { plugins: cachedPlugins, error: null } } const allPlugins: CatalogPlugin[] = [] const errors: string[] = [] const results = await Promise.allSettled( SOURCES.map(async (source) => { const marketplaceUrl = `https://raw.githubusercontent.com/${source.repo}/main/.claude-plugin/marketplace.json` log(`Fetching marketplace: ${marketplaceUrl}`) const marketplaceRes = await netFetch(marketplaceUrl) if (!marketplaceRes.ok) { throw new Error(`Failed to fetch marketplace for ${source.repo}: ${marketplaceRes.status}`) } const marketplaceData = JSON.parse(marketplaceRes.body) as { name: string plugins: Array<{ name: string source: string description?: string author?: { name: string } | string skills?: string[] }> } const safeMarketplaceName = typeof marketplaceData.name === 'string' && marketplaceData.name.trim().length > 0 ? marketplaceData.name.trim() : source.repo // Flatten: for entries with a skills[] array, expand each skill as its own catalog item. // For entries without skills[] (knowledge-work, financial-services), use plugin.json as before. type FetchJob = { installName: string; skillPath: string; entryDescription: string; entryAuthor: string; useSkillMd: boolean } const jobs: FetchJob[] = [] for (const entry of marketplaceData.plugins) { let entryAuthor = '' if (entry.author) { entryAuthor = typeof entry.author === 'string' ? entry.author : entry.author.name || '' } if (entry.skills && entry.skills.length > 0) { // Skills repo: each skill path (e.g. "./skills/xlsx") becomes its own entry for (const skillRef of entry.skills) { const skillPath = skillRef.replace(/^\.\//, '').replace(/\/$/, '') // Use the individual skill directory name as installName (not the bundle name) const individualName = skillPath.split('/').pop() || entry.name jobs.push({ installName: individualName, skillPath, entryDescription: entry.description || '', entryAuthor, useSkillMd: true, }) } } else { // Standard plugin: source points to a directory with .claude-plugin/plugin.json const normalizedSource = entry.source.replace(/^\.\//, '').replace(/\/$/, '') jobs.push({ installName: entry.name, skillPath: normalizedSource || entry.name, entryDescription: entry.description || '', entryAuthor, useSkillMd: false, }) } } const jobResults = await Promise.allSettled( jobs.map(async (job) => { let name = '' let description = '' let version = '0.0.0' let author = job.entryAuthor || 'Anthropic' if (job.useSkillMd) { // Fetch SKILL.md and parse frontmatter for name/description const skillUrl = `https://raw.githubusercontent.com/${source.repo}/main/${job.skillPath}/SKILL.md` try { const res = await netFetch(skillUrl) if (res.ok) { const parsed = parseSkillFrontmatter(res.body) name = parsed.name description = parsed.description // Cache raw content for direct installation skillContentCache.set(job.installName, res.body) } } catch (e) { log(`SKILL.md fetch failed for ${job.skillPath}`) } } else { // Fetch plugin.json const pluginUrl = `https://raw.githubusercontent.com/${source.repo}/main/${job.skillPath}/.claude-plugin/plugin.json` try { const res = await netFetch(pluginUrl) if (res.ok) { const data = JSON.parse(res.body) as { name?: string; version?: string; description?: string; author?: string } name = data.name?.trim() || '' description = data.description || '' version = data.version?.trim() || '0.0.0' author = data.author?.trim() || author } } catch (e) { log(`plugin.json fetch failed for ${job.skillPath}`) } } // Fallbacks const dirName = job.skillPath.split('/').pop() || job.installName if (!name) name = dirName if (!description) description = job.entryDescription const plugin: CatalogPlugin = { id: `${source.repo}/${job.skillPath}`, name, description, version, author, marketplace: safeMarketplaceName, repo: source.repo, sourcePath: job.skillPath, installName: job.installName, category: source.category, tags: deriveSemanticTags(name, description, job.skillPath), isSkillMd: job.useSkillMd, } return plugin }) ) for (const r of jobResults) { if (r.status === 'fulfilled') { allPlugins.push(r.value) } else { log(`Plugin fetch warning: ${r.reason}`) } } }) ) for (const r of results) { if (r.status === 'rejected') { log(`Source fetch error: ${r.reason}`) errors.push(String(r.reason)) } } // Only error if ALL sources failed and we got no plugins if (allPlugins.length === 0 && errors.length > 0) { return { plugins: [], error: errors.join('; ') } } // Sort by name allPlugins.sort((a, b) => a.name.localeCompare(b.name)) // Update cache cachedPlugins = allPlugins cacheTimestamp = Date.now() return { plugins: allPlugins, error: null } } // ─── listInstalled ─── // Reads directly from ~/.claude filesystem for reliable detection: // - Plugins: ~/.claude/plugins/installed_plugins.json (keys are "name@marketplace") // - Skills: ~/.claude/skills/ (each subdirectory is an installed skill) export async function listInstalled(): Promise { const claudeDir = join(homedir(), '.claude') const names: string[] = [] // 1. Installed plugins from JSON registry try { const raw = await readFile(join(claudeDir, 'plugins', 'installed_plugins.json'), 'utf-8') const data = JSON.parse(raw) as { plugins?: Record } if (data.plugins) { for (const key of Object.keys(data.plugins)) { // Keys are "name@marketplace" e.g. "design@knowledge-work-plugins" const pluginName = key.split('@')[0] if (pluginName) names.push(pluginName) // Also push the full key for exact matching names.push(key) } } } catch (e) { log(`listInstalled: no installed_plugins.json or parse error: ${e}`) } // 2. Installed skills from ~/.claude/skills/ try { const entries = await readdir(join(claudeDir, 'skills'), { withFileTypes: true }) for (const entry of entries) { if (entry.isDirectory()) { names.push(entry.name) } } } catch (e) { log(`listInstalled: no skills dir or read error: ${e}`) } return [...new Set(names)] } // ─── installPlugin ─── // For SKILL.md skills: writes directly to ~/.claude/skills// // For CLI plugins: falls back to `claude plugin install` export async function installPlugin( repo: string, pluginName: string, marketplace: string, sourcePath?: string, isSkillMd?: boolean ): Promise<{ ok: boolean; error?: string }> { try { // Validate all external inputs before any filesystem or network operations if (!validatePluginName(pluginName)) { return { ok: false, error: `Invalid plugin name: ${pluginName}` } } if (!validateRepo(repo)) { return { ok: false, error: `Invalid repo format: ${repo}` } } if (sourcePath && !validateSourcePath(sourcePath)) { return { ok: false, error: `Invalid source path: ${sourcePath}` } } if (isSkillMd !== false) { // Direct SKILL.md install const skillsBase = join(homedir(), '.claude', 'skills') const skillsDir = join(skillsBase, pluginName) assertSkillDirContained(skillsDir, skillsBase) // Check if we have cached content from the catalog fetch let content = skillContentCache.get(pluginName) if (!content) { const path = sourcePath || `skills/${pluginName}` const url = `https://raw.githubusercontent.com/${repo}/main/${path}/SKILL.md` log(`installPlugin: fetching ${url}`) const res = await netFetch(url) if (!res.ok) { return { ok: false, error: `Failed to fetch SKILL.md (${res.status})` } } content = res.body } await mkdir(skillsDir, { recursive: true }) await writeFile(join(skillsDir, 'SKILL.md'), content, 'utf-8') log(`installPlugin: wrote ${skillsDir}/SKILL.md`) } else { // CLI plugin install (knowledge-work, financial-services bundles) const addResult = await execAsync('claude', ['plugin', 'marketplace', 'add', repo], 15000) if (addResult.exitCode !== 0 && !addResult.stdout.includes('already added') && !addResult.stderr.includes('already added')) { return { ok: false, error: addResult.stderr || 'Failed to add marketplace' } } const installResult = await execAsync('claude', ['plugin', 'install', `${pluginName}@${marketplace}`], 15000) if (installResult.exitCode !== 0) { return { ok: false, error: installResult.stderr || 'Failed to install plugin' } } } return { ok: true } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) log(`installPlugin error: ${msg}`) return { ok: false, error: msg } } } // ─── uninstallPlugin ─── export async function uninstallPlugin( pluginName: string ): Promise<{ ok: boolean; error?: string }> { try { if (!validatePluginName(pluginName)) { return { ok: false, error: `Invalid plugin name: ${pluginName}` } } const skillsBase = join(homedir(), '.claude', 'skills') const skillsDir = join(skillsBase, pluginName) assertSkillDirContained(skillsDir, skillsBase) await rm(skillsDir, { recursive: true, force: true }) log(`uninstallPlugin: removed ${skillsDir}`) return { ok: true } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) log(`uninstallPlugin error: ${msg}`) return { ok: false, error: msg } } } // ─── Helpers ─── function netFetch(url: string): Promise<{ ok: boolean; status: number; body: string }> { return new Promise((resolve, reject) => { const request = net.request(url) request.on('response', (response) => { let body = '' response.on('data', (chunk) => { body += chunk.toString() }) response.on('end', () => { resolve({ ok: response.statusCode >= 200 && response.statusCode < 300, status: response.statusCode, body, }) }) }) request.on('error', (err) => reject(err)) request.end() }) } /** Parse YAML-like frontmatter from SKILL.md (name: ..., description: "...") */ function parseSkillFrontmatter(content: string): { name: string; description: string } { let name = '' let description = '' // Frontmatter is at the top, no --- delimiters — just key: value lines const lines = content.split('\n') for (const line of lines) { const nameMatch = line.match(/^name:\s*(.+)/) if (nameMatch && !name) { name = nameMatch[1].replace(/^["']|["']$/g, '').trim() } const descMatch = line.match(/^description:\s*(.+)/) if (descMatch && !description) { // Description may be quoted and span conceptually one line description = descMatch[1].replace(/^["']|["']$/g, '').trim() // Truncate long descriptions for display if (description.length > 200) { description = description.substring(0, 197) + '...' } } // Stop after we have both, or after hitting a markdown heading (end of frontmatter) if (name && description) break if (line.startsWith('# ')) break } return { name, description } } // ─── Semantic tag derivation ─── // Maps plugin meaning (name, description, path) to discoverable use-case tags. // Provenance (repo, author, marketplace) stays in metadata, not tags. const TAG_RULES: Array<{ tag: string; patterns: RegExp }> = [ { tag: 'Design', patterns: /\b(figma|ui|ux|design|sketch|prototype|wireframe|layout|css|style|visual)\b/i }, { tag: 'Product', patterns: /\b(prd|roadmap|strategy|product|backlog|prioriti[sz]|feature\s*request|user\s*stor)\b/i }, { tag: 'Research', patterns: /\b(research|interview|insights?|survey|user\s*study|ethnograph|discover)\b/i }, { tag: 'Docs', patterns: /\b(doc(ument)?s?|writing|spec(ification)?|readme|markdown|technical\s*writ|content)\b/i }, { tag: 'Spreadsheet', patterns: /\b(sheet|spreadsheet|xlsx?|csv|tabular|pivot|formula)\b/i }, { tag: 'Slides', patterns: /\b(slides?|presentation|deck|pptx?|keynote|pitch)\b/i }, { tag: 'Analysis', patterns: /\b(analy[sz](is|e|ing)|insight|metric|dashboard|report(ing)?|data\s*viz|statistic)\b/i }, { tag: 'Finance', patterns: /\b(financ|accounting|budget|revenue|forecast|valuation|portfolio|investment)\b/i }, { tag: 'Compliance', patterns: /\b(risk|audit|policy|compliance|regulat|governance|sox|gdpr|hipaa)\b/i }, { tag: 'Management', patterns: /\b(manag|planning|meeting|ops|operations|team|workflow|project\s*plan)\b/i }, { tag: 'Automation', patterns: /\b(automat|workflow|pipeline|ci\s*cd|deploy|integrat|orchestrat|script)\b/i }, { tag: 'Code', patterns: /\b(code|coding|program|develop|engineer|debug|refactor|test(ing)?|linter?)\b/i }, { tag: 'Creative', patterns: /\b(creative|brainstorm|ideation|copywriting|storytelling|narrative)\b/i }, { tag: 'Sales', patterns: /\b(sales|crm|prospect|lead|deal|pipeline|outreach|cold\s*(call|email))\b/i }, { tag: 'Support', patterns: /\b(support|customer|helpdesk|ticket|troubleshoot|faq|knowledge\s*base)\b/i }, { tag: 'Security', patterns: /\b(secur|vulnerabilit|pentest|threat|encrypt|auth(enticat|ori[sz]))\b/i }, { tag: 'Data', patterns: /\b(data|database|sql|etl|warehouse|lake|ingest|transform|schema)\b/i }, { tag: 'AI/ML', patterns: /\b(ai|ml|machine\s*learn|model|train|inference|llm|prompt|embed)\b/i }, ] function deriveSemanticTags(name: string, description: string, skillPath: string): string[] { const text = `${name} ${description} ${skillPath}`.toLowerCase() const matched: string[] = [] for (const rule of TAG_RULES) { if (rule.patterns.test(text)) { matched.push(rule.tag) } if (matched.length >= 2) break // Cap at 2 semantic tags } return matched } function execAsync(cmd: string, args: string[], timeout: number): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve) => { execFile(cmd, args, { timeout, env: getCliEnv() }, (err, stdout, stderr) => { resolve({ exitCode: err ? 1 : 0, stdout: stdout || '', stderr: stderr || '', }) }) }) } ================================================ FILE: src/main/process-manager.ts ================================================ import { spawn, execSync, ChildProcess } from 'child_process' import { EventEmitter } from 'events' import { homedir } from 'os' import { appendFileSync } from 'fs' import { join } from 'path' import { StreamParser } from './stream-parser' import { getCliEnv } from './cli-env' import type { ClaudeEvent, RunOptions } from '../shared/types' const LOG_FILE = join(homedir(), '.clui-debug.log') function log(msg: string): void { const line = `[${new Date().toISOString()}] ${msg}\n` try { appendFileSync(LOG_FILE, line) } catch {} } export interface RunHandle { runId: string sessionId: string | null process: ChildProcess parser: StreamParser } /** * Manages Claude Code subprocesses. */ export class ProcessManager extends EventEmitter { private activeRuns = new Map() private claudeBinary: string constructor() { super() // Find the real claude binary — Electron doesn't inherit shell aliases or full PATH this.claudeBinary = this.findClaudeBinary() log(`Claude binary: ${this.claudeBinary}`) } private findClaudeBinary(): string { // Try common locations const candidates = [ '/usr/local/bin/claude', '/opt/homebrew/bin/claude', join(homedir(), '.npm-global/bin/claude'), join(homedir(), '.nvm/versions/node', '**', 'bin/claude'), ] for (const c of candidates) { try { execSync(`test -x "${c}"`, { stdio: 'ignore' }) return c } catch {} } // Fallback: ask a login shell try { const result = execSync('/bin/zsh -ilc "whence -p claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() if (result) return result } catch {} try { const result = execSync('/bin/bash -lc "which claude"', { encoding: 'utf-8', env: getCliEnv() }).trim() if (result) return result } catch {} // Last resort return 'claude' } startRun(options: RunOptions): RunHandle { const runId = crypto.randomUUID() const cwd = options.projectPath === '~' ? homedir() : options.projectPath const args: string[] = [ '-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--permission-mode', 'acceptEdits', '--chrome', ] if (options.sessionId) { args.push('--resume', options.sessionId) } if (options.allowedTools?.length) { args.push('--allowedTools', options.allowedTools.join(',')) } if (options.maxTurns) { args.push('--max-turns', String(options.maxTurns)) } if (options.maxBudgetUsd) { args.push('--max-budget-usd', String(options.maxBudgetUsd)) } if (options.systemPrompt) { args.push('--system-prompt', options.systemPrompt) } log(`Starting run ${runId}: ${this.claudeBinary} ${args.join(' ')}`) log(`Prompt: ${options.prompt.substring(0, 200)}`) // Build environment: merge login shell PATH with Electron's env // Electron doesn't source ~/.zshrc so PATH is often incomplete const env = getCliEnv() // Ensure our claude binary's directory is in PATH const binDir = this.claudeBinary.substring(0, this.claudeBinary.lastIndexOf('/')) if (env.PATH && !env.PATH.includes(binDir)) { env.PATH = `${binDir}:${env.PATH}` } const child = spawn(this.claudeBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd, env, }) log(`Spawned PID: ${child.pid}`) const parser = StreamParser.fromStream(child.stdout!) const handle: RunHandle = { runId, sessionId: null, process: child, parser, } parser.on('event', (event: ClaudeEvent) => { log(`Event [${runId}]: ${event.type}`) if (event.type === 'system' && 'subtype' in event && event.subtype === 'init') { handle.sessionId = (event as any).session_id } this.emit('event', runId, event) }) parser.on('parse-error', (line: string) => { log(`Parse error [${runId}]: ${line.substring(0, 200)}`) this.emit('parse-error', runId, line) }) child.on('close', (code) => { log(`Process closed [${runId}]: code=${code}`) this.activeRuns.delete(runId) this.emit('exit', runId, code, handle.sessionId) }) child.on('error', (err) => { log(`Process error [${runId}]: ${err.message}`) this.activeRuns.delete(runId) this.emit('error', runId, err) }) child.stderr?.setEncoding('utf-8') child.stderr?.on('data', (data: string) => { log(`Stderr [${runId}]: ${data.trim().substring(0, 500)}`) this.emit('stderr', runId, data) }) child.stdin!.write(options.prompt) child.stdin!.end() this.activeRuns.set(runId, handle) return handle } cancelRun(runId: string): boolean { const handle = this.activeRuns.get(runId) if (!handle) return false log(`Cancelling run ${runId}`) handle.process.kill('SIGINT') setTimeout(() => { if (handle.process.exitCode === null) { handle.process.kill('SIGTERM') } }, 5000) return true } isRunning(runId: string): boolean { return this.activeRuns.has(runId) } getActiveRunIds(): string[] { return Array.from(this.activeRuns.keys()) } } ================================================ FILE: src/main/skills/installer.ts ================================================ /** * Skill installer — ensures manifest skills are present in ~/.claude/skills/. * * Runs on app startup (non-blocking). Uses atomic install: * tmp dir → validate → rename into place. * * Respects user-managed skills: if a skill dir exists without .clui-version, * it was placed there by the user and we don't touch it. */ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, rmSync, cpSync } from 'fs' import { join, dirname } from 'path' import { homedir } from 'os' import { execSync } from 'child_process' import { randomUUID } from 'crypto' import { SKILLS, type SkillEntry } from './manifest' /** Directory containing bundled skill sources (relative to main process __dirname) */ const BUNDLED_SKILLS_DIR = join(__dirname, '../../skills') const SKILLS_DIR = join(homedir(), '.claude', 'skills') const VERSION_FILE = '.clui-version' export type SkillState = 'pending' | 'downloading' | 'validating' | 'installed' | 'failed' | 'skipped' export interface SkillStatus { name: string state: SkillState error?: string reason?: 'up-to-date' | 'user-managed' } interface VersionMeta { version: string source: string installedBy: string installedAt: string } function log(msg: string): void { const { appendFileSync } = require('fs') const line = `[${new Date().toISOString()}] [skills] ${msg}\n` try { appendFileSync(join(homedir(), '.clui-debug.log'), line) } catch {} } function readVersionFile(skillDir: string): VersionMeta | null { const fp = join(skillDir, VERSION_FILE) if (!existsSync(fp)) return null try { return JSON.parse(readFileSync(fp, 'utf-8')) } catch { return null } } function writeVersionFile(skillDir: string, entry: SkillEntry): void { const meta: VersionMeta = { version: entry.version, source: entry.source.type === 'github' ? `github:${entry.source.repo}@${entry.source.commitSha}` : 'bundled', installedBy: 'clui', installedAt: new Date().toISOString(), } writeFileSync(join(skillDir, VERSION_FILE), JSON.stringify(meta, null, 2) + '\n') } function validateSkill(dir: string, requiredFiles: string[]): string | null { for (const f of requiredFiles) { if (!existsSync(join(dir, f))) { return `Missing required file: ${f}` } } return null } async function installGithubSkill( entry: SkillEntry & { source: { type: 'github'; repo: string; path: string; commitSha: string } }, onStatus: (s: SkillStatus) => void, ): Promise { const targetDir = join(SKILLS_DIR, entry.name) const tmpDir = join(SKILLS_DIR, `.tmp-${entry.name}-${randomUUID().slice(0, 8)}`) onStatus({ name: entry.name, state: 'downloading' }) log(`Downloading ${entry.name} from ${entry.source.repo}@${entry.source.commitSha}`) try { mkdirSync(tmpDir, { recursive: true }) // Download pinned tarball and extract only the skill subdirectory. // GitHub tarballs have a top-level directory like "anthropics-skills-/". // We strip the top-level + intermediate path components to get just the skill files. const { repo, path, commitSha } = entry.source const pathDepth = path.split('/').length + 1 // +1 for the github top-level dir const tarballUrl = `https://api.github.com/repos/${repo}/tarball/${commitSha}` // Use curl + tar — both always available on macOS const cmd = [ `curl -sL "${tarballUrl}"`, '|', `tar -xz --strip-components=${pathDepth} -C "${tmpDir}" "*/${path}"`, ].join(' ') execSync(cmd, { timeout: 60000, stdio: 'pipe' }) // Validate extracted files onStatus({ name: entry.name, state: 'validating' }) const err = validateSkill(tmpDir, entry.requiredFiles) if (err) { throw new Error(`Validation failed: ${err}`) } // Atomic swap: remove old (if CLUI-managed), rename tmp into place if (existsSync(targetDir)) { const existing = readVersionFile(targetDir) if (existing?.installedBy === 'clui') { rmSync(targetDir, { recursive: true, force: true }) } else { // User-managed — shouldn't reach here (checked earlier), but be safe rmSync(tmpDir, { recursive: true, force: true }) onStatus({ name: entry.name, state: 'skipped', reason: 'user-managed' }) return } } renameSync(tmpDir, targetDir) writeVersionFile(targetDir, entry) log(`Installed ${entry.name} v${entry.version}`) onStatus({ name: entry.name, state: 'installed' }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) log(`Failed to install ${entry.name}: ${msg}`) // Clean up tmp dir on failure try { rmSync(tmpDir, { recursive: true, force: true }) } catch {} onStatus({ name: entry.name, state: 'failed', error: msg }) } } async function installBundledSkill( entry: SkillEntry, onStatus: (s: SkillStatus) => void, ): Promise { const sourceDir = join(BUNDLED_SKILLS_DIR, entry.name) const targetDir = join(SKILLS_DIR, entry.name) const tmpDir = join(SKILLS_DIR, `.tmp-${entry.name}-${randomUUID().slice(0, 8)}`) onStatus({ name: entry.name, state: 'downloading' }) // "downloading" reused for copy log(`Copying bundled skill ${entry.name} from ${sourceDir}`) try { if (!existsSync(sourceDir)) { throw new Error(`Bundled skill source not found: ${sourceDir}`) } mkdirSync(tmpDir, { recursive: true }) cpSync(sourceDir, tmpDir, { recursive: true }) // Validate onStatus({ name: entry.name, state: 'validating' }) const err = validateSkill(tmpDir, entry.requiredFiles) if (err) { throw new Error(`Validation failed: ${err}`) } // Atomic swap if (existsSync(targetDir)) { const existing = readVersionFile(targetDir) if (existing?.installedBy === 'clui') { rmSync(targetDir, { recursive: true, force: true }) } else { rmSync(tmpDir, { recursive: true, force: true }) onStatus({ name: entry.name, state: 'skipped', reason: 'user-managed' }) return } } renameSync(tmpDir, targetDir) writeVersionFile(targetDir, entry) log(`Installed bundled skill ${entry.name} v${entry.version}`) onStatus({ name: entry.name, state: 'installed' }) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) log(`Failed to install bundled skill ${entry.name}: ${msg}`) try { rmSync(tmpDir, { recursive: true, force: true }) } catch {} onStatus({ name: entry.name, state: 'failed', error: msg }) } } async function installSkill( entry: SkillEntry, onStatus: (s: SkillStatus) => void, ): Promise { const targetDir = join(SKILLS_DIR, entry.name) // Check if already installed and up-to-date if (existsSync(targetDir)) { const meta = readVersionFile(targetDir) if (!meta) { // Dir exists but no .clui-version — user-managed, don't touch log(`Skipping ${entry.name}: user-managed (no ${VERSION_FILE})`) onStatus({ name: entry.name, state: 'skipped', reason: 'user-managed' }) return } if (meta.version === entry.version && meta.installedBy === 'clui') { // Re-validate required files to detect corrupt/partial installs const validationErr = validateSkill(targetDir, entry.requiredFiles) if (!validationErr) { log(`Skipping ${entry.name}: already at v${entry.version}`) onStatus({ name: entry.name, state: 'skipped', reason: 'up-to-date' }) return } log(`Repairing ${entry.name}: version matches but ${validationErr}`) } // Version mismatch — needs update log(`Updating ${entry.name}: v${meta.version} → v${entry.version}`) } // Ensure parent dir exists mkdirSync(SKILLS_DIR, { recursive: true }) if (entry.source.type === 'github') { await installGithubSkill( entry as SkillEntry & { source: { type: 'github'; repo: string; path: string; commitSha: string } }, onStatus, ) } else { await installBundledSkill(entry, onStatus) } } /** * Ensure all manifest skills are installed. Non-blocking, non-crashing. * Calls onStatus for each skill as it progresses through states. */ export async function ensureSkills( onStatus: (s: SkillStatus) => void = () => {}, ): Promise { log(`Checking ${SKILLS.length} skill(s)`) for (const entry of SKILLS) { onStatus({ name: entry.name, state: 'pending' }) try { await installSkill(entry, onStatus) } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err) log(`Unexpected error installing ${entry.name}: ${msg}`) onStatus({ name: entry.name, state: 'failed', error: msg }) } } log('Skill provisioning complete') } ================================================ FILE: src/main/skills/manifest.ts ================================================ /** * Skill manifest — defines which skills CLUI auto-installs into ~/.claude/skills/. * * Two source types: * - github: downloaded from a pinned commit SHA (deterministic, not branch tip) * - bundled: copied from CLUI's own resources (for skills we author ourselves) * * To add a new skill, append an entry here. The installer handles the rest. */ export interface SkillEntry { name: string source: | { type: 'github'; repo: string; path: string; commitSha: string } | { type: 'bundled' } version: string /** Files that must exist after install for validation */ requiredFiles: string[] } export const SKILLS: SkillEntry[] = [ { name: 'skill-creator', source: { type: 'github', repo: 'anthropics/skills', path: 'skills/skill-creator', commitSha: 'b0cbd3df1533b396d281a6886d5132f623393a9c', }, version: '1.0.0', requiredFiles: [ 'SKILL.md', 'agents/grader.md', 'agents/comparator.md', 'agents/analyzer.md', 'references/schemas.md', 'scripts/run_loop.py', 'scripts/run_eval.py', 'scripts/package_skill.py', ], }, ] ================================================ FILE: src/main/stream-parser.ts ================================================ import { Readable } from 'stream' import { EventEmitter } from 'events' import type { ClaudeEvent } from '../shared/types' /** * Parses NDJSON output from `claude -p --output-format stream-json`. * Each line is a JSON object. Unknown event types are emitted but never crash. */ export class StreamParser extends EventEmitter { private buffer = '' /** * Feed a chunk of data (from stdout) into the parser. * Emits 'event' for each parsed JSON line. */ feed(chunk: string): void { this.buffer += chunk const lines = this.buffer.split('\n') // Keep the last (possibly incomplete) line in the buffer this.buffer = lines.pop() || '' for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue try { const parsed = JSON.parse(trimmed) as ClaudeEvent this.emit('event', parsed) } catch { // Non-JSON line (e.g. stderr mixed in) — log but don't crash this.emit('parse-error', trimmed) } } } /** * Flush any remaining data in the buffer (call when stream ends). */ flush(): void { const trimmed = this.buffer.trim() if (trimmed) { try { const parsed = JSON.parse(trimmed) as ClaudeEvent this.emit('event', parsed) } catch { this.emit('parse-error', trimmed) } } this.buffer = '' } /** * Convenience: pipe a readable stream through the parser. */ static fromStream(stream: Readable): StreamParser { const parser = new StreamParser() stream.setEncoding('utf-8') stream.on('data', (chunk: string) => parser.feed(chunk)) stream.on('end', () => parser.flush()) return parser } } ================================================ FILE: src/preload/index.ts ================================================ import { contextBridge, ipcRenderer } from 'electron' import { IPC } from '../shared/types' import type { RunOptions, NormalizedEvent, HealthReport, EnrichedError, Attachment, SessionMeta, CatalogPlugin, SessionLoadMessage } from '../shared/types' export interface CluiAPI { // ─── Request-response (renderer → main) ─── start(): Promise<{ version: string; auth: { email?: string; subscriptionType?: string; authMethod?: string }; mcpServers: string[]; projectPath: string; homePath: string }> createTab(): Promise<{ tabId: string }> prompt(tabId: string, requestId: string, options: RunOptions): Promise cancel(requestId: string): Promise stopTab(tabId: string): Promise retry(tabId: string, requestId: string, options: RunOptions): Promise status(): Promise tabHealth(): Promise closeTab(tabId: string): Promise selectDirectory(): Promise openExternal(url: string): Promise openInTerminal(sessionId: string | null, projectPath?: string): Promise attachFiles(): Promise takeScreenshot(): Promise pasteImage(dataUrl: string): Promise transcribeAudio(audioBase64: string): Promise<{ error: string | null; transcript: string | null }> getDiagnostics(): Promise respondPermission(tabId: string, questionId: string, optionId: string): Promise initSession(tabId: string): void resetTabSession(tabId: string): void listSessions(projectPath?: string): Promise loadSession(sessionId: string, projectPath?: string): Promise fetchMarketplace(forceRefresh?: boolean): Promise<{ plugins: CatalogPlugin[]; error: string | null }> listInstalledPlugins(): Promise installPlugin(repo: string, pluginName: string, marketplace: string, sourcePath?: string, isSkillMd?: boolean): Promise<{ ok: boolean; error?: string }> uninstallPlugin(pluginName: string): Promise<{ ok: boolean; error?: string }> setPermissionMode(mode: string): void getTheme(): Promise<{ isDark: boolean }> onThemeChange(callback: (isDark: boolean) => void): () => void // ─── Window management ─── resizeHeight(height: number): void setWindowWidth(width: number): void animateHeight(from: number, to: number, durationMs: number): Promise hideWindow(): void isVisible(): Promise /** OS-level click-through for transparent window regions */ setIgnoreMouseEvents(ignore: boolean, options?: { forward?: boolean }): void /** Manual window drag for frameless windows */ startWindowDrag(deltaX: number, deltaY: number): void /** Reset overlay to its default bottom-center position */ resetWindowPosition(): void // ─── Event listeners (main → renderer) ─── onEvent(callback: (tabId: string, event: NormalizedEvent) => void): () => void onTabStatusChange(callback: (tabId: string, newStatus: string, oldStatus: string) => void): () => void onError(callback: (tabId: string, error: EnrichedError) => void): () => void onSkillStatus(callback: (status: { name: string; state: string; error?: string; reason?: string }) => void): () => void onWindowShown(callback: () => void): () => void } const api: CluiAPI = { // ─── Request-response ─── start: () => ipcRenderer.invoke(IPC.START), createTab: () => ipcRenderer.invoke(IPC.CREATE_TAB), prompt: (tabId, requestId, options) => ipcRenderer.invoke(IPC.PROMPT, { tabId, requestId, options }), cancel: (requestId) => ipcRenderer.invoke(IPC.CANCEL, requestId), stopTab: (tabId) => ipcRenderer.invoke(IPC.STOP_TAB, tabId), retry: (tabId, requestId, options) => ipcRenderer.invoke(IPC.RETRY, { tabId, requestId, options }), status: () => ipcRenderer.invoke(IPC.STATUS), tabHealth: () => ipcRenderer.invoke(IPC.TAB_HEALTH), closeTab: (tabId) => ipcRenderer.invoke(IPC.CLOSE_TAB, tabId), selectDirectory: () => ipcRenderer.invoke(IPC.SELECT_DIRECTORY), openExternal: (url) => ipcRenderer.invoke(IPC.OPEN_EXTERNAL, url), openInTerminal: (sessionId, projectPath) => ipcRenderer.invoke(IPC.OPEN_IN_TERMINAL, { sessionId, projectPath }), attachFiles: () => ipcRenderer.invoke(IPC.ATTACH_FILES), takeScreenshot: () => ipcRenderer.invoke(IPC.TAKE_SCREENSHOT), pasteImage: (dataUrl) => ipcRenderer.invoke(IPC.PASTE_IMAGE, dataUrl), transcribeAudio: (audioBase64) => ipcRenderer.invoke(IPC.TRANSCRIBE_AUDIO, audioBase64), getDiagnostics: () => ipcRenderer.invoke(IPC.GET_DIAGNOSTICS), respondPermission: (tabId, questionId, optionId) => ipcRenderer.invoke(IPC.RESPOND_PERMISSION, { tabId, questionId, optionId }), initSession: (tabId) => ipcRenderer.send(IPC.INIT_SESSION, tabId), resetTabSession: (tabId) => ipcRenderer.send(IPC.RESET_TAB_SESSION, tabId), listSessions: (projectPath?: string) => ipcRenderer.invoke(IPC.LIST_SESSIONS, projectPath), loadSession: (sessionId: string, projectPath?: string) => ipcRenderer.invoke(IPC.LOAD_SESSION, { sessionId, projectPath }), fetchMarketplace: (forceRefresh) => ipcRenderer.invoke(IPC.MARKETPLACE_FETCH, { forceRefresh }), listInstalledPlugins: () => ipcRenderer.invoke(IPC.MARKETPLACE_INSTALLED), installPlugin: (repo, pluginName, marketplace, sourcePath, isSkillMd) => ipcRenderer.invoke(IPC.MARKETPLACE_INSTALL, { repo, pluginName, marketplace, sourcePath, isSkillMd }), uninstallPlugin: (pluginName) => ipcRenderer.invoke(IPC.MARKETPLACE_UNINSTALL, { pluginName }), setPermissionMode: (mode) => ipcRenderer.send(IPC.SET_PERMISSION_MODE, mode), getTheme: () => ipcRenderer.invoke(IPC.GET_THEME), onThemeChange: (callback) => { const handler = (_e: Electron.IpcRendererEvent, isDark: boolean) => callback(isDark) ipcRenderer.on(IPC.THEME_CHANGED, handler) return () => ipcRenderer.removeListener(IPC.THEME_CHANGED, handler) }, // ─── Window management ─── resizeHeight: (height) => ipcRenderer.send(IPC.RESIZE_HEIGHT, height), animateHeight: (from, to, durationMs) => ipcRenderer.invoke(IPC.ANIMATE_HEIGHT, { from, to, durationMs }), hideWindow: () => ipcRenderer.send(IPC.HIDE_WINDOW), isVisible: () => ipcRenderer.invoke(IPC.IS_VISIBLE), setIgnoreMouseEvents: (ignore, options) => ipcRenderer.send(IPC.SET_IGNORE_MOUSE_EVENTS, ignore, options || {}), startWindowDrag: (deltaX, deltaY) => ipcRenderer.send(IPC.START_WINDOW_DRAG, deltaX, deltaY), resetWindowPosition: () => ipcRenderer.send(IPC.RESET_WINDOW_POSITION), setWindowWidth: (width) => ipcRenderer.send(IPC.SET_WINDOW_WIDTH, width), // ─── Event listeners ─── onEvent: (callback) => { const channels = [ IPC.TEXT_CHUNK, IPC.TOOL_CALL, IPC.TOOL_CALL_UPDATE, IPC.TOOL_CALL_COMPLETE, IPC.TASK_UPDATE, IPC.TASK_COMPLETE, IPC.SESSION_DEAD, IPC.SESSION_INIT, IPC.ERROR, IPC.RATE_LIMIT, ] // Single unified handler — all normalized events come through one channel const handler = (_e: Electron.IpcRendererEvent, tabId: string, event: NormalizedEvent) => callback(tabId, event) ipcRenderer.on('clui:normalized-event', handler) return () => ipcRenderer.removeListener('clui:normalized-event', handler) }, onTabStatusChange: (callback) => { const handler = (_e: Electron.IpcRendererEvent, tabId: string, newStatus: string, oldStatus: string) => callback(tabId, newStatus, oldStatus) ipcRenderer.on('clui:tab-status-change', handler) return () => ipcRenderer.removeListener('clui:tab-status-change', handler) }, onError: (callback) => { const handler = (_e: Electron.IpcRendererEvent, tabId: string, error: EnrichedError) => callback(tabId, error) ipcRenderer.on('clui:enriched-error', handler) return () => ipcRenderer.removeListener('clui:enriched-error', handler) }, onSkillStatus: (callback) => { const handler = (_e: Electron.IpcRendererEvent, status: any) => callback(status) ipcRenderer.on(IPC.SKILL_STATUS, handler) return () => ipcRenderer.removeListener(IPC.SKILL_STATUS, handler) }, onWindowShown: (callback) => { const handler = () => callback() ipcRenderer.on(IPC.WINDOW_SHOWN, handler) return () => ipcRenderer.removeListener(IPC.WINDOW_SHOWN, handler) }, } contextBridge.exposeInMainWorld('clui', api) ================================================ FILE: src/renderer/App.tsx ================================================ import React, { useEffect, useCallback, useRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Paperclip, Camera, HeadCircuit } from '@phosphor-icons/react' import { TabStrip } from './components/TabStrip' import { ConversationView } from './components/ConversationView' import { InputBar } from './components/InputBar' import { StatusBar } from './components/StatusBar' import { MarketplacePanel } from './components/MarketplacePanel' import { PopoverLayerProvider } from './components/PopoverLayer' import { useClaudeEvents } from './hooks/useClaudeEvents' import { useHealthReconciliation } from './hooks/useHealthReconciliation' import { useSessionStore } from './stores/sessionStore' import { useColors, useThemeStore, spacing } from './theme' const TRANSITION = { duration: 0.26, ease: [0.4, 0, 0.1, 1] as const } export default function App() { useClaudeEvents() useHealthReconciliation() const activeTabStatus = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId)?.status) const addAttachments = useSessionStore((s) => s.addAttachments) const colors = useColors() const setSystemTheme = useThemeStore((s) => s.setSystemTheme) const expandedUI = useThemeStore((s) => s.expandedUI) // ─── Theme initialization ─── useEffect(() => { // Get initial OS theme — setSystemTheme respects themeMode (system/light/dark) window.clui.getTheme().then(({ isDark }) => { setSystemTheme(isDark) }).catch(() => {}) // Listen for OS theme changes const unsub = window.clui.onThemeChange((isDark) => { setSystemTheme(isDark) }) return unsub }, [setSystemTheme]) useEffect(() => { useSessionStore.getState().initStaticInfo().then(() => { const homeDir = useSessionStore.getState().staticInfo?.homePath || '~' const tab = useSessionStore.getState().tabs[0] if (tab) { // Set working directory to home by default (user hasn't chosen yet) useSessionStore.setState((s) => ({ tabs: s.tabs.map((t, i) => (i === 0 ? { ...t, workingDirectory: homeDir, hasChosenDirectory: false } : t)), })) window.clui.createTab().then(({ tabId }) => { useSessionStore.setState((s) => ({ tabs: s.tabs.map((t, i) => (i === 0 ? { ...t, id: tabId } : t)), activeTabId: tabId, })) }).catch(() => {}) } }) }, []) // Shared drag ref — must be declared before the setIgnoreMouseEvents effect so both closures can read it const dragRef = useRef<{ startX: number; startY: number } | null>(null) // Vertical position tracking — window moves first (until macOS clamps it), then CSS overflows const PILL_HEIGHT_CONST = 720 const PILL_BOTTOM_MARGIN_CONST = 24 const minWindowY = window.screen.availTop // top of work area (below menu bar) const initialWindowY = window.screen.availTop + window.screen.availHeight - PILL_HEIGHT_CONST - PILL_BOTTOM_MARGIN_CONST const windowYRef = useRef(initialWindowY) const cardYRef = useRef(0) // CSS translateY offset (only used after window hits its y constraint) // OS-level click-through (RAF-throttled to avoid per-pixel IPC) useEffect(() => { if (!window.clui?.setIgnoreMouseEvents) return let lastIgnored: boolean | null = null const onMouseMove = (e: MouseEvent) => { // While dragging, keep full mouse capture — don't toggle ignore-events if (dragRef.current) return const el = document.elementFromPoint(e.clientX, e.clientY) const isUI = !!(el && el.closest('[data-clui-ui]')) const shouldIgnore = !isUI if (shouldIgnore !== lastIgnored) { lastIgnored = shouldIgnore if (shouldIgnore) { window.clui.setIgnoreMouseEvents(true, { forward: true }) } else { window.clui.setIgnoreMouseEvents(false) } } } const onMouseLeave = () => { if (dragRef.current) return if (lastIgnored !== true) { lastIgnored = true window.clui.setIgnoreMouseEvents(true, { forward: true }) } } document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseleave', onMouseLeave) return () => { document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseleave', onMouseLeave) } }, []) // Manual window drag — bypasses -webkit-app-region conflicts with setIgnoreMouseEvents useEffect(() => { if (!window.clui?.startWindowDrag) return const onMouseDown = (e: MouseEvent) => { const el = e.target as HTMLElement // Skip interactive elements — everything else on the card is draggable if (el.closest('button, input, textarea, a, select, [role="button"], [contenteditable], .cm-editor')) return if (!el.closest('[data-clui-ui]')) return e.preventDefault() // Double-click: snap back to default position if (e.detail >= 2) { window.clui.resetWindowPosition() windowYRef.current = initialWindowY cardYRef.current = 0 document.documentElement.style.setProperty('--clui-card-y', '0px') return } // Ensure full mouse capture for the duration of the drag window.clui.setIgnoreMouseEvents(false) dragRef.current = { startX: e.screenX, startY: e.screenY } } const onMouseMove = (e: MouseEvent) => { if (!dragRef.current) return const dx = e.screenX - dragRef.current.startX const dy = e.screenY - dragRef.current.startY if (dx !== 0 || dy !== 0) { // Horizontal: always native window movement (full screen width range) if (dx !== 0) window.clui.startWindowDrag(dx, 0) // Vertical: move window first (until macOS y constraint), then CSS within window if (dy !== 0) { if (dy < 0) { // Moving up — window first, then CSS overflow const windowCanMove = windowYRef.current - minWindowY const windowDy = Math.max(-windowCanMove, dy) const cssDy = dy - windowDy if (windowDy !== 0) { window.clui.startWindowDrag(0, windowDy) windowYRef.current += windowDy } if (cssDy !== 0) { cardYRef.current += cssDy document.documentElement.style.setProperty('--clui-card-y', `${cardYRef.current}px`) } } else { // Moving down — undo CSS first, then move window const cssUndo = Math.min(-cardYRef.current, dy) const windowDy = dy - cssUndo if (cssUndo !== 0) { cardYRef.current += cssUndo document.documentElement.style.setProperty('--clui-card-y', `${cardYRef.current}px`) } if (windowDy !== 0) { window.clui.startWindowDrag(0, windowDy) windowYRef.current += windowDy } } } dragRef.current.startX = e.screenX dragRef.current.startY = e.screenY } } const onMouseUp = () => { dragRef.current = null } document.addEventListener('mousedown', onMouseDown) document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) return () => { document.removeEventListener('mousedown', onMouseDown) document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) } }, []) const isExpanded = useSessionStore((s) => s.isExpanded) const marketplaceOpen = useSessionStore((s) => s.marketplaceOpen) const isRunning = activeTabStatus === 'running' || activeTabStatus === 'connecting' // Layout dimensions — expandedUI widens and heightens the panel const contentWidth = expandedUI ? 700 : spacing.contentWidth const cardExpandedWidth = expandedUI ? 700 : 460 const cardCollapsedWidth = expandedUI ? 670 : 430 const cardCollapsedMargin = expandedUI ? 15 : 15 const bodyMaxHeight = expandedUI ? 520 : 400 const handleScreenshot = useCallback(async () => { const result = await window.clui.takeScreenshot() if (!result) return addAttachments([result]) }, [addAttachments]) const handleAttachFile = useCallback(async () => { const files = await window.clui.attachFiles() if (!files || files.length === 0) return addAttachments(files) }, [addAttachments]) return (
{/* ─── 460px content column, centered. Circles overflow left. ─── */}
{marketplaceOpen && (
)}
{/* ─── Tabs / message shell ─── This always remains the chat shell. The marketplace is a separate panel rendered above it, never inside it. */} {/* Tab strip — always mounted */}
{/* Body — chat history only; the marketplace is a separate overlay above */}
{/* ─── Input row — circles float outside left ─── */} {/* marginBottom: shadow buffer so the glass-surface drop shadow isn't clipped at the native window edge */}
{/* Stacked circle buttons — expand on hover */}
{/* btn-1: Attach (front, rightmost) */} {/* btn-2: Screenshot (middle) */} {/* btn-3: Skills (back, leftmost) */}
{/* Input pill */}
) } ================================================ FILE: src/renderer/components/AttachmentChips.tsx ================================================ import React from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X, FileText, Image, FileCode, File } from '@phosphor-icons/react' import { useColors } from '../theme' import type { Attachment } from '../../shared/types' const FILE_ICONS: Record = { 'image/png': , 'image/jpeg': , 'image/gif': , 'image/webp': , 'image/svg+xml': , 'text/plain': , 'text/markdown': , 'application/json': , 'text/yaml': , 'text/toml': , } export function AttachmentChips({ attachments, onRemove, }: { attachments: Attachment[] onRemove: (id: string) => void }) { const colors = useColors() if (attachments.length === 0) return null return (
{attachments.map((a) => ( {/* Image preview thumbnail */} {a.dataUrl ? ( {a.name} ) : ( {FILE_ICONS[a.mimeType || ''] || } )} {/* File name */} {a.name} {/* Remove button */} ))}
) } ================================================ FILE: src/renderer/components/ConversationView.tsx ================================================ import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { FileText, PencilSimple, FileArrowUp, Terminal, MagnifyingGlass, Globe, Robot, Question, Wrench, FolderOpen, Copy, Check, CaretRight, CaretDown, SpinnerGap, ArrowCounterClockwise, Square, } from '@phosphor-icons/react' import { useSessionStore } from '../stores/sessionStore' import { PermissionCard } from './PermissionCard' import { PermissionDeniedCard } from './PermissionDeniedCard' import { useColors, useThemeStore } from '../theme' import type { Message } from '../../shared/types' // ─── Constants ─── const INITIAL_RENDER_CAP = 100 const PAGE_SIZE = 100 const REMARK_PLUGINS = [remarkGfm] // Hoisted — prevents re-parse on every render // ─── Types ─── type GroupedItem = | { kind: 'user'; message: Message } | { kind: 'assistant'; message: Message } | { kind: 'system'; message: Message } | { kind: 'tool-group'; messages: Message[] } // ─── Helpers ─── function groupMessages(messages: Message[]): GroupedItem[] { const result: GroupedItem[] = [] let toolBuf: Message[] = [] const flushTools = () => { if (toolBuf.length > 0) { result.push({ kind: 'tool-group', messages: [...toolBuf] }) toolBuf = [] } } for (const msg of messages) { if (msg.role === 'tool') { toolBuf.push(msg) } else { flushTools() if (msg.role === 'user') result.push({ kind: 'user', message: msg }) else if (msg.role === 'assistant') result.push({ kind: 'assistant', message: msg }) else result.push({ kind: 'system', message: msg }) } } flushTools() return result } // ─── Main Component ─── export function ConversationView() { const tabs = useSessionStore((s) => s.tabs) const activeTabId = useSessionStore((s) => s.activeTabId) const sendMessage = useSessionStore((s) => s.sendMessage) const staticInfo = useSessionStore((s) => s.staticInfo) const scrollRef = useRef(null) const bottomRef = useRef(null) const [hovered, setHovered] = useState(false) const [renderOffset, setRenderOffset] = useState(0) // 0 = show from tail const isNearBottomRef = useRef(true) const prevTabIdRef = useRef(activeTabId) const colors = useColors() const expandedUI = useThemeStore((s) => s.expandedUI) const tab = tabs.find((t) => t.id === activeTabId) // Reset render offset and scroll state when switching tabs useEffect(() => { if (activeTabId !== prevTabIdRef.current) { prevTabIdRef.current = activeTabId setRenderOffset(0) isNearBottomRef.current = true } }, [activeTabId]) // Track whether user is scrolled near the bottom const handleScroll = useCallback(() => { const el = scrollRef.current if (!el) return isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60 }, []) // Auto-scroll when content changes and user is near bottom. const msgCount = tab?.messages.length ?? 0 const lastMsg = tab?.messages[tab.messages.length - 1] const permissionQueueLen = tab?.permissionQueue?.length ?? 0 const queuedCount = tab?.queuedPrompts?.length ?? 0 const scrollTrigger = `${msgCount}:${lastMsg?.content?.length ?? 0}:${permissionQueueLen}:${queuedCount}` useEffect(() => { if (isNearBottomRef.current && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [scrollTrigger]) // Group only the visible slice of messages const allMessages = tab?.messages ?? [] const totalCount = allMessages.length const startIndex = Math.max(0, totalCount - INITIAL_RENDER_CAP - renderOffset * PAGE_SIZE) const visibleMessages = startIndex > 0 ? allMessages.slice(startIndex) : allMessages const hasOlder = startIndex > 0 const grouped = useMemo( () => groupMessages(visibleMessages), [visibleMessages], ) const hiddenCount = totalCount - visibleMessages.length const handleLoadOlder = useCallback(() => { setRenderOffset((o) => o + 1) }, []) if (!tab) return null const isRunning = tab.status === 'running' || tab.status === 'connecting' const isDead = tab.status === 'dead' const isFailed = tab.status === 'failed' const showInterrupt = isRunning && tab.messages.some((m) => m.role === 'user') if (tab.messages.length === 0) { return } // Messages from before initial render cap are "historical" — no motion const historicalThreshold = Math.max(0, totalCount - 20) const handleRetry = () => { const lastUserMsg = [...tab.messages].reverse().find((m) => m.role === 'user') if (lastUserMsg) { sendMessage(lastUserMsg.content) } } return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > {/* Scrollable messages area */}
{/* Load older button */} {hasOlder && (
)}
{grouped.map((item, idx) => { const msgIndex = startIndex + idx const isHistorical = msgIndex < historicalThreshold switch (item.kind) { case 'user': return case 'assistant': return case 'tool-group': return case 'system': return default: return null } })}
{/* Permission card (shows first item from queue) */} {tab.permissionQueue.length > 0 && ( )} {/* Permission denied fallback card */} {tab.permissionDenied && ( { useSessionStore.setState((s) => ({ tabs: s.tabs.map((t) => t.id === tab.id ? { ...t, permissionDenied: null } : t ), })) }} /> )} {/* Queued prompts */} {tab.queuedPrompts.map((prompt, i) => ( ))}
{/* Activity row — overlaps bottom of scroll area as a fade strip */}
{/* Left: status indicator */}
{isRunning && ( {tab.currentActivity || 'Working...'} )} {isDead && ( Session ended unexpectedly )} {isFailed && ( Failed )}
{/* Right: interrupt button when running */}
{showInterrupt && ( )}
) } // ─── Empty State (directory picker before first message) ─── function EmptyState() { const setBaseDirectory = useSessionStore((s) => s.setBaseDirectory) const colors = useColors() const handleChooseFolder = async () => { const dir = await window.clui.selectDirectory() if (dir) { setBaseDirectory(dir) } } return (
Press ⌥ + Space to show/hide this overlay
) } // ─── Copy Button ─── function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false) const colors = useColors() const handleCopy = async () => { try { await navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch {} } return ( {copied ? : } {copied ? 'Copied' : 'Copy'} ) } // ─── Interrupt Button ─── function InterruptButton({ tabId }: { tabId: string }) { const colors = useColors() const handleStop = () => { window.clui.stopTab(tabId) } return ( { e.currentTarget.style.background = colors.statusErrorBg }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }} title="Stop current task" > Interrupt ) } // ─── User Message ─── function UserMessage({ message, skipMotion }: { message: Message; skipMotion?: boolean }) { const colors = useColors() const content = (
{message.content}
) if (skipMotion) { return
{content}
} return ( {content} ) } // ─── Queued Message (waiting at bottom until processed) ─── function QueuedMessage({ content }: { content: string }) { const colors = useColors() return (
{content}
) } // ─── Table scroll wrapper — fade edges when horizontally scrollable ─── function TableScrollWrapper({ children }: { children: React.ReactNode }) { const ref = useRef(null) const [fade, setFade] = useState(undefined) const prevFade = useRef(undefined) const update = useCallback(() => { const el = ref.current if (!el) return const { scrollLeft, scrollWidth, clientWidth } = el let next: string | undefined if (scrollWidth <= clientWidth + 1) { next = undefined } else { const l = scrollLeft > 1 const r = scrollLeft + clientWidth < scrollWidth - 1 next = l && r ? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)' : l ? 'linear-gradient(to right, transparent, black 24px)' : r ? 'linear-gradient(to right, black calc(100% - 24px), transparent)' : undefined } if (next !== prevFade.current) { prevFade.current = next setFade(next) } }, []) useEffect(() => { update() const el = ref.current if (!el) return const ro = new ResizeObserver(update) ro.observe(el) const table = el.querySelector('table') if (table) ro.observe(table) return () => ro.disconnect() }, [update]) return (
{children}
) } // ─── Image card — graceful fallback when src returns 404 ─── function ImageCard({ src, alt, colors }: { src?: string; alt?: string; colors: ReturnType }) { const [failed, setFailed] = useState(false) // Reset failed state when src changes (e.g. during streaming) useEffect(() => { setFailed(false) }, [src]) const label = alt || 'Image' const open = () => { if (src) window.clui.openExternal(String(src)) } if (failed || !src) { return ( ) } return ( ) } // ─── Assistant Message (memoized — only re-renders when content changes) ─── const AssistantMessage = React.memo(function AssistantMessage({ message, skipMotion, }: { message: Message skipMotion?: boolean }) { const colors = useColors() const markdownComponents = useMemo(() => ({ table: ({ children }: any) => {children}, a: ({ href, children }: any) => ( ), img: ({ src, alt }: any) => , }), [colors]) const inner = (
{message.content}
{/* Copy button — always in DOM, shown via CSS :hover (no React state needed). Absolute positioning so it never shifts the text layout. */} {message.content.trim() && (
)}
) if (skipMotion) { return
{inner}
} return ( {inner} ) }, (prev, next) => prev.message.content === next.message.content && prev.skipMotion === next.skipMotion) // ─── Tool Group (collapsible timeline — Claude Code style) ─── /** Build a short description from tool name + input for the collapsed summary */ function toolSummary(tools: Message[]): string { if (tools.length === 0) return '' // Use first tool's context for summary const first = tools[0] const desc = getToolDescription(first.toolName || 'Tool', first.toolInput) if (tools.length === 1) return desc return `${desc} and ${tools.length - 1} more tool${tools.length > 2 ? 's' : ''}` } /** Short human-readable description from tool name + already-parsed input */ function getToolDescriptionFromParsed(name: string, parsed: Record): string { const s = (v: unknown) => (typeof v === 'string' ? v : '') switch (name) { case 'Read': return `Read ${s(parsed.file_path) || s(parsed.path) || 'file'}` case 'Edit': return `Edit ${s(parsed.file_path) || 'file'}` case 'Write': return `Write ${s(parsed.file_path) || 'file'}` case 'Glob': return `Search files: ${s(parsed.pattern)}` case 'Grep': return `Search: ${s(parsed.pattern)}` case 'Bash': { const cmd = s(parsed.command) return cmd.length > 60 ? `${cmd.substring(0, 57)}...` : cmd || 'Bash' } case 'WebSearch': return `Search: ${s(parsed.query) || s(parsed.search_query)}` case 'WebFetch': return `Fetch: ${s(parsed.url)}` case 'Agent': return `Agent: ${(s(parsed.prompt) || s(parsed.description)).substring(0, 50)}` default: return name } } /** Short human-readable description from tool name + input */ function getToolDescription(name: string, input?: string): string { if (!input) return name try { return getToolDescriptionFromParsed(name, JSON.parse(input)) } catch { // Input is not JSON or is partial — show truncated raw const trimmed = input.trim() if (trimmed.length > 60) return `${name}: ${trimmed.substring(0, 57)}...` return trimmed ? `${name}: ${trimmed}` : name } } function ToolGroup({ tools, skipMotion }: { tools: Message[]; skipMotion?: boolean }) { const hasRunning = tools.some((t) => t.toolStatus === 'running') const [expanded, setExpanded] = useState(false) const colors = useColors() const isOpen = expanded || hasRunning if (isOpen) { const inner = (
{/* Collapse header — click to close */} {!hasRunning && (
setExpanded(false)} > Used {tools.length} tool{tools.length !== 1 ? 's' : ''}
)} {/* Timeline */}
{/* Vertical line */}
{tools.map((tool) => { const isRunning = tool.toolStatus === 'running' const toolName = tool.toolName || 'Tool' // Parse tool input once for both description and detail content let parsedInput: Record | null = null if (tool.toolInput) { try { parsedInput = JSON.parse(tool.toolInput) } catch { /* partial JSON */ } } const desc = parsedInput ? getToolDescriptionFromParsed(toolName, parsedInput) : getToolDescription(toolName, tool.toolInput) return (
{/* Timeline node */}
{isRunning ? : }
{/* Tool description */}
{desc} {/* Tool detail content for Edit/Write */} {!isRunning && parsedInput && (() => { const monoFont = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' if (toolName === 'Edit' && ('old_string' in parsedInput || 'new_string' in parsedInput)) { const oldStr = typeof parsedInput.old_string === 'string' ? parsedInput.old_string : null const newStr = typeof parsedInput.new_string === 'string' ? parsedInput.new_string : null if (oldStr === null && newStr === null) return null return (
{oldStr !== null && (
- {oldStr.length > 300 ? oldStr.slice(0, 297) + '...' : oldStr}
)} {newStr !== null && (
+ {newStr.length > 300 ? newStr.slice(0, 297) + '...' : newStr}
)}
) } if (toolName === 'Write' && typeof parsedInput.content === 'string') { const content = parsedInput.content const snippet = content.length > 200 ? content.slice(0, 197) + '...' : content return (
{snippet}
) } return null })()} {/* Result badge */} {!isRunning && ( Result )} {isRunning && ( running... )}
) })}
) if (skipMotion) return inner return ( {inner} ) } // Collapsed state — summary text + chevron, no container const summary = toolSummary(tools) const inner = (
setExpanded(true)} > {summary}
) if (skipMotion) return
{inner}
return ( {inner} ) } // ─── System Message ─── function SystemMessage({ message, skipMotion }: { message: Message; skipMotion?: boolean }) { const isError = message.content.startsWith('Error:') || message.content.includes('unexpectedly') const colors = useColors() const inner = (
{message.content}
) if (skipMotion) return
{inner}
return ( {inner} ) } // ─── Tool Icon mapping ─── function ToolIcon({ name, size = 12 }: { name: string; size?: number }) { const colors = useColors() const ICONS: Record = { Read: , Edit: , Write: , Bash: , Glob: , Grep: , WebSearch: , WebFetch: , Agent: , AskUserQuestion: , } return ( {ICONS[name] || } ) } ================================================ FILE: src/renderer/components/HistoryPicker.tsx ================================================ import React, { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { motion } from 'framer-motion' import { Clock, ChatCircle } from '@phosphor-icons/react' import { useSessionStore } from '../stores/sessionStore' import { usePopoverLayer } from './PopoverLayer' import { useColors } from '../theme' import type { SessionMeta } from '../../shared/types' function formatTimeAgo(isoDate: string): string { const diff = Date.now() - new Date(isoDate).getTime() const mins = Math.floor(diff / 60000) if (mins < 1) return 'just now' if (mins < 60) return `${mins}m ago` const hours = Math.floor(mins / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) if (days < 7) return `${days}d ago` return new Date(isoDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K` return `${(bytes / (1024 * 1024)).toFixed(1)}M` } export function HistoryPicker() { const resumeSession = useSessionStore((s) => s.resumeSession) const isExpanded = useSessionStore((s) => s.isExpanded) const activeTab = useSessionStore( (s) => s.tabs.find((t) => t.id === s.activeTabId), (a, b) => a === b || (!!a && !!b && a.hasChosenDirectory === b.hasChosenDirectory && a.workingDirectory === b.workingDirectory), ) const staticInfo = useSessionStore((s) => s.staticInfo) const popoverLayer = usePopoverLayer() const colors = useColors() const effectiveProjectPath = activeTab?.hasChosenDirectory ? activeTab.workingDirectory : (staticInfo?.homePath || activeTab?.workingDirectory || '~') const [open, setOpen] = useState(false) const [sessions, setSessions] = useState([]) const [loading, setLoading] = useState(false) const triggerRef = useRef(null) const popoverRef = useRef(null) const [pos, setPos] = useState<{ right: number; top?: number; bottom?: number; maxHeight?: number }>({ right: 0 }) const updatePos = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() if (isExpanded) { const top = rect.bottom + 6 setPos({ top, right: window.innerWidth - rect.right, maxHeight: window.innerHeight - top - 12, }) } else { setPos({ bottom: window.innerHeight - rect.top + 6, right: window.innerWidth - rect.right, }) } }, [isExpanded]) const loadSessions = useCallback(async () => { setLoading(true) try { const result = await window.clui.listSessions(effectiveProjectPath) setSessions(result) } catch { setSessions([]) } setLoading(false) }, [effectiveProjectPath]) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { const target = e.target as Node if (triggerRef.current?.contains(target)) return if (popoverRef.current?.contains(target)) return setOpen(false) } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open]) const handleToggle = () => { if (!open) { updatePos() void loadSessions() } setOpen((o) => !o) } const handleSelect = (session: SessionMeta) => { setOpen(false) const title = session.firstMessage ? (session.firstMessage.length > 30 ? session.firstMessage.substring(0, 27) + '...' : session.firstMessage) : session.slug || 'Resumed' void resumeSession(session.sessionId, title, effectiveProjectPath) } return ( <> {popoverLayer && open && createPortal(
Recent Sessions
{loading && (
Loading...
)} {!loading && sessions.length === 0 && (
No previous sessions found
)} {!loading && sessions.map((session) => ( ))}
, popoverLayer, )} ) } ================================================ FILE: src/renderer/components/InputBar.tsx ================================================ import React, { useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Microphone, ArrowUp, SpinnerGap, X, Check } from '@phosphor-icons/react' import { useSessionStore, AVAILABLE_MODELS } from '../stores/sessionStore' import { AttachmentChips } from './AttachmentChips' import { SlashCommandMenu, getFilteredCommandsWithExtras, type SlashCommand } from './SlashCommandMenu' import { useColors } from '../theme' const INPUT_MIN_HEIGHT = 20 const INPUT_MAX_HEIGHT = 140 const MULTILINE_ENTER_HEIGHT = 52 const MULTILINE_EXIT_HEIGHT = 50 const INLINE_CONTROLS_RESERVED_WIDTH = 104 type VoiceState = 'idle' | 'recording' | 'transcribing' /** * InputBar renders inside a glass-surface rounded-full pill provided by App.tsx. * It provides: textarea + mic/send buttons. Attachment chips render above when present. */ export function InputBar() { const [input, setInput] = useState('') const [voiceState, setVoiceState] = useState('idle') const [voiceError, setVoiceError] = useState(null) const [slashFilter, setSlashFilter] = useState(null) const [slashIndex, setSlashIndex] = useState(0) const [isMultiLine, setIsMultiLine] = useState(false) const textareaRef = useRef(null) const wrapperRef = useRef(null) const measureRef = useRef(null) const mediaRecorderRef = useRef(null) const chunksRef = useRef([]) const sendMessage = useSessionStore((s) => s.sendMessage) const clearTab = useSessionStore((s) => s.clearTab) const addSystemMessage = useSessionStore((s) => s.addSystemMessage) const addAttachments = useSessionStore((s) => s.addAttachments) const removeAttachment = useSessionStore((s) => s.removeAttachment) const setPreferredModel = useSessionStore((s) => s.setPreferredModel) const staticInfo = useSessionStore((s) => s.staticInfo) const preferredModel = useSessionStore((s) => s.preferredModel) const activeTabId = useSessionStore((s) => s.activeTabId) const tab = useSessionStore((s) => s.tabs.find((t) => t.id === s.activeTabId)) const colors = useColors() const isBusy = tab?.status === 'running' || tab?.status === 'connecting' const isConnecting = tab?.status === 'connecting' const hasContent = input.trim().length > 0 || (tab?.attachments?.length ?? 0) > 0 const canSend = !!tab && !isConnecting && hasContent const attachments = tab?.attachments || [] const showSlashMenu = slashFilter !== null && !isConnecting const skillCommands: SlashCommand[] = (tab?.sessionSkills || []).map((skill) => ({ command: `/${skill}`, description: `Run skill: ${skill}`, icon: , })) useEffect(() => { textareaRef.current?.focus() }, [activeTabId]) // Focus textarea when window is shown (shortcut toggle, screenshot return) useEffect(() => { const unsub = window.clui.onWindowShown(() => { textareaRef.current?.focus() }) return unsub }, []) const measureInlineHeight = useCallback((value: string): number => { if (typeof document === 'undefined') return 0 if (!measureRef.current) { const m = document.createElement('textarea') m.setAttribute('aria-hidden', 'true') m.tabIndex = -1 m.style.position = 'absolute' m.style.top = '-99999px' m.style.left = '0' m.style.height = '0' m.style.minHeight = '0' m.style.overflow = 'hidden' m.style.visibility = 'hidden' m.style.pointerEvents = 'none' m.style.zIndex = '-1' m.style.resize = 'none' m.style.border = '0' m.style.outline = '0' m.style.boxSizing = 'border-box' document.body.appendChild(m) measureRef.current = m } const m = measureRef.current const hostWidth = wrapperRef.current?.clientWidth ?? 0 const inlineWidth = Math.max(120, hostWidth - INLINE_CONTROLS_RESERVED_WIDTH) m.style.width = `${inlineWidth}px` m.style.fontSize = '14px' m.style.lineHeight = '20px' m.style.paddingTop = '15px' m.style.paddingBottom = '15px' m.style.paddingLeft = '0' m.style.paddingRight = '0' const computed = textareaRef.current ? window.getComputedStyle(textareaRef.current) : null if (computed) { m.style.fontFamily = computed.fontFamily m.style.letterSpacing = computed.letterSpacing m.style.fontWeight = computed.fontWeight } m.value = value || ' ' return m.scrollHeight }, []) const autoResize = useCallback(() => { const el = textareaRef.current if (!el) return el.style.height = `${INPUT_MIN_HEIGHT}px` const naturalHeight = el.scrollHeight const clampedHeight = Math.min(naturalHeight, INPUT_MAX_HEIGHT) el.style.height = `${clampedHeight}px` el.style.overflowY = naturalHeight > INPUT_MAX_HEIGHT ? 'auto' : 'hidden' if (naturalHeight <= INPUT_MAX_HEIGHT) { el.scrollTop = 0 } // Decide multiline mode against fixed inline-width measurement to avoid // expand/collapse bounce when layout switches between modes. const inlineHeight = measureInlineHeight(input) setIsMultiLine((prev) => { if (!prev) return inlineHeight > MULTILINE_ENTER_HEIGHT return inlineHeight > MULTILINE_EXIT_HEIGHT }) }, [input, measureInlineHeight]) useLayoutEffect(() => { autoResize() }, [input, isMultiLine, autoResize]) useEffect(() => { return () => { if (mediaRecorderRef.current?.state === 'recording') { mediaRecorderRef.current.stop() } if (measureRef.current) { measureRef.current.remove() measureRef.current = null } } }, []) // ─── Slash command detection ─── const updateSlashFilter = useCallback((value: string) => { const match = value.match(/^(\/[a-zA-Z-]*)$/) if (match) { setSlashFilter(match[1]) setSlashIndex(0) } else { setSlashFilter(null) } }, []) // ─── Handle slash commands ─── const executeCommand = useCallback((cmd: SlashCommand) => { switch (cmd.command) { case '/clear': clearTab() addSystemMessage('Conversation cleared.') break case '/cost': { if (tab?.lastResult) { const r = tab.lastResult const parts = [`$${r.totalCostUsd.toFixed(4)}`, `${(r.durationMs / 1000).toFixed(1)}s`, `${r.numTurns} turn${r.numTurns !== 1 ? 's' : ''}`] if (r.usage.input_tokens) { parts.push(`${r.usage.input_tokens.toLocaleString()} in / ${(r.usage.output_tokens || 0).toLocaleString()} out`) } addSystemMessage(parts.join(' · ')) } else { addSystemMessage('No cost data yet — send a message first.') } break } case '/model': { const model = tab?.sessionModel || null const version = tab?.sessionVersion || staticInfo?.version || null const current = preferredModel || model || 'default' const lines = AVAILABLE_MODELS.map((m) => { const active = m.id === current || (!preferredModel && m.id === model) return ` ${active ? '\u25CF' : '\u25CB'} ${m.label} (${m.id})` }) const header = version ? `Claude Code ${version}` : 'Claude Code' addSystemMessage(`${header}\n\n${lines.join('\n')}\n\nSwitch model: type /model \n e.g. /model sonnet`) break } case '/mcp': { if (tab?.sessionMcpServers && tab.sessionMcpServers.length > 0) { const lines = tab.sessionMcpServers.map((s) => { const icon = s.status === 'connected' ? '\u2713' : s.status === 'failed' ? '\u2717' : '\u25CB' return ` ${icon} ${s.name} — ${s.status}` }) addSystemMessage(`MCP Servers (${tab.sessionMcpServers.length}):\n${lines.join('\n')}`) } else if (tab?.claudeSessionId) { addSystemMessage('No MCP servers connected in this session.') } else { addSystemMessage('No MCP data yet — send a message to start a session.') } break } case '/skills': { if (tab?.sessionSkills && tab.sessionSkills.length > 0) { const lines = tab.sessionSkills.map((s) => `/${s}`) addSystemMessage(`Available skills (${tab.sessionSkills.length}):\n${lines.join('\n')}`) } else if (tab?.claudeSessionId) { addSystemMessage('No skills available in this session.') } else { addSystemMessage('No session metadata yet — send a message first.') } break } case '/help': { const lines = [ '/clear — Clear conversation history', '/cost — Show token usage and cost', '/model — Show model info & switch models', '/mcp — Show MCP server status', '/skills — Show available skills', '/help — Show this list', ] addSystemMessage(lines.join('\n')) break } } }, [tab, clearTab, addSystemMessage, staticInfo, preferredModel]) const handleSlashSelect = useCallback((cmd: SlashCommand) => { const isSkillCommand = !!tab?.sessionSkills?.includes(cmd.command.replace(/^\//, '')) if (isSkillCommand) { setInput(`${cmd.command} `) setSlashFilter(null) requestAnimationFrame(() => textareaRef.current?.focus()) return } setInput('') setSlashFilter(null) executeCommand(cmd) }, [executeCommand, tab?.sessionSkills]) // ─── Send ─── const handleSend = useCallback(() => { if (showSlashMenu) { const filtered = getFilteredCommandsWithExtras(slashFilter!, skillCommands) if (filtered.length > 0) { handleSlashSelect(filtered[slashIndex]) return } } const prompt = input.trim() const modelMatch = prompt.match(/^\/model\s+(\S+)/i) if (modelMatch) { const query = modelMatch[1].toLowerCase() const match = AVAILABLE_MODELS.find((m: { id: string; label: string }) => m.id.toLowerCase().includes(query) || m.label.toLowerCase().includes(query) ) if (match) { setPreferredModel(match.id) setInput('') setSlashFilter(null) addSystemMessage(`Model switched to ${match.label} (${match.id})`) } else { setInput('') setSlashFilter(null) addSystemMessage(`Unknown model "${modelMatch[1]}". Available: opus, sonnet, haiku`) } return } if (!prompt && attachments.length === 0) return if (isConnecting) return setInput('') setSlashFilter(null) if (textareaRef.current) { textareaRef.current.style.height = `${INPUT_MIN_HEIGHT}px` } sendMessage(prompt || 'See attached files') // Refocus after React re-renders from the state update requestAnimationFrame(() => textareaRef.current?.focus()) }, [input, isBusy, sendMessage, attachments.length, showSlashMenu, slashFilter, slashIndex, handleSlashSelect]) // ─── Keyboard ─── const handleKeyDown = (e: React.KeyboardEvent) => { if (showSlashMenu) { const filtered = getFilteredCommandsWithExtras(slashFilter!, skillCommands) if (e.key === 'ArrowDown') { e.preventDefault(); setSlashIndex((i) => (i + 1) % filtered.length); return } if (e.key === 'ArrowUp') { e.preventDefault(); setSlashIndex((i) => (i - 1 + filtered.length) % filtered.length); return } if (e.key === 'Tab') { e.preventDefault(); if (filtered.length > 0) handleSlashSelect(filtered[slashIndex]); return } if (e.key === 'Escape') { e.preventDefault(); setSlashFilter(null); return } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } if (e.key === 'Escape' && !showSlashMenu) { window.clui.hideWindow() } } const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value setInput(value) updateSlashFilter(value) } // ─── Paste image ─── const handlePaste = useCallback(async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items if (!items) return for (const item of Array.from(items)) { if (item.type.startsWith('image/')) { e.preventDefault() const blob = item.getAsFile() if (!blob) return const reader = new FileReader() reader.onload = async () => { const dataUrl = reader.result as string const attachment = await window.clui.pasteImage(dataUrl) if (attachment) addAttachments([attachment]) } reader.readAsDataURL(blob) return } } }, [addAttachments]) // ─── Voice ─── const cancelledRef = useRef(false) const stopRecording = useCallback(() => { cancelledRef.current = false if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop() }, []) const cancelRecording = useCallback(() => { cancelledRef.current = true if (mediaRecorderRef.current?.state === 'recording') mediaRecorderRef.current.stop() }, []) const startRecording = useCallback(async () => { setVoiceError(null) chunksRef.current = [] let stream: MediaStream try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }) } catch { setVoiceError('Microphone permission denied.') return } const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' const recorder = new MediaRecorder(stream, { mimeType }) recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data) } recorder.onstop = async () => { stream.getTracks().forEach((t) => t.stop()) if (cancelledRef.current) { cancelledRef.current = false; setVoiceState('idle'); return } if (chunksRef.current.length === 0) { setVoiceState('idle'); return } setVoiceState('transcribing') try { const blob = new Blob(chunksRef.current, { type: mimeType }) const wavBase64 = await blobToWavBase64(blob) const result = await window.clui.transcribeAudio(wavBase64) if (result.error) setVoiceError(result.error) else if (result.transcript) setInput((prev) => (prev ? `${prev} ${result.transcript}` : result.transcript!)) } catch (err: any) { setVoiceError(`Voice failed: ${err.message}`) } finally { setVoiceState('idle') } } recorder.onerror = () => { stream.getTracks().forEach((t) => t.stop()); setVoiceError('Recording failed.'); setVoiceState('idle') } mediaRecorderRef.current = recorder setVoiceState('recording') recorder.start() }, []) const handleVoiceToggle = useCallback(() => { if (voiceState === 'recording') stopRecording() else if (voiceState === 'idle') void startRecording() }, [voiceState, startRecording, stopRecording]) const hasAttachments = attachments.length > 0 return (
{/* Slash command menu */} {showSlashMenu && ( )} {/* Attachment chips — renders inside the pill, above textarea */} {hasAttachments && (
)} {/* Single-line: inline controls. Multi-line: controls in bottom row */}
{isMultiLine ? (