Example: Project Stats Plugin

Walk through the built-in Project Stats example plugin to learn patterns for dashboards, file scanning, caching, and theme-aware rendering.

The hello-world example (in examples/plugins/hello-world/) is a full reference implementation. It scans the current project and displays file counts, lines of code, file-type breakdown, largest files, and recently modified files.

What It Does

  1. Reads api.context.project.path for the current project directory
  2. Calls api.rpc('GET', '/stats?path=...') to scan the directory on the backend
  3. Backend walks the file tree, counts files and lines, returns statistics
  4. Frontend renders an animated dashboard with metric cards and charts
  5. On project switch, detects the context change and reloads

Manifest

json
{
  "name": "hello-world",
  "displayName": "Project Stats",
  "version": "2.0.0",
  "description": "Scans the current project and shows file counts, lines of code, file-type breakdown.",
  "author": "Claude Code UI",
  "icon": "icon.svg",
  "type": "module",
  "slot": "tab",
  "entry": "index.js",
  "server": "server.js"
}

Frontend Patterns (index.js)

Style Injection with Deduplication

Styles and fonts are injected once into <head>, with ID checks to prevent duplicates:

javascript
function injectFont() {
  if (document.querySelector('#ps-font')) return;
  const link = document.createElement('link');
  link.id = 'ps-font';
  link.rel = 'stylesheet';
  link.href = 'https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap';
  document.head.appendChild(link);
}

Client-Side Caching

Results are cached and only reloaded when the project changes:

javascript
let cache = null;

async function load(ctx) {
  const projectPath = ctx.project?.path;
  if (!projectPath) { renderNoProject(); return; }
  if (cache?.projectPath === projectPath) { renderDashboard(cache.stats); return; }

  renderLoading();
  const stats = await api.rpc('GET', `stats?path=${encodeURIComponent(projectPath)}`);
  cache = { projectPath, stats };
  renderDashboard(stats);
}

Animated Count-Up

Metric numbers animate from 0 to their target value:

javascript
function animateCount(el, target, duration = 900) {
  const start = performance.now();
  const tick = (now) => {
    const progress = Math.min((now - start) / duration, 1);
    const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
    el.textContent = Math.floor(target * eased).toLocaleString();
    if (progress < 1) requestAnimationFrame(tick);
  };
  requestAnimationFrame(tick);
}

Theme Support via CSS Variables

javascript
const isDark = api.context.theme === 'dark';
container.style.setProperty('--ps-bg', isDark ? '#0d1117' : '#f6f8fa');
container.style.setProperty('--ps-text', isDark ? '#c9d1d9' : '#24292f');
container.style.setProperty('--ps-card-bg', isDark ? '#161b22' : '#ffffff');

Context change callbacks update these variables for instant theme switching.

Backend Patterns (server.js)

Server Setup

javascript
import http from 'node:http';
const server = http.createServer(handleRequest);
server.listen(0, '127.0.0.1', () => {
  console.log(JSON.stringify({ ready: true, port: server.address().port }));
});

File Scanning with Safety Limits

javascript
const MAX_FILES = 5000;  // Hard limit on files scanned
const MAX_DEPTH = 6;     // Maximum recursion depth

const SKIP_DIRS = new Set([
  'node_modules', '.git', 'dist', 'build', '.next', '.nuxt',
  'coverage', '.cache', '__pycache__', '.venv', 'venv',
  'target', 'vendor', '.turbo', 'out', '.output', 'tmp'
]);

Line Counting (Text Files Only)

Only recognized text extensions are counted, and files over 256KB are skipped:

javascript
const TEXT_EXTS = new Set([
  '.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rs', '.rb',
  '.java', '.c', '.cpp', '.h', '.cs', '.php', '.swift',
  '.kt', '.scala', '.vue', '.svelte', '.html', '.css',
  '.scss', '.json', '.yaml', '.yml', '.md', '.sql'
]);
const MAX_FILE_SIZE = 256 * 1024;

Response Format

json
{
  "totalFiles": 342,
  "totalLines": 28491,
  "totalSize": 1548672,
  "byExtension": [[".ts", 120], [".tsx", 85], [".json", 42]],
  "largest": [{"name": "package-lock.json", "size": 524288}],
  "recent": [{"name": "src/App.tsx", "mtime": 1709654400000}]
}

Installing the Example

Option 1: Copy Directly

bash
cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/hello-world

Then refresh the plugins list in Settings.

Option 2: Publish to Git

bash
cd examples/plugins/hello-world
git init && git add . && git commit -m "init"
# Push to your Git host, then install via Settings > Plugins

Key Takeaways

PatternWhere
Client-side cachingcache variable in load()
Skeleton loading statesrenderLoading() function
Graceful "no project" handlingrenderNoProject() function
Theme-aware CSS custom propertiesContext change handler
Animated count-up numbersanimateCount() helper
Server safety limitsMAX_FILES, MAX_DEPTH constants
Skip list for non-source directoriesSKIP_DIRS set
Style deduplication on mountinjectFont(), injectStyles()
Clean unsubscribe on unmountunmount() cleanup

Last updated March 18, 2026