diff --git a/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx b/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx
index a55ff4f2..27f2c32f 100644
--- a/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx
+++ b/ccw/frontend/src/components/api-settings/CcwLitellmStatus.tsx
@@ -13,6 +13,7 @@ import {
XCircle,
Loader2,
Package,
+ AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent } from '@/components/ui/Card';
@@ -71,6 +72,8 @@ export function CcwLitellmStatus() {
const installed = status?.installed ?? false;
const version = status?.version;
+ const systemPythonInstalled = status?.checks?.systemPython?.installed === true;
+ const showSystemPythonMismatch = !isLoading && !installed && systemPythonInstalled;
return (
<>
@@ -86,6 +89,12 @@ export function CcwLitellmStatus() {
{formatMessage({ id: 'apiSettings.ccwLitellm.description' })}
+ {showSystemPythonMismatch && (
+
+
+
{formatMessage({ id: 'apiSettings.ccwLitellm.systemPythonMismatch' })}
+
+ )}
diff --git a/ccw/frontend/src/components/shared/SkillDetailPanel.tsx b/ccw/frontend/src/components/shared/SkillDetailPanel.tsx
index d973bdd4..e89775cf 100644
--- a/ccw/frontend/src/components/shared/SkillDetailPanel.tsx
+++ b/ccw/frontend/src/components/shared/SkillDetailPanel.tsx
@@ -3,7 +3,7 @@
// ========================================
// Right-side slide-out panel for viewing skill details
-import { useEffect } from 'react';
+import { useEffect, useState, useMemo, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
X,
@@ -15,12 +15,21 @@ import {
Tag,
MapPin,
Code,
+ ChevronRight,
+ ChevronDown,
+ Eye,
+ Loader2,
+ Maximize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import type { Skill } from '@/lib/api';
+import { buildSkillFileTree, getDefaultExpandedPaths } from '@/utils/skill-files';
+import type { FileSystemNode } from '@/types/file-explorer';
+import { readSkillFile } from '@/lib/api';
+import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
export interface SkillDetailPanelProps {
skill: Skill | null;
@@ -42,6 +51,15 @@ export function SkillDetailPanel({
isLoading = false,
}: SkillDetailPanelProps) {
const { formatMessage } = useIntl();
+ const projectPath = useWorkflowStore(selectProjectPath);
+ const [cliMode] = useState<'claude' | 'codex'>('claude');
+
+ // Tree view state
+ const [expandedPaths, setExpandedPaths] = useState>(new Set());
+ const [selectedFile, setSelectedFile] = useState(null);
+ const [previewContent, setPreviewContent] = useState(null);
+ const [isPreviewLoading, setIsPreviewLoading] = useState(false);
+ const [showPreviewPanel, setShowPreviewPanel] = useState(false);
// Prevent body scroll when panel is open
useEffect(() => {
@@ -55,6 +73,56 @@ export function SkillDetailPanel({
};
}, [isOpen]);
+ // Build file tree from supportingFiles
+ const fileTree = useMemo(() => {
+ if (!skill?.supportingFiles) return [];
+ return buildSkillFileTree(skill.supportingFiles);
+ }, [skill?.supportingFiles]);
+
+ // Initialize expanded paths when skill changes
+ useEffect(() => {
+ if (fileTree.length > 0) {
+ setExpandedPaths(getDefaultExpandedPaths(fileTree));
+ }
+ }, [fileTree]);
+
+ // Load file content for preview
+ const loadFilePreview = useCallback(async (filePath: string) => {
+ if (!skill) return;
+
+ setIsPreviewLoading(true);
+ setShowPreviewPanel(true);
+ setSelectedFile(filePath);
+ setPreviewContent(null);
+
+ try {
+ const data = await readSkillFile({
+ skillName: skill.folderName || skill.name,
+ fileName: filePath,
+ location: skill.location || 'project',
+ projectPath: projectPath,
+ cliType: cliMode,
+ });
+ setPreviewContent(data.content);
+ } catch (error) {
+ console.error('Failed to load file preview:', error);
+ setPreviewContent(`Error loading preview: ${error instanceof Error ? error.message : String(error)}`);
+ } finally {
+ setIsPreviewLoading(false);
+ }
+ }, [skill, projectPath, cliMode]);
+
+ const handleClosePreview = () => {
+ setShowPreviewPanel(false);
+ };
+
+ // Close preview on panel close
+ useEffect(() => {
+ if (!isOpen) {
+ handleClosePreview();
+ }
+ }, [isOpen]);
+
if (!isOpen || !skill) {
return null;
}
@@ -67,6 +135,19 @@ export function SkillDetailPanel({
onEditFile?.(folderName, fileName, skill.location || 'project');
};
+ // Toggle directory expanded state
+ const togglePath = (path: string) => {
+ setExpandedPaths(prev => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ };
+
return (
<>
{/* Overlay */}
@@ -206,58 +287,67 @@ export function SkillDetailPanel({
{formatMessage({ id: 'skills.files' })}
-
+
{/* SKILL.md (main file) */}
SKILL.md
- {onEditFile && (
+
- )}
+ {onEditFile && (
+
+ )}
+
- {/* Supporting Files */}
- {hasSupportingFiles && skill.supportingFiles!.map((file) => {
- const isDir = file.endsWith('/');
- const displayName = isDir ? file.slice(0, -1) : file;
- return (
-
-
- {isDir ? (
-
- ) : (
-
- )}
- {displayName}
-
- {!isDir && onEditFile && (
-
- )}
-
- );
- })}
+ {/* Supporting Files Tree */}
+ {hasSupportingFiles && fileTree.length > 0 && (
+
+
+
+ )}
+
+ {/* Empty state */}
+ {hasSupportingFiles && fileTree.length === 0 && (
+
+ {formatMessage({ id: 'skills.files.empty' })}
+
+ )}
+ {/* File Preview Modal */}
+
+
{/* Path */}
{skill.path && (
@@ -309,4 +399,243 @@ export function SkillDetailPanel({
);
}
+// ========================================
+// SkillFileTree Component
+// ========================================
+// Recursive tree view for skill files
+
+interface SkillFileTreeNodeProps {
+ node: FileSystemNode;
+ depth: number;
+ expandedPaths: Set;
+ onTogglePath: (path: string) => void;
+ onEditFile: (fileName: string) => void;
+ onPreviewFile: (fileName: string) => void;
+}
+
+function SkillFileTreeNode({
+ node,
+ depth,
+ expandedPaths,
+ onTogglePath,
+ onEditFile,
+ onPreviewFile,
+}: SkillFileTreeNodeProps) {
+ const isDirectory = node.type === 'directory';
+ const isExpanded = expandedPaths.has(node.path);
+ const hasChildren = node.children && node.children.length > 0;
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (isDirectory) {
+ onTogglePath(node.path);
+ } else {
+ // Preview file on click
+ onPreviewFile(node.path);
+ }
+ };
+
+ return (
+
+
+ {/* Chevron for directories */}
+ {isDirectory && (
+
+ {hasChildren && (
+ isExpanded ? (
+
+ ) : (
+
+ )
+ )}
+
+ )}
+
+ {/* Icon */}
+ {isDirectory ? (
+
+ ) : (
+
+ )}
+
+ {/* Name */}
+
{node.name}
+
+ {/* Preview button for files */}
+ {!isDirectory && (
+
+
+
+
+ )}
+
+
+ {/* Children */}
+ {isDirectory && isExpanded && hasChildren && (
+
+ {node.children!.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+interface SkillFileTreeProps {
+ nodes: FileSystemNode[];
+ expandedPaths: Set;
+ onTogglePath: (path: string) => void;
+ onEditFile: (fileName: string) => void;
+ onPreviewFile: (fileName: string) => void;
+ depth: number;
+}
+
+function SkillFileTree({
+ nodes,
+ expandedPaths,
+ onTogglePath,
+ onEditFile,
+ onPreviewFile,
+ depth,
+}: SkillFileTreeProps) {
+ return (
+
+ {nodes.map((node) => (
+
+ ))}
+
+ );
+}
+
+// ========================================
+// File Preview Modal Component
+// ========================================
+
+interface FilePreviewModalProps {
+ fileName: string | null;
+ content: string | null;
+ isLoading: boolean;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+function FilePreviewModal({ fileName, content, isLoading, isOpen, onClose }: FilePreviewModalProps) {
+ const { formatMessage } = useIntl();
+
+ if (!isOpen) return null;
+
+ return (
+ <>
+ {/* Overlay */}
+
+
+ {/* Modal */}
+
+
+ {/* Header */}
+
+
+
+ {fileName}
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {isLoading ? (
+
+
+
+ ) : content ? (
+
+
+ {content}
+
+
+ ) : (
+
+ {formatMessage({ id: 'skills.files.preview.empty' })}
+
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+
+ >
+ );
+}
+
export default SkillDetailPanel;
diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts
index b475f564..270c1ace 100644
--- a/ccw/frontend/src/lib/api.ts
+++ b/ccw/frontend/src/lib/api.ts
@@ -5968,10 +5968,31 @@ export async function previewYamlConfig(): Promise<{ success: boolean; config: s
// ========== CCW-LiteLLM Package Management ==========
+export interface CcwLitellmEnvCheck {
+ python: string;
+ installed: boolean;
+ version?: string;
+ error?: string;
+}
+
+export interface CcwLitellmStatus {
+ /**
+ * Whether ccw-litellm is installed in the CodexLens venv.
+ * This is the environment used for the LiteLLM embedding backend.
+ */
+ installed: boolean;
+ version?: string;
+ error?: string;
+ checks?: {
+ codexLensVenv: CcwLitellmEnvCheck;
+ systemPython?: CcwLitellmEnvCheck;
+ };
+}
+
/**
* Check ccw-litellm status
*/
-export async function checkCcwLitellmStatus(refresh = false): Promise<{ installed: boolean; version?: string; error?: string }> {
+export async function checkCcwLitellmStatus(refresh = false): Promise {
return fetchApi(`/api/litellm-api/ccw-litellm/status${refresh ? '?refresh=true' : ''}`);
}
diff --git a/ccw/frontend/src/locales/en/api-settings.json b/ccw/frontend/src/locales/en/api-settings.json
index 92c6737e..b8aee0e2 100644
--- a/ccw/frontend/src/locales/en/api-settings.json
+++ b/ccw/frontend/src/locales/en/api-settings.json
@@ -322,6 +322,7 @@
"ccwLitellm": {
"title": "CCW-LiteLLM Package",
"description": "Manage ccw-litellm Python package installation",
+ "systemPythonMismatch": "Detected ccw-litellm installed in system Python, but not in the CodexLens venv (~/.codexlens/venv). Click Install to install it into the venv.",
"status": {
"installed": "Installed",
"notInstalled": "Not Installed",
diff --git a/ccw/frontend/src/locales/zh/api-settings.json b/ccw/frontend/src/locales/zh/api-settings.json
index 8d526c29..54910428 100644
--- a/ccw/frontend/src/locales/zh/api-settings.json
+++ b/ccw/frontend/src/locales/zh/api-settings.json
@@ -322,6 +322,7 @@
"ccwLitellm": {
"title": "CCW-LiteLLM 包",
"description": "管理 ccw-litellm Python 包安装",
+ "systemPythonMismatch": "检测到 ccw-litellm 已安装在系统 Python 中,但未安装到 CodexLens 虚拟环境(~/.codexlens/venv)里,所以这里显示未安装。点击“安装”即可安装到虚拟环境中。",
"status": {
"installed": "已安装",
"notInstalled": "未安装",
diff --git a/ccw/frontend/src/utils/skill-files.ts b/ccw/frontend/src/utils/skill-files.ts
new file mode 100644
index 00000000..8540c246
--- /dev/null
+++ b/ccw/frontend/src/utils/skill-files.ts
@@ -0,0 +1,160 @@
+// ========================================
+// Skill File Tree Utilities
+// ========================================
+// Convert flat supportingFiles array to tree structure
+
+import type { FileSystemNode } from '@/types/file-explorer';
+
+/**
+ * Build a file tree from a flat array of file paths
+ *
+ * @param supportingFiles - Array of file paths (e.g., ['components/', 'components/Button.tsx'])
+ * @returns Tree structure of files and directories
+ */
+export function buildSkillFileTree(supportingFiles: string[]): FileSystemNode[] {
+ // Map to store all nodes by their path
+ const nodeMap = new Map();
+
+ for (const entry of supportingFiles) {
+ const isDirectoryMarker = entry.endsWith('/');
+ const cleanPath = isDirectoryMarker ? entry.slice(0, -1) : entry;
+ const parts = cleanPath.split('/');
+
+ if (!cleanPath || parts.length === 0) continue;
+
+ // Build the path hierarchy for this entry
+ let currentPath = '';
+
+ // Process all parts except the last one (these are intermediate directories)
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i];
+ const parentPath = currentPath;
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
+
+ // Create directory node if it doesn't exist
+ if (!nodeMap.has(currentPath)) {
+ nodeMap.set(currentPath, {
+ name: part,
+ path: currentPath,
+ type: 'directory',
+ children: [],
+ });
+ }
+
+ // Add to parent's children
+ if (parentPath) {
+ const parent = nodeMap.get(parentPath);
+ if (parent && parent.type === 'directory') {
+ const existingChild = parent.children!.find(c => c.path === currentPath);
+ if (!existingChild) {
+ parent.children!.push(nodeMap.get(currentPath)!);
+ }
+ }
+ }
+ }
+
+ // Process the final part (file or empty directory marker)
+ const finalName = parts[parts.length - 1];
+ const finalPath = cleanPath;
+
+ // Create or update the final node
+ const existingNode = nodeMap.get(finalPath);
+ const finalNode: FileSystemNode = existingNode
+ ? { ...existingNode, name: finalName } // Keep children if already exists
+ : {
+ name: finalName,
+ path: finalPath,
+ type: isDirectoryMarker ? 'directory' : 'file',
+ children: isDirectoryMarker ? [] : undefined,
+ };
+
+ nodeMap.set(finalPath, finalNode);
+
+ // Add final node to parent's children
+ const parentPath = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
+ if (parentPath) {
+ const parent = nodeMap.get(parentPath);
+ if (parent && parent.type === 'directory') {
+ const existingChild = parent.children!.find(c => c.path === finalPath);
+ if (!existingChild) {
+ parent.children!.push(finalNode);
+ } else {
+ // Update existing child if this is a file (not just a directory marker)
+ if (!isDirectoryMarker) {
+ existingChild.type = 'file';
+ existingChild.children = undefined;
+ }
+ }
+ }
+ }
+ }
+
+ // Collect root-level nodes (nodes with single-segment paths)
+ const result: FileSystemNode[] = [];
+ for (const [path, node] of nodeMap.entries()) {
+ if (!path.includes('/')) {
+ result.push(node);
+ }
+ }
+
+ // Sort: directories first, then files, alphabetically
+ result.sort((a, b) => {
+ if (a.type === 'directory' && b.type === 'file') return -1;
+ if (a.type === 'file' && b.type === 'directory') return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ // Sort children recursively
+ const sortChildren = (node: FileSystemNode) => {
+ if (node.type === 'directory' && node.children) {
+ node.children.sort((a, b) => {
+ if (a.type === 'directory' && b.type === 'file') return -1;
+ if (a.type === 'file' && b.type === 'directory') return 1;
+ return a.name.localeCompare(b.name);
+ });
+ node.children.forEach(sortChildren);
+ }
+ };
+
+ result.forEach(sortChildren);
+
+ return result;
+}
+
+/**
+ * Get default expanded paths for a file tree
+ * Expands all directories by default for skill details
+ *
+ * @param nodes - File tree nodes
+ * @returns Set of all directory paths
+ */
+export function getDefaultExpandedPaths(nodes: FileSystemNode[]): Set {
+ const expanded = new Set();
+
+ const collectDirectories = (node: FileSystemNode) => {
+ if (node.type === 'directory') {
+ expanded.add(node.path);
+ node.children?.forEach(collectDirectories);
+ }
+ };
+
+ nodes.forEach(collectDirectories);
+
+ return expanded;
+}
+
+/**
+ * Format file path for display
+ */
+export function formatFilePath(path: string): string {
+ const parts = path.split('/');
+ return parts[parts.length - 1];
+}
+
+/**
+ * Get file extension
+ */
+export function getFileExtension(path: string): string {
+ const parts = path.split('.');
+ return parts.length > 1 ? parts.pop()! : '';
+}
diff --git a/ccw/src/core/routes/litellm-api-routes.ts b/ccw/src/core/routes/litellm-api-routes.ts
index b8d84371..e88c6ac4 100644
--- a/ccw/src/core/routes/litellm-api-routes.ts
+++ b/ccw/src/core/routes/litellm-api-routes.ts
@@ -80,9 +80,89 @@ import { getContextCacheStore } from '../../tools/context-cache-store.js';
import { getLiteLLMClient } from '../../tools/litellm-client.js';
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
+interface CcwLitellmEnvCheck {
+ python: string;
+ installed: boolean;
+ version?: string;
+ error?: string;
+}
+
+interface CcwLitellmStatusResponse {
+ /**
+ * Whether ccw-litellm is installed in the CodexLens venv.
+ * This is the environment used for the LiteLLM embedding backend.
+ */
+ installed: boolean;
+ version?: string;
+ error?: string;
+ checks?: {
+ codexLensVenv: CcwLitellmEnvCheck;
+ systemPython?: CcwLitellmEnvCheck;
+ };
+}
+
+function checkCcwLitellmImport(
+ pythonCmd: string,
+ options: { timeout: number; shell?: boolean }
+): Promise {
+ const { timeout, shell = false } = options;
+
+ const sanitizePythonError = (stderrText: string): string | undefined => {
+ const trimmed = stderrText.trim();
+ if (!trimmed) return undefined;
+ const lines = trimmed
+ .split(/\r?\n/g)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ // Prefer the final exception line (avoids leaking full traceback + file paths)
+ return lines[lines.length - 1] || undefined;
+ };
+
+ return new Promise((resolve) => {
+ const child = spawn(pythonCmd, ['-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ timeout,
+ windowsHide: true,
+ shell,
+ });
+
+ let stdout = '';
+ let stderr = '';
+
+ child.stdout?.on('data', (data: Buffer) => {
+ stdout += data.toString();
+ });
+
+ child.stderr?.on('data', (data: Buffer) => {
+ stderr += data.toString();
+ });
+
+ child.on('close', (code: number | null) => {
+ const version = stdout.trim();
+ const error = sanitizePythonError(stderr);
+
+ if (code === 0 && version) {
+ resolve({ python: pythonCmd, installed: true, version });
+ return;
+ }
+
+ if (code === null) {
+ resolve({ python: pythonCmd, installed: false, error: `Timed out after ${timeout}ms` });
+ return;
+ }
+
+ resolve({ python: pythonCmd, installed: false, error: error || undefined });
+ });
+
+ child.on('error', (err) => {
+ resolve({ python: pythonCmd, installed: false, error: err.message });
+ });
+ });
+}
+
// Cache for ccw-litellm status check
let ccwLitellmStatusCache: {
- data: { installed: boolean; version?: string; error?: string } | null;
+ data: CcwLitellmStatusResponse | null;
timestamp: number;
ttl: number;
} = {
@@ -849,51 +929,29 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise((resolve) => {
- const child = spawn(venvPython, ['-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
- stdio: ['ignore', 'pipe', 'pipe'],
- timeout: statusTimeout,
- windowsHide: true,
- });
- let stdout = '';
- child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
- child.on('close', (code: number | null) => {
- if (code === 0) {
- const version = stdout.trim();
- if (version) {
- console.log(`[ccw-litellm status] Found in CodexLens venv: ${version}`);
- resolve({ installed: true, version });
- return;
- }
- }
- console.log('[ccw-litellm status] Not found in CodexLens venv');
- resolve({ installed: false });
- });
- child.on('error', () => {
- console.log('[ccw-litellm status] Spawn error checking venv');
- resolve({ installed: false });
- });
- });
- } catch (venvErr) {
- console.log('[ccw-litellm status] Not found in CodexLens venv');
- result = { installed: false };
- }
- } else {
- console.log('[ccw-litellm status] CodexLens venv not valid');
- result = { installed: false };
- }
+ // Diagnostics only: if not installed in venv, also check system python so users understand mismatches.
+ // NOTE: `installed` flag remains the CodexLens venv status (we want isolated venv dependencies).
+ const systemPython = !codexLensVenv.installed
+ ? await checkCcwLitellmImport(getSystemPython(), { timeout: statusTimeout, shell: true })
+ : undefined;
+
+ const result: CcwLitellmStatusResponse = {
+ installed: codexLensVenv.installed,
+ version: codexLensVenv.version,
+ error: codexLensVenv.error,
+ checks: {
+ codexLensVenv,
+ ...(systemPython ? { systemPython } : {}),
+ },
+ };
// Update cache
ccwLitellmStatusCache = {
diff --git a/ccw/src/core/routes/skills-routes.ts b/ccw/src/core/routes/skills-routes.ts
index 9b2262bc..048b46b3 100644
--- a/ccw/src/core/routes/skills-routes.ts
+++ b/ccw/src/core/routes/skills-routes.ts
@@ -279,27 +279,37 @@ function parseSkillFrontmatter(content: string): ParsedSkillFrontmatter {
}
/**
- * Get list of supporting files for a skill
+ * Get list of supporting files for a skill (recursive)
* @param {string} skillDir
- * @returns {string[]}
+ * @returns {string[]} Array of relative paths (directories end with '/')
*/
function getSupportingFiles(skillDir: string): string[] {
const files: string[] = [];
- try {
- const entries = readdirSync(skillDir, { withFileTypes: true });
- for (const entry of entries) {
- // Exclude SKILL.md and SKILL.md.disabled from supporting files
- if (entry.name !== 'SKILL.md' && entry.name !== 'SKILL.md.disabled') {
- if (entry.isFile()) {
- files.push(entry.name);
- } else if (entry.isDirectory()) {
- files.push(entry.name + '/');
+
+ function readDirRecursive(dirPath: string, relativePath: string = '') {
+ try {
+ const entries = readdirSync(dirPath, { withFileTypes: true });
+ for (const entry of entries) {
+ // Exclude SKILL.md and SKILL.md.disabled from supporting files
+ if (entry.name !== 'SKILL.md' && entry.name !== 'SKILL.md.disabled') {
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
+
+ if (entry.isFile()) {
+ files.push(entryRelativePath);
+ } else if (entry.isDirectory()) {
+ // Add directory marker
+ files.push(entryRelativePath + '/');
+ // Recurse into subdirectory
+ readDirRecursive(join(dirPath, entry.name), entryRelativePath);
+ }
}
}
+ } catch (e) {
+ // Ignore errors
}
- } catch (e) {
- // Ignore errors
}
+
+ readDirRecursive(skillDir);
return files;
}