feat: Enhance JSON streaming parsing and UI updates

- Added a function to parse JSON streaming content in core-memory.js, extracting readable text from messages.
- Updated memory detail view to utilize the new parsing function for content and summary.
- Introduced an enableReview option in rules-manager.js, allowing users to toggle review functionality in rule creation.
- Simplified skill creation modal in skills-manager.js by removing generation type selection UI.
- Improved CLI executor to handle tool calls for file writing, ensuring proper output parsing.
- Adjusted CLI command tests to set timeout to 0 for immediate execution.
- Updated file watcher to implement a true debounce mechanism and added a pending queue status for UI updates.
- Enhanced watcher manager to handle queue changes and provide JSON output for better integration with TypeScript backend.
- Established TypeScript naming conventions documentation to standardize code style across the project.
This commit is contained in:
catlog22
2026-01-07 21:51:26 +08:00
parent e9fb7be85f
commit 05514631f2
19 changed files with 1346 additions and 173 deletions

View File

@@ -0,0 +1,88 @@
# TypeScript Naming Conventions
This rule enforces consistent naming conventions for TypeScript code to improve readability and maintain codebase consistency.
## Guidelines
1. **Variables and Functions** - Use camelCase for all variable names, function names, and function parameters
2. **Classes and Interfaces** - Use PascalCase for class names, interface names, type aliases, and enum names
3. **Constants** - Use UPPER_SNAKE_CASE for module-level constants and readonly static class members
4. **Private Members** - Use camelCase with no special prefix for private class members (rely on TypeScript's `private` keyword)
5. **File Names** - Use kebab-case for file names (e.g., `user-service.ts`, `auth-controller.ts`)
## Examples
### ✅ Correct
```typescript
// Variables and functions
const userName = 'John';
let itemCount = 0;
function calculateTotal(orderItems: Item[]): number {
return orderItems.reduce((sum, item) => sum + item.price, 0);
}
// Classes and interfaces
class UserService {
private userRepository: UserRepository;
constructor(userRepository: UserRepository) {
this.userRepository = userRepository;
}
}
interface ApiResponse {
statusCode: number;
data: unknown;
}
type UserId = string;
enum OrderStatus {
Pending,
Confirmed,
Shipped
}
// Constants
const MAX_RETRY_ATTEMPTS = 3;
const API_BASE_URL = 'https://api.example.com';
class Configuration {
static readonly DEFAULT_TIMEOUT = 5000;
}
```
### ❌ Incorrect
```typescript
// Wrong: PascalCase for variables/functions
const UserName = 'John';
function CalculateTotal(order_items: Item[]): number { }
// Wrong: camelCase for classes/interfaces
class userService { }
interface apiResponse { }
type userId = string;
// Wrong: camelCase for constants
const maxRetryAttempts = 3;
const apiBaseUrl = 'https://api.example.com';
// Wrong: snake_case usage
const user_name = 'John';
function calculate_total(items: Item[]): number { }
class user_service { }
// Wrong: Hungarian notation or underscore prefix for private
class Service {
private _userData: User; // Don't use underscore prefix
private m_count: number; // Don't use Hungarian notation
}
```
## Exceptions
- Third-party library types and names should maintain their original casing for compatibility
- Database column names or API field names may use different conventions when mapped from external systems (use transformation layers to convert)
- Test files may use descriptive names like `UserService.test.ts` to match the tested class

View File

@@ -453,7 +453,8 @@ ${memory.content}
category: 'internal'
});
const summary = result.stdout?.trim() || 'Failed to generate summary';
// Use parsedOutput (extracted text from stream JSON) instead of raw stdout
const summary = result.parsedOutput?.trim() || result.stdout?.trim() || 'Failed to generate summary';
// Update memory with summary
const stmt = this.db.prepare(`

View File

@@ -613,7 +613,7 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
id: syncId
});
if (!result.success || !result.execution?.output) {
if (!result.success) {
return {
error: 'CLI execution failed',
details: result.execution?.error || 'No output received',
@@ -621,10 +621,13 @@ export async function handleClaudeRoutes(ctx: RouteContext): Promise<boolean> {
};
}
// Extract CLI output
const cliOutput = typeof result.execution.output === 'string'
? result.execution.output
: result.execution.output.stdout || '';
// Extract CLI output - prefer parsedOutput (extracted text from stream JSON)
let cliOutput = result.parsedOutput || '';
if (!cliOutput && result.execution?.output) {
cliOutput = typeof result.execution.output === 'string'
? result.execution.output
: result.execution.output.stdout || '';
}
if (!cliOutput || cliOutput.trim().length === 0) {
return { error: 'CLI returned empty output', status: 500 };

View File

@@ -39,11 +39,32 @@ interface WatcherConfig {
debounce_ms: number;
}
interface PendingQueueStatus {
file_count: number;
files: string[];
countdown_seconds: number;
last_event_time: number | null;
}
interface IndexResultDetail {
files_indexed: number;
files_removed: number;
symbols_added: number;
symbols_removed: number;
files_success: string[];
files_failed: string[];
errors: string[];
timestamp: number;
}
interface WatcherStats {
running: boolean;
root_path: string;
events_processed: number;
start_time: Date | null;
pending_queue: PendingQueueStatus | null;
last_index_result: IndexResultDetail | null;
index_history: IndexResultDetail[];
}
interface ActiveWatcher {
@@ -58,13 +79,12 @@ const WATCHER_CONFIG_FILE = path.join(WATCHER_CONFIG_DIR, 'watchers.json');
// Active watchers Map: normalized_path -> { process, stats }
const activeWatchers = new Map<string, ActiveWatcher>();
/**
* Normalize path for consistent key usage
* - Convert to absolute path
// Flag to ensure watchers are initialized only once
let watchersInitialized = false;
/**
* Normalize path for consistent key usage
* - Convert to absolute path
* - Convert to lowercase on Windows
* - Use forward slashes
*/
@@ -183,7 +203,10 @@ async function startWatcherProcess(
running: true,
root_path: targetPath,
events_processed: 0,
start_time: new Date()
start_time: new Date(),
pending_queue: null,
last_index_result: null,
index_history: []
};
// Register in activeWatchers Map
@@ -201,16 +224,66 @@ async function startWatcherProcess(
});
}
// Handle process output for event counting
// Handle process output for JSON parsing and event counting
if (childProcess.stdout) {
childProcess.stdout.on('data', (data: Buffer) => {
const output = data.toString();
const matches = output.match(/Processed \d+ events?/g);
if (matches) {
const watcher = activeWatchers.get(normalizedPath);
if (watcher) {
watcher.stats.events_processed += matches.length;
const watcher = activeWatchers.get(normalizedPath);
if (!watcher) return;
// Process output line by line for reliable JSON parsing
// (handles nested arrays/objects that simple regex can't match)
const lines = output.split('\n');
let hasIndexResult = false;
for (const line of lines) {
// Parse [QUEUE_STATUS] JSON
if (line.includes('[QUEUE_STATUS]')) {
const jsonStart = line.indexOf('{');
if (jsonStart !== -1) {
try {
const queueStatus: PendingQueueStatus = JSON.parse(line.slice(jsonStart));
watcher.stats.pending_queue = queueStatus;
broadcastToClients({
type: 'CODEXLENS_WATCHER_QUEUE_UPDATE',
payload: { path: targetPath, queue: queueStatus }
});
} catch (e) {
console.warn('[CodexLens] Failed to parse queue status:', e, line);
}
}
}
// Parse [INDEX_RESULT] JSON
if (line.includes('[INDEX_RESULT]')) {
const jsonStart = line.indexOf('{');
if (jsonStart !== -1) {
try {
const indexResult: IndexResultDetail = JSON.parse(line.slice(jsonStart));
watcher.stats.last_index_result = indexResult;
watcher.stats.index_history.push(indexResult);
if (watcher.stats.index_history.length > 10) {
watcher.stats.index_history.shift();
}
watcher.stats.events_processed += indexResult.files_indexed + indexResult.files_removed;
watcher.stats.pending_queue = null;
hasIndexResult = true;
broadcastToClients({
type: 'CODEXLENS_WATCHER_INDEX_COMPLETE',
payload: { path: targetPath, result: indexResult }
});
} catch (e) {
console.warn('[CodexLens] Failed to parse index result:', e, line);
}
}
}
}
// Legacy event counting (fallback)
const matches = output.match(/Processed \d+ events?/g);
if (matches && !hasIndexResult) {
watcher.stats.events_processed += matches.length;
}
});
}
@@ -2111,6 +2184,68 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean>
return true;
}
// API: Get Pending Queue Status
if (pathname === '/api/codexlens/watch/queue' && req.method === 'GET') {
const queryPath = url.searchParams.get('path');
const targetPath = queryPath || initialPath;
const normalizedPath = normalizePath(targetPath);
const watcher = activeWatchers.get(normalizedPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
queue: watcher?.stats.pending_queue || { file_count: 0, files: [], countdown_seconds: 0, last_event_time: null }
}));
return true;
}
// API: Flush Pending Queue (Immediate Index)
if (pathname === '/api/codexlens/watch/flush' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: watchPath } = body;
const targetPath = watchPath || initialPath;
const normalizedPath = normalizePath(targetPath);
const watcher = activeWatchers.get(normalizedPath);
if (!watcher) {
return { success: false, error: 'Watcher not running for this path', status: 400 };
}
try {
// Create flush.signal file to trigger immediate indexing
const signalDir = path.join(targetPath, '.codexlens');
const signalFile = path.join(signalDir, 'flush.signal');
if (!fs.existsSync(signalDir)) {
fs.mkdirSync(signalDir, { recursive: true });
}
fs.writeFileSync(signalFile, Date.now().toString());
return { success: true, message: 'Flush signal sent' };
} catch (err: any) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: Get Index History
if (pathname === '/api/codexlens/watch/history' && req.method === 'GET') {
const queryPath = url.searchParams.get('path');
const limitParam = url.searchParams.get('limit');
const limit = limitParam ? parseInt(limitParam, 10) : 10;
const targetPath = queryPath || initialPath;
const normalizedPath = normalizePath(targetPath);
const watcher = activeWatchers.get(normalizedPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
history: watcher?.stats.index_history?.slice(-limit) || []
}));
return true;
}
// ============================================================

View File

@@ -392,10 +392,11 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
category: 'insight'
});
// Try to parse JSON from response
// Try to parse JSON from response - use parsedOutput (extracted text) instead of raw stdout
let insights: { patterns: any[]; suggestions: any[] } = { patterns: [], suggestions: [] };
if (result.stdout) {
let outputText = result.stdout;
const cliOutput = result.parsedOutput || result.stdout || '';
if (cliOutput) {
let outputText = cliOutput;
// Strip markdown code blocks if present
const codeBlockMatch = outputText.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -415,14 +416,14 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
console.error('[insights/analyze] JSON parse error:', e);
// Return raw output if JSON parse fails
insights = {
patterns: [{ type: 'raw_analysis', description: result.stdout.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }],
patterns: [{ type: 'raw_analysis', description: cliOutput.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }],
suggestions: []
};
}
} else {
// No JSON found, wrap raw output
insights = {
patterns: [{ type: 'raw_analysis', description: result.stdout.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }],
patterns: [{ type: 'raw_analysis', description: cliOutput.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }],
suggestions: []
};
}
@@ -439,7 +440,7 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
promptCount: prompts.length,
patterns: insights.patterns,
suggestions: insights.suggestions,
rawOutput: result.stdout || '',
rawOutput: cliOutput,
executionId: result.execution?.id,
lang
});
@@ -981,23 +982,26 @@ RULES: Be concise. Focus on practical understanding. Include function signatures
id: syncId
});
if (result.success && result.execution?.output) {
// Extract stdout from output object with proper serialization
const output = result.execution.output;
if (typeof output === 'string') {
cliOutput = output;
} else if (output && typeof output === 'object') {
// Handle object output - extract stdout or serialize the object
if (output.stdout && typeof output.stdout === 'string') {
cliOutput = output.stdout;
} else if (output.stderr && typeof output.stderr === 'string') {
cliOutput = output.stderr;
} else {
// Last resort: serialize the entire object as JSON
cliOutput = JSON.stringify(output, null, 2);
if (result.success) {
// Prefer parsedOutput (extracted text from stream JSON) over raw execution output
if (result.parsedOutput) {
cliOutput = result.parsedOutput;
} else if (result.execution?.output) {
// Fallback to execution.output
const output = result.execution.output;
if (typeof output === 'string') {
cliOutput = output;
} else if (output && typeof output === 'object') {
// Handle object output - extract stdout or serialize the object
if (output.stdout && typeof output.stdout === 'string') {
cliOutput = output.stdout;
} else if (output.stderr && typeof output.stderr === 'string') {
cliOutput = output.stderr;
} else {
// Last resort: serialize the entire object as JSON
cliOutput = JSON.stringify(output, null, 2);
}
}
} else {
cliOutput = '';
}
}

View File

@@ -221,6 +221,310 @@ function deleteRule(ruleName, location, projectPath) {
}
}
/**
* Infer rule context from file name and subdirectory for better prompt generation
* @param {string} fileName - Rule file name
* @param {string} subdirectory - Optional subdirectory path
* @param {string} location - 'project' or 'user'
* @returns {Object} Inferred context
*/
function inferRuleContext(fileName: string, subdirectory: string, location: string) {
const normalizedName = fileName.replace(/\.md$/i, '').toLowerCase();
const normalizedSubdir = (subdirectory || '').toLowerCase();
// Rule category inference from file name and subdirectory
const categories = {
coding: ['coding', 'code', 'style', 'format', 'lint', 'convention'],
testing: ['test', 'spec', 'jest', 'vitest', 'mocha', 'coverage'],
security: ['security', 'auth', 'permission', 'access', 'secret', 'credential'],
architecture: ['arch', 'design', 'pattern', 'structure', 'module', 'layer'],
documentation: ['doc', 'comment', 'readme', 'jsdoc', 'api-doc'],
performance: ['perf', 'performance', 'optimize', 'cache', 'memory'],
workflow: ['workflow', 'ci', 'cd', 'deploy', 'build', 'release'],
tooling: ['tool', 'cli', 'script', 'npm', 'yarn', 'pnpm'],
error: ['error', 'exception', 'handling', 'logging', 'debug']
};
let inferredCategory = 'general';
let inferredKeywords: string[] = [];
for (const [category, keywords] of Object.entries(categories)) {
for (const keyword of keywords) {
if (normalizedName.includes(keyword) || normalizedSubdir.includes(keyword)) {
inferredCategory = category;
inferredKeywords = keywords;
break;
}
}
if (inferredCategory !== 'general') break;
}
// Scope inference from location
const scopeHint = location === 'project'
? 'This rule applies to the current project only'
: 'This rule applies globally to all projects';
// Technology hints from file name
const techPatterns = {
typescript: ['ts', 'typescript', 'tsc'],
javascript: ['js', 'javascript', 'node'],
react: ['react', 'jsx', 'tsx', 'component'],
vue: ['vue', 'vuex', 'pinia'],
python: ['python', 'py', 'pip', 'poetry'],
rust: ['rust', 'cargo', 'rs'],
go: ['go', 'golang', 'mod'],
java: ['java', 'maven', 'gradle', 'spring']
};
let inferredTech: string | null = null;
for (const [tech, patterns] of Object.entries(techPatterns)) {
if (patterns.some(p => normalizedName.includes(p) || normalizedSubdir.includes(p))) {
inferredTech = tech;
break;
}
}
return {
category: inferredCategory,
keywords: inferredKeywords,
scopeHint,
technology: inferredTech,
isConditional: normalizedSubdir.length > 0
};
}
/**
* Build structured prompt for rule generation
* @param {Object} params
* @returns {string} Structured prompt
*/
function buildStructuredRulePrompt(params: {
description: string;
fileName: string;
subdirectory: string;
location: string;
context: ReturnType<typeof inferRuleContext>;
enableReview?: boolean;
}) {
const { description, fileName, subdirectory, location, context, enableReview } = params;
// Build category-specific guidance
const categoryGuidance = {
coding: 'Focus on code style, naming conventions, and formatting rules. Include specific examples of correct and incorrect patterns.',
testing: 'Emphasize test structure, coverage expectations, mocking strategies, and assertion patterns.',
security: 'Highlight security best practices, input validation, authentication requirements, and sensitive data handling.',
architecture: 'Define module boundaries, dependency rules, layer responsibilities, and design pattern usage.',
documentation: 'Specify documentation requirements, comment styles, and API documentation standards.',
performance: 'Address caching strategies, optimization guidelines, resource management, and performance metrics.',
workflow: 'Define CI/CD requirements, deployment procedures, and release management rules.',
tooling: 'Specify tool configurations, script conventions, and dependency management rules.',
error: 'Define error handling patterns, logging requirements, and exception management.',
general: 'Provide clear, actionable guidelines that Claude can follow consistently.'
};
const guidance = categoryGuidance[context.category] || categoryGuidance.general;
// Build technology-specific hint
const techHint = context.technology
? `\nTECHNOLOGY CONTEXT: This rule is for ${context.technology} development. Use ${context.technology}-specific best practices and terminology.`
: '';
// Build subdirectory context
const subdirHint = subdirectory
? `\nORGANIZATION: This rule will be placed in the "${subdirectory}" subdirectory, indicating its category/scope.`
: '';
// Build review instruction if enabled
const reviewInstruction = enableReview
? `\n\nAFTER GENERATION:
- Verify the rule is specific and actionable
- Check for ambiguous language that could be misinterpreted
- Ensure examples are clear and relevant
- Validate markdown formatting is correct`
: '';
return `PURPOSE: Generate a high-quality Claude Code memory rule that will guide Claude's behavior when working in this codebase
SUCCESS CRITERIA: The rule must be (1) specific and actionable, (2) include concrete examples, (3) avoid ambiguous language, (4) follow Claude Code rule format
TASK:
• Parse the user's description to identify core requirements
• Infer additional context from file name "${fileName}" and category "${context.category}"
• Generate structured markdown content with clear instructions
• Include DO and DON'T examples where appropriate
${context.isConditional ? 'Consider if frontmatter paths are needed for conditional activation' : 'Create as a global rule'}
MODE: write
RULE CATEGORY: ${context.category}
CATEGORY GUIDANCE: ${guidance}
${techHint}
${subdirHint}
SCOPE: ${context.scopeHint}
EXPECTED OUTPUT FORMAT:
\`\`\`markdown
${context.isConditional ? `---
paths: [specific/path/patterns/**/*]
---
` : ''}# Rule Title
Brief description of what this rule enforces.
## Guidelines
1. **First guideline** - Explanation
2. **Second guideline** - Explanation
## Examples
### ✅ Correct
\`\`\`language
// Good example
\`\`\`
### ❌ Incorrect
\`\`\`language
// Bad example
\`\`\`
## Exceptions
- When this rule may not apply
\`\`\`
USER DESCRIPTION:
${description}
FILE NAME: ${fileName}
${subdirectory ? `SUBDIRECTORY: ${subdirectory}` : ''}
${reviewInstruction}
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Generate ONLY the rule content in markdown | No additional commentary | Do NOT use any tools | Output raw markdown text directly | write=CREATE`;
}
/**
* Build structured prompt for code extraction
* @param {Object} params
* @returns {string} Structured prompt
*/
function buildExtractPrompt(params: {
extractScope: string;
extractFocus: string;
fileName: string;
subdirectory: string;
context: ReturnType<typeof inferRuleContext>;
}) {
const { extractScope, extractFocus, fileName, subdirectory, context } = params;
const scope = extractScope || '**/*';
const focus = extractFocus || 'naming conventions, error handling, code structure, patterns';
return `PURPOSE: Extract and document coding conventions from the existing codebase to create a Claude Code memory rule
SUCCESS CRITERIA: The rule must reflect ACTUAL patterns found in the code, not theoretical best practices
TASK:
• Scan files matching "${scope}" for recurring patterns
• Identify ${focus.split(',').length} or more distinct conventions
• Document each pattern with real code examples from the codebase
• Create actionable rules based on observed practices
• Note any inconsistencies found (optional section)
MODE: analysis
ANALYSIS SCOPE: @${scope}
FOCUS AREAS: ${focus}
EXTRACTION STRATEGY:
1. **Pattern Recognition**: Look for repeated code structures, naming patterns, file organization
2. **Consistency Check**: Identify which patterns are consistently followed vs. occasionally violated
3. **Frequency Analysis**: Prioritize patterns that appear most frequently
4. **Context Awareness**: Consider why certain patterns are used (performance, readability, etc.)
EXPECTED OUTPUT FORMAT:
\`\`\`markdown
# ${fileName.replace(/\.md$/i, '')} Conventions
Conventions extracted from codebase analysis of \`${scope}\`.
## Naming Conventions
- **Pattern name**: Description with example
\`\`\`language
// Actual code from codebase
\`\`\`
## Code Structure
- **Pattern name**: Description with example
## Error Handling
- **Pattern name**: Description with example
## Notes
- Any inconsistencies or variations observed
\`\`\`
FILE NAME: ${fileName}
${subdirectory ? `SUBDIRECTORY: ${subdirectory}` : ''}
INFERRED CATEGORY: ${context.category}
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Extract REAL patterns from code | Include actual code snippets as examples | Do NOT use any tools | Output raw markdown text directly | analysis=READ-ONLY`;
}
/**
* Build review prompt for validating and improving generated rules
* @param {string} content - Generated rule content to review
* @param {string} fileName - Target file name
* @param {Object} context - Inferred context
* @returns {string} Review prompt
*/
function buildReviewPrompt(
content: string,
fileName: string,
context: ReturnType<typeof inferRuleContext>
) {
return `PURPOSE: Review and improve a Claude Code memory rule for quality, clarity, and actionability
SUCCESS CRITERIA: Output an improved version that is (1) more specific, (2) includes better examples, (3) has no ambiguous language
TASK:
• Analyze the rule for clarity and specificity
• Check if guidelines are actionable (Claude can follow them)
• Verify examples are concrete and helpful
• Remove any ambiguous or vague language
• Ensure markdown formatting is correct
• Improve structure if needed
• Keep the core intent and requirements intact
MODE: write
REVIEW CRITERIA:
1. **Specificity**: Each guideline should be specific enough to follow without interpretation
2. **Actionability**: Guidelines should tell Claude exactly what to do or not do
3. **Examples**: Good and bad examples should be clearly different and illustrative
4. **Consistency**: Formatting and style should be consistent throughout
5. **Completeness**: All necessary aspects of the rule should be covered
6. **Conciseness**: No unnecessary verbosity or repetition
RULE CATEGORY: ${context.category}
FILE NAME: ${fileName}
ORIGINAL RULE CONTENT:
\`\`\`markdown
${content}
\`\`\`
EXPECTED OUTPUT:
- Output ONLY the improved rule content in markdown format
- Do NOT include any commentary, explanation, or meta-text
- If the original is already high quality, return it unchanged
- Preserve any frontmatter (---paths---) if present
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Output ONLY improved markdown content | No additional text | Do NOT use any tools | Output raw markdown text directly | write=CREATE`;
}
/**
* Generate rule content via CLI tool
* @param {Object} params
@@ -233,6 +537,7 @@ function deleteRule(ruleName, location, projectPath) {
* @param {string} params.location - 'project' or 'user'
* @param {string} params.subdirectory - Optional subdirectory
* @param {string} params.projectPath - Project root path
* @param {boolean} params.enableReview - Optional: enable secondary review
* @returns {Object}
*/
async function generateRuleViaCLI(params) {
@@ -246,47 +551,47 @@ async function generateRuleViaCLI(params) {
fileName,
location,
subdirectory,
projectPath
projectPath,
enableReview
} = params;
let prompt = '';
let mode = 'analysis';
let workingDir = projectPath;
// Infer context from file name and subdirectory
const context = inferRuleContext(fileName, subdirectory || '', location);
// Build prompt based on generation type
if (generationType === 'description') {
mode = 'write';
prompt = `PURPOSE: Generate Claude Code memory rule from description to guide Claude's behavior
TASK: • Analyze rule requirements • Generate markdown content with clear instructions
MODE: write
EXPECTED: Complete rule content in markdown format
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use frontmatter for conditional rules if paths specified | write=CREATE
RULE DESCRIPTION:
${description}
FILE NAME: ${fileName}`;
prompt = buildStructuredRulePrompt({
description,
fileName,
subdirectory: subdirectory || '',
location,
context,
enableReview
});
} else if (generationType === 'template') {
mode = 'write';
prompt = `PURPOSE: Generate Claude Code rule from template type
TASK: • Create rule based on ${templateType} template • Generate structured markdown content
MODE: write
EXPECTED: Complete rule content in markdown format following template structure
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use ${templateType} template patterns | write=CREATE
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/universal/00-universal-rigorous-style.txt) | Follow Claude Code rule format | Use ${templateType} template patterns | Do NOT use any tools | Output raw markdown text directly | write=CREATE
TEMPLATE TYPE: ${templateType}
FILE NAME: ${fileName}`;
} else if (generationType === 'extract') {
mode = 'analysis';
prompt = `PURPOSE: Extract coding rules from existing codebase to document patterns and conventions
TASK: • Analyze code patterns in specified scope • Extract common conventions • Identify best practices
MODE: analysis
CONTEXT: @${extractScope || '**/*'}
EXPECTED: Rule content based on codebase analysis with examples
RULES: $(cat ~/.claude/workflows/cli-templates/prompts/analysis/02-analyze-code-patterns.txt) | Focus on actual patterns found | Include code examples | analysis=READ-ONLY
ANALYSIS SCOPE: ${extractScope || '**/*'}
FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structure'}`;
prompt = buildExtractPrompt({
extractScope: extractScope || '',
extractFocus: extractFocus || '',
fileName,
subdirectory: subdirectory || '',
context
});
} else {
return { error: `Unknown generation type: ${generationType}` };
}
@@ -308,8 +613,15 @@ FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structu
};
}
// Extract generated content from stdout
const generatedContent = result.stdout.trim();
// Extract generated content - prefer parsedOutput (extracted text from stream JSON)
let generatedContent = (result.parsedOutput || result.stdout || '').trim();
// Remove markdown code block wrapper if present (e.g., ```markdown...```)
if (generatedContent.startsWith('```markdown')) {
generatedContent = generatedContent.replace(/^```markdown\s*\n?/, '').replace(/\n?```\s*$/, '');
} else if (generatedContent.startsWith('```')) {
generatedContent = generatedContent.replace(/^```\w*\s*\n?/, '').replace(/\n?```\s*$/, '');
}
if (!generatedContent) {
return {
@@ -319,6 +631,40 @@ FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structu
};
}
// Optional review step - verify and improve the generated rule
let reviewResult = null;
if (enableReview) {
const reviewPrompt = buildReviewPrompt(generatedContent, fileName, context);
const reviewExecution = await executeCliTool({
tool: 'claude',
prompt: reviewPrompt,
mode: 'write',
cd: workingDir,
timeout: 300000, // 5 minutes for review
category: 'internal'
});
if (reviewExecution.success) {
let reviewedContent = (reviewExecution.parsedOutput || reviewExecution.stdout || '').trim();
// Remove markdown code block wrapper if present
if (reviewedContent.startsWith('```markdown')) {
reviewedContent = reviewedContent.replace(/^```markdown\s*\n?/, '').replace(/\n?```\s*$/, '');
} else if (reviewedContent.startsWith('```')) {
reviewedContent = reviewedContent.replace(/^```\w*\s*\n?/, '').replace(/\n?```\s*$/, '');
}
// Only use reviewed content if it's valid and different
if (reviewedContent.length > 50 && reviewedContent !== generatedContent) {
generatedContent = reviewedContent;
reviewResult = {
reviewed: true,
originalLength: (result.parsedOutput || result.stdout || '').trim().length,
reviewedLength: reviewedContent.length
};
}
}
}
// Create the rule using the generated content
const createResult = await createRule({
fileName,
@@ -333,7 +679,8 @@ FOCUS AREAS: ${extractFocus || 'naming conventions, error handling, code structu
success: createResult.success || false,
...createResult,
generatedContent,
executionId: result.conversation?.id
executionId: result.conversation?.id,
review: reviewResult
};
} catch (error) {
return { error: (error as Error).message };

View File

@@ -483,7 +483,7 @@ Create a new Claude Code skill with the following specifications:
if (!result.success) {
return {
error: `CLI generation failed: ${result.stderr || 'Unknown error'}`,
stdout: result.stdout,
stdout: result.parsedOutput || result.stdout,
stderr: result.stderr
};
}
@@ -493,7 +493,7 @@ Create a new Claude Code skill with the following specifications:
if (!validation.valid) {
return {
error: `Generated skill is invalid: ${validation.errors.join(', ')}`,
stdout: result.stdout,
stdout: result.parsedOutput || result.stdout,
stderr: result.stderr
};
}
@@ -503,7 +503,7 @@ Create a new Claude Code skill with the following specifications:
skillName: validation.skillInfo.name,
location,
path: targetPath,
stdout: result.stdout,
stdout: result.parsedOutput || result.stdout,
stderr: result.stderr
};
} catch (error) {

View File

@@ -472,6 +472,65 @@ function handleNotification(data) {
}
break;
case 'CODEXLENS_WATCHER_UPDATE':
// Handle CodexLens watcher real-time updates (file changes detected)
if (typeof handleWatcherStatusUpdate === 'function') {
handleWatcherStatusUpdate(payload);
}
console.log('[CodexLens] Watcher update:', payload.events_processed, 'events');
break;
case 'CODEXLENS_WATCHER_QUEUE_UPDATE':
// Handle pending queue status updates
if (typeof updatePendingQueueUI === 'function') {
updatePendingQueueUI(payload.queue);
}
// Add activity log entries only for NEW files (not already logged)
if (payload.queue && payload.queue.files && payload.queue.files.length > 0) {
if (typeof addWatcherLogEntry === 'function') {
// Track logged files to avoid duplicates
window._watcherLoggedFiles = window._watcherLoggedFiles || new Set();
var newFiles = payload.queue.files.filter(function(f) {
return !window._watcherLoggedFiles.has(f);
});
// Only show first few new files to avoid spam
newFiles.slice(0, 5).forEach(function(fileName) {
window._watcherLoggedFiles.add(fileName);
addWatcherLogEntry('modified', fileName);
});
// Clear tracking when queue is empty (after flush)
if (payload.queue.file_count === 0) {
window._watcherLoggedFiles.clear();
}
}
}
console.log('[CodexLens] Queue update:', payload.queue?.file_count, 'files pending');
break;
case 'CODEXLENS_WATCHER_INDEX_COMPLETE':
// Handle index completion event
if (typeof updateLastIndexResult === 'function') {
updateLastIndexResult(payload.result);
}
// Clear logged files tracking after index completes
if (window._watcherLoggedFiles) {
window._watcherLoggedFiles.clear();
}
// Add activity log entry for index completion
if (typeof addWatcherLogEntry === 'function' && payload.result) {
var summary = 'Indexed ' + (payload.result.files_indexed || 0) + ' files';
addWatcherLogEntry('indexed', summary);
}
// Show toast notification
if (typeof showRefreshToast === 'function' && payload.result) {
var indexMsg = 'Indexed ' + (payload.result.files_indexed || 0) + ' files, ' +
(payload.result.symbols_added || 0) + ' symbols';
var toastType = (payload.result.errors && payload.result.errors.length > 0) ? 'warning' : 'success';
showRefreshToast(indexMsg, toastType);
}
console.log('[CodexLens] Index complete:', payload.result?.files_indexed, 'files indexed');
break;
default:
console.log('[WS] Unknown notification type:', type);
}

View File

@@ -1363,6 +1363,8 @@ const i18n = {
'rules.extractScopeRequired': 'Analysis scope is required',
'rules.extractFocus': 'Focus Areas',
'rules.extractFocusHint': 'Comma-separated aspects to focus on (e.g., naming, error-handling)',
'rules.enableReview': 'Enable Quality Review',
'rules.enableReviewHint': 'AI will verify the generated rule for clarity, actionability, and proper formatting',
'rules.cliGenerating': 'Generating rule via CLI (this may take a few minutes)...',
// CLAUDE.md Manager
@@ -3375,6 +3377,8 @@ const i18n = {
'rules.extractScopeRequired': '分析范围是必需的',
'rules.extractFocus': '关注领域',
'rules.extractFocusHint': '以逗号分隔的关注方面(例如:命名规范, 错误处理)',
'rules.enableReview': '启用质量审查',
'rules.enableReviewHint': 'AI 将验证生成的规则是否清晰、可操作且格式正确',
'rules.cliGenerating': '正在通过 CLI 生成规则(可能需要几分钟)...',
// CLAUDE.md Manager

View File

@@ -636,12 +636,6 @@ function renderFileMetadata() {
'<option value="gemini">Gemini</option>' +
'<option value="qwen">Qwen</option>' +
'</select>' +
'<label>' + (t('claude.mode') || 'Mode') + '</label>' +
'<select id="cliModeSelect" class="sync-select">' +
'<option value="update">' + (t('claude.modeUpdate') || 'Update (Smart Merge)') + '</option>' +
'<option value="generate">' + (t('claude.modeGenerate') || 'Generate (Full Replace)') + '</option>' +
'<option value="append">' + (t('claude.modeAppend') || 'Append') + '</option>' +
'</select>' +
'</div>' +
'<button class="btn btn-sm btn-primary full-width sync-button" onclick="syncFileWithCLI()" id="cliSyncButton">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i> ' +
@@ -664,7 +658,7 @@ async function syncFileWithCLI() {
if (!selectedFile) return;
var tool = document.getElementById('cliToolSelect').value;
var mode = document.getElementById('cliModeSelect').value;
var mode = 'generate'; // Default to full replace mode
// Show progress
showSyncProgress(true, tool);

View File

@@ -4536,10 +4536,25 @@ window.toggleWatcher = async function toggleWatcher() {
// Check current status first
try {
console.log('[CodexLens] Checking watcher status...');
var statusResponse = await fetch('/api/codexlens/watch/status');
// Pass path parameter to get specific watcher status
var statusResponse = await fetch('/api/codexlens/watch/status?path=' + encodeURIComponent(projectPath));
var statusResult = await statusResponse.json();
console.log('[CodexLens] Status result:', statusResult);
var isRunning = statusResult.success && statusResult.running;
// Handle both single watcher response and array response
var isRunning = false;
if (statusResult.success) {
if (typeof statusResult.running === 'boolean') {
isRunning = statusResult.running;
} else if (statusResult.watchers && Array.isArray(statusResult.watchers)) {
var normalizedPath = projectPath.toLowerCase().replace(/\\/g, '/');
var matchingWatcher = statusResult.watchers.find(function(w) {
var watcherPath = (w.root_path || '').toLowerCase().replace(/\\/g, '/');
return watcherPath === normalizedPath || watcherPath.includes(normalizedPath) || normalizedPath.includes(watcherPath);
});
isRunning = matchingWatcher ? matchingWatcher.running : false;
}
}
// Toggle: if running, stop; if stopped, start
var action = isRunning ? 'stop' : 'start';
@@ -4592,7 +4607,8 @@ function updateWatcherUI(running, stats) {
var uptimeDisplay = document.getElementById('watcherUptimeDisplay');
if (filesCount) filesCount.textContent = stats.files_watched || '-';
if (changesCount) changesCount.textContent = stats.changes_detected || '0';
// Support both changes_detected and events_processed
if (changesCount) changesCount.textContent = stats.events_processed || stats.changes_detected || '0';
if (uptimeDisplay) uptimeDisplay.textContent = formatUptime(stats.uptime_seconds);
}
@@ -4628,17 +4644,25 @@ function startWatcherPolling() {
if (watcherPollInterval) return; // Already polling
watcherStartTime = Date.now();
var projectPath = window.CCW_PROJECT_ROOT || '.';
watcherPollInterval = setInterval(async function() {
try {
var response = await fetch('/api/codexlens/watch/status');
// Must include path parameter to get specific watcher status
var response = await fetch('/api/codexlens/watch/status?path=' + encodeURIComponent(projectPath));
var result = await response.json();
if (result.success && result.running) {
// Update uptime
// Update uptime from server response
var uptimeDisplay = document.getElementById('watcherUptimeDisplay');
if (uptimeDisplay) {
var uptime = (Date.now() - watcherStartTime) / 1000;
uptimeDisplay.textContent = formatUptime(uptime);
if (uptimeDisplay && result.uptime_seconds !== undefined) {
uptimeDisplay.textContent = formatUptime(result.uptime_seconds);
}
// Update changes count from events_processed
if (result.events_processed !== undefined) {
var changesCount = document.getElementById('watcherChangesCount');
if (changesCount) changesCount.textContent = result.events_processed;
}
// Update files count if available
@@ -4653,8 +4677,8 @@ function startWatcherPolling() {
addWatcherLogEntry(event.type, event.path);
});
}
} else if (!result.running) {
// Watcher stopped externally
} else if (result.success && result.running === false) {
// Watcher stopped externally (only if running is explicitly false)
updateWatcherUI(false);
stopWatcherPolling();
}
@@ -4699,13 +4723,15 @@ function addWatcherLogEntry(type, path) {
'created': 'text-success',
'modified': 'text-warning',
'deleted': 'text-destructive',
'renamed': 'text-primary'
'renamed': 'text-primary',
'indexed': 'text-success'
};
var typeIcons = {
'created': '+',
'modified': '~',
'deleted': '-',
'renamed': '→'
'renamed': '→',
'indexed': '✓'
};
var colorClass = typeColors[type] || 'text-muted-foreground';
@@ -4748,13 +4774,35 @@ function clearWatcherLog() {
*/
async function initWatcherStatus() {
try {
var response = await fetch('/api/codexlens/watch/status');
var projectPath = window.CCW_PROJECT_ROOT || '.';
// Pass path parameter to get specific watcher status
var response = await fetch('/api/codexlens/watch/status?path=' + encodeURIComponent(projectPath));
var result = await response.json();
if (result.success) {
updateWatcherUI(result.running, {
files_watched: result.files_watched,
// Handle both single watcher response (with path param) and array response (without path param)
var running = result.running;
var uptime = result.uptime_seconds || 0;
var filesWatched = result.files_watched;
// If response has watchers array (no path param), find matching watcher
if (result.watchers && Array.isArray(result.watchers)) {
var normalizedPath = projectPath.toLowerCase().replace(/\\/g, '/');
var matchingWatcher = result.watchers.find(function(w) {
var watcherPath = (w.root_path || '').toLowerCase().replace(/\\/g, '/');
return watcherPath === normalizedPath || watcherPath.includes(normalizedPath) || normalizedPath.includes(watcherPath);
});
if (matchingWatcher) {
running = matchingWatcher.running;
uptime = matchingWatcher.uptime_seconds || 0;
} else {
running = false;
}
}
updateWatcherUI(running, {
files_watched: filesWatched,
changes_detected: 0,
uptime_seconds: result.uptime_seconds
uptime_seconds: uptime
});
}
} catch (err) {
@@ -5846,6 +5894,45 @@ function buildWatcherControlContent(status, defaultPath) {
'</div>' +
'</div>' +
// Pending Queue Section (shown when running)
'<div id="watcherPendingQueue" class="tool-config-section" style="display:' + (running ? 'block' : 'none') + '">' +
'<div class="flex items-center justify-between mb-2">' +
'<h4 class="flex items-center gap-2 m-0">' +
'<i data-lucide="clock" class="w-4 h-4"></i>' +
(t('codexlens.pendingChanges') || 'Pending Changes') +
'</h4>' +
'<button onclick="flushWatcherNow()" class="btn btn-sm btn-primary" id="flushNowBtn" disabled>' +
'<i data-lucide="zap" class="w-3 h-3 mr-1"></i>' +
(t('codexlens.indexNow') || 'Index Now') +
'</button>' +
'</div>' +
'<div class="flex items-center justify-between p-3 bg-muted/20 rounded-lg mb-2">' +
'<div>' +
'<span class="text-2xl font-bold text-warning" id="pendingFileCount">0</span>' +
'<span class="text-sm text-muted-foreground ml-1">' + (t('codexlens.filesWaiting') || 'files waiting') + '</span>' +
'</div>' +
'<div class="text-right">' +
'<div class="text-lg font-mono" id="countdownTimer">--:--</div>' +
'<div class="text-xs text-muted-foreground">' + (t('codexlens.untilNextIndex') || 'until next index') + '</div>' +
'</div>' +
'</div>' +
'<div id="pendingFilesList" class="max-h-24 overflow-y-auto space-y-1 text-sm"></div>' +
'</div>' +
// Last Index Result (shown when running)
'<div id="watcherLastIndex" class="tool-config-section" style="display:none">' +
'<div class="flex items-center justify-between mb-2">' +
'<h4 class="flex items-center gap-2 m-0">' +
'<i data-lucide="check-circle" class="w-4 h-4"></i>' +
(t('codexlens.lastIndexResult') || 'Last Index Result') +
'</h4>' +
'<button onclick="showIndexHistory()" class="text-xs text-muted-foreground hover:text-foreground">' +
(t('codexlens.viewHistory') || 'View History') +
'</button>' +
'</div>' +
'<div class="grid grid-cols-4 gap-2 text-center" id="lastIndexStats"></div>' +
'</div>' +
// Start Configuration (shown when not running)
'<div id="watcherStartConfig" class="tool-config-section" style="display:' + (running ? 'none' : 'block') + '">' +
'<h4>' + (t('codexlens.watcherConfig') || 'Configuration') + '</h4>' +
@@ -5857,7 +5944,7 @@ function buildWatcherControlContent(status, defaultPath) {
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-1.5">' + (t('codexlens.debounceMs') || 'Debounce (ms)') + '</label>' +
'<input type="number" id="watcherDebounce" value="1000" min="100" max="10000" step="100" ' +
'<input type="number" id="watcherDebounce" value="60000" min="1000" max="120000" step="1000" ' +
'class="w-full px-3 py-2 border border-border rounded-lg bg-background text-sm" />' +
'<p class="text-xs text-muted-foreground mt-1">' + (t('codexlens.debounceHint') || 'Time to wait before processing file changes') + '</p>' +
'</div>' +
@@ -5986,6 +6073,204 @@ function stopWatcherStatusPolling() {
clearInterval(watcherPollingInterval);
watcherPollingInterval = null;
}
stopCountdownTimer();
}
// Countdown timer for pending queue
var countdownInterval = null;
var currentCountdownSeconds = 0;
function startCountdownTimer(seconds) {
currentCountdownSeconds = seconds;
if (countdownInterval) return;
countdownInterval = setInterval(function() {
var timerEl = document.getElementById('countdownTimer');
if (!timerEl) {
stopCountdownTimer();
return;
}
if (currentCountdownSeconds <= 0) {
timerEl.textContent = '--:--';
} else {
currentCountdownSeconds--;
timerEl.textContent = formatCountdown(currentCountdownSeconds);
}
}, 1000);
}
function stopCountdownTimer() {
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
function formatCountdown(seconds) {
if (seconds <= 0) return '--:--';
var mins = Math.floor(seconds / 60);
var secs = seconds % 60;
return (mins < 10 ? '0' : '') + mins + ':' + (secs < 10 ? '0' : '') + secs;
}
/**
* Immediately flush pending queue and trigger indexing
*/
async function flushWatcherNow() {
var btn = document.getElementById('flushNowBtn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i data-lucide="loader-2" class="w-3 h-3 mr-1 animate-spin"></i> Indexing...';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
try {
var watchPath = document.getElementById('watcherPath');
var path = watchPath ? watchPath.value.trim() : '';
var response = await fetch('/api/codexlens/watch/flush', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path || undefined })
});
var result = await response.json();
if (result.success) {
showRefreshToast(t('codexlens.indexTriggered') || 'Indexing triggered', 'success');
} else {
showRefreshToast(t('common.error') + ': ' + result.error, 'error');
}
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="zap" class="w-3 h-3 mr-1"></i>' + (t('codexlens.indexNow') || 'Index Now');
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
}
window.flushWatcherNow = flushWatcherNow;
/**
* Show index history in a modal
*/
async function showIndexHistory() {
try {
var watchPath = document.getElementById('watcherPath');
var path = watchPath ? watchPath.value.trim() : '';
var response = await fetch('/api/codexlens/watch/history?limit=10&path=' + encodeURIComponent(path));
var result = await response.json();
if (!result.success || !result.history || result.history.length === 0) {
showRefreshToast(t('codexlens.noHistory') || 'No index history available', 'info');
return;
}
var historyHtml = result.history.slice().reverse().map(function(h, i) {
var timestamp = h.timestamp ? new Date(h.timestamp * 1000).toLocaleString() : 'Unknown';
return '<div class="p-3 border-b border-border last:border-0">' +
'<div class="flex justify-between items-center mb-2">' +
'<span class="text-sm font-medium">#' + (result.history.length - i) + '</span>' +
'<span class="text-xs text-muted-foreground">' + timestamp + '</span>' +
'</div>' +
'<div class="grid grid-cols-4 gap-2 text-center text-sm">' +
'<div><span class="text-success">' + (h.files_indexed || 0) + '</span> indexed</div>' +
'<div><span class="text-warning">' + (h.files_removed || 0) + '</span> removed</div>' +
'<div><span class="text-primary">+' + (h.symbols_added || 0) + '</span> symbols</div>' +
'<div><span class="text-destructive">' + ((h.errors && h.errors.length) || 0) + '</span> errors</div>' +
'</div>' +
(h.errors && h.errors.length > 0 ? '<div class="mt-2 text-xs text-destructive">' +
h.errors.slice(0, 2).map(function(e) { return '<div>• ' + e + '</div>'; }).join('') +
(h.errors.length > 2 ? '<div>... and ' + (h.errors.length - 2) + ' more</div>' : '') +
'</div>' : '') +
'</div>';
}).join('');
var modal = document.createElement('div');
modal.id = 'indexHistoryModal';
modal.className = 'modal-backdrop';
modal.innerHTML = '<div class="modal-container max-w-md">' +
'<div class="modal-header">' +
'<h2 class="text-lg font-bold">' + (t('codexlens.indexHistory') || 'Index History') + '</h2>' +
'<button onclick="document.getElementById(\'indexHistoryModal\').remove()" class="text-muted-foreground hover:text-foreground">' +
'<i data-lucide="x" class="w-5 h-5"></i>' +
'</button>' +
'</div>' +
'<div class="modal-body max-h-96 overflow-y-auto">' + historyHtml + '</div>' +
'</div>';
document.body.appendChild(modal);
if (typeof lucide !== 'undefined') lucide.createIcons();
} catch (err) {
showRefreshToast(t('common.error') + ': ' + err.message, 'error');
}
}
window.showIndexHistory = showIndexHistory;
/**
* Update pending queue UI elements
*/
function updatePendingQueueUI(queue) {
var countEl = document.getElementById('pendingFileCount');
var timerEl = document.getElementById('countdownTimer');
var listEl = document.getElementById('pendingFilesList');
var flushBtn = document.getElementById('flushNowBtn');
if (countEl) countEl.textContent = queue.file_count || 0;
if (queue.countdown_seconds > 0) {
currentCountdownSeconds = queue.countdown_seconds;
if (timerEl) timerEl.textContent = formatCountdown(queue.countdown_seconds);
startCountdownTimer(queue.countdown_seconds);
} else {
if (timerEl) timerEl.textContent = '--:--';
}
if (flushBtn) flushBtn.disabled = (queue.file_count || 0) === 0;
if (listEl && queue.files) {
listEl.innerHTML = queue.files.map(function(f) {
return '<div class="flex items-center gap-2 text-muted-foreground">' +
'<i data-lucide="file" class="w-3 h-3"></i>' +
'<span class="truncate">' + f + '</span>' +
'</div>';
}).join('');
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
/**
* Update last index result UI
*/
function updateLastIndexResult(result) {
var statsEl = document.getElementById('lastIndexStats');
var sectionEl = document.getElementById('watcherLastIndex');
if (sectionEl) sectionEl.style.display = 'block';
if (statsEl) {
statsEl.innerHTML = '<div class="p-2 bg-success/10 rounded">' +
'<div class="text-lg font-bold text-success">' + (result.files_indexed || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">Indexed</div>' +
'</div>' +
'<div class="p-2 bg-warning/10 rounded">' +
'<div class="text-lg font-bold text-warning">' + (result.files_removed || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">Removed</div>' +
'</div>' +
'<div class="p-2 bg-primary/10 rounded">' +
'<div class="text-lg font-bold text-primary">' + (result.symbols_added || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">+Symbols</div>' +
'</div>' +
'<div class="p-2 bg-destructive/10 rounded">' +
'<div class="text-lg font-bold text-destructive">' + ((result.errors && result.errors.length) || 0) + '</div>' +
'<div class="text-xs text-muted-foreground">Errors</div>' +
'</div>';
}
// Clear pending queue after indexing
updatePendingQueueUI({ file_count: 0, files: [], countdown_seconds: 0 });
}
/**
@@ -5999,12 +6284,37 @@ function closeWatcherModal() {
/**
* Handle watcher status update from WebSocket
* @param {Object} payload - { running: boolean, path?: string, error?: string }
* @param {Object} payload - { running: boolean, path?: string, error?: string, events_processed?: number, uptime_seconds?: number }
*/
function handleWatcherStatusUpdate(payload) {
var toggle = document.getElementById('watcherToggle');
var statsDiv = document.getElementById('watcherStats');
var configDiv = document.getElementById('watcherStartConfig');
var eventsCountEl = document.getElementById('watcherEventsCount');
var uptimeEl = document.getElementById('watcherUptime');
// Update events count if provided (real-time updates)
if (payload.events_processed !== undefined && eventsCountEl) {
eventsCountEl.textContent = payload.events_processed;
}
// Update uptime if provided
if (payload.uptime_seconds !== undefined && uptimeEl) {
var seconds = payload.uptime_seconds;
var formatted = seconds < 60 ? seconds + 's' :
seconds < 3600 ? Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's' :
Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
uptimeEl.textContent = formatted;
}
// Also update main page watcher status badge if it exists
var statusBadge = document.getElementById('watcherStatusBadge');
if (statusBadge && payload.running !== undefined) {
updateWatcherUI(payload.running, {
events_processed: payload.events_processed,
uptime_seconds: payload.uptime_seconds
});
}
if (payload.error) {
// Watcher failed - update UI to show stopped state
@@ -6018,8 +6328,8 @@ function handleWatcherStatusUpdate(payload) {
if (statsDiv) statsDiv.style.display = 'block';
if (configDiv) configDiv.style.display = 'none';
startWatcherStatusPolling();
} else {
// Watcher stopped normally
} else if (payload.running === false) {
// Watcher stopped normally (only if running is explicitly false)
if (toggle) toggle.checked = false;
if (statsDiv) statsDiv.style.display = 'none';
if (configDiv) configDiv.style.display = 'block';

View File

@@ -1,6 +1,56 @@
// Core Memory View
// Manages strategic context entries with knowledge graph and evolution tracking
/**
* Parse JSON streaming content and extract readable text
* Handles Gemini/Qwen format: {"type":"message","content":"...","delta":true}
*/
function parseJsonStreamContent(content) {
if (!content || typeof content !== 'string') return content;
// Check if content looks like JSON streaming (multiple JSON objects)
if (!content.includes('{"type":')) return content;
const lines = content.split('\n');
const extractedParts = [];
let hasJsonLines = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Try to parse as JSON
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
try {
const obj = JSON.parse(trimmed);
// Extract content from message type
if (obj.type === 'message' && obj.content) {
extractedParts.push(obj.content);
hasJsonLines = true;
}
// Skip init/result/error types (metadata)
else if (obj.type === 'init' || obj.type === 'result' || obj.type === 'error') {
hasJsonLines = true;
continue;
}
} catch (e) {
// Not valid JSON, keep as plain text
extractedParts.push(trimmed);
}
} else {
// Plain text line
extractedParts.push(trimmed);
}
}
// If we found JSON lines, return extracted content
if (hasJsonLines && extractedParts.length > 0) {
return extractedParts.join('');
}
return content;
}
// Notification function
function showNotification(message, type = 'info') {
// Create notification container if it doesn't exist
@@ -527,20 +577,24 @@ async function viewMemoryDetail(memoryId) {
const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = memory.id;
// Parse content and summary in case they contain JSON streaming format
const parsedContent = parseJsonStreamContent(memory.content);
const parsedSummary = parseJsonStreamContent(memory.summary);
const body = document.getElementById('memoryDetailBody');
body.innerHTML = `
<div class="memory-detail-content">
${memory.summary
${parsedSummary
? `<div class="detail-section">
<h3>${t('coreMemory.summary')}</h3>
<div class="detail-text">${escapeHtml(memory.summary)}</div>
<div class="detail-text">${escapeHtml(parsedSummary)}</div>
</div>`
: ''
}
<div class="detail-section">
<h3>${t('coreMemory.content')}</h3>
<pre class="detail-code">${escapeHtml(memory.content)}</pre>
<pre class="detail-code">${escapeHtml(parsedContent)}</pre>
</div>
${(() => {
@@ -564,7 +618,7 @@ async function viewMemoryDetail(memoryId) {
${memory.raw_output
? `<div class="detail-section">
<h3>${t('coreMemory.rawOutput')}</h3>
<pre class="detail-code">${escapeHtml(memory.raw_output)}</pre>
<pre class="detail-code">${escapeHtml(parseJsonStreamContent(memory.raw_output))}</pre>
</div>`
: ''
}

View File

@@ -347,7 +347,8 @@ var ruleCreateState = {
generationType: 'description',
description: '',
extractScope: '',
extractFocus: ''
extractFocus: '',
enableReview: false
};
function openRuleCreateModal() {
@@ -363,7 +364,8 @@ function openRuleCreateModal() {
generationType: 'description',
description: '',
extractScope: '',
extractFocus: ''
extractFocus: '',
enableReview: false
};
// Create modal HTML
@@ -506,6 +508,18 @@ function openRuleCreateModal() {
</div>
</div>
<!-- Review Option (CLI mode only) -->
<div id="ruleReviewSection" style="display: ${ruleCreateState.mode === 'cli-generate' ? 'block' : 'none'}">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="ruleEnableReview"
class="w-4 h-4 text-primary bg-background border-border rounded focus:ring-2 focus:ring-primary"
${ruleCreateState.enableReview ? 'checked' : ''}
onchange="toggleRuleReview()">
<span class="text-sm font-medium text-foreground">${t('rules.enableReview')}</span>
</label>
<p class="text-xs text-muted-foreground mt-1 ml-6">${t('rules.enableReviewHint')}</p>
</div>
<!-- Conditional Rule Toggle (Manual mode only) -->
<div id="ruleConditionalSection" style="display: ${ruleCreateState.mode === 'input' ? 'block' : 'none'}">
<label class="flex items-center gap-2 cursor-pointer">
@@ -667,11 +681,13 @@ function switchRuleCreateMode(mode) {
const generationTypeSection = document.getElementById('ruleGenerationTypeSection');
const descriptionSection = document.getElementById('ruleDescriptionSection');
const extractSection = document.getElementById('ruleExtractSection');
const reviewSection = document.getElementById('ruleReviewSection');
const conditionalSection = document.getElementById('ruleConditionalSection');
const contentSection = document.getElementById('ruleContentSection');
if (mode === 'cli-generate') {
if (generationTypeSection) generationTypeSection.style.display = 'block';
if (reviewSection) reviewSection.style.display = 'block';
if (conditionalSection) conditionalSection.style.display = 'none';
if (contentSection) contentSection.style.display = 'none';
@@ -687,6 +703,7 @@ function switchRuleCreateMode(mode) {
if (generationTypeSection) generationTypeSection.style.display = 'none';
if (descriptionSection) descriptionSection.style.display = 'none';
if (extractSection) extractSection.style.display = 'none';
if (reviewSection) reviewSection.style.display = 'none';
if (conditionalSection) conditionalSection.style.display = 'block';
if (contentSection) contentSection.style.display = 'block';
}
@@ -724,6 +741,11 @@ function switchRuleGenerationType(type) {
}
}
function toggleRuleReview() {
const checkbox = document.getElementById('ruleEnableReview');
ruleCreateState.enableReview = checkbox ? checkbox.checked : false;
}
async function createRule() {
const fileNameInput = document.getElementById('ruleFileName');
const subdirectoryInput = document.getElementById('ruleSubdirectory');
@@ -784,7 +806,8 @@ async function createRule() {
generationType: ruleCreateState.generationType,
description: ruleCreateState.generationType === 'description' ? description : undefined,
extractScope: ruleCreateState.generationType === 'extract' ? extractScope : undefined,
extractFocus: ruleCreateState.generationType === 'extract' ? extractFocus : undefined
extractFocus: ruleCreateState.generationType === 'extract' ? extractFocus : undefined,
enableReview: ruleCreateState.enableReview || undefined
};
// Show progress message

View File

@@ -510,35 +510,8 @@ function openSkillCreateModal() {
<p class="text-xs text-muted-foreground mt-1">${t('skills.skillNameHint')}</p>
</div>
<!-- Generation Type Selection -->
<div>
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.generationType')}</label>
<div class="flex gap-3">
<button class="flex-1 px-4 py-3 text-left border-2 rounded-lg transition-all ${skillCreateState.generationType === 'description' ? 'border-primary bg-primary/10' : 'border-border hover:border-primary/50'}"
onclick="switchSkillGenerationType('description')">
<div class="flex items-center gap-2">
<i data-lucide="file-text" class="w-5 h-5"></i>
<div>
<div class="font-medium text-sm">${t('skills.fromDescription')}</div>
<div class="text-xs text-muted-foreground">${t('skills.fromDescriptionHint')}</div>
</div>
</div>
</button>
<button class="flex-1 px-4 py-3 text-left border-2 rounded-lg transition-all opacity-50 cursor-not-allowed"
disabled>
<div class="flex items-center gap-2">
<i data-lucide="layout-template" class="w-5 h-5"></i>
<div>
<div class="font-medium text-sm">${t('skills.fromTemplate')}</div>
<div class="text-xs text-muted-foreground">${t('skills.comingSoon')}</div>
</div>
</div>
</button>
</div>
</div>
<!-- Description Text Area (for 'description' type) -->
<div id="skillDescriptionArea" style="display: ${skillCreateState.generationType === 'description' ? 'block' : 'none'}">
<!-- Description Text Area -->
<div id="skillDescriptionArea">
<label class="block text-sm font-medium text-foreground mb-2">${t('skills.descriptionLabel')} <span class="text-destructive">*</span></label>
<textarea id="skillDescription"
class="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
@@ -683,19 +656,6 @@ function switchSkillCreateMode(mode) {
}
}
function switchSkillGenerationType(type) {
skillCreateState.generationType = type;
// Toggle visibility of description area
const descriptionArea = document.getElementById('skillDescriptionArea');
if (descriptionArea) {
descriptionArea.style.display = type === 'description' ? 'block' : 'none';
}
// Update generation type button styles (only the description button is active, template is disabled)
// No need to update button styles since template button is disabled
}
function browseSkillFolder() {
// Use browser prompt for now (Phase 3 will implement file browser)
const path = prompt(t('skills.enterFolderPath'), skillCreateState.sourcePath);

View File

@@ -205,6 +205,18 @@ class UnifiedStreamParser {
this.extractedText += item.text;
output += `[响应] ${item.text}\n`; // Add newline for proper line separation
}
// Extract content from write_file tool calls (for rules generation)
// Use type assertion to access tool_use properties
const anyItem = item as { type: string; name?: string; input?: { content?: string } };
if (anyItem.type === 'tool_use' && anyItem.input?.content && typeof anyItem.input.content === 'string') {
const toolName = anyItem.name || '';
// Check if this is a file write operation
if (toolName.includes('write_file') || toolName.includes('Write')) {
// Use the file content as extracted text (overwrite previous text response)
this.extractedText = anyItem.input.content;
output += `[工具] ${toolName}: 写入文件内容 (${anyItem.input.content.length} 字符)\n`;
}
}
}
}
@@ -1286,9 +1298,10 @@ async function executeCliTool(
stdout += text;
// Parse stream-json for all supported tools
if (streamParser && onOutput) {
// Always process chunks to populate extractedText, even without onOutput callback
if (streamParser) {
const parsedText = streamParser.processChunk(text);
if (parsedText) {
if (parsedText && onOutput) {
onOutput({ type: 'stdout', data: parsedText });
}
} else if (onOutput) {
@@ -1311,9 +1324,10 @@ async function executeCliTool(
currentChildProcess = null;
// Flush unified parser buffer if present
if (streamParser && onOutput) {
// Always flush to capture remaining content, even without onOutput callback
if (streamParser) {
const remaining = streamParser.flush();
if (remaining) {
if (remaining && onOutput) {
onOutput({ type: 'stdout', data: remaining });
}

View File

@@ -107,7 +107,7 @@ describe('cli command module', async () => {
assert.equal(call.prompt, 'Hello');
assert.equal(call.mode, 'analysis');
assert.equal(call.stream, false);
assert.equal(call.timeout, 300000);
assert.equal(call.timeout, 0);
}
assert.deepEqual(exitCodes, [0, 0, 0]);
});

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
@@ -28,7 +29,7 @@ class FileEvent:
@dataclass
class WatcherConfig:
"""Configuration for file watcher."""
debounce_ms: int = 1000
debounce_ms: int = 60000 # Default 60 seconds for debounce
ignored_patterns: Set[str] = field(default_factory=lambda: {
# Version control
".git", ".svn", ".hg",
@@ -50,13 +51,26 @@ class WatcherConfig:
languages: Optional[List[str]] = None # None = all supported
@dataclass
class PendingQueueStatus:
"""Status of pending file changes queue."""
file_count: int = 0
files: List[str] = field(default_factory=list) # Limited to 20 files
countdown_seconds: int = 0
last_event_time: Optional[float] = None
@dataclass
class IndexResult:
"""Result of processing file changes."""
files_indexed: int = 0
files_removed: int = 0
symbols_added: int = 0
symbols_removed: int = 0
files_success: List[str] = field(default_factory=list)
files_failed: List[str] = field(default_factory=list)
errors: List[str] = field(default_factory=list)
timestamp: float = field(default_factory=time.time)
@dataclass

View File

@@ -11,11 +11,15 @@ from typing import Callable, Dict, List, Optional
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from .events import ChangeType, FileEvent, WatcherConfig
from .events import ChangeType, FileEvent, WatcherConfig, PendingQueueStatus
from ..config import Config
logger = logging.getLogger(__name__)
# Maximum queue size to prevent unbounded memory growth
# When exceeded, forces immediate flush to avoid memory exhaustion
MAX_QUEUE_SIZE = 50000
class _CodexLensHandler(FileSystemEventHandler):
"""Internal handler for watchdog events."""
@@ -112,8 +116,12 @@ class FileWatcher:
self._event_queue: List[FileEvent] = []
self._queue_lock = threading.Lock()
# Debounce thread
self._debounce_thread: Optional[threading.Thread] = None
# Debounce timer (true debounce - waits after last event)
self._flush_timer: Optional[threading.Timer] = None
self._last_event_time: float = 0
# Queue change callbacks for real-time UI updates
self._queue_change_callbacks: List[Callable[[PendingQueueStatus], None]] = []
# Config instance for language checking
self._codexlens_config = Config()
@@ -138,16 +146,57 @@ class FileWatcher:
return language is not None
def _on_raw_event(self, event: FileEvent) -> None:
"""Handle raw event from watchdog handler."""
"""Handle raw event from watchdog handler with true debounce."""
force_flush = False
with self._queue_lock:
# Check queue size limit to prevent memory exhaustion
if len(self._event_queue) >= MAX_QUEUE_SIZE:
logger.warning(
"Event queue limit (%d) reached, forcing immediate flush",
MAX_QUEUE_SIZE
)
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = None
force_flush = True
self._event_queue.append(event)
# Debouncing is handled by background thread
self._last_event_time = time.time()
# Cancel previous timer and schedule new one (true debounce)
# Skip if we're about to force flush
if not force_flush:
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = threading.Timer(
self.config.debounce_ms / 1000.0,
self._flush_events
)
self._flush_timer.daemon = True
self._flush_timer.start()
# Force flush outside lock to avoid deadlock
if force_flush:
self._flush_events()
# Notify queue change (outside lock to avoid deadlock)
self._notify_queue_change()
def _debounce_loop(self) -> None:
"""Background thread for debounced event batching."""
"""Background thread for checking flush signal file."""
signal_file = self.root_path / '.codexlens' / 'flush.signal'
while self._running:
time.sleep(self.config.debounce_ms / 1000.0)
self._flush_events()
time.sleep(1.0) # Check every second
# Check for flush signal file
if signal_file.exists():
try:
signal_file.unlink()
logger.info("Flush signal detected, triggering immediate index")
self.flush_now()
except Exception as e:
logger.warning("Failed to handle flush signal: %s", e)
def _flush_events(self) -> None:
"""Flush queued events with deduplication."""
@@ -162,6 +211,10 @@ class FileWatcher:
events = list(deduped.values())
self._event_queue.clear()
self._last_event_time = 0 # Reset after flush
# Notify queue cleared
self._notify_queue_change()
if events:
try:
@@ -169,6 +222,50 @@ class FileWatcher:
except Exception as exc:
logger.error("Error in on_changes callback: %s", exc)
def flush_now(self) -> None:
"""Immediately flush pending queue (manual trigger)."""
with self._queue_lock:
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = None
self._flush_events()
def get_pending_queue_status(self) -> PendingQueueStatus:
"""Get current pending queue status for UI display."""
with self._queue_lock:
file_count = len(self._event_queue)
files = [str(e.path.name) for e in self._event_queue[:20]]
# Calculate countdown
if self._last_event_time > 0 and file_count > 0:
elapsed = time.time() - self._last_event_time
remaining = max(0, self.config.debounce_ms / 1000.0 - elapsed)
countdown = int(remaining)
else:
countdown = 0
return PendingQueueStatus(
file_count=file_count,
files=files,
countdown_seconds=countdown,
last_event_time=self._last_event_time if file_count > 0 else None
)
def register_queue_change_callback(
self, callback: Callable[[PendingQueueStatus], None]
) -> None:
"""Register callback for queue change notifications."""
self._queue_change_callbacks.append(callback)
def _notify_queue_change(self) -> None:
"""Notify all registered callbacks of queue change."""
status = self.get_pending_queue_status()
for callback in self._queue_change_callbacks:
try:
callback(status)
except Exception as e:
logger.error("Queue change callback error: %s", e)
def start(self) -> None:
"""Start watching the directory.
@@ -190,13 +287,13 @@ class FileWatcher:
self._stop_event.clear()
self._observer.start()
# Start debounce thread
self._debounce_thread = threading.Thread(
# Start signal check thread (for flush.signal file)
self._signal_check_thread = threading.Thread(
target=self._debounce_loop,
daemon=True,
name="FileWatcher-Debounce",
name="FileWatcher-SignalCheck",
)
self._debounce_thread.start()
self._signal_check_thread.start()
logger.info("Started watching: %s", self.root_path)
@@ -212,15 +309,20 @@ class FileWatcher:
self._running = False
self._stop_event.set()
# Cancel pending flush timer
if self._flush_timer:
self._flush_timer.cancel()
self._flush_timer = None
if self._observer:
self._observer.stop()
self._observer.join(timeout=5.0)
self._observer = None
# Wait for debounce thread to finish
if self._debounce_thread and self._debounce_thread.is_alive():
self._debounce_thread.join(timeout=2.0)
self._debounce_thread = None
# Wait for signal check thread to finish
if hasattr(self, '_signal_check_thread') and self._signal_check_thread and self._signal_check_thread.is_alive():
self._signal_check_thread.join(timeout=2.0)
self._signal_check_thread = None
# Flush any remaining events
self._flush_events()

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import signal
import threading
@@ -13,7 +14,7 @@ from codexlens.config import Config
from codexlens.storage.path_mapper import PathMapper
from codexlens.storage.registry import RegistryStore
from .events import FileEvent, IndexResult, WatcherConfig, WatcherStats
from .events import FileEvent, IndexResult, PendingQueueStatus, WatcherConfig, WatcherStats
from .file_watcher import FileWatcher
from .incremental_indexer import IncrementalIndexer
@@ -36,44 +37,68 @@ class WatcherManager:
config: Optional[Config] = None,
watcher_config: Optional[WatcherConfig] = None,
on_indexed: Optional[Callable[[IndexResult], None]] = None,
on_queue_change: Optional[Callable[[PendingQueueStatus], None]] = None,
) -> None:
self.root_path = Path(root_path).resolve()
self.config = config or Config()
self.watcher_config = watcher_config or WatcherConfig()
self.on_indexed = on_indexed
self.on_queue_change = on_queue_change
self._registry: Optional[RegistryStore] = None
self._mapper: Optional[PathMapper] = None
self._watcher: Optional[FileWatcher] = None
self._indexer: Optional[IncrementalIndexer] = None
self._running = False
self._stop_event = threading.Event()
self._lock = threading.RLock()
# Statistics
self._stats = WatcherStats()
self._original_sigint = None
self._original_sigterm = None
# Index history for tracking recent results
self._index_history: List[IndexResult] = []
self._max_history_size = 10
def _handle_changes(self, events: List[FileEvent]) -> None:
"""Handle file change events from watcher."""
if not self._indexer or not events:
return
logger.info("Processing %d file changes", len(events))
result = self._indexer.process_changes(events)
# Update stats
self._stats.events_processed += len(events)
self._stats.last_event_time = time.time()
# Save to history
self._index_history.append(result)
if len(self._index_history) > self._max_history_size:
self._index_history.pop(0)
if result.files_indexed > 0 or result.files_removed > 0:
logger.info(
"Indexed %d files, removed %d files, %d errors",
result.files_indexed, result.files_removed, len(result.errors)
)
# Output JSON for TypeScript backend parsing
result_data = {
"files_indexed": result.files_indexed,
"files_removed": result.files_removed,
"symbols_added": result.symbols_added,
"symbols_removed": result.symbols_removed,
"files_success": result.files_success[:20], # Limit output
"files_failed": result.files_failed[:20],
"errors": result.errors[:10],
"timestamp": result.timestamp
}
print(f"[INDEX_RESULT] {json.dumps(result_data)}", flush=True)
if self.on_indexed:
try:
self.on_indexed(result)
@@ -128,7 +153,11 @@ class WatcherManager:
self._watcher = FileWatcher(
self.root_path, self.watcher_config, self._handle_changes
)
# Register queue change callback for real-time UI updates
if self.on_queue_change:
self._watcher.register_queue_change_callback(self._on_queue_change_wrapper)
# Install signal handlers
self._install_signal_handlers()
@@ -192,3 +221,35 @@ class WatcherManager:
last_event_time=self._stats.last_event_time,
is_running=self._running,
)
def _on_queue_change_wrapper(self, status: PendingQueueStatus) -> None:
"""Wrapper for queue change callback with JSON output."""
# Output JSON for TypeScript backend parsing
status_data = {
"file_count": status.file_count,
"files": status.files,
"countdown_seconds": status.countdown_seconds,
"last_event_time": status.last_event_time
}
print(f"[QUEUE_STATUS] {json.dumps(status_data)}", flush=True)
if self.on_queue_change:
try:
self.on_queue_change(status)
except Exception as exc:
logger.error("Error in on_queue_change callback: %s", exc)
def flush_now(self) -> None:
"""Immediately flush pending queue (manual trigger)."""
if self._watcher:
self._watcher.flush_now()
def get_pending_queue_status(self) -> Optional[PendingQueueStatus]:
"""Get current pending queue status."""
if self._watcher:
return self._watcher.get_pending_queue_status()
return None
def get_index_history(self, limit: int = 5) -> List[IndexResult]:
"""Get recent index history."""
return self._index_history[-limit:]