diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ba69db5..813b2fd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,9 @@ "Bash(grep:*)", "Bash(sed:*)", "Bash(rg:*)", - "Bash(npx tsc:*)" + "Bash(npx tsc:*)", + "Bash(ls:*)", + "Bash(npx vsce package)" ], "deny": [] }, diff --git a/.gitignore b/.gitignore index a20aa9e..ea60012 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ dist node_modules .vscode-test/ *.vsix -backup \ No newline at end of file +backup +backup-files +.claude/settings.local.json diff --git a/.vscode/settings.json b/.vscode/settings.json index afdab66..8baa588 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,8 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off" + "typescript.tsc.autoDetect": "off", + "claudeCodeChat.permissions.yoloMode": true, + "claudeCodeChat.notifications.windowsSound": true, + "claudeCodeChat.notifications.customSoundPath": "C:/Windows/Media/windsurfCascadeDone.wav" } diff --git a/.vscodeignore b/.vscodeignore index 295df75..35e8ec9 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -14,4 +14,6 @@ backup claude-code-chat-permissions-mcp/** node_modules mcp-permissions.js -build \ No newline at end of file +backup-files +build +**/test/** \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ef57185..31a3da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,80 @@ All notable changes to the "claude-code-chat" extension will be documented in th Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [2.0.7] - 2026-04-24 + +### πŸš€ Features Added +- **In-process installer**: Installing Claude Code no longer shells out to PowerShell, `curl | bash`, or `npm install -g`. The extension now fetches the platform-specific native binary directly (npm registry first, with Anthropic's CDN as a fallback), verifies the download with sha512/sha256, and writes it into the extension's own storage. Zero PATH, sudo, execution-policy, Node-version, or shell-quoting dependencies β€” if the extension installed, installing Claude works. +- **Progress updates during install**: The install modal now reports "Looking up…", "Downloading… (X%)", "Verifying…", and "Installing…" as it runs, with an automatic retry message if it falls back to the CDN source. +- **Cleaner install analytics**: `Install success` now includes `source` (npm/cdn) and `version`; `Install failed` now includes a typed `errorCode` (NETWORK / INTEGRITY / WRITE / AGGREGATE / UNSUPPORTED_PLATFORM) so failure buckets are meaningful instead of just "the shell command failed". + +### πŸ› Bug Fixes +- **Paths with spaces on Windows**: Fixed an edge case where the main Claude spawn could fail when the executable path contained spaces (e.g. `C:\Users\Some User\…`). Absolute paths now bypass `cmd.exe` wrapping entirely. + +### πŸ”§ Technical Improvements +- New `src/claudeDownloader.ts` module: self-contained, no new runtime dependencies. Includes a minimal in-tree tar parser so we can stream-extract the one binary we need from the npm tarball without bundling a tar library. +- Removed the old PowerShell / curl / npm install paths and the associated `_getKnownInstallLocation` / `_checkClaudeAvailable` helpers β€” the download flow now owns the install location end-to-end. + +## [2.0.6] - 2026-04-23 + +### πŸš€ Features Added +- **Smarter post-install setup**: Fresh installs now "just work" without a VS Code restart. After install, the extension checks whether `claude` resolved on your PATH and, if not, auto-configures `claudeCodeChat.executable.path` to the known install location. An existing custom executable path is respected. +- **WSL: Node.js path is now optional**: Recent Claude Code ships as a native binary and doesn't need Node. Leave the **Node.js Path** field blank unless you installed Claude via npm. The WSL settings panel was also reordered so **Claude Path** comes first. + +### πŸ› Bug Fixes +- **Rock-solid terminals across shells**: Login, Model, /usage, and slash-command terminals now launch Claude directly instead of sending text through the shell. Fixes a class of quoting issues on Windows PowerShell and keeps behavior identical across PowerShell, cmd, bash, and zsh. + +### πŸ”§ Technical Improvements +- Terminal sites now use `createTerminal`'s `shellPath`/`shellArgs` β€” no shell quoting, consistent env inheritance, identical behavior across OSes. + +## [2.0.4] - 2026-04-21 + +### πŸš€ Features Added +- **Plan Mode (Improved)**: + - Plans now render as beautifully formatted markdown with headings, lists, and code blocks + - Suggested actions shown as clickable buttons below the plan (e.g. "run npm build") + - Permission prompt says "Approve the plan above?" with an Approve button instead of the generic tool approval +- **MCP, Skills & Plugins Marketplace**: + - Browse 30+ curated MCP servers (GitHub, Slack, Stripe, Notion, Supabase, etc.) + - Search across both add-mcp curated and official Anthropic registries with smart ranking + - Install MCP servers to project (`.mcp.json`) or global (`~/.claude.json`) + - Skills marketplace with one-click install via `npx skills add` + - Plugins marketplace to extend Claude Code + - OAuth authentication support β€” open terminal to log in to MCPs +- **150+ AI Models via OpenCredits**: + - Quick model switching: GPT, Gemini, MiniMax, Kimi, GLM, DeepSeek buttons above the text box + - Browse and select from 150+ models across providers + - Pay-as-you-go with OpenCredits β€” no subscription needed + - US & EU provider filtering option in settings + - Model selection persists correctly after checkout +- **Image Preview**: + - Paste or pick images with thumbnail preview before sending + - Remove attached images before sending + - Multiple image attachments per message + - Image paths in text auto-detected and sent as base64 +- **Support & Feedback**: + - "Support" button in status bar to send bug reports and feature requests + - Submissions sent directly to Discord + +### 🎨 UI Improvements +- Inline stop button replaces send button during processing +- Self-hosted Umami analytics with editor tracking (VS Code vs Cursor) +- BETA badge on model section with instant tooltip +- Cleaner model selector and Browse All Models alignment + +### πŸ› Bug Fixes & Reliability +- Fix model not being selected after OpenCredits checkout +- Fix provider choice modal appearing unexpectedly after settings changes +- Fix duplicate login error toast +- Fix WSL environment variable passthrough for OpenCredits +- Fix Windows URL opening with `start` command +- Fix `--mcp-config` error on fresh installs +- Await `setEnvsDisabled` so settings reflect changes immediately +- Skip npx install prompt with `-y` flag for skills +- Better install error messages (Node.js 18+ requirement) +- Add node and mocha types to tsconfig for clean editor diagnostics +- Remove debug `console.log`s, add `console.error` to empty catch blocks + ## [1.1.0] - 2025-12-06 ### πŸš€ Features Added diff --git a/README.md b/README.md index a78c3a4..22e2a75 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ [![Claude Code](https://img.shields.io/badge/Powered%20by-Claude%20Code-orange?style=for-the-badge)](https://claude.ai/code) [![TypeScript](https://img.shields.io/badge/Built%20with-TypeScript-3178C6?style=for-the-badge&logo=typescript)](https://www.typescriptlang.org/) +πŸ”” Notify Sound β€” Optional notification sound for incoming Claude responses (configurable in extension settings). + > **No more terminal commands. Chat with Claude Code through a beautiful, intuitive interface right inside VS Code.** Ditch the command line and experience Claude Code like never before. This extension brings a stunning chat interface directly into your editor, making AI assistance accessible, visual, and enjoyable. @@ -15,13 +17,12 @@ Ditch the command line and experience Claude Code like never before. This extens ## ✨ **Why Choose Claude Code Chat?** πŸ–₯️ **No Terminal Required** - Beautiful chat interface replaces command-line interactions -βͺ **Restore Checkpoints** - Undo changes and restore code to any previous state -πŸ”Œ **MCP Server Support** - Complete Model Context Protocol server management +βͺ **Restore Checkpoints** - Undo changes and restore code to any previous state +πŸ”Œ **MCP, Skills & Plugins** - Browse, search, and install from curated marketplaces πŸ’Ύ **Conversation History** - Automatic conversation history and session management -🎨 **VS Code Native** - Claude Code integrated directly into VS Code with native theming and sidebar support -🧠 **Plan and Thinking modes** - Plan First and configurable Thinking modes for better results -⚑ **Smart File/Image Context and Custom Commands** - Reference any file, paste images or screenshots and create custom commands -πŸ€– **Model Selection** - Choose between Opus, Sonnet, or Default based on your needs +🎨 **VS Code & Cursor** - Works in VS Code, Cursor, and other compatible editors +🧠 **Plan and Ultrathink modes** - Plan First and Ultrathink modes +⚑ **Smart Context** - Reference files, paste images, and create custom commands 🐧 **Windows/WSL Support** - Full native Windows and WSL support ![Claude Code Chat 1 0 0](https://github.com/user-attachments/assets/5954a74c-eff7-4205-8482-6a1c9de6e102) @@ -47,35 +48,36 @@ Ditch the command line and experience Claude Code like never before. This extens - Real-time cost and token tracking - Session statistics and performance metrics -### πŸ“ **Inline Diff Viewer** ⭐ **NEW IN V1.1** +### πŸ“ **Inline Diff Viewer** - **Full Diff Display** - See complete file changes directly in Edit, MultiEdit, and Write messages - **Open in VS Code Diff** - One-click button to open VS Code's native side-by-side diff editor - **Smart Truncation** - Long diffs are truncated with an expand button for better readability - **Syntax Highlighting** - Proper code highlighting in diff views - **Visual Change Indicators** - Clear green/red highlighting for additions and deletions -### πŸ”Œ **MCP Server Management** ⭐ **NEW IN V1.0** -- **Popular Servers Gallery** - One-click installation of common MCP servers -- **Custom Server Creation** - Build and configure your own MCP servers -- **Server Management** - Edit, delete, enable/disable servers through UI -- **Automatic Integration** - Seamless permissions and tool integration -- **Cross-platform Support** - Full WSL compatibility with path conversion - -### πŸ”’ **Advanced Permissions System** ⭐ **NEW IN V1.0** +### πŸ”Œ **MCP, Skills & Plugins Marketplace** ⭐ **NEW IN V2.0** +- **MCP Servers** - Browse 30+ featured servers (GitHub, Slack, Stripe, Notion, etc.) with dual registry search +- **Skills** - Browse and install curated skills from skills.sh with project or global scope +- **Plugins** - Browse and install plugins to extend Claude Code +- **Smart Search** - Search across add-mcp curated and official Anthropic registries with relevance ranking +- **Project or Global Install** - Install MCP servers to `.mcp.json` or `~/.claude.json`, skills to `.claude/skills/` +- **One-Click Install** - Pre-filled configuration with env vars, headers, and OAuth authentication + +### πŸ–ΌοΈ **Image Preview & Attachments** ⭐ **NEW IN V2.0** +- **Paste Images** - Paste images with thumbnail preview before sending +- **File Picker** - Select images through VS Code's native file picker +- **Preview Strip** - See attached images above the text box with remove buttons +- **Inline Detection** - Image paths in messages are automatically detected and sent as base64 +- **Multiple Images** - Attach multiple images to a single message + +### πŸ”’ **Advanced Permissions System** - **Interactive Permission Dialogs** - Detailed tool information with command previews - **Always Allow Functionality** - Smart command pattern matching for common tools (npm, git, docker) - **YOLO Mode** - Skip all permission checks for power users - **Workspace Permissions** - Granular control over what tools can execute - **Real-time Permission Management** - Add/remove permissions through intuitive UI -### πŸ–ΌοΈ **Image & Clipboard Support** ⭐ **NEW IN V1.0** -- **Drag & Drop Images** - Simply drag images directly into the chat -- **Clipboard Paste** - Press Ctrl+V to paste screenshots and copied images -- **Multiple Image Selection** - Choose multiple images through VS Code's file picker -- **Organized Storage** - Automatic organization in `.claude/claude-code-chat-images/` -- **Format Support** - PNG, JPG, JPEG, GIF, SVG, WebP, BMP formats - -### πŸ“± **Sidebar Integration** ⭐ **NEW IN V1.0** +### πŸ“± **Sidebar Integration** - **Native VS Code Sidebar** - Full chat functionality in the sidebar panel - **Smart Panel Management** - Automatic switching between main and sidebar views - **Persistent Sessions** - State maintained across panel switches @@ -83,30 +85,30 @@ Ditch the command line and experience Claude Code like never before. This extens ### πŸ“ **Smart File Integration** - Type `@` to instantly search and reference workspace files -- Image attachments via file browser and copy-paste screeshots +- Image attachments via file browser and copy-paste screenshots - Lightning-fast file search across your entire project - Seamless context preservation for multi-file discussions ### πŸ› οΈ **Tool Management** - Visual dashboard showing all available Claude Code tools - Real-time tool execution with formatted results -- Process control - start, stop, and monitor operations +- Inline stop button replaces send during processing - Smart permission system for secure tool execution ### 🎨 **VS Code Integration** - Native theming that matches your editor -- Status bar integration with connection status +- Status bar with support button - Activity bar panel for quick access - Responsive design for any screen size ### πŸ€– **Model Selection** +- **Quick Buttons** - GPT, Gemini, MiniMax, Kimi, GLM, DeepSeek above the text box - **Opus** - Most capable model for complex tasks requiring deep reasoning - **Sonnet** - Balanced model offering great performance for most use cases - **Default** - Uses your configured model setting -- Model preference persists across sessions and is saved automatically -- Easy switching via dropdown selector in the chat interface -- Visual confirmation when switching between models -- One-click model configuration through integrated terminal +- **150+ OpenCredits Models** - Browse and switch to any available model +- Model preference persists across sessions +- Provider choice (OpenCredits vs Anthropic) for Claude models ### ⚑ **Slash Commands Integration** - **Slash Commands Modal** - Type "/" to access all Claude Code commands instantly @@ -122,14 +124,20 @@ Ditch the command line and experience Claude Code like never before. This extens - **Intelligent Prompting** - Different prompts based on selected thinking intensity - **Token Awareness** - Higher thinking levels consume more tokens but provide deeper reasoning +### πŸ’¬ **Support & Feedback** ⭐ **NEW IN V2.0** +- **In-App Support** - Click "Support" in the status bar to send feedback +- **Bug Reports & Feature Requests** - Submit directly from the extension +- **Optional Email** - Include your email for follow-up + --- ## πŸš€ **Getting Started** ### Prerequisites -- **VS Code 1.80+** - Latest version recommended +- **VS Code 1.80+** or **Cursor** - Latest version recommended - **Claude Code CLI** - [Install from Anthropic](https://claude.ai/code) - **Active Claude API or subscription** - API or Pro/Max plan +- **Node.js 18+** - Required for installation ### Installation @@ -251,25 +259,26 @@ Example configuration in `settings.json`: - Type `@` followed by your search term to quickly reference files - Use `@src/` to narrow down to specific directories - Reference multiple files in one message for cross-file analysis -- **NEW**: Copy-paste images directly into chat for visual context -- **NEW**: Paste screenshots with Ctrl+V for instant visual communication +- Paste images directly with preview thumbnails before sending +- Paste screenshots with Ctrl+V for instant visual communication ### ⚑ **Productivity Boosters** - **Creates checkpoints automatically** before changes for safe experimentation - **Restore instantly** if changes don't work out as expected -- **NEW**: Permission system prevents accidental tool execution -- **NEW**: YOLO mode for power users who want speed over safety -- Use the stop button to cancel long-running operations +- Permission system prevents accidental tool execution +- YOLO mode for power users who want speed over safety +- Inline stop button to cancel long-running operations - Copy message contents to reuse Claude's responses - Open history panel to reference previous conversations -- **NEW**: Sidebar integration for multi-panel workflow +- Sidebar integration for multi-panel workflow +- **Plan mode** and **Ultrathink** toggles above the text box ### 🎨 **Interface Customization** - The UI automatically adapts to your VS Code theme - Messages are color-coded: Green for you, Blue for Claude - Hover over messages to reveal the copy button -- **NEW**: Enhanced code block rendering with syntax highlighting -- **NEW**: Copy-to-clipboard functionality for code blocks +- Enhanced code block rendering with syntax highlighting +- Copy-to-clipboard functionality for code blocks --- @@ -341,6 +350,7 @@ See the [LICENSE](LICENSE) file for details. Need help? We've got you covered: +- πŸ’¬ **In-App** - Click "Support" in the status bar to send feedback directly - πŸ› **Issues**: [GitHub Issues](https://github.com/andrepimenta/claude-code-chat/issues) --- diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..adac868 --- /dev/null +++ b/backup.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Backup script for src folder + +# Get the directory where the script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Create backup directory if it doesn't exist +BACKUP_DIR="$SCRIPT_DIR/backup-files" +mkdir -p "$BACKUP_DIR" + +# Generate timestamp +TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S") + +# Create backup filename +BACKUP_NAME="src-backup-$TIMESTAMP" + +# Copy src folder to backup +cp -r "$SCRIPT_DIR/src" "$BACKUP_DIR/$BACKUP_NAME" + +echo "Backup created: $BACKUP_DIR/$BACKUP_NAME" diff --git a/build/open-vsix/build.sh b/build/open-vsix/build.sh index a673553..9996fda 100755 --- a/build/open-vsix/build.sh +++ b/build/open-vsix/build.sh @@ -6,7 +6,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -VERSION="1.1.0" +VERSION="2.0.8" OUTPUT_NAME="vsix-claude-code-chat-${VERSION}.vsix" echo "Building Open VSIX version ${VERSION}..." diff --git a/package-lock.json b/package-lock.json index 82a87fc..cb8b8d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-chat", - "version": "1.0.0", + "version": "2.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-chat", - "version": "1.0.0", + "version": "2.0.8", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index 05b3db5..f42c9cc 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,8 @@ "name": "claude-code-chat", "displayName": "Chat for Claude Code", "description": "Beautiful Claude Code Chat Interface for VS Code", - "version": "1.1.0", - "publisher": "AndrePimenta", + "version": "2.0.8", + "publisher": "OnderAkbulut", "author": "Andre Pimenta", "repository": { "type": "git", @@ -57,6 +57,11 @@ "title": "Open Claude Code Chat", "category": "Claude Code Chat", "icon": "icon-bubble.png" + }, + { + "command": "claude-code-chat.addToChat", + "title": "Add to Claude Chat", + "category": "Claude Code Chat" } ], "keybindings": [ @@ -96,6 +101,11 @@ { "command": "claude-code-chat.openChat", "group": "claude@1" + }, + { + "command": "claude-code-chat.addToChat", + "group": "claude@2", + "when": "!explorerResourceIsRoot" } ], "scm/title": [ @@ -162,8 +172,8 @@ }, "claudeCodeChat.wsl.nodePath": { "type": "string", - "default": "/usr/bin/node", - "description": "Path to Node.js in the WSL distribution" + "default": "", + "description": "Optional path to Node.js in the WSL distribution. Only needed if Claude was installed via npm. Recent Claude installs ship as a native executable and don't require Node." }, "claudeCodeChat.wsl.claudePath": { "type": "string", @@ -185,6 +195,26 @@ "type": "boolean", "default": false, "description": "Enable Yolo Mode to skip all permission checks. Use with caution as Claude can execute any command without asking." + }, + "claudeCodeChat.executable.path": { + "type": "string", + "default": "", + "description": "Custom path to the Claude Code executable. Leave empty to use the default 'claude' command." + }, + "claudeCodeChat.environment.variables": { + "type": "object", + "default": {}, + "description": "Custom environment variables to pass to Claude Code. Example: {\"ANTHROPIC_API_KEY\": \"sk-...\"}" + }, + "claudeCodeChat.environment.disabled": { + "type": "boolean", + "default": false, + "description": "When enabled, custom environment variables are not passed to Claude Code." + }, + "claudeCodeChat.router.enabled": { + "type": "boolean", + "default": false, + "description": "Enable the local router to convert OpenAI format to Anthropic format. Required for providers that use OpenAI-compatible APIs." } } } @@ -195,7 +225,9 @@ "watch": "tsc -watch -p ./", "pretest": "npm run compile && npm run lint", "lint": "eslint src", - "test": "vscode-test" + "test": "vscode-test", + "test:downloader": "npm run compile && mocha --ui tdd \"out/test/downloader*.test.js\" --reporter spec --timeout 360000", + "test:downloader:unit": "npm run compile && mocha --ui tdd out/test/downloader.test.js --reporter spec" }, "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/src/claudeDownloader.ts b/src/claudeDownloader.ts new file mode 100644 index 0000000..6f7d15a --- /dev/null +++ b/src/claudeDownloader.ts @@ -0,0 +1,662 @@ +// Self-contained downloader for the Claude Code native binary. +// Tries the npm registry tarball first (smaller over the wire thanks to gzip), +// falls back to Anthropic's CDN (downloads.claude.ai) on any npm failure. +// +// Replaces the previous shell-based install flows (curl|bash, irm|iex, npm -g) +// so users never see execution-policy, EACCES, missing-bash, or Node-version +// failure modes. Everything runs in-process using Node built-ins only. + +import * as https from 'https'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as zlib from 'zlib'; +import * as os from 'os'; +import * as cp from 'child_process'; +import { URL } from 'url'; + +const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; +const DEFAULT_CDN_BASE = 'https://downloads.claude.ai/claude-code-releases'; +const NPM_PACKAGE_PREFIX = '@anthropic-ai/claude-code-'; +const META_TIMEOUT_MS = 30_000; +const PROGRESS_THROTTLE_MS = 250; + +export type DownloaderErrorCode = + | 'UNSUPPORTED_PLATFORM' + | 'NETWORK' + | 'INTEGRITY' + | 'WRITE' + | 'CANCELLED' + | 'AGGREGATE'; + +export interface PlatformKey { + key: string; // 'darwin-arm64' | 'linux-x64-musl' | 'win32-x64' | ... + binaryName: string; // 'claude' | 'claude.exe' + tarEntry: string; // 'package/claude' | 'package/claude.exe' +} + +export interface DownloadProgress { + phase: 'resolving' | 'downloading' | 'verifying' | 'installing' | 'fallback'; + source?: 'npm' | 'cdn'; + loaded?: number; + total?: number; + message?: string; +} + +export interface DownloadOptions { + destDir: string; + onProgress?: (p: DownloadProgress) => void; + signal?: AbortSignal; + /** @internal β€” override the npm registry base (for tests). */ + npmRegistry?: string; + /** @internal β€” override the Anthropic CDN base (for tests). */ + cdnBase?: string; +} + +export interface DownloadResult { + binaryPath: string; + version: string; + source: 'npm' | 'cdn'; + bytesDownloaded: number; +} + +export class DownloaderError extends Error { + public readonly code: DownloaderErrorCode; + public readonly details?: Record; + public readonly cause?: unknown; + + constructor(code: DownloaderErrorCode, message: string, details?: Record, cause?: unknown) { + super(message); + this.name = 'DownloaderError'; + this.code = code; + this.details = details; + this.cause = cause; + } +} + +// Extract the OS-level error code (EACCES/EBUSY/ENOSPC/ENOTFOUND/etc.) from an +// arbitrary error, falling back to a short constant. We never inline err.message +// into DownloaderError.message because Node's fs errors interpolate the offending +// path β€” e.g. "EACCES: permission denied, open '/Users//Library/...'" β€” +// which would exfiltrate the user's home directory in analytics. +function _errCode(err: unknown, fallback: string): string { + if (err && typeof err === 'object') { + const c = (err as { code?: unknown }).code; + if (typeof c === 'string' && c) {return c;} + } + return fallback; +} + +// Invoke the caller's onProgress callback without letting a user throw crash +// the download stream. Throws inside a stream 'data' handler otherwise surface +// as uncaughtException on the extension host. +function _safeProgress(cb: ((p: DownloadProgress) => void) | undefined, p: DownloadProgress): void { + if (!cb) {return;} + try { + cb(p); + } catch { + // swallow β€” progress reporting is best-effort + } +} + +// ------------- Platform detection ------------- + +export function detectPlatform(): PlatformKey | null { + const platform = process.platform; + let arch = os.arch(); + + if (platform === 'darwin') { + // Rosetta 2: x64 Node on Apple Silicon should use the arm64 binary β€” + // the x64 build needs AVX which Rosetta doesn't emulate. + if (arch === 'x64') { + try { + const r = cp.spawnSync('sysctl', ['-n', 'sysctl.proc_translated'], { encoding: 'utf8' }); + if (r.stdout && r.stdout.trim() === '1') { + arch = 'arm64'; + } + } catch { + // sysctl missing β€” treat as non-Rosetta + } + } + if (arch !== 'x64' && arch !== 'arm64') {return null;} + return { key: `darwin-${arch}`, binaryName: 'claude', tarEntry: 'package/claude' }; + } + + if (platform === 'linux') { + if (arch !== 'x64' && arch !== 'arm64') {return null;} + const musl = _detectMusl(); + const key = `linux-${arch}${musl ? '-musl' : ''}`; + return { key, binaryName: 'claude', tarEntry: 'package/claude' }; + } + + if (platform === 'win32') { + if (arch !== 'x64' && arch !== 'arm64') {return null;} + return { key: `win32-${arch}`, binaryName: 'claude.exe', tarEntry: 'package/claude.exe' }; + } + + return null; +} + +function _detectMusl(): boolean { + try { + const report = (process as unknown as { report?: { getReport?: () => { header?: { glibcVersionRuntime?: string } } } }).report; + if (report && typeof report.getReport === 'function') { + const r = report.getReport(); + return !r.header?.glibcVersionRuntime; + } + } catch { + // fall through to file-presence check + } + try { + if (fs.existsSync('/lib/libc.musl-x86_64.so.1') || fs.existsSync('/lib/libc.musl-aarch64.so.1')) { + return true; + } + } catch { + // fall through + } + return false; +} + +// ------------- HTTP helpers ------------- + +function _checkAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new DownloaderError('CANCELLED', 'Cancelled'); + } +} + +function _httpGet(urlStr: string, signal?: AbortSignal, redirectsRemaining = 5): Promise { + return new Promise((resolve, reject) => { + _checkAborted(signal); + const parsed = new URL(urlStr); + // Pick http or https by scheme so tests can target a local http server. + const getter = parsed.protocol === 'http:' ? http.get : https.get; + const req = getter(urlStr, { headers: { 'user-agent': 'claude-code-chat-vscode' } }, (res) => { + const status = res.statusCode ?? 0; + if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { + res.resume(); + if (redirectsRemaining <= 0) { + reject(new DownloaderError('NETWORK', 'Too many redirects', { host: parsed.host })); + return; + } + const next = new URL(res.headers.location, urlStr).toString(); + _httpGet(next, signal, redirectsRemaining - 1).then(resolve, reject); + return; + } + if (status < 200 || status >= 300) { + res.resume(); + reject(new DownloaderError('NETWORK', `HTTP ${status} from ${parsed.host}`, { status, host: parsed.host })); + return; + } + resolve(res); + }); + req.on('error', (err) => { + const code = _errCode(err, 'NETERR'); + reject(new DownloaderError('NETWORK', `Network error (${code}) from ${parsed.host}`, { host: parsed.host, code }, err)); + }); + const onAbort = () => { + req.destroy(); + reject(new DownloaderError('CANCELLED', 'Cancelled')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +async function _fetchBuffer(urlStr: string, signal?: AbortSignal): Promise { + const res = await _httpGet(urlStr, signal); + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + res.destroy(); + reject(new DownloaderError('NETWORK', 'Metadata request timed out')); + }, META_TIMEOUT_MS); + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { clearTimeout(timer); resolve(Buffer.concat(chunks)); }); + res.on('error', (err) => { clearTimeout(timer); reject(new DownloaderError('NETWORK', `Response error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err)); }); + }); +} + +async function _fetchText(url: string, signal?: AbortSignal): Promise { + return (await _fetchBuffer(url, signal)).toString('utf8'); +} + +async function _fetchJson(url: string, signal?: AbortSignal): Promise { + const body = await _fetchText(url, signal); + try { + return JSON.parse(body) as T; + } catch (err) { + throw new DownloaderError('NETWORK', 'Invalid JSON in response', undefined, err); + } +} + +// ------------- Tar extraction (minimal, ustar-only) ------------- +// +// Extracts a single file by name from a gunzipped tar stream. Npm-published +// tarballs use plain ustar with short filenames, so we don't handle GNU long- +// link extensions, PAX headers, or sparse files. If the target entry isn't +// found by end of stream, we throw INTEGRITY β€” the tarball shape is wrong. + +function _parseOctal(buf: Buffer): number { + // Octal ASCII, null/space terminated. + let end = 0; + while (end < buf.length && buf[end] !== 0 && buf[end] !== 0x20) {end++;} + const s = buf.subarray(0, end).toString('ascii').trim(); + return s.length ? parseInt(s, 8) : 0; +} + +function _readTarHeader(block: Buffer): { name: string; size: number; isRegularFile: boolean } { + const name = block.subarray(0, 100).toString('utf8').replace(/\0+$/, ''); + const prefix = block.subarray(345, 500).toString('utf8').replace(/\0+$/, ''); + const rawSize = _parseOctal(block.subarray(124, 136)); + // Defensive: guard against NaN / negative / non-finite sizes from malformed + // tarballs before they poison our skip-byte arithmetic downstream. + const size = Number.isFinite(rawSize) && rawSize >= 0 ? rawSize : -1; + const typeFlag = String.fromCharCode(block[156] || 0); + const isRegularFile = typeFlag === '0' || typeFlag === '\0'; + const fullName = prefix ? `${prefix}/${name}` : name; + return { name: fullName, size, isRegularFile }; +} + +interface TarExtractState { + found: boolean; + bytesWritten: number; + buffer: Buffer; + // When >0, we are in the middle of the target file's data, and this many + // bytes still need to be written to out. + remainingFileBytes: number; + // When >0, we are skipping past a non-target file's data+padding. + remainingSkipBytes: number; +} + +function _processTarChunk(state: TarExtractState, chunk: Buffer, entryName: string, out: fs.WriteStream): void { + state.buffer = state.buffer.length ? Buffer.concat([state.buffer, chunk]) : chunk; + + while (true) { + if (state.remainingFileBytes > 0) { + const take = Math.min(state.remainingFileBytes, state.buffer.length); + if (take === 0) {return;} + out.write(state.buffer.subarray(0, take)); + state.bytesWritten += take; + state.remainingFileBytes -= take; + state.buffer = state.buffer.subarray(take); + if (state.remainingFileBytes === 0) { + // After the file data, skip the 512-byte padding tail. + const padLen = (512 - (state.bytesWritten % 512)) % 512; + state.remainingSkipBytes = padLen; + } + continue; + } + + if (state.remainingSkipBytes > 0) { + const skip = Math.min(state.remainingSkipBytes, state.buffer.length); + if (skip === 0) {return;} + state.remainingSkipBytes -= skip; + state.buffer = state.buffer.subarray(skip); + continue; + } + + if (state.buffer.length < 512) {return;} + + const header = state.buffer.subarray(0, 512); + // End-of-archive is two consecutive zero-blocks. A single zero-block + // also terminates our scan safely. + if (header[0] === 0) {return;} + + const { name, size, isRegularFile } = _readTarHeader(header); + state.buffer = state.buffer.subarray(512); + + // Size < 0 means the header was malformed (NaN / negative octal). Bail so + // we don't poison the skip arithmetic β€” the outer INTEGRITY check will fire. + if (size < 0) {throw new DownloaderError('INTEGRITY', 'Malformed tar header (invalid size)');} + + if (name === entryName && isRegularFile) { + state.found = true; + state.remainingFileBytes = size; + state.bytesWritten = 0; + } else { + // Skip this file's data + padding. + const padded = Math.ceil(size / 512) * 512; + state.remainingSkipBytes = padded; + } + } +} + +// ------------- npm source ------------- + +interface NpmPackageMetadata { + 'dist-tags': { latest: string; [tag: string]: string }; + versions: Record; +} + +async function _downloadFromNpm(platform: PlatformKey, opts: DownloadOptions): Promise { + const onProgress = opts.onProgress; + const registry = opts.npmRegistry || DEFAULT_NPM_REGISTRY; + _safeProgress(onProgress, { phase: 'resolving', source: 'npm', message: 'Looking up latest version' }); + + const metaUrl = `${registry}/${NPM_PACKAGE_PREFIX}${platform.key}`; + const meta = await _fetchJson(metaUrl, opts.signal); + const version = meta['dist-tags']?.latest; + if (!version) {throw new DownloaderError('NETWORK', 'npm metadata missing dist-tags.latest');} + const versionMeta = meta.versions?.[version]; + if (!versionMeta?.dist?.tarball || !versionMeta.dist.integrity) { + throw new DownloaderError('NETWORK', 'npm metadata missing tarball or integrity'); + } + const tarballUrl = versionMeta.dist.tarball; + const integrity = versionMeta.dist.integrity; + const dashIdx = integrity.indexOf('-'); + if (dashIdx < 0) {throw new DownloaderError('INTEGRITY', 'Unrecognized integrity format');} + const algo = integrity.slice(0, dashIdx); + const expectedB64 = integrity.slice(dashIdx + 1); + if (!['sha256', 'sha384', 'sha512'].includes(algo)) { + throw new DownloaderError('INTEGRITY', `Unsupported hash algorithm: ${algo}`, { algo }); + } + + const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`); + const writeStream = fs.createWriteStream(tempPath); + const hash = crypto.createHash(algo); + const gunzip = zlib.createGunzip(); + + const state: TarExtractState = { + found: false, + bytesWritten: 0, + buffer: Buffer.alloc(0), + remainingFileBytes: 0, + remainingSkipBytes: 0, + }; + + _safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: 0 }); + + let res: http.IncomingMessage; + try { + res = await _httpGet(tarballUrl, opts.signal); + } catch (err) { + writeStream.destroy(); + await _safeUnlink(tempPath); + throw err; + } + + const total = Number(res.headers['content-length']) || undefined; + let bytesDownloaded = 0; + let lastProgressAt = 0; + + const extractPromise = new Promise((resolve, reject) => { + gunzip.on('data', (chunk: Buffer) => { + try { + _processTarChunk(state, chunk, platform.tarEntry, writeStream); + } catch (err) { + reject(err); + } + }); + gunzip.on('end', () => resolve()); + gunzip.on('error', (err) => reject(new DownloaderError('INTEGRITY', 'Tarball decompression failed', undefined, err))); + }); + + res.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length; + hash.update(chunk); + const now = Date.now(); + if (now - lastProgressAt > PROGRESS_THROTTLE_MS) { + lastProgressAt = now; + _safeProgress(onProgress, { phase: 'downloading', source: 'npm', loaded: bytesDownloaded, total }); + } + }); + + const responseDone = new Promise((resolve, reject) => { + res.on('end', () => resolve()); + res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err))); + }); + + const writeDone = new Promise((resolve, reject) => { + writeStream.on('close', () => resolve()); + writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err))); + }); + + const onAbort = () => { + res.destroy(); + gunzip.destroy(); + writeStream.destroy(); + }; + opts.signal?.addEventListener('abort', onAbort, { once: true }); + + res.pipe(gunzip); + + try { + await Promise.all([responseDone, extractPromise]); + writeStream.end(); + await writeDone; + } catch (err) { + // Tear down both ends explicitly β€” leaving res piping after an extract + // failure would leak bandwidth and memory. + res.destroy(); + gunzip.destroy(); + writeStream.destroy(); + await _safeUnlink(tempPath); + if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');} + throw err; + } + + _safeProgress(onProgress, { phase: 'verifying', source: 'npm', loaded: bytesDownloaded, total }); + + if (!state.found) { + await _safeUnlink(tempPath); + throw new DownloaderError('INTEGRITY', `Tarball missing expected entry ${platform.tarEntry}`, { platformKey: platform.key }); + } + + const computed = hash.digest('base64'); + if (computed !== expectedB64) { + await _safeUnlink(tempPath); + throw new DownloaderError('INTEGRITY', 'npm tarball hash mismatch', { algo }); + } + + _safeProgress(onProgress, { phase: 'installing', source: 'npm' }); + const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName)); + return { binaryPath: finalPath, version, source: 'npm', bytesDownloaded }; +} + +// ------------- CDN source ------------- + +interface CdnManifest { + platforms: Record; +} + +async function _downloadFromCdn(platform: PlatformKey, opts: DownloadOptions): Promise { + const onProgress = opts.onProgress; + const base = opts.cdnBase || DEFAULT_CDN_BASE; + _safeProgress(onProgress, { phase: 'resolving', source: 'cdn', message: 'Looking up latest version' }); + + const versionRaw = (await _fetchText(`${base}/latest`, opts.signal)).trim(); + if (!/^\d+\.\d+\.\d+(-[\w.-]+)?$/.test(versionRaw)) { + throw new DownloaderError('NETWORK', 'CDN returned invalid version string'); + } + const version = versionRaw; + + const manifest = await _fetchJson(`${base}/${version}/manifest.json`, opts.signal); + const expectedHex = manifest.platforms?.[platform.key]?.checksum; + if (!expectedHex || !/^[a-f0-9]{64}$/i.test(expectedHex)) { + throw new DownloaderError('INTEGRITY', `CDN manifest missing checksum for ${platform.key}`, { platformKey: platform.key }); + } + + const binName = process.platform === 'win32' ? 'claude.exe' : 'claude'; + const binUrl = `${base}/${version}/${platform.key}/${binName}`; + const tempPath = path.join(opts.destDir, `.claude.download.${process.pid}.${Date.now()}`); + const writeStream = fs.createWriteStream(tempPath); + const hash = crypto.createHash('sha256'); + + _safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: 0 }); + + let res: http.IncomingMessage; + try { + res = await _httpGet(binUrl, opts.signal); + } catch (err) { + writeStream.destroy(); + await _safeUnlink(tempPath); + throw err; + } + + const total = Number(res.headers['content-length']) || undefined; + let bytesDownloaded = 0; + let lastProgressAt = 0; + + const onAbort = () => { + res.destroy(); + writeStream.destroy(); + }; + opts.signal?.addEventListener('abort', onAbort, { once: true }); + + const responseDone = new Promise((resolve, reject) => { + res.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length; + hash.update(chunk); + const now = Date.now(); + if (now - lastProgressAt > PROGRESS_THROTTLE_MS) { + lastProgressAt = now; + _safeProgress(onProgress, { phase: 'downloading', source: 'cdn', loaded: bytesDownloaded, total }); + } + }); + res.on('end', () => resolve()); + res.on('error', (err) => reject(new DownloaderError('NETWORK', `Response stream error (${_errCode(err, 'NETERR')})`, { code: _errCode(err, 'NETERR') }, err))); + }); + + // Wait for 'close' (fd released), not just 'finish' (data flushed). Matters on + // Windows β€” rename() fails with EBUSY if the underlying handle is still open. + const writeDone = new Promise((resolve, reject) => { + writeStream.on('close', () => resolve()); + writeStream.on('error', (err) => reject(new DownloaderError('WRITE', `Write failed (${_errCode(err, 'WRITEERR')})`, { code: _errCode(err, 'WRITEERR') }, err))); + }); + + res.pipe(writeStream); + + try { + await responseDone; + writeStream.end(); + await writeDone; + } catch (err) { + res.destroy(); + writeStream.destroy(); + await _safeUnlink(tempPath); + if (opts.signal?.aborted) {throw new DownloaderError('CANCELLED', 'Cancelled');} + throw err; + } + + _safeProgress(onProgress, { phase: 'verifying', source: 'cdn', loaded: bytesDownloaded, total }); + + const computedHex = hash.digest('hex'); + if (computedHex.toLowerCase() !== expectedHex.toLowerCase()) { + await _safeUnlink(tempPath); + throw new DownloaderError('INTEGRITY', 'CDN binary hash mismatch'); + } + + _safeProgress(onProgress, { phase: 'installing', source: 'cdn' }); + const finalPath = await _finalize(tempPath, path.join(opts.destDir, platform.binaryName)); + return { binaryPath: finalPath, version, source: 'cdn', bytesDownloaded }; +} + +// ------------- Finalize (chmod + atomic rename) ------------- + +async function _finalize(tempPath: string, finalPath: string): Promise { + if (process.platform !== 'win32') { + try { + fs.chmodSync(tempPath, 0o755); + } catch (err) { + await _safeUnlink(tempPath); + const code = _errCode(err, 'CHMODERR'); + throw new DownloaderError('WRITE', `chmod failed (${code})`, { code }, err); + } + } + + try { + fs.renameSync(tempPath, finalPath); + } catch (err) { + const code = _errCode(err, 'RENAMEERR'); + // EXDEV means temp and final are on different filesystems (shouldn't happen, + // but defensive). + if (code === 'EXDEV') { + try { + fs.copyFileSync(tempPath, finalPath); + await _safeUnlink(tempPath); + return finalPath; + } catch (copyErr) { + await _safeUnlink(tempPath); + const ccode = _errCode(copyErr, 'COPYERR'); + throw new DownloaderError('WRITE', `Copy to final path failed (${ccode})`, { code: ccode }, copyErr); + } + } + await _safeUnlink(tempPath); + // EBUSY/EPERM on Windows means the target is currently running β€” we can't replace it. + if (code === 'EBUSY' || code === 'EPERM') { + throw new DownloaderError('WRITE', 'Target binary is in use. Close any running claude sessions and try again.', { code }, err); + } + throw new DownloaderError('WRITE', `Rename failed (${code})`, { code }, err); + } + return finalPath; +} + +async function _safeUnlink(p: string): Promise { + try { + await fs.promises.unlink(p); + } catch { + // ignore β€” cleanup best-effort + } +} + +// ------------- Public orchestrator ------------- + +export async function downloadClaude(opts: DownloadOptions): Promise { + const platform = detectPlatform(); + if (!platform) { + throw new DownloaderError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${process.platform}/${os.arch()}`, { + platform: process.platform, + arch: os.arch(), + }); + } + + try { + fs.mkdirSync(opts.destDir, { recursive: true }); + } catch (err) { + const code = _errCode(err, 'MKDIRERR'); + // Never include the path β€” destDir is under the user's home directory and + // would leak the username if posted to analytics. + throw new DownloaderError('WRITE', `Could not create download directory (${code})`, { code }, err); + } + + let npmErr: unknown; + try { + return await _downloadFromNpm(platform, opts); + } catch (err) { + if (err instanceof DownloaderError && err.code === 'CANCELLED') {throw err;} + npmErr = err; + _safeProgress(opts.onProgress, { phase: 'fallback', source: 'cdn', message: 'npm source failed β€” retrying via CDN' }); + } + + try { + return await _downloadFromCdn(platform, opts); + } catch (cdnErr) { + if (cdnErr instanceof DownloaderError && cdnErr.code === 'CANCELLED') {throw cdnErr;} + const npmCode = npmErr instanceof DownloaderError ? npmErr.code : 'NETWORK'; + const cdnCode = cdnErr instanceof DownloaderError ? cdnErr.code : 'NETWORK'; + throw new DownloaderError( + 'AGGREGATE', + `Both sources failed (npm: ${npmCode}, cdn: ${cdnCode}).`, + { npmCode, cdnCode }, + [npmErr, cdnErr], + ); + } +} + +// ------------- Internal exports for tests ------------- +// These are NOT part of the public API β€” consumers should use downloadClaude +// and detectPlatform. They're exported here so the test suite can unit-test the +// tar parser, octal parsing, and error-code helpers without network I/O. + +/** @internal */ +export const __test__ = { + parseOctal: _parseOctal, + readTarHeader: _readTarHeader, + processTarChunk: _processTarChunk, + errCode: _errCode, + safeProgress: _safeProgress, +}; + +/** @internal */ +export type __TarExtractState__ = TarExtractState; diff --git a/src/extension.ts b/src/extension.ts index 6d62328..290d8db 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,17 @@ import * as vscode from 'vscode'; import * as cp from 'child_process'; import * as util from 'util'; import * as path from 'path'; +import * as os from 'os'; import getHtml from './ui'; +import { startRouter, stopRouter, setModelConfig, setBaseUrl } from './router'; +import { fetchAndResolveModels } from './model-updater'; +import recommendedModels from './recommended-models.json'; +import { downloadClaude, detectPlatform, DownloaderError } from './claudeDownloader'; + +// OpenCredits environment configuration +let OPENCREDITS_API_URL = 'https://ccc.api.opencredits.ai'; +let OPENCREDITS_WEB_URL = 'https://ccc.opencredits.ai'; +let OPENCREDITS_PUBLISHABLE_KEY = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f'; const exec = util.promisify(cp.exec); @@ -18,11 +28,15 @@ class DiffContentProvider implements vscode.TextDocumentContentProvider { } export function activate(context: vscode.ExtensionContext) { - console.log('Claude Code Chat extension is being activated!'); + + if (context.extensionMode === vscode.ExtensionMode.Development) { + OPENCREDITS_API_URL = 'http://localhost:8787'; + OPENCREDITS_WEB_URL = 'http://localhost:3000'; + OPENCREDITS_PUBLISHABLE_KEY = 'oc_pk_c78315e9ff3a425ebca398bb69282429'; + } const provider = new ClaudeChatProvider(context.extensionUri, context); const disposable = vscode.commands.registerCommand('claude-code-chat.openChat', (column?: vscode.ViewColumn) => { - console.log('Claude Code Chat command executed!'); provider.show(column); }); @@ -30,8 +44,14 @@ export function activate(context: vscode.ExtensionContext) { provider.loadConversation(filename); }); + const addToChatDisposable = vscode.commands.registerCommand('claude-code-chat.addToChat', (uri: vscode.Uri, selectedUris: vscode.Uri[]) => { + // If multiple items selected in explorer, use selectedUris, otherwise use single uri + const uris = selectedUris && selectedUris.length > 0 ? selectedUris : [uri]; + provider.addFilesToChat(uris); + }); + // Register webview view provider for sidebar chat (using shared provider instance) - const webviewProvider = new ClaudeChatWebviewProvider(context.extensionUri, context, provider); + const webviewProvider = new ClaudeChatWebviewProvider(context.extensionUri, provider); vscode.window.registerWebviewViewProvider('claude-code-chat.chat', webviewProvider); // Register custom content provider for read-only diff views @@ -41,7 +61,6 @@ export function activate(context: vscode.ExtensionContext) { // Listen for configuration changes const configChangeDisposable = vscode.workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('claudeCodeChat.wsl')) { - console.log('WSL configuration changed, starting new session'); provider.newSessionOnConfigChange(); } }); @@ -53,11 +72,45 @@ export function activate(context: vscode.ExtensionContext) { statusBarItem.command = 'claude-code-chat.openChat'; statusBarItem.show(); - context.subscriptions.push(disposable, loadConversationDisposable, configChangeDisposable, statusBarItem); - console.log('Claude Code Chat extension activation completed successfully!'); + // Register URI handler for deep links (e.g., OpenCredits key callback) + const uriHandler = vscode.window.registerUriHandler({ + async handleUri(uri: vscode.Uri) { + + // Handle OpenCredits key callback: vscode://AndrePimenta.claude-code-chat/opencredits-key?key=xxx + if (uri.path === '/opencredits-key') { + const params = new URLSearchParams(uri.query); + const key = params.get('key'); + + if (key) { + // Save the key and OpenCredits env vars + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const envVars = config.get>('environment.variables', {}); + envVars['ANTHROPIC_AUTH_TOKEN'] = key; + envVars['ANTHROPIC_BASE_URL'] = OPENCREDITS_API_URL; + + await config.update('environment.variables', envVars, vscode.ConfigurationTarget.Global); + + // Handle pending model activation after payment + await provider.handleOpenCreditsKeyReceived(key); + + // Show success message + vscode.window.showInformationMessage('OpenCredits account connected! You can now use Claude Code Chat.', 'Open Chat').then(selection => { + if (selection === 'Open Chat') { + provider.show(); + } + }); + } + } + } + }); + + context.subscriptions.push(disposable, loadConversationDisposable, configChangeDisposable, statusBarItem, uriHandler); } -export function deactivate() { } +export function deactivate() { + // Stop the local router when the extension is deactivated + stopRouter().catch(err => console.error('Failed to stop router:', err)); +} interface ConversationData { sessionId: string; @@ -76,7 +129,6 @@ interface ConversationData { class ClaudeChatWebviewProvider implements vscode.WebviewViewProvider { constructor( private readonly _extensionUri: vscode.Uri, - private readonly _context: vscode.ExtensionContext, private readonly _chatProvider: ClaudeChatProvider ) { } @@ -99,7 +151,6 @@ class ClaudeChatWebviewProvider implements vscode.WebviewViewProvider { if (webviewView.visible) { // Close main panel when sidebar becomes visible if (this._chatProvider._panel) { - console.log('Closing main panel because sidebar became visible'); this._chatProvider._panel.dispose(); this._chatProvider._panel = undefined; } @@ -122,6 +173,7 @@ class ClaudeChatProvider { private _requestCount: number = 0; private _subscriptionType: string | undefined; // 'pro', 'max', or undefined for API users private _accountInfoFetchedThisSession: boolean = false; // Track if we fetched account info this session + private _pendingModelAfterPayment: string | null = null; private _currentSessionId: string | undefined; private _backupRepoPath: string | undefined; private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = []; @@ -162,7 +214,6 @@ class ClaudeChatProvider { // Initialize backup repository and conversations this._initializeBackupRepo(); this._initializeConversations(); - this._initializeMCPConfig(); // Load conversation index from workspace state this._conversationIndex = this._context.workspaceState.get('claude.conversationIndex', []); @@ -186,7 +237,13 @@ class ClaudeChatProvider { this._closeSidebar(); if (this._panel) { - this._panel.reveal(actualColumn); + // If panel is already visible, just reveal it without changing the column + // This prevents resizing the panel when it's already open + if (this._panel.visible) { + this._panel.reveal(); + } else { + this._panel.reveal(actualColumn); + } return; } @@ -197,7 +254,8 @@ class ClaudeChatProvider { { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [this._extensionUri] + localResourceRoots: [this._extensionUri], + enableFindWidget: true } ); @@ -230,6 +288,83 @@ class ClaudeChatProvider { }, 100); } + // Get the OpenCredits API key from environment variables + private _getOpenCreditsKey(): string { + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const envVars = config.get>('environment.variables', {}); + return envVars['ANTHROPIC_AUTH_TOKEN'] || ''; + } + + // Check if the configured base URL points to OpenCredits + private _isOpenCredits(): boolean { + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + if (config.get('environment.disabled', false)) { + return false; + } + const envVars = config.get>('environment.variables', {}); + const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; + return baseUrl.includes('opencredits.ai') || baseUrl.includes('localhost:8787'); + } + + private async _setEnvsDisabled(disabled: boolean): Promise { + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + await config.update('environment.disabled', disabled, vscode.ConfigurationTarget.Global); + this._sendCurrentSettings(); + + if (!disabled && (this._isOpenCredits() || this._getOpenCreditsKey())) { + this._sendOpenCreditsBalance(); + } else if (disabled) { + this._postMessage({ type: 'opencreditsBalance', balance: null }); + } + } + + private static readonly OC_KEY_SECRET = 'opencredits.userKey'; + + private async _saveOpenCreditsKey(key: string) { + await this._context.secrets.store(ClaudeChatProvider.OC_KEY_SECRET, key); + } + + private async _getSavedOpenCreditsKey(): Promise { + return await this._context.secrets.get(ClaudeChatProvider.OC_KEY_SECRET) || null; + } + + public async handleOpenCreditsKeyReceived(key: string) { + // Persist key in encrypted storage + await this._saveOpenCreditsKey(key); + + this._postMessage({ + type: 'opencreditsKeyReceived', + key: key + }); + + if (this._pendingModelAfterPayment) { + const pendingModel = this._pendingModelAfterPayment; + this._pendingModelAfterPayment = null; + + try { + this._updateLocalRouterModel(pendingModel); + this._selectedModel = pendingModel; + this._context.workspaceState.update('claude.selectedModel', pendingModel); + await this._setModelEnvVars(pendingModel); + + const balance = await this._fetchOpenCreditsBalance(); + + this._postMessage({ + type: 'opencreditsActivated', + model: pendingModel, + balance: balance + }); + + } catch (error) { + console.error('Failed to activate model after payment:', error); + } + } else { + await this._sendOpenCreditsBalance(); + } + + this._sendCurrentSettings(); + } + private _postMessage(message: any) { if (this._panel && this._panel.webview) { this._panel.webview.postMessage(message); @@ -238,6 +373,17 @@ class ClaudeChatProvider { } } + private async _getImageDataUri(filePath: string): Promise { + try { + const imageData = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); + const base64 = Buffer.from(imageData).toString('base64'); + const ext = path.extname(filePath).toLowerCase(); + return `data:${ClaudeChatProvider.IMAGE_MEDIA_TYPES[ext] || 'image/png'};base64,${base64}`; + } catch { + return undefined; + } + } + private _sendReadyMessage() { // Send current session info if available /*if (this._currentSessionId) { @@ -276,6 +422,11 @@ class ClaudeChatProvider { // Send current settings to webview this._sendCurrentSettings(); + // Fetch and send OpenCredits balance if using OpenCredits + if (this._isOpenCredits() || this._getOpenCreditsKey()) { + this._sendOpenCreditsBalance(); + } + // Send saved draft message if any if (this._draftMessage) { this._postMessage({ @@ -285,10 +436,10 @@ class ClaudeChatProvider { } } - private _handleWebviewMessage(message: any) { + private async _handleWebviewMessage(message: any) { switch (message.type) { case 'sendMessage': - this._sendMessageToClaude(message.text, message.planMode, message.thinkingMode); + this._sendMessageToClaude(message.text, message.planMode, message.thinkingMode, message.images); return; case 'newSession': this._newSession(); @@ -314,6 +465,15 @@ class ClaudeChatProvider { case 'getSettings': this._sendCurrentSettings(); return; + case 'getEnvVars': { + const evConfig = vscode.workspace.getConfiguration('claudeCodeChat'); + const evVars = evConfig.get>('environment.variables', {}); + this._postMessage({ type: 'envVarsData', data: evVars }); + return; + } + case 'setEnvsDisabled': + await this._setEnvsDisabled(!!message.disabled); + return; case 'updateSettings': this._updateSettings(message.settings); return; @@ -321,7 +481,7 @@ class ClaudeChatProvider { this._getClipboardText(); return; case 'selectModel': - this._setSelectedModel(message.model); + this._setSelectedModel(message.model, message.tierModels); return; case 'openModelTerminal': this._openModelTerminal(); @@ -336,8 +496,115 @@ class ClaudeChatProvider { this._dismissWSLAlert(); return; case 'runInstallCommand': - this._runInstallCommand(); + this._runInstallCommand(message.method || 'installer'); + return; + case 'openLoginTerminal': + this._openLoginTerminal(); + return; + case 'openFundsPage': + if (message.pendingModel) { + this._pendingModelAfterPayment = message.pendingModel; + } + vscode.env.openExternal(vscode.Uri.parse(`${OPENCREDITS_WEB_URL}/embed/checkout`)); + return; + case 'setPendingModel': + if (message.pendingModel) { + this._pendingModelAfterPayment = message.pendingModel; + } + return; + case 'opencreditsKeyFromCheckout': + if (message.key) { + // Save the key and OpenCredits env vars (same as URI handler) + const checkoutConfig = vscode.workspace.getConfiguration('claudeCodeChat'); + const checkoutEnvVars = checkoutConfig.get>('environment.variables', {}); + checkoutEnvVars['ANTHROPIC_AUTH_TOKEN'] = message.key; + checkoutEnvVars['ANTHROPIC_BASE_URL'] = OPENCREDITS_API_URL; + checkoutConfig.update('environment.variables', checkoutEnvVars, vscode.ConfigurationTarget.Global).then( + () => { + this.handleOpenCreditsKeyReceived(message.key); + }, + (err: Error) => { + console.error('Failed to save OpenCredits env vars from checkout:', err); + this._postMessage({ + type: 'checkoutSaveError', + message: 'Failed to save your account credentials. Please try again.' + }); + } + ); + // Bring VS Code window to foreground + if (this._panel) { + this._panel.reveal(vscode.ViewColumn.One); + } + const focusCmd = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'start' + : 'xdg-open'; + cp.spawn(focusCmd, [`${vscode.env.uriScheme}://AndrePimenta.claude-code-chat/focus`], { detached: true, stdio: 'ignore' }).unref(); + } + return; + case 'openOpenCreditsAccount': + this._openOpenCreditsAccount(); + return; + case 'restoreOpenCredits': { + const savedKey = await this._getSavedOpenCreditsKey(); + if (savedKey) { + const restoreConfig = vscode.workspace.getConfiguration('claudeCodeChat'); + const restoreEnvVars = restoreConfig.get>('environment.variables', {}); + restoreEnvVars['ANTHROPIC_AUTH_TOKEN'] = savedKey; + restoreEnvVars['ANTHROPIC_BASE_URL'] = OPENCREDITS_API_URL; + await restoreConfig.update('environment.variables', restoreEnvVars, vscode.ConfigurationTarget.Global); + await this.handleOpenCreditsKeyReceived(savedKey); + } + return; + } + case 'checkSavedOpenCredits': { + const hasSaved = !!(await this._getSavedOpenCreditsKey()); + this._postMessage({ type: 'savedOpenCreditsStatus', hasSavedKey: hasSaved }); + return; + } + case 'saveCustomProvider': + if (message.envVars) { + const cpConfig = vscode.workspace.getConfiguration('claudeCodeChat'); + const cpEnvVars = cpConfig.get>('environment.variables', {}); + Object.assign(cpEnvVars, message.envVars); + cpConfig.update('environment.variables', cpEnvVars, vscode.ConfigurationTarget.Global).then( + () => { + this._postMessage({ type: 'customProviderSaved' }); + }, + (err: Error) => { + console.error('Failed to save custom provider:', err); + } + ); + } + return; + case 'copyToClipboard': + if (message.text) { + vscode.env.clipboard.writeText(message.text); + } + return; + case 'saveOpenCreditsKeyEarly': + // Save key to secrets early (before payment) so it can be recovered if VS Code closes + if (message.key) { + this._saveOpenCreditsKey(message.key); + } + return; + case 'openExternalUrl': { + const extUrl = message.url; + try { + if (process.platform === 'win32') { + cp.exec(`start "" "${extUrl}"`, { windowsHide: true }); + } else { + const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open'; + const proc = cp.spawn(openCmd, [extUrl], { detached: true, stdio: 'ignore' }); + proc.on('error', () => { + vscode.env.openExternal(vscode.Uri.parse(extUrl)); + }); + proc.unref(); + } + } catch { + vscode.env.openExternal(vscode.Uri.parse(extUrl)); + } return; + } case 'openFile': this._openFileInEditor(message.filePath); return; @@ -353,6 +620,15 @@ class ClaudeChatProvider { case 'permissionResponse': this._handlePermissionResponse(message.id, message.approved, message.alwaysAllow); return; + case 'askUserQuestionResponse': + this._handleAskUserQuestionResponse(message.id, message.answers); + return; + case 'showInfoMessage': + vscode.window.showInformationMessage(message.message); + return; + case 'marketplaceFetch': + this._fetchMarketplace(message.url, message.append, message.isSearch); + return; case 'getPermissions': this._sendPermissions(); return; @@ -362,14 +638,38 @@ class ClaudeChatProvider { case 'addPermission': this._addPermission(message.toolName, message.command); return; + case 'loadSkills': + this._loadSkills(); + return; + case 'saveSkill': + this._saveSkill(message.name, message.scope, message.content); + return; + case 'deleteSkill': + this._deleteSkill(message.name, message.scope); + return; + case 'searchSkills': + this._searchSkills(message.query); + return; + case 'runTerminalCommand': + this._runTerminalCommand(message.command); + return; + case 'loadPlugins': + this._loadPlugins(); + return; + case 'installPlugin': + this._installPlugin(message.installId); + return; + case 'removePlugin': + this._removePlugin(message.installId); + return; case 'loadMCPServers': this._loadMCPServers(); return; case 'saveMCPServer': - this._saveMCPServer(message.name, message.config); + this._saveMCPServer(message.name, message.config, message.scope || 'project'); return; case 'deleteMCPServer': - this._deleteMCPServer(message.name); + this._deleteMCPServer(message.name, message.scope || 'project'); return; case 'getCustomSnippets': this._sendCustomSnippets(); @@ -413,7 +713,6 @@ class ClaudeChatProvider { public showInWebview(webview: vscode.Webview, webviewView?: vscode.WebviewView) { // Close main panel if it's open if (this._panel) { - console.log('Closing main panel because sidebar is opening'); this._panel.dispose(); this._panel = undefined; } @@ -443,6 +742,124 @@ class ClaudeChatProvider { this._sendReadyMessage(); }, 100); } + + // Check feature flags and auto-update models (non-blocking) + this._checkFeatureFlags().then(enabled => { + if (enabled) { + this._autoUpdateRecommendedModels().catch(() => {}); + } + }).catch(() => {}); + } + + private static readonly FEATURES_CACHE_KEY = 'claude.featureFlags'; + private static readonly FEATURES_CACHE_TTL = 0; // Always re-fetch for now + private static readonly MODEL_CACHE_KEY = 'claude.recommendedModelsCache.v1'; + private static readonly MODEL_CACHE_TTL = 24 * 60 * 60 * 1000; // 1 day + + private async _checkFeatureFlags(): Promise { + + // Check cache first + const cached = this._context.globalState.get<{ timestamp: number; opencredits_enabled: boolean }>(ClaudeChatProvider.FEATURES_CACHE_KEY); + if (cached && Date.now() - cached.timestamp < ClaudeChatProvider.FEATURES_CACHE_TTL) { + this._postMessage({ type: 'featureFlags', opencredits_enabled: cached.opencredits_enabled }); + return cached.opencredits_enabled; + } + + try { + const res = await fetch(OPENCREDITS_API_URL + '/v1/features'); + if (res.ok) { + const data = await res.json() as { opencredits_enabled: boolean; country: string }; + this._context.globalState.update(ClaudeChatProvider.FEATURES_CACHE_KEY, { + timestamp: Date.now(), + opencredits_enabled: data.opencredits_enabled + }); + this._postMessage({ type: 'featureFlags', opencredits_enabled: data.opencredits_enabled }); + return data.opencredits_enabled; + } + } catch (e) { + console.error('[OpenCredits] Feature flags fetch failed:', e); + } + + // Default to disabled if fetch fails + this._postMessage({ type: 'featureFlags', opencredits_enabled: false }); + return false; + } + + private static readonly REFERENCE_MODEL = 'anthropic/claude-opus-4.6'; + private static get PUBLISHABLE_KEY() { return OPENCREDITS_PUBLISHABLE_KEY; } + private static readonly IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']; + private static readonly IMAGE_MEDIA_TYPES: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp', '.svg': 'image/svg+xml' + }; + + private async _autoUpdateRecommendedModels() { + // Check cache first + const cached = this._context.globalState.get<{ timestamp: number; models: any[]; creditsPricing?: any }>(ClaudeChatProvider.MODEL_CACHE_KEY); + if (cached && Date.now() - cached.timestamp < ClaudeChatProvider.MODEL_CACHE_TTL) { + this._postMessage({ + type: 'updateRecommendedModels', + models: cached.models, + creditsPricing: cached.creditsPricing + }); + return; + } + + const updated = await fetchAndResolveModels(recommendedModels as any[], OPENCREDITS_API_URL); + if (updated && updated.length > 0) { + // Fetch credit pricing for recommended models + reference model + const modelIds = updated.map((m: any) => m.id); + // Also include tier model IDs + for (const m of updated) { + if ((m as any).tierModels) { + const tiers = (m as any).tierModels; + for (const key of Object.keys(tiers)) { + if (tiers[key] && !modelIds.includes(tiers[key])) { + modelIds.push(tiers[key]); + } + } + } + } + if (!modelIds.includes(ClaudeChatProvider.REFERENCE_MODEL)) { + modelIds.push(ClaudeChatProvider.REFERENCE_MODEL); + } + if (!modelIds.includes('anthropic/claude-sonnet-4.6')) { + modelIds.push('anthropic/claude-sonnet-4.6'); + } + + let creditsPricing: any = null; + try { + const pricingRes = await fetch(OPENCREDITS_API_URL + '/v1/credits/pricing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publishable_key: ClaudeChatProvider.PUBLISHABLE_KEY, + models: modelIds + }) + }); + if (pricingRes.ok) { + const pricingData = await pricingRes.json() as any; + creditsPricing = { + referenceModel: ClaudeChatProvider.REFERENCE_MODEL, + models: pricingData.models || [], + tokenAssumption: pricingData.token_assumption + }; + } + } catch (e) { + console.error('[OpenCredits] Credit pricing fetch failed:', e); + } + + this._context.globalState.update(ClaudeChatProvider.MODEL_CACHE_KEY, { + timestamp: Date.now(), + models: updated, + creditsPricing + }); + this._postMessage({ + type: 'updateRecommendedModels', + models: updated, + creditsPricing + }); + } } public reinitializeWebview() { @@ -455,7 +872,7 @@ class ClaudeChatProvider { } } - private async _sendMessageToClaude(message: string, planMode?: boolean, thinkingMode?: boolean) { + private async _sendMessageToClaude(message: string, planMode?: boolean, thinkingMode?: boolean, images?: string[]) { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const cwd = workspaceFolder ? workspaceFolder.uri.fsPath : process.cwd(); @@ -509,7 +926,7 @@ class ClaudeChatProvider { await this._createBackupCommit(message); } catch (e) { - console.log("error", e); + console.error("error", e); } // Show loading indicator @@ -539,10 +956,15 @@ class ClaudeChatProvider { args.push('--permission-prompt-tool', 'stdio'); } - // Add MCP config if user has custom servers configured - const mcpConfigPath = this.getMCPConfigPath(); + // Pass extension's MCP config to Claude CLI (only if file exists) + const mcpConfigPath = this._getExtensionMCPConfigPath(); if (mcpConfigPath) { - args.push('--mcp-config', this.convertToWSLPath(mcpConfigPath)); + try { + await vscode.workspace.fs.stat(vscode.Uri.file(mcpConfigPath)); + args.push('--mcp-config', this.convertToWSLPath(mcpConfigPath)); + } catch { + // File doesn't exist, skip --mcp-config + } } // Add plan mode if enabled @@ -550,34 +972,84 @@ class ClaudeChatProvider { args.push('--permission-mode', 'plan'); } - // Add model selection if not using default - if (this._selectedModel && this._selectedModel !== 'default') { + // Add model selection for Claude models only (opus, sonnet) + // OpenCredits models are handled via env vars or router mapping + const claudeModels = ['opus', 'sonnet']; + if (this._selectedModel && claudeModels.includes(this._selectedModel)) { args.push('--model', this._selectedModel); } // Add session resume if we have a current session if (this._currentSessionId) { args.push('--resume', this._currentSessionId); - console.log('Resuming session:', this._currentSessionId); - } else { - console.log('Starting new session'); } - console.log('Claude command args:', args); const wslEnabled = config.get('wsl.enabled', false); const wslDistro = config.get('wsl.distro', 'Ubuntu'); - const nodePath = config.get('wsl.nodePath', '/usr/bin/node'); + const nodePath = config.get('wsl.nodePath', ''); const claudePath = config.get('wsl.claudePath', '/usr/local/bin/claude'); + const routerExplicitlyEnabled = config.get('router.enabled', false); + const customExecutablePath = config.get('executable.path', ''); + const envsDisabled = config.get('environment.disabled', false); + const customEnvVars = envsDisabled ? {} : config.get>('environment.variables', {}); + + // Check if using OpenCredits (base URL contains opencredits.ai) + const isOpenCredits = this._isOpenCredits(); + + // Router is only used when explicitly enabled (fallback for older OpenCredits support) + // OpenCredits now supports Anthropic API format directly, so env vars pass through + const useRouter = routerExplicitlyEnabled && !wslEnabled; + let claudeProcess: cp.ChildProcess; // Create new AbortController for this request this._abortController = new AbortController(); + // Build environment variables - apply custom env vars from settings + let spawnEnv: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: '0', + NO_COLOR: '1', + ...customEnvVars // Apply custom environment variables (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, etc.) + }; + + // OpenCredits: clear Anthropic-specific vars so Claude CLI uses env vars directly + if (isOpenCredits) { + spawnEnv.ANTHROPIC_API_KEY = ''; + spawnEnv.DISABLE_TELEMETRY = 'true'; + spawnEnv.DISABLE_COST_WARNINGS = 'true'; + delete spawnEnv.CLAUDE_CODE_USE_BEDROCK; + } + + // If router explicitly enabled, start local router and override ANTHROPIC_BASE_URL + if (useRouter) { + // Pass the real ANTHROPIC_BASE_URL to the router before starting + const realBaseUrl = customEnvVars['ANTHROPIC_BASE_URL'] || ''; + if (realBaseUrl) { + setBaseUrl(realBaseUrl); + } + + const routerPort = await this._ensureLocalRouter(); + spawnEnv.ANTHROPIC_BASE_URL = `http://127.0.0.1:${routerPort}`; + spawnEnv.NO_PROXY = '127.0.0.1'; + } + if (wslEnabled) { - // Use WSL with bash -ic for proper environment loading - console.log('Using WSL configuration:', { wslDistro, nodePath, claudePath }); - const wslCommand = `"${nodePath}" --no-warnings --enable-source-maps "${claudePath}" ${args.join(' ')}`; + // Build env var exports to prepend to the WSL command + // WSL doesn't reliably inherit Windows env vars via spawn + const wslEnvOverrides: Record = { ...customEnvVars }; + if (isOpenCredits) { + wslEnvOverrides['ANTHROPIC_API_KEY'] = ''; + wslEnvOverrides['DISABLE_TELEMETRY'] = 'true'; + wslEnvOverrides['DISABLE_COST_WARNINGS'] = 'true'; + } + const envExports = Object.entries(wslEnvOverrides) + .map(([k, v]) => `export ${k}="${v.replace(/"/g, '\\"')}"`) + .join(' && '); + const envPrefix = envExports ? envExports + ' && ' : ''; + + const wslCommand = envPrefix + this._buildWslClaudeCommand(nodePath, claudePath, args); // Track WSL state for proper process termination this._isWslProcess = true; @@ -588,29 +1060,25 @@ class ClaudeChatProvider { detached: process.platform !== 'win32', cwd: cwd, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - FORCE_COLOR: '0', - NO_COLOR: '1' - } + env: spawnEnv }); } else { // Not using WSL this._isWslProcess = false; - // Use native claude command - console.log('Using native Claude command'); - claudeProcess = cp.spawn('claude', args, { + // Use native claude command (or custom executable if configured). + // shell:true is only needed on Windows when we don't have an absolute path β€” + // cmd.exe's resolver finds .cmd/.bat shims on PATH. With an absolute .exe + // path we skip shell wrapping to avoid cmd.exe mis-quoting paths with spaces + // (e.g. the default globalStorage location "...Application Support..."). + const executable = customExecutablePath || 'claude'; + claudeProcess = cp.spawn(executable, args, { signal: this._abortController.signal, - shell: process.platform === 'win32', + shell: process.platform === 'win32' && !customExecutablePath, detached: process.platform !== 'win32', cwd: cwd, stdio: ['pipe', 'pipe', 'pipe'], - env: { - ...process.env, - FORCE_COLOR: '0', - NO_COLOR: '1' - } + env: spawnEnv }); } @@ -633,16 +1101,86 @@ class ClaudeChatProvider { claudeProcess.stdin.write(JSON.stringify(initRequest) + '\n'); } + // Build message content β€” detect image file paths and inline them as base64 + const content: Array<{type: string; text?: string; source?: {type: string; media_type: string; data: string}}> = []; + const imageExtensions = ClaudeChatProvider.IMAGE_EXTENSIONS; + const imageMediaTypes = ClaudeChatProvider.IMAGE_MEDIA_TYPES; + + // Scan message for image file paths and inline them as base64 + const imagePathRegex = /(\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|bmp))\b/gi; + let lastIndex = 0; + let match; + while ((match = imagePathRegex.exec(actualMessage)) !== null) { + const imagePath = match[1]; + const ext = path.extname(imagePath).toLowerCase(); + if (imageExtensions.includes(ext)) { + try { + const imageData = await vscode.workspace.fs.readFile(vscode.Uri.file(imagePath)); + const base64 = Buffer.from(imageData).toString('base64'); + // Flush text before this match + const textBefore = actualMessage.substring(lastIndex, match.index); + if (textBefore.trim()) { + content.push({ type: 'text', text: textBefore.trim() }); + } + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: imageMediaTypes[ext] || 'image/png', + data: base64 + } + }); + lastIndex = match.index + match[0].length; + } catch (e) { + console.error('Could not read image file:', imagePath, e); + } + } + } + // Add remaining text + const remaining = actualMessage.substring(lastIndex); + if (remaining.trim()) { + content.push({ type: 'text', text: remaining.trim() }); + } + + // Add explicitly attached images + if (images && images.length > 0) { + for (const imagePath of images) { + const ext = imageExtensions.find(e => imagePath.toLowerCase().endsWith(e)); + if (ext) { + try { + const imageData = await vscode.workspace.fs.readFile(vscode.Uri.file(imagePath)); + const base64 = Buffer.from(imageData).toString('base64'); + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: imageMediaTypes[ext] || 'image/png', + data: base64 + } + }); + } catch (e) { + console.error('Could not read attached image:', imagePath, e); + } + } + } + } + + // Ensure at least one text block + if (content.length === 0) { + content.push({ type: 'text', text: actualMessage }); + } + const userMessage = { type: 'user', session_id: this._currentSessionId || '', message: { role: 'user', - content: [{ type: 'text', text: actualMessage }] + content: content }, parent_tool_use_id: null }; - claudeProcess.stdin.write(JSON.stringify(userMessage) + '\n'); + const userMessageJson = JSON.stringify(userMessage); + claudeProcess.stdin.write(userMessageJson + '\n'); } let rawOutput = ''; @@ -684,7 +1222,7 @@ class ClaudeChatProvider { this._processJsonStreamData(jsonData); } catch (error) { - console.log('Failed to parse JSON line:', line, error); + console.error('Failed to parse JSON line:', line, error); } } } @@ -698,8 +1236,6 @@ class ClaudeChatProvider { } claudeProcess.on('close', (code) => { - console.log('Claude process closed with code:', code); - console.log('Claude stderr output:', errorOutput); if (!this._currentClaudeProcess) { return; @@ -726,16 +1262,24 @@ class ClaudeChatProvider { }); if (code !== 0 && errorOutput.trim()) { - // Error with output - this._sendAndSaveMessage({ - type: 'error', - data: errorOutput.trim() - }); + // Check if claude command is not installed (Windows cmd.exe) + if (errorOutput.includes('not recognized as an internal or external command')) { + this._postMessage({ + type: 'showInstallModal', + installAttempted: !!this._context.globalState.get('installAttempted') + }); + } else { + // Error with output + this._sendAndSaveMessage({ + type: 'error', + data: errorOutput.trim() + }); + } } }); claudeProcess.on('error', (error) => { - console.log('Claude process error:', error.message); + console.error('Claude process error:', error.message); if (!this._currentClaudeProcess) { return; @@ -760,9 +1304,10 @@ class ClaudeChatProvider { }); // Check if claude command is not installed - if (error.message.includes('ENOENT') || error.message.includes('command not found')) { + if (error.message.includes('ENOENT') || error.message.includes('command not found') || error.message.includes('not recognized as an internal or external command')) { this._postMessage({ - type: 'showInstallModal' + type: 'showInstallModal', + installAttempted: !!this._context.globalState.get('installAttempted') }); } else { this._sendAndSaveMessage({ @@ -778,7 +1323,6 @@ class ClaudeChatProvider { case 'system': if (jsonData.subtype === 'init') { // System initialization message - session ID will be captured from final result - console.log('System initialized'); this._currentSessionId = jsonData.session_id; //this._sendAndSaveMessage({ type: 'init', data: { sessionId: jsonData.session_id; } }) @@ -794,13 +1338,11 @@ class ClaudeChatProvider { } else if (jsonData.subtype === 'status') { // Handle status changes (e.g., compacting) if (jsonData.status === 'compacting') { - console.log('Conversation compacting started'); this._sendAndSaveMessage({ type: 'compacting', data: { isCompacting: true } }); } else if (jsonData.status === null) { - console.log('Status cleared'); this._sendAndSaveMessage({ type: 'compacting', data: { isCompacting: false } @@ -808,7 +1350,6 @@ class ClaudeChatProvider { } } else if (jsonData.subtype === 'compact_boundary') { // Compact boundary - conversation was compacted, reset token counts - console.log('Compact boundary received', jsonData.compact_metadata); // Reset tokens since the conversation is now summarized this._totalTokensInput = 0; @@ -1008,24 +1549,23 @@ class ClaudeChatProvider { case 'result': if (jsonData.subtype === 'success') { // Check for login errors - if (jsonData.is_error && jsonData.result && jsonData.result.includes('Invalid API key')) { + if (jsonData.is_error && jsonData.result && ( + jsonData.result.includes('Invalid API key') || + jsonData.result.includes('Not logged in') || + jsonData.result.includes('/login') || + jsonData.result.includes('not authenticated') + )) { this._handleLoginRequired(); return; } this._isProcessing = false; + // Play Windows notification sound when Claude completes a response + void this._playWindowsNotificationSound(); + // Capture session ID from final result if (jsonData.session_id) { - const isNewSession = !this._currentSessionId; - const sessionChanged = this._currentSessionId && this._currentSessionId !== jsonData.session_id; - - console.log('Session ID found in result:', { - sessionId: jsonData.session_id, - isNewSession, - sessionChanged, - currentSessionId: this._currentSessionId - }); this._currentSessionId = jsonData.session_id; @@ -1052,11 +1592,6 @@ class ClaudeChatProvider { this._totalCost += jsonData.total_cost_usd; } - console.log('Result received:', { - cost: jsonData.total_cost_usd, - duration: jsonData.duration_ms, - turns: jsonData.num_turns - }); // Send updated totals to webview this._postMessage({ @@ -1071,6 +1606,11 @@ class ClaudeChatProvider { currentTurns: jsonData.num_turns } }); + + // Refresh OpenCredits balance after each request if using OpenCredits + if (this._isOpenCredits() || this._getOpenCreditsKey()) { + this._sendOpenCreditsBalance(); + } } break; } @@ -1110,9 +1650,6 @@ class ClaudeChatProvider { } public newSessionOnConfigChange() { - // Reinitialize MCP config with new WSL paths - this._initializeMCPConfig(); - // Start a new session due to configuration change this._newSession(); @@ -1129,7 +1666,7 @@ class ClaudeChatProvider { }); } - private _handleLoginRequired() { + private async _handleLoginRequired() { this._isProcessing = false; @@ -1139,41 +1676,19 @@ class ClaudeChatProvider { data: { isProcessing: false } }); - // Show login required message - this._postMessage({ - type: 'loginRequired' - }); - - // Get configuration to check if WSL is enabled - const config = vscode.workspace.getConfiguration('claudeCodeChat'); - const wslEnabled = config.get('wsl.enabled', false); - const wslDistro = config.get('wsl.distro', 'Ubuntu'); - const nodePath = config.get('wsl.nodePath', '/usr/bin/node'); - const claudePath = config.get('wsl.claudePath', '/usr/local/bin/claude'); - - // Open terminal and run claude login - const terminal = vscode.window.createTerminal({ - name: 'Claude Login', - location: { viewColumn: vscode.ViewColumn.One } - }); - if (wslEnabled) { - terminal.sendText(`wsl -d ${wslDistro} ${nodePath} --no-warnings --enable-source-maps ${claudePath}`); + // Check if OpenCredits is enabled - if so, show options modal + const opencreditsEnabled = await this._checkFeatureFlags(); + if (opencreditsEnabled) { + this._postMessage({ + type: 'showLoginOptions' + }); } else { - terminal.sendText('claude'); + // Just open login terminal directly + this._openLoginTerminal(); + this._postMessage({ + type: 'loginRequired' + }); } - terminal.show(); - - // Show info message - vscode.window.showInformationMessage( - 'Please login with your Claude plan or API key in the terminal, then come back to this chat.', - 'OK' - ); - - // Send message to UI about terminal - this._postMessage({ - type: 'terminalOpened', - data: `Please login with your Claude plan or API key in the terminal, then come back to this chat.`, - }); } private async _initializeBackupRepo(): Promise { @@ -1186,7 +1701,6 @@ class ClaudeChatProvider { console.error('No workspace storage available'); return; } - console.log('Workspace storage path:', storagePath); this._backupRepoPath = path.join(storagePath, 'backups', '.git'); // Create backup git directory if it doesn't exist @@ -1202,7 +1716,6 @@ class ClaudeChatProvider { await exec(`git --git-dir="${this._backupRepoPath}" config user.name "Claude Code Chat"`); await exec(`git --git-dir="${this._backupRepoPath}" config user.email "claude@anthropic.com"`); - console.log(`Initialized backup repository at: ${this._backupRepoPath}`); } } catch (error: any) { console.error('Failed to initialize backup repository:', error.message); @@ -1264,7 +1777,6 @@ class ClaudeChatProvider { data: commitInfo }); - console.log(`Created backup commit: ${commitInfo.sha.substring(0, 8)} - ${actualMessage}`); } catch (error: any) { console.error('Failed to create backup commit:', error.message); } @@ -1333,62 +1845,12 @@ class ClaudeChatProvider { await vscode.workspace.fs.stat(vscode.Uri.file(this._conversationsPath)); } catch { await vscode.workspace.fs.createDirectory(vscode.Uri.file(this._conversationsPath)); - console.log(`Created conversations directory at: ${this._conversationsPath}`); } } catch (error: any) { console.error('Failed to initialize conversations directory:', error.message); } } - private async _initializeMCPConfig(): Promise { - try { - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) { return; } - - // Create MCP config directory - const mcpConfigDir = path.join(storagePath, 'mcp'); - try { - await vscode.workspace.fs.stat(vscode.Uri.file(mcpConfigDir)); - } catch { - await vscode.workspace.fs.createDirectory(vscode.Uri.file(mcpConfigDir)); - console.log(`Created MCP config directory at: ${mcpConfigDir}`); - } - - // Create or update mcp-servers.json, preserving user's custom servers - // Note: Permissions are now handled via stdio, not MCP - const mcpConfigPath = path.join(mcpConfigDir, 'mcp-servers.json'); - - // Load existing config or create new one - let mcpConfig: any = { mcpServers: {} }; - const mcpConfigUri = vscode.Uri.file(mcpConfigPath); - - try { - const existingContent = await vscode.workspace.fs.readFile(mcpConfigUri); - mcpConfig = JSON.parse(new TextDecoder().decode(existingContent)); - console.log('Loaded existing MCP config, preserving user servers'); - } catch { - console.log('No existing MCP config found, creating new one'); - } - - // Ensure mcpServers exists - if (!mcpConfig.mcpServers) { - mcpConfig.mcpServers = {}; - } - - // Remove old permissions server if it exists (migrating from file-based to stdio) - if (mcpConfig.mcpServers['claude-code-chat-permissions']) { - delete mcpConfig.mcpServers['claude-code-chat-permissions']; - console.log('Removed legacy permissions MCP server (now using stdio)'); - } - - const configContent = new TextEncoder().encode(JSON.stringify(mcpConfig, null, 2)); - await vscode.workspace.fs.writeFile(mcpConfigUri, configContent); - - console.log(`Updated MCP config at: ${mcpConfigPath}`); - } catch (error: any) { - console.error('Failed to initialize MCP config:', error.message); - } - } /** * Check if a tool is pre-approved in local permissions @@ -1462,10 +1924,6 @@ class ClaudeChatProvider { const account = innerResponse.account; this._subscriptionType = account.subscriptionType; - console.log('Account info received:', { - subscriptionType: account.subscriptionType, - email: account.email - }); // Save to globalState for persistence this._context.globalState.update('claude.subscriptionType', this._subscriptionType); @@ -1484,13 +1942,12 @@ class ClaudeChatProvider { * Handle control_request messages from Claude CLI via stdio * This is the new permission flow that replaces the MCP file-based approach */ - private async _handleControlRequest(controlRequest: any, claudeProcess: cp.ChildProcess): Promise { + private async _handleControlRequest(controlRequest: any, _claudeProcess: cp.ChildProcess): Promise { const request = controlRequest.request; const requestId = controlRequest.request_id; // Only handle can_use_tool requests (permission requests) if (request?.subtype !== 'can_use_tool') { - console.log('Ignoring non-permission control request:', request?.subtype); return; } @@ -1499,13 +1956,17 @@ class ClaudeChatProvider { const suggestions = request.permission_suggestions; const toolUseId = request.tool_use_id; - console.log(`Permission request for tool: ${toolName}, requestId: ${requestId}`); + + // Handle AskUserQuestion tool separately + if (toolName === 'AskUserQuestion') { + this._handleAskUserQuestion(requestId, input, toolUseId); + return; + } // Check if this tool is pre-approved const isPreApproved = await this._isToolPreApproved(toolName, input); if (isPreApproved) { - console.log(`Tool ${toolName} is pre-approved, auto-allowing`); // Auto-approve without showing UI this._sendPermissionResponse(requestId, true, { requestId, @@ -1601,8 +2062,6 @@ class ClaudeChatProvider { } const responseJson = JSON.stringify(response) + '\n'; - console.log('Sending permission response:', responseJson); - console.log('Always allow:', alwaysAllow, 'Suggestions included:', !!pendingRequest.suggestions); this._currentClaudeProcess.stdin.write(responseJson); } @@ -1644,17 +2103,110 @@ class ClaudeChatProvider { } /** - * Cancel all pending permission requests (called when process ends) + * Handle AskUserQuestion tool - show questions UI and collect answers */ - private _cancelPendingPermissionRequests(): void { - for (const [id, _request] of this._pendingPermissionRequests) { - this._postMessage({ - type: 'updatePermissionStatus', - data: { - id: id, - status: 'cancelled' + private _handleAskUserQuestion(requestId: string, input: Record, toolUseId: string): void { + const questions = (input.questions as any[]) || []; + + // Store the pending request + this._pendingPermissionRequests.set(requestId, { + requestId, + toolName: 'AskUserQuestion', + input, + suggestions: undefined, + toolUseId + }); + + // Send to UI for rendering + this._sendAndSaveMessage({ + type: 'askUserQuestion', + data: { + id: requestId, + questions: questions, + status: 'pending' + } + }); + } + + /** + * Handle user's answers to AskUserQuestion + */ + private _handleAskUserQuestionResponse(requestId: string, answers: Record): void { + const pendingRequest = this._pendingPermissionRequests.get(requestId); + if (!pendingRequest) { + console.error('No pending AskUserQuestion request found for id:', requestId); + return; + } + + this._pendingPermissionRequests.delete(requestId); + + if (!this._currentClaudeProcess?.stdin || this._currentClaudeProcess.stdin.destroyed) { + console.error('Cannot send AskUserQuestion response: stdin not available'); + return; + } + + const response = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { + behavior: 'allow', + updatedInput: { + questions: (pendingRequest.input as any).questions, + answers: answers + }, + toolUseID: pendingRequest.toolUseId } - }); + } + }; + + const responseJson = JSON.stringify(response) + '\n'; + this._currentClaudeProcess.stdin.write(responseJson); + + // Update the saved conversation message to reflect answered status + const savedMsg = this._currentConversation.find( + m => m.messageType === 'askUserQuestion' && m.data?.id === requestId + ); + if (savedMsg) { + savedMsg.data = { ...savedMsg.data, status: 'answered', answers: answers }; + void this._saveCurrentConversation(); + } + + // Update UI status + this._postMessage({ + type: 'updateAskUserQuestionStatus', + data: { + id: requestId, + status: 'answered', + answers: answers + } + }); + } + + /** + * Cancel all pending permission requests (called when process ends) + */ + private _cancelPendingPermissionRequests(): void { + for (const [id, request] of this._pendingPermissionRequests) { + if (request.toolName === 'AskUserQuestion') { + this._postMessage({ + type: 'updateAskUserQuestionStatus', + data: { + id: id, + status: 'cancelled', + answers: null + } + }); + } else { + this._postMessage({ + type: 'updatePermissionStatus', + data: { + id: id, + status: 'cancelled' + } + }); + } } this._pendingPermissionRequests.clear(); } @@ -1706,7 +2258,6 @@ class ClaudeChatProvider { const permissionsContent = new TextEncoder().encode(JSON.stringify(permissions, null, 2)); await vscode.workspace.fs.writeFile(permissionsUri, permissionsContent); - console.log(`Saved local permission for ${toolName}`); } catch (error) { console.error('Error saving local permission:', error); } @@ -1880,7 +2431,6 @@ class ClaudeChatProvider { // Send updated permissions to UI this._sendPermissions(); - console.log(`Removed permission for ${toolName}${command ? ` command: ${command}` : ''}`); } catch (error) { console.error('Error removing permission:', error); } @@ -1945,120 +2495,349 @@ class ClaudeChatProvider { // Send updated permissions to UI this._sendPermissions(); - console.log(`Added permission for ${toolName}${command ? ` command: ${command}` : ' (all commands)'}`); } catch (error) { console.error('Error adding permission:', error); } } + // ─── Skills ─── + + private async _loadSkills(): Promise { + const skills: { name: string; scope: string; description: string; content: string }[] = []; + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + + // Scan personal skills + const personalDir = path.join(homeDir, '.claude', 'skills'); + try { + const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(personalDir)); + for (const [name, type] of entries) { + if (type === vscode.FileType.Directory) { + const skillPath = path.join(personalDir, name, 'SKILL.md'); + try { + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(skillPath)); + const text = new TextDecoder().decode(content); + const descMatch = text.match(/description:\s*(.+)/); + const bodyMatch = text.match(/^---[\s\S]*?---\s*([\s\S]*)$/); + skills.push({ name, scope: 'personal', description: descMatch ? descMatch[1].trim() : '', content: bodyMatch ? bodyMatch[1].trim() : text }); + } catch { /* no SKILL.md */ } + } + } + } catch { /* dir doesn't exist */ } + + // Scan project skills + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (workspaceFolder) { + const projectDir = path.join(workspaceFolder, '.claude', 'skills'); + try { + const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(projectDir)); + for (const [name, type] of entries) { + if (type === vscode.FileType.Directory) { + const skillPath = path.join(projectDir, name, 'SKILL.md'); + try { + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(skillPath)); + const text = new TextDecoder().decode(content); + const descMatch = text.match(/description:\s*(.+)/); + const bodyMatch = text.match(/^---[\s\S]*?---\s*([\s\S]*)$/); + skills.push({ name, scope: 'project', description: descMatch ? descMatch[1].trim() : '', content: bodyMatch ? bodyMatch[1].trim() : text }); + } catch { /* no SKILL.md */ } + } + } + } catch { /* dir doesn't exist */ } + } + + this._postMessage({ type: 'skillsList', data: skills }); + } + + private async _saveSkill(name: string, scope: string, content: string): Promise { + try { + let baseDir: string; + if (scope === 'project') { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceFolder) { throw new Error('No workspace folder'); } + baseDir = path.join(workspaceFolder, '.claude', 'skills'); + } else { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + baseDir = path.join(homeDir, '.claude', 'skills'); + } + + const skillDir = path.join(baseDir, name); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(skillDir)); + const skillPath = path.join(skillDir, 'SKILL.md'); + await vscode.workspace.fs.writeFile(vscode.Uri.file(skillPath), new TextEncoder().encode(content)); + + this._postMessage({ type: 'skillSaved', data: { name } }); + vscode.window.showInformationMessage(`Skill "${name}" created successfully.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to create skill: ${err.message}`); + } + } + + private async _deleteSkill(name: string, scope: string): Promise { + try { + let baseDir: string; + if (scope === 'project') { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceFolder) { throw new Error('No workspace folder'); } + baseDir = path.join(workspaceFolder, '.claude', 'skills'); + } else { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + baseDir = path.join(homeDir, '.claude', 'skills'); + } + + const skillDir = path.join(baseDir, name); + await vscode.workspace.fs.delete(vscode.Uri.file(skillDir), { recursive: true }); + + this._postMessage({ type: 'skillDeleted', data: { name } }); + vscode.window.showInformationMessage(`Skill "${name}" deleted.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to delete skill: ${err.message}`); + } + } + + private async _searchSkills(query: string): Promise { + try { + const res = await fetch(`https://skills.sh/api/search?q=${encodeURIComponent(query)}&limit=20`); + if (!res.ok) { throw new Error('HTTP ' + res.status); } + const data = await res.json() as any; + this._postMessage({ type: 'skillsSearchResponse', data }); + } catch (err: any) { + this._postMessage({ type: 'skillsSearchResponse', data: { skills: [] } }); + } + } + + // ─── Plugins ─── + + private async _getClaudeSettingsPath(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceFolder) { return undefined; } + return path.join(workspaceFolder, '.claude', 'settings.json'); + } + + private async _readClaudeSettings(): Promise { + const settingsPath = await this._getClaudeSettingsPath(); + if (!settingsPath) { return {}; } + try { + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(settingsPath)); + return JSON.parse(new TextDecoder().decode(content)); + } catch { + return {}; + } + } + + private async _writeClaudeSettings(settings: any): Promise { + const settingsPath = await this._getClaudeSettingsPath(); + if (!settingsPath) { return; } + const dirPath = path.dirname(settingsPath); + await vscode.workspace.fs.createDirectory(vscode.Uri.file(dirPath)); + await vscode.workspace.fs.writeFile( + vscode.Uri.file(settingsPath), + new TextEncoder().encode(JSON.stringify(settings, null, 2) + '\n') + ); + } + + private async _loadPlugins(): Promise { + const settings = await this._readClaudeSettings(); + const enabled = settings.enabledPlugins || {}; + this._postMessage({ type: 'pluginsList', data: { enabled } }); + } + + private async _installPlugin(installId: string): Promise { + try { + const settings = await this._readClaudeSettings(); + if (!settings.enabledPlugins) { settings.enabledPlugins = {}; } + settings.enabledPlugins[installId] = true; + await this._writeClaudeSettings(settings); + this._postMessage({ type: 'pluginInstalled', data: { installId } }); + vscode.window.showInformationMessage(`Plugin "${installId.replace(/@.*$/, '')}" enabled.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to enable plugin: ${err.message}`); + } + } + + private async _removePlugin(installId: string): Promise { + try { + const settings = await this._readClaudeSettings(); + if (settings.enabledPlugins) { + delete settings.enabledPlugins[installId]; + if (Object.keys(settings.enabledPlugins).length === 0) { + delete settings.enabledPlugins; + } + } + await this._writeClaudeSettings(settings); + this._postMessage({ type: 'pluginRemoved', data: { installId } }); + vscode.window.showInformationMessage(`Plugin "${installId.replace(/@.*$/, '')}" removed.`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to remove plugin: ${err.message}`); + } + } + + private _runTerminalCommand(command: string): void { + const terminal = vscode.window.createTerminal({ + name: 'Claude Code', + location: vscode.TerminalLocation.Editor + }); + terminal.show(); + terminal.sendText(command); + } + + private async _fetchMarketplace(url: string, append?: boolean, isSearch?: boolean): Promise { + try { + const res = await fetch(url, { + headers: { 'accept': 'application/json' } + }); + if (!res.ok) { throw new Error('HTTP ' + res.status); } + const data = await res.json() as any; + data._append = !!append; + data._isSearch = !!isSearch; + this._postMessage({ type: 'marketplaceResponse', data }); + } catch (err: any) { + console.error('Marketplace fetch error:', err); + this._postMessage({ type: 'marketplaceError', data: { error: err.message } }); + } + } + + private _getExtensionMCPConfigPath(): string | undefined { + const storagePath = this._context.storageUri?.fsPath; + if (!storagePath) { return undefined; } + return path.join(storagePath, 'mcp', 'mcp-servers.json'); + } + + private _getMCPConfigPathForScope(scope: string): string | undefined { + if (scope === 'global') { + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + return homeDir ? path.join(homeDir, '.claude.json') : undefined; + } + if (scope === 'project') { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + return workspaceFolder ? path.join(workspaceFolder, '.mcp.json') : undefined; + } + // 'extension' scope β€” the private config + return this._getExtensionMCPConfigPath(); + } + + private async _readMCPConfigFile(filePath: string): Promise> { + try { + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); + const config = JSON.parse(new TextDecoder().decode(content)); + return config.mcpServers || {}; + } catch { + return {}; + } + } + private async _loadMCPServers(): Promise { try { - const mcpConfigPath = this.getMCPConfigPath(); - if (!mcpConfigPath) { - this._postMessage({ type: 'mcpServers', data: {} }); - return; + const servers: Record = {}; + + // Read extension's private config + const extPath = this._getExtensionMCPConfigPath(); + if (extPath) { + const extServers = await this._readMCPConfigFile(extPath); + for (const [name, config] of Object.entries(extServers)) { + if (name === 'claude-code-chat-permissions') continue; + servers[name] = { ...config as any, _scope: 'extension' }; + } } - const mcpConfigUri = vscode.Uri.file(mcpConfigPath); - let mcpConfig: any = { mcpServers: {} }; + // Read project .mcp.json + const projectPath = this._getMCPConfigPathForScope('project'); + if (projectPath) { + const projectServers = await this._readMCPConfigFile(projectPath); + for (const [name, config] of Object.entries(projectServers)) { + if (!servers[name]) { + servers[name] = { ...config as any, _scope: 'project' }; + } + } + } - try { - const content = await vscode.workspace.fs.readFile(mcpConfigUri); - mcpConfig = JSON.parse(new TextDecoder().decode(content)); - } catch (error) { - console.log('MCP config file not found or error reading:', error); - // File doesn't exist, return empty servers + // Read global ~/.claude.json + const globalPath = this._getMCPConfigPathForScope('global'); + if (globalPath) { + const globalServers = await this._readMCPConfigFile(globalPath); + for (const [name, config] of Object.entries(globalServers)) { + if (!servers[name]) { + servers[name] = { ...config as any, _scope: 'global' }; + } + } } - // Filter out internal servers before sending to UI - const filteredServers = Object.fromEntries( - Object.entries(mcpConfig.mcpServers || {}).filter(([name]) => name !== 'claude-code-chat-permissions') - ); - this._postMessage({ type: 'mcpServers', data: filteredServers }); + this._postMessage({ type: 'mcpServers', data: servers }); } catch (error) { console.error('Error loading MCP servers:', error); this._postMessage({ type: 'mcpServerError', data: { error: 'Failed to load MCP servers' } }); } } - private async _saveMCPServer(name: string, config: any): Promise { + private async _saveMCPServer(name: string, config: any, scope: string): Promise { try { - const mcpConfigPath = this.getMCPConfigPath(); - if (!mcpConfigPath) { - this._postMessage({ type: 'mcpServerError', data: { error: 'Storage path not available' } }); + // Remove internal _scope field before saving + const cleanConfig = { ...config }; + delete cleanConfig._scope; + + const configPath = this._getMCPConfigPathForScope(scope); + if (!configPath) { + this._postMessage({ type: 'mcpServerError', data: { error: scope === 'project' ? 'No workspace folder open' : 'Config path not available' } }); return; } - const mcpConfigUri = vscode.Uri.file(mcpConfigPath); - let mcpConfig: any = { mcpServers: {} }; + // Ensure directory exists for extension scope + if (scope === 'extension') { + const dir = vscode.Uri.file(path.dirname(configPath)); + try { await vscode.workspace.fs.stat(dir); } catch { + await vscode.workspace.fs.createDirectory(dir); + } + } + + const configUri = vscode.Uri.file(configPath); + let fileConfig: any = {}; - // Load existing config try { - const content = await vscode.workspace.fs.readFile(mcpConfigUri); - mcpConfig = JSON.parse(new TextDecoder().decode(content)); + const content = await vscode.workspace.fs.readFile(configUri); + fileConfig = JSON.parse(new TextDecoder().decode(content)); } catch { - // File doesn't exist, use default structure + // File doesn't exist } - // Ensure mcpServers exists - if (!mcpConfig.mcpServers) { - mcpConfig.mcpServers = {}; + if (!fileConfig.mcpServers) { + fileConfig.mcpServers = {}; } - // Add/update the server - mcpConfig.mcpServers[name] = config; - - // Ensure directory exists - const mcpDir = vscode.Uri.file(path.dirname(mcpConfigPath)); - try { - await vscode.workspace.fs.stat(mcpDir); - } catch { - await vscode.workspace.fs.createDirectory(mcpDir); - } + fileConfig.mcpServers[name] = cleanConfig; - // Save the config - const configContent = new TextEncoder().encode(JSON.stringify(mcpConfig, null, 2)); - await vscode.workspace.fs.writeFile(mcpConfigUri, configContent); + const configContent = new TextEncoder().encode(JSON.stringify(fileConfig, null, 2)); + await vscode.workspace.fs.writeFile(configUri, configContent); this._postMessage({ type: 'mcpServerSaved', data: { name } }); - console.log(`Saved MCP server: ${name}`); } catch (error) { console.error('Error saving MCP server:', error); this._postMessage({ type: 'mcpServerError', data: { error: 'Failed to save MCP server' } }); } } - private async _deleteMCPServer(name: string): Promise { + private async _deleteMCPServer(name: string, scope: string): Promise { try { - const mcpConfigPath = this.getMCPConfigPath(); - if (!mcpConfigPath) { - this._postMessage({ type: 'mcpServerError', data: { error: 'Storage path not available' } }); + const configPath = this._getMCPConfigPathForScope(scope); + if (!configPath) { + this._postMessage({ type: 'mcpServerError', data: { error: 'Config path not available' } }); return; } - const mcpConfigUri = vscode.Uri.file(mcpConfigPath); - let mcpConfig: any = { mcpServers: {} }; + const configUri = vscode.Uri.file(configPath); + let fileConfig: any = {}; - // Load existing config try { - const content = await vscode.workspace.fs.readFile(mcpConfigUri); - mcpConfig = JSON.parse(new TextDecoder().decode(content)); + const content = await vscode.workspace.fs.readFile(configUri); + fileConfig = JSON.parse(new TextDecoder().decode(content)); } catch { - // File doesn't exist, nothing to delete - this._postMessage({ type: 'mcpServerError', data: { error: 'MCP config file not found' } }); + this._postMessage({ type: 'mcpServerError', data: { error: 'Config file not found' } }); return; } - // Delete the server - if (mcpConfig.mcpServers && mcpConfig.mcpServers[name]) { - delete mcpConfig.mcpServers[name]; - - // Save the updated config - const configContent = new TextEncoder().encode(JSON.stringify(mcpConfig, null, 2)); - await vscode.workspace.fs.writeFile(mcpConfigUri, configContent); - + if (fileConfig.mcpServers && fileConfig.mcpServers[name]) { + delete fileConfig.mcpServers[name]; + const configContent = new TextEncoder().encode(JSON.stringify(fileConfig, null, 2)); + await vscode.workspace.fs.writeFile(configUri, configContent); this._postMessage({ type: 'mcpServerDeleted', data: { name } }); - console.log(`Deleted MCP server: ${name}`); } else { this._postMessage({ type: 'mcpServerError', data: { error: `Server '${name}' not found` } }); } @@ -2096,7 +2875,6 @@ class ClaudeChatProvider { data: { snippet } }); - console.log('Saved custom snippet:', snippet.name); } catch (error) { console.error('Error saving custom snippet:', error); this._postMessage({ @@ -2119,7 +2897,6 @@ class ClaudeChatProvider { data: { snippetId } }); - console.log('Deleted custom snippet:', snippetId); } else { this._postMessage({ type: 'error', @@ -2147,13 +2924,6 @@ class ClaudeChatProvider { return windowsPath; } - public getMCPConfigPath(): string | undefined { - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) { return undefined; } - - const configPath = path.join(storagePath, 'mcp', 'mcp-servers.json'); - return path.join(configPath); - } private _sendAndSaveMessage(message: { type: string, data: any }): void { @@ -2234,7 +3004,6 @@ class ClaudeChatProvider { // Update conversation index this._updateConversationIndex(filename, conversationData); - console.log(`Saved conversation: ${filename}`, this._conversationsPath); } catch (error: any) { console.error('Failed to save conversation:', error.message); } @@ -2317,13 +3086,16 @@ class ClaudeChatProvider { }); if (result && result.length > 0) { - // Send the selected file paths back to webview - result.forEach(uri => { - this._postMessage({ - type: 'imagePath', - path: uri.fsPath - }); - }); + for (const uri of result) { + const dataUri = await this._getImageDataUri(uri.fsPath); + if (dataUri) { + this._postMessage({ + type: 'imageAttached', + filePath: uri.fsPath, + previewUri: dataUri + }); + } + } } } catch (error) { @@ -2380,7 +3152,6 @@ class ClaudeChatProvider { return; } - console.log(`Terminating Claude process group (PID: ${pid})...`); // 3. Kill process group (handles children) await this._killProcessGroup(pid, 'SIGTERM'); @@ -2402,15 +3173,12 @@ class ClaudeChatProvider { // 5. Force kill if still running if (processToKill && !processToKill.killed) { - console.log(`Force killing Claude process group (PID: ${pid})...`); await this._killProcessGroup(pid, 'SIGKILL'); } - console.log('Claude process group terminated'); } private async _stopClaudeProcess(): Promise { - console.log('Stop request received'); this._isProcessing = false; @@ -2431,6 +3199,9 @@ class ClaudeChatProvider { type: 'error', data: '⏹️ Claude code was stopped.' }); + + // Refresh OpenCredits balance (request may have consumed credits) + this._sendOpenCreditsBalance(); } private _updateConversationIndex(filename: string, conversationData: ConversationData): void { @@ -2471,12 +3242,10 @@ class ClaudeChatProvider { } private async _loadConversationHistory(filename: string): Promise { - console.log("_loadConversationHistory"); if (!this._conversationsPath) { return; } try { const filePath = path.join(this._conversationsPath, filename); - console.log("filePath", filePath); let conversationData: ConversationData; try { @@ -2530,6 +3299,13 @@ class ClaudeChatProvider { messageData = { ...message.data, status: 'expired' }; } + // For askUserQuestion loaded from history, expire pending ones if no active process + if (message.messageType === 'askUserQuestion' && + message.data?.status === 'pending' && + !this._currentClaudeProcess) { + messageData = { ...message.data, status: 'expired' }; + } + this._postMessage({ type: message.messageType, data: messageData @@ -2538,7 +3314,7 @@ class ClaudeChatProvider { try { requestStartTime = new Date(message.timestamp).getTime() } catch (e) { - console.log(e) + console.error('Failed to parse message timestamp:', e); } } } @@ -2575,14 +3351,13 @@ class ClaudeChatProvider { }, 50); }, 100); // Small delay to ensure webview is ready - console.log(`Loaded conversation history: ${filename}`); } catch (error: any) { console.error('Failed to load conversation history:', error.message); } } private _getHtmlForWebview(): string { - return getHtml(vscode.env?.isTelemetryEnabled); + return getHtml(vscode.env?.isTelemetryEnabled, OPENCREDITS_API_URL, OPENCREDITS_WEB_URL, OPENCREDITS_PUBLISHABLE_KEY, vscode.env?.appName, this._context?.extension?.packageJSON?.version); } private _sendCurrentSettings(): void { @@ -2591,9 +3366,16 @@ class ClaudeChatProvider { 'thinking.intensity': config.get('thinking.intensity', 'think'), 'wsl.enabled': config.get('wsl.enabled', false), 'wsl.distro': config.get('wsl.distro', 'Ubuntu'), - 'wsl.nodePath': config.get('wsl.nodePath', '/usr/bin/node'), + 'wsl.nodePath': config.get('wsl.nodePath', ''), 'wsl.claudePath': config.get('wsl.claudePath', '/usr/local/bin/claude'), - 'permissions.yoloMode': config.get('permissions.yoloMode', false) + 'permissions.yoloMode': config.get('permissions.yoloMode', false), + 'router.enabled': config.get('router.enabled', false), + 'executable.path': config.get('executable.path', ''), + 'environment.variables': config.get>('environment.variables', {}), + 'environment.disabled': config.get('environment.disabled', false), + 'isOpenCredits': this._isOpenCredits(), + 'notifications.windowsSound': config.get('notifications.windowsSound', false), + 'notifications.customSoundPath': config.get('notifications.customSoundPath', '') }; this._postMessage({ @@ -2610,7 +3392,6 @@ class ClaudeChatProvider { // Clear any global setting and set workspace setting await config.update('permissions.yoloMode', true, vscode.ConfigurationTarget.Workspace); - console.log('YOLO Mode enabled - all future permissions will be skipped'); // Send updated settings to UI this._sendCurrentSettings(); @@ -2624,6 +3405,98 @@ class ClaudeChatProvider { this._draftMessage = text || ''; } + private async _playWindowsNotificationSound(): Promise { + try { + // Only play sound on Windows platform + if (process.platform !== 'win32') { + return; + } + + // Check if Windows notification sound is enabled + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const soundEnabled = config.get('notifications.windowsSound', false); + + if (!soundEnabled) { + return; + } + + // Get custom sound path if specified + const customSoundPath = config.get('notifications.customSoundPath', '').trim(); + + if (customSoundPath) { + console.log('Attempting to play custom sound:', customSoundPath); + + // First check if file exists + try { + const checkFileCommand = `powershell.exe -Command "Test-Path '${customSoundPath}'"`; + const fileExists = await exec(checkFileCommand); + console.log('File exists check result:', fileExists.stdout.trim()); + + if (fileExists.stdout.trim() !== 'True') { + console.log('Custom sound file does not exist at path:', customSoundPath); + // Continue to default sound + } else { + console.log('Custom sound file exists, proceeding to play'); + } + } catch (checkError: any) { + console.log('File existence check failed:', checkError.message); + } + + // Treat custom sound as WAV only: use SoundPlayer as primary method and keep fallbacks + try { + const wavCommand = `powershell.exe -Command "(New-Object Media.SoundPlayer \\\"${customSoundPath}\\\").PlaySync()"`; + console.log('Playing WAV with SoundPlayer:', wavCommand); + await exec(wavCommand); + console.log('WAV played successfully with SoundPlayer'); + return; + } catch (customError: any) { + console.log('Primary WAV method failed:', customError.message); + + // Fallback: mshta.exe with HTML5 audio (may handle WAV files) + try { + const htmlAudioCommand = `mshta "javascript:var audio=new Audio('file:///${customSoundPath.replace(/\\/g, '/') }');audio.play();setTimeout(function(){window.close();},2000);void(0);"`; + console.log('Trying HTML5 audio method:', htmlAudioCommand); + await exec(htmlAudioCommand); + console.log('Custom WAV played with HTML5 audio successfully'); + return; + } catch (altError: any) { + console.log('HTML5 audio method failed:', altError.message); + + // Final fallback: PowerShell with System.Windows.Media.MediaPlayer + try { + const correctNamespaceCommand = `powershell.exe -Command "Add-Type -AssemblyName PresentationCore; $player = New-Object System.Windows.Media.MediaPlayer; $player.Open([System.Uri]'${customSoundPath}'); $player.Play(); Start-Sleep -Seconds 2"`; + console.log('Trying System.Windows.Media.MediaPlayer:', correctNamespaceCommand); + await exec(correctNamespaceCommand); + console.log('Custom sound played with System.Windows.Media.MediaPlayer'); + return; + } catch (finalError: any) { + console.log('All custom sound methods failed, falling back to default'); + // Continue to default sound as fallback + } + } + } + } + + // Try to play Windows Generic notification sound (default) + try { + const command = 'powershell.exe -Command "(New-Object Media.SoundPlayer \\"C:\\Windows\\Media\\Windows Notify System Generic.wav\\").PlaySync()"'; + await exec(command); + } catch (error1: any) { + // Fallback to alternative Windows notification sound + try { + const fallbackCommand = 'rundll32 user32.dll,MessageBeep 64'; + await exec(fallbackCommand); + } catch (error2: any) { + // Silent fail - don't interrupt user workflow + console.log('Windows notification sound failed to play'); + } + } + } catch (error: any) { + // Silent fail - don't interrupt user workflow + console.log('Windows notification sound error:', error.message); + } + } + private async _updateSettings(settings: { [key: string]: any }): Promise { const config = vscode.workspace.getConfiguration('claudeCodeChat'); @@ -2631,17 +3504,40 @@ class ClaudeChatProvider { for (const [key, value] of Object.entries(settings)) { if (key === 'permissions.yoloMode') { // YOLO mode is workspace-specific - await config.update(key, value, vscode.ConfigurationTarget.Workspace); + try { + await config.update(key, value, vscode.ConfigurationTarget.Workspace); + } catch { + await config.update(key, value, vscode.ConfigurationTarget.Global); + } + } else if (key === 'notifications.windowsSound' || key === 'notifications.customSoundPath') { + // Windows sound notification settings are workspace-specific + try { + await config.update(key, value, vscode.ConfigurationTarget.Workspace); + } catch { + await config.update(key, value, vscode.ConfigurationTarget.Global); + } } else { // Other settings are global (user-wide) await config.update(key, value, vscode.ConfigurationTarget.Global); } } - console.log('Settings updated:', settings); - } catch (error) { - console.error('Failed to update settings:', error); - vscode.window.showErrorMessage('Failed to update settings'); + // Re-send settings so webview gets updated isOpenCredits flag, etc. + this._sendCurrentSettings(); + + // Update balance display based on new env vars + if (this._isOpenCredits() || this._getOpenCreditsKey()) { + this._sendOpenCreditsBalance(); + } else { + // Clear balance if no longer OpenCredits + this._postMessage({ + type: 'opencreditsBalance', + balance: null + }); + } + } catch (error: any) { + console.error('Failed to update settings:', error?.message || error); + vscode.window.showErrorMessage(`Failed to update settings: ${error?.message || 'Unknown error'}`); } } @@ -2657,31 +3553,159 @@ class ClaudeChatProvider { } } - private _setSelectedModel(model: string): void { - // Validate model name to prevent issues mentioned in the GitHub issue - const validModels = ['opus', 'sonnet', 'default']; - if (validModels.includes(model)) { + private async _setSelectedModel(model: string, tierModels?: { sonnet: string; opus: string; haiku: string }): Promise { + // Valid Claude models + const validClaudeModels = ['opus', 'sonnet', 'default']; + + if (validClaudeModels.includes(model)) { this._selectedModel = model; - console.log('Model selected:', model); // Store the model preference in workspace state this._context.workspaceState.update('claude.selectedModel', model); + // Remove model env vars so Claude CLI uses defaults + await this._removeModelEnvVars(); + + // Refresh settings UI to reflect removed env vars + this._sendCurrentSettings(); + // Show confirmation - vscode.window.showInformationMessage(`Claude model switched to: ${model.charAt(0).toUpperCase() + model.slice(1)}`); + vscode.window.showInformationMessage(`Model switched to: ${model.charAt(0).toUpperCase() + model.slice(1)}`); } else { - console.error('Invalid model selected:', model); - vscode.window.showErrorMessage(`Invalid model: ${model}. Please select Opus, Sonnet, or Default.`); + // Any other model is treated as a OpenCredits model + this._selectedModel = model; + + // Store the model preference in workspace state + this._context.workspaceState.update('claude.selectedModel', model); + + // Set model env vars so Claude CLI routes to this model + await this._setModelEnvVars(model, tierModels); + + // Notify webview that model is switching + this._postMessage({ + type: 'modelSwitching', + model: model + }); + + // Update the local router config to use this model + this._updateLocalRouterModel(model, tierModels); + + // Fetch and send balance + await this._sendOpenCreditsBalance(); + + // Notify webview that model switch is complete + this._postMessage({ + type: 'modelSwitched', + model: model + }); + + // Show confirmation + vscode.window.showInformationMessage(`Model switched to: ${model}`); } } - private _openModelTerminal(): void { + // Set model env vars for non-Claude models + private async _setModelEnvVars(model: string, tierModels?: { sonnet: string; opus: string; haiku: string }): Promise { const config = vscode.workspace.getConfiguration('claudeCodeChat'); - const wslEnabled = config.get('wsl.enabled', false); - const wslDistro = config.get('wsl.distro', 'Ubuntu'); - const nodePath = config.get('wsl.nodePath', '/usr/bin/node'); - const claudePath = config.get('wsl.claudePath', '/usr/local/bin/claude'); + const envVars = config.get>('environment.variables', {}); + envVars['ANTHROPIC_DEFAULT_SONNET_MODEL'] = tierModels?.sonnet || model; + envVars['ANTHROPIC_DEFAULT_OPUS_MODEL'] = tierModels?.opus || model; + envVars['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = tierModels?.haiku || model; + await config.update('environment.variables', envVars, vscode.ConfigurationTarget.Global); + } + + // Remove model env vars so Claude CLI uses defaults + private async _removeModelEnvVars(): Promise { + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const envVars = config.get>('environment.variables', {}); + const filtered: Record = {}; + for (const [key, value] of Object.entries(envVars)) { + if (key !== 'ANTHROPIC_DEFAULT_SONNET_MODEL' && + key !== 'ANTHROPIC_DEFAULT_OPUS_MODEL' && + key !== 'ANTHROPIC_DEFAULT_HAIKU_MODEL') { + filtered[key] = value; + } + } + await config.update('environment.variables', filtered, vscode.ConfigurationTarget.Global); + } + + // Fetch OpenCredits account balance + private async _fetchOpenCreditsBalance(): Promise { + const userKey = this._getOpenCreditsKey(); + + if (!userKey) { + return null; + } + + try { + const response = await fetch(OPENCREDITS_API_URL + '/v1/credits/balance', { + method: 'GET', + headers: { + 'X-User-Key': userKey, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + console.error('Failed to fetch OpenCredits balance:', response.status); + return null; + } + + const data = await response.json() as { balance?: number }; + return data.balance != null ? data.balance : null; + } catch (error) { + console.error('Error fetching OpenCredits balance:', error); + return null; + } + } + + private async _sendOpenCreditsBalance(): Promise { + const balance = await this._fetchOpenCreditsBalance(); + this._postMessage({ + type: 'opencreditsBalance', + balance: balance + }); + } + + private async _openOpenCreditsAccount(): Promise { + const url = OPENCREDITS_WEB_URL + '/dashboard'; + + // Open via native OS command + const openCmd = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'start' + : 'xdg-open'; + cp.spawn(openCmd, [url], { detached: true, stdio: 'ignore' }).unref(); + + // Show fallback modal in webview in case native open didn't work + this._postMessage({ + type: 'openedExternalUrl', + url: url + }); + } + + // Update the model configuration for the local router + private _updateLocalRouterModel(model: string, tierModels?: { sonnet: string; opus: string; haiku: string }): void { + setModelConfig({ + haikuModel: tierModels?.haiku || model, + sonnetModel: tierModels?.sonnet || model, + opusModel: tierModels?.opus || model + }); + } + + private _quoteBashArg(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; + } + private _buildWslClaudeCommand(nodePath: string, claudePath: string, args: string[] = []): string { + const trimmedNodePath = nodePath.trim(); + const commandParts = trimmedNodePath + ? [this._quoteBashArg(trimmedNodePath), '--no-warnings', '--enable-source-maps', this._quoteBashArg(claudePath)] + : [this._quoteBashArg(claudePath)]; + const quotedArgs = args.map(arg => this._quoteBashArg(arg)); + return [...commandParts, ...quotedArgs].join(' '); + } + + private _openModelTerminal(): void { // Build command arguments const args = ['/model']; @@ -2690,16 +3714,12 @@ class ClaudeChatProvider { args.push('--resume', this._currentSessionId); } - // Create terminal with the claude /model command + // Launch claude as the terminal process directly β€” no shell quoting const terminal = vscode.window.createTerminal({ name: 'Claude Model Selection', - location: { viewColumn: vscode.ViewColumn.One } + location: { viewColumn: vscode.ViewColumn.One }, + ...this._buildClaudeTerminalOptions(args) }); - if (wslEnabled) { - terminal.sendText(`wsl -d ${wslDistro} ${nodePath} --no-warnings --enable-source-maps ${claudePath} ${args.join(' ')}`); - } else { - terminal.sendText(`claude ${args.join(' ')}`); - } terminal.show(); // Show info message @@ -2715,75 +3735,140 @@ class ClaudeChatProvider { }); } - private _openUsageTerminal(usageType: string): void { - // Get WSL configuration - const config = vscode.workspace.getConfiguration('claudeCodeChat'); - const wslEnabled = config.get('wsl.enabled', false); - const wslDistro = config.get('wsl.distro', 'Ubuntu'); - + private _openUsageTerminal(_usageType: string): void { const terminal = vscode.window.createTerminal({ name: 'Claude Usage', - location: { viewColumn: vscode.ViewColumn.One } + location: { viewColumn: vscode.ViewColumn.One }, + ...this._buildClaudeTerminalOptions(['/usage']) }); + terminal.show(); + } - let command: string; - if (usageType === 'plan') { - // Plan users get live usage view - command = 'npx -y ccusage blocks --live'; - } else { - // API users get recent usage history - command = 'npx -y ccusage blocks --recent --order desc'; - } + private async _runInstallCommand(method: string = 'installer'): Promise { + this._context.globalState.update('installAttempted', true); + + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const wslEnabled = config.get('wsl.enabled', false); + // WSL install needs to run inside the distro, not on the Windows host. + // The old shell-based flow didn't handle this either β€” not regressing, + // not fixing here. User should install claude inside their distro manually + // and set claudeCodeChat.wsl.claudePath. if (wslEnabled) { - terminal.sendText(`wsl -d ${wslDistro} bash -ic "${command}"`); - } else { - terminal.sendText(command); + this._postMessage({ + type: 'installComplete', + success: false, + method, + error: 'WSL mode: please install Claude inside your WSL distro, then set claudeCodeChat.wsl.claudePath.', + errorCode: 'UNSUPPORTED_PLATFORM' + }); + return; } - terminal.show(); - } + if (!detectPlatform()) { + this._postMessage({ + type: 'installComplete', + success: false, + method, + error: `Unsupported platform: ${process.platform}/${os.arch()}. Install Claude manually from https://code.claude.com.`, + errorCode: 'UNSUPPORTED_PLATFORM' + }); + return; + } - private _runInstallCommand(): void { - const { exec } = require('child_process'); + const destDir = path.join(this._context.globalStorageUri.fsPath, 'bin'); - // Check if npm exists and node >= 18 - exec('node --version', { shell: true }, (nodeErr: Error | null, nodeStdout: string) => { - let useNpm = false; + try { + const result = await downloadClaude({ + destDir, + onProgress: (p) => this._postMessage({ type: 'installProgress', ...p }) + }); - if (!nodeErr && nodeStdout) { - // Parse version (e.g., "v18.17.0" -> 18) - const match = nodeStdout.trim().match(/^v(\d+)/); - if (match && parseInt(match[1], 10) >= 18) { - useNpm = true; + const existing = (config.get('executable.path', '') || '').trim(); + if (!existing) { + try { + await config.update('executable.path', result.binaryPath, vscode.ConfigurationTarget.Global); + } catch { + // fall through β€” UI will still reflect success and the spawn will find it on next launch } } - let command: string; - if (useNpm) { - command = 'npm install -g @anthropic-ai/claude-code'; - } else if (process.platform === 'win32') { - command = 'irm https://claude.ai/install.ps1 | iex'; - } else { - command = 'curl -fsSL https://claude.ai/install.sh | sh'; - } - - // Run installation silently in the background - exec(command, { shell: true }, (error: Error | null, stdout: string, stderr: string) => { - if (error) { - this._postMessage({ - type: 'installComplete', - success: false, - error: stderr || error.message - }); - } else { - this._postMessage({ - type: 'installComplete', - success: true - }); - } + this._postMessage({ + type: 'installComplete', + success: true, + method, + configuredPath: existing ? undefined : result.binaryPath, + existingPathRespected: !!existing, + source: result.source, + version: result.version }); + } catch (err) { + const d = err instanceof DownloaderError ? err : null; + const details = d?.details; + this._postMessage({ + type: 'installComplete', + success: false, + method, + error: d?.message || 'Installation failed. Please try again.', + errorCode: d?.code, + // AGGREGATE errors carry the per-source failure codes; surface them so + // analytics can bucket "both npm+cdn failed with NETWORK" vs + // "npm INTEGRITY, cdn NETWORK" etc. + npmCode: typeof details?.npmCode === 'string' ? details.npmCode : undefined, + cdnCode: typeof details?.cdnCode === 'string' ? details.cdnCode : undefined + }); + } + } + + private _buildClaudeTerminalOptions(args: string[] = []): { shellPath: string; shellArgs: string[] } { + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const wslEnabled = config.get('wsl.enabled', false); + + if (wslEnabled) { + const wslDistro = config.get('wsl.distro', 'Ubuntu'); + const nodePath = config.get('wsl.nodePath', ''); + const claudePath = config.get('wsl.claudePath', '/usr/local/bin/claude'); + const wslCommand = this._buildWslClaudeCommand(nodePath, claudePath, args); + return { + shellPath: process.platform === 'win32' ? 'wsl.exe' : 'wsl', + shellArgs: ['-d', wslDistro, 'bash', '-ic', wslCommand] + }; + } + + const custom = (config.get('executable.path', '') || '').trim(); + return { + shellPath: custom || 'claude', + shellArgs: args + }; + } + + private _openLoginTerminal(): void { + const terminal = vscode.window.createTerminal({ + name: 'Claude Login', + location: { viewColumn: vscode.ViewColumn.One }, + ...this._buildClaudeTerminalOptions() }); + terminal.show(); + } + + // Start the local router and return its port + private async _ensureLocalRouter(): Promise { + // Update model config with the selected model, restoring tier models from persisted env vars + if (this._selectedModel && this._selectedModel !== 'default') { + const config = vscode.workspace.getConfiguration('claudeCodeChat'); + const envVars = config.get>('environment.variables', {}); + const sonnet = envVars['ANTHROPIC_DEFAULT_SONNET_MODEL']; + const opus = envVars['ANTHROPIC_DEFAULT_OPUS_MODEL']; + const haiku = envVars['ANTHROPIC_DEFAULT_HAIKU_MODEL']; + const tierModels = (sonnet || opus || haiku) + ? { sonnet: sonnet || this._selectedModel, opus: opus || this._selectedModel, haiku: haiku || this._selectedModel } + : undefined; + this._updateLocalRouterModel(this._selectedModel, tierModels); + } + + // Start the router if not already running + const port = await startRouter(); + return port; } private _executeSlashCommand(command: string): void { @@ -2793,12 +3878,6 @@ class ClaudeChatProvider { return; } - const config = vscode.workspace.getConfiguration('claudeCodeChat'); - const wslEnabled = config.get('wsl.enabled', false); - const wslDistro = config.get('wsl.distro', 'Ubuntu'); - const nodePath = config.get('wsl.nodePath', '/usr/bin/node'); - const claudePath = config.get('wsl.claudePath', '/usr/local/bin/claude'); - // Build command arguments const args = [`/${command}`]; @@ -2807,16 +3886,12 @@ class ClaudeChatProvider { args.push('--resume', this._currentSessionId); } - // Create terminal with the claude command + // Launch claude as the terminal process directly β€” no shell quoting const terminal = vscode.window.createTerminal({ name: `Claude /${command}`, - location: { viewColumn: vscode.ViewColumn.One } + location: { viewColumn: vscode.ViewColumn.One }, + ...this._buildClaudeTerminalOptions(args) }); - if (wslEnabled) { - terminal.sendText(`wsl -d ${wslDistro} ${nodePath} --no-warnings --enable-source-maps ${claudePath} ${args.join(' ')}`); - } else { - terminal.sendText(`claude ${args.join(' ')}`); - } terminal.show(); // Show info message @@ -3003,12 +4078,11 @@ class ClaudeChatProvider { const imagePath = vscode.Uri.joinPath(imagesDir, imageFileName); await vscode.workspace.fs.writeFile(imagePath, buffer); - // Send the file path back to webview + // Send the file path back to webview β€” use the original data URL for preview this._postMessage({ - type: 'imagePath', - data: { - filePath: imagePath.fsPath - } + type: 'imageAttached', + filePath: imagePath.fsPath, + previewUri: imageData }); } catch (error) { @@ -3017,6 +4091,74 @@ class ClaudeChatProvider { } } + public async addFilesToChat(uris: vscode.Uri[]): Promise { + console.log('addFilesToChat called with', uris.length, 'URIs'); + + // Ensure chat panel is open + this.show(); + + // Wait for panel to be ready + await new Promise(resolve => setTimeout(resolve, 100)); + + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage('No workspace folder open'); + return; + } + + const fileReferences: string[] = []; + const errors: string[] = []; + + for (const uri of uris) { + try { + // Check if path is within workspace + const relativePath = vscode.workspace.asRelativePath(uri, false); + + // If relativePath is still absolute, it's outside workspace + if (path.isAbsolute(relativePath)) { + errors.push(`${path.basename(uri.fsPath)} is outside workspace`); + continue; + } + + // Check if path exists + try { + await vscode.workspace.fs.stat(uri); + // Add @ prefix for file reference + fileReferences.push(`@${relativePath}`); + } catch (statError) { + errors.push(`${path.basename(uri.fsPath)} does not exist`); + continue; + } + + } catch (error) { + errors.push(`Failed to process ${path.basename(uri.fsPath)}`); + console.error('Error processing path:', error); + } + } + + // Show errors if any + if (errors.length > 0) { + vscode.window.showWarningMessage( + `Some items could not be added: ${errors.join(', ')}` + ); + } + + // Send file references to webview to add to textarea + if (fileReferences.length > 0) { + console.log('Sending file references to webview:', fileReferences); + this._postMessage({ + type: 'addFilesToInput', + files: fileReferences + }); + } + + } catch (error) { + console.error('Error in addFilesToChat:', error); + vscode.window.showErrorMessage('Failed to add files to chat'); + } + } + public dispose() { if (this._panel) { this._panel.dispose(); @@ -3036,4 +4178,4 @@ class ClaudeChatProvider { } } } -} \ No newline at end of file +} diff --git a/src/model-updater.ts b/src/model-updater.ts new file mode 100644 index 0000000..bbcddef --- /dev/null +++ b/src/model-updater.ts @@ -0,0 +1,148 @@ +interface ApiModel { + id: string; + name?: string; + description?: string; + pricing?: { prompt: number; completion: number; currency?: string; unit?: string }; + context_length?: number; + max_output_tokens?: number; + [key: string]: any; +} + +interface BundledModel { + id: string; + name: string; + description?: string; + provider: string; + quickLabel?: string; + context_length?: number; + max_output_tokens?: number; + tierModels?: { sonnet: string; opus: string; haiku: string }; + [key: string]: any; +} + +interface ProviderResolver { + main: RegExp; + opus?: RegExp; + haiku?: RegExp; +} + +function parseVersion(ver: string): number[] { + return ver.split('.').map(Number); +} + +function compareVersions(a: string, b: string): number { + const va = parseVersion(a); + const vb = parseVersion(b); + for (let i = 0; i < Math.max(va.length, vb.length); i++) { + const na = va[i] || 0; + const nb = vb[i] || 0; + if (na !== nb) { return na - nb; } + } + return 0; +} + +function findHighestMatch(apiModels: ApiModel[], regex: RegExp): ApiModel | null { + let best: ApiModel | null = null; + let bestVer: string | null = null; + for (const m of apiModels) { + const match = regex.exec(m.id); + if (match) { + const ver = match[1] || '0'; + if (!bestVer || compareVersions(ver, bestVer) > 0) { + bestVer = ver; + best = m; + } + } + } + return best; +} + +const providerResolvers: Record = { + 'zai/glm-': { + main: /^zai\/glm-(\d+(?:\.\d+)?)$/, + haiku: /^zai\/GLM-([\d.]+)-(?:Air|Flash)$/i + }, + 'openai/gpt-': { + main: /^openai\/gpt-([\d.]+)-codex$/, + haiku: /^openai\/gpt-([\d.]+)-codex-mini$/ + }, + 'gemini-': { + main: /^(?:google\/)?gemini-([\d.]+)-pro-preview$/, + opus: /^(?:google\/)?gemini-([\d.]+)-pro-preview-thinking$/, + haiku: /^(?:google\/)?gemini-([\d.]+)-flash(?:-preview)?$/ + }, + 'deepseek/deepseek-': { + main: /^deepseek\/deepseek-v([\d.]+)[-:]thinking$/ + }, + 'minimax/minimax-': { + main: /^minimax\/minimax-m([\d.]+)$/ + }, + 'moonshotai/kimi-': { + main: /^moonshotai\/kimi-k([\d.]+)$/, + haiku: /^moonshotai\/kimi-k([\d.]+)-turbo$/ + } +}; + +export function resolveLatestModels(apiModels: ApiModel[], bundledModels: BundledModel[]): BundledModel[] { + return bundledModels.map(bundled => { + const b: BundledModel = JSON.parse(JSON.stringify(bundled)); + + let resolver: ProviderResolver | null = null; + for (const prefix of Object.keys(providerResolvers)) { + if (b.id.toLowerCase().startsWith(prefix)) { + resolver = providerResolvers[prefix]; + break; + } + } + if (!resolver) { return b; } + + // Resolve main (sonnet-tier) model + const mainMatch = findHighestMatch(apiModels, resolver.main); + if (mainMatch) { + b.id = mainMatch.id; + b.name = mainMatch.name || b.name; + b.description = mainMatch.description || b.description; + b.context_length = mainMatch.context_length || b.context_length; + b.max_output_tokens = mainMatch.max_output_tokens || b.max_output_tokens; + if (b.tierModels) { + b.tierModels.sonnet = mainMatch.id; + if (!resolver.opus) { + b.tierModels.opus = mainMatch.id; + } + } + } + + // Resolve opus-tier model (e.g. Gemini thinking variant) + if (resolver.opus && b.tierModels) { + const opusMatch = findHighestMatch(apiModels, resolver.opus); + if (opusMatch) { + b.tierModels.opus = opusMatch.id; + } + } + + // Resolve haiku-tier model + if (resolver.haiku && b.tierModels) { + const haikuMatch = findHighestMatch(apiModels, resolver.haiku); + if (haikuMatch) { + b.tierModels.haiku = haikuMatch.id; + } + } + + return b; + }); +} + +export async function fetchAndResolveModels(bundledModels: BundledModel[], apiBaseUrl: string = 'https://ccc.api.opencredits.ai'): Promise { + try { + const response = await fetch(apiBaseUrl + '/v1/models'); + const data: any = await response.json(); + const apiModels: ApiModel[] = data.data || data; + if (!Array.isArray(apiModels) || apiModels.length === 0) { + return null; + } + return resolveLatestModels(apiModels, bundledModels); + } catch (e) { + console.log('Auto-update models failed:', e); + return null; + } +} diff --git a/src/plugins-script.ts b/src/plugins-script.ts new file mode 100644 index 0000000..e73cc49 --- /dev/null +++ b/src/plugins-script.ts @@ -0,0 +1,153 @@ +const getPluginsScript = () => ` + // ─── Plugins ─── + var topPlugins = (window.__topPlugins || []); + var pluginsDisplayedList = null; + + function formatPluginName(name) { + return name.replace(/-/g, ' ').replace(/\\b\\w/g, function(c) { return c.toUpperCase(); }); + } + + function showPluginsModal() { + sendStats('Plugins modal opened'); + document.getElementById('pluginsModal').style.display = 'flex'; + loadInstalledPlugins(); + renderAvailablePlugins(topPlugins); + } + + function hidePluginsModal() { + document.getElementById('pluginsModal').style.display = 'none'; + } + + function loadInstalledPlugins() { + vscode.postMessage({ type: 'loadPlugins' }); + } + + function displayPlugins(data) { + var pluginsList = document.getElementById('pluginsList'); + pluginsList.innerHTML = ''; + var enabled = data.enabled || {}; + + var keys = Object.keys(enabled); + if (keys.length === 0) { + pluginsList.innerHTML = '
' + + '
' + + '
No plugins enabled
' + + '
'; + return; + } + + keys.forEach(function(installId) { + var isEnabled = enabled[installId]; + var name = installId.replace(/@.*$/, ''); + var displayName = formatPluginName(name); + var plugin = topPlugins.find(function(p) { return p.installId === installId; }); + var desc = plugin ? plugin.description : ''; + var verified = plugin ? plugin.verified : false; + + var item = document.createElement('div'); + item.className = 'mcp-server-item'; + var verifiedHtml = verified ? '' : ''; + var statusHtml = isEnabled ? 'enabled' : 'disabled'; + item.innerHTML = '
' + + '
' + escapeHtml(displayName) + verifiedHtml + ' ' + statusHtml + '
' + + (desc ? '
' + escapeHtml(desc) + '
' : '') + + '
' + + '
' + + '' + + '
'; + pluginsList.appendChild(item); + }); + } + + function renderAvailablePlugins(plugins) { + var grid = document.getElementById('pluginsGrid'); + if (!grid) return; + if (!plugins || plugins.length === 0) { + grid.innerHTML = '
No plugins found.
'; + return; + } + var html = ''; + plugins.forEach(function(plugin) { + var name = plugin.name || 'Unknown'; + var displayName = formatPluginName(name); + var desc = escapeHtml(plugin.description || 'No description'); + var verified = plugin.verified; + var safeId = escapeHtml(plugin.installId || name).replace(/'/g, '''); + + html += '
' + + '
' + + '
' + escapeHtml(displayName.charAt(0).toUpperCase()) + '
' + + '
' + + '
' + escapeHtml(displayName) + '
' + + '
' + + '
' + + '
' + desc + '
' + + '
'; + }); + grid.innerHTML = html; + } + + function searchPlugins(query) { + if (!query) { + renderAvailablePlugins(topPlugins); + return; + } + var q = query.toLowerCase(); + var filtered = topPlugins.filter(function(p) { + return (p.name && p.name.toLowerCase().indexOf(q) >= 0) || + (p.description && p.description.toLowerCase().indexOf(q) >= 0); + }); + renderAvailablePlugins(filtered); + } + + function showPluginDetail(installId) { + var plugin = topPlugins.find(function(p) { return p.installId === installId; }); + if (!plugin) return; + + var name = plugin.name || 'Unknown'; + var displayName = formatPluginName(name); + var desc = plugin.description || 'No description available.'; + var verified = plugin.verified; + var verifiedHtml = verified ? '✓ Anthropic verified' : ''; + + var grid = document.getElementById('pluginsGrid'); + pluginsDisplayedList = grid.innerHTML; + + grid.innerHTML = '
' + + '' + + '
' + + '
' + escapeHtml(displayName.charAt(0).toUpperCase()) + '
' + + '
' + + '
' + escapeHtml(displayName) + '
' + + '
' + verifiedHtml + '
' + + '
' + + '' + + '
' + + '
' + escapeHtml(desc) + '
' + + '' + + '
Adds ' + escapeHtml(installId) + ' to .claude/settings.json
' + + '
'; + } + + function backToPluginsList() { + var grid = document.getElementById('pluginsGrid'); + if (pluginsDisplayedList) { + grid.innerHTML = pluginsDisplayedList; + } else { + renderAvailablePlugins(topPlugins); + } + } + + function installPlugin(installId) { + sendStats('Plugin installed', { plugin: installId }); + vscode.postMessage({ type: 'installPlugin', installId: installId }); + hidePluginsModal(); + } + + function removePlugin(installId) { + sendStats('Plugin removed', { plugin: installId }); + vscode.postMessage({ type: 'removePlugin', installId: installId }); + } +`; + +export default getPluginsScript; diff --git a/src/plugins-ui.ts b/src/plugins-ui.ts new file mode 100644 index 0000000..c80c0b6 --- /dev/null +++ b/src/plugins-ui.ts @@ -0,0 +1,26 @@ +const getPluginsHtml = () => ` + + +`; + +export default getPluginsHtml; diff --git a/src/recommended-models.json b/src/recommended-models.json new file mode 100644 index 0000000..716280a --- /dev/null +++ b/src/recommended-models.json @@ -0,0 +1,66 @@ +[ + { + "id": "openai/gpt-5.3-codex", + "name": "GPT 5.3 Codex", + "description": "Coding-focused GPT-5.3 variant with optimized routing.", + "context_length": 400000, + "max_output_tokens": 128000, + "credits_per_request": 4.921875, + "provider": "OpenAI", + "quickLabel": "GPT", + "tierModels": { "sonnet": "openai/gpt-5.3-codex", "opus": "openai/gpt-5.3-codex", "haiku": "openai/gpt-5.1-codex-mini" } + }, + { + "id": "google/gemini-3.1-pro-preview", + "name": "Gemini 3.1 Pro Preview", + "description": "Google's Gemini 3.1 Pro with enhanced reasoning and multimodal support.", + "context_length": 1000000, + "max_output_tokens": 64000, + "credits_per_request": 4.375, + "provider": "Google", + "quickLabel": "Gemini", + "tierModels": { "sonnet": "google/gemini-3.1-pro-preview", "opus": "google/gemini-3.1-pro-preview", "haiku": "google/gemini-3-flash" } + }, + { + "id": "minimax/minimax-m2.7", + "name": "Minimax M2.7", + "description": "MiniMax M2.7 with enhanced context understanding and improved complex tool use. Optimized for agentic workflows and long-horizon tasks.", + "context_length": 204800, + "max_output_tokens": 131000, + "credits_per_request": 0.46875, + "provider": "MiniMax", + "quickLabel": "MiniMax" + }, + { + "id": "moonshotai/kimi-k2.5", + "name": "Kimi K2.5", + "description": "Kimi K2.5 is Moonshot AI's native multimodal model with strong general reasoning, visual coding, and agentic tool-calling.", + "context_length": 262114, + "max_output_tokens": 262114, + "credits_per_request": 1.125, + "provider": "Moonshot AI", + "quickLabel": "Kimi", + "tierModels": { "sonnet": "moonshotai/kimi-k2.5", "opus": "moonshotai/kimi-k2.5", "haiku": "moonshotai/kimi-k2-turbo" } + }, + { + "id": "zai/glm-5", + "name": "GLM 5", + "description": "GLM-5 is the latest GLM series text model with stronger reasoning, long-context chat, and reliable tool use.", + "context_length": 202800, + "max_output_tokens": 131100, + "credits_per_request": 1.3125, + "provider": "Zhipu AI", + "quickLabel": "GLM", + "tierModels": { "sonnet": "zai/glm-5", "opus": "zai/glm-5", "haiku": "zai/glm-4.7-flash" } + }, + { + "id": "deepseek/deepseek-v3.2-thinking", + "name": "DeepSeek V3.2 Thinking", + "description": "DeepSeek V3.2 thinking/reasoner mode. Reasoning-first model built for agents. First DeepSeek model with thinking-in-tool-use capability.", + "context_length": 128000, + "max_output_tokens": 64000, + "credits_per_request": 0.21875, + "provider": "DeepSeek", + "quickLabel": "DeepSeek" + } +] diff --git a/src/router/formatRequest.ts b/src/router/formatRequest.ts new file mode 100644 index 0000000..f584edd --- /dev/null +++ b/src/router/formatRequest.ts @@ -0,0 +1,265 @@ +interface MessageCreateParamsBase { + model: string; + messages: any[]; + system?: any; + temperature?: number; + tools?: any[]; + stream?: boolean; +} + + +/** + * Validates OpenAI format messages to ensure complete tool_calls/tool message pairing. + * Requires tool messages to immediately follow assistant messages with tool_calls. + * Enforces strict immediate following sequence between tool_calls and tool messages. + */ +function validateOpenAIToolCalls(messages: any[]): any[] { + const validatedMessages: any[] = []; + + for (let i = 0; i < messages.length; i++) { + const currentMessage = { ...messages[i] }; + + // Process assistant messages with tool_calls + if (currentMessage.role === "assistant" && currentMessage.tool_calls) { + const validToolCalls: any[] = []; + const removedToolCallIds: string[] = []; + + // Collect all immediately following tool messages + const immediateToolMessages: any[] = []; + let j = i + 1; + while (j < messages.length && messages[j].role === "tool") { + immediateToolMessages.push(messages[j]); + j++; + } + + // For each tool_call, check if there's an immediately following tool message + currentMessage.tool_calls.forEach((toolCall: any) => { + const hasImmediateToolMessage = immediateToolMessages.some(toolMsg => + toolMsg.tool_call_id === toolCall.id + ); + + if (hasImmediateToolMessage) { + validToolCalls.push(toolCall); + } else { + removedToolCallIds.push(toolCall.id); + } + }); + + // Update the assistant message + if (validToolCalls.length > 0) { + currentMessage.tool_calls = validToolCalls; + } else { + delete currentMessage.tool_calls; + } + + + // Only include message if it has content or valid tool_calls + if (currentMessage.content || currentMessage.tool_calls) { + validatedMessages.push(currentMessage); + } + } + + // Process tool messages + else if (currentMessage.role === "tool") { + let hasImmediateToolCall = false; + + // Check if the immediately preceding assistant message has matching tool_call + if (i > 0) { + const prevMessage = messages[i - 1]; + if (prevMessage.role === "assistant" && prevMessage.tool_calls) { + hasImmediateToolCall = prevMessage.tool_calls.some((toolCall: any) => + toolCall.id === currentMessage.tool_call_id + ); + } else if (prevMessage.role === "tool") { + // Check for assistant message before the sequence of tool messages + for (let k = i - 1; k >= 0; k--) { + if (messages[k].role === "tool") continue; + if (messages[k].role === "assistant" && messages[k].tool_calls) { + hasImmediateToolCall = messages[k].tool_calls.some((toolCall: any) => + toolCall.id === currentMessage.tool_call_id + ); + } + break; + } + } + } + + if (hasImmediateToolCall) { + validatedMessages.push(currentMessage); + } + } + + // For all other message types, include as-is + else { + validatedMessages.push(currentMessage); + } + } + + return validatedMessages; +} + +// Model configuration - set from extension +interface ModelConfig { + haikuModel: string; + sonnetModel: string; + opusModel: string; +} + +let modelConfig: ModelConfig | null = null; + +export function setModelConfig(config: ModelConfig): void { + modelConfig = config; + console.log('[Router] Model config updated:', config); +} + +export function mapModel(anthropicModel: string): string { + console.log('[Router] Mapping model:', anthropicModel); + + // If model already contains '/', it's already a provider model ID - return as-is + if (anthropicModel.includes('/')) { + console.log(`[Router] Model already has provider prefix, passing through: ${anthropicModel}`); + return anthropicModel; + } + + if (!modelConfig) { + console.log('[Router] No model config set, returning as-is'); + return anthropicModel; + } + + if (anthropicModel.includes('haiku') && modelConfig.haikuModel) { + console.log(`[Router] Mapping haiku -> ${modelConfig.haikuModel}`); + return modelConfig.haikuModel; + } else if (anthropicModel.includes('sonnet') && modelConfig.sonnetModel) { + console.log(`[Router] Mapping sonnet -> ${modelConfig.sonnetModel}`); + return modelConfig.sonnetModel; + } else if (anthropicModel.includes('opus') && modelConfig.opusModel) { + console.log(`[Router] Mapping opus -> ${modelConfig.opusModel}`); + return modelConfig.opusModel; + } + + console.log(`[Router] No mapping found for model: ${anthropicModel}, passing through`); + return anthropicModel; +} + +export function formatAnthropicToOpenAI(body: MessageCreateParamsBase): any { + const { model, messages, system = [], temperature, tools, stream } = body; + + const openAIMessages = Array.isArray(messages) + ? messages.flatMap((anthropicMessage) => { + const openAiMessagesFromThisAnthropicMessage: any[] = []; + + if (!Array.isArray(anthropicMessage.content)) { + if (typeof anthropicMessage.content === "string") { + openAiMessagesFromThisAnthropicMessage.push({ + role: anthropicMessage.role, + content: anthropicMessage.content, + }); + } + return openAiMessagesFromThisAnthropicMessage; + } + + if (anthropicMessage.role === "assistant") { + const assistantMessage: any = { + role: "assistant", + content: null, + }; + let textContent = ""; + const toolCalls: any[] = []; + + anthropicMessage.content.forEach((contentPart: any) => { + if (contentPart.type === "text") { + textContent += (typeof contentPart.text === "string" + ? contentPart.text + : JSON.stringify(contentPart.text)) + "\n"; + } else if (contentPart.type === "tool_use") { + toolCalls.push({ + id: contentPart.id, + type: "function", + function: { + name: contentPart.name, + arguments: JSON.stringify(contentPart.input), + }, + }); + } + }); + + const trimmedTextContent = textContent.trim(); + if (trimmedTextContent.length > 0) { + assistantMessage.content = trimmedTextContent; + } + if (toolCalls.length > 0) { + assistantMessage.tool_calls = toolCalls; + } + if (assistantMessage.content || (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0)) { + openAiMessagesFromThisAnthropicMessage.push(assistantMessage); + } + } else if (anthropicMessage.role === "user") { + let userTextMessageContent = ""; + const subsequentToolMessages: any[] = []; + + anthropicMessage.content.forEach((contentPart: any) => { + if (contentPart.type === "text") { + userTextMessageContent += (typeof contentPart.text === "string" + ? contentPart.text + : JSON.stringify(contentPart.text)) + "\n"; + } else if (contentPart.type === "tool_result") { + subsequentToolMessages.push({ + role: "tool", + tool_call_id: contentPart.tool_use_id, + content: typeof contentPart.content === "string" + ? contentPart.content + : JSON.stringify(contentPart.content), + }); + } + }); + + const trimmedUserText = userTextMessageContent.trim(); + if (trimmedUserText.length > 0) { + openAiMessagesFromThisAnthropicMessage.push({ + role: "user", + content: trimmedUserText, + }); + } + openAiMessagesFromThisAnthropicMessage.push(...subsequentToolMessages); + } + return openAiMessagesFromThisAnthropicMessage; + }) + : []; + + const systemMessages = Array.isArray(system) + ? system.map((item) => ({ + role: "system", + content: typeof item === "string" ? item : item.text + })) + : typeof system === "string" && system.length > 0 + ? [{ role: "system", content: system }] + : []; + + const data: any = { + model: mapModel(model), + messages: [...systemMessages, ...openAIMessages], + temperature, + stream, + }; + + // Request usage stats in streaming responses + if (stream) { + data.stream_options = { include_usage: true }; + } + + if (tools) { + data.tools = tools.map((item: any) => ({ + type: "function", + function: { + name: item.name, + description: item.description, + parameters: item.input_schema, + }, + })); + } + + // Validate OpenAI messages to ensure complete tool_calls/tool message pairing + data.messages = [...systemMessages, ...validateOpenAIToolCalls(openAIMessages)]; + + return data; +} \ No newline at end of file diff --git a/src/router/formatResponse.ts b/src/router/formatResponse.ts new file mode 100644 index 0000000..4dde0ba --- /dev/null +++ b/src/router/formatResponse.ts @@ -0,0 +1,37 @@ +export function formatOpenAIToAnthropic(completion: any, model: string): any { + const messageId = "msg_" + Date.now(); + const message = completion.choices[0].message; + + const content: any[] = []; + if (message.content) { + content.push({ text: message.content, type: "text" }); + } + if (message.tool_calls) { + for (const item of message.tool_calls) { + content.push({ + type: 'tool_use', + id: item.id, + name: item.function?.name, + input: item.function?.arguments ? JSON.parse(item.function.arguments) : {}, + }); + } + } + + const hasToolUse = message.tool_calls && message.tool_calls.length > 0; + const usage = completion.usage || {}; + + const result = { + id: messageId, + type: "message", + role: "assistant", + content: content, + stop_reason: hasToolUse ? "tool_use" : "end_turn", + stop_sequence: null, + model, + usage: { + input_tokens: usage.prompt_tokens || 0, + output_tokens: usage.completion_tokens || 0, + }, + }; + return result; +} \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..d735d67 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,2 @@ +export { startRouter, stopRouter, isRouterRunning, getRouterPort, setBaseUrl } from './server'; +export { setModelConfig } from './formatRequest'; diff --git a/src/router/server.ts b/src/router/server.ts new file mode 100644 index 0000000..f02e5b7 --- /dev/null +++ b/src/router/server.ts @@ -0,0 +1,220 @@ +import * as http from 'http'; +import { formatAnthropicToOpenAI } from './formatRequest'; +import { streamOpenAIToAnthropic } from './streamResponse'; +import { formatOpenAIToAnthropic } from './formatResponse'; + +const DEFAULT_PORT = 31548; +const DEFAULT_BASE_URL = "http://localhost:8787/v1"; + +let server: http.Server | null = null; +let currentPort: number = DEFAULT_PORT; +let baseUrl: string = DEFAULT_BASE_URL; + +export function setBaseUrl(url: string): void { + baseUrl = url || DEFAULT_BASE_URL; + console.log('[Router] Base URL set to:', baseUrl); +} + +// Helper to parse JSON body +async function parseBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + // Prevent payload too large (50MB limit) + if (body.length > 50 * 1024 * 1024) { + req.destroy(); + reject(new Error('Payload too large')); + } + }); + req.on('end', () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (e) { + reject(new Error('Invalid JSON')); + } + }); + req.on('error', reject); + }); +} + +function createServer(): http.Server { + return http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const method = req.method || 'GET'; + + try { + // POST /v1/messages + if (url.pathname === '/v1/messages' && method === 'POST') { + console.log('[Router] πŸ“₯ Received request to /v1/messages'); + + const anthropicRequest = await parseBody(req); + const openaiRequest = formatAnthropicToOpenAI(anthropicRequest); + + console.log('[Router] πŸ”„ Converted to OpenAI format:', { + model: openaiRequest.model, + stream: openaiRequest.stream, + messageCount: openaiRequest.messages?.length + }); + + const bearerToken = (req.headers['x-api-key'] as string) || + (req.headers.authorization as string)?.replace("Bearer ", "").replace("bearer ", ""); + + if (!bearerToken || bearerToken.trim() === '') { + console.log('[Router] ❌ No bearer token found'); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + type: 'error', + error: { + type: 'authentication_error', + message: 'No API key provided. Please configure your OpenCredits user key in environment variables.' + } + })); + return; + } + + const fetchHeaders = { + "Content-Type": "application/json", + "Authorization": `Bearer ${bearerToken}`, + "HTTP-Referer": "https://claude-code-chat.local", + "X-Title": "Claude-Code-Chat-Router" + }; + + const openaiResponse = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: fetchHeaders, + body: JSON.stringify(openaiRequest), + }); + + console.log('[Router] πŸ“₯ Response status:', openaiResponse.status); + + if (!openaiResponse.ok) { + const errorText = await openaiResponse.text(); + console.log('[Router] ❌ Error:', errorText); + + // Try to parse as JSON, otherwise use raw text + let errorMessage = errorText; + try { + const parsed = JSON.parse(errorText); + errorMessage = parsed.error?.message || parsed.message || errorText; + } catch {} + + res.writeHead(openaiResponse.status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + type: 'error', + error: { + type: openaiResponse.status === 401 ? 'authentication_error' : 'api_error', + message: `[Router] ${errorMessage}` + } + })); + return; + } + + if (openaiRequest.stream) { + console.log('[Router] 🌊 Starting stream response'); + const anthropicStream = streamOpenAIToAnthropic( + openaiResponse.body as ReadableStream, + openaiRequest.model + ); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + + const reader = anthropicStream.getReader(); + + const pump = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + res.end(); + break; + } + res.write(value); + } + } catch (error) { + console.error('[Router] Stream error:', error); + res.end(); + } + }; + + pump(); + } else { + const openaiData = await openaiResponse.json(); + const anthropicResponse = formatOpenAIToAnthropic(openaiData, openaiRequest.model); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(anthropicResponse)); + } + return; + } + + // 404 Not Found + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } catch (error) { + console.error('[Router] Error processing request:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + type: 'error', + error: { + type: 'api_error', + message: `[Router] Internal error: ${(error as Error).message}` + } + })); + } + }); +} + +export function startRouter(port: number = DEFAULT_PORT): Promise { + return new Promise((resolve, reject) => { + if (server) { + console.log('[Router] Already running on port', currentPort); + resolve(currentPort); + return; + } + + server = createServer(); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.log(`[Router] Port ${port} in use, trying ${port + 1}`); + server = null; + startRouter(port + 1).then(resolve).catch(reject); + } else { + reject(err); + } + }); + + server.listen(port, () => { + currentPort = port; + console.log(`[Router] πŸš€ Running on http://localhost:${port}`); + resolve(port); + }); + }); +} + +export function stopRouter(): Promise { + return new Promise((resolve) => { + if (!server) { + resolve(); + return; + } + + server.close(() => { + console.log('[Router] Stopped'); + server = null; + resolve(); + }); + }); +} + +export function isRouterRunning(): boolean { + return server !== null; +} + +export function getRouterPort(): number { + return currentPort; +} diff --git a/src/router/streamResponse.ts b/src/router/streamResponse.ts new file mode 100644 index 0000000..afe1bc3 --- /dev/null +++ b/src/router/streamResponse.ts @@ -0,0 +1,219 @@ +export function streamOpenAIToAnthropic(openaiStream: ReadableStream, model: string): ReadableStream { + const messageId = "msg_" + Date.now(); + + const enqueueSSE = (controller: ReadableStreamDefaultController, eventType: string, data: any) => { + const sseMessage = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(new TextEncoder().encode(sseMessage)); + }; + + return new ReadableStream({ + async start(controller) { + // Send message_start event + const messageStart = { + type: "message_start", + message: { + id: messageId, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }; + enqueueSSE(controller, "message_start", messageStart); + + let contentBlockIndex = 0; + let hasAnyBlock = false; + let hasStartedTextBlock = false; + let isToolUse = false; + let currentToolCallId: string | null = null; + let toolCallJsonMap = new Map(); + let streamUsage: { input_tokens: number; output_tokens: number } | null = null; + + const reader = openaiStream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + // Process any remaining data in buffer + if (buffer.trim()) { + const lines = buffer.split('\n'); + for (const line of lines) { + if (line.trim() && line.startsWith('data: ')) { + const data = line.slice(6).trim(); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + processStreamChunk(parsed); + } catch (e) { + // Parse error + } + } + } + } + break; + } + + // Decode chunk and add to buffer + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + // Process complete lines from buffer + const lines = buffer.split('\n'); + // Keep the last potentially incomplete line in buffer + buffer = lines.pop() || ''; + + // Process complete lines in order + for (const line of lines) { + if (line.trim() && line.startsWith('data: ')) { + const data = line.slice(6).trim(); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + processStreamChunk(parsed); + } catch (e) { + // Parse error + continue; + } + } + } + } + } finally { + reader.releaseLock(); + } + + function processStreamChunk(parsed: any) { + // Capture usage from the chunk if available + if (parsed.usage) { + streamUsage = { + input_tokens: parsed.usage.prompt_tokens || 0, + output_tokens: parsed.usage.completion_tokens || 0, + }; + } + + const delta = parsed.choices?.[0]?.delta; + if (delta) { + processStreamDelta(delta); + } + } + + function closeCurrentBlock() { + if (hasAnyBlock) { + enqueueSSE(controller, "content_block_stop", { + type: "content_block_stop", + index: contentBlockIndex, + }); + contentBlockIndex++; + } + hasAnyBlock = true; + } + + function processStreamDelta(delta: any) { + + // Handle tool calls + if (delta.tool_calls?.length > 0) { + for (const toolCall of delta.tool_calls) { + const toolCallId = toolCall.id; + + if (toolCallId && toolCallId !== currentToolCallId) { + closeCurrentBlock(); + + isToolUse = true; + hasStartedTextBlock = false; + currentToolCallId = toolCallId; + toolCallJsonMap.set(toolCallId, ""); + + const toolBlock = { + type: "tool_use", + id: toolCallId, + name: toolCall.function?.name, + input: {}, + }; + + enqueueSSE(controller, "content_block_start", { + type: "content_block_start", + index: contentBlockIndex, + content_block: toolBlock, + }); + } + + if (toolCall.function?.arguments && currentToolCallId) { + const currentJson = toolCallJsonMap.get(currentToolCallId) || ""; + toolCallJsonMap.set(currentToolCallId, currentJson + toolCall.function.arguments); + + enqueueSSE(controller, "content_block_delta", { + type: "content_block_delta", + index: contentBlockIndex, + delta: { + type: "input_json_delta", + partial_json: toolCall.function.arguments, + }, + }); + } + } + } else if (delta.content) { + if (isToolUse) { + closeCurrentBlock(); + isToolUse = false; + currentToolCallId = null; + } + + if (!hasStartedTextBlock) { + if (!hasAnyBlock) { + hasAnyBlock = true; + } + enqueueSSE(controller, "content_block_start", { + type: "content_block_start", + index: contentBlockIndex, + content_block: { + type: "text", + text: "", + }, + }); + hasStartedTextBlock = true; + } + + enqueueSSE(controller, "content_block_delta", { + type: "content_block_delta", + index: contentBlockIndex, + delta: { + type: "text_delta", + text: delta.content, + }, + }); + } + } + + // Close last content block + if (hasAnyBlock) { + enqueueSSE(controller, "content_block_stop", { + type: "content_block_stop", + index: contentBlockIndex, + }); + } + + // Send message_delta and message_stop + enqueueSSE(controller, "message_delta", { + type: "message_delta", + delta: { + stop_reason: isToolUse ? "tool_use" : "end_turn", + stop_sequence: null, + }, + usage: streamUsage || { input_tokens: 0, output_tokens: 0 }, + }); + + enqueueSSE(controller, "message_stop", { + type: "message_stop", + }); + + controller.close(); + }, + }); +} diff --git a/src/script.ts b/src/script.ts index 7029415..d6f4704 100644 --- a/src/script.ts +++ b/src/script.ts @@ -1,4 +1,67 @@ -const getScript = (isTelemetryEnabled: boolean) => `` -export default getScript; \ No newline at end of file +export default getScript; diff --git a/src/skills-script.ts b/src/skills-script.ts new file mode 100644 index 0000000..5fc8ec1 --- /dev/null +++ b/src/skills-script.ts @@ -0,0 +1,288 @@ +const getSkillsScript = () => ` + // ─── Skills ─── + var skillsSearchTimeout = null; + var skillsCache = null; + var topSkills = (window.__topSkills || []); + + function showSkillsModal() { + sendStats('Skills modal opened'); + document.getElementById('skillsModal').style.display = 'flex'; + loadInstalledSkills(); + if (topSkills.length > 0) { + renderFeaturedSkills(topSkills); + } + } + + function renderFeaturedSkills(skills) { + var grid = document.getElementById('skillsGrid'); + if (!grid) return; + var html = ''; + skills.forEach(function(skill) { + var name = skill.name || 'Unknown'; + var installs = skill.installs || 0; + var source = skill.source || ''; + var installsHtml = installs > 0 ? '' + (installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k' : installs) + ' installs' : ''; + var safeId = escapeHtml(skill.id || name).replace(/'/g, '''); + + var rawUrl = skill.rawUrl || ''; + var installsText = installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k installs' : (installs > 0 ? installs + ' installs' : ''); + html += '
' + + '
' + + '
' + escapeHtml(name.charAt(0).toUpperCase()) + '
' + + '
' + + '
' + escapeHtml(name) + '
' + + '
' + installsHtml + '
' + + '
' + + '
' + + '
' + escapeHtml(source) + '
' + + '
'; + }); + grid.innerHTML = html; + } + + function hideSkillsModal() { + document.getElementById('skillsModal').style.display = 'none'; + } + + function loadInstalledSkills() { + vscode.postMessage({ type: 'loadSkills' }); + } + + function displaySkills(skills) { + var skillsList = document.getElementById('skillsList'); + skillsList.innerHTML = ''; + + if (!skills || skills.length === 0) { + skillsList.innerHTML = '
' + + '
' + + '
No skills installed
' + + '' + + '
'; + return; + } + + skills.forEach(function(skill, idx) { + var item = document.createElement('div'); + item.className = 'mcp-server-item'; + item.style.flexDirection = 'column'; + item.style.alignItems = 'stretch'; + var desc = skill.description || 'No description'; + var content = skill.content || ''; + var detailId = 'skill-detail-' + idx; + item.innerHTML = '
' + + '
' + + '
' + escapeHtml(skill.name) + ' ' + escapeHtml(skill.scope) + '
' + + '
' + escapeHtml(desc) + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + ''; + skillsList.appendChild(item); + }); + + // Add create button at bottom + var addDiv = document.createElement('div'); + addDiv.className = 'mcp-add-server'; + addDiv.innerHTML = ''; + skillsList.appendChild(addDiv); + } + + function showSkillAddForm() { + document.getElementById('skillsList').style.display = 'none'; + document.getElementById('skillsMarketplace').style.display = 'none'; + document.getElementById('skillAddForm').style.display = 'block'; + // Clear form + document.getElementById('skillName').value = ''; + document.getElementById('skillDescription').value = ''; + document.getElementById('skillContent').value = ''; + document.getElementById('skillName').disabled = false; + } + + function hideSkillAddForm() { + document.getElementById('skillsList').style.display = ''; + document.getElementById('skillsMarketplace').style.display = 'block'; + document.getElementById('skillAddForm').style.display = 'none'; + loadInstalledSkills(); + } + + function saveSkill() { + var name = document.getElementById('skillName').value.trim(); + var description = document.getElementById('skillDescription').value.trim(); + var scope = document.getElementById('skillScope').value; + var content = document.getElementById('skillContent').value; + + if (!name) return; + + // Build SKILL.md content + var skillMd = '---\\n'; + skillMd += 'name: ' + name + '\\n'; + if (description) { + skillMd += 'description: ' + description + '\\n'; + } + skillMd += '---\\n\\n'; + skillMd += content || ''; + + vscode.postMessage({ + type: 'saveSkill', + name: name, + scope: scope, + content: skillMd + }); + + hideSkillAddForm(); + } + + function deleteSkill(name, scope) { + vscode.postMessage({ + type: 'deleteSkill', + name: name, + scope: scope + }); + } + + function searchSkills(query) { + clearTimeout(skillsSearchTimeout); + skillsSearchTimeout = setTimeout(function() { + if (!query || query.length < 2) { + renderFeaturedSkills(topSkills); + return; + } + // Filter featured locally first + var q = query.toLowerCase(); + var local = topSkills.filter(function(s) { + return (s.name && s.name.toLowerCase().indexOf(q) >= 0) || + (s.source && s.source.toLowerCase().indexOf(q) >= 0); + }); + if (local.length > 0) { + renderFeaturedSkills(local); + } else { + var grid = document.getElementById('skillsGrid'); + grid.innerHTML = '
Searching...
'; + } + // Also search API + vscode.postMessage({ type: 'searchSkills', query: query }); + }, 300); + } + + function handleSkillsSearchResponse(data) { + var grid = document.getElementById('skillsGrid'); + if (!grid) return; + + var skills = data.skills || []; + if (skills.length === 0) { + grid.innerHTML = '
No skills found.
'; + return; + } + + var html = ''; + skills.forEach(function(skill) { + var name = skill.name || skill.skillId || 'Unknown'; + var installs = skill.installs || 0; + var source = skill.source || ''; + var safeId = escapeHtml(skill.id || name).replace(/'/g, '''); + + var installsHtml = installs > 0 ? '' + (installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k' : installs) + ' installs' : ''; + + var rawUrl = skill.rawUrl || ''; + var installsText = installs >= 1000 ? (Math.round(installs / 100) / 10) + 'k installs' : (installs > 0 ? installs + ' installs' : ''); + html += '
' + + '
' + + '
' + escapeHtml(name.charAt(0).toUpperCase()) + '
' + + '
' + + '
' + escapeHtml(name) + '
' + + '
' + installsHtml + '
' + + '
' + + '
' + + '
' + escapeHtml(source) + '
' + + '
'; + }); + grid.innerHTML = html; + } + + var skillsDisplayedList = null; + + function installSkillFromMarketplace(el) { + var source = el.dataset.skillSource; + var name = el.dataset.skillName; + var installs = el.dataset.skillInstalls || ''; + + if (!source || !name) return; + + var repoUrl = 'https://github.com/' + source.replace(/^github\\//, ''); + var installsHtml = installs ? '' + installs + '' : ''; + + var grid = document.getElementById('skillsGrid'); + // Save current grid content to restore on back + skillsDisplayedList = grid.innerHTML; + + grid.innerHTML = '
' + + '' + + '
' + + '
' + escapeHtml(name.charAt(0).toUpperCase()) + '
' + + '
' + + '
' + escapeHtml(name) + '
' + + '
' + + installsHtml + + 'GitHub' + + '
' + + '
' + + '
' + + '
' + escapeHtml('Source: ' + source) + '
' + + '
' + + '
Install to
' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '
Opens a terminal running npx skills add via skills.sh
' + + '
' + + '
'; + } + + function backToSkillsList() { + var grid = document.getElementById('skillsGrid'); + if (skillsDisplayedList) { + grid.innerHTML = skillsDisplayedList; + } else { + renderFeaturedSkills(topSkills); + } + } + + function toggleSkillDetail(id) { + var el = document.getElementById(id); + if (!el) return; + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + } + + function confirmSkillInstall(btn) { + var source = btn.dataset.source; + var name = btn.dataset.name; + sendStats('Skill installed', { name: name, source: source }); + var scope = document.getElementById('skillInstallScope').value; + + var repoUrl = 'https://github.com/' + source.replace(/^github\\//, ''); + var command = 'npx -y skills add ' + repoUrl + ' --skill ' + name + ' --agent claude-code -y'; + if (scope === 'global') { + command += ' --global'; + } + + vscode.postMessage({ + type: 'runTerminalCommand', + command: command + }); + + hideSkillsModal(); + } +`; + +export default getSkillsScript; diff --git a/src/skills-ui.ts b/src/skills-ui.ts new file mode 100644 index 0000000..be530d0 --- /dev/null +++ b/src/skills-ui.ts @@ -0,0 +1,51 @@ +const getSkillsHtml = () => ` + + +`; + +export default getSkillsHtml; diff --git a/src/test/downloader.integration.test.ts b/src/test/downloader.integration.test.ts new file mode 100644 index 0000000..1f508d5 --- /dev/null +++ b/src/test/downloader.integration.test.ts @@ -0,0 +1,203 @@ +// End-to-end integration tests for the claude downloader. +// +// These hit the REAL npm registry and REAL Anthropic CDN and download the REAL +// native binary to a temp directory. They are slow (~60MB–213MB of transfer) +// and network-dependent. If the suite is ever run in a CI environment without +// egress, these will fail with NETWORK β€” mark them .skip() if you need to. +// +// We never EXECUTE the downloaded binary. We just verify: +// - the downloader returns a sensible result +// - the file exists at the expected path with mode 755 (on Unix) +// - the file starts with a platform-appropriate executable magic number +// - the integrity hash matched (implicit β€” the downloader would throw if not) + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { detectPlatform, downloadClaude, DownloaderError } from '../claudeDownloader'; + +const INTEGRATION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes β€” 213MB on slow networks + +function mkTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix + '-')); +} + +function rmRf(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { /* best effort */ } +} + +// Check the first few bytes of the binary match the expected executable format. +// We never parse further than the magic β€” just enough to confirm we wrote out an +// actual executable and not e.g. an HTML error page or a README. +function assertExecutableMagic(binaryPath: string): void { + const fd = fs.openSync(binaryPath, 'r'); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + + if (process.platform === 'darwin') { + // Mach-O magic numbers: MH_MAGIC_64 (0xFEEDFACF) or MH_CIGAM_64 (0xCFFAEDFE) + // or fat universal binary (0xCAFEBABE / 0xBEBAFECA). + const m = buf.readUInt32BE(0); + const lm = buf.readUInt32LE(0); + assert.ok( + m === 0xFEEDFACF || m === 0xCAFEBABE || lm === 0xFEEDFACF || lm === 0xCAFEBABE, + `Not a Mach-O binary β€” got magic 0x${m.toString(16)} (LE 0x${lm.toString(16)})`, + ); + } else if (process.platform === 'linux') { + // ELF: 0x7F 'E' 'L' 'F' + assert.strictEqual(buf[0], 0x7F); + assert.strictEqual(buf[1], 0x45); + assert.strictEqual(buf[2], 0x4C); + assert.strictEqual(buf[3], 0x46); + } else if (process.platform === 'win32') { + // PE: starts with MZ (DOS stub). + assert.strictEqual(buf[0], 0x4D); + assert.strictEqual(buf[1], 0x5A); + } +} + +suite('claudeDownloader: integration (real network)', function () { + // Skip entirely on unsupported platforms β€” integration only makes sense where + // a binary is published for us. + const platform = detectPlatform(); + if (!platform) { + test.skip('no supported binary for this platform', () => { /* skipped */ }); + return; + } + + this.timeout(INTEGRATION_TIMEOUT_MS); + + let tempDirs: string[] = []; + + teardown(() => { + for (const d of tempDirs) {rmRf(d);} + tempDirs = []; + }); + + test('downloads the real binary from npm and verifies integrity', async () => { + const dest = mkTempDir('claude-dl-npm'); + tempDirs.push(dest); + + const progressPhases: string[] = []; + const result = await downloadClaude({ + destDir: dest, + onProgress: (p) => { + if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);} + }, + }); + + // Happy path should go npm, no fallback. + assert.strictEqual(result.source, 'npm'); + assert.ok(/^\d+\.\d+\.\d+/.test(result.version), 'version looks unfamiliar: ' + result.version); + assert.ok(result.bytesDownloaded > 1_000_000, 'tarball was suspiciously small: ' + result.bytesDownloaded); + + // File is at the expected path with correct permissions. + const expectedPath = path.join(dest, platform.binaryName); + assert.strictEqual(result.binaryPath, expectedPath); + assert.ok(fs.existsSync(result.binaryPath)); + const stat = fs.statSync(result.binaryPath); + if (process.platform !== 'win32') { + + assert.strictEqual(stat.mode & 0o777, 0o755, 'expected chmod 755'); + } + assert.ok(stat.size > 50_000_000, 'extracted binary is suspiciously small: ' + stat.size); + + assertExecutableMagic(result.binaryPath); + + // Progress pipeline actually fired phase transitions. + assert.ok(progressPhases.includes('resolving'), 'missing resolving phase'); + assert.ok(progressPhases.includes('downloading'), 'missing downloading phase'); + assert.ok(progressPhases.includes('verifying'), 'missing verifying phase'); + assert.ok(progressPhases.includes('installing'), 'missing installing phase'); + assert.ok(!progressPhases.includes('fallback'), 'fallback phase fired unexpectedly'); + }); + + test('falls back to CDN when npm is unreachable', async () => { + const dest = mkTempDir('claude-dl-fallback'); + tempDirs.push(dest); + + const progressPhases: string[] = []; + const result = await downloadClaude({ + destDir: dest, + // Point npm at a loopback port that actively refuses connections so + // the npm path fails fast (ECONNREFUSED). CDN override is left at + // default so it hits the real Anthropic CDN. + npmRegistry: 'http://127.0.0.1:1', + onProgress: (p) => { + if (!progressPhases.includes(p.phase)) {progressPhases.push(p.phase);} + }, + }); + + assert.strictEqual(result.source, 'cdn'); + assert.ok(/^\d+\.\d+\.\d+/.test(result.version)); + assert.ok(result.bytesDownloaded > 50_000_000, 'CDN serves uncompressed β‰₯50MB: ' + result.bytesDownloaded); + assert.ok(fs.existsSync(result.binaryPath)); + assertExecutableMagic(result.binaryPath); + assert.ok(progressPhases.includes('fallback'), 'expected fallback phase after npm failure'); + }); + + test('AGGREGATE error when both sources are unreachable', async () => { + const dest = mkTempDir('claude-dl-aggregate'); + tempDirs.push(dest); + + let caught: unknown; + try { + await downloadClaude({ + destDir: dest, + npmRegistry: 'http://127.0.0.1:1', + cdnBase: 'http://127.0.0.1:1', + }); + assert.fail('expected both-sources-fail to throw'); + } catch (err) { + caught = err; + } + + assert.ok(caught instanceof DownloaderError, 'expected DownloaderError'); + const e = caught as DownloaderError; + assert.strictEqual(e.code, 'AGGREGATE'); + assert.ok(e.details, 'AGGREGATE should carry details'); + assert.ok(typeof e.details!.npmCode === 'string', 'npmCode should be populated'); + assert.ok(typeof e.details!.cdnCode === 'string', 'cdnCode should be populated'); + // Should not leak any path from the local temp dir. + assert.ok(!e.message.includes(os.homedir()), 'error message leaks home dir'); + assert.ok(!e.message.includes(dest), 'error message leaks temp dir'); + + // Temp file should be cleaned up β€” nothing left in dest except the dir itself. + const entries = fs.readdirSync(dest); + assert.deepStrictEqual(entries, [], 'temp download files were not cleaned up'); + }); + + test('INTEGRITY error when CDN manifest is tampered (simulated via bogus CDN base)', async () => { + const dest = mkTempDir('claude-dl-bad-cdn'); + tempDirs.push(dest); + + // npm still works so we actually need to disable it to force CDN. + // Point CDN at a non-existent but reachable-looking host β€” expect + // NETWORK (DNS failure) bubbled through AGGREGATE, not INTEGRITY. + // This is really just confirming error classification is coherent when + // the CDN hostname resolves but returns nonsense β€” skip the exact + // INTEGRITY path since we'd need to stand up a mock server. This test + // doubles as a sanity check on the AGGREGATE error formatting. + let caught: unknown; + try { + await downloadClaude({ + destDir: dest, + npmRegistry: 'http://127.0.0.1:1', + cdnBase: 'http://127.0.0.1:1', + }); + assert.fail('expected failure'); + } catch (err) { + caught = err; + } + const e = caught as DownloaderError; + assert.strictEqual(e.code, 'AGGREGATE'); + assert.ok( + e.message.includes('npm:') && e.message.includes('cdn:'), + 'AGGREGATE message should name both sources', + ); + }); +}); diff --git a/src/test/downloader.test.ts b/src/test/downloader.test.ts new file mode 100644 index 0000000..0da8385 --- /dev/null +++ b/src/test/downloader.test.ts @@ -0,0 +1,312 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { detectPlatform, DownloaderError, __test__ } from '../claudeDownloader'; + +const { parseOctal, readTarHeader, processTarChunk, errCode, safeProgress } = __test__; + +// ---- Tar fixture builder ----------------------------------------------- +// Build a ustar header block + aligned data. We only populate the fields our +// parser reads: name (0..100), size (124..136), typeflag (156), prefix (345..500). + +function makeHeader(name: string, size: number, opts: { typeFlag?: string; prefix?: string } = {}): Buffer { + const block = Buffer.alloc(512, 0); + block.write(name, 0, 100, 'utf8'); + // Size: octal ASCII, null-terminated, 11 digits + NUL. + const oct = size.toString(8).padStart(11, '0'); + block.write(oct, 124, 11, 'ascii'); + block[135] = 0; + block[156] = (opts.typeFlag || '0').charCodeAt(0); + if (opts.prefix) {block.write(opts.prefix, 345, 155, 'utf8');} + return block; +} + +function makeMalformedSizeHeader(name: string): Buffer { + // Non-octal junk in the size field. + const block = Buffer.alloc(512, 0); + block.write(name, 0, 100, 'utf8'); + block.write('ZZZ', 124, 3, 'ascii'); + block[156] = '0'.charCodeAt(0); + return block; +} + +function paddedData(size: number, fill = 0x41 /* 'A' */): Buffer { + const padded = Math.ceil(size / 512) * 512; + const buf = Buffer.alloc(padded, 0); + for (let i = 0; i < size; i++) {buf[i] = fill;} + return buf; +} + +function buildTarball(entries: Array<{ name: string; data: Buffer; typeFlag?: string; prefix?: string }>): Buffer { + const parts: Buffer[] = []; + for (const e of entries) { + parts.push(makeHeader(e.name, e.data.length, { typeFlag: e.typeFlag, prefix: e.prefix })); + const padded = Math.ceil(e.data.length / 512) * 512; + const padBlock = Buffer.alloc(padded, 0); + e.data.copy(padBlock, 0); + parts.push(padBlock); + } + // End-of-archive: two zero-blocks. + parts.push(Buffer.alloc(1024, 0)); + return Buffer.concat(parts); +} + +function newWriteStream(): { stream: fs.WriteStream; path: string; read(): Buffer } { + const tmp = path.join(os.tmpdir(), 'downloader-test-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2)); + const stream = fs.createWriteStream(tmp); + return { + stream, + path: tmp, + read: () => fs.readFileSync(tmp), + }; +} + +async function flushWriteStream(s: fs.WriteStream): Promise { + return new Promise((resolve, reject) => { + s.end(() => resolve()); + s.on('error', reject); + }); +} + +suite('claudeDownloader: detectPlatform', () => { + test('returns a supported platform shape on the current host', () => { + const p = detectPlatform(); + if (!p) { + // Test suite only runs on supported hosts β€” skip if we land somewhere weird. + return; + } + assert.strictEqual(typeof p.key, 'string'); + assert.ok(p.key.length > 0); + assert.ok( + /^(darwin|linux|win32)-(x64|arm64)(-musl)?$/.test(p.key), + 'unexpected platform key: ' + p.key, + ); + assert.ok(p.binaryName === 'claude' || p.binaryName === 'claude.exe'); + assert.ok(p.tarEntry === 'package/claude' || p.tarEntry === 'package/claude.exe'); + // Windows β†’ .exe, others β†’ no extension + if (process.platform === 'win32') { + assert.strictEqual(p.binaryName, 'claude.exe'); + assert.strictEqual(p.tarEntry, 'package/claude.exe'); + } else { + assert.strictEqual(p.binaryName, 'claude'); + assert.strictEqual(p.tarEntry, 'package/claude'); + } + }); +}); + +suite('claudeDownloader: DownloaderError', () => { + test('exposes code, message, details, cause', () => { + const cause = new Error('underlying'); + const e = new DownloaderError('NETWORK', 'something failed', { status: 503, host: 'example.com' }, cause); + assert.strictEqual(e.code, 'NETWORK'); + assert.strictEqual(e.message, 'something failed'); + assert.deepStrictEqual(e.details, { status: 503, host: 'example.com' }); + assert.strictEqual(e.cause, cause); + assert.strictEqual(e.name, 'DownloaderError'); + assert.ok(e instanceof Error); + }); + + test('details and cause are optional', () => { + const e = new DownloaderError('CANCELLED', 'stop'); + assert.strictEqual(e.details, undefined); + assert.strictEqual(e.cause, undefined); + }); +}); + +suite('claudeDownloader: parseOctal', () => { + test('parses standard octal size', () => { + const buf = Buffer.alloc(12, 0); + buf.write('00000001024', 0, 'ascii'); // 1024 in octal + assert.strictEqual(parseOctal(buf), 0o1024); + }); + + test('parses octal with trailing NUL terminator', () => { + const buf = Buffer.alloc(12, 0); + buf.write('0000100', 0, 'ascii'); + assert.strictEqual(parseOctal(buf), 0o100); + }); + + test('parses octal with trailing space terminator', () => { + const buf = Buffer.from('0000100 \0\0\0\0\0'); + assert.strictEqual(parseOctal(buf), 0o100); + }); + + test('returns 0 for empty buffer', () => { + assert.strictEqual(parseOctal(Buffer.alloc(12, 0)), 0); + }); + + test('returns NaN for non-octal garbage', () => { + const buf = Buffer.from('ZZZ\0\0\0\0\0\0\0\0\0'); + const result = parseOctal(buf); + assert.ok(Number.isNaN(result), 'expected NaN for non-octal input, got ' + result); + }); +}); + +suite('claudeDownloader: readTarHeader', () => { + test('reads a well-formed header', () => { + const hdr = makeHeader('package/claude', 1024); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.name, 'package/claude'); + assert.strictEqual(parsed.size, 1024); + assert.strictEqual(parsed.isRegularFile, true); + }); + + test('combines prefix + name for long paths', () => { + const hdr = makeHeader('claude', 512, { prefix: 'package' }); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.name, 'package/claude'); + }); + + test('flags non-regular entries (directory)', () => { + const hdr = makeHeader('package/', 0, { typeFlag: '5' }); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.isRegularFile, false); + }); + + test('returns size=-1 when size field is garbage (NaN guard)', () => { + const hdr = makeMalformedSizeHeader('package/claude'); + const parsed = readTarHeader(hdr); + assert.strictEqual(parsed.size, -1, 'malformed size must be clamped to -1'); + }); +}); + +suite('claudeDownloader: processTarChunk', () => { + test('extracts a single matching entry', async () => { + const data = paddedData(2000, 0x42 /* 'B' */); + const binary = data.subarray(0, 2000); + const tar = buildTarball([{ name: 'package/claude', data: binary }]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + const out = ws.read(); + assert.strictEqual(state.found, true); + assert.strictEqual(out.length, 2000); + assert.deepStrictEqual(out, binary); + fs.unlinkSync(ws.path); + }); + + test('skips non-matching entries and still extracts target', async () => { + const decoy = Buffer.from('ignore me'); + const binary = paddedData(1500, 0x43 /* 'C' */).subarray(0, 1500); + const tar = buildTarball([ + { name: 'package/README.md', data: decoy }, + { name: 'package/claude', data: binary }, + { name: 'package/LICENSE', data: Buffer.from('also ignore') }, + ]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, true); + assert.deepStrictEqual(ws.read(), binary); + fs.unlinkSync(ws.path); + }); + + test('handles headers split across multiple chunks', async () => { + const binary = paddedData(3000, 0x44 /* 'D' */).subarray(0, 3000); + const tar = buildTarball([ + { name: 'package/README.md', data: Buffer.from('meh') }, + { name: 'package/claude', data: binary }, + ]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + // Drip-feed 137-byte chunks β€” guaranteed to bisect every header + data block. + const chunkSize = 137; + for (let i = 0; i < tar.length; i += chunkSize) { + processTarChunk(state, tar.subarray(i, Math.min(i + chunkSize, tar.length)), 'package/claude', ws.stream); + } + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, true); + assert.deepStrictEqual(ws.read(), binary); + fs.unlinkSync(ws.path); + }); + + test('sets found=false when target entry is absent', async () => { + const tar = buildTarball([ + { name: 'package/README.md', data: Buffer.from('nope') }, + ]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, false); + assert.strictEqual(ws.read().length, 0); + fs.unlinkSync(ws.path); + }); + + test('throws INTEGRITY on malformed size in header', () => { + const bad = makeMalformedSizeHeader('package/evil'); + const endBlocks = Buffer.alloc(1024, 0); + const tar = Buffer.concat([bad, endBlocks]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + assert.throws( + () => processTarChunk(state, tar, 'package/claude', ws.stream), + (err) => err instanceof DownloaderError && err.code === 'INTEGRITY', + ); + ws.stream.destroy(); + try { fs.unlinkSync(ws.path); } catch { /* best effort */ } + }); + + test('stops cleanly at end-of-archive zero block', async () => { + const binary = paddedData(800, 0x45 /* 'E' */).subarray(0, 800); + const tar = buildTarball([{ name: 'package/claude', data: binary }]); + + const ws = newWriteStream(); + const state = { found: false, bytesWritten: 0, buffer: Buffer.alloc(0), remainingFileBytes: 0, remainingSkipBytes: 0 }; + processTarChunk(state, tar, 'package/claude', ws.stream); + await flushWriteStream(ws.stream); + + assert.strictEqual(state.found, true); + assert.deepStrictEqual(ws.read(), binary); + fs.unlinkSync(ws.path); + }); +}); + +suite('claudeDownloader: errCode helper', () => { + test('extracts .code when present', () => { + const err = Object.assign(new Error('x'), { code: 'EACCES' }); + assert.strictEqual(errCode(err, 'FALLBACK'), 'EACCES'); + }); + + test('returns fallback when code is absent', () => { + assert.strictEqual(errCode(new Error('x'), 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode(null, 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode(undefined, 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode('just a string', 'FALLBACK'), 'FALLBACK'); + }); + + test('returns fallback when code is a non-string', () => { + assert.strictEqual(errCode({ code: 123 }, 'FALLBACK'), 'FALLBACK'); + assert.strictEqual(errCode({ code: '' }, 'FALLBACK'), 'FALLBACK'); + }); +}); + +suite('claudeDownloader: safeProgress helper', () => { + test('invokes the callback with the progress payload', () => { + const calls: unknown[] = []; + safeProgress((p) => calls.push(p), { phase: 'resolving' }); + assert.deepStrictEqual(calls, [{ phase: 'resolving' }]); + }); + + test('swallows callback throws without propagating', () => { + assert.doesNotThrow(() => { + safeProgress(() => { throw new Error('boom'); }, { phase: 'downloading' }); + }); + }); + + test('handles undefined callback', () => { + assert.doesNotThrow(() => safeProgress(undefined, { phase: 'verifying' })); + }); +}); diff --git a/src/top-mcp-servers.json b/src/top-mcp-servers.json new file mode 100644 index 0000000..2e8928d --- /dev/null +++ b/src/top-mcp-servers.json @@ -0,0 +1,479 @@ +[ + { + "id": "sequential-thinking", + "name": "Sequential Thinking", + "description": "Step-by-step reasoning capabilities", + "icon": "", + "stars": 0, + "url": "", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-sequential-thinking" + ] + }, + "featured": true + }, + { + "id": "memory", + "name": "Memory", + "description": "Knowledge graph storage", + "icon": "", + "stars": 0, + "url": "", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-memory" + ] + }, + "featured": true + }, + { + "id": "puppeteer", + "name": "Puppeteer", + "description": "Browser automation", + "icon": "", + "stars": 0, + "url": "", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-puppeteer" + ] + }, + "featured": true + }, + { + "id": "fetch", + "name": "Fetch", + "description": "HTTP requests & web scraping", + "icon": "", + "stars": 0, + "url": "", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-fetch" + ] + }, + "featured": true + }, + { + "id": "filesystem", + "name": "Filesystem", + "description": "File operations & management", + "icon": "", + "stars": 0, + "url": "", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem" + ] + }, + "featured": true + }, + { + "id": "io.github.upstash/context7", + "name": "Context7", + "description": "Up-to-date code docs for any prompt", + "icon": "", + "stars": 0, + "url": "https://github.com/upstash/context7", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp" + ], + "env": { + "CONTEXT7_API_KEY": "" + } + }, + "featured": true + }, + { + "id": "com.airtable/mcp", + "name": "Airtable", + "description": "Official Airtable MCP server for managing bases, tables, and records.", + "icon": "", + "stars": 0, + "url": "", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.airtable.com/mcp", + "headers": { + "Authorization": "" + } + } + }, + { + "id": "com.apify/mcp", + "name": "Apify", + "description": "Extract data from social media, search engines, maps, e-commerce sites, and any website using thousands of ready-made tools from Apify Store.", + "icon": "", + "stars": 0, + "url": "https://github.com/apify/apify-mcp-server", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.apify.com" + } + }, + { + "id": "io.github.browserbase/mcp-server-browserbase", + "name": "Browserbase", + "description": "MCP server for AI web browser automation using Browserbase and Stagehand", + "icon": "", + "stars": 0, + "url": "https://github.com/browserbase/mcp-server-browserbase", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@browserbasehq/mcp-server-browserbase" + ], + "env": { + "BROWSERBASE_API_KEY": "", + "BROWSERBASE_PROJECT_ID": "", + "GEMINI_API_KEY": "" + } + } + }, + { + "id": "io.github.clerk/mcp-server", + "name": "Clerk", + "description": "Access Clerk authentication docs, SDK snippets, and quickstart guides", + "icon": "", + "stars": 0, + "url": "https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.clerk.com/mcp" + } + }, + { + "id": "com.cloudflare.mcp/mcp", + "name": "Cloudflare", + "description": "Cloudflare MCP servers", + "icon": "", + "stars": 0, + "url": "https://github.com/cloudflare/mcp-server-cloudflare", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://docs.mcp.cloudflare.com/mcp" + } + }, + { + "id": "ai.exa/mcp", + "name": "Exa", + "description": "Web search and code search MCP server powered by Exa", + "icon": "", + "stars": 0, + "url": "https://github.com/exa-labs/exa-mcp-server", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.exa.ai/mcp" + } + }, + { + "id": "com.figma/mcp", + "name": "Figma", + "description": "Official Figma MCP server for accessing design files, components, and design context", + "icon": "", + "stars": 0, + "url": "https://help.figma.com/hc/en-us/articles/35281350665623-Figma-MCP-collection-How-to-set-up-the-Figma-remote-MCP-server-preferred", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.figma.com/mcp" + } + }, + { + "id": "dev.firecrawl/mcp", + "name": "Firecrawl", + "description": "Web scraping, crawling, search, and structured data extraction powered by Firecrawl.", + "icon": "", + "stars": 0, + "url": "https://github.com/firecrawl/firecrawl-mcp-server", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.firecrawl.dev/v2/mcp", + "headers": { + "Authorization": "" + } + } + }, + { + "id": "io.github.github/github-mcp-server", + "name": "GitHub", + "description": "Official GitHub MCP server for repos, issues, PRs, and workflows", + "icon": "", + "stars": 0, + "url": "https://github.com/github/github-mcp-server", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + }, + { + "id": "app.linear/linear", + "name": "Linear", + "description": "MCP server for Linear project management and issue tracking", + "icon": "", + "stars": 0, + "url": "", + "installType": "sse", + "installConfig": { + "type": "sse", + "url": "https://mcp.linear.app/sse" + } + }, + { + "id": "com.mux/mcp", + "name": "Mux", + "description": "The official MCP Server for the Mux API", + "icon": "", + "stars": 0, + "url": "https://github.com/muxinc/mux-node-sdk", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.mux.com", + "headers": { + "Authorization": "" + } + } + }, + { + "id": "com.neon/mcp", + "name": "Neon", + "description": "Official Neon MCP server for managing Neon projects and Postgres databases.", + "icon": "", + "stars": 0, + "url": "https://github.com/neondatabase/mcp-server-neon", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.neon.tech/mcp", + "headers": { + "Authorization": "", + "x-read-only": "" + } + } + }, + { + "id": "com.netlify/mcp", + "name": "Netlify", + "description": "Netlify's official MCP server for builds, deploys, and project management.", + "icon": "", + "stars": 0, + "url": "https://github.com/netlify/netlify-mcp", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@netlify/mcp" + ], + "env": { + "NETLIFY_PERSONAL_ACCESS_TOKEN": "" + } + } + }, + { + "id": "io.github.vercel/next-devtools-mcp", + "name": "Next.js Devtools", + "description": "Next.js development tools MCP server with stdio transport", + "icon": "", + "stars": 0, + "url": "https://github.com/vercel/next-devtools-mcp", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "next-devtools-mcp" + ] + } + }, + { + "id": "com.notion/mcp", + "name": "Notion", + "description": "Official Notion MCP server", + "icon": "", + "stars": 0, + "url": "", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.notion.com/mcp" + } + }, + { + "id": "io.github.railwayapp/mcp-server", + "name": "Railway", + "description": "Official Railway MCP server", + "icon": "", + "stars": 0, + "url": "https://github.com/railwayapp/railway-mcp-server", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@railway/mcp-server" + ] + } + }, + { + "id": "com.render/mcp", + "name": "Render", + "description": "Official Render MCP server for managing Render resources.", + "icon": "", + "stars": 0, + "url": "https://github.com/render-oss/render-mcp-server", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.render.com/mcp", + "headers": { + "Authorization": "" + } + } + }, + { + "id": "com.resend/mcp", + "name": "Resend", + "description": "Official Resend MCP server for email operations and audience management.", + "icon": "", + "stars": 0, + "url": "https://github.com/resend/mcp-send-email", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "resend-mcp" + ], + "env": { + "RESEND_API_KEY": "" + } + } + }, + { + "id": "io.sanity.www/mcp", + "name": "Sanity", + "description": "Direct access to your Sanity projects (content, datasets, releases, schemas) and agent rules", + "icon": "", + "stars": 0, + "url": "https://github.com/sanity-io/agent-toolkit", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.sanity.io" + } + }, + { + "id": "io.github.getsentry/sentry-mcp", + "name": "Sentry", + "description": "MCP server for Sentry issue tracking and debugging", + "icon": "", + "stars": 0, + "url": "https://github.com/getsentry/sentry-mcp", + "installType": "npm", + "installConfig": { + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "@sentry/mcp-server" + ], + "env": { + "SENTRY_ACCESS_TOKEN": "" + } + } + }, + { + "id": "com.slack/mcp", + "name": "Slack", + "description": "Official Slack MCP server for search, messaging, canvases, and users.", + "icon": "", + "stars": 0, + "url": "https://github.com/slackapi/slack-mcp-plugin", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.slack.com/mcp" + } + }, + { + "id": "com.stripe/mcp", + "name": "Stripe", + "description": "Official Stripe MCP server for Stripe API tools.", + "icon": "", + "stars": 0, + "url": "https://github.com/stripe/agent-toolkit", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.stripe.com" + } + }, + { + "id": "com.supabase/mcp", + "name": "Supabase", + "description": "MCP server for interacting with the Supabase platform", + "icon": "", + "stars": 0, + "url": "https://github.com/supabase-community/supabase-mcp", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.supabase.com/mcp" + } + }, + { + "id": "com.vercel/vercel-mcp", + "name": "Vercel", + "description": "An MCP server for Vercel", + "icon": "", + "stars": 0, + "url": "https://github.com/vercel/vercel-mcp-overview", + "installType": "http", + "installConfig": { + "type": "http", + "url": "https://mcp.vercel.com" + } + } +] \ No newline at end of file diff --git a/src/top-plugins.json b/src/top-plugins.json new file mode 100644 index 0000000..92ff4a0 --- /dev/null +++ b/src/top-plugins.json @@ -0,0 +1,240 @@ +[ + { + "name": "agent-sdk-dev", + "description": "Claude Agent SDK Development Plugin", + "verified": true, + "type": "official", + "installId": "agent-sdk-dev@claude-plugins-official" + }, + { + "name": "claude-code-setup", + "description": "Analyze codebases and recommend tailored Claude Code automations such as hooks, skills, MCP servers, and subagents.", + "verified": true, + "type": "official", + "installId": "claude-code-setup@claude-plugins-official" + }, + { + "name": "claude-md-management", + "description": "Tools to maintain and improve CLAUDE.md files - audit quality, capture session learnings, and keep project memory current.", + "verified": true, + "type": "official", + "installId": "claude-md-management@claude-plugins-official" + }, + { + "name": "code-review", + "description": "Automated code review for pull requests using multiple specialized agents with confidence-based scoring", + "verified": true, + "type": "official", + "installId": "code-review@claude-plugins-official" + }, + { + "name": "code-simplifier", + "description": "Agent that simplifies and refines code for clarity, consistency, and maintainability while preserving functionality", + "verified": true, + "type": "official", + "installId": "code-simplifier@claude-plugins-official" + }, + { + "name": "commit-commands", + "description": "Streamline your git workflow with simple commands for committing, pushing, and creating pull requests", + "verified": true, + "type": "official", + "installId": "commit-commands@claude-plugins-official" + }, + { + "name": "explanatory-output-style", + "description": "Adds educational insights about implementation choices and codebase patterns (mimics the deprecated Explanatory output style)", + "verified": true, + "type": "official", + "installId": "explanatory-output-style@claude-plugins-official" + }, + { + "name": "feature-dev", + "description": "Comprehensive feature development workflow with specialized agents for codebase exploration, architecture design, and quality review", + "verified": true, + "type": "official", + "installId": "feature-dev@claude-plugins-official" + }, + { + "name": "frontend-design", + "description": "Frontend design skill for UI/UX implementation", + "verified": true, + "type": "official", + "installId": "frontend-design@claude-plugins-official" + }, + { + "name": "hookify", + "description": "Easily create hooks to prevent unwanted behaviors by analyzing conversation patterns", + "verified": true, + "type": "official", + "installId": "hookify@claude-plugins-official" + }, + { + "name": "learning-output-style", + "description": "Interactive learning mode that requests meaningful code contributions at decision points (mimics the unshipped Learning output style)", + "verified": true, + "type": "official", + "installId": "learning-output-style@claude-plugins-official" + }, + { + "name": "math-olympiad", + "description": "Solve competition math (IMO, Putnam, USAMO) with adversarial verification that catches what self-verification misses. Fresh-context verifiers attack proofs with specific failure patterns. Calibrated abstention over bluffing.", + "verified": true, + "type": "official", + "installId": "math-olympiad@claude-plugins-official" + }, + { + "name": "mcp-server-dev", + "description": "Skills for designing and building MCP servers that work seamlessly with Claude \u2014 guides you through deployment models (remote HTTP, MCPB, local), tool design patterns, auth, and interactive MCP apps.", + "verified": true, + "type": "official", + "installId": "mcp-server-dev@claude-plugins-official" + }, + { + "name": "playground", + "description": "Creates interactive HTML playgrounds \u2014 self-contained single-file explorers with visual controls, live preview, and prompt output with copy button", + "verified": true, + "type": "official", + "installId": "playground@claude-plugins-official" + }, + { + "name": "plugin-dev", + "description": "Plugin development toolkit with skills for creating agents, commands, hooks, MCP integrations, and comprehensive plugin structure guidance", + "verified": true, + "type": "official", + "installId": "plugin-dev@claude-plugins-official" + }, + { + "name": "pr-review-toolkit", + "description": "Comprehensive PR review agents specializing in comments, tests, error handling, type design, code quality, and code simplification", + "verified": true, + "type": "official", + "installId": "pr-review-toolkit@claude-plugins-official" + }, + { + "name": "ralph-loop", + "description": "Continuous self-referential AI loops for interactive iterative development, implementing the Ralph Wiggum technique. Run Claude in a while-true loop with the same prompt until task completion.", + "verified": true, + "type": "official", + "installId": "ralph-loop@claude-plugins-official" + }, + { + "name": "security-guidance", + "description": "Security reminder hook that warns about potential security issues when editing files, including command injection, XSS, and unsafe code patterns", + "verified": true, + "type": "official", + "installId": "security-guidance@claude-plugins-official" + }, + { + "name": "skill-creator", + "description": "Create new skills, improve existing skills, and measure skill performance. Use when users want to create a skill from scratch, update or optimize an existing skill, run evals to test a skill, or benchmark skill performance with variance analysis.", + "verified": true, + "type": "official", + "installId": "skill-creator@claude-plugins-official" + }, + { + "name": "asana", + "description": "Asana project management integration. Create and manage tasks, search projects, update assignments, track progress, and integrate your development workflow with Asana's work management platform.", + "verified": false, + "type": "external", + "installId": "asana@claude-plugins-official" + }, + { + "name": "context7", + "description": "Upstash Context7 MCP server for up-to-date documentation lookup. Pull version-specific documentation and code examples directly from source repositories into your LLM context.", + "verified": false, + "type": "external", + "installId": "context7@claude-plugins-official" + }, + { + "name": "discord", + "description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.", + "verified": false, + "type": "external", + "installId": "discord@claude-plugins-official" + }, + { + "name": "fakechat", + "description": "Localhost iMessage-style web chat for Claude Code \u2014 test surface with file upload and edits. No tokens, no access control.", + "verified": false, + "type": "external", + "installId": "fakechat@claude-plugins-official" + }, + { + "name": "firebase", + "description": "Google Firebase MCP integration. Manage Firestore databases, authentication, cloud functions, hosting, and storage. Build and manage your Firebase backend directly from your development workflow.", + "verified": false, + "type": "external", + "installId": "firebase@claude-plugins-official" + }, + { + "name": "github", + "description": "Official GitHub MCP server for repository management. Create issues, manage pull requests, review code, search repositories, and interact with GitHub's full API directly from Claude Code.", + "verified": false, + "type": "external", + "installId": "github@claude-plugins-official" + }, + { + "name": "gitlab", + "description": "GitLab DevOps platform integration. Manage repositories, merge requests, CI/CD pipelines, issues, and wikis. Full access to GitLab's comprehensive DevOps lifecycle tools.", + "verified": false, + "type": "external", + "installId": "gitlab@claude-plugins-official" + }, + { + "name": "greptile", + "description": "AI code review agent for GitHub and GitLab. View and resolve Greptile's PR review comments directly from Claude Code.", + "verified": false, + "type": "external", + "installId": "greptile@claude-plugins-official" + }, + { + "name": "laravel-boost", + "description": "Laravel development toolkit MCP server. Provides intelligent assistance for Laravel applications including Artisan commands, Eloquent queries, routing, migrations, and framework-specific code generation.", + "verified": false, + "type": "external", + "installId": "laravel-boost@claude-plugins-official" + }, + { + "name": "linear", + "description": "Linear issue tracking integration. Create issues, manage projects, update statuses, search across workspaces, and streamline your software development workflow with Linear's modern issue tracker.", + "verified": false, + "type": "external", + "installId": "linear@claude-plugins-official" + }, + { + "name": "playwright", + "description": "Browser automation and end-to-end testing MCP server by Microsoft. Enables Claude to interact with web pages, take screenshots, fill forms, click elements, and perform automated browser testing workflows.", + "verified": false, + "type": "external", + "installId": "playwright@claude-plugins-official" + }, + { + "name": "serena", + "description": "Semantic code analysis MCP server providing intelligent code understanding, refactoring suggestions, and codebase navigation through language server protocol integration.", + "verified": false, + "type": "external", + "installId": "serena@claude-plugins-official" + }, + { + "name": "slack", + "description": "Slack workspace integration. Search messages, access channels, read threads, and stay connected with your team's communications while coding. Find relevant discussions and context quickly.", + "verified": false, + "type": "external", + "installId": "slack@claude-plugins-official" + }, + { + "name": "supabase", + "description": "Supabase MCP integration for database operations, authentication, storage, and real-time subscriptions. Manage your Supabase projects, run SQL queries, and interact with your backend directly.", + "verified": false, + "type": "external", + "installId": "supabase@claude-plugins-official" + }, + { + "name": "telegram", + "description": "Telegram channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /telegram:access.", + "verified": false, + "type": "external", + "installId": "telegram@claude-plugins-official" + } +] \ No newline at end of file diff --git a/src/top-skills.json b/src/top-skills.json new file mode 100644 index 0000000..37bd948 --- /dev/null +++ b/src/top-skills.json @@ -0,0 +1,289 @@ +[ + { + "id": "vercel-labs/skills/find-skills", + "name": "find-skills", + "installs": 654260, + "source": "vercel-labs/skills", + "rawUrl": "https://raw.githubusercontent.com/vercel-labs/skills/main/skills/find-skills/SKILL.md" + }, + { + "id": "vercel-labs/agent-skills/vercel-react-best-practices", + "name": "vercel-react-best-practices", + "installs": 234225, + "source": "vercel-labs/agent-skills", + "rawUrl": "https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/react-best-practices/SKILL.md" + }, + { + "id": "vercel-labs/agent-skills/web-design-guidelines", + "name": "web-design-guidelines", + "installs": 187122, + "source": "vercel-labs/agent-skills", + "rawUrl": "https://raw.githubusercontent.com/vercel-labs/agent-skills/main/skills/web-design-guidelines/SKILL.md" + }, + { + "id": "anthropics/skills/frontend-design", + "name": "frontend-design", + "installs": 184608, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/frontend-design/SKILL.md" + }, + { + "id": "vercel-labs/agent-browser/agent-browser", + "name": "agent-browser", + "installs": 119125, + "source": "vercel-labs/agent-browser", + "rawUrl": "https://raw.githubusercontent.com/vercel-labs/agent-browser/main/skills/agent-browser/SKILL.md" + }, + { + "id": "anthropics/skills/skill-creator", + "name": "skill-creator", + "installs": 97605, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/skill-creator/SKILL.md" + }, + { + "id": "nextlevelbuilder/ui-ux-pro-max-skill/ui-ux-pro-max", + "name": "ui-ux-pro-max", + "installs": 74564, + "source": "nextlevelbuilder/ui-ux-pro-max-skill", + "rawUrl": "https://raw.githubusercontent.com/nextlevelbuilder/ui-ux-pro-max-skill/main/.claude/skills/ui-ux-pro-max/SKILL.md" + }, + { + "id": "microsoft/azure-skills/microsoft-foundry", + "name": "microsoft-foundry", + "installs": 74376, + "source": "microsoft/azure-skills", + "rawUrl": "https://raw.githubusercontent.com/microsoft/azure-skills/main/skills/microsoft-foundry/SKILL.md" + }, + { + "id": "obra/superpowers/brainstorming", + "name": "brainstorming", + "installs": 66697, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/brainstorming/SKILL.md" + }, + { + "id": "browser-use/browser-use/browser-use", + "name": "browser-use", + "installs": 52773, + "source": "browser-use/browser-use", + "rawUrl": "https://raw.githubusercontent.com/browser-use/browser-use/main/skills/browser-use/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/seo-audit", + "name": "seo-audit", + "installs": 50157, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/seo-audit/SKILL.md" + }, + { + "id": "anthropics/skills/pdf", + "name": "pdf", + "installs": 45709, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/pdf/SKILL.md" + }, + { + "id": "supabase/agent-skills/supabase-postgres-best-practices", + "name": "supabase-postgres-best-practices", + "installs": 43862, + "source": "supabase/agent-skills", + "rawUrl": "https://raw.githubusercontent.com/supabase/agent-skills/main/skills/supabase-postgres-best-practices/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/copywriting", + "name": "copywriting", + "installs": 42743, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/copywriting/SKILL.md" + }, + { + "id": "anthropics/skills/pptx", + "name": "pptx", + "installs": 41526, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/pptx/SKILL.md" + }, + { + "id": "vercel-labs/next-skills/next-best-practices", + "name": "next-best-practices", + "installs": 40732, + "source": "vercel-labs/next-skills", + "rawUrl": "https://raw.githubusercontent.com/vercel-labs/next-skills/main/skills/next-best-practices/SKILL.md" + }, + { + "id": "squirrelscan/skills/audit-website", + "name": "audit-website", + "installs": 37654, + "source": "squirrelscan/skills", + "rawUrl": "https://raw.githubusercontent.com/squirrelscan/skills/main/audit-website/SKILL.md" + }, + { + "id": "obra/superpowers/systematic-debugging", + "name": "systematic-debugging", + "installs": 36470, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/systematic-debugging/SKILL.md" + }, + { + "id": "anthropics/skills/docx", + "name": "docx", + "installs": 35928, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/docx/SKILL.md" + }, + { + "id": "obra/superpowers/writing-plans", + "name": "writing-plans", + "installs": 35010, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/writing-plans/SKILL.md" + }, + { + "id": "shadcn/ui/shadcn", + "name": "shadcn", + "installs": 33897, + "source": "shadcn/ui", + "rawUrl": "https://raw.githubusercontent.com/shadcn/ui/main/skills/shadcn/SKILL.md" + }, + { + "id": "anthropics/skills/xlsx", + "name": "xlsx", + "installs": 32936, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/xlsx/SKILL.md" + }, + { + "id": "obra/superpowers/using-superpowers", + "name": "using-superpowers", + "installs": 30937, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/using-superpowers/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/marketing-psychology", + "name": "marketing-psychology", + "installs": 30917, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/marketing-psychology/SKILL.md" + }, + { + "id": "obra/superpowers/test-driven-development", + "name": "test-driven-development", + "installs": 30410, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/test-driven-development/SKILL.md" + }, + { + "id": "anthropics/skills/webapp-testing", + "name": "webapp-testing", + "installs": 29748, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/webapp-testing/SKILL.md" + }, + { + "id": "obra/superpowers/executing-plans", + "name": "executing-plans", + "installs": 28743, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/executing-plans/SKILL.md" + }, + { + "id": "obra/superpowers/requesting-code-review", + "name": "requesting-code-review", + "installs": 28421, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/requesting-code-review/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/content-strategy", + "name": "content-strategy", + "installs": 27875, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/content-strategy/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/programmatic-seo", + "name": "programmatic-seo", + "installs": 27820, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/programmatic-seo/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/social-content", + "name": "social-content", + "installs": 26700, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/social-content/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/product-marketing-context", + "name": "product-marketing-context", + "installs": 25930, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/product-marketing-context/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/marketing-ideas", + "name": "marketing-ideas", + "installs": 25516, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/marketing-ideas/SKILL.md" + }, + { + "id": "roin-orca/skills/simple", + "name": "simple", + "installs": 25467, + "source": "roin-orca/skills", + "rawUrl": "https://raw.githubusercontent.com/roin-orca/skills/main/skills/simple/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/pricing-strategy", + "name": "pricing-strategy", + "installs": 25142, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/pricing-strategy/SKILL.md" + }, + { + "id": "anthropics/skills/mcp-builder", + "name": "mcp-builder", + "installs": 24764, + "source": "anthropics/skills", + "rawUrl": "https://raw.githubusercontent.com/anthropics/skills/main/skills/mcp-builder/SKILL.md" + }, + { + "id": "obra/superpowers/subagent-driven-development", + "name": "subagent-driven-development", + "installs": 24432, + "source": "obra/superpowers", + "rawUrl": "https://raw.githubusercontent.com/obra/superpowers/main/skills/subagent-driven-development/SKILL.md" + }, + { + "id": "coreyhaines31/marketingskills/copy-editing", + "name": "copy-editing", + "installs": 24073, + "source": "coreyhaines31/marketingskills", + "rawUrl": "https://raw.githubusercontent.com/coreyhaines31/marketingskills/main/skills/copy-editing/SKILL.md" + }, + { + "id": "pbakaus/impeccable/frontend-design", + "name": "frontend-design", + "installs": 23984, + "source": "pbakaus/impeccable", + "rawUrl": "https://raw.githubusercontent.com/pbakaus/impeccable/main/.claude/skills/frontend-design/SKILL.md" + }, + { + "id": "pbakaus/impeccable/polish", + "name": "polish", + "installs": 23360, + "source": "pbakaus/impeccable", + "rawUrl": "https://raw.githubusercontent.com/pbakaus/impeccable/main/.claude/skills/polish/SKILL.md" + }, + { + "id": "google-labs-code/stitch-skills/design-md", + "name": "design-md", + "installs": 19272, + "source": "google-labs-code/stitch-skills", + "rawUrl": "https://raw.githubusercontent.com/google-labs-code/stitch-skills/main/skills/design-md/SKILL.md" + } +] \ No newline at end of file diff --git a/src/ui-styles.ts b/src/ui-styles.ts index 0c33137..76bd766 100644 --- a/src/ui-styles.ts +++ b/src/ui-styles.ts @@ -338,6 +338,206 @@ const styles = ` background-color: rgba(128, 128, 128, 0.05); } + /* AskUserQuestion */ + .ask-user-question { + margin: 4px 12px 20px 12px; + background-color: rgba(0, 122, 204, 0.08); + border: 1px solid rgba(0, 122, 204, 0.3); + border-radius: 8px; + padding: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + animation: slideUp 0.3s ease; + } + + .ask-question-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-weight: 600; + color: var(--vscode-foreground); + } + + .ask-question-header .icon { + font-size: 16px; + } + + .ask-question-content { + font-size: 13px; + line-height: 1.4; + color: var(--vscode-descriptionForeground); + } + + .question-block { + margin-bottom: 16px; + } + + .question-block:last-of-type { + margin-bottom: 12px; + } + + .question-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; + } + + .question-text { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); + margin-bottom: 8px; + } + + .question-options { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 6px; + } + + .question-option { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + background-color: transparent; + } + + .question-option:hover { + background-color: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); + } + + /* Hide native radio/checkbox, use custom styling */ + .question-option input[type="radio"], + .question-option input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border: 2px solid var(--vscode-descriptionForeground); + background: transparent; + cursor: pointer; + flex-shrink: 0; + margin: 1px 0 0 0; + padding: 0; + transition: all 0.15s ease; + } + + .question-option input[type="radio"] { + border-radius: 50%; + } + + .question-option input[type="checkbox"] { + border-radius: 3px; + } + + .question-option input[type="radio"]:checked { + border-color: var(--vscode-button-background); + background: radial-gradient(circle, var(--vscode-button-background) 40%, transparent 44%); + } + + .question-option input[type="checkbox"]:checked { + border-color: var(--vscode-button-background); + background-color: var(--vscode-button-background); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='white'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E"); + background-size: 12px; + background-position: center; + background-repeat: no-repeat; + } + + /* Selected option card highlight */ + .question-option:has(input:checked) { + border-color: var(--vscode-button-background); + background-color: rgba(0, 122, 204, 0.08); + } + + .option-content { + display: flex; + flex-direction: column; + gap: 2px; + } + + .option-label { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); + } + + .option-description { + font-size: 12px; + color: var(--vscode-descriptionForeground); + } + + .question-freetext { + margin-top: 6px; + } + + .question-freetext-input { + width: 100%; + padding: 6px 10px; + font-size: 13px; + font-family: var(--vscode-font-family); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 4px; + outline: none; + box-sizing: border-box; + } + + .question-freetext-input:focus { + border-color: var(--vscode-focusBorder); + } + + .question-freetext-input:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .ask-question-buttons { + margin-top: 8px; + display: flex; + gap: 8px; + justify-content: flex-end; + } + + .ask-question-decision { + font-size: 12px; + padding: 8px 12px; + border-radius: 4px; + margin-top: 8px; + } + + .ask-question-decision.allowed { + background-color: rgba(0, 122, 204, 0.1); + color: var(--vscode-foreground); + border: 1px solid rgba(0, 122, 204, 0.2); + } + + .ask-question-decision.expired { + background-color: rgba(128, 128, 128, 0.15); + color: var(--vscode-descriptionForeground); + border: 1px solid rgba(128, 128, 128, 0.3); + } + + .ask-question-decided { + opacity: 0.7; + pointer-events: none; + } + + .ask-question-decided .ask-question-buttons { + display: none; + } + /* Permissions Management */ .permissions-list { max-height: 300px; @@ -585,6 +785,62 @@ const styles = ` line-height: 1.3; } + .env-variables-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .env-variable-row { + display: flex; + gap: 8px; + align-items: center; + } + + .env-variable-row input { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 4px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + font-family: monospace; + } + + .env-variable-row input:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } + + .env-variable-row input::placeholder { + color: var(--vscode-input-placeholderForeground); + } + + .env-variable-row .env-key { + flex: 0.4; + } + + .env-variable-row .env-value { + flex: 0.6; + } + + .env-variable-remove { + background: transparent; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 4px 8px; + font-size: 14px; + opacity: 0.6; + transition: opacity 0.15s ease; + } + + .env-variable-remove:hover { + opacity: 1; + color: var(--vscode-errorForeground); + } + .yolo-mode-section { display: flex; align-items: center; @@ -1068,6 +1324,62 @@ const styles = ` opacity: 0.95; } + .plan-content { + font-size: 13px; + line-height: 1.6; + } + + .plan-content h1, .plan-content h2, .plan-content h3 { + margin: 12px 0 6px; + font-weight: 600; + } + + .plan-content h1 { font-size: 16px; } + .plan-content h2 { font-size: 14px; } + .plan-content h3 { font-size: 13px; } + + .plan-content ul, .plan-content ol { + padding-left: 20px; + margin: 4px 0; + } + + .plan-content code { + font-family: var(--vscode-editor-font-family); + font-size: 12px; + background: rgba(127, 127, 127, 0.15); + padding: 1px 4px; + border-radius: 3px; + } + + .plan-actions { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--vscode-panel-border); + } + + .plan-actions-label { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; + } + + .plan-action-btn { + display: inline-block; + background: var(--vscode-button-secondaryBackground, rgba(128, 128, 128, 0.2)); + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + border: 1px solid var(--vscode-panel-border); + padding: 5px 12px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + margin: 0 6px 6px 0; + } + + .plan-action-btn:hover { + background: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); + } + /* Diff display styles for Edit tool */ .diff-container { border: 1px solid var(--vscode-panel-border); @@ -1331,7 +1643,8 @@ const styles = ` } .input-container { - padding: 10px; + padding: 1px 10px 10px 10px; + margin: 0; border-top: 1px solid var(--vscode-panel-border); background-color: var(--vscode-panel-background); display: flex; @@ -1339,25 +1652,188 @@ const styles = ` position: relative; } - .input-modes { + .model-selector-row { display: flex; - gap: 16px; align-items: center; - padding-bottom: 5px; - font-size: 9.5px; + gap: 6px; + margin-top: 6px; + margin-bottom: 6px; + overflow: hidden; } - .mode-toggle { + .model-selector-new { + font-size: 9px; + font-weight: 700; + color: #fff; + background: linear-gradient(135deg, #f97316, #ea580c); + padding: 2px 6px; + border-radius: 4px; + letter-spacing: 0.5px; + } + + .model-selector-main { display: flex; align-items: center; gap: 6px; + padding: 4px 10px; + background: rgba(139, 92, 246, 0.1); + border: 1px solid rgba(139, 92, 246, 0.3); + border-radius: 20px; color: var(--vscode-foreground); - opacity: 0.8; - transition: opacity 0.2s ease; - } - - .mode-toggle span { - cursor: pointer; + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + } + + .model-selector-main:hover { + background: rgba(139, 92, 246, 0.18); + border-color: rgba(139, 92, 246, 0.4); + } + + #modelDropdownBtn { + background: none; + border-color: var(--vscode-panel-border); + } + + #modelDropdownBtn:hover { + background: rgba(128, 128, 128, 0.15); + border-color: var(--vscode-focusBorder); + } + + #modelDropdownBtn svg { + color: var(--vscode-descriptionForeground); + width: 8px; + height: 8px; + } + + .model-selector-main svg { + color: #8b5cf6; + width: 12px; + height: 12px; + } + + .model-quick-select { + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; + flex: 1; + min-width: 0; + scrollbar-width: none; + -ms-overflow-style: none; + } + + .model-quick-select::-webkit-scrollbar { + display: none; + } + + .model-quick-btn { + display: flex; + align-items: center; + gap: 3px; + padding: 4px 8px; + background: transparent; + border: 1px solid rgba(139, 92, 246, 0.2); + border-radius: 20px; + color: var(--vscode-foreground); + font-size: 10px; + cursor: pointer; + transition: all 0.15s ease; + opacity: 0.8; + white-space: nowrap; + } + + .model-quick-btn:hover { + background: rgba(139, 92, 246, 0.1); + border-color: rgba(139, 92, 246, 0.3); + opacity: 1; + } + + .model-quick-btn.selected { + background: rgba(139, 92, 246, 0.18); + border-color: rgba(139, 92, 246, 0.4); + opacity: 1; + } + + .model-quick-icon { + font-size: 10px; + } + + .model-quick-select { + mask-image: linear-gradient(to right, black calc(100% - 20px), transparent 100%); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent 100%); + } + + .model-more-btn { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + background: transparent; + border: 1px solid rgba(139, 92, 246, 0.2); + border-radius: 20px; + color: var(--vscode-foreground); + font-size: 10px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + opacity: 0.7; + flex-shrink: 0; + } + + .model-more-btn:hover { + background: rgba(139, 92, 246, 0.1); + border-color: rgba(139, 92, 246, 0.3); + opacity: 1; + } + + .model-more-btn.model-dropdown-btn { + padding: 4px 10px; + font-size: 11px; + border-color: var(--vscode-panel-border); + } + + .model-more-btn.model-dropdown-btn:hover { + background: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); + } + + .model-more-btn svg { + width: 10px; + height: 10px; + } + + .input-modes { + display: flex; + gap: 16px; + align-items: center; + padding-bottom: 5px; + font-size: 9.5px; + } + + .mode-toggle { + display: flex; + align-items: center; + gap: 5px; + color: var(--vscode-foreground); + opacity: 0.7; + transition: opacity 0.2s ease; + font-size: 10px; + } + + .left-controls .mode-toggle { + padding: 3px 0; + } + + .left-controls .mode-toggle span { + cursor: pointer; + } + + .mode-toggle span { + cursor: pointer; transition: opacity 0.2s ease; } @@ -1411,9 +1887,54 @@ const styles = ` background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); border-radius: 6px; + overflow: visible; + } + + .image-preview-container { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 8px 0; + } + + .image-preview-item { + position: relative; + width: 56px; + height: 56px; + border-radius: 6px; overflow: hidden; + border: 1px solid var(--vscode-input-border); + } + + .image-preview-item img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .image-preview-remove { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 10px; + line-height: 16px; + text-align: center; + cursor: pointer; + padding: 0; + display: none; + } + + .image-preview-item:hover .image-preview-remove { + display: block; } + .textarea-wrapper:focus-within { border-color: var(--vscode-focusBorder); } @@ -1431,6 +1952,7 @@ const styles = ` line-height: 1.4; overflow-y: hidden; resize: none; + border-radius: 6px 6px 0 0; } .input-field:focus { @@ -1452,6 +1974,7 @@ const styles = ` padding: 2px 4px; border-top: 1px solid var(--vscode-panel-border); background-color: var(--vscode-input-background); + border-radius: 0 0 6px 6px; } .left-controls { @@ -1461,24 +1984,51 @@ const styles = ` } .model-selector { - background-color: rgba(128, 128, 128, 0.15); + background: linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(16, 185, 129, 0.15)); color: var(--vscode-foreground); - border: none; - padding: 3px 7px; - border-radius: 4px; + border: 1px solid rgba(139, 92, 246, 0.3); + padding: 4px 10px; + border-radius: 6px; cursor: pointer; font-size: 11px; font-weight: 500; transition: all 0.2s ease; - opacity: 0.9; display: flex; align-items: center; - gap: 4px; + gap: 6px; } .model-selector:hover { - background-color: rgba(128, 128, 128, 0.25); - opacity: 1; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(16, 185, 129, 0.25)); + border-color: rgba(139, 92, 246, 0.5); + } + + .model-selector-label { + display: flex; + align-items: center; + gap: 6px; + } + + .model-selector-label #selectedModel { + font-weight: 600; + color: #a78bfa; + } + + .model-selector-examples { + font-size: 10px; + opacity: 0.6; + font-weight: 400; + } + + .model-selector-badge { + font-size: 8px; + font-weight: 700; + padding: 2px 5px; + border-radius: 3px; + background: linear-gradient(135deg, #f59e0b, #ea580c); + color: white; + text-transform: uppercase; + letter-spacing: 0.3px; } .tools-btn { @@ -1502,69 +2052,194 @@ const styles = ` opacity: 1; } - .slash-btn, - .at-btn { - background-color: transparent; - color: var(--vscode-foreground); + .plus-btn { + background: none; border: none; - padding: 4px 6px; - border-radius: 4px; + color: var(--vscode-foreground); + font-size: 16px; + line-height: 1; cursor: pointer; - font-size: 13px; - font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + opacity: 0.6; transition: all 0.2s ease; } - .slash-btn:hover, - .at-btn:hover { - background-color: var(--vscode-list-hoverBackground); + .plus-btn:hover { + opacity: 1; + background-color: rgba(128, 128, 128, 0.2); } - .image-btn { - background-color: transparent; - color: var(--vscode-foreground); - border: none; - padding: 4px; - border-radius: 4px; - cursor: pointer; + .input-dropdown-btn { display: flex; align-items: center; - justify-content: center; - width: 24px; - height: 24px; - transition: all 0.2s ease; - padding-top: 6px; + gap: 3px; + background: none; + border: none; + color: var(--vscode-descriptionForeground); + font-size: 12px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.15s ease; } - .image-btn:hover { - background-color: var(--vscode-list-hoverBackground); + .input-dropdown-btn:hover { + color: var(--vscode-foreground); + background-color: rgba(128, 128, 128, 0.15); } - .send-btn { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - padding: 3px 7px; - border-radius: 4px; - cursor: pointer; - font-size: 11px; - font-weight: 500; - transition: all 0.2s ease; + #connectBtn { + color: var(--vscode-foreground); + background-color: rgba(128, 128, 128, 0.12); + padding: 3px 8px; } - .send-btn div { + #connectBtn:hover { + background-color: rgba(128, 128, 128, 0.25); + } + + .input-dropdown-btn svg { + opacity: 0.6; + } + + + .input-toggle-btn { display: flex; align-items: center; - justify-content: center; - gap: 2px; + background: none; + border: 1px solid transparent; + color: var(--vscode-descriptionForeground); + font-size: 12px; + cursor: pointer; + padding: 1px 5px; + border-radius: 4px; + transition: all 0.15s ease; } - .send-btn span { - line-height: 1; + .input-toggle-btn:hover { + color: var(--vscode-foreground); + background-color: rgba(128, 128, 128, 0.15); } - .send-btn:hover { - background-color: var(--vscode-button-hoverBackground); + .input-toggle-btn.active { + color: var(--vscode-button-background); + background-color: rgba(0, 122, 204, 0.12); + border-color: rgba(0, 122, 204, 0.3); + } + + .connect-dropdown-wrapper { + position: relative; + } + + .connect-menu { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 6px; + background-color: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + min-width: 180px; + padding: 6px 0; + z-index: 1000; + } + + .connect-menu-header { + padding: 8px 14px 6px; + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + } + + .connect-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 14px; + background: none; + border: none; + color: var(--vscode-foreground); + font-size: 13px; + cursor: pointer; + text-align: left; + transition: background-color 0.1s ease; + } + + .connect-menu-item:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .connect-menu-item svg { + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + } + + .slash-btn, + .at-btn { + background-color: transparent; + color: var(--vscode-foreground); + border: none; + padding: 4px 6px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.2s ease; + } + + .slash-btn:hover, + .at-btn:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .image-btn { + background-color: transparent; + color: var(--vscode-foreground); + border: none; + padding: 4px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + transition: all 0.2s ease; + padding-top: 6px; + } + + .image-btn:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .send-btn { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 3px 7px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 500; + transition: all 0.2s ease; + } + + .send-btn div { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + } + + .send-btn span { + line-height: 1; + } + + .send-btn:hover { + background-color: var(--vscode-button-hoverBackground); } .send-btn:disabled { @@ -1572,6 +2247,28 @@ const styles = ` cursor: not-allowed; } + .stop-inline-btn { + background-color: #ef4444; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 500; + display: none; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 39px; + min-height: 11px; + padding: 3px 7px; + box-sizing: content-box; + } + + .stop-inline-btn:hover { + background-color: #dc2626; + } + .secondary-button { background-color: var(--vscode-button-secondaryBackground, rgba(128, 128, 128, 0.2)); color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); @@ -1765,7 +2462,7 @@ const styles = ` left: 0; width: 100%; height: 100%; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 0.75); z-index: 1000; display: flex; align-items: center; @@ -1778,7 +2475,7 @@ const styles = ` border-radius: 8px; width: 700px; max-width: 90vw; - max-height: 80vh; + max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); @@ -1792,6 +2489,7 @@ const styles = ` justify-content: space-between; align-items: center; flex-shrink: 0; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(59, 130, 246, 0.08)); } .tools-modal-body { @@ -1830,13 +2528,17 @@ const styles = ` } /* MCP Modal content area improvements */ - #mcpModal * { + #mcpModal *, + #skillsModal *, + #pluginsModal * { box-sizing: border-box; } - #mcpModal .tools-list { + #mcpModal .tools-list, + #skillsModal .tools-list, + #pluginsModal .tools-list { padding: 24px; - max-height: calc(80vh - 120px); + max-height: calc(90vh - 120px); overflow-y: auto; width: 100%; } @@ -1930,1047 +2632,1941 @@ const styles = ` align-self: flex-start; } - /* Thinking intensity slider */ - .thinking-slider-container { - position: relative; - padding: 0px 16px; - margin: 12px 0; + /* Model modal styles */ + .model-modal-content { + width: 520px; + max-width: 90vw; + max-height: 80vh; + overflow-y: auto; + display: flex; + flex-direction: column; } - .thinking-slider { - width: 100%; - height: 4px; - -webkit-appearance: none; - appearance: none; - background: var(--vscode-panel-border); - outline: none !important; - border: none; - cursor: pointer; - border-radius: 2px; + .model-section { + padding: 16px; } - .thinking-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 16px; - height: 16px; - background: var(--vscode-foreground); - cursor: pointer; - border-radius: 50%; - transition: transform 0.2s ease; + .model-section.opencredits-section { + border-top: 1px solid var(--vscode-panel-border); } - .thinking-slider::-webkit-slider-thumb:hover { - transform: scale(1.2); + .model-section-header { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; } - .thinking-slider::-moz-range-thumb { - width: 16px; - height: 16px; - background: var(--vscode-foreground); - cursor: pointer; - border-radius: 50%; - border: none; - transition: transform 0.2s ease; + .model-section-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 8px; + color: white; } - .thinking-slider::-moz-range-thumb:hover { - transform: scale(1.2); + .new-badge { + font-size: 9px; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + background: linear-gradient(135deg, #f59e0b, #ea580c); + color: white; + text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.4); } - .slider-labels { - display: flex; - justify-content: space-between; - margin-top: 12px; - padding: 0 8px; + .beta-badge { + font-size: 9px; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + background: rgba(127, 127, 127, 0.25); + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-left: auto; + cursor: default; + position: relative; } - .slider-label { + .beta-badge:hover::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--vscode-editorHoverWidget-background, #1e1e1e); + color: var(--vscode-editorHoverWidget-foreground, #ccc); + border: 1px solid var(--vscode-editorHoverWidget-border, #454545); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 400; + letter-spacing: 0; + text-transform: none; + white-space: nowrap; + z-index: 100; + } + + .model-section-subtitle { font-size: 12px; color: var(--vscode-descriptionForeground); - opacity: 0.7; - transition: all 0.2s ease; - text-align: center; - width: 100px; - cursor: pointer; } - .slider-label:hover { - opacity: 1; - color: var(--vscode-foreground); + .model-section-divider { + height: 1px; + background: var(--vscode-panel-border); + margin: 0 16px; } - .slider-label.active { - opacity: 1; - color: var(--vscode-foreground); - font-weight: 500; + /* Flexible grid for model cards */ + .model-cards-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; } - .slider-label:first-child { - margin-left: -50px; + @media (min-width: 600px) { + .model-cards-container { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } } - .slider-label:last-child { - margin-right: -50px; + .model-card { + position: relative; + padding: 12px; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-left: 3px solid #10b981; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + flex-direction: column; + gap: 4px; } - .settings-group { - padding-bottom: 20px; - margin-bottom: 40px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + .model-card:hover { + border-color: #10b981; + border-left: 3px solid #10b981; + background: rgba(16, 185, 129, 0.1); } - .settings-group h3 { - margin: 0 0 12px 0; + .model-card.selected { + border-color: #10b981; + border-left: 3px solid #10b981; + background: rgba(16, 185, 129, 0.15); + } + + .model-card-name { font-size: 13px; font-weight: 600; color: var(--vscode-foreground); + line-height: 1.3; } + .model-card-provider { + font-size: 10px; + color: var(--vscode-descriptionForeground); + } - /* Thinking intensity modal */ - .thinking-modal-description { - padding: 0px 20px; - font-size: 12px; + .model-card-price { + font-size: 10px; color: var(--vscode-descriptionForeground); - line-height: 1.5; - text-align: center; - margin: 20px; - margin-bottom: 0px; + margin-top: 4px; } - .thinking-modal-actions { - padding-top: 20px; - text-align: right; - border-top: 1px solid var(--vscode-widget-border); + .model-card-requests { + font-size: 10px; + color: var(--vscode-descriptionForeground); + margin-top: 4px; } - .confirm-btn { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: 1px solid var(--vscode-panel-border); - padding: 6px 12px; - border-radius: 4px; - cursor: pointer; - font-size: 12px; - font-weight: 400; - transition: all 0.2s ease; - display: inline-flex; - align-items: center; - gap: 5px; + .claude-card-requests { + font-size: 10px; + color: var(--vscode-descriptionForeground); + margin-top: 4px; } - .confirm-btn:hover { - background-color: var(--vscode-button-background); - border-color: var(--vscode-focusBorder); + .model-section-links { + display: flex; + justify-content: space-between; + width: 100%; + grid-column: 1 / -1; } - /* Slash commands modal */ - .slash-commands-search { - padding: 16px 20px; - border-bottom: 1px solid var(--vscode-panel-border); - position: sticky; - top: 0; - background-color: var(--vscode-editor-background); - z-index: 10; + .model-section-links a { + font-size: 11px; + color: var(--vscode-foreground); + text-decoration: none; } - .search-input-wrapper { - display: flex; - align-items: center; - border: 1px solid var(--vscode-input-border); - border-radius: 6px; - background-color: var(--vscode-input-background); - transition: all 0.2s ease; - position: relative; + .model-section-links a:hover { + text-decoration: underline; } - .search-input-wrapper:focus-within { - border-color: var(--vscode-focusBorder); - box-shadow: 0 0 0 1px var(--vscode-focusBorder); + .custom-provider-field { + margin-bottom: 12px; + overflow: visible; } - .search-prefix { - display: flex; - align-items: center; - justify-content: center; - min-width: 32px; - height: 32px; - background-color: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - font-size: 13px; + .custom-provider-field label { + display: block; + font-size: 11px; font-weight: 600; - border-radius: 4px 0 0 4px; - border-right: 1px solid var(--vscode-input-border); + color: var(--vscode-foreground); + margin-bottom: 4px; } - .slash-commands-search input { - flex: 1; - padding: 8px 12px; - border: none !important; - background: transparent; + .custom-provider-field input { + width: 100%; + padding: 8px 10px; + font-size: 12px; + font-family: inherit; color: var(--vscode-input-foreground); - font-size: 13px; - outline: none !important; - box-shadow: none !important; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.1)); + border-radius: 4px; + outline: none; } - .slash-commands-search input:focus { - border: none !important; - outline: none !important; - box-shadow: none !important; + .custom-provider-field input:focus { + border-color: var(--vscode-focusBorder); } - .slash-commands-search input::placeholder { - color: var(--vscode-input-placeholderForeground); + .model-combo { + position: relative; + max-width: 100%; + overflow: visible; } - .command-input-wrapper { - display: flex; - align-items: center; - border: 1px solid var(--vscode-input-border); - border-radius: 6px; - background-color: var(--vscode-input-background); - transition: all 0.2s ease; + .model-combo-input { width: 100%; - position: relative; + padding: 8px 28px 8px 10px; + font-size: 12px; + font-family: inherit; + color: var(--vscode-input-foreground); + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, rgba(255,255,255,0.2)); + border-radius: 4px; + outline: none; + box-sizing: border-box; } - .command-input-wrapper:focus-within { + .model-combo::after { + content: ''; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid var(--vscode-descriptionForeground, #888); + pointer-events: none; + } + + .model-combo-input:focus { border-color: var(--vscode-focusBorder); - box-shadow: 0 0 0 1px var(--vscode-focusBorder); } - .command-prefix { - display: flex; - align-items: center; - justify-content: center; - min-width: 32px; - height: 32px; - background-color: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - font-size: 12px; - font-weight: 600; - border-radius: 4px 0 0 4px; - border-right: 1px solid var(--vscode-input-border); + .model-combo-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 120px; + overflow-y: auto; + background: var(--vscode-dropdown-background, #1e1e1e); + border: 1px solid var(--vscode-dropdown-border, rgba(255,255,255,0.2)); + border-radius: 4px; + margin-top: 2px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); } - .slash-commands-section { - margin-bottom: 32px; + .model-combo.open .model-combo-dropdown { + display: block; } - .slash-commands-section:last-child { - margin-bottom: 16px; + .model-combo-option { + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + color: var(--vscode-dropdown-foreground); } - .slash-commands-section h3 { - margin: 16px 20px 12px 20px; - font-size: 14px; - font-weight: 600; - color: var(--vscode-foreground); + .model-combo-option:hover { + background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.05)); } - .slash-commands-info { - padding: 12px 20px; - background-color: rgba(255, 149, 0, 0.1); - border: 1px solid rgba(255, 149, 0, 0.2); - border-radius: 4px; - margin: 0 20px 16px 20px; + .model-combo-option .model-combo-option-name { + font-weight: 500; } - .slash-commands-info p { - margin: 0; - font-size: 11px; - color: var(--vscode-descriptionForeground); - text-align: center; - opacity: 0.9; + .model-combo-option .model-combo-option-id { + font-size: 10px; + opacity: 0.6; } - .prompt-snippet-item { - border-left: 2px solid var(--vscode-charts-blue); - background-color: rgba(0, 122, 204, 0.03); + .model-combo-custom { + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + color: var(--vscode-textLink-foreground, #3794ff); + border-top: 1px solid var(--vscode-dropdown-border, rgba(255,255,255,0.1)); } - .prompt-snippet-item:hover { - background-color: rgba(0, 122, 204, 0.08); + .model-combo-custom:hover { + background: var(--vscode-list-hoverBackground, rgba(255,255,255,0.05)); } - .add-snippet-item { - border-left: 2px solid var(--vscode-charts-green); - background-color: rgba(0, 200, 83, 0.03); - border-style: dashed; + .model-comparison-header { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 10px; + line-height: 1.5; } - .add-snippet-item:hover { - background-color: rgba(0, 200, 83, 0.08); - border-style: solid; + .model-card-unlock { + font-size: 9px; + color: #10b981; + margin-top: 6px; + font-weight: 500; } - .add-snippet-form { - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 6px; - padding: 16px; - margin: 8px 0; - animation: slideDown 0.2s ease; + .model-card.pending { + border-color: rgba(249, 115, 22, 0.5); + background: rgba(249, 115, 22, 0.1); } - .add-snippet-form .form-group { - margin-bottom: 12px; + .model-card-price-label { + display: none; } - .add-snippet-form label { - display: block; - margin-bottom: 4px; + .price-current { font-weight: 500; - font-size: 12px; - color: var(--vscode-foreground); } - .add-snippet-form textarea { - width: 100%; - padding: 6px 8px; - border: 1px solid var(--vscode-input-border); + .price-comparison { + margin-left: 4px; + opacity: 0.7; + } + + .price-comparison s { + text-decoration: line-through; + } + + /* Savings badge */ + .savings-badge { + position: absolute; + top: 8px; + right: 8px; + font-size: 9px; + font-weight: 600; + padding: 2px 6px; border-radius: 3px; - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - font-size: 12px; - font-family: var(--vscode-font-family); - box-sizing: border-box; + background: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.3); } - .add-snippet-form .command-input-wrapper input { - flex: 1; - padding: 6px 8px; - border: none !important; - background: transparent; - color: var(--vscode-input-foreground); - font-size: 12px; - font-family: var(--vscode-font-family); - outline: none !important; - box-shadow: none !important; + /* More models card */ + .more-models-card { + background: var(--vscode-button-secondaryBackground) !important; + border-style: dashed !important; } - .add-snippet-form .command-input-wrapper input:focus { - border: none !important; - outline: none !important; - box-shadow: none !important; + .more-models-card:hover { + background: var(--vscode-button-secondaryHoverBackground) !important; } - .add-snippet-form textarea:focus { - outline: none; - border-color: var(--vscode-focusBorder); + .more-models-card .savings-badge { + display: none; } - .add-snippet-form input::placeholder, - .add-snippet-form textarea::placeholder { - color: var(--vscode-input-placeholderForeground); + /* All models browser */ + .all-models-search { + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-panel-border); } - .add-snippet-form textarea { - resize: vertical; - min-height: 60px; + .all-models-search input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: 4px; + font-size: 13px; + box-sizing: border-box; } - .add-snippet-form .form-buttons { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 12px; + .all-models-search input:focus { + outline: none; + border-color: var(--vscode-focusBorder); } - .custom-snippet-item { - position: relative; + .all-models-list { + max-height: 400px; + overflow-y: auto; + padding: 4px 8px; } - .snippet-actions { + .all-models-item { display: flex; + justify-content: space-between; align-items: center; - opacity: 0; - transition: opacity 0.2s ease; - margin-left: 8px; + padding: 10px 16px; + border-radius: 6px; + cursor: pointer; + margin-bottom: 2px; + background: var(--vscode-list-hoverBackground); } - .custom-snippet-item:hover .snippet-actions { - opacity: 1; + .all-models-item:hover { + background: var(--vscode-list-activeSelectionBackground); } - .snippet-delete-btn { - background: none; - border: none; - color: var(--vscode-descriptionForeground); - cursor: pointer; - padding: 4px; - border-radius: 3px; - font-size: 12px; - transition: all 0.2s ease; - opacity: 0.7; + .all-models-item.selected { + background: var(--vscode-list-activeSelectionBackground); + border: 1px solid var(--vscode-focusBorder); } - .snippet-delete-btn:hover { - background-color: rgba(231, 76, 60, 0.1); - color: var(--vscode-errorForeground); - opacity: 1; + .all-models-item-main { + flex: 1; + min-width: 0; } - .slash-commands-list { - display: grid; - gap: 6px; - padding: 0 20px; + .all-models-item-name { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - .slash-command-item { + .all-models-item-provider { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-top: 2px; + } + + .all-models-item-details { display: flex; - align-items: center; gap: 12px; - padding: 10px 14px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - border: 1px solid transparent; - background-color: transparent; + align-items: center; + flex-shrink: 0; } - .slash-command-item:hover { - background-color: var(--vscode-list-hoverBackground); - border-color: var(--vscode-list-hoverBackground); + .all-models-item-context { + font-size: 11px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-badge-background); + padding: 2px 6px; + border-radius: 3px; } - .slash-command-icon { - font-size: 16px; - min-width: 20px; + .all-models-item-price { + font-size: 11px; + color: var(--vscode-descriptionForeground); + } + + .all-models-loading, + .all-models-error, + .all-models-empty { text-align: center; - opacity: 0.8; + padding: 40px 20px; + color: var(--vscode-descriptionForeground); } - .slash-command-content { - flex: 1; + .all-models-error { + color: var(--vscode-errorForeground); } - .slash-command-title { - font-size: 13px; - font-weight: 500; - color: var(--vscode-foreground); - margin-bottom: 2px; + + /* Claude Code model cards */ + .claude-cards-container { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; } - .slash-command-description { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.7; - line-height: 1.3; + .claude-card { + padding: 12px; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-panel-border); + border-left: 3px solid #8b5cf6; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + flex-direction: column; + gap: 4px; } - /* Quick command input */ - .custom-command-item { - cursor: default; + .claude-card:hover { + border-color: #8b5cf6; + border-left: 3px solid #8b5cf6; + background: rgba(139, 92, 246, 0.1); } - .custom-command-item .command-input-wrapper { - margin-top: 4px; - max-width: 200px; + .claude-card.selected { + border-color: #8b5cf6; + border-left: 3px solid #8b5cf6; + background: rgba(139, 92, 246, 0.15); } - .custom-command-item .command-input-wrapper input { - flex: 1; - padding: 4px 6px; - border: none !important; - background: transparent; - color: var(--vscode-input-foreground); - font-size: 11px; - font-family: var(--vscode-editor-font-family); - outline: none !important; - box-shadow: none !important; + .claude-card-name { + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); } - .custom-command-item .command-input-wrapper input:focus { - border: none !important; - outline: none !important; - box-shadow: none !important; + .claude-card-desc { + font-size: 10px; + color: var(--vscode-descriptionForeground); + line-height: 1.3; } - .custom-command-item .command-input-wrapper input::placeholder { - color: var(--vscode-input-placeholderForeground); - opacity: 0.7; + /* Thinking intensity slider */ + .thinking-slider-container { + position: relative; + padding: 0px 16px; + margin: 12px 0; } - .status { - padding: 8px 12px; - background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%); - color: #e1e1e1; - font-size: 12px; - border-top: 1px solid var(--vscode-panel-border); - display: flex; - align-items: center; - gap: 8px; - font-weight: 500; + .thinking-slider { + width: 100%; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: var(--vscode-panel-border); + outline: none !important; + border: none; + cursor: pointer; + border-radius: 2px; } - .status-indicator { - width: 8px; - height: 8px; + .thinking-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background: var(--vscode-foreground); + cursor: pointer; border-radius: 50%; - flex-shrink: 0; + transition: transform 0.2s ease; } - .status.ready .status-indicator { - background-color: #00d26a; - box-shadow: 0 0 6px rgba(0, 210, 106, 0.5); + .thinking-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); } - .status.processing .status-indicator { - background-color: #ff9500; - box-shadow: 0 0 6px rgba(255, 149, 0, 0.5); - animation: pulse 1.5s ease-in-out infinite; + .thinking-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: var(--vscode-foreground); + cursor: pointer; + border-radius: 50%; + border: none; + transition: transform 0.2s ease; } - .status.error .status-indicator { - background-color: #ff453a; - box-shadow: 0 0 6px rgba(255, 69, 58, 0.5); + .thinking-slider::-moz-range-thumb:hover { + transform: scale(1.2); } - @keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.7; transform: scale(1.1); } + .slider-labels { + display: flex; + justify-content: space-between; + margin-top: 12px; + padding: 0 8px; } - .status-text { - flex: 1; + .slider-label { + font-size: 12px; + color: var(--vscode-descriptionForeground); + opacity: 0.7; + transition: all 0.2s ease; + text-align: center; + width: 100px; + cursor: pointer; } - .status-text .usage-badge { - display: inline-flex; - align-items: center; - gap: 4px; - color: inherit; - text-decoration: none; - background: rgba(255, 255, 255, 0.08); - padding: 2px 8px 2px 8px; - border-radius: 10px; - cursor: pointer; - transition: background 0.15s, transform 0.1s; + .slider-label:hover { + opacity: 1; + color: var(--vscode-foreground); } - .status-text .usage-badge:hover { - background: rgba(255, 255, 255, 0.15); - transform: translateY(-1px); + .slider-label.active { + opacity: 1; + color: var(--vscode-foreground); + font-weight: 500; } - .status-text .usage-badge:active { - transform: translateY(0); + .slider-label:first-child { + margin-left: -50px; } - .status-text .usage-icon { - width: 12px; - height: 12px; - flex-shrink: 0; + .slider-label:last-child { + margin-right: -50px; } - pre { - white-space: pre-wrap; - word-wrap: break-word; - margin: 0; + .settings-group { + padding-bottom: 20px; + margin-bottom: 40px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); } - .session-badge { - margin-left: 16px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - padding: 4px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 500; - display: flex; - align-items: center; - gap: 4px; - transition: background-color 0.2s, transform 0.1s; + .settings-group h3 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); } - .session-badge:hover { - background-color: var(--vscode-button-hoverBackground); - transform: scale(1.02); + + /* Thinking intensity modal */ + .thinking-modal-description { + padding: 0px 20px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + text-align: center; + margin: 20px; + margin-bottom: 0px; + } + + .thinking-modal-actions { + padding-top: 20px; + text-align: right; + border-top: 1px solid var(--vscode-widget-border); + } + + .confirm-btn { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: 1px solid var(--vscode-panel-border); + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 400; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 5px; + } + + .confirm-btn:hover { + background-color: var(--vscode-button-background); + border-color: var(--vscode-focusBorder); + } + + /* Slash commands modal */ + .slash-commands-search { + padding: 16px 20px; + border-bottom: 1px solid var(--vscode-panel-border); + position: sticky; + top: 0; + background-color: var(--vscode-editor-background); + z-index: 10; + } + + .search-input-wrapper { + display: flex; + align-items: center; + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + background-color: var(--vscode-input-background); + transition: all 0.2s ease; + position: relative; + } + + .search-input-wrapper:focus-within { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); + } + + .search-prefix { + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font-size: 13px; + font-weight: 600; + border-radius: 4px 0 0 4px; + border-right: 1px solid var(--vscode-input-border); + } + + .slash-commands-search input { + flex: 1; + padding: 8px 12px; + border: none !important; + background: transparent; + color: var(--vscode-input-foreground); + font-size: 13px; + outline: none !important; + box-shadow: none !important; + } + + .slash-commands-search input:focus { + border: none !important; + outline: none !important; + box-shadow: none !important; + } + + .slash-commands-search input::placeholder { + color: var(--vscode-input-placeholderForeground); + } + + .command-input-wrapper { + display: flex; + align-items: center; + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + background-color: var(--vscode-input-background); + transition: all 0.2s ease; + width: 100%; + position: relative; + } + + .command-input-wrapper:focus-within { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); + } + + .command-prefix { + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font-size: 12px; + font-weight: 600; + border-radius: 4px 0 0 4px; + border-right: 1px solid var(--vscode-input-border); + } + + .slash-commands-section { + margin-bottom: 32px; + } + + .slash-commands-section:last-child { + margin-bottom: 16px; + } + + .slash-commands-section h3 { + margin: 16px 20px 12px 20px; + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); + } + + .slash-commands-info { + padding: 12px 20px; + background-color: rgba(255, 149, 0, 0.1); + border: 1px solid rgba(255, 149, 0, 0.2); + border-radius: 4px; + margin: 0 20px 16px 20px; + } + + .slash-commands-info p { + margin: 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); + text-align: center; + opacity: 0.9; + } + + .prompt-snippet-item { + border-left: 2px solid var(--vscode-charts-blue); + background-color: rgba(0, 122, 204, 0.03); + } + + .prompt-snippet-item:hover { + background-color: rgba(0, 122, 204, 0.08); + } + + .add-snippet-item { + border-left: 2px solid var(--vscode-charts-green); + background-color: rgba(0, 200, 83, 0.03); + border-style: dashed; + } + + .add-snippet-item:hover { + background-color: rgba(0, 200, 83, 0.08); + border-style: solid; + } + + .add-snippet-form { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + padding: 16px; + margin: 8px 0; + animation: slideDown 0.2s ease; + } + + .add-snippet-form .form-group { + margin-bottom: 12px; + } + + .add-snippet-form label { + display: block; + margin-bottom: 4px; + font-weight: 500; + font-size: 12px; + color: var(--vscode-foreground); + } + + .add-snippet-form textarea { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--vscode-input-border); + border-radius: 3px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + font-family: var(--vscode-font-family); + box-sizing: border-box; + } + + .add-snippet-form .command-input-wrapper input { + flex: 1; + padding: 6px 8px; + border: none !important; + background: transparent; + color: var(--vscode-input-foreground); + font-size: 12px; + font-family: var(--vscode-font-family); + outline: none !important; + box-shadow: none !important; + } + + .add-snippet-form .command-input-wrapper input:focus { + border: none !important; + outline: none !important; + box-shadow: none !important; + } + + .add-snippet-form textarea:focus { + outline: none; + border-color: var(--vscode-focusBorder); + } + + .add-snippet-form input::placeholder, + .add-snippet-form textarea::placeholder { + color: var(--vscode-input-placeholderForeground); + } + + .add-snippet-form textarea { + resize: vertical; + min-height: 60px; + } + + .add-snippet-form .form-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 12px; + } + + .custom-snippet-item { + position: relative; + } + + .snippet-actions { + display: flex; + align-items: center; + opacity: 0; + transition: opacity 0.2s ease; + margin-left: 8px; + } + + .custom-snippet-item:hover .snippet-actions { + opacity: 1; + } + + .snippet-delete-btn { + background: none; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 4px; + border-radius: 3px; + font-size: 12px; + transition: all 0.2s ease; + opacity: 0.7; + } + + .snippet-delete-btn:hover { + background-color: rgba(231, 76, 60, 0.1); + color: var(--vscode-errorForeground); + opacity: 1; + } + + .slash-commands-list { + display: grid; + gap: 6px; + padding: 0 20px; + } + + .slash-command-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + border: 1px solid transparent; + background-color: transparent; + } + + .slash-command-item:hover { + background-color: var(--vscode-list-hoverBackground); + border-color: var(--vscode-list-hoverBackground); + } + + .slash-command-icon { + font-size: 16px; + min-width: 20px; + text-align: center; + opacity: 0.8; + } + + .slash-command-content { + flex: 1; + } + + .slash-command-title { + font-size: 13px; + font-weight: 500; + color: var(--vscode-foreground); + margin-bottom: 2px; + } + + .slash-command-description { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.7; + line-height: 1.3; + } + + /* Quick command input */ + .custom-command-item { + cursor: default; + } + + .custom-command-item .command-input-wrapper { + margin-top: 4px; + max-width: 200px; + } + + .custom-command-item .command-input-wrapper input { + flex: 1; + padding: 4px 6px; + border: none !important; + background: transparent; + color: var(--vscode-input-foreground); + font-size: 11px; + font-family: var(--vscode-editor-font-family); + outline: none !important; + box-shadow: none !important; + } + + .custom-command-item .command-input-wrapper input:focus { + border: none !important; + outline: none !important; + box-shadow: none !important; + } + + .custom-command-item .command-input-wrapper input::placeholder { + color: var(--vscode-input-placeholderForeground); + opacity: 0.7; + } + + .status { + padding: 8px 12px; + background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%); + color: #e1e1e1; + font-size: 12px; + border-top: 1px solid var(--vscode-panel-border); + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + } + + .status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .status.ready .status-indicator { + background-color: #00d26a; + box-shadow: 0 0 6px rgba(0, 210, 106, 0.5); + } + + .status.processing .status-indicator { + background-color: #ff9500; + box-shadow: 0 0 6px rgba(255, 149, 0, 0.5); + animation: pulse 1.5s ease-in-out infinite; + } + + .status.error .status-indicator { + background-color: #ff453a; + box-shadow: 0 0 6px rgba(255, 69, 58, 0.5); + } + + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.1); } + } + + .status-text { + flex: 1; + } + + .support-btn { + background: none; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 2px 4px; + opacity: 0.6; + font-size: 11px; + display: flex; + align-items: center; + gap: 4px; + } + + .support-btn:hover { + opacity: 1; + } + + .status-text .usage-badge { + display: inline-flex; + align-items: center; + gap: 4px; + color: inherit; + text-decoration: none; + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px 2px 8px; + border-radius: 10px; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + } + + .status-text .usage-badge:hover { + background: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); + } + + .status-text .usage-badge:active { + transform: translateY(0); + } + + .status-text .usage-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + } + + pre { + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + } + + .session-badge { + margin-left: 16px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 4px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; + transition: background-color 0.2s, transform 0.1s; + } + + .session-badge:hover { + background-color: var(--vscode-button-hoverBackground); + transform: scale(1.02); } .session-icon { font-size: 10px; } - .session-label { - opacity: 0.8; - font-size: 10px; + .session-label { + opacity: 0.8; + font-size: 10px; + } + + .session-status { + font-size: 12px; + color: var(--vscode-descriptionForeground); + padding: 2px 6px; + border-radius: 4px; + background-color: var(--vscode-badge-background); + border: 1px solid var(--vscode-panel-border); + } + + .session-status.active { + color: var(--vscode-terminal-ansiGreen); + background-color: rgba(0, 210, 106, 0.1); + border-color: var(--vscode-terminal-ansiGreen); + } + + /* Markdown content styles */ + .message h1, .message h2, .message h3, .message h4 { + margin: 0.8em 0 0.4em 0; + font-weight: 600; + line-height: 1.3; + } + + .message h1 { + font-size: 1.5em; + border-bottom: 2px solid var(--vscode-panel-border); + padding-bottom: 0.3em; + } + + .message h2 { + font-size: 1.3em; + border-bottom: 1px solid var(--vscode-panel-border); + padding-bottom: 0.2em; + } + + .message h3 { + font-size: 1.1em; + } + + .message h4 { + font-size: 1.05em; + } + + .message strong { + font-weight: 600; + color: var(--vscode-terminal-ansiBrightWhite); + } + + .message em { + font-style: italic; + } + + .message ul, .message ol { + margin: 0.6em 0; + padding-left: 1.5em; + } + + .message li { + margin: 0.3em 0; + line-height: 1.4; + } + + .message ul li { + list-style-type: disc; + } + + .message ol li { + list-style-type: decimal; + } + + .message p { + margin: 0.5em 0; + line-height: 1.6; + } + + .message p:first-child { + margin-top: 0; + } + + .message p:last-child { + margin-bottom: 0; + } + + .message br { + line-height: 1.2; + } + + .restore-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px + } + + .restore-btn { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + } + + .restore-btn.dark { + background-color: #2d2d30; + color: #999999; + } + + .restore-btn:hover { + background-color: var(--vscode-button-hoverBackground); + } + + .restore-btn.dark:hover { + background-color: #3e3e42; + } + + .restore-date { + font-size: 10px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + } + + .conversation-history { + position: absolute; + top: 60px; + left: 0; + right: 0; + bottom: 60px; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-widget-border); + z-index: 1000; + } + + .conversation-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-widget-border); + } + + .conversation-header h3 { + margin: 0; + font-size: 16px; + } + + .conversation-list { + padding: 8px; + overflow-y: auto; + height: calc(100% - 60px); + } + + .conversation-item { + padding: 12px; + margin: 4px 0; + border: 1px solid var(--vscode-widget-border); + border-radius: 6px; + cursor: pointer; + background-color: var(--vscode-list-inactiveSelectionBackground); + } + + .conversation-item:hover { + background-color: var(--vscode-list-hoverBackground); + } + + .conversation-title { + font-weight: 500; + margin-bottom: 4px; + } + + .conversation-meta { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; + } + + .conversation-preview { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; + } + + /* Tool loading animation */ + .tool-loading { + padding: 16px 12px; + display: flex; + align-items: center; + gap: 12px; + background-color: var(--vscode-panel-background); + border-top: 1px solid var(--vscode-panel-border); + } + + .loading-spinner { + display: flex; + gap: 4px; + } + + .loading-ball { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--vscode-button-background); + animation: bounce 1.4s ease-in-out infinite both; + } + + .loading-ball:nth-child(1) { animation-delay: -0.32s; } + .loading-ball:nth-child(2) { animation-delay: -0.16s; } + .loading-ball:nth-child(3) { animation-delay: 0s; } + + @keyframes bounce { + 0%, 80%, 100% { + transform: scale(0.6); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } + } + + .loading-text { + font-size: 12px; + color: var(--vscode-descriptionForeground); + font-style: italic; + } + + /* Tool completion indicator */ + .tool-completion { + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + background-color: rgba(76, 175, 80, 0.1); + border-top: 1px solid rgba(76, 175, 80, 0.2); + font-size: 12px; + } + + .completion-icon { + color: #4caf50; + font-weight: bold; + } + + .completion-text { + color: var(--vscode-foreground); + opacity: 0.8; + } + + /* MCP Servers styles */ + .mcp-servers-list { + padding: 4px; + } + + .mcp-server-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + margin-bottom: 8px; + background-color: var(--vscode-editor-background); + transition: all 0.2s ease; + flex-wrap: wrap; + } + + .mcp-server-item:hover { + border-color: var(--vscode-focusBorder); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .server-info { + flex: 1; + min-width: 0; + } + + .server-name { + font-weight: 600; + font-size: 14px; + color: var(--vscode-foreground); + margin-bottom: 4px; + } + + .server-type { + display: inline-block; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + margin-bottom: 8px; } - .session-status { + .server-config { font-size: 12px; color: var(--vscode-descriptionForeground); - padding: 2px 6px; - border-radius: 4px; - background-color: var(--vscode-badge-background); - border: 1px solid var(--vscode-panel-border); - } - - .session-status.active { - color: var(--vscode-terminal-ansiGreen); - background-color: rgba(0, 210, 106, 0.1); - border-color: var(--vscode-terminal-ansiGreen); + opacity: 0.9; + line-height: 1.4; + word-break: break-all; } - /* Markdown content styles */ - .message h1, .message h2, .message h3, .message h4 { - margin: 0.8em 0 0.4em 0; - font-weight: 600; - line-height: 1.3; + .server-delete-btn { + padding: 4px 10px; + font-size: 12px; + color: var(--vscode-errorForeground); + border-color: var(--vscode-errorForeground); + justify-content: center; } - .message h1 { - font-size: 1.5em; - border-bottom: 2px solid var(--vscode-panel-border); - padding-bottom: 0.3em; + .server-delete-btn:hover { + background-color: var(--vscode-inputValidation-errorBackground); + border-color: var(--vscode-errorForeground); } - .message h2 { - font-size: 1.3em; - border-bottom: 1px solid var(--vscode-panel-border); - padding-bottom: 0.2em; + .server-actions { + display: flex; + gap: 8px; + align-items: center; + flex-shrink: 0; } - .message h3 { - font-size: 1.1em; + .server-edit-btn { + padding: 4px 10px; + font-size: 12px; + color: var(--vscode-foreground); + border-color: var(--vscode-panel-border); + transition: all 0.2s ease; + justify-content: center; } - .message h4 { - font-size: 1.05em; + .server-edit-btn:hover { + background-color: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); } - .message strong { - font-weight: 600; - color: var(--vscode-terminal-ansiBrightWhite); + .mcp-add-server { + text-align: center; + margin-bottom: 24px; + padding: 0 4px; } - .message em { - font-style: italic; + .mcp-add-form { + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + padding: 24px; + margin-top: 20px; + box-sizing: border-box; + width: 100%; } - .message ul, .message ol { - margin: 0.6em 0; - padding-left: 1.5em; + .form-group { + margin-bottom: 20px; + box-sizing: border-box; + width: 100%; } - .message li { - margin: 0.3em 0; - line-height: 1.4; + .form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 13px; + color: var(--vscode-foreground); } - .message ul li { - list-style-type: disc; + .form-group input, + .form-group select, + .form-group textarea { + width: 100%; + max-width: 100%; + padding: 8px 12px; + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 13px; + font-family: var(--vscode-font-family); + box-sizing: border-box; + resize: vertical; } - .message ol li { - list-style-type: decimal; + .form-group input:focus, + .form-group select:focus, + .form-group textarea:focus { + outline: none; + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); } - .message p { - margin: 0.5em 0; - line-height: 1.6; + .form-group textarea { + resize: vertical; + min-height: 60px; } - .message p:first-child { - margin-top: 0; + .form-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 20px; } - .message p:last-child { + .mcp-add-server { margin-bottom: 0; + padding: 0 4px; } - .message br { - line-height: 1.2; + .mcp-auth-btn { + color: var(--vscode-textLink-foreground); + font-size: 12px; + cursor: pointer; + position: relative; } - .restore-container { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px + .mcp-auth-btn:hover { + text-decoration: underline; } - .restore-btn { - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - padding: 4px 10px; + .mcp-auth-btn:hover::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + right: 0; + background: var(--vscode-editorHoverWidget-background, #1e1e1e); + color: var(--vscode-editorHoverWidget-foreground, #ccc); + border: 1px solid var(--vscode-editorHoverWidget-border, #454545); + padding: 4px 8px; border-radius: 4px; - cursor: pointer; - font-size: 12px; - font-weight: 500; + font-size: 11px; + white-space: nowrap; + z-index: 100; } - .restore-btn.dark { - background-color: #2d2d30; - color: #999999; + .no-servers { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 20px 12px; + color: var(--vscode-descriptionForeground); } - .restore-btn:hover { - background-color: var(--vscode-button-hoverBackground); + .no-servers-icon { + opacity: 0.4; } - .restore-btn.dark:hover { - background-color: #3e3e42; + .no-servers-text { + font-size: 13px; } - .restore-date { - font-size: 10px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; + .no-servers-btn { + margin-top: 4px; + font-size: 12px; } - .conversation-history { - position: absolute; - top: 60px; - left: 0; - right: 0; - bottom: 60px; - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-widget-border); - z-index: 1000; + /* Popular MCP Servers */ + .mcp-popular-servers { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--vscode-panel-border); } - .conversation-header { + .mcp-popular-servers h4 { + margin: 0 0 16px 0; + font-size: 14px; + font-weight: 600; + color: var(--vscode-foreground); + opacity: 0.9; + } + + .skill-item-row { display: flex; - justify-content: space-between; align-items: center; - padding: 12px 16px; - border-bottom: 1px solid var(--vscode-widget-border); + gap: 12px; + width: 100%; } - .conversation-header h3 { - margin: 0; - font-size: 16px; + .skill-item-info { + flex: 1; + min-width: 0; + overflow: hidden; } - .conversation-list { - padding: 8px; - overflow-y: auto; - height: calc(100% - 60px); + .skill-item-desc { + font-size: 12px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - .conversation-item { - padding: 12px; - margin: 4px 0; - border: 1px solid var(--vscode-widget-border); + .popular-servers-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + } + + .popular-server-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); border-radius: 6px; cursor: pointer; - background-color: var(--vscode-list-inactiveSelectionBackground); + transition: all 0.2s ease; } - .conversation-item:hover { + .popular-server-item:hover { + border-color: var(--vscode-focusBorder); background-color: var(--vscode-list-hoverBackground); + transform: translateY(-1px); } - .conversation-title { - font-weight: 500; - margin-bottom: 4px; + .popular-server-icon { + font-size: 24px; + flex-shrink: 0; } - .conversation-meta { - font-size: 12px; - color: var(--vscode-descriptionForeground); - margin-bottom: 4px; + .popular-server-info { + flex: 1; + min-width: 0; } - .conversation-preview { + .popular-server-name { + font-weight: 600; + font-size: 13px; + color: var(--vscode-foreground); + margin-bottom: 2px; + } + + .popular-server-desc { font-size: 11px; color: var(--vscode-descriptionForeground); opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - /* Tool loading animation */ - .tool-loading { - padding: 16px 12px; + /* MCP Tabs */ + .mcp-tabs { display: flex; - align-items: center; - gap: 12px; - background-color: var(--vscode-panel-background); - border-top: 1px solid var(--vscode-panel-border); + gap: 0; } - .loading-spinner { - display: flex; - gap: 4px; + .mcp-tab { + background: none; + border: none; + color: var(--vscode-descriptionForeground); + font-size: 14px; + font-weight: 500; + cursor: pointer; + padding: 4px 12px; + border-bottom: 2px solid transparent; + transition: all 0.15s ease; } - .loading-ball { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--vscode-button-background); - animation: bounce 1.4s ease-in-out infinite both; + .mcp-tab:hover { + color: var(--vscode-foreground); } - .loading-ball:nth-child(1) { animation-delay: -0.32s; } - .loading-ball:nth-child(2) { animation-delay: -0.16s; } - .loading-ball:nth-child(3) { animation-delay: 0s; } - - @keyframes bounce { - 0%, 80%, 100% { - transform: scale(0.6); - opacity: 0.5; - } - 40% { - transform: scale(1); - opacity: 1; - } + .mcp-tab.active { + color: var(--vscode-foreground); + border-bottom-color: var(--vscode-button-background); } - .loading-text { - font-size: 12px; - color: var(--vscode-descriptionForeground); - font-style: italic; + /* MCP Marketplace */ + .marketplace-search { + padding: 0 0 12px 0; } - /* Tool completion indicator */ - .tool-completion { + .marketplace-search input { + width: 100%; padding: 8px 12px; - display: flex; - align-items: center; - gap: 6px; - background-color: rgba(76, 175, 80, 0.1); - border-top: 1px solid rgba(76, 175, 80, 0.2); - font-size: 12px; + font-size: 13px; + font-family: var(--vscode-font-family); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border)); + border-radius: 6px; + outline: none; + box-sizing: border-box; } - .completion-icon { - color: #4caf50; - font-weight: bold; + .marketplace-search input:focus { + border-color: var(--vscode-focusBorder); } - .completion-text { - color: var(--vscode-foreground); - opacity: 0.8; + .marketplace-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; } - /* MCP Servers styles */ - .mcp-servers-list { - padding: 4px; + .marketplace-item { + padding: 12px; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-panel-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; } - .mcp-server-item { + .marketplace-item:hover { + border-color: var(--vscode-focusBorder); + background-color: var(--vscode-list-hoverBackground); + transform: translateY(-1px); + } + + .marketplace-item-header { display: flex; align-items: center; - justify-content: space-between; - padding: 20px 24px; - border: 1px solid var(--vscode-panel-border); - border-radius: 8px; - margin-bottom: 16px; - background-color: var(--vscode-editor-background); - transition: all 0.2s ease; + gap: 10px; + margin-bottom: 6px; } - .mcp-server-item:hover { - border-color: var(--vscode-focusBorder); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + .marketplace-item-icon { + width: 28px; + height: 28px; + border-radius: 6px; + flex-shrink: 0; + object-fit: cover; } - .server-info { + .marketplace-item-icon-placeholder { + width: 28px; + height: 28px; + border-radius: 6px; + flex-shrink: 0; + background-color: rgba(128, 128, 128, 0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + } + + .marketplace-item-info { flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; } - .server-name { + .marketplace-item-name { font-weight: 600; - font-size: 16px; + font-size: 13px; color: var(--vscode-foreground); - margin-bottom: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - .server-type { - display: inline-block; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - padding: 4px 8px; - border-radius: 4px; - font-size: 11px; - font-weight: 500; - margin-bottom: 8px; + .marketplace-item-type { + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + background-color: rgba(128, 128, 128, 0.15); + color: var(--vscode-descriptionForeground); + flex-shrink: 0; } - .server-config { - font-size: 13px; + .marketplace-item-desc { + font-size: 11px; color: var(--vscode-descriptionForeground); - opacity: 0.9; line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; } - .server-delete-btn { - padding: 8px 16px; - font-size: 13px; - color: var(--vscode-errorForeground); - border-color: var(--vscode-errorForeground); - min-width: 80px; - justify-content: center; + .marketplace-item-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; } - .server-delete-btn:hover { - background-color: var(--vscode-inputValidation-errorBackground); - border-color: var(--vscode-errorForeground); + .marketplace-item-stars { + font-size: 11px; + color: var(--vscode-descriptionForeground); } - .server-actions { + .marketplace-item-lang { display: flex; - gap: 8px; align-items: center; - flex-shrink: 0; + gap: 4px; + font-size: 11px; + color: var(--vscode-descriptionForeground); } - .server-edit-btn { - padding: 8px 16px; - font-size: 13px; - color: var(--vscode-foreground); - border-color: var(--vscode-panel-border); - min-width: 80px; - transition: all 0.2s ease; - justify-content: center; + .lang-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; } - .server-edit-btn:hover { - background-color: var(--vscode-list-hoverBackground); - border-color: var(--vscode-focusBorder); + .marketplace-item-license { + font-size: 10px; + color: var(--vscode-descriptionForeground); } - .mcp-add-server { - text-align: center; - margin-bottom: 24px; - padding: 0 4px; + .marketplace-detail-meta { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; } - .mcp-add-form { - background-color: var(--vscode-editor-background); - border: 1px solid var(--vscode-panel-border); - border-radius: 8px; - padding: 24px; - margin-top: 20px; - box-sizing: border-box; - width: 100%; + .marketplace-detail-link { + color: var(--vscode-textLink-foreground); + font-size: 12px; + text-decoration: none; } - .form-group { - margin-bottom: 20px; - box-sizing: border-box; - width: 100%; + .marketplace-detail-link:hover { + text-decoration: underline; } - .form-group label { - display: block; - margin-bottom: 6px; - font-weight: 500; - font-size: 13px; - color: var(--vscode-foreground); + .marketplace-detail-install { + margin: 12px 0; } - .form-group input, - .form-group select, - .form-group textarea { - width: 100%; - max-width: 100%; - padding: 8px 12px; - border: 1px solid var(--vscode-input-border); - border-radius: 4px; - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); + .marketplace-loading { + text-align: center; + padding: 24px; + color: var(--vscode-descriptionForeground); font-size: 13px; - font-family: var(--vscode-font-family); - box-sizing: border-box; - resize: vertical; } - .form-group input:focus, - .form-group select:focus, - .form-group textarea:focus { - outline: none; - border-color: var(--vscode-focusBorder); - box-shadow: 0 0 0 1px var(--vscode-focusBorder); + .marketplace-load-more { + text-align: center; + padding: 12px 0; + } + + .marketplace-detail { + padding: 4px 0; + } + + .marketplace-back-btn { + background: none; + border: none; + color: var(--vscode-textLink-foreground); + font-size: 12px; + cursor: pointer; + padding: 0 0 12px 0; + display: block; + } + + .marketplace-back-btn:hover { + text-decoration: underline; } - .form-group textarea { - resize: vertical; - min-height: 60px; + .marketplace-detail-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; } - .form-buttons { - display: flex; - gap: 8px; - justify-content: flex-end; - margin-top: 20px; + .marketplace-detail-icon { + width: 36px; + height: 36px; + border-radius: 8px; + object-fit: cover; } - .no-servers { - text-align: center; - color: var(--vscode-descriptionForeground); - font-style: italic; - padding: 40px 20px; + .marketplace-detail-header-info { + flex: 1; + min-width: 0; } - /* Popular MCP Servers */ - .mcp-popular-servers { - margin-top: 32px; - padding-top: 24px; - border-top: 1px solid var(--vscode-panel-border); + .marketplace-detail-header-meta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; } - .mcp-popular-servers h4 { - margin: 0 0 16px 0; - font-size: 14px; + .marketplace-detail-name { + font-size: 15px; font-weight: 600; color: var(--vscode-foreground); - opacity: 0.9; } - .popular-servers-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; + .marketplace-install-btn { + flex-shrink: 0; + align-self: center; } - .popular-server-item { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; + .marketplace-detail-desc { + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.5; + margin-bottom: 14px; + } + + .marketplace-detail-config { background-color: var(--vscode-editor-background); border: 1px solid var(--vscode-panel-border); border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; + padding: 10px 12px; } - .popular-server-item:hover { - border-color: var(--vscode-focusBorder); - background-color: var(--vscode-list-hoverBackground); - transform: translateY(-1px); + .marketplace-detail-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; } - .popular-server-icon { - font-size: 24px; - flex-shrink: 0; + .marketplace-detail-row { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; } - .popular-server-info { - flex: 1; - min-width: 0; + .marketplace-detail-row code, + .marketplace-detail-env code { + background-color: var(--vscode-textCodeBlock-background); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--vscode-editor-font-family); + font-size: 11px; } - .popular-server-name { - font-weight: 600; - font-size: 13px; + .detail-label { color: var(--vscode-foreground); - margin-bottom: 2px; + font-weight: 500; } - .popular-server-desc { - font-size: 11px; + .marketplace-detail-env { + font-size: 12px; color: var(--vscode-descriptionForeground); - opacity: 0.8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + margin-left: 12px; + margin-bottom: 2px; } /* Processing indicator - morphing orange dot */ @@ -3054,7 +4650,6 @@ const styles = ` background: var(--vscode-editor-background); border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); border-radius: 12px; - width: 320px; padding: 32px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); animation: installFadeIn 0.2s ease-out; @@ -3090,6 +4685,7 @@ const styles = ` .install-body { text-align: center; + margin-top: 20px; } .install-main { @@ -3238,6 +4834,221 @@ const styles = ` color: var(--vscode-descriptionForeground); } + .install-options { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + margin-top: 8px; + } + + .install-option { + width: 100%; + padding: 14px 16px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + } + + .install-option:hover { + background: var(--vscode-button-hoverBackground); + transform: translateY(-1px); + } + + .install-option-secondary { + background: transparent; + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + color: var(--vscode-foreground); + } + + .install-option-secondary:hover { + background: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); + } + + .install-option-title { + font-size: 14px; + font-weight: 500; + } + + .install-option-desc { + font-size: 12px; + opacity: 0.8; + } + + .install-funds { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 12px 0; + } + + .install-funds-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 10px; + } + + .install-funds-hint { + margin: 0; + font-size: 13px; + color: var(--vscode-descriptionForeground); + margin-bottom: 10px; + } + + .install-amounts { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + } + + .install-amount { + flex: 1 1 calc(33.333% - 6px); + min-width: 60px; + padding: 12px 8px; + font-size: 14px; + font-weight: 600; + background: var(--vscode-input-background); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + } + + .install-amount:hover { + border-color: var(--vscode-focusBorder); + background: var(--vscode-list-hoverBackground); + } + + .install-custom-amount { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin-top: 4px; + } + + .install-custom-currency { + font-size: 14px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + } + + .install-custom-input { + flex: 1; + padding: 10px 12px; + font-size: 14px; + background: var(--vscode-input-background); + color: var(--vscode-foreground); + border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border)); + border-radius: 6px; + outline: none; + } + + .install-custom-input:focus { + border-color: var(--vscode-focusBorder); + } + + .install-custom-input::placeholder { + color: var(--vscode-descriptionForeground); + } + + .install-custom-btn { + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; + } + + .install-custom-btn:hover { + background: var(--vscode-button-hoverBackground); + } + + .install-powered-by { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-top: 8px; + } + + .install-powered-by a { + color: var(--vscode-textLink-foreground); + text-decoration: none; + } + + .install-powered-by a:hover { + text-decoration: underline; + } + + .install-back-btn { + background: none; + border: none; + color: var(--vscode-textLink-foreground); + font-size: 13px; + cursor: pointer; + padding: 8px; + } + + .install-back-btn:hover { + text-decoration: underline; + } + + /* Toast notifications */ + .toast-notification { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #10b981, #059669); + color: white; + padding: 10px 20px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + z-index: 10000; + animation: toastSlideUp 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } + + .toast-notification.fade-out { + opacity: 0; + transform: translateX(-50%) translateY(10px); + transition: all 0.3s ease; + } + + @keyframes toastSlideUp { + from { + transform: translateX(-50%) translateY(20px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } + } + + /* OpenCredits balance badge style */ + .opencredits-balance { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.15), rgba(5, 150, 105, 0.15)) !important; + color: #10b981 !important; + } + ` export default styles \ No newline at end of file diff --git a/src/ui.ts b/src/ui.ts index a4883e3..be18286 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,12 +1,19 @@ import getScript from './script'; import styles from './ui-styles' +import recommendedModels from './recommended-models.json' +import topMcpServers from './top-mcp-servers.json' +import topSkills from './top-skills.json' +import topPlugins from './top-plugins.json' +import getSkillsHtml from './skills-ui' +import getPluginsHtml from './plugins-ui' -const getHtml = (isTelemetryEnabled: boolean) => ` +const getHtml = (isTelemetryEnabled: boolean, opencreditsApiUrl: string = 'https://ccc.api.opencredits.ai', opencreditsWebUrl: string = 'https://ccc.opencredits.ai', opencreditsPublishableKey: string = 'oc_pk_c43da4f9a9484ae484ad29bc97cc354f', editorName: string = 'unknown', extensionVersion: string = 'unknown') => ` + Claude Code Chat ${styles} @@ -57,33 +64,48 @@ const getHtml = (isTelemetryEnabled: boolean) => `
-
-
- Plan First -
-
-
- Thinking Mode -
+
+ + +
+
+
- - +
+ + +
+ +
@@ -102,21 +124,19 @@ const getHtml = (isTelemetryEnabled: boolean) => ` +
@@ -127,11 +147,9 @@ const getHtml = (isTelemetryEnabled: boolean) => `
Initializing...
-
@@ -163,54 +181,15 @@ const getHtml = (isTelemetryEnabled: boolean) => `
-
- -
+
+ + +
+
+ + + + + + @@ -477,6 +788,9 @@ const getHtml = (isTelemetryEnabled: boolean) => ` + ${getSkillsHtml()} + ${getPluginsHtml()} + - ${getScript(isTelemetryEnabled)} + + ${getScript(isTelemetryEnabled, opencreditsApiUrl, opencreditsWebUrl, opencreditsPublishableKey)} - - ${isTelemetryEnabled ? '' : ''} + ${isTelemetryEnabled ? '' : ''} `; -export default getHtml; \ No newline at end of file +export default getHtml; diff --git a/tsconfig.json b/tsconfig.json index 78af3c7..1f7ad51 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,8 +6,10 @@ "lib": [ "ES2022" ], + "types": ["node", "mocha"], "sourceMap": true, "rootDir": "src", + "resolveJsonModule": true, "strict": true, /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ @@ -16,6 +18,7 @@ }, "exclude": [ "mcp-permissions.js", - "claude-code-chat-permissions-mcp" + "claude-code-chat-permissions-mcp", + "backup-files" ] }