feat: Enhance navigation and cleanup for graph explorer view

- Added a cleanup function to reset the state when navigating away from the graph explorer.
- Updated navigation logic to call the cleanup function before switching views.
- Improved internationalization by adding new translations for graph-related terms.
- Adjusted icon sizes for better UI consistency in the graph explorer.
- Implemented impact analysis button functionality in the graph explorer.
- Refactored CLI tool configuration to use updated model names.
- Enhanced CLI executor to handle prompts correctly for codex commands.
- Introduced code relationship storage for better visualization in the index tree.
- Added support for parsing Markdown and plain text files in the symbol parser.
- Updated tests to reflect changes in language detection logic.
This commit is contained in:
catlog22
2025-12-15 23:11:01 +08:00
parent 894b93e08d
commit 35485bbbb1
35 changed files with 3348 additions and 228 deletions

View File

@@ -27,7 +27,7 @@ export type CliToolName = 'gemini' | 'qwen' | 'codex';
export const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
codex: ['gpt5-codex', 'gpt-4.1', 'o4-mini', 'o3']
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3']
};
export const DEFAULT_CONFIG: CliConfig = {
@@ -45,8 +45,8 @@ export const DEFAULT_CONFIG: CliConfig = {
},
codex: {
enabled: true,
primaryModel: 'gpt5-codex',
secondaryModel: 'gpt5-codex'
primaryModel: 'gpt-5.2',
secondaryModel: 'gpt-5.2'
}
}
};

View File

@@ -319,6 +319,8 @@ function buildCommand(params: {
break;
case 'codex':
// Codex does NOT support stdin - prompt must be passed as command line argument
useStdin = false;
// Native resume: codex resume <uuid> [prompt] or --last
if (nativeResume?.enabled) {
args.push('resume');
@@ -343,6 +345,10 @@ function buildCommand(params: {
args.push('--add-dir', addDir);
}
}
// Add prompt as positional argument for resume
if (prompt) {
args.push(prompt);
}
} else {
// Standard exec mode
args.push('exec');
@@ -362,6 +368,10 @@ function buildCommand(params: {
args.push('--add-dir', addDir);
}
}
// Add prompt as positional argument (codex exec "prompt")
if (prompt) {
args.push(prompt);
}
}
break;
@@ -379,9 +389,11 @@ function buildCommand(params: {
if (model) {
args.push('--model', model);
}
// Permission modes for write/auto
// Permission modes: write/auto → bypassPermissions, analysis → default
if (mode === 'write' || mode === 'auto') {
args.push('--dangerously-skip-permissions');
args.push('--permission-mode', 'bypassPermissions');
} else {
args.push('--permission-mode', 'default');
}
// Output format for better parsing
args.push('--output-format', 'text');
@@ -570,7 +582,7 @@ async function executeCliTool(
// Determine working directory early (needed for conversation lookup)
const workingDir = cd || process.cwd();
const historyDir = ensureHistoryDir(workingDir);
ensureHistoryDir(workingDir); // Ensure history directory exists
// Get SQLite store for native session lookup
const store = await getSqliteStore(workingDir);
@@ -722,16 +734,8 @@ async function executeCliTool(
}
}
// Determine effective model (use config's primaryModel if not explicitly provided)
let effectiveModel = model;
if (!effectiveModel) {
try {
effectiveModel = getPrimaryModel(workingDir, tool);
} catch {
// Config not available, use default (let the CLI tool use its own default)
effectiveModel = undefined;
}
}
// Only pass model if explicitly provided - let CLI tools use their own defaults
const effectiveModel = model;
// Build command
const { command, args, useStdin } = buildCommand({
@@ -864,7 +868,7 @@ async function executeCliTool(
// Save all source conversations
try {
for (const conv of savedConversations) {
saveConversation(historyDir, conv);
saveConversation(workingDir, conv);
}
} catch (err) {
console.error('[CLI Executor] Failed to save merged histories:', (err as Error).message);
@@ -906,7 +910,7 @@ async function executeCliTool(
};
// Save merged conversation
try {
saveConversation(historyDir, conversation);
saveConversation(workingDir, conversation);
} catch (err) {
console.error('[CLI Executor] Failed to save merged conversation:', (err as Error).message);
}
@@ -937,7 +941,7 @@ async function executeCliTool(
};
// Try to save conversation to history
try {
saveConversation(historyDir, conversation);
saveConversation(workingDir, conversation);
} catch (err) {
// Non-fatal: continue even if history save fails
console.error('[CLI Executor] Failed to save history:', (err as Error).message);
@@ -945,7 +949,8 @@ async function executeCliTool(
}
// Track native session after execution (async, non-blocking)
trackNewSession(tool, new Date(startTime), workingDir)
// Pass prompt for precise matching in parallel execution scenarios
trackNewSession(tool, new Date(startTime), workingDir, prompt)
.then((nativeSession) => {
if (nativeSession) {
// Save native session mapping
@@ -1211,8 +1216,8 @@ export function getExecutionHistory(baseDir: string, options: {
* Get conversation detail by ID (returns ConversationRecord)
*/
export function getConversationDetail(baseDir: string, conversationId: string): ConversationRecord | null {
const paths = StoragePaths.project(baseDir);
return loadConversation(paths.cliHistory, conversationId);
// Pass baseDir directly - loadConversation will resolve the correct storage path
return loadConversation(baseDir, conversationId);
}
/**

View File

@@ -7,7 +7,7 @@ import Database from 'better-sqlite3';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
import { join } from 'path';
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
import { StoragePaths, ensureStorageDir, getProjectId } from '../config/storage-paths.js';
// Types
export interface ConversationTurn {
@@ -239,10 +239,12 @@ export class CliHistoryStore {
*/
private migrateSchema(): void {
try {
// Check if category column exists
// Check if columns exist
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
const hasCategory = tableInfo.some(col => col.name === 'category');
const hasParentExecutionId = tableInfo.some(col => col.name === 'parent_execution_id');
const hasProjectRoot = tableInfo.some(col => col.name === 'project_root');
const hasRelativePath = tableInfo.some(col => col.name === 'relative_path');
if (!hasCategory) {
console.log('[CLI History] Migrating database: adding category column...');
@@ -270,6 +272,28 @@ export class CliHistoryStore {
}
console.log('[CLI History] Migration complete: parent_execution_id column added');
}
// Add hierarchical storage support columns
if (!hasProjectRoot) {
console.log('[CLI History] Migrating database: adding project_root column for hierarchical storage...');
this.db.exec(`
ALTER TABLE conversations ADD COLUMN project_root TEXT;
`);
try {
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_project_root ON conversations(project_root);`);
} catch (indexErr) {
console.warn('[CLI History] Project root index creation warning:', (indexErr as Error).message);
}
console.log('[CLI History] Migration complete: project_root column added');
}
if (!hasRelativePath) {
console.log('[CLI History] Migrating database: adding relative_path column for hierarchical storage...');
this.db.exec(`
ALTER TABLE conversations ADD COLUMN relative_path TEXT;
`);
console.log('[CLI History] Migration complete: relative_path column added');
}
} catch (err) {
console.error('[CLI History] Migration error:', (err as Error).message);
// Don't throw - allow the store to continue working with existing schema
@@ -1115,17 +1139,21 @@ export class CliHistoryStore {
}
}
// Singleton instance cache
// Singleton instance cache - keyed by normalized project ID for consistency
const storeCache = new Map<string, CliHistoryStore>();
/**
* Get or create a store instance for a directory
* Uses normalized project ID as cache key to handle path casing differences
*/
export function getHistoryStore(baseDir: string): CliHistoryStore {
if (!storeCache.has(baseDir)) {
storeCache.set(baseDir, new CliHistoryStore(baseDir));
// Use getProjectId to normalize path for consistent cache key
const cacheKey = getProjectId(baseDir);
if (!storeCache.has(cacheKey)) {
storeCache.set(cacheKey, new CliHistoryStore(baseDir));
}
return storeCache.get(baseDir)!;
return storeCache.get(cacheKey)!;
}
/**

View File

@@ -70,18 +70,60 @@ abstract class SessionDiscoverer {
/**
* Track new session created during execution
* @param beforeTimestamp - Filter sessions created after this time
* @param workingDir - Project working directory
* @param prompt - Optional prompt content for precise matching (fallback)
*/
async trackNewSession(
beforeTimestamp: Date,
workingDir: string
workingDir: string,
prompt?: string
): Promise<NativeSession | null> {
const sessions = this.getSessions({
workingDir,
afterTimestamp: beforeTimestamp,
limit: 1
limit: 10 // Get more candidates for prompt matching
});
return sessions.length > 0 ? sessions[0] : null;
if (sessions.length === 0) return null;
// If only one session or no prompt provided, return the latest
if (sessions.length === 1 || !prompt) {
return sessions[0];
}
// Try to match by prompt content (fallback for parallel execution)
const matched = this.matchSessionByPrompt(sessions, prompt);
return matched || sessions[0]; // Fallback to latest if no match
}
/**
* Match session by prompt content
* Searches for the prompt in session's user messages
*/
matchSessionByPrompt(sessions: NativeSession[], prompt: string): NativeSession | null {
// Normalize prompt for comparison (first 200 chars)
const promptPrefix = prompt.substring(0, 200).trim();
if (!promptPrefix) return null;
for (const session of sessions) {
try {
const userMessage = this.extractFirstUserMessage(session.filePath);
if (userMessage && userMessage.includes(promptPrefix)) {
return session;
}
} catch {
// Skip sessions that can't be read
}
}
return null;
}
/**
* Extract first user message from session file
* Override in subclass for tool-specific format
*/
abstract extractFirstUserMessage(filePath: string): string | null;
}
/**
@@ -157,6 +199,23 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
/**
* Extract first user message from Gemini session file
* Format: { "messages": [{ "type": "user", "content": "..." }] }
*/
extractFirstUserMessage(filePath: string): string | null {
try {
const content = JSON.parse(readFileSync(filePath, 'utf8'));
if (content.messages && Array.isArray(content.messages)) {
const userMsg = content.messages.find((m: { type: string }) => m.type === 'user');
return userMsg?.content || null;
}
return null;
} catch {
return null;
}
}
}
/**
@@ -330,6 +389,46 @@ class QwenSessionDiscoverer extends SessionDiscoverer {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
/**
* Extract first user message from Qwen session file
* New format (.jsonl): { type: "user", message: { role: "user", parts: [{ text: "..." }] } }
* Legacy format (.json): { "messages": [{ "type": "user", "content": "..." }] }
*/
extractFirstUserMessage(filePath: string): string | null {
try {
const content = readFileSync(filePath, 'utf8');
// Check if JSONL (new format) or JSON (legacy)
if (filePath.endsWith('.jsonl')) {
// JSONL format - find first user message
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// New Qwen format: { type: "user", message: { parts: [{ text: "..." }] } }
if (entry.type === 'user' && entry.message?.parts?.[0]?.text) {
return entry.message.parts[0].text;
}
// Alternative format
if (entry.role === 'user' && entry.content) {
return entry.content;
}
} catch { /* skip invalid lines */ }
}
} else {
// Legacy JSON format
const data = JSON.parse(content);
if (data.messages && Array.isArray(data.messages)) {
const userMsg = data.messages.find((m: { type: string }) => m.type === 'user');
return userMsg?.content || null;
}
}
return null;
} catch {
return null;
}
}
}
/**
@@ -430,6 +529,32 @@ class CodexSessionDiscoverer extends SessionDiscoverer {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
/**
* Extract first user message from Codex session file (.jsonl)
* Format: {"type":"event_msg","payload":{"type":"user_message","message":"..."}}
*/
extractFirstUserMessage(filePath: string): string | null {
try {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Look for user_message event
if (entry.type === 'event_msg' &&
entry.payload?.type === 'user_message' &&
entry.payload?.message) {
return entry.payload.message;
}
} catch { /* skip invalid lines */ }
}
return null;
} catch {
return null;
}
}
}
/**
@@ -462,15 +587,17 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
}
for (const projectHash of projectDirs) {
const sessionsDir = join(this.basePath, projectHash, 'sessions');
if (!existsSync(sessionsDir)) continue;
// Claude Code stores session files directly in project folder (not in 'sessions' subdirectory)
// e.g., ~/.claude/projects/D--Claude-dms3/<uuid>.jsonl
const projectDir = join(this.basePath, projectHash);
if (!existsSync(projectDir)) continue;
const sessionFiles = readdirSync(sessionsDir)
const sessionFiles = readdirSync(projectDir)
.filter(f => f.endsWith('.jsonl') || f.endsWith('.json'))
.map(f => ({
name: f,
path: join(sessionsDir, f),
stat: statSync(join(sessionsDir, f))
path: join(projectDir, f),
stat: statSync(join(projectDir, f))
}))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
@@ -521,6 +648,35 @@ class ClaudeSessionDiscoverer extends SessionDiscoverer {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
/**
* Extract first user message from Claude Code session file (.jsonl)
* Format: {"type":"user","message":{"role":"user","content":"..."},"isMeta":false,...}
*/
extractFirstUserMessage(filePath: string): string | null {
try {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Claude Code format: type="user", message.role="user", message.content="..."
// Skip meta messages and command messages
if (entry.type === 'user' &&
entry.message?.role === 'user' &&
entry.message?.content &&
!entry.isMeta &&
!entry.message.content.startsWith('<command-')) {
return entry.message.content;
}
} catch { /* skip invalid lines */ }
}
return null;
} catch {
return null;
}
}
}
// Singleton discoverers
@@ -564,15 +720,20 @@ export function findNativeSessionById(
/**
* Track new session created during execution
* @param tool - CLI tool name (gemini, qwen, codex, claude)
* @param beforeTimestamp - Filter sessions created after this time
* @param workingDir - Project working directory
* @param prompt - Optional prompt for precise matching in parallel execution
*/
export async function trackNewSession(
tool: string,
beforeTimestamp: Date,
workingDir: string
workingDir: string,
prompt?: string
): Promise<NativeSession | null> {
const discoverer = discoverers[tool];
if (!discoverer) return null;
return discoverer.trackNewSession(beforeTimestamp, workingDir);
return discoverer.trackNewSession(beforeTimestamp, workingDir, prompt);
}
/**

View File

@@ -137,47 +137,112 @@ function getDbRecordCount(dbPath: string, tableName: string): number {
}
/**
* Get storage statistics for a specific project by ID
* Check if a directory is a project data directory
* A project data directory contains at least one of: cli-history, memory, cache, config
*/
export function getProjectStorageStats(projectId: string): ProjectStorageStats {
const paths = StoragePaths.projectById(projectId);
function isProjectDataDirectory(dirPath: string): boolean {
const dataMarkers = ['cli-history', 'memory', 'cache', 'config'];
return dataMarkers.some(marker => existsSync(join(dirPath, marker)));
}
const cliHistorySize = getDirSize(paths.cliHistory);
const memorySize = getDirSize(paths.memory);
const cacheSize = getDirSize(paths.cache);
const configSize = getDirSize(paths.config);
/**
* Get storage statistics for a specific project by path
* @param projectId - Project ID (can be hierarchical like "parent/child")
* @param projectDir - Actual directory path in storage
*/
function getProjectStats(projectId: string, projectDir: string): ProjectStorageStats {
const cliHistoryDir = join(projectDir, 'cli-history');
const memoryDir = join(projectDir, 'memory');
const cacheDir = join(projectDir, 'cache');
const configDir = join(projectDir, 'config');
const cliHistorySize = getDirSize(cliHistoryDir);
const memorySize = getDirSize(memoryDir);
const cacheSize = getDirSize(cacheDir);
const configSize = getDirSize(configDir);
let recordCount: number | undefined;
if (existsSync(paths.historyDb)) {
recordCount = getDbRecordCount(paths.historyDb, 'conversations');
const historyDb = join(cliHistoryDir, 'history.db');
if (existsSync(historyDb)) {
recordCount = getDbRecordCount(historyDb, 'conversations');
}
return {
projectId,
totalSize: cliHistorySize + memorySize + cacheSize + configSize,
cliHistory: {
exists: existsSync(paths.cliHistory),
exists: existsSync(cliHistoryDir),
size: cliHistorySize,
recordCount
},
memory: {
exists: existsSync(paths.memory),
exists: existsSync(memoryDir),
size: memorySize
},
cache: {
exists: existsSync(paths.cache),
exists: existsSync(cacheDir),
size: cacheSize
},
config: {
exists: existsSync(paths.config),
exists: existsSync(configDir),
size: configSize
},
lastModified: getLatestModTime(paths.root)
lastModified: getLatestModTime(projectDir)
};
}
/**
* Get storage statistics for a specific project by ID (legacy)
*/
export function getProjectStorageStats(projectId: string): ProjectStorageStats {
const paths = StoragePaths.projectById(projectId);
return getProjectStats(projectId, paths.root);
}
/**
* Recursively scan project directory for hierarchical structure
* @param basePath - Base directory to scan
* @param relativePath - Relative path from projects root
* @param results - Array to accumulate results
*/
function scanProjectDirectory(
basePath: string,
relativePath: string,
results: ProjectStorageStats[]
): void {
if (!existsSync(basePath)) return;
try {
const entries = readdirSync(basePath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const fullPath = join(basePath, entry.name);
const currentRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
// Check if this is a project data directory
if (isProjectDataDirectory(fullPath)) {
const projectId = currentRelPath;
const stats = getProjectStats(projectId, fullPath);
results.push(stats);
}
// Recursively scan subdirectories (excluding data directories)
const dataDirs = ['cli-history', 'memory', 'cache', 'config'];
if (!dataDirs.includes(entry.name)) {
scanProjectDirectory(fullPath, currentRelPath, results);
}
}
} catch (err) {
// Ignore read errors
if (process.env.DEBUG) console.error(`[Storage] Failed to scan ${basePath}: ${err}`);
}
}
/**
* Get all storage statistics
* Supports hierarchical project structure
*/
export function getStorageStats(): StorageStats {
const rootPath = CCW_HOME;
@@ -187,19 +252,10 @@ export function getStorageStats(): StorageStats {
const mcpTemplatesPath = StoragePaths.global.mcpTemplates();
const globalDbSize = getFileSize(mcpTemplatesPath);
// Projects
// Projects - use recursive scanning for hierarchical structure
const projects: ProjectStorageStats[] = [];
if (existsSync(projectsDir)) {
try {
const entries = readdirSync(projectsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
projects.push(getProjectStorageStats(entry.name));
}
}
} catch {
// Ignore read errors
}
scanProjectDirectory(projectsDir, '', projects);
}
// Sort by last modified (most recent first)