diff --git a/ui/src/components/shared/ccs-logo.tsx b/ui/src/components/shared/ccs-logo.tsx
new file mode 100644
index 00000000..58e6eda6
--- /dev/null
+++ b/ui/src/components/shared/ccs-logo.tsx
@@ -0,0 +1,30 @@
+import { cn } from '@/lib/utils';
+
+interface CcsLogoProps {
+ size?: 'sm' | 'md' | 'lg';
+ className?: string;
+ showText?: boolean;
+}
+
+const sizeMap = {
+ sm: 24,
+ md: 32,
+ lg: 48,
+};
+
+export function CcsLogo({ size = 'md', className, showText = true }: CcsLogoProps) {
+ const dimension = sizeMap[size];
+
+ return (
+
+

+ {showText &&
CCS Config}
+
+ );
+}
diff --git a/ui/src/components/shared/claudekit-badge.tsx b/ui/src/components/shared/claudekit-badge.tsx
new file mode 100644
index 00000000..a8c4308b
--- /dev/null
+++ b/ui/src/components/shared/claudekit-badge.tsx
@@ -0,0 +1,49 @@
+/**
+ * ClaudeKit Badge Button
+ *
+ * "Powered by ClaudeKit" badge for navbar, inspired by landing page design.
+ * Compact version optimized for header placement.
+ */
+
+import { cn } from '@/lib/utils';
+
+const CLAUDEKIT_URL = 'https://claudekit.cc?ref=HMNKXOHN';
+
+export function ClaudeKitBadge() {
+ return (
+
+
+
+
+ Powered by
+
+
+ ClaudeKit
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx
new file mode 100644
index 00000000..47f95edc
--- /dev/null
+++ b/ui/src/components/shared/code-editor.tsx
@@ -0,0 +1,221 @@
+/**
+ * Code Editor Component
+ * Lightweight JSON editor with syntax highlighting, line numbers, and validation
+ * Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
+ */
+
+import { useState, useCallback, useMemo } from 'react';
+import Editor from 'react-simple-code-editor';
+import { Highlight, themes } from 'prism-react-renderer';
+import { useTheme } from '@/hooks/use-theme';
+import { cn } from '@/lib/utils';
+import { isSensitiveKey } from '@/lib/sensitive-keys';
+import { AlertCircle, CheckCircle2, Eye, EyeOff } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+interface CodeEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ language?: 'json' | 'yaml';
+ readonly?: boolean;
+ className?: string;
+ minHeight?: string;
+}
+
+interface ValidationResult {
+ valid: boolean;
+ error?: string;
+ line?: number;
+}
+
+/**
+ * Validate JSON and extract error location
+ */
+function validateJson(code: string): ValidationResult {
+ if (!code.trim()) {
+ return { valid: true };
+ }
+
+ try {
+ JSON.parse(code);
+ return { valid: true };
+ } catch (e) {
+ const error = e as SyntaxError;
+ const message = error.message;
+
+ // Try to extract line number from error message
+ // Format: "... at position X" or "... at line Y column Z"
+ const posMatch = message.match(/position (\d+)/);
+ if (posMatch) {
+ const pos = parseInt(posMatch[1], 10);
+ const lines = code.substring(0, pos).split('\n');
+ return {
+ valid: false,
+ error: message,
+ line: lines.length,
+ };
+ }
+
+ return {
+ valid: false,
+ error: message,
+ };
+ }
+}
+
+export function CodeEditor({
+ value,
+ onChange,
+ language = 'json',
+ readonly = false,
+ className,
+ minHeight = '300px',
+}: CodeEditorProps) {
+ const { isDark } = useTheme();
+ const [isFocused, setIsFocused] = useState(false);
+ const [isMasked, setIsMasked] = useState(true);
+
+ // Validate on every change for JSON
+ const validation = useMemo(() => {
+ if (language === 'json') {
+ return validateJson(value);
+ }
+ return { valid: true };
+ }, [value, language]);
+
+ // Highlight function using prism-react-renderer
+ // Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
+ const highlightCode = useCallback(
+ (code: string) => (
+
+ {({ tokens, getLineProps, getTokenProps }) => {
+ let nextValueIsSensitive = false;
+
+ return (
+ <>
+ {tokens.map((line, i) => (
+
+ {line.map((token, key) => {
+ let isSensitive = false;
+
+ // Check for sensitive keys
+ if (token.types.includes('property')) {
+ const content = token.content.replace(/['"]/g, '');
+ // Use shared sensitive key detection utility
+ if (isSensitiveKey(content)) {
+ nextValueIsSensitive = true;
+ } else {
+ nextValueIsSensitive = false;
+ }
+ }
+ // Apply masking to values following sensitive keys
+ else if (
+ (token.types.includes('string') ||
+ token.types.includes('number') ||
+ token.types.includes('boolean')) &&
+ nextValueIsSensitive
+ ) {
+ isSensitive = true;
+ // Consumes the flag for this value
+ nextValueIsSensitive = false;
+ }
+ // Reset flag on commas or new keys (handled by property check),
+ // but persist through colons and whitespace
+ else if (token.types.includes('punctuation')) {
+ if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
+ nextValueIsSensitive = false;
+ }
+ }
+
+ const tokenProps = getTokenProps({ token });
+
+ if (isSensitive && isMasked) {
+ tokenProps.className = cn(
+ tokenProps.className,
+ 'blur-[3px] select-none opacity-70 transition-all duration-200'
+ );
+ }
+
+ return ;
+ })}
+
+ ))}
+ >
+ );
+ }}
+
+ ),
+ [isDark, language, validation.line, isMasked]
+ );
+
+ return (
+
+ {/* Editor container */}
+
+
{} : onChange}
+ highlight={highlightCode}
+ key={isDark ? 'dark-editor' : 'light-editor'}
+ padding={12}
+ disabled={readonly}
+ onFocus={() => setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ textareaClassName={cn(
+ 'focus:outline-none font-mono text-sm',
+ readonly && 'cursor-not-allowed'
+ )}
+ preClassName="font-mono text-sm"
+ style={{
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+ fontSize: '0.875rem',
+ minHeight,
+ }}
+ />
+
+ {/* Secrets Toggle Overlay */}
+
+
+
+
+
+ {/* Validation status */}
+
+ {validation.valid ? (
+
+
+ Valid {language.toUpperCase()}
+
+ ) : (
+
+
+ {validation.error}
+ {validation.line && ` (line ${validation.line})`}
+
+ )}
+ {readonly &&
(Read-only)}
+
+
+ );
+}
diff --git a/ui/src/components/shared/command-builder.tsx b/ui/src/components/shared/command-builder.tsx
new file mode 100644
index 00000000..8e33dae3
--- /dev/null
+++ b/ui/src/components/shared/command-builder.tsx
@@ -0,0 +1,148 @@
+import { useState } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { CopyIcon, PlayIcon, TerminalIcon } from 'lucide-react';
+
+interface Command {
+ id: string;
+ command: string;
+ description: string;
+ category: string;
+}
+
+const commonCommands: Command[] = [
+ {
+ id: '1',
+ command: 'ccs config',
+ description: 'Open configuration interface',
+ category: 'Config',
+ },
+ {
+ id: '2',
+ command: 'ccs profile create --name my-profile',
+ description: 'Create a new profile',
+ category: 'Profile',
+ },
+ {
+ id: '3',
+ command: 'ccs profile switch --name my-profile',
+ description: 'Switch to a profile',
+ category: 'Profile',
+ },
+ {
+ id: '4',
+ command: 'ccs doctor',
+ description: 'Check system health',
+ category: 'Diagnostics',
+ },
+ {
+ id: '5',
+ command: 'ccs cliproxy list',
+ description: 'List available CLIProxy providers',
+ category: 'CLIProxy',
+ },
+ {
+ id: '6',
+ command: 'ccs cliproxy add --provider gemini --token YOUR_TOKEN',
+ description: 'Add CLIProxy provider',
+ category: 'CLIProxy',
+ },
+];
+
+export function CommandBuilder() {
+ const [command, setCommand] = useState('');
+ const [filteredCommands, setFilteredCommands] = useState(commonCommands);
+
+ const handleCommandChange = (value: string) => {
+ setCommand(value);
+ const filtered = commonCommands.filter(
+ (cmd) =>
+ cmd.command.toLowerCase().includes(value.toLowerCase()) ||
+ cmd.description.toLowerCase().includes(value.toLowerCase())
+ );
+ setFilteredCommands(filtered);
+ };
+
+ const handleCommandSelect = (cmd: string) => {
+ setCommand(cmd);
+ setFilteredCommands(commonCommands);
+ };
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(command);
+ };
+
+ const handleRun = () => {
+ console.log('Running command:', command);
+ };
+
+ const categories = Array.from(new Set(commonCommands.map((cmd) => cmd.category)));
+
+ return (
+
+
+
+
+ Command Builder
+
+
+
+
+
handleCommandChange(e.target.value)}
+ className="font-mono"
+ />
+
+
+
+
+
+
+
+ {categories.map((category) => (
+ {
+ const categoryCommands = commonCommands.filter((cmd) => cmd.category === category);
+ console.log(`${category} commands:`, categoryCommands);
+ }}
+ >
+ {category}
+
+ ))}
+
+
+
+
+ {filteredCommands.map((cmd) => (
+
handleCommandSelect(cmd.command)}
+ >
+
{cmd.command}
+
{cmd.description}
+
+ {cmd.category}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/confirm-dialog.tsx b/ui/src/components/shared/confirm-dialog.tsx
new file mode 100644
index 00000000..43d7f940
--- /dev/null
+++ b/ui/src/components/shared/confirm-dialog.tsx
@@ -0,0 +1,50 @@
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+
+interface ConfirmDialogProps {
+ open: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+ title: string;
+ description: string;
+ confirmText?: string;
+ variant?: 'default' | 'destructive';
+}
+
+export function ConfirmDialog({
+ open,
+ onConfirm,
+ onCancel,
+ title,
+ description,
+ confirmText = 'Confirm',
+ variant = 'default',
+}: ConfirmDialogProps) {
+ return (
+ !isOpen && onCancel()}>
+
+
+ {title}
+ {description}
+
+
+ Cancel
+
+ {confirmText}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx
new file mode 100644
index 00000000..eb00487d
--- /dev/null
+++ b/ui/src/components/shared/connection-indicator.tsx
@@ -0,0 +1,28 @@
+/**
+ * Connection Indicator (Phase 04)
+ *
+ * Shows WebSocket connection status in the header.
+ */
+
+import { Wifi, WifiOff } from 'lucide-react';
+import { useWebSocket } from '@/hooks/use-websocket';
+
+export function ConnectionIndicator() {
+ const { status } = useWebSocket();
+
+ const statusConfig = {
+ connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' },
+ connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' },
+ disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
+ };
+
+ const config = statusConfig[status];
+ const Icon = config.icon;
+
+ return (
+
+
+ {config.label}
+
+ );
+}
diff --git a/ui/src/components/shared/docs-link.tsx b/ui/src/components/shared/docs-link.tsx
new file mode 100644
index 00000000..72e16014
--- /dev/null
+++ b/ui/src/components/shared/docs-link.tsx
@@ -0,0 +1,20 @@
+/**
+ * Docs Link Button
+ *
+ * Links to CCS documentation site for guides and reference.
+ */
+
+import { BookOpen } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+const DOCS_URL = 'https://docs.ccs.kaitran.ca';
+
+export function DocsLink() {
+ return (
+
+ );
+}
diff --git a/ui/src/components/shared/github-link.tsx b/ui/src/components/shared/github-link.tsx
new file mode 100644
index 00000000..8ece4cab
--- /dev/null
+++ b/ui/src/components/shared/github-link.tsx
@@ -0,0 +1,20 @@
+/**
+ * GitHub Link Button
+ *
+ * Links to CCS GitHub issues page for bug reports and feature requests.
+ */
+
+import { Github } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+const GITHUB_REPO_URL = 'https://github.com/kaitranntt/ccs/issues';
+
+export function GitHubLink() {
+ return (
+
+ );
+}
diff --git a/ui/src/components/shared/global-env-indicator.tsx b/ui/src/components/shared/global-env-indicator.tsx
new file mode 100644
index 00000000..b6cc0cf3
--- /dev/null
+++ b/ui/src/components/shared/global-env-indicator.tsx
@@ -0,0 +1,132 @@
+/**
+ * Global Environment Variables Indicator
+ *
+ * Shows which env vars from global_env will be injected at runtime.
+ * Displayed below the Raw Configuration (JSON) section in profile editors.
+ */
+
+import { useState, useEffect } from 'react';
+import { Link } from 'react-router-dom';
+import { Settings2, ChevronDown, ChevronUp, ExternalLink, Info } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+interface GlobalEnvConfig {
+ enabled: boolean;
+ env: Record;
+}
+
+interface GlobalEnvIndicatorProps {
+ /** Current profile's env vars (to show which are overridden) */
+ profileEnv?: Record;
+}
+
+export function GlobalEnvIndicator({ profileEnv = {} }: GlobalEnvIndicatorProps) {
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [expanded, setExpanded] = useState(false);
+
+ useEffect(() => {
+ fetchConfig();
+ }, []);
+
+ const fetchConfig = async () => {
+ try {
+ setLoading(true);
+ const res = await fetch('/api/global-env');
+ if (!res.ok) throw new Error('Failed to load');
+ const data = await res.json();
+ setConfig(data);
+ } catch {
+ setConfig(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Don't render if loading or disabled or no vars
+ if (loading) return null;
+ if (!config?.enabled) return null;
+
+ const envVars = config.env || {};
+ const envKeys = Object.keys(envVars);
+ if (envKeys.length === 0) return null;
+
+ // Check which keys are already in profile (won't be overridden)
+ const injectedKeys = envKeys.filter((key) => !(key in profileEnv));
+ const overriddenKeys = envKeys.filter((key) => key in profileEnv);
+
+ return (
+
+ {/* Header - clickable to expand */}
+
+
+ {/* Expanded content */}
+ {expanded && (
+
+ {/* Injected vars */}
+ {injectedKeys.length > 0 && (
+
+ {injectedKeys.map((key) => (
+
+ +
+
+ {key}={envVars[key]}
+
+
+ ))}
+
+ )}
+
+ {/* Overridden vars (profile takes precedence) */}
+ {overriddenKeys.length > 0 && (
+
+
Skipped (profile already defines):
+ {overriddenKeys.map((key) => (
+
+ ~
+ {key}
+
+ ))}
+
+ )}
+
+ {/* Link to settings */}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts
new file mode 100644
index 00000000..2a181ec4
--- /dev/null
+++ b/ui/src/components/shared/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Shared Components Barrel Export
+ */
+
+export { CcsLogo } from './ccs-logo';
+export { ClaudeKitBadge } from './claudekit-badge';
+export { CodeEditor } from './code-editor';
+export { CommandBuilder } from './command-builder';
+export { ConfirmDialog } from './confirm-dialog';
+export { ConnectionIndicator } from './connection-indicator';
+export { DocsLink } from './docs-link';
+export { GitHubLink } from './github-link';
+export { GlobalEnvIndicator } from './global-env-indicator';
+export { LocalhostDisclaimer } from './localhost-disclaimer';
+export { PrivacyToggle } from './privacy-toggle';
+export { ProjectSelectionDialog } from './project-selection-dialog';
+export { ProviderIcon } from './provider-icon';
+export { QuickCommands } from './quick-commands';
+export { SettingsDialog } from './settings-dialog';
+export { SponsorButton } from './sponsor-button';
+export { StatCard } from './stat-card';
+export { ValueMetrics } from './value-metrics';
diff --git a/ui/src/components/shared/localhost-disclaimer.tsx b/ui/src/components/shared/localhost-disclaimer.tsx
new file mode 100644
index 00000000..78d5c6e1
--- /dev/null
+++ b/ui/src/components/shared/localhost-disclaimer.tsx
@@ -0,0 +1,29 @@
+import { Shield, X } from 'lucide-react';
+import { useState } from 'react';
+
+export function LocalhostDisclaimer() {
+ const [dismissed, setDismissed] = useState(false);
+
+ if (dismissed) return null;
+
+ return (
+
+
+
+
+
+ This dashboard runs locally. All data stays on your machine.
+
+ Local dashboard - data stays on your device.
+
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/privacy-toggle.tsx b/ui/src/components/shared/privacy-toggle.tsx
new file mode 100644
index 00000000..eb70362d
--- /dev/null
+++ b/ui/src/components/shared/privacy-toggle.tsx
@@ -0,0 +1,39 @@
+/**
+ * Privacy Toggle Button
+ * Toggles demo mode to blur personal information (emails, account IDs)
+ */
+
+import { Button } from '@/components/ui/button';
+import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
+import { usePrivacy } from '@/contexts/privacy-context';
+import { Eye, EyeOff } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export function PrivacyToggle() {
+ const { privacyMode, togglePrivacyMode } = usePrivacy();
+
+ return (
+
+
+
+
+
+
+ {privacyMode
+ ? 'Privacy mode ON - Click to show data'
+ : 'Privacy mode OFF - Click to hide data'}
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/project-selection-dialog.tsx b/ui/src/components/shared/project-selection-dialog.tsx
new file mode 100644
index 00000000..b80b00b9
--- /dev/null
+++ b/ui/src/components/shared/project-selection-dialog.tsx
@@ -0,0 +1,203 @@
+/**
+ * Project Selection Dialog Component
+ *
+ * Displays during OAuth flow when CLIProxyAPI requires user to select
+ * a Google Cloud project. Shows list of available projects with option
+ * to select one or ALL.
+ */
+
+import { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react';
+
+interface GCloudProject {
+ id: string;
+ name: string;
+ index: number;
+}
+
+interface ProjectSelectionDialogProps {
+ open: boolean;
+ onClose: () => void;
+ sessionId: string;
+ provider: string;
+ projects: GCloudProject[];
+ defaultProjectId: string;
+ supportsAll: boolean;
+ onSelect: (selectedId: string) => Promise;
+ /** Timeout in seconds before auto-selecting default */
+ timeoutSeconds?: number;
+}
+
+export function ProjectSelectionDialog({
+ open,
+ onClose,
+ sessionId,
+ provider,
+ projects,
+ defaultProjectId,
+ supportsAll,
+ onSelect,
+ timeoutSeconds = 30,
+}: ProjectSelectionDialogProps) {
+ const [selectedId, setSelectedId] = useState(defaultProjectId);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [countdown, setCountdown] = useState(timeoutSeconds);
+
+ // Countdown timer for auto-selection
+ useEffect(() => {
+ if (!open) {
+ setCountdown(timeoutSeconds);
+ return;
+ }
+
+ const timer = setInterval(() => {
+ setCountdown((prev) => {
+ if (prev <= 1) {
+ clearInterval(timer);
+ // Auto-submit on timeout
+ handleSubmit(true);
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => clearInterval(timer);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, timeoutSeconds]);
+
+ // Reset state when dialog opens
+ useEffect(() => {
+ if (open) {
+ setSelectedId(defaultProjectId);
+ setIsSubmitting(false);
+ setCountdown(timeoutSeconds);
+ }
+ }, [open, defaultProjectId, timeoutSeconds]);
+
+ const handleSubmit = async (isTimeout = false) => {
+ if (isSubmitting) return;
+ setIsSubmitting(true);
+
+ try {
+ // Empty string means use default (press Enter behavior)
+ const submitId = isTimeout ? '' : selectedId;
+ await onSelect(submitId);
+ onClose();
+ } catch (error) {
+ console.error('Failed to submit project selection:', error);
+ // On error, submit empty to use default
+ try {
+ await onSelect('');
+ } catch {
+ // Ignore double-error
+ }
+ onClose();
+ }
+ };
+
+ const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1);
+
+ // Suppress unused variable warning - sessionId used for identification
+ void sessionId;
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/shared/provider-icon.tsx b/ui/src/components/shared/provider-icon.tsx
new file mode 100644
index 00000000..828f649e
--- /dev/null
+++ b/ui/src/components/shared/provider-icon.tsx
@@ -0,0 +1,95 @@
+/**
+ * Provider Icon Component
+ * Renders provider logos from /assets/providers/
+ * Supports white background circle variant for dark themes
+ */
+
+import { cn } from '@/lib/utils';
+import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config';
+
+interface ProviderIconProps {
+ provider: string;
+ className?: string;
+ size?: number;
+ /** White background circle variant for better visibility */
+ withBackground?: boolean;
+}
+
+export function ProviderIcon({
+ provider,
+ className,
+ size = 18,
+ withBackground = false,
+}: ProviderIconProps) {
+ const normalized = provider.toLowerCase();
+ const assetPath = PROVIDER_ASSETS[normalized];
+
+ // Icon size is smaller when inside background circle
+ const iconSize = withBackground ? Math.floor(size * 0.65) : size;
+
+ const iconElement = assetPath ? (
+
+ ) : (
+ // Fallback: colored text letter
+
+ {provider.charAt(0).toUpperCase()}
+
+ );
+
+ if (withBackground) {
+ return (
+
+ {iconElement}
+
+ );
+ }
+
+ // Without background - original behavior for logos, colored circle for fallback
+ if (assetPath) {
+ return (
+
+ );
+ }
+
+ const bgColor = PROVIDER_COLORS[normalized] || '#6b7280';
+ return (
+
+ {provider.charAt(0).toUpperCase()}
+
+ );
+}
diff --git a/ui/src/components/shared/quick-commands.tsx b/ui/src/components/shared/quick-commands.tsx
new file mode 100644
index 00000000..6ab4d6f8
--- /dev/null
+++ b/ui/src/components/shared/quick-commands.tsx
@@ -0,0 +1,92 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Copy, Check, Terminal } from 'lucide-react';
+import { useState } from 'react';
+import { cn } from '@/lib/utils';
+
+interface CommandSnippet {
+ label: string;
+ command: string;
+ description: string;
+}
+
+const defaultSnippets: CommandSnippet[] = [
+ {
+ label: 'Start Default',
+ command: 'ccs',
+ description: 'Launch Claude with default profile',
+ },
+ {
+ label: 'GLM Profile',
+ command: 'ccs glm',
+ description: 'Switch to GLM model',
+ },
+ {
+ label: 'Health Check',
+ command: 'ccs doctor',
+ description: 'Run system diagnostics',
+ },
+ {
+ label: 'Delegate Task',
+ command: 'ccs glm -p "your task"',
+ description: 'Delegate to GLM profile',
+ },
+];
+
+interface QuickCommandsProps {
+ snippets?: CommandSnippet[];
+}
+
+export function QuickCommands({ snippets = defaultSnippets }: QuickCommandsProps) {
+ const [copiedIndex, setCopiedIndex] = useState(null);
+
+ const copyToClipboard = async (text: string, index: number) => {
+ await navigator.clipboard.writeText(text);
+ setCopiedIndex(index);
+ setTimeout(() => setCopiedIndex(null), 2000);
+ };
+
+ return (
+
+
+
+
+ Quick Commands
+
+
+
+
+ {snippets.map((snippet, index) => (
+
+
+
{snippet.label}
+
+ {snippet.command}
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx
new file mode 100644
index 00000000..f0f6c760
--- /dev/null
+++ b/ui/src/components/shared/settings-dialog.tsx
@@ -0,0 +1,385 @@
+/**
+ * Settings Dialog Component
+ * Reusable dialog for editing profile environment variables
+ * Features: masked inputs for sensitive keys, conflict detection, save/cancel, raw JSON editor
+ */
+
+import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { MaskedInput } from '@/components/ui/masked-input';
+import { ConfirmDialog } from './confirm-dialog';
+import { Save, X, Loader2, Code2 } from 'lucide-react';
+import { toast } from 'sonner';
+
+// Lazy load CodeEditor to reduce initial bundle size
+const CodeEditor = lazy(() => import('./code-editor').then((m) => ({ default: m.CodeEditor })));
+
+interface Settings {
+ env?: Record;
+}
+
+interface SettingsResponse {
+ profile: string;
+ settings: Settings;
+ mtime: number;
+ path: string;
+}
+
+interface SettingsDialogProps {
+ open: boolean;
+ onClose: () => void;
+ profileName: string | null;
+}
+
+/**
+ * Inner component that manages local edits state
+ * Gets unmounted/remounted via key prop when dialog closes/opens
+ */
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+
+function SettingsDialogContent({
+ profileName,
+ onClose,
+}: {
+ profileName: string;
+ onClose: () => void;
+}) {
+ const [localEdits, setLocalEdits] = useState>({});
+ const [conflictDialog, setConflictDialog] = useState(false);
+ const [rawJsonEdits, setRawJsonEdits] = useState(null);
+ const [activeTab, setActiveTab] = useState('env');
+ const queryClient = useQueryClient();
+
+ // Fetch settings for selected profile
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['settings', profileName],
+ queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()),
+ });
+
+ // Derive raw JSON content: use edits if available, otherwise serialize from data
+ const settings = data?.settings;
+ const rawJsonContent = useMemo(() => {
+ if (rawJsonEdits !== null) {
+ return rawJsonEdits;
+ }
+ if (settings) {
+ return JSON.stringify(settings, null, 2);
+ }
+ return '';
+ }, [rawJsonEdits, settings]);
+
+ // Update raw JSON when user edits
+ const handleRawJsonChange = useCallback((value: string) => {
+ setRawJsonEdits(value);
+ }, []);
+
+ // Derive current settings by merging original data with local edits
+ const currentSettings = useMemo((): Settings | undefined => {
+ const settings = data?.settings;
+ if (!settings) return undefined;
+ return {
+ ...settings,
+ env: {
+ ...settings.env,
+ ...localEdits,
+ },
+ };
+ }, [data?.settings, localEdits]);
+
+ // Check if raw JSON is valid
+ const isRawJsonValid = useMemo(() => {
+ try {
+ JSON.parse(rawJsonContent);
+ return true;
+ } catch {
+ return false;
+ }
+ }, [rawJsonContent]);
+
+ // Save mutation
+ const saveMutation = useMutation({
+ mutationFn: async () => {
+ let settingsToSave: Settings;
+
+ // Determine what to save based on active tab
+ if (activeTab === 'raw') {
+ // Parse raw JSON content
+ try {
+ settingsToSave = JSON.parse(rawJsonContent);
+ } catch {
+ throw new Error('Invalid JSON');
+ }
+ } else {
+ // Use form-based edits
+ settingsToSave = {
+ ...data?.settings,
+ env: {
+ ...data?.settings?.env,
+ ...localEdits,
+ },
+ };
+ }
+
+ const res = await fetch(`/api/settings/${profileName}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ settings: settingsToSave,
+ expectedMtime: data?.mtime,
+ }),
+ });
+
+ if (res.status === 409) {
+ throw new Error('CONFLICT');
+ }
+
+ if (!res.ok) {
+ throw new Error('Failed to save');
+ }
+
+ return res.json();
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
+ queryClient.invalidateQueries({ queryKey: ['profiles'] });
+ toast.success('Settings saved');
+ onClose();
+ },
+ onError: (error: Error) => {
+ if (error.message === 'CONFLICT') {
+ setConflictDialog(true);
+ } else {
+ toast.error(error.message);
+ }
+ },
+ });
+
+ const handleSave = () => {
+ saveMutation.mutate();
+ };
+
+ const handleConflictResolve = async (overwrite: boolean) => {
+ setConflictDialog(false);
+ if (overwrite) {
+ // Refetch to get new mtime, then save
+ await refetch();
+ saveMutation.mutate();
+ } else {
+ // Discard local changes and close
+ onClose();
+ }
+ };
+
+ const updateEnvValue = (key: string, value: string) => {
+ setLocalEdits((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ const isSensitiveKey = (key: string): boolean => {
+ // Pattern-based matching for sensitive keys (same as backend)
+ const sensitivePatterns = [
+ /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token
+ /_API_KEY$/, // Keys ending with _API_KEY
+ /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN
+ /^API_KEY$/, // Exact match for API_KEY
+ /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN
+ /_SECRET$/, // Keys ending with _SECRET
+ /^SECRET$/, // Exact match for SECRET
+ ];
+ return sensitivePatterns.some((pattern) => pattern.test(key));
+ };
+
+ return (
+ <>
+
+ Edit Profile: {profileName}
+
+ Configure environment variables and settings for this profile.
+
+
+
+ {isLoading ? (
+
+
+ Loading settings...
+
+ ) : (
+
+
+
+
+ Environment
+
+
+
+ Raw JSON
+
+
+ General
+
+
+
+
+
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
+
+ {Object.entries(currentSettings.env).map(([key, value]) => (
+
+
+ {isSensitiveKey(key) ? (
+ updateEnvValue(key, e.target.value)}
+ className="font-mono text-sm"
+ />
+ ) : (
+ updateEnvValue(key, e.target.value)}
+ className="font-mono text-sm"
+ />
+ )}
+
+ ))}
+
+ ) : (
+
+
No environment variables configured.
+
Add variables in your settings.json file.
+
+ )}
+
+
+
+
+
+
+ Loading editor...
+
+ }
+ >
+
+
+
+
+
+
+
+ Profile Information
+ Details about this configuration file.
+
+
+ {data && (
+ <>
+
+ Path
+
+ {data.path}
+
+
+
+ Last Modified
+ {new Date(data.mtime).toLocaleString()}
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ handleConflictResolve(true)}
+ onCancel={() => handleConflictResolve(false)}
+ />
+ >
+ );
+}
+
+export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) {
+ // Handle dialog open/close state changes
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ if (!isOpen) {
+ onClose();
+ }
+ },
+ [onClose]
+ );
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/shared/sponsor-button.tsx b/ui/src/components/shared/sponsor-button.tsx
new file mode 100644
index 00000000..382d9afb
--- /dev/null
+++ b/ui/src/components/shared/sponsor-button.tsx
@@ -0,0 +1,46 @@
+/**
+ * Sponsor Button
+ *
+ * GitHub Sponsors button for navbar.
+ * Heart icon with hover animation.
+ */
+
+import { Heart } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+const SPONSOR_URL = 'https://github.com/sponsors/kaitranntt';
+
+export function SponsorButton() {
+ return (
+
+
+
+ Sponsor
+
+
+ );
+}
diff --git a/ui/src/components/shared/stat-card.tsx b/ui/src/components/shared/stat-card.tsx
new file mode 100644
index 00000000..8ef7f37b
--- /dev/null
+++ b/ui/src/components/shared/stat-card.tsx
@@ -0,0 +1,88 @@
+import { Card, CardContent } from '@/components/ui/card';
+import type { LucideIcon } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface StatCardProps {
+ title: string;
+ value: number | string;
+ icon: LucideIcon;
+ color?: string;
+ variant?: 'default' | 'success' | 'warning' | 'error' | 'accent';
+ subtitle?: string;
+ onClick?: () => void;
+}
+
+const variantStyles = {
+ default: {
+ iconBg: 'bg-muted',
+ iconColor: 'text-muted-foreground',
+ borderHover: 'hover:border-primary',
+ },
+ success: {
+ iconBg: 'bg-green-500/10',
+ iconColor: 'text-green-600',
+ borderHover: 'hover:border-green-500/50',
+ },
+ warning: {
+ iconBg: 'bg-yellow-500/10',
+ iconColor: 'text-yellow-500',
+ borderHover: 'hover:border-yellow-500/50',
+ },
+ error: {
+ iconBg: 'bg-red-500/10',
+ iconColor: 'text-red-500',
+ borderHover: 'hover:border-red-500/50',
+ },
+ accent: {
+ iconBg: 'bg-accent/10',
+ iconColor: 'text-accent',
+ borderHover: 'hover:border-accent/50',
+ },
+};
+
+export function StatCard({
+ title,
+ value,
+ icon: Icon,
+ color,
+ variant = 'default',
+ subtitle,
+ onClick,
+}: StatCardProps) {
+ const styles = variantStyles[variant];
+ const iconColorClass = color || styles.iconColor;
+
+ return (
+
+
+
+ {/* Icon Container with background */}
+
+
+
+
+ {/* Content */}
+
+
{title}
+
{value}
+ {subtitle &&
{subtitle}
}
+
+
+
+
+ );
+}
diff --git a/ui/src/components/shared/value-metrics.tsx b/ui/src/components/shared/value-metrics.tsx
new file mode 100644
index 00000000..9975a7b9
--- /dev/null
+++ b/ui/src/components/shared/value-metrics.tsx
@@ -0,0 +1,114 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { TrendingUpIcon, TrendingDownIcon, DollarSignIcon, ZapIcon } from 'lucide-react';
+
+interface MetricCardProps {
+ title: string;
+ value: string | number;
+ change?: number;
+ changeLabel?: string;
+ icon: React.ReactNode;
+ trend?: 'up' | 'down' | 'neutral';
+}
+
+function MetricCard({ title, value, change, changeLabel, icon, trend }: MetricCardProps) {
+ const TrendIcon = trend === 'up' ? TrendingUpIcon : trend === 'down' ? TrendingDownIcon : null;
+
+ return (
+
+
+
+
+ {icon}
+
{title}
+
+ {trend && TrendIcon && (
+
+
+ {change && `${Math.abs(change)}%`}
+
+ )}
+
+
+
{value}
+ {changeLabel &&
{changeLabel}
}
+
+
+
+ );
+}
+
+export function ValueMetrics() {
+ // Mock data for demonstration
+ const metrics = [
+ {
+ title: 'API Cost Saved',
+ value: '$127.50',
+ change: 23,
+ changeLabel: 'vs last month',
+ icon: ,
+ trend: 'up' as const,
+ },
+ {
+ title: 'Tokens Saved',
+ value: '2.4M',
+ change: 18,
+ changeLabel: 'through caching',
+ icon: ,
+ trend: 'up' as const,
+ },
+ {
+ title: 'Queries Faster',
+ value: '43%',
+ change: 12,
+ changeLabel: 'average speedup',
+ icon: ,
+ trend: 'up' as const,
+ },
+ {
+ title: 'Errors Reduced',
+ value: '-67%',
+ change: 67,
+ changeLabel: 'with retry logic',
+ icon: ,
+ trend: 'down' as const,
+ },
+ ];
+
+ return (
+
+
Performance Metrics
+
+ {metrics.map((metric, index) => (
+
+ ))}
+
+
+
+
+ Monthly Summary
+
+
+
+
+
$342.10
+
Total Saved
+
+
+
8.7M
+
Tokens Processed
+
+
+
1,247
+
Queries Handled
+
+
+
+
+
+
+ );
+}