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
- Reads
api.context.project.pathfor the current project directory - Calls
api.rpc('GET', '/stats?path=...')to scan the directory on the backend - Backend walks the file tree, counts files and lines, returns statistics
- Frontend renders an animated dashboard with metric cards and charts
- On project switch, detects the context change and reloads
Manifest
{
"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:
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:
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:
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
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
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
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:
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
{
"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
cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/hello-worldThen refresh the plugins list in Settings.
Option 2: Publish to Git
cd examples/plugins/hello-world
git init && git add . && git commit -m "init"
# Push to your Git host, then install via Settings > PluginsKey Takeaways
| Pattern | Where |
|---|---|
| Client-side caching | cache variable in load() |
| Skeleton loading states | renderLoading() function |
| Graceful "no project" handling | renderNoProject() function |
| Theme-aware CSS custom properties | Context change handler |
| Animated count-up numbers | animateCount() helper |
| Server safety limits | MAX_FILES, MAX_DEPTH constants |
| Skip list for non-source directories | SKIP_DIRS set |
| Style deduplication on mount | injectFont(), injectStyles() |
| Clean unsubscribe on unmount | unmount() cleanup |