mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
feat(cli): 添加 --rule 选项支持模板自动发现
重构 ccw cli 模板系统: - 新增 template-discovery.ts 模块,支持扁平化模板自动发现 - 添加 --rule <template> 选项,自动加载 protocol 和 template - 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt) - 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式 - 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt 其他更新: - Dashboard: 添加 Claude Manager 和 Issue Manager 页面 - Codex-lens: 增强 chain_search 和 clustering 模块 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -187,6 +187,13 @@ export function run(argv: string[]): void {
|
||||
.option('--no-native', 'Force prompt concatenation instead of native resume')
|
||||
.option('--cache [items]', 'Cache: comma-separated @patterns and text content')
|
||||
.option('--inject-mode <mode>', 'Inject mode: none, full, progressive (default: codex=full, others=none)')
|
||||
// Template/Rules options
|
||||
.option('--rule <template>', 'Template name for auto-discovery (defines $PROTO and $TMPL env vars)')
|
||||
// Codex review options
|
||||
.option('--uncommitted', 'Review uncommitted changes (codex review)')
|
||||
.option('--base <branch>', 'Review changes against base branch (codex review)')
|
||||
.option('--commit <sha>', 'Review changes from specific commit (codex review)')
|
||||
.option('--title <title>', 'Optional commit title for review summary (codex review)')
|
||||
// Storage options
|
||||
.option('--project <path>', 'Project path for storage operations')
|
||||
.option('--force', 'Confirm destructive operations')
|
||||
|
||||
@@ -124,6 +124,13 @@ interface CliExecOptions {
|
||||
cache?: string | boolean; // Cache: true = auto from CONTEXT, string = comma-separated patterns/content
|
||||
injectMode?: 'none' | 'full' | 'progressive'; // Inject mode for cached content
|
||||
debug?: boolean; // Enable debug logging
|
||||
// Codex review options
|
||||
uncommitted?: boolean; // Review uncommitted changes (default for review mode)
|
||||
base?: string; // Review changes against base branch
|
||||
commit?: string; // Review changes from specific commit
|
||||
title?: string; // Optional title for review summary
|
||||
// Template/Rules options
|
||||
rule?: string; // Template name for auto-discovery (defines $PROTO and $TMPL env vars)
|
||||
}
|
||||
|
||||
/** Cache configuration parsed from --cache */
|
||||
@@ -535,7 +542,7 @@ async function statusAction(debug?: boolean): Promise<void> {
|
||||
* @param {Object} options - CLI options
|
||||
*/
|
||||
async function execAction(positionalPrompt: string | undefined, options: CliExecOptions): Promise<void> {
|
||||
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug } = options;
|
||||
const { prompt: optionPrompt, file, tool = 'gemini', mode = 'analysis', model, cd, includeDirs, stream, resume, id, noNative, cache, injectMode, debug, uncommitted, base, commit, title, rule } = options;
|
||||
|
||||
// Enable debug mode if --debug flag is set
|
||||
if (debug) {
|
||||
@@ -579,6 +586,25 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
|
||||
const prompt_to_use = finalPrompt || '';
|
||||
|
||||
// Load rules templates if --rule is specified (will be passed as env vars)
|
||||
let rulesEnv: { PROTO?: string; TMPL?: string } = {};
|
||||
if (rule) {
|
||||
try {
|
||||
const { loadProtocol, loadTemplate } = await import('../tools/template-discovery.js');
|
||||
const proto = loadProtocol(mode);
|
||||
const tmpl = loadTemplate(rule);
|
||||
if (proto) rulesEnv.PROTO = proto;
|
||||
if (tmpl) rulesEnv.TMPL = tmpl;
|
||||
if (debug) {
|
||||
console.log(chalk.gray(` Rule loaded: PROTO(${proto ? proto.length : 0} chars) + TMPL(${tmpl ? tmpl.length : 0} chars)`));
|
||||
console.log(chalk.gray(` Use $PROTO and $TMPL in your prompt to reference them`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(chalk.red(`Error loading rule template: ${error instanceof Error ? error.message : error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cache option: pack @patterns and/or content
|
||||
let cacheSessionId: string | undefined;
|
||||
let actualPrompt = prompt_to_use;
|
||||
@@ -847,7 +873,14 @@ async function execAction(positionalPrompt: string | undefined, options: CliExec
|
||||
id, // custom execution ID
|
||||
noNative,
|
||||
stream: !!stream, // stream=true → streaming enabled (no cache), stream=false → cache output (default)
|
||||
outputFormat // Enable JSONL parsing for tools that support it
|
||||
outputFormat, // Enable JSONL parsing for tools that support it
|
||||
// Codex review options
|
||||
uncommitted,
|
||||
base,
|
||||
commit,
|
||||
title,
|
||||
// Rules env vars (PROTO, TMPL)
|
||||
rulesEnv: Object.keys(rulesEnv).length > 0 ? rulesEnv : undefined
|
||||
}, onOutput); // Always pass onOutput for real-time dashboard streaming
|
||||
|
||||
if (elapsedInterval) clearInterval(elapsedInterval);
|
||||
|
||||
@@ -811,6 +811,56 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Batch delete files
|
||||
if (pathname === '/api/memory/claude/batch-delete' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { paths, confirm } = body;
|
||||
|
||||
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
||||
return { error: 'paths array is required', status: 400 };
|
||||
}
|
||||
|
||||
if (confirm !== true) {
|
||||
return { error: 'Confirmation required', status: 400 };
|
||||
}
|
||||
|
||||
const results = {
|
||||
success: true,
|
||||
total: paths.length,
|
||||
deleted: 0,
|
||||
errors: [] as Array<{ path: string; error: string }>
|
||||
};
|
||||
|
||||
// Delete each file
|
||||
for (const filePath of paths) {
|
||||
const result = deleteClaudeFile(filePath);
|
||||
if (result.success) {
|
||||
results.deleted++;
|
||||
// Broadcast individual file deletion
|
||||
broadcastToClients({
|
||||
type: 'CLAUDE_FILE_DELETED',
|
||||
data: { path: filePath }
|
||||
});
|
||||
} else {
|
||||
results.errors.push({ path: filePath, error: result.error || 'Unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast batch deletion completion
|
||||
broadcastToClients({
|
||||
type: 'CLAUDE_BATCH_DELETED',
|
||||
data: {
|
||||
total: results.total,
|
||||
deleted: results.deleted,
|
||||
failed: results.errors.length
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Create file
|
||||
if (pathname === '/api/memory/claude/create' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
|
||||
@@ -79,6 +79,12 @@ function writeSolutionsJsonl(issuesDir: string, issueId: string, solutions: any[
|
||||
writeFileSync(join(solutionsDir, `${issueId}.jsonl`), solutions.map(s => JSON.stringify(s)).join('\n'));
|
||||
}
|
||||
|
||||
function generateQueueFileId(): string {
|
||||
const now = new Date();
|
||||
const ts = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
||||
return `QUE-${ts}`;
|
||||
}
|
||||
|
||||
function readQueue(issuesDir: string) {
|
||||
// Try new multi-queue structure first
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
@@ -718,6 +724,183 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/queue/split - Split items from source queue into a new queue
|
||||
if (pathname === '/api/queue/split' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
const { sourceQueueId, itemIds } = body;
|
||||
if (!sourceQueueId || !itemIds || !Array.isArray(itemIds) || itemIds.length === 0) {
|
||||
return { error: 'sourceQueueId and itemIds (non-empty array) required' };
|
||||
}
|
||||
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
const sourcePath = join(queuesDir, `${sourceQueueId}.json`);
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
return { error: `Source queue ${sourceQueueId} not found` };
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceQueue = JSON.parse(readFileSync(sourcePath, 'utf8'));
|
||||
const sourceItems = sourceQueue.solutions || sourceQueue.tasks || [];
|
||||
const isSolutionBased = !!sourceQueue.solutions;
|
||||
|
||||
// Find items to split
|
||||
const itemsToSplit = sourceItems.filter((item: any) =>
|
||||
itemIds.includes(item.item_id) ||
|
||||
itemIds.includes(item.solution_id) ||
|
||||
itemIds.includes(item.task_id)
|
||||
);
|
||||
|
||||
if (itemsToSplit.length === 0) {
|
||||
return { error: 'No matching items found to split' };
|
||||
}
|
||||
|
||||
if (itemsToSplit.length === sourceItems.length) {
|
||||
return { error: 'Cannot split all items - at least one item must remain in source queue' };
|
||||
}
|
||||
|
||||
// Find remaining items
|
||||
const remainingItems = sourceItems.filter((item: any) =>
|
||||
!itemIds.includes(item.item_id) &&
|
||||
!itemIds.includes(item.solution_id) &&
|
||||
!itemIds.includes(item.task_id)
|
||||
);
|
||||
|
||||
// Create new queue with split items
|
||||
const newQueueId = generateQueueFileId();
|
||||
const newQueuePath = join(queuesDir, `${newQueueId}.json`);
|
||||
|
||||
// Re-index split items
|
||||
const reindexedSplitItems = itemsToSplit.map((item: any, idx: number) => ({
|
||||
...item,
|
||||
execution_order: idx + 1
|
||||
}));
|
||||
|
||||
// Extract issue IDs from split items
|
||||
const splitIssueIds = [...new Set(itemsToSplit.map((item: any) => item.issue_id).filter(Boolean))];
|
||||
|
||||
// Remaining issue IDs
|
||||
const remainingIssueIds = [...new Set(remainingItems.map((item: any) => item.issue_id).filter(Boolean))];
|
||||
|
||||
// Create new queue
|
||||
const newQueue: any = {
|
||||
id: newQueueId,
|
||||
status: 'active',
|
||||
issue_ids: splitIssueIds,
|
||||
conflicts: [],
|
||||
_metadata: {
|
||||
version: '2.1',
|
||||
updated_at: new Date().toISOString(),
|
||||
split_from: sourceQueueId,
|
||||
split_at: new Date().toISOString(),
|
||||
...(isSolutionBased
|
||||
? {
|
||||
total_solutions: reindexedSplitItems.length,
|
||||
completed_solutions: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
|
||||
}
|
||||
: {
|
||||
total_tasks: reindexedSplitItems.length,
|
||||
completed_tasks: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
if (isSolutionBased) {
|
||||
newQueue.solutions = reindexedSplitItems;
|
||||
} else {
|
||||
newQueue.tasks = reindexedSplitItems;
|
||||
}
|
||||
|
||||
// Update source queue with remaining items
|
||||
const reindexedRemainingItems = remainingItems.map((item: any, idx: number) => ({
|
||||
...item,
|
||||
execution_order: idx + 1
|
||||
}));
|
||||
|
||||
if (isSolutionBased) {
|
||||
sourceQueue.solutions = reindexedRemainingItems;
|
||||
} else {
|
||||
sourceQueue.tasks = reindexedRemainingItems;
|
||||
}
|
||||
|
||||
sourceQueue.issue_ids = remainingIssueIds;
|
||||
sourceQueue._metadata = {
|
||||
...sourceQueue._metadata,
|
||||
updated_at: new Date().toISOString(),
|
||||
...(isSolutionBased
|
||||
? {
|
||||
total_solutions: reindexedRemainingItems.length,
|
||||
completed_solutions: reindexedRemainingItems.filter((i: any) => i.status === 'completed').length
|
||||
}
|
||||
: {
|
||||
total_tasks: reindexedRemainingItems.length,
|
||||
completed_tasks: reindexedRemainingItems.filter((i: any) => i.status === 'completed').length
|
||||
})
|
||||
};
|
||||
|
||||
// Write both queues
|
||||
writeFileSync(newQueuePath, JSON.stringify(newQueue, null, 2));
|
||||
writeFileSync(sourcePath, JSON.stringify(sourceQueue, null, 2));
|
||||
|
||||
// Update index
|
||||
const indexPath = join(queuesDir, 'index.json');
|
||||
if (existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(readFileSync(indexPath, 'utf8'));
|
||||
|
||||
// Add new queue to index
|
||||
const newQueueEntry: any = {
|
||||
id: newQueueId,
|
||||
status: 'active',
|
||||
issue_ids: splitIssueIds,
|
||||
created_at: new Date().toISOString(),
|
||||
...(isSolutionBased
|
||||
? {
|
||||
total_solutions: reindexedSplitItems.length,
|
||||
completed_solutions: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
|
||||
}
|
||||
: {
|
||||
total_tasks: reindexedSplitItems.length,
|
||||
completed_tasks: reindexedSplitItems.filter((i: any) => i.status === 'completed').length
|
||||
})
|
||||
};
|
||||
|
||||
index.queues = index.queues || [];
|
||||
index.queues.push(newQueueEntry);
|
||||
|
||||
// Update source queue in index
|
||||
const sourceEntry = index.queues.find((q: any) => q.id === sourceQueueId);
|
||||
if (sourceEntry) {
|
||||
sourceEntry.issue_ids = remainingIssueIds;
|
||||
if (isSolutionBased) {
|
||||
sourceEntry.total_solutions = reindexedRemainingItems.length;
|
||||
sourceEntry.completed_solutions = reindexedRemainingItems.filter((i: any) => i.status === 'completed').length;
|
||||
} else {
|
||||
sourceEntry.total_tasks = reindexedRemainingItems.length;
|
||||
sourceEntry.completed_tasks = reindexedRemainingItems.filter((i: any) => i.status === 'completed').length;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(indexPath, JSON.stringify(index, null, 2));
|
||||
} catch {
|
||||
// Ignore index update errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sourceQueueId,
|
||||
newQueueId,
|
||||
splitItemCount: itemsToSplit.length,
|
||||
remainingItemCount: remainingItems.length
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: 'Failed to split queue' };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy: GET /api/issues/queue (backward compat)
|
||||
if (pathname === '/api/issues/queue' && req.method === 'GET') {
|
||||
const queue = groupQueueByExecutionGroup(readQueue(issuesDir));
|
||||
|
||||
@@ -75,10 +75,13 @@ async function getSessionDetailData(sessionPath: string, dataType: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
// Load summaries from .summaries/
|
||||
// Load summaries from .summaries/ and fallback to plan.json
|
||||
if (dataType === 'summary' || dataType === 'all') {
|
||||
const summariesDir = join(normalizedPath, '.summaries');
|
||||
result.summaries = [];
|
||||
result.summary = null; // Single summary text from plan.json
|
||||
|
||||
// 1. Try to load from .summaries/ directory
|
||||
if (await fileExists(summariesDir)) {
|
||||
const files = (await readdir(summariesDir)).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
@@ -90,6 +93,26 @@ async function getSessionDetailData(sessionPath: string, dataType: string): Prom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: Try to get summary from plan.json (for lite-fix-plan sessions)
|
||||
if (result.summaries.length === 0) {
|
||||
const planFile = join(normalizedPath, 'plan.json');
|
||||
if (await fileExists(planFile)) {
|
||||
try {
|
||||
const planData = JSON.parse(await readFile(planFile, 'utf8'));
|
||||
// Check plan.summary
|
||||
if (planData.summary) {
|
||||
result.summary = planData.summary;
|
||||
}
|
||||
// Check synthesis.convergence.summary
|
||||
if (!result.summary && planData.synthesis?.convergence?.summary) {
|
||||
result.summary = planData.synthesis.convergence.summary;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse plan file for summary:', planFile, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load plan.json (for lite tasks)
|
||||
|
||||
@@ -906,3 +906,182 @@
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Batch Delete Modal
|
||||
* ======================================== */
|
||||
.batch-delete-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--destructive));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.warning-banner i {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delete-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.file-list-container h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--muted) / 0.2);
|
||||
}
|
||||
|
||||
.delete-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.delete-file-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.delete-file-item:hover {
|
||||
background: hsl(var(--hover));
|
||||
}
|
||||
|
||||
.delete-file-item i {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-info .file-name {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.file-info .file-path {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.level-badge.project {
|
||||
background: hsl(142, 76%, 36%, 0.15);
|
||||
color: hsl(142, 76%, 36%);
|
||||
border: 1px solid hsl(142, 76%, 36%, 0.3);
|
||||
}
|
||||
|
||||
.level-badge.module {
|
||||
background: hsl(221, 83%, 53%, 0.15);
|
||||
color: hsl(221, 83%, 53%);
|
||||
border: 1px solid hsl(221, 83%, 53%, 0.3);
|
||||
}
|
||||
|
||||
.confirmation-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Remove file button in batch delete list */
|
||||
.remove-file-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.delete-file-item:hover .remove-file-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-file-btn:hover {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* Empty list message */
|
||||
.empty-list-message {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -3300,3 +3300,93 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
SPLIT QUEUE MODAL STYLES
|
||||
========================================== */
|
||||
|
||||
.split-queue-modal-content {
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.split-queue-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.split-queue-issues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.split-queue-issue-group {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.split-queue-issue-group:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
.split-queue-issue-header {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.split-queue-issue-header label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.split-queue-issue-header input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.split-queue-solutions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.split-queue-solutions label {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.split-queue-solutions label:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.split-queue-solutions input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Checkbox styles */
|
||||
.split-queue-modal-content input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.split-queue-modal-content input[type="checkbox"]:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.split-queue-modal-content input[type="checkbox"]:checked {
|
||||
background-color: hsl(var(--primary));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
@@ -1704,6 +1704,19 @@ const i18n = {
|
||||
'claude.deleteFile': 'Delete File',
|
||||
'claude.deleteConfirm': 'Are you sure you want to delete {file}?',
|
||||
'claude.deleteWarning': 'This action cannot be undone.',
|
||||
'claude.batchDeleteProject': 'Delete Project Files',
|
||||
'claude.batchDeleteTitle': 'Delete Project Workspace Files',
|
||||
'claude.batchDeleteWarning': 'This will delete all CLAUDE.md files in the project workspace (excluding user-level files)',
|
||||
'claude.noProjectFiles': 'No project workspace files to delete',
|
||||
'claude.filesToDelete': 'Files to delete:',
|
||||
'claude.totalSize': 'Total size:',
|
||||
'claude.fileList': 'File List',
|
||||
'claude.confirmDelete': 'Confirm Delete',
|
||||
'claude.deletingFiles': 'Deleting {count} files...',
|
||||
'claude.batchDeleteSuccess': 'Successfully deleted {deleted} of {total} files',
|
||||
'claude.batchDeleteError': 'Failed to delete files',
|
||||
'claude.removeFromList': 'Remove from list',
|
||||
'claude.noFilesInList': 'No files in the list',
|
||||
'claude.copyContent': 'Copy Content',
|
||||
'claude.contentCopied': 'Content copied to clipboard',
|
||||
'claude.copyError': 'Failed to copy content',
|
||||
@@ -4013,6 +4026,19 @@ const i18n = {
|
||||
'claude.deleteFile': '删除文件',
|
||||
'claude.deleteConfirm': '确定要删除 {file} 吗?',
|
||||
'claude.deleteWarning': '此操作无法撤销。',
|
||||
'claude.batchDeleteProject': '删除项目文件',
|
||||
'claude.batchDeleteTitle': '删除项目工作空间文件',
|
||||
'claude.batchDeleteWarning': '此操作将删除项目工作空间内的所有 CLAUDE.md 文件(不包括用户级文件)',
|
||||
'claude.noProjectFiles': '没有可删除的项目工作空间文件',
|
||||
'claude.filesToDelete': '待删除文件数:',
|
||||
'claude.totalSize': '总大小:',
|
||||
'claude.fileList': '文件清单',
|
||||
'claude.confirmDelete': '确认删除',
|
||||
'claude.deletingFiles': '正在删除 {count} 个文件...',
|
||||
'claude.batchDeleteSuccess': '成功删除 {deleted}/{total} 个文件',
|
||||
'claude.batchDeleteError': '删除文件失败',
|
||||
'claude.removeFromList': '从清单中移除',
|
||||
'claude.noFilesInList': '清单中没有文件',
|
||||
'claude.copyContent': '复制内容',
|
||||
'claude.contentCopied': '内容已复制到剪贴板',
|
||||
'claude.copyError': '复制内容失败',
|
||||
|
||||
@@ -24,6 +24,7 @@ var searchQuery = '';
|
||||
var freshnessData = {}; // { [filePath]: FreshnessResult }
|
||||
var freshnessSummary = null;
|
||||
var searchKeyboardHandlerAdded = false;
|
||||
var pendingDeleteFiles = []; // Files pending for batch delete
|
||||
|
||||
// ========== Main Render Function ==========
|
||||
async function renderClaudeManager() {
|
||||
@@ -64,6 +65,9 @@ async function renderClaudeManager() {
|
||||
'<button class="btn btn-sm btn-secondary" onclick="refreshClaudeFiles()">' +
|
||||
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' + t('common.refresh') +
|
||||
'</button>' +
|
||||
'<button class="btn btn-sm btn-danger" onclick="showBatchDeleteDialog()">' +
|
||||
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.batchDeleteProject') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="claude-manager-columns">' +
|
||||
@@ -959,3 +963,167 @@ window.initClaudeManager = function() {
|
||||
|
||||
// Make destroyClaudeManager accessible globally as well
|
||||
window.destroyClaudeManager = destroyClaudeManager;
|
||||
|
||||
// ========== Batch Delete Functions ==========
|
||||
/**
|
||||
* Show batch delete confirmation dialog for project workspace files
|
||||
*/
|
||||
function showBatchDeleteDialog() {
|
||||
// Get project workspace files (project + modules, exclude user)
|
||||
var projectFiles = [];
|
||||
|
||||
if (claudeFilesData.project.main) {
|
||||
projectFiles.push(claudeFilesData.project.main);
|
||||
}
|
||||
|
||||
projectFiles.push(...claudeFilesData.modules);
|
||||
|
||||
if (projectFiles.length === 0) {
|
||||
showRefreshToast(t('claude.noProjectFiles') || 'No project workspace files to delete', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize pending delete files list
|
||||
pendingDeleteFiles = [...projectFiles];
|
||||
|
||||
// Render the modal with current pending files
|
||||
renderBatchDeleteModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render or re-render the batch delete modal content
|
||||
*/
|
||||
function renderBatchDeleteModal() {
|
||||
// Build file list HTML with remove buttons
|
||||
var fileListHTML = pendingDeleteFiles.map(function(file, index) {
|
||||
var levelBadge = file.level === 'project'
|
||||
? '<span class="level-badge project">' + t('claudeManager.projectLevel') + '</span>'
|
||||
: '<span class="level-badge module">' + t('claudeManager.moduleLevel') + '</span>';
|
||||
|
||||
return '<div class="delete-file-item" data-file-index="' + index + '">' +
|
||||
'<i data-lucide="file-text" class="w-4 h-4"></i>' +
|
||||
'<div class="file-info">' +
|
||||
'<span class="file-name">' + escapeHtml(file.name) + '</span>' +
|
||||
'<span class="file-path">' + escapeHtml(file.relativePath) + '</span>' +
|
||||
'</div>' +
|
||||
levelBadge +
|
||||
'<button class="btn btn-sm btn-ghost remove-file-btn" onclick="removeFromDeleteList(' + index + ')" title="' + (t('claude.removeFromList') || 'Remove from list') + '">' +
|
||||
'<i data-lucide="x" class="w-4 h-4"></i>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
var totalSize = pendingDeleteFiles.reduce(function(sum, f) { return sum + f.size; }, 0);
|
||||
|
||||
var modalContent = '<div class="batch-delete-modal">' +
|
||||
'<div class="warning-banner">' +
|
||||
'<i data-lucide="alert-triangle" class="w-5 h-5"></i>' +
|
||||
'<span>' + (t('claude.batchDeleteWarning') || 'This will delete all CLAUDE.md files in the project workspace') + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="delete-summary" id="delete-summary">' +
|
||||
'<div class="summary-item">' +
|
||||
'<span class="summary-label">' + t('claude.filesToDelete') + '</span>' +
|
||||
'<span class="summary-value" id="files-to-delete-count">' + pendingDeleteFiles.length + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="summary-item">' +
|
||||
'<span class="summary-label">' + t('claude.totalSize') + '</span>' +
|
||||
'<span class="summary-value" id="total-size-value">' + formatFileSize(totalSize) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="file-list-container">' +
|
||||
'<h4>' + t('claude.fileList') + '</h4>' +
|
||||
'<div class="file-list" id="pending-file-list">' + (fileListHTML || '<div class="empty-list-message">' + (t('claude.noFilesInList') || 'No files in the list') + '</div>') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="confirmation-actions">' +
|
||||
'<button class="btn btn-secondary" onclick="closeModal()">' +
|
||||
'<i data-lucide="x" class="w-4 h-4"></i> ' + t('common.cancel') +
|
||||
'</button>' +
|
||||
'<button class="btn btn-danger" onclick="confirmBatchDeleteProject()"' + (pendingDeleteFiles.length === 0 ? ' disabled' : '') + '>' +
|
||||
'<i data-lucide="trash-2" class="w-4 h-4"></i> ' + t('claude.confirmDelete') +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
showModal(
|
||||
t('claude.batchDeleteTitle') || 'Delete Project Workspace Files',
|
||||
modalContent,
|
||||
{ size: 'large' }
|
||||
);
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the pending delete list
|
||||
*/
|
||||
function removeFromDeleteList(index) {
|
||||
if (index >= 0 && index < pendingDeleteFiles.length) {
|
||||
pendingDeleteFiles.splice(index, 1);
|
||||
renderBatchDeleteModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute batch delete for project workspace files
|
||||
*/
|
||||
async function confirmBatchDeleteProject() {
|
||||
// Collect file paths from pending delete list
|
||||
var filePaths = pendingDeleteFiles.map(function(file) {
|
||||
return file.path;
|
||||
});
|
||||
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
closeModal();
|
||||
|
||||
// Show progress
|
||||
showRefreshToast(
|
||||
(t('claude.deletingFiles') || 'Deleting {count} files...').replace('{count}', filePaths.length),
|
||||
'info'
|
||||
);
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/memory/claude/batch-delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paths: filePaths,
|
||||
confirm: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Batch delete failed');
|
||||
|
||||
var result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
var message = (t('claude.batchDeleteSuccess') || 'Successfully deleted {deleted} of {total} files')
|
||||
.replace('{deleted}', result.deleted)
|
||||
.replace('{total}', result.total);
|
||||
|
||||
showRefreshToast(message, 'success');
|
||||
addGlobalNotification('success', message, null, 'CLAUDE.md');
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.warn('Some files failed to delete:', result.errors);
|
||||
}
|
||||
|
||||
// Clear selection if deleted file was selected
|
||||
if (selectedFile && filePaths.includes(selectedFile.path)) {
|
||||
selectedFile = null;
|
||||
}
|
||||
|
||||
// Refresh file tree
|
||||
await refreshClaudeFiles();
|
||||
} else {
|
||||
throw new Error(result.error || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in batch delete:', error);
|
||||
showRefreshToast(
|
||||
t('claude.batchDeleteError') || 'Failed to delete files',
|
||||
'error'
|
||||
);
|
||||
addGlobalNotification('error', t('claude.batchDeleteError') || 'Failed to delete files', null, 'CLAUDE.md');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +562,11 @@ function renderQueueCard(queue, isActive) {
|
||||
<i data-lucide="git-merge" class="w-3 h-3"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
${queue.status !== 'merged' && issueCount > 1 ? `
|
||||
<button class="btn-sm" onclick="showSplitQueueModal('${safeQueueId}')" title="Split queue into multiple queues">
|
||||
<i data-lucide="git-branch" class="w-3 h-3"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn-sm btn-danger" onclick="confirmDeleteQueue('${safeQueueId}')" title="${t('issues.deleteQueue') || 'Delete queue'}">
|
||||
<i data-lucide="trash-2" class="w-3 h-3"></i>
|
||||
</button>
|
||||
@@ -989,6 +994,188 @@ async function executeQueueMerge(sourceQueueId) {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Queue Split Modal ==========
|
||||
async function showSplitQueueModal(queueId) {
|
||||
let modal = document.getElementById('splitQueueModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'splitQueueModal';
|
||||
modal.className = 'issue-modal';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Fetch queue details
|
||||
let queue;
|
||||
try {
|
||||
const response = await fetch('/api/queue/' + encodeURIComponent(queueId) + '?path=' + encodeURIComponent(projectPath));
|
||||
queue = await response.json();
|
||||
if (queue.error) throw new Error(queue.error);
|
||||
} catch (err) {
|
||||
showNotification('Failed to load queue details', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const safeQueueId = escapeHtml(queueId || '');
|
||||
const items = queue.solutions || queue.tasks || [];
|
||||
const isSolutionLevel = !!queue.solutions;
|
||||
|
||||
// Group items by issue
|
||||
const issueGroups = {};
|
||||
items.forEach(item => {
|
||||
const issueId = item.issue_id || 'unknown';
|
||||
if (!issueGroups[issueId]) {
|
||||
issueGroups[issueId] = [];
|
||||
}
|
||||
issueGroups[issueId].push(item);
|
||||
});
|
||||
|
||||
const issueIds = Object.keys(issueGroups);
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="issue-modal-content split-queue-modal-content">
|
||||
<div class="issue-modal-header">
|
||||
<h3><i data-lucide="git-branch" class="w-5 h-5"></i> Split Queue: ${safeQueueId}</h3>
|
||||
<button class="issue-modal-close" onclick="hideSplitQueueModal()">
|
||||
<i data-lucide="x" class="w-5 h-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="issue-modal-body">
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Select issues and their solutions to split into a new queue. The remaining items will stay in the current queue.
|
||||
</p>
|
||||
|
||||
${issueIds.length === 0 ? `
|
||||
<p class="text-center text-muted-foreground py-4">No items to split</p>
|
||||
` : `
|
||||
<div class="split-queue-controls mb-3">
|
||||
<button class="btn-sm btn-secondary" onclick="selectAllIssues()">
|
||||
<i data-lucide="check-square" class="w-3 h-3"></i> Select All
|
||||
</button>
|
||||
<button class="btn-sm btn-secondary" onclick="deselectAllIssues()">
|
||||
<i data-lucide="square" class="w-3 h-3"></i> Deselect All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="split-queue-issues">
|
||||
${issueIds.map(issueId => {
|
||||
const issueItems = issueGroups[issueId];
|
||||
const safeIssueId = escapeHtml(issueId);
|
||||
return `
|
||||
<div class="split-queue-issue-group" data-issue-id="${safeIssueId}">
|
||||
<div class="split-queue-issue-header">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox"
|
||||
class="issue-checkbox"
|
||||
data-issue-id="${safeIssueId}"
|
||||
onchange="toggleIssueSelection('${safeIssueId}')">
|
||||
<span class="font-medium">${safeIssueId}</span>
|
||||
<span class="text-xs text-muted-foreground">(${issueItems.length} ${isSolutionLevel ? 'solution' : 'task'}${issueItems.length > 1 ? 's' : ''})</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="split-queue-solutions ml-6">
|
||||
${issueItems.map(item => {
|
||||
const itemId = item.item_id || item.solution_id || item.task_id || '';
|
||||
const safeItemId = escapeHtml(itemId);
|
||||
const displayName = isSolutionLevel
|
||||
? (item.solution_id || itemId)
|
||||
: (item.task_id || itemId);
|
||||
return `
|
||||
<label class="flex items-center gap-2 py-1">
|
||||
<input type="checkbox"
|
||||
class="solution-checkbox"
|
||||
data-issue-id="${safeIssueId}"
|
||||
data-item-id="${safeItemId}"
|
||||
value="${safeItemId}">
|
||||
<span class="text-sm font-mono">${escapeHtml(displayName)}</span>
|
||||
${item.task_count ? `<span class="text-xs text-muted-foreground">(${item.task_count} tasks)</span>` : ''}
|
||||
</label>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="issue-modal-footer">
|
||||
<button class="btn-secondary" onclick="hideSplitQueueModal()">Cancel</button>
|
||||
${issueIds.length > 0 ? `
|
||||
<button class="btn-primary" onclick="executeQueueSplit('${safeQueueId}')">
|
||||
<i data-lucide="git-branch" class="w-4 h-4"></i>
|
||||
<span>Split Queue</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function hideSplitQueueModal() {
|
||||
const modal = document.getElementById('splitQueueModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIssueSelection(issueId) {
|
||||
const issueCheckbox = document.querySelector(`.issue-checkbox[data-issue-id="${issueId}"]`);
|
||||
const solutionCheckboxes = document.querySelectorAll(`.solution-checkbox[data-issue-id="${issueId}"]`);
|
||||
|
||||
if (issueCheckbox && solutionCheckboxes) {
|
||||
solutionCheckboxes.forEach(cb => {
|
||||
cb.checked = issueCheckbox.checked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllIssues() {
|
||||
const allCheckboxes = document.querySelectorAll('.split-queue-modal-content input[type="checkbox"]');
|
||||
allCheckboxes.forEach(cb => cb.checked = true);
|
||||
}
|
||||
|
||||
function deselectAllIssues() {
|
||||
const allCheckboxes = document.querySelectorAll('.split-queue-modal-content input[type="checkbox"]');
|
||||
allCheckboxes.forEach(cb => cb.checked = false);
|
||||
}
|
||||
|
||||
async function executeQueueSplit(sourceQueueId) {
|
||||
const selectedCheckboxes = document.querySelectorAll('.solution-checkbox:checked');
|
||||
const selectedItemIds = Array.from(selectedCheckboxes).map(cb => cb.value);
|
||||
|
||||
if (selectedItemIds.length === 0) {
|
||||
showNotification('Please select at least one item to split', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/queue/split?path=' + encodeURIComponent(projectPath), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceQueueId, itemIds: selectedItemIds })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showNotification(`Split ${result.splitItemCount} items into new queue ${result.newQueueId}`, 'success');
|
||||
hideSplitQueueModal();
|
||||
queueData.expandedQueueId = null;
|
||||
await Promise.all([loadQueueData(), loadAllQueues()]);
|
||||
renderIssueView();
|
||||
} else {
|
||||
showNotification(result.error || 'Failed to split queue', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to split queue:', err);
|
||||
showNotification('Failed to split queue', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Legacy Queue Render (for backward compatibility) ==========
|
||||
function renderLegacyQueueSection() {
|
||||
const queue = issueData.queue;
|
||||
|
||||
@@ -1024,7 +1024,9 @@ async function loadAndRenderMultiCliSummaryTab(session, contentArea) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderMultiCliSummaryContent(data.summary, session);
|
||||
// Support both summaries (from .summaries/) and summary (from plan.json)
|
||||
const summaryText = data.summary || (data.summaries?.length ? data.summaries[0].content : null);
|
||||
contentArea.innerHTML = renderMultiCliSummaryContent(summaryText, session);
|
||||
initCollapsibleSections(contentArea);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
@@ -3135,16 +3137,38 @@ async function loadAndRenderLiteSummaryTab(session, contentArea) {
|
||||
const response = await fetch(`/api/session-detail?path=${encodeURIComponent(session.path)}&type=summary`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
contentArea.innerHTML = renderSummaryContent(data.summaries);
|
||||
return;
|
||||
// Prioritize .summaries/ directory content
|
||||
if (data.summaries?.length) {
|
||||
contentArea.innerHTML = renderSummaryContent(data.summaries);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
// Fallback to plan.json summary field
|
||||
if (data.summary) {
|
||||
contentArea.innerHTML = renderSummaryContent([{ name: 'Summary', content: data.summary }]);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
|
||||
// Fallback: try to get summary from session object (plan.summary or synthesis.convergence.summary)
|
||||
const plan = session.plan || {};
|
||||
const synthesis = session.latestSynthesis || session.discussionTopic || {};
|
||||
const summaryText = plan.summary || synthesis.convergence?.summary;
|
||||
|
||||
if (summaryText) {
|
||||
contentArea.innerHTML = renderSummaryContent([{ name: 'Summary', content: summaryText }]);
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// No summary available
|
||||
contentArea.innerHTML = `
|
||||
<div class="tab-empty-state">
|
||||
<div class="empty-icon"><i data-lucide="file-text" class="w-12 h-12"></i></div>
|
||||
<div class="empty-title">No Summaries</div>
|
||||
<div class="empty-text">No summaries found in .summaries/</div>
|
||||
<div class="empty-title">${t('empty.noSummary') || 'No Summary'}</div>
|
||||
<div class="empty-text">${t('empty.noSummaryText') || 'No summary available for this session.'}</div>
|
||||
</div>
|
||||
`;
|
||||
if (typeof lucide !== 'undefined') lucide.createIcons();
|
||||
|
||||
@@ -364,6 +364,16 @@ const ParamsSchema = z.object({
|
||||
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
|
||||
stream: z.boolean().default(false), // false = cache full output (default), true = stream output via callback
|
||||
outputFormat: z.enum(['text', 'json-lines']).optional().default('json-lines'), // Output parsing format (default: json-lines for type badges)
|
||||
// Codex review options
|
||||
uncommitted: z.boolean().optional(), // Review uncommitted changes (default for review mode)
|
||||
base: z.string().optional(), // Review changes against base branch
|
||||
commit: z.string().optional(), // Review changes from specific commit
|
||||
title: z.string().optional(), // Optional title for review summary
|
||||
// Rules env vars (PROTO, TMPL) - will be passed to subprocess environment
|
||||
rulesEnv: z.object({
|
||||
PROTO: z.string().optional(),
|
||||
TMPL: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
@@ -388,7 +398,7 @@ async function executeCliTool(
|
||||
throw new Error(`Invalid params: ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat } = parsed.data;
|
||||
const { tool, prompt, mode, format, model, cd, includeDirs, resume, id: customId, noNative, category, parentExecutionId, outputFormat, uncommitted, base, commit, title, rulesEnv } = parsed.data;
|
||||
|
||||
// Validate and determine working directory early (needed for conversation lookup)
|
||||
let workingDir: string;
|
||||
@@ -786,7 +796,8 @@ async function executeCliTool(
|
||||
model: effectiveModel,
|
||||
dir: cd,
|
||||
include: includeDirs,
|
||||
nativeResume: nativeResumeConfig
|
||||
nativeResume: nativeResumeConfig,
|
||||
reviewOptions: mode === 'review' ? { uncommitted, base, commit, title } : undefined
|
||||
});
|
||||
|
||||
// Create output parser and IR storage
|
||||
@@ -823,9 +834,11 @@ async function executeCliTool(
|
||||
}
|
||||
|
||||
// Merge custom env with process.env (custom env takes precedence)
|
||||
// Also include rulesEnv for $PROTO and $TMPL template variables
|
||||
const spawnEnv = {
|
||||
...process.env,
|
||||
...customEnv
|
||||
...customEnv,
|
||||
...(rulesEnv || {})
|
||||
};
|
||||
|
||||
debugLog('SPAWN', `Spawning process`, {
|
||||
|
||||
@@ -159,8 +159,15 @@ export function buildCommand(params: {
|
||||
nativeResume?: NativeResumeConfig;
|
||||
/** Claude CLI settings file path (for --settings parameter) */
|
||||
settingsFile?: string;
|
||||
/** Codex review options */
|
||||
reviewOptions?: {
|
||||
uncommitted?: boolean;
|
||||
base?: string;
|
||||
commit?: string;
|
||||
title?: string;
|
||||
};
|
||||
}): { command: string; args: string[]; useStdin: boolean } {
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile } = params;
|
||||
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume, settingsFile, reviewOptions } = params;
|
||||
|
||||
debugLog('BUILD_CMD', `Building command for tool: ${tool}`, {
|
||||
mode,
|
||||
@@ -227,10 +234,25 @@ export function buildCommand(params: {
|
||||
// codex review mode: non-interactive code review
|
||||
// Format: codex review [OPTIONS] [PROMPT]
|
||||
args.push('review');
|
||||
// Default to --uncommitted if no specific review target in prompt
|
||||
args.push('--uncommitted');
|
||||
|
||||
// Review target: --uncommitted (default), --base <branch>, or --commit <sha>
|
||||
if (reviewOptions?.base) {
|
||||
args.push('--base', reviewOptions.base);
|
||||
} else if (reviewOptions?.commit) {
|
||||
args.push('--commit', reviewOptions.commit);
|
||||
} else {
|
||||
// Default to --uncommitted if no specific target
|
||||
args.push('--uncommitted');
|
||||
}
|
||||
|
||||
// Optional title for review summary
|
||||
if (reviewOptions?.title) {
|
||||
args.push('--title', reviewOptions.title);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
args.push('-m', model);
|
||||
// codex review uses -c key=value for config override, not -m
|
||||
args.push('-c', `model=${model}`);
|
||||
}
|
||||
// codex review uses positional prompt argument, not stdin
|
||||
useStdin = false;
|
||||
|
||||
303
ccw/src/tools/template-discovery.ts
Normal file
303
ccw/src/tools/template-discovery.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Template Discovery Module
|
||||
*
|
||||
* Provides auto-discovery and loading of CLI templates from
|
||||
* ~/.claude/workflows/cli-templates/
|
||||
*
|
||||
* Features:
|
||||
* - Scan prompts/ directory (flat structure with category-function.txt naming)
|
||||
* - Match template names (e.g., "analysis-review-architecture" or just "review-architecture")
|
||||
* - Load protocol files based on mode (analysis/write)
|
||||
* - Cache template content for performance
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'fs';
|
||||
import { join, basename, extname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TemplateMeta {
|
||||
name: string; // Full filename without extension (e.g., "analysis-review-architecture")
|
||||
path: string; // Full absolute path
|
||||
category: string; // Category from filename (e.g., "analysis")
|
||||
shortName: string; // Name without category prefix (e.g., "review-architecture")
|
||||
}
|
||||
|
||||
export interface TemplateIndex {
|
||||
templates: Map<string, TemplateMeta>; // name -> meta (full name match)
|
||||
byShortName: Map<string, TemplateMeta>; // shortName -> meta (for fuzzy match)
|
||||
categories: Map<string, string[]>; // category -> template names
|
||||
lastScan: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
const TEMPLATES_BASE_DIR = join(homedir(), '.claude', 'workflows', 'cli-templates');
|
||||
const PROMPTS_DIR = join(TEMPLATES_BASE_DIR, 'prompts');
|
||||
const PROTOCOLS_DIR = join(TEMPLATES_BASE_DIR, 'protocols');
|
||||
|
||||
const PROTOCOL_FILES: Record<string, string> = {
|
||||
analysis: 'analysis-protocol.md',
|
||||
write: 'write-protocol.md',
|
||||
};
|
||||
|
||||
// Cache
|
||||
let templateIndex: TemplateIndex | null = null;
|
||||
const contentCache: Map<string, string> = new Map();
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the base templates directory path
|
||||
*/
|
||||
export function getTemplatesDir(): string {
|
||||
return TEMPLATES_BASE_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the prompts directory path
|
||||
*/
|
||||
export function getPromptsDir(): string {
|
||||
return PROMPTS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the protocols directory path
|
||||
*/
|
||||
export function getProtocolsDir(): string {
|
||||
return PROTOCOLS_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan templates directory and build index
|
||||
* Flat structure: prompts/category-function.txt
|
||||
* Results are cached for performance
|
||||
*/
|
||||
export function scanTemplates(forceRescan = false): TemplateIndex {
|
||||
if (templateIndex && !forceRescan) {
|
||||
return templateIndex;
|
||||
}
|
||||
|
||||
const templates = new Map<string, TemplateMeta>();
|
||||
const byShortName = new Map<string, TemplateMeta>();
|
||||
const categories = new Map<string, string[]>();
|
||||
|
||||
if (!existsSync(PROMPTS_DIR)) {
|
||||
console.warn(`[template-discovery] Prompts directory not found: ${PROMPTS_DIR}`);
|
||||
templateIndex = { templates, byShortName, categories, lastScan: Date.now() };
|
||||
return templateIndex;
|
||||
}
|
||||
|
||||
// Scan all files directly in prompts/ (flat structure)
|
||||
const files = readdirSync(PROMPTS_DIR).filter(file => {
|
||||
const ext = extname(file).toLowerCase();
|
||||
return ext === '.txt' || ext === '.md';
|
||||
});
|
||||
|
||||
for (const file of files) {
|
||||
const name = basename(file, extname(file)); // e.g., "analysis-review-architecture"
|
||||
const fullPath = join(PROMPTS_DIR, file);
|
||||
|
||||
// Extract category from filename (first segment before -)
|
||||
const dashIndex = name.indexOf('-');
|
||||
const category = dashIndex > 0 ? name.substring(0, dashIndex) : 'other';
|
||||
const shortName = dashIndex > 0 ? name.substring(dashIndex + 1) : name;
|
||||
|
||||
const meta: TemplateMeta = {
|
||||
name,
|
||||
path: fullPath,
|
||||
category,
|
||||
shortName,
|
||||
};
|
||||
|
||||
// Index by full name
|
||||
templates.set(name, meta);
|
||||
|
||||
// Index by short name (for fuzzy match)
|
||||
// If duplicate shortName exists, prefer keeping first one
|
||||
if (!byShortName.has(shortName)) {
|
||||
byShortName.set(shortName, meta);
|
||||
}
|
||||
|
||||
// Group by category
|
||||
if (!categories.has(category)) {
|
||||
categories.set(category, []);
|
||||
}
|
||||
categories.get(category)!.push(name);
|
||||
}
|
||||
|
||||
templateIndex = { templates, byShortName, categories, lastScan: Date.now() };
|
||||
return templateIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a template by name
|
||||
*
|
||||
* @param nameOrShort - Full template name (e.g., "analysis-review-architecture")
|
||||
* or short name (e.g., "review-architecture")
|
||||
* @returns Full path to template file, or null if not found
|
||||
*/
|
||||
export function findTemplate(nameOrShort: string): string | null {
|
||||
const index = scanTemplates();
|
||||
|
||||
// Try exact full name match first
|
||||
if (index.templates.has(nameOrShort)) {
|
||||
return index.templates.get(nameOrShort)!.path;
|
||||
}
|
||||
|
||||
// Try with .txt extension removed
|
||||
const nameWithoutExt = nameOrShort.replace(/\.(txt|md)$/i, '');
|
||||
if (index.templates.has(nameWithoutExt)) {
|
||||
return index.templates.get(nameWithoutExt)!.path;
|
||||
}
|
||||
|
||||
// Try short name match (without category prefix)
|
||||
if (index.byShortName.has(nameOrShort)) {
|
||||
return index.byShortName.get(nameOrShort)!.path;
|
||||
}
|
||||
|
||||
// Try short name without extension
|
||||
if (index.byShortName.has(nameWithoutExt)) {
|
||||
return index.byShortName.get(nameWithoutExt)!.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load protocol content based on mode
|
||||
*
|
||||
* @param mode - Execution mode: "analysis" or "write"
|
||||
* @returns Protocol file content, or empty string if not found
|
||||
*/
|
||||
export function loadProtocol(mode: string): string {
|
||||
const protocolFile = PROTOCOL_FILES[mode];
|
||||
if (!protocolFile) {
|
||||
console.warn(`[template-discovery] No protocol defined for mode: ${mode}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
const protocolPath = join(PROTOCOLS_DIR, protocolFile);
|
||||
|
||||
// Check cache
|
||||
if (contentCache.has(protocolPath)) {
|
||||
return contentCache.get(protocolPath)!;
|
||||
}
|
||||
|
||||
if (!existsSync(protocolPath)) {
|
||||
console.warn(`[template-discovery] Protocol file not found: ${protocolPath}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(protocolPath, 'utf8');
|
||||
contentCache.set(protocolPath, content);
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`[template-discovery] Failed to read protocol: ${protocolPath}`, error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load template content by name or path
|
||||
*
|
||||
* @param nameOrPath - Template name or relative path
|
||||
* @returns Template file content
|
||||
* @throws Error if template not found
|
||||
*/
|
||||
export function loadTemplate(nameOrPath: string): string {
|
||||
const templatePath = findTemplate(nameOrPath);
|
||||
|
||||
if (!templatePath) {
|
||||
// List available templates for helpful error message
|
||||
const index = scanTemplates();
|
||||
const available = Array.from(index.templates.keys()).slice(0, 10).join(', ');
|
||||
throw new Error(
|
||||
`Template not found: "${nameOrPath}"\n` +
|
||||
`Available templates (first 10): ${available}...\n` +
|
||||
`Use 'ccw cli templates' to list all available templates.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache
|
||||
if (contentCache.has(templatePath)) {
|
||||
return contentCache.get(templatePath)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(templatePath, 'utf8');
|
||||
contentCache.set(templatePath, content);
|
||||
return content;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read template: ${templatePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build rules content from protocol and template
|
||||
*
|
||||
* @param mode - Execution mode for protocol selection
|
||||
* @param templateName - Template name or path (optional)
|
||||
* @param includeProtocol - Whether to include protocol (default: true)
|
||||
* @returns Combined rules content
|
||||
*/
|
||||
export function buildRulesContent(
|
||||
mode: string,
|
||||
templateName?: string,
|
||||
includeProtocol = true
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Load protocol if requested
|
||||
if (includeProtocol) {
|
||||
const protocol = loadProtocol(mode);
|
||||
if (protocol) {
|
||||
parts.push(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
// Load template if specified
|
||||
if (templateName) {
|
||||
const template = loadTemplate(templateName);
|
||||
if (template) {
|
||||
parts.push(template);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available templates
|
||||
*
|
||||
* @returns Object with categories and their templates
|
||||
*/
|
||||
export function listTemplates(): Record<string, TemplateMeta[]> {
|
||||
const index = scanTemplates();
|
||||
const result: Record<string, TemplateMeta[]> = {};
|
||||
|
||||
for (const [category, names] of index.categories) {
|
||||
result[category] = names.map(name => {
|
||||
const meta = index.templates.get(name);
|
||||
return meta || { name, path: '', category, shortName: '' };
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear template cache (useful for testing or after template updates)
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
templateIndex = null;
|
||||
contentCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user