MCP bridge added

This commit is contained in:
abhigyanpatwari 2026-01-17 23:08:48 +05:30
parent df4f9e30c2
commit e153d30c8f
101 changed files with 5374 additions and 1010 deletions

1994
gitnexus-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

33
gitnexus-mcp/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "gitnexus-mcp",
"version": "0.1.0",
"description": "MCP bridge for GitNexus code intelligence",
"type": "module",
"bin": {
"gitnexus-mcp": "./dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsx watch src/cli.ts",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"commander": "^12.0.0",
"ws": "^8.16.0",
"chalk": "^5.3.0",
"ora": "^8.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/ws": "^8.5.10",
"typescript": "^5.4.0",
"tsx": "^4.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View file

@ -0,0 +1,173 @@
/**
* Daemon Client
*
* WebSocket client that connects to the GitNexus daemon.
* Used by `serve` command to route tool calls through the daemon to the browser.
* Also receives codebase context for MCP resource exposure.
*/
import WebSocket from 'ws';
export interface BridgeMessage {
id: string;
method?: string;
params?: any;
result?: any;
error?: { message: string };
type?: string;
context?: CodebaseContext;
}
/**
* Codebase context from browser
*/
export interface CodebaseContext {
projectName: string;
stats: {
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
};
hotspots: Array<{
name: string;
type: string;
filePath: string;
connections: number;
}>;
folderTree: string;
}
type RequestResolver = {
resolve: (result: any) => void;
reject: (error: Error) => void;
};
export class DaemonClient {
private ws: WebSocket | null = null;
private pendingRequests: Map<string, RequestResolver> = new Map();
private requestId = 0;
private _context: CodebaseContext | null = null;
private contextListeners: Set<(context: CodebaseContext | null) => void> = new Set();
constructor(private port: number = 54319) {}
/**
* Connect to the daemon
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
// Connect to /mcp path so daemon knows this is an MCP client
this.ws = new WebSocket(`ws://localhost:${this.port}/mcp`);
this.ws.on('open', () => {
resolve();
});
this.ws.on('error', (error) => {
reject(error);
});
this.ws.on('message', (data) => {
try {
const msg: BridgeMessage = JSON.parse(data.toString());
this.handleMessage(msg);
} catch (error) {
console.error('Failed to parse message:', error);
}
});
this.ws.on('close', () => {
this.ws = null;
this._context = null;
// Reject all pending requests
for (const { reject } of this.pendingRequests.values()) {
reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
});
});
}
private handleMessage(msg: BridgeMessage) {
// Handle context update from daemon
if (msg.type === 'context_update' && msg.context) {
this._context = msg.context;
this.notifyContextListeners();
return;
}
// Response from daemon (originally from browser)
if (msg.id && this.pendingRequests.has(msg.id)) {
const { resolve, reject } = this.pendingRequests.get(msg.id)!;
this.pendingRequests.delete(msg.id);
if (msg.error) {
reject(new Error(msg.error.message));
} else {
resolve(msg.result);
}
}
}
/**
* Get current codebase context
*/
get context(): CodebaseContext | null {
return this._context;
}
/**
* Listen for context changes
*/
onContextChange(listener: (context: CodebaseContext | null) => void) {
this.contextListeners.add(listener);
return () => this.contextListeners.delete(listener);
}
private notifyContextListeners() {
this.contextListeners.forEach(listener => listener(this._context));
}
/**
* Check if connected to daemon
*/
get isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
/**
* Call a tool (routed through daemon to browser)
*/
async callTool(method: string, params: any): Promise<any> {
if (!this.isConnected) {
throw new Error('Not connected to daemon');
}
const id = `req_${++this.requestId}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
const msg: BridgeMessage = { id, method, params };
this.ws!.send(JSON.stringify(msg));
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
/**
* Disconnect from daemon
*/
disconnect() {
this.ws?.close();
this.ws = null;
}
}

View file

@ -0,0 +1,36 @@
/**
* Bridge Protocol Types
*
* JSON-RPC-like protocol for communication between bridge and browser.
*/
export interface ToolCallRequest {
id: string;
method: string;
params: Record<string, any>;
}
export interface ToolCallResponse {
id: string;
result?: any;
error?: {
code?: number;
message: string;
};
}
export type BridgeMessage = ToolCallRequest | ToolCallResponse;
/**
* Check if message is a request (has method)
*/
export function isRequest(msg: BridgeMessage): msg is ToolCallRequest {
return 'method' in msg;
}
/**
* Check if message is a response (has result or error)
*/
export function isResponse(msg: BridgeMessage): msg is ToolCallResponse {
return 'result' in msg || 'error' in msg;
}

View file

@ -0,0 +1,163 @@
/**
* WebSocket Bridge
*
* WebSocket server that connects to the GitNexus browser tab.
* Relays tool calls from MCP server to browser and returns results.
*/
import { WebSocketServer, WebSocket } from 'ws';
import { createServer as createNetServer } from 'net';
export interface BridgeMessage {
id: string;
method?: string;
params?: any;
result?: any;
error?: { message: string };
}
type RequestResolver = {
resolve: (result: any) => void;
reject: (error: Error) => void;
};
/**
* Check if a port is available
*/
async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createNetServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port);
});
}
export class WebSocketBridge {
private wss: WebSocketServer | null = null;
private client: WebSocket | null = null;
private pendingRequests: Map<string, RequestResolver> = new Map();
private requestId = 0;
private started = false;
constructor(private port: number = 54319) {}
/**
* Start the WebSocket server (handles port-in-use gracefully)
*/
async start(): Promise<boolean> {
const available = await isPortAvailable(this.port);
if (!available) {
// Port already in use - another instance is handling browser connections
// This is OK - we can still run MCP server for stdio communication
console.error(`Port ${this.port} in use. Browser bridge running elsewhere.`);
return false;
}
return new Promise((resolve) => {
this.wss = new WebSocketServer({ port: this.port });
this.wss.on('connection', (ws) => {
// Only allow one browser connection at a time
if (this.client) {
this.client.close();
}
this.client = ws;
ws.on('message', (data) => {
try {
const msg: BridgeMessage = JSON.parse(data.toString());
this.handleMessage(msg);
} catch (error) {
console.error('Failed to parse message:', error);
}
});
ws.on('close', () => {
if (this.client === ws) {
this.client = null;
}
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
this.wss.on('listening', () => {
this.started = true;
resolve(true);
});
this.wss.on('error', (error) => {
console.error('WebSocket server error:', error);
resolve(false);
});
});
}
private handleMessage(msg: BridgeMessage) {
// This is a response to a pending request
if (msg.id && this.pendingRequests.has(msg.id)) {
const { resolve, reject } = this.pendingRequests.get(msg.id)!;
this.pendingRequests.delete(msg.id);
if (msg.error) {
reject(new Error(msg.error.message));
} else {
resolve(msg.result);
}
}
}
/**
* Check if browser is connected
*/
get isConnected(): boolean {
return this.client !== null && this.client.readyState === WebSocket.OPEN;
}
/**
* Check if server started successfully
*/
get isStarted(): boolean {
return this.started;
}
/**
* Call a tool in the browser
*/
async callTool(method: string, params: any): Promise<any> {
if (!this.isConnected) {
throw new Error('GitNexus browser not connected. Open GitNexus and enable MCP toggle.');
}
const id = `req_${++this.requestId}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
const msg: BridgeMessage = { id, method, params };
this.client!.send(JSON.stringify(msg));
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
/**
* Close the WebSocket server
*/
close() {
this.wss?.close();
}
}

47
gitnexus-mcp/src/cli.ts Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env node
/**
* GitNexus MCP CLI
*
* Bridge between external AI agents (Cursor, Claude Code, Windsurf)
* and GitNexus code intelligence running in the browser.
*/
import { Command } from 'commander';
import { setupCommand } from './commands/setup.js';
import { serveCommand } from './commands/serve.js';
import { daemonCommand } from './commands/daemon.js';
import { generateGuidanceCommand } from './commands/generate-guidance.js';
const program = new Command();
program
.name('gitnexus-mcp')
.description('MCP bridge for GitNexus code intelligence')
.version('0.1.0');
program
.command('setup')
.description('Detect AI tools and configure MCP settings')
.action(setupCommand);
program
.command('daemon')
.description('Start the MCP daemon (run this once in background)')
.option('-p, --port <port>', 'WebSocket port', '54319')
.action(daemonCommand);
program
.command('serve')
.description('Start MCP server for an AI tool (connects to daemon)')
.option('-p, --port <port>', 'Daemon port to connect to', '54319')
.action(serveCommand);
program
.command('generate-guidance')
.description('Generate AI assistant guidance files (.cursorrules, AGENTS.md)')
.option('-o, --output <dir>', 'Output directory', '.')
.action(generateGuidanceCommand);
program.parse();

View file

@ -0,0 +1,194 @@
/**
* Daemon Command
*
* Runs a persistent daemon that acts as the central hub for:
* - Browser connections (GitNexus web app)
* - MCP server connections (from AI tools like Cursor, Antigravity)
*
* This allows multiple AI tools to share the same browser bridge.
* Also stores codebase context sent by the browser for MCP resource exposure.
*/
import { WebSocketServer, WebSocket } from 'ws';
interface BridgeMessage {
id: string;
method?: string;
params?: any;
result?: any;
error?: { message: string };
source?: 'browser' | 'mcp';
type?: 'context' | 'tool_call' | 'tool_result';
}
/**
* Codebase context sent from browser
*/
interface CodebaseContext {
projectName: string;
stats: {
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
};
hotspots: Array<{
name: string;
type: string;
filePath: string;
connections: number;
}>;
folderTree: string;
}
interface DaemonOptions {
port: string;
}
// Module-level state for context (so MCP servers can access it)
let currentContext: CodebaseContext | null = null;
export function getCodebaseContext(): CodebaseContext | null {
return currentContext;
}
export async function daemonCommand(options: DaemonOptions) {
const port = parseInt(options.port, 10);
let browserClient: WebSocket | null = null;
const mcpClients: Set<WebSocket> = new Set();
const pendingRequests: Map<string, WebSocket> = new Map(); // request ID → which MCP client sent it
const wss = new WebSocketServer({ port });
console.log(`🔌 GitNexus MCP Daemon running on port ${port}`);
console.log(' Waiting for connections...\n');
wss.on('connection', (ws, req) => {
// Determine client type from URL path
const url = new URL(req.url || '/', `http://localhost:${port}`);
const clientType = url.pathname === '/mcp' ? 'mcp' : 'browser';
if (clientType === 'browser') {
// Browser connection
if (browserClient) {
browserClient.close();
}
browserClient = ws;
console.log('✅ Browser connected');
ws.on('message', (data) => {
try {
const msg: BridgeMessage = JSON.parse(data.toString());
// Handle context update from browser
if (msg.type === 'context' && msg.params) {
currentContext = msg.params as CodebaseContext;
console.log(`📊 Context received: ${currentContext.projectName} (${currentContext.stats.fileCount} files)`);
// Broadcast context update to all MCP clients
const contextNotification = JSON.stringify({
type: 'context_update',
context: currentContext,
});
mcpClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(contextNotification);
}
});
return;
}
// Response from browser - route back to the MCP client that sent the request
if (msg.id && pendingRequests.has(msg.id)) {
const mcpClient = pendingRequests.get(msg.id)!;
pendingRequests.delete(msg.id);
if (mcpClient.readyState === WebSocket.OPEN) {
mcpClient.send(JSON.stringify(msg));
}
}
} catch (error) {
console.error('Failed to parse browser message:', error);
}
});
ws.on('close', () => {
if (browserClient === ws) {
browserClient = null;
currentContext = null; // Clear context when browser disconnects
console.log('❌ Browser disconnected');
}
});
} else {
// MCP server connection
mcpClients.add(ws);
console.log(`✅ MCP client connected (total: ${mcpClients.size})`);
// Send current context to new MCP client if available
if (currentContext) {
ws.send(JSON.stringify({
type: 'context_update',
context: currentContext,
}));
}
ws.on('message', (data) => {
try {
const msg: BridgeMessage = JSON.parse(data.toString());
// Handle context request
if (msg.method === 'getContext') {
ws.send(JSON.stringify({
id: msg.id,
result: currentContext,
}));
return;
}
// Tool call request from MCP server - forward to browser
if (msg.method && msg.id) {
if (browserClient && browserClient.readyState === WebSocket.OPEN) {
pendingRequests.set(msg.id, ws);
browserClient.send(JSON.stringify(msg));
} else {
// No browser connected
ws.send(JSON.stringify({
id: msg.id,
error: { message: 'GitNexus browser not connected. Open GitNexus and enable MCP toggle.' }
}));
}
}
} catch (error) {
console.error('Failed to parse MCP message:', error);
}
});
ws.on('close', () => {
mcpClients.delete(ws);
console.log(`❌ MCP client disconnected (remaining: ${mcpClients.size})`);
});
}
ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
});
});
wss.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
console.error(`❌ Port ${port} is already in use.`);
console.error(' A daemon may already be running. Check with: lsof -i :54319');
process.exit(1);
}
console.error('Server error:', error);
});
// Keep the daemon running
process.on('SIGINT', () => {
console.log('\n👋 Shutting down daemon...');
wss.close();
process.exit(0);
});
}

View file

@ -0,0 +1,153 @@
/**
* Generate Guidance Command
*
* Creates AI assistant guidance files for a project:
* - .cursor/rules/gitnexus.mdc - Cursor skills file
* - AGENTS.md - Generic tool usage patterns for Claude Code etc.
*/
import { writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import chalk from 'chalk';
interface GenerateOptions {
output: string;
}
// Cursor uses MDC format (Markdown with frontmatter)
const CURSOR_RULES_CONTENT = `---
description: GitNexus MCP code intelligence tools
globs:
alwaysApply: true
---
# GitNexus Code Intelligence
This project uses GitNexus MCP for code understanding. You have access to these tools:
## Available MCP Tools
### search
Semantic search across code. Returns functions, classes, files matching meaning.
- Use for: Finding code by description, not exact text
- Example: "authentication logic", "database connection handling"
### cypher
Direct KuzuDB graph queries for relationship traversal.
- Pattern: \`MATCH (f:Function)-[:CodeRelation {type:'CALLS'}]->(g:Function) RETURN f.name, g.name\`
- Node types: File, Folder, Function, Class, Interface, Method, CodeElement
- Relation types: CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS
### blastRadius
Find all code affected by changing a node. Returns N-hop connections.
- Use for: Before refactoring, understanding impact of changes
### highlight
Highlight nodes in the graph visualization.
### context
Get project overview: stats, hotspots, folder tree.
- Use for: Understanding project structure
### grep
Regex pattern search across file contents.
- Use for: Finding exact patterns, TODOs
### read
Read file content by path. Supports line ranges.
## Best Practices
1. **Start with context** - Understand project structure first
2. **Use search for discovery** - Find by meaning, not keywords
3. **Use cypher for relationships** - Call chains, imports, inheritance
4. **Check blast radius before refactoring** - Understand impact
## Cypher Examples
\`\`\`cypher
# Find functions that call a specific function
MATCH (caller:Function)-[:CodeRelation {type:'CALLS'}]->(target:Function {name: 'authenticate'})
RETURN caller.name, caller.filePath
# Find class inheritance
MATCH (child:Class)-[:CodeRelation {type:'EXTENDS'}]->(parent:Class)
RETURN child.name, parent.name
\`\`\`
`;
const AGENTS_MD_CONTENT = `# GitNexus Agent Guidelines
This document describes how to use GitNexus MCP tools for code understanding.
## Quick Reference
| Tool | Use When | Example |
|------|----------|---------|
| \`search\` | Finding code by meaning | "user authentication flow" |
| \`cypher\` | Traversing relationships | Finding what calls a function |
| \`blastRadius\` | Before refactoring | What depends on this class? |
| \`context\` | Understanding project | Get overview, hotspots |
| \`grep\` | Exact pattern match | Find all TODO comments |
| \`read\` | Reading file content | View actual code |
| \`highlight\` | Visual feedback | Show user what you found |
## Node Types
File, Folder, Function, Class, Interface, Method, CodeElement
## Relation Types
CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS
## Cypher Query Examples
\`\`\`cypher
-- Find functions called by a specific function
MATCH (f:Function {name: 'handleRequest'})-[:CodeRelation {type:'CALLS'}]->(called)
RETURN called.name, called.filePath
-- Find class hierarchy
MATCH (c:Class)-[:CodeRelation {type:'EXTENDS'}*1..3]->(parent)
RETURN c.name, collect(parent.name) as ancestors
\`\`\`
## Workflow
1. Run \`context\` to understand project structure
2. Use \`search\` to find relevant code
3. Use \`blastRadius\` before making changes
4. Use \`read\` to examine actual code
`;
export async function generateGuidanceCommand(options: GenerateOptions): Promise<void> {
const outputDir = options.output || '.';
console.log(chalk.cyan('\n🔧 Generating AI agent guidance files...\n'));
try {
// Create .cursor/rules directory
const cursorRulesDir = join(outputDir, '.cursor', 'rules');
if (!existsSync(cursorRulesDir)) {
await mkdir(cursorRulesDir, { recursive: true });
}
// Write .cursor/rules/gitnexus.mdc
const cursorRulePath = join(cursorRulesDir, 'gitnexus.mdc');
await writeFile(cursorRulePath, CURSOR_RULES_CONTENT, 'utf-8');
console.log(chalk.green('✓'), 'Created', chalk.bold('.cursor/rules/gitnexus.mdc'));
// Write AGENTS.md
const agentsPath = join(outputDir, 'AGENTS.md');
await writeFile(agentsPath, AGENTS_MD_CONTENT, 'utf-8');
console.log(chalk.green('✓'), 'Created', chalk.bold('AGENTS.md'));
console.log(chalk.green('\n✅ Guidance files generated successfully!'));
console.log(chalk.dim('\nThese files help AI assistants understand how to use GitNexus MCP tools.'));
console.log(chalk.dim('Commit them to your repo so all team members benefit.\n'));
} catch (error) {
console.error(chalk.red('\n❌ Error generating guidance files:'), error);
process.exit(1);
}
}

View file

@ -0,0 +1,33 @@
/**
* Serve Command
*
* Starts the MCP server that bridges external AI agents to GitNexus.
* - Listens on stdio for MCP protocol (from AI tools)
* - Connects to daemon via WebSocket (client mode)
*/
import { startMCPServer } from '../mcp/server.js';
import { DaemonClient } from '../bridge/daemon-client.js';
interface ServeOptions {
port: string;
}
export async function serveCommand(options: ServeOptions) {
const port = parseInt(options.port, 10);
// Connect to daemon as a WebSocket client
const client = new DaemonClient(port);
try {
await client.connect();
} catch (error) {
// Daemon not running - provide helpful error
console.error('Failed to connect to GitNexus daemon.');
console.error('Make sure the daemon is running: npx gitnexus-mcp daemon');
process.exit(1);
}
// Start MCP server on stdio (AI tools connect here)
await startMCPServer(client);
}

View file

@ -0,0 +1,55 @@
/**
* Setup Command
*
* Detects installed AI tools (Cursor, Claude Code, Windsurf) and
* configures their MCP settings to use GitNexus.
*/
import chalk from 'chalk';
import ora from 'ora';
import { detectIDEs, type IDE } from '../config/detect-ides.js';
import { injectMCPConfig } from '../config/inject-config.js';
import { getBridgePath } from '../config/paths.js';
export async function setupCommand() {
console.log(chalk.blue('\n🔧 GitNexus MCP Setup\n'));
// Step 1: Get bridge path (this CLI itself is the bridge)
const spinner = ora('Locating bridge...').start();
const bridgePath = getBridgePath();
spinner.succeed(`Bridge located at ${chalk.dim(bridgePath)}`);
// Step 2: Detect AI tools
spinner.start('Detecting AI tools...');
const ides = await detectIDEs();
if (ides.length === 0) {
spinner.warn('No supported AI tools detected');
console.log(chalk.yellow('\nSupported tools: Cursor, Claude Code, Windsurf, VS Code'));
console.log('Install one of these tools to use GitNexus MCP.\n');
return;
}
spinner.succeed(`Found: ${ides.map(i => chalk.cyan(i.name)).join(', ')}`);
// Step 3: Inject MCP config into each IDE
for (const ide of ides) {
spinner.start(`Configuring ${ide.name}...`);
try {
await injectMCPConfig(ide, bridgePath);
spinner.succeed(`Configured ${chalk.cyan(ide.name)}`);
} catch (error) {
spinner.fail(`Failed to configure ${ide.name}: ${error}`);
}
}
// Success message
console.log(chalk.green('\n✅ Setup complete!\n'));
console.log('Next steps:');
console.log(' 1. Open GitNexus in your browser');
console.log(' 2. Load a codebase');
console.log(' 3. Click the MCP toggle to connect');
console.log(' 4. Your AI tools can now use GitNexus!\n');
console.log(chalk.dim('Available tools: search, cypher, blastRadius, highlight\n'));
}

View file

@ -0,0 +1,103 @@
/**
* IDE Detection
*
* Detects installed AI coding tools by checking for their config directories.
* Supports Windows, macOS, and Linux paths.
*/
import { existsSync } from 'fs';
import { homedir, platform } from 'os';
import { join } from 'path';
export interface IDE {
name: string;
configPath: string;
}
interface IDECheck {
name: string;
// Paths relative to home directory (first found is used)
paths: {
win32: string[];
darwin: string[];
linux: string[];
};
}
const IDE_CHECKS: IDECheck[] = [
{
name: 'Cursor',
paths: {
win32: ['AppData/Roaming/Cursor/User/globalStorage/mcp.json', '.cursor/mcp.json'],
darwin: ['.cursor/mcp.json', 'Library/Application Support/Cursor/User/globalStorage/mcp.json'],
linux: ['.cursor/mcp.json', '.config/Cursor/User/globalStorage/mcp.json'],
},
},
{
name: 'Claude Code',
paths: {
win32: ['.claude/mcp.json', 'AppData/Roaming/Claude/mcp.json'],
darwin: ['.claude/mcp.json', 'Library/Application Support/Claude/mcp.json'],
linux: ['.claude/mcp.json', '.config/Claude/mcp.json'],
},
},
{
name: 'Windsurf',
paths: {
win32: ['.windsurf/mcp.json', '.codeium/windsurf/mcp.json'],
darwin: ['.windsurf/mcp.json', '.codeium/windsurf/mcp.json'],
linux: ['.windsurf/mcp.json', '.codeium/windsurf/mcp.json'],
},
},
{
name: 'VS Code',
paths: {
win32: ['AppData/Roaming/Code/User/globalStorage/mcp.json'],
darwin: ['Library/Application Support/Code/User/globalStorage/mcp.json'],
linux: ['.config/Code/User/globalStorage/mcp.json'],
},
},
{
name: 'Antigravity',
paths: {
win32: ['.gemini/antigravity/mcp_config.json'],
darwin: ['.gemini/antigravity/mcp_config.json'],
linux: ['.gemini/antigravity/mcp_config.json'],
},
},
{
name: 'OpenCode',
paths: {
win32: ['.config/opencode/opencode.json', 'opencode.json'],
darwin: ['.config/opencode/opencode.json', 'opencode.json'],
linux: ['.config/opencode/opencode.json', 'opencode.json'],
},
},
];
export async function detectIDEs(): Promise<IDE[]> {
const home = homedir();
const os = platform() as 'win32' | 'darwin' | 'linux';
const ides: IDE[] = [];
for (const check of IDE_CHECKS) {
const paths = check.paths[os] || check.paths.linux;
for (const relPath of paths) {
const configPath = join(home, relPath);
// Check if the parent directory exists (IDE is installed)
const dirPath = configPath.replace(/[/\\]mcp\.json$/, '');
const parentDir = dirPath.split(/[/\\]/).slice(0, -1).join('/');
// Look for IDE installation markers
const ideDirExists = existsSync(dirPath) || existsSync(parentDir);
if (ideDirExists) {
ides.push({ name: check.name, configPath });
break; // Found this IDE, move to next
}
}
}
return ides;
}

View file

@ -0,0 +1,97 @@
/**
* MCP Config Injection
*
* Adds GitNexus MCP server to an IDE's configuration.
* Handles different config formats for different tools.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
import { dirname } from 'path';
import { type IDE } from './detect-ides.js';
interface MCPServerEntry {
command: string;
args?: string[];
env?: Record<string, string>;
}
interface StandardMCPConfig {
mcpServers?: Record<string, MCPServerEntry>;
}
// OpenCode uses a different format with 'mcp' key
interface OpenCodeConfig {
mcp?: Record<string, {
type?: string;
command: string;
args?: string[];
}>;
[key: string]: any;
}
export async function injectMCPConfig(ide: IDE, bridgePath: string): Promise<void> {
// Create directory structure if needed
if (!existsSync(ide.configPath)) {
const dir = dirname(ide.configPath);
mkdirSync(dir, { recursive: true });
}
// Handle OpenCode's different format
if (ide.name === 'OpenCode') {
await injectOpenCodeConfig(ide.configPath, bridgePath);
return;
}
// Standard MCP format (Cursor, Claude Code, Windsurf, VS Code, Antigravity)
let config: StandardMCPConfig = { mcpServers: {} };
// Read existing config if present
if (existsSync(ide.configPath)) {
try {
const content = readFileSync(ide.configPath, 'utf-8');
config = JSON.parse(content);
} catch {
// Ignore parse errors, use empty config
}
}
// Ensure mcpServers object exists
config.mcpServers = config.mcpServers || {};
// Add GitNexus MCP server configuration
config.mcpServers.gitnexus = {
command: bridgePath,
args: ['serve'],
};
// Write updated config
writeFileSync(ide.configPath, JSON.stringify(config, null, 2));
}
async function injectOpenCodeConfig(configPath: string, bridgePath: string): Promise<void> {
let config: OpenCodeConfig = {};
// Read existing config if present
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, 'utf-8');
config = JSON.parse(content);
} catch {
// Ignore parse errors
}
}
// Ensure mcp object exists
config.mcp = config.mcp || {};
// Add GitNexus MCP server (OpenCode format)
config.mcp.gitnexus = {
type: 'local',
command: bridgePath,
args: ['serve'],
};
// Write updated config
writeFileSync(configPath, JSON.stringify(config, null, 2));
}

View file

@ -0,0 +1,37 @@
/**
* Path Utilities
*
* Resolves paths for the MCP bridge executable.
*/
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
/**
* Get the path to this CLI executable.
* When installed via npm, this will be in node_modules/.bin/
* The MCP config needs to point to the actual executable.
*/
export function getBridgePath(): string {
// When running as ES module, we need to resolve from import.meta.url
// But for the installed package, we want the bin path
// Get the path to this module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Go up from dist/config/ to the package root, then to the bin
const packageRoot = resolve(__dirname, '..', '..');
const binPath = resolve(packageRoot, 'dist', 'cli.js');
// Return as node command since it's a JS file
return `node ${binPath}`;
}
/**
* Get user's GitNexus config directory
*/
export function getGitNexusConfigDir(): string {
const home = process.env.HOME || process.env.USERPROFILE || '';
return resolve(home, '.gitnexus');
}

View file

@ -0,0 +1,220 @@
/**
* MCP Server
*
* Model Context Protocol server that runs on stdio.
* External AI tools (Cursor, Claude Code) spawn this process and
* communicate via stdin/stdout using the MCP protocol.
*
* Exposes:
* - Tools: search, cypher, blastRadius, highlight
* - Resources: codebase context (stats, hotspots, folder tree)
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { GITNEXUS_TOOLS } from './tools.js';
import type { DaemonClient, CodebaseContext } from '../bridge/daemon-client.js';
// Interface for anything that can call tools (DaemonClient or WebSocketBridge)
interface ToolCaller {
callTool(method: string, params: any): Promise<any>;
disconnect?(): void;
context?: CodebaseContext | null;
onContextChange?: (listener: (context: CodebaseContext | null) => void) => () => void;
}
/**
* Format context as markdown for the resource
*/
function formatContextAsMarkdown(context: CodebaseContext): string {
const { projectName, stats, hotspots, folderTree } = context;
const lines: string[] = [];
lines.push(`# GitNexus: ${projectName}`);
lines.push('');
lines.push('This codebase is currently loaded in GitNexus. Use the tools below to explore it.');
lines.push('');
// Stats
lines.push('## 📊 Statistics');
lines.push(`- **Files**: ${stats.fileCount}`);
lines.push(`- **Functions**: ${stats.functionCount}`);
if (stats.classCount > 0) lines.push(`- **Classes**: ${stats.classCount}`);
if (stats.interfaceCount > 0) lines.push(`- **Interfaces**: ${stats.interfaceCount}`);
if (stats.methodCount > 0) lines.push(`- **Methods**: ${stats.methodCount}`);
lines.push('');
// Hotspots
if (hotspots.length > 0) {
lines.push('## 🔥 Hotspots (Most Connected Nodes)');
lines.push('');
hotspots.forEach(h => {
lines.push(`- \`${h.name}\` (${h.type}) — ${h.connections} connections — ${h.filePath}`);
});
lines.push('');
}
// Folder tree
if (folderTree) {
lines.push('## 📁 Project Structure');
lines.push('```');
lines.push(projectName + '/');
lines.push(folderTree);
lines.push('```');
lines.push('');
}
// Usage hints
lines.push('## 🛠️ Available Tools');
lines.push('');
lines.push('- **search**: Semantic search across the codebase');
lines.push('- **cypher**: Execute Cypher queries on the knowledge graph');
lines.push('- **blastRadius**: Analyze impact of changes to a node');
lines.push('- **highlight**: Visualize nodes in the graph');
lines.push('');
lines.push('## 📝 Graph Schema');
lines.push('');
lines.push('**Node Types**: File, Folder, Function, Class, Interface, Method');
lines.push('');
lines.push('**Relation**: `CodeRelation` with `type` property:');
lines.push('- CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS');
lines.push('');
lines.push('**Example Cypher Queries**:');
lines.push('```cypher');
lines.push('MATCH (f:Function) RETURN f.name LIMIT 10');
lines.push("MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f.name, g.name");
lines.push('```');
return lines.join('\n');
}
export async function startMCPServer(client: ToolCaller): Promise<void> {
const server = new Server(
{
name: 'gitnexus',
version: '0.1.0',
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// Handle list resources request
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const context = client.context;
if (!context) {
return { resources: [] };
}
return {
resources: [
{
uri: 'gitnexus://codebase/context',
name: `GitNexus: ${context.projectName}`,
description: `Codebase context for ${context.projectName} (${context.stats.fileCount} files, ${context.stats.functionCount} functions)`,
mimeType: 'text/markdown',
},
],
};
});
// Handle read resource request
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === 'gitnexus://codebase/context') {
const context = client.context;
if (!context) {
return {
contents: [
{
uri,
mimeType: 'text/plain',
text: 'No codebase loaded. Open GitNexus in your browser and load a repository.',
},
],
};
}
return {
contents: [
{
uri,
mimeType: 'text/markdown',
text: formatContextAsMarkdown(context),
},
],
};
}
throw new Error(`Unknown resource: ${uri}`);
});
// Handle list tools request
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: GITNEXUS_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// Forward the tool call to the browser via daemon
const result = await client.callTool(name, args);
return {
content: [
{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return {
content: [
{
type: 'text',
text: `Error: ${message}`,
},
],
isError: true,
};
}
});
// Connect to stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);
// Handle graceful shutdown
process.on('SIGINT', async () => {
client.disconnect?.();
await server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
client.disconnect?.();
await server.close();
process.exit(0);
});
}

View file

@ -0,0 +1,174 @@
/**
* MCP Tool Definitions
*
* Defines the tools that GitNexus exposes to external AI agents.
* Each tool has a rich description with examples to help agents use them correctly.
*/
export interface ToolDefinition {
name: string;
description: string;
inputSchema: {
type: 'object';
properties: Record<string, {
type: string;
description?: string;
default?: any;
items?: { type: string };
}>;
required: string[];
};
}
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'context',
description: `Get GitNexus codebase context. CALL THIS FIRST before using other tools.
Returns:
- Project name and stats (files, functions, classes)
- Hotspots (most connected/important nodes)
- Directory structure (TOON format for token efficiency)
- Tool usage guidance
ALWAYS call this first to understand the codebase before searching or querying.`,
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'search',
description: `Hybrid search (keyword + semantic) across the codebase.
Returns code nodes with their graph connections.
WHEN TO USE:
- Finding implementations ("where is auth handled?")
- Understanding code flow ("what calls UserService?")
- Locating patterns ("find all API endpoints")
RETURNS: Array of {name, type, filePath, code, connections[]}`,
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Natural language or keyword search query' },
limit: { type: 'number', description: 'Max results to return', default: 10 },
},
required: ['query'],
},
},
{
name: 'cypher',
description: `Execute Cypher query against the code knowledge graph.
SCHEMA:
- Nodes: File, Function, Class, Interface, Method
- Edges: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, CONTAINS
EXAMPLES:
• Find callers of a function:
MATCH (a)-[:CALLS]->(b:Function {name: "validateUser"}) RETURN a.name, a.filePath
• Find class hierarchy:
MATCH (c:Class)-[:EXTENDS*]->(base) WHERE c.name = "AdminUser" RETURN base.name
• Impact analysis (what depends on X):
MATCH (target:Function {name: $name})<-[:CALLS*1..3]-(caller) RETURN DISTINCT caller
TIPS:
- Relationship types are UPPERCASE: CALLS, IMPORTS, EXTENDS
- Node labels are PascalCase: Function, Class, Interface
- Properties: name, filePath, code, startLine, endLine`,
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Cypher query to execute' },
},
required: ['query'],
},
},
{
name: 'grep',
description: `Regex search for exact patterns in file contents.
WHEN TO USE:
- Finding exact strings: error codes, TODOs, specific API keys
- Pattern matching: all console.log, all fetch calls
- Finding imports of specific modules
BETTER THAN search for: exact matches, regex patterns, case-sensitive
RETURNS: Array of {filePath, line, lineNumber, match}`,
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Regex pattern to search for' },
caseSensitive: { type: 'boolean', description: 'Case-sensitive search', default: false },
maxResults: { type: 'number', description: 'Max results to return', default: 50 },
},
required: ['pattern'],
},
},
{
name: 'read',
description: `Read file content from the codebase.
WHEN TO USE:
- After search/grep to see full context
- To understand implementation details
- Before making changes
ALWAYS read before concluding - don't guess from names alone.
RETURNS: {filePath, content, language, lines}`,
inputSchema: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Path to file to read' },
startLine: { type: 'number', description: 'Start line (optional)' },
endLine: { type: 'number', description: 'End line (optional)' },
},
required: ['filePath'],
},
},
{
name: 'blastRadius',
description: `Analyze the impact of changing a code element.
Returns all nodes that would be affected by modifying the target.
USE BEFORE making changes to understand ripple effects.
Shows direct and indirect dependencies up to N hops.
RETURNS: {
target: {...},
affected: [{name, type, filePath, distance}...],
totalAffected: number,
riskLevel: "low" | "medium" | "high"
}`,
inputSchema: {
type: 'object',
properties: {
nodeId: { type: 'string', description: 'Node ID or name to analyze' },
hops: { type: 'number', description: 'Max relationship depth (default: 3)', default: 3 },
},
required: ['nodeId'],
},
},
{
name: 'highlight',
description: `Highlight nodes in the GitNexus graph visualization.
Use after search/analysis to show the user what you found.
The user will see the nodes glow in the graph view.
Great for visual confirmation of your findings.`,
inputSchema: {
type: 'object',
properties: {
nodeIds: { type: 'array', items: { type: 'string' }, description: 'Array of node IDs to highlight' },
color: { type: 'string', description: 'Highlight color (optional, default: cyan)' },
},
required: ['nodeIds'],
},
},
];

View file

@ -0,0 +1,69 @@
# GitNexus Code Intelligence
This project uses GitNexus MCP for code understanding. When working with this codebase, you have access to powerful code intelligence tools.
## Available MCP Tools
### search
Semantic search across code. Returns functions, classes, files matching meaning.
- Use for: Finding code by description, not exact text
- Example queries: "authentication logic", "database connection handling", "error middleware"
### cypher
Direct KuzuDB graph queries for precise relationship traversal.
- Use for: Finding call chains, imports, inheritance
- Pattern: `MATCH (f:Function)-[:CodeRelation {type:'CALLS'}]->(g:Function) RETURN f.name, g.name`
- Node types: File, Folder, Function, Class, Interface, Method, CodeElement
- Relation types: CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS
### blastRadius
Find all code affected by changing a node. Returns N-hop connections.
- Use for: Before refactoring, understanding impact of changes
- Returns: List of connected nodes with their types
### highlight
Highlight nodes in the graph visualization.
- Use for: Showing the user what you're looking at
### context
Get project overview: stats, hotspots, folder tree.
- Use for: Understanding project structure, finding important files
- Returns: File counts, hotspots (most connected files), directory tree
### grep
Regex pattern search across file contents.
- Use for: Finding exact patterns, TODOs, specific strings
- Faster than search for literal text
### read
Read file content by path. Supports line ranges.
- Use for: Reading actual code after finding via search/grep
## Best Practices
1. **Start with context** - Run `context` first to understand project structure
2. **Use search for discovery** - Find relevant code by meaning, not keywords
3. **Use cypher for relationships** - When you need call chains, imports, inheritance
4. **Check blast radius before refactoring** - Understand impact before making changes
5. **Read to confirm** - After finding code via search, read it to see full context
## Cypher Query Examples
```cypher
# Find all functions that call a specific function
MATCH (caller:Function)-[:CodeRelation {type:'CALLS'}]->(target:Function {name: 'authenticate'})
RETURN caller.name, caller.filePath
# Find class inheritance chain
MATCH (child:Class)-[:CodeRelation {type:'EXTENDS'}]->(parent:Class)
RETURN child.name, parent.name
# Find all imports in a file
MATCH (f:File {name: 'api.ts'})-[:CodeRelation {type:'IMPORTS'}]->(imported:File)
RETURN imported.name, imported.filePath
# Find hotspots (most connected nodes)
MATCH (n)-[r:CodeRelation]-()
RETURN n.name, labels(n), count(r) as connections
ORDER BY connections DESC LIMIT 10
```

View file

@ -0,0 +1,98 @@
# GitNexus Agent Guidelines
This document describes how to use GitNexus MCP tools effectively for code understanding.
## Quick Reference
| Tool | Use When | Example |
|------|----------|---------|
| `search` | Finding code by meaning | "user authentication flow" |
| `cypher` | Traversing relationships | Finding what calls a function |
| `blastRadius` | Before refactoring | What depends on this class? |
| `context` | Understanding project | Get overview, hotspots |
| `grep` | Exact pattern match | Find all TODO comments |
| `read` | Reading file content | View actual code |
| `highlight` | Visual feedback | Show user what you found |
## Tool Details
### search(query, limit?)
Semantic search using embeddings. Understands meaning, not just keywords.
**Good for:**
- "error handling middleware"
- "database connection pooling"
- "form validation logic"
**Returns:** Array of matching nodes with id, name, type, filePath, score
---
### cypher(query)
Direct graph queries using Cypher syntax. Use for relationship traversal.
**Node Tables:** File, Folder, Function, Class, Interface, Method, CodeElement
**Relation Types:** CONTAINS, DEFINES, IMPORTS, CALLS, EXTENDS, IMPLEMENTS
**Example Queries:**
```cypher
-- Find functions called by a specific function
MATCH (f:Function {name: 'handleRequest'})-[:CodeRelation {type:'CALLS'}]->(called)
RETURN called.name, called.filePath
-- Find class hierarchy
MATCH (c:Class)-[:CodeRelation {type:'EXTENDS'}*1..3]->(parent)
RETURN c.name, collect(parent.name) as ancestors
```
---
### blastRadius(nodeId, hops?)
Find all nodes within N hops of a starting node.
**Use before:** Renaming, refactoring, deleting code
---
### context()
Get project overview including:
- File/function/class counts
- Hotspots (most connected nodes)
- Folder tree in TOON format
**Use first** when starting work on unfamiliar project.
---
### grep(pattern, caseSensitive?, maxResults?)
Regex search across file contents.
**Faster than search** for exact patterns like:
- `TODO`, `FIXME`, `HACK`
- Specific function calls
- Import statements
---
### read(filePath, startLine?, endLine?)
Read file content. Use after finding files via search/grep.
---
## Workflow Recommendations
### New to a Project
1. Run `context` to see structure and hotspots
2. Use `search` to find relevant areas
3. Use `read` to examine actual code
### Before Refactoring
1. Find the node: `search("function to refactor")`
2. Check impact: `blastRadius(nodeId, 2)`
3. Review callers via `cypher`
### Debugging
1. Use `grep` to find error patterns
2. Use `cypher` to trace call chain to error
3. Use `read` to examine suspicious code

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

View file

@ -0,0 +1,202 @@
/**
* Activity Feed Component
*
* Shows real-time log of external AI agent tool calls.
* Used in RightPanel as an alternative to the Chat tab.
*/
import { useState, useEffect, useRef } from 'react';
import { Activity, Search, Database, Terminal, Eye, Loader2, CheckCircle, XCircle, Clock, FileText, Zap } from 'lucide-react';
import { getMCPClient, type ActivityEvent } from '../core/mcp/mcp-client';
// Tool icons
const TOOL_ICONS: Record<string, typeof Search> = {
context: Zap,
search: Search,
cypher: Database,
grep: Terminal,
read: FileText,
blastRadius: Activity,
highlight: Eye,
};
// Tool colors
const TOOL_COLORS: Record<string, string> = {
context: 'text-amber-400',
search: 'text-cyan-400',
cypher: 'text-purple-400',
grep: 'text-green-400',
read: 'text-blue-400',
blastRadius: 'text-rose-400',
highlight: 'text-teal-400',
};
export function ActivityFeed() {
const [events, setEvents] = useState<ActivityEvent[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const client = getMCPClient();
// Subscribe to activity events
const unsubscribe = client.onActivity((event) => {
setEvents(prev => {
// Keep max 100 events
const next = [...prev, event];
if (next.length > 100) {
next.shift();
}
return next;
});
});
// Get existing events
setEvents(client.getActivityLog());
return () => {
unsubscribe();
};
}, []);
// Auto-scroll to bottom
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [events]);
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatParams = (params: any): string => {
if (!params) return '';
// Show first key-value pairs, truncated
const entries = Object.entries(params).slice(0, 2);
return entries.map(([k, v]) => {
const val = typeof v === 'string' ? v.slice(0, 30) : JSON.stringify(v).slice(0, 30);
return `${k}: ${val}${val.length >= 30 ? '...' : ''}`;
}).join(', ');
};
const formatResult = (event: ActivityEvent): string => {
if (event.status === 'running') return 'Running...';
if (event.status === 'error') return `Error: ${event.error?.slice(0, 50) || 'Unknown'}`;
// Format result based on type
if (Array.isArray(event.result)) {
return `${event.result.length} results`;
}
if (typeof event.result === 'object' && event.result) {
const keys = Object.keys(event.result);
if (keys.includes('content')) return `${event.result.content?.length || 0} chars`;
if (keys.includes('projectName')) return event.result.projectName;
return `{${keys.slice(0, 3).join(', ')}${keys.length > 3 ? '...' : ''}}`;
}
return String(event.result || 'Done');
};
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-center px-8">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-class rounded-xl shadow-glow text-2xl">
📡
</div>
<h3 className="text-base font-medium mb-2">
No Agent Activity
</h3>
<p className="text-sm text-text-secondary leading-relaxed">
When external AI agents (Cursor, Claude Code) call GitNexus tools,
their activity will appear here in real-time.
</p>
<p className="text-xs text-text-muted mt-4">
Make sure MCP toggle is enabled in the header
</p>
</div>
);
}
return (
<div ref={containerRef} className="flex-1 overflow-y-auto p-4 scrollbar-thin">
<div className="space-y-3">
{events.map((event) => {
const Icon = TOOL_ICONS[event.tool] || Activity;
const color = TOOL_COLORS[event.tool] || 'text-text-muted';
return (
<div
key={event.id}
className="bg-elevated border border-border-subtle rounded-lg p-3 animate-fade-in"
>
{/* Header row */}
<div className="flex items-center gap-2 mb-2">
{/* Agent color indicator */}
{event.agentColor && (
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event.agentColor }}
title={event.agentName || 'Unknown agent'}
/>
)}
<Icon className={`w-4 h-4 ${color}`} />
<span className="font-medium text-sm">{event.tool}</span>
{event.agentName && event.agentName !== 'Unknown' && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${event.agentColor}20`,
color: event.agentColor
}}
>
{event.agentName}
</span>
)}
<span className="text-xs text-text-muted ml-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(event.timestamp)}
</span>
</div>
{/* Params preview */}
{event.params && Object.keys(event.params).length > 0 && (
<div className="text-xs text-text-secondary font-mono bg-deep rounded px-2 py-1 mb-2 truncate">
{formatParams(event.params)}
</div>
)}
{/* Status/Result */}
<div className="flex items-center gap-2 text-xs">
{event.status === 'running' && (
<>
<Loader2 className="w-3 h-3 animate-spin text-accent" />
<span className="text-accent">Running...</span>
</>
)}
{event.status === 'complete' && (
<>
<CheckCircle className="w-3 h-3 text-green-400" />
<span className="text-text-secondary">{formatResult(event)}</span>
{event.duration && (
<span className="text-text-muted ml-auto">{event.duration}ms</span>
)}
</>
)}
{event.status === 'error' && (
<>
<XCircle className="w-3 h-3 text-rose-400" />
<span className="text-rose-300">{formatResult(event)}</span>
</>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,8 +1,8 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import {
ChevronRight,
ChevronDown,
Folder,
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
FileCode,
Search,
@ -16,7 +16,7 @@ import {
Target,
} from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { FILTERABLE_LABELS, NODE_COLORS } from '../lib/constants';
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants';
import { GraphNode, NodeLabel } from '../core/graph/types';
// Tree node structure
@ -36,7 +36,7 @@ const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
// Filter to only folders and files
const fileNodes = nodes.filter(n => n.label === 'Folder' || n.label === 'File');
// Sort by path to ensure parents come before children
fileNodes.sort((a, b) => a.properties.filePath.localeCompare(b.properties.filePath));
@ -47,13 +47,13 @@ const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
let existing = pathMap.get(currentPath);
if (!existing) {
const isLastPart = index === parts.length - 1;
const isFile = isLastPart && node.label === 'File';
existing = {
id: isLastPart ? node.id : currentPath,
name: part,
@ -62,11 +62,11 @@ const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
children: [],
graphNode: isLastPart ? node : undefined,
};
pathMap.set(currentPath, existing);
currentLevel.push(existing);
}
currentLevel = existing.children;
});
});
@ -85,23 +85,23 @@ interface TreeItemProps {
selectedPath: string | null;
}
const TreeItem = ({
node,
depth,
searchQuery,
onNodeClick,
expandedPaths,
const TreeItem = ({
node,
depth,
searchQuery,
onNodeClick,
expandedPaths,
toggleExpanded,
selectedPath,
}: TreeItemProps) => {
const isExpanded = expandedPaths.has(node.path);
const isSelected = selectedPath === node.path;
const hasChildren = node.children.length > 0;
// Filter children based on search
const filteredChildren = useMemo(() => {
if (!searchQuery) return node.children;
return node.children.filter(child =>
return node.children.filter(child =>
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
child.children.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
@ -139,7 +139,7 @@ const TreeItem = ({
) : (
<span className="w-3.5" />
)}
{/* Node icon */}
{node.type === 'folder' ? (
isExpanded ? (
@ -150,11 +150,11 @@ const TreeItem = ({
) : (
<FileCode className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.File }} />
)}
{/* Name */}
<span className="truncate font-mono text-xs">{node.name}</span>
</button>
{/* Children */}
{isExpanded && filteredChildren.length > 0 && (
<div>
@ -195,8 +195,8 @@ interface FileTreePanelProps {
}
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
const { graph, visibleLabels, toggleLabelVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState();
const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState();
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
@ -220,18 +220,18 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
useEffect(() => {
const path = selectedNode?.properties?.filePath;
if (!path) return;
// Expand all parent folders leading to this file
const parts = path.split('/').filter(Boolean);
const pathsToExpand: string[] = [];
let currentPath = '';
// Build all parent paths (exclude the last part if it's a file)
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
pathsToExpand.push(currentPath);
}
if (pathsToExpand.length > 0) {
setExpandedPaths(prev => {
const next = new Set(prev);
@ -303,21 +303,19 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<div className="flex items-center gap-1">
<button
onClick={() => setActiveTab('files')}
className={`px-2 py-1 text-xs rounded transition-colors ${
activeTab === 'files'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'files'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
>
Explorer
</button>
<button
onClick={() => setActiveTab('filters')}
className={`px-2 py-1 text-xs rounded transition-colors ${
activeTab === 'filters'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'filters'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
>
Filters
</button>
@ -381,32 +379,32 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
Toggle visibility of node types in the graph
</p>
</div>
<div className="flex flex-col gap-1">
{FILTERABLE_LABELS.map((label) => {
const Icon = getNodeTypeIcon(label);
const isVisible = visibleLabels.includes(label);
return (
<button
key={label}
onClick={() => toggleLabelVisibility(label)}
className={`
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
${isVisible
? 'bg-elevated text-text-primary'
${isVisible
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
}
`}
>
<div
<div
className={`w-5 h-5 rounded flex items-center justify-center ${isVisible ? '' : 'opacity-40'}`}
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
>
<Icon className="w-3 h-3" style={{ color: NODE_COLORS[label] }} />
</div>
<span className="text-xs flex-1">{label}</span>
<div
<div
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
/>
</button>
@ -414,6 +412,46 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
})}
</div>
{/* Edge Type Toggles */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
Edge Types
</h3>
<p className="text-[11px] text-text-muted mb-3">
Toggle visibility of relationship types
</p>
<div className="flex flex-col gap-1">
{ALL_EDGE_TYPES.map((edgeType) => {
const info = EDGE_INFO[edgeType];
const isVisible = visibleEdgeTypes.includes(edgeType);
return (
<button
key={edgeType}
onClick={() => toggleEdgeVisibility(edgeType)}
className={`
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
${isVisible
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
}
`}
>
<div
className={`w-6 h-1.5 rounded-full ${isVisible ? '' : 'opacity-40'}`}
style={{ backgroundColor: info.color }}
/>
<span className="text-xs flex-1">{info.label}</span>
<div
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
/>
</button>
);
})}
</div>
</div>
{/* Depth Filter */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
@ -423,7 +461,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<p className="text-[11px] text-text-muted mb-3">
Show nodes within N hops of selection
</p>
<div className="flex flex-wrap gap-1.5">
{[
{ value: null, label: 'All' },
@ -447,7 +485,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</button>
))}
</div>
{depthFilter !== null && !selectedNode && (
<p className="mt-2 text-[10px] text-amber-400">
Select a node to apply depth filter
@ -463,7 +501,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<div className="grid grid-cols-2 gap-2">
{(['Folder', 'File', 'Class', 'Function', 'Interface', 'Method'] as NodeLabel[]).map(label => (
<div key={label} className="flex items-center gap-1.5">
<div
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: NODE_COLORS[label] }}
/>

View file

@ -16,6 +16,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
setSelectedNode,
selectedNode: appSelectedNode,
visibleLabels,
visibleEdgeTypes,
openCodePanel,
depthFilter,
highlightedNodeIds,
@ -24,6 +25,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
blastRadiusNodeIds,
isAIHighlightsEnabled,
toggleAIHighlights,
animatedNodes,
} = useAppState();
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
@ -42,6 +44,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
return blastRadiusNodeIds;
}, [blastRadiusNodeIds, isAIHighlightsEnabled]);
// Animated nodes (only when AI highlights enabled)
const effectiveAnimatedNodes = useMemo(() => {
if (!isAIHighlightsEnabled) return new Map();
return animatedNodes;
}, [animatedNodes, isAIHighlightsEnabled]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graph) return;
const node = graph.nodes.find(n => n.id === nodeId);
@ -85,6 +93,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
onStageClick: handleStageClick,
highlightedNodeIds: effectiveHighlightedNodeIds,
blastRadiusNodeIds: effectiveBlastRadiusNodeIds,
animatedNodes: effectiveAnimatedNodes,
visibleEdgeTypes,
});
// Expose focusNode to parent via ref

View file

@ -4,6 +4,7 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { GraphNode } from '../core/graph/types';
import { EmbeddingStatus } from './EmbeddingStatus';
import { MCPToggle } from './MCPToggle';
import { buildCodebaseContext } from '../core/llm/context-builder';
// Color mapping for node types in search results
const NODE_TYPE_COLORS: Record<string, string> = {
@ -33,6 +34,8 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
runQuery,
semanticSearch,
setHighlightedNodeIds,
fileContents,
triggerNodeAnimation,
} = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [isSearchOpen, setIsSearchOpen] = useState(false);
@ -223,6 +226,11 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
onSearch={async (query, limit = 10) => {
// Use semantic search from the app
const results = await semanticSearch(query, limit);
// Trigger pulse animation on search results
const nodeIds = results.map((r: any) => r.id).filter(Boolean);
if (nodeIds.length > 0) {
triggerNodeAnimation(nodeIds, 'pulse');
}
return results;
}}
onCypher={async (query) => {
@ -238,11 +246,101 @@ export const Header = ({ onFocusNode }: HeaderProps) => {
RETURN DISTINCT connected.id AS id, connected.name AS name, labels(connected) AS labels
`;
const results = await runQuery(query);
// Trigger ripple animation on blast radius results
const nodeIds = results.map((r: any) => r.id).filter(Boolean);
if (nodeIds.length > 0) {
triggerNodeAnimation(nodeIds, 'ripple');
}
return results;
}}
onHighlight={(nodeIds) => {
// Highlight nodes in the graph
setHighlightedNodeIds(new Set(nodeIds));
// Trigger glow animation on highlighted nodes
if (nodeIds.length > 0) {
triggerNodeAnimation(nodeIds, 'glow');
}
}}
getContext={async () => {
// Build codebase context for external AI agents
if (!projectName) return null;
const context = await buildCodebaseContext(runQuery, projectName);
// Reshape to match MCP CodebaseContext format
return {
projectName: context.stats.projectName,
stats: {
fileCount: context.stats.fileCount,
functionCount: context.stats.functionCount,
classCount: context.stats.classCount,
interfaceCount: context.stats.interfaceCount,
methodCount: context.stats.methodCount,
},
hotspots: context.hotspots,
folderTree: context.folderTree,
};
}}
onGrep={async (pattern, caseSensitive = false, maxResults = 50) => {
// Grep across file contents
const results: Array<{ filePath: string; line: string; lineNumber: number; match: string }> = [];
const regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi');
for (const [filePath, content] of fileContents.entries()) {
const lines = content.split('\n');
for (let i = 0; i < lines.length && results.length < maxResults; i++) {
const line = lines[i];
const match = line.match(regex);
if (match) {
results.push({
filePath,
line: line.trim(),
lineNumber: i + 1,
match: match[0],
});
}
}
if (results.length >= maxResults) break;
}
return results;
}}
onRead={async (filePath, startLine, endLine) => {
// Read file content
let content = fileContents.get(filePath);
// Try normalized path if not found
if (!content) {
const normalizedPath = filePath.replace(/\\/g, '/');
for (const [path, c] of fileContents.entries()) {
if (path.endsWith(normalizedPath) || normalizedPath.endsWith(path)) {
content = c;
break;
}
}
}
if (!content) {
return { error: `File not found: ${filePath}` };
}
const lines = content.split('\n');
const language = filePath.split('.').pop() || 'text';
// If line range specified, return only those lines
if (startLine !== undefined && endLine !== undefined) {
const slice = lines.slice(startLine - 1, endLine);
return {
filePath,
content: slice.join('\n'),
language,
lines: slice.length,
};
}
return {
filePath,
content,
language,
lines: lines.length,
};
}}
/>

View file

@ -7,7 +7,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Copy, Check, AlertCircle, X, Sparkles } from 'lucide-react';
import { getMCPClient } from '../core/mcp/mcp-client';
import { getMCPClient, type CodebaseContext } from '../core/mcp/mcp-client';
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
@ -20,8 +20,14 @@ interface MCPToggleProps {
onBlastRadius?: (nodeId: string, hops?: number) => Promise<any>;
/** Handler for highlight tool calls from external agents */
onHighlight?: (nodeIds: string[], color?: string) => void;
/** Handler for grep tool calls from external agents */
onGrep?: (pattern: string, caseSensitive?: boolean, maxResults?: number) => Promise<any>;
/** Handler for read tool calls from external agents */
onRead?: (filePath: string, startLine?: number, endLine?: number) => Promise<any>;
/** Whether to show the onboarding tip */
showOnboardingTip?: boolean;
/** Callback to get codebase context for external agents */
getContext?: () => Promise<CodebaseContext | null>;
}
const MCP_TIP_DISMISSED_KEY = 'gitnexus-mcp-tip-dismissed';
@ -31,7 +37,10 @@ export function MCPToggle({
onCypher,
onBlastRadius,
onHighlight,
onGrep,
onRead,
showOnboardingTip = false,
getContext,
}: MCPToggleProps = {}) {
const [status, setStatus] = useState<ConnectionState>('disconnected');
const [copied, setCopied] = useState(false);
@ -92,14 +101,48 @@ export function MCPToggle({
});
}
// Register context tool handler
if (getContext) {
client.registerHandler('context', async () => {
const context = await getContext();
return context;
});
}
// Register grep tool handler
if (onGrep) {
client.registerHandler('grep', async (params) => {
return await onGrep(params.pattern, params.caseSensitive, params.maxResults);
});
}
// Register read tool handler
if (onRead) {
client.registerHandler('read', async (params) => {
return await onRead(params.filePath, params.startLine, params.endLine);
});
}
setStatus('connected');
setShowSetup(false);
localStorage.setItem(MCP_TIP_DISMISSED_KEY, 'true');
// Send codebase context after connecting
if (getContext) {
try {
const context = await getContext();
if (context) {
client.sendContext(context);
}
} catch (e) {
console.error('[MCP] Failed to send context:', e);
}
}
} catch {
setStatus('error');
setShowSetup(true);
}
}, [onSearch, onCypher, onBlastRadius, onHighlight]);
}, [onSearch, onCypher, onBlastRadius, onHighlight, onGrep, onRead, getContext]);
const disconnect = useCallback(() => {
const client = getMCPClient();

View file

@ -0,0 +1,172 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { MermaidDiagram } from './MermaidDiagram';
import { ToolCallCard } from './ToolCallCard';
// Custom syntax theme
const customTheme = {
...vscDarkPlus,
'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10',
margin: 0,
padding: '16px 0',
fontSize: '13px',
lineHeight: '1.6',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
};
interface MarkdownRendererProps {
content: string;
onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now
}
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onLinkClick,
toolCalls
}) => {
// Helper to format text for display (convert [[links]] to markdown links)
const formatMarkdownForDisplay = (md: string) => {
// Avoid rewriting inside fenced code blocks.
const parts = md.split('```');
for (let i = 0; i < parts.length; i += 2) {
// Pattern 1: File grounding - [[file.ext]]
parts[i] = parts[i].replace(
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-–]\d+)?)?)\]\]/g,
(_m, inner: string) => {
const trimmed = inner.trim();
const href = `code-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
// Pattern 2: Node grounding - [[Type:Name]]
parts[i] = parts[i].replace(
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
}
return parts.join('```');
};
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
e.preventDefault();
onLinkClick?.(href);
}
// External links open in new tab (default behavior)
};
const formattedContent = formatMarkdownForDisplay(content);
return (
<div className="text-text-primary text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...props }) => {
const hrefStr = href || '';
// Grounding links (Code refs & Node refs)
if (hrefStr.startsWith('code-ref:') || hrefStr.startsWith('node-ref:')) {
const isNodeRef = hrefStr.startsWith('node-ref:');
const inner = decodeURIComponent(hrefStr.slice(isNodeRef ? 9 : 9)); // length is same? wait.. code-ref: (9), node-ref: (9). Yes.
// Styles
const baseParams = "code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md font-mono text-[12px] !no-underline hover:!no-underline transition-colors";
const colorParams = isNodeRef
? "border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 hover:bg-amber-400/15 hover:border-amber-200/70"
: "border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 hover:bg-cyan-400/15 hover:border-cyan-200/70";
return (
<a
href={hrefStr}
onClick={(e) => handleLinkClick(e, hrefStr)}
className={`${baseParams} ${colorParams}`}
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{children}</span>
</a>
);
}
// External links
return (
<a
href={hrefStr}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
// Render Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram code={codeContent} />;
}
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }) => <>{children}</>,
}}
>
{formattedContent}
</ReactMarkdown>
{/* Tool Call Cards appended at the bottom if provided */}
{toolCalls && toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{toolCalls.map(tc => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,435 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle, Activity
} from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { ToolCallCard } from './ToolCallCard';
import { isProviderConfigured } from '../core/llm/settings-service';
import { ActivityFeed } from './ActivityFeed';
import { MarkdownRenderer } from './MarkdownRenderer';
export const RightPanel = () => {
const {
isRightPanelOpen,
setRightPanelOpen,
fileContents,
graph,
addCodeReference,
// LLM / chat state
chatMessages,
isChatLoading,
currentToolCalls,
agentError,
isAgentReady,
isAgentInitializing,
sendChatMessage,
clearChat,
} = useAppState();
const [chatInput, setChatInput] = useState('');
const [activeTab, setActiveTab] = useState<'chat' | 'activity'>('chat');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages update or while streaming
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatMessages, isChatLoading]);
const resolveFilePathForUI = useCallback((requestedPath: string): string | null => {
const req = requestedPath.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (!req) return null;
// Exact match first (case-insensitive)
for (const key of fileContents.keys()) {
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (norm === req) return key;
}
// Ends-with match (best for partial paths)
let best: { path: string; score: number } | null = null;
for (const key of fileContents.keys()) {
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (norm.endsWith(req)) {
const score = 1000 - norm.length;
if (!best || score > best.score) best = { path: key, score };
}
}
return best?.path ?? null;
}, [fileContents]);
const findFileNodeIdForUI = useCallback((filePath: string): string | undefined => {
if (!graph) return undefined;
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
const node = graph.nodes.find(
(n) => n.label === 'File' && n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target
);
return node?.id;
}, [graph]);
const handleGroundingClick = useCallback((inner: string) => {
const raw = inner.trim();
if (!raw) return;
let rawPath = raw;
let startLine1: number | undefined;
let endLine1: number | undefined;
// Match line:num or line:num-num (supports both hyphen - and en dash –)
const lineMatch = raw.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
if (lineMatch) {
rawPath = lineMatch[1].trim();
startLine1 = parseInt(lineMatch[2], 10);
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
const resolvedPath = resolveFilePathForUI(rawPath);
if (!resolvedPath) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
addCodeReference({
filePath: resolvedPath,
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
endLine: endLine1 ? Math.max(0, endLine1 - 1) : (startLine1 ? Math.max(0, startLine1 - 1) : undefined),
nodeId,
label: 'File',
name: resolvedPath.split('/').pop() ?? resolvedPath,
source: 'ai',
});
}, [addCodeReference, findFileNodeIdForUI, resolveFilePathForUI]);
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
const handleNodeGroundingClick = useCallback((nodeTypeAndName: string) => {
const raw = nodeTypeAndName.trim();
if (!raw || !graph) return;
// Parse Type:Name format
const match = raw.match(/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/);
if (!match) return;
const [, nodeType, nodeName] = match;
const trimmedName = nodeName.trim();
// Find node in graph by type + name
const node = graph.nodes.find(n =>
n.label === nodeType &&
n.properties.name === trimmedName
);
if (!node) {
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
return;
}
// 1. Highlight in graph (add to AI citation highlights)
// Note: This requires accessing the state setter from parent context
// For now, we'll add to code references which triggers the highlight
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
source: 'ai',
});
}
}
}, [graph, resolveFilePathForUI, addCodeReference]);
const handleLinkClick = useCallback((href: string) => {
if (href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
handleGroundingClick(inner);
} else if (href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
handleNodeGroundingClick(inner);
}
}, [handleGroundingClick, handleNodeGroundingClick]);
// Auto-resize textarea as user types
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to get accurate scrollHeight
textarea.style.height = 'auto';
// Set to scrollHeight, capped at max
const maxHeight = 160; // ~6 lines
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
// Show scrollbar if content exceeds max
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
}, []);
// Adjust height when input changes
useEffect(() => {
adjustTextareaHeight();
}, [chatInput, adjustTextareaHeight]);
// Chat handlers
const handleSendMessage = async () => {
if (!chatInput.trim()) return;
const text = chatInput.trim();
setChatInput('');
// Reset textarea height after sending
if (textareaRef.current) {
textareaRef.current.style.height = '36px';
textareaRef.current.style.overflowY = 'hidden';
}
await sendChatMessage(text);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const chatSuggestions = [
'Explain the project architecture',
'What does this project do?',
'Show me the most important files',
'Find all API handlers',
];
if (!isRightPanelOpen) return null;
return (
<aside className="w-[40%] min-w-[400px] max-w-[600px] flex flex-col bg-deep border-l border-border-subtle animate-slide-in relative z-30 flex-shrink-0">
{/* Header with Tabs */}
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
<div className="flex items-center gap-1">
{/* Chat Tab */}
<button
onClick={() => setActiveTab('chat')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'chat'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
>
<Sparkles className="w-3.5 h-3.5" />
<span>Nexus AI</span>
</button>
{/* Activity Tab */}
<button
onClick={() => setActiveTab('activity')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'activity'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
>
<Activity className="w-3.5 h-3.5" />
<span>Activity</span>
</button>
</div>
{/* Close button */}
<button
onClick={() => setRightPanelOpen(false)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Close Panel"
>
<PanelRightClose className="w-4 h-4" />
</button>
</div>
{/* Activity Feed Tab */}
{activeTab === 'activity' && (
<div className="flex-1 flex flex-col overflow-hidden">
<ActivityFeed />
</div>
)}
{/* Chat Content - only show when chat tab is active */}
{activeTab === 'chat' && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Status bar */}
<div className="flex items-center gap-2.5 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
<div className="ml-auto flex items-center gap-2">
{!isAgentReady && (
<span className="text-[11px] px-2 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30">
Configure AI
</span>
)}
{isAgentInitializing && (
<span className="text-[11px] px-2 py-1 rounded-full bg-surface border border-border-subtle flex items-center gap-1 text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" /> Connecting
</span>
)}
</div>
</div>
{/* Status / errors */}
{agentError && (
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>{agentError}</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
🧠
</div>
<h3 className="text-base font-medium mb-2">
Ask me anything
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-5">
I can help you understand the architecture, find functions, or explain connections.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{chatSuggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-6">
{chatMessages.map((message) => (
<div
key={message.id}
className="animate-fade-in"
>
{/* User message - compact label style */}
{message.role === 'user' && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-text-muted" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">You</span>
</div>
<div className="pl-6 text-sm text-text-primary">
{message.content}
</div>
</div>
)}
{/* Assistant message - copilot style */}
{message.role === 'assistant' && (
<div>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">Nexus AI</span>
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
<Loader2 className="w-3 h-3 animate-spin text-accent" />
)}
</div>
<div className="pl-6 chat-prose">
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
{message.steps && message.steps.length > 0 ? (
<div className="space-y-4">
{message.steps.map((step) => (
<div key={step.id}>
{step.type === 'reasoning' && step.content && (
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
<MarkdownRenderer
content={step.content}
onLinkClick={handleLinkClick}
/>
</div>
)}
{step.type === 'tool_call' && step.toolCall && (
<div className="mb-3">
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
</div>
)}
{step.type === 'content' && step.content && (
<MarkdownRenderer
content={step.content}
onLinkClick={handleLinkClick}
/>
)}
</div>
))}
</div>
) : (
// Fallback: render content + toolCalls separately (old format)
<MarkdownRenderer
content={message.content}
onLinkClick={handleLinkClick}
toolCalls={message.toolCalls}
/>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Scroll anchor for auto-scroll */}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 bg-surface border-t border-border-subtle">
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<textarea
ref={textareaRef}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
rows={1}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button
onClick={clearChat}
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
title="Clear chat"
>
Clear
</button>
<button
onClick={handleSendMessage}
disabled={!chatInput.trim() || isChatLoading || isAgentInitializing}
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
>
{isChatLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
</button>
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5" />
<span>
{isProviderConfigured()
? 'Initializing AI agent...'
: 'Configure an LLM provider to enable chat.'}
</span>
</div>
)}
</div>
</div>
)}
</aside>
);
};

View file

@ -205,7 +205,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPane
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.openai?.model ?? 'gpt-4o'}
value={settings.openai?.model ?? 'gpt-5.2-chat'}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: e.target.value }

View file

@ -145,6 +145,7 @@ export async function getHotspots(
/**
* Build folder tree structure from file paths
* Returns TOON format for token efficiency (~50% reduction vs ASCII tree)
*/
export async function getFolderTree(
executeQuery: (cypher: string) => Promise<any[]>,
@ -162,15 +163,87 @@ export async function getFolderTree(
if (paths.length === 0) return '';
// Build tree structure from paths
const tree = buildTreeFromPaths(paths, maxDepth);
return formatTreeAsAscii(tree, '');
// Use TOON format for token efficiency
return formatPathsAsTOON(paths, maxDepth);
} catch (error) {
console.error('Failed to get folder tree:', error);
return '';
}
}
/**
* Format paths as TOON (Token-Oriented Object Notation)
* Much more token-efficient than ASCII trees
*
* Example output:
* folders:5
* src,src/cli,src/core,src/utils,test
* files:8
* path
* src/index.ts
* src/cli/main.ts
* ...
*/
function formatPathsAsTOON(paths: string[], maxDepth: number): string {
const folders = new Set<string>();
const files: string[] = [];
for (const path of paths) {
// Normalize path
const normalized = path.replace(/\\/g, '/');
const parts = normalized.split('/').filter(Boolean);
// Add file (truncate to maxDepth)
if (parts.length <= maxDepth + 1) {
files.push(normalized);
} else {
// Show truncated path for deep files
files.push(parts.slice(0, maxDepth + 1).join('/') + '/...');
}
// Add folders
for (let i = 0; i < Math.min(parts.length - 1, maxDepth); i++) {
const folderPath = parts.slice(0, i + 1).join('/');
folders.add(folderPath);
}
}
// Sort folders and files
const sortedFolders = Array.from(folders).sort();
const sortedFiles = [...new Set(files)].sort();
// Build TOON output
const lines: string[] = [];
// Folders section
lines.push(`folders:${sortedFolders.length}`);
if (sortedFolders.length > 0) {
// Compact comma-separated for small lists
if (sortedFolders.length <= 10) {
lines.push(` ${sortedFolders.join(',')}`);
} else {
// Chunked for larger lists
for (let i = 0; i < sortedFolders.length; i += 8) {
lines.push(` ${sortedFolders.slice(i, i + 8).join(',')}`);
}
}
}
// Files section
lines.push(`files:${sortedFiles.length}`);
lines.push(' path');
// Show first 30 files, then summarize
const maxFiles = 30;
for (let i = 0; i < Math.min(sortedFiles.length, maxFiles); i++) {
lines.push(` ${sortedFiles[i]}`);
}
if (sortedFiles.length > maxFiles) {
lines.push(` ...(${sortedFiles.length - maxFiles} more)`);
}
return lines.join('\n');
}
/**
* Build a tree structure from file paths
*/

View file

@ -0,0 +1,341 @@
/**
* MCP Browser Client
*
* WebSocket client that connects to the gitnexus-mcp daemon.
* - Sends codebase context (stats, hotspots, folder tree) on connect
* - Receives tool calls from external AI agents and executes them
* - Emits activity events for real-time monitoring
*/
/**
* Agent color mapping for multi-agent support
*/
export const AGENT_COLORS: Record<string, string> = {
'cursor': '#a855f7', // Purple
'claude': '#3b82f6', // Blue
'claude-code': '#3b82f6', // Blue
'windsurf': '#22c55e', // Green
'codeium': '#22c55e', // Green (Windsurf)
'unknown': '#6b7280', // Gray
};
export function getAgentColor(agentName: string): string {
const normalized = agentName.toLowerCase().trim();
for (const [key, color] of Object.entries(AGENT_COLORS)) {
if (normalized.includes(key)) {
return color;
}
}
return AGENT_COLORS.unknown;
}
export interface ConnectedAgent {
name: string;
color: string;
}
export interface MCPMessage {
id: string;
type?: 'context' | 'tool_call' | 'tool_result' | 'agent_info';
method?: string;
params?: Record<string, any>;
result?: any;
error?: { message: string };
agentName?: string;
}
/**
* Codebase context to send to daemon
*/
export interface CodebaseContext {
projectName: string;
stats: {
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
};
hotspots: Array<{
name: string;
type: string;
filePath: string;
connections: number;
}>;
folderTree: string;
}
/**
* Activity event for real-time monitoring
*/
export interface ActivityEvent {
id: string;
tool: string;
params: Record<string, any>;
status: 'running' | 'complete' | 'error';
result?: any;
error?: string;
timestamp: number;
duration?: number;
agentName?: string;
agentColor?: string;
}
type ToolHandler = (params: Record<string, any>) => Promise<any>;
type ActivityListener = (event: ActivityEvent) => void;
export class MCPBrowserClient {
private ws: WebSocket | null = null;
private handlers: Map<string, ToolHandler> = new Map();
private connectionListeners: Set<(connected: boolean) => void> = new Set();
private activityListeners: Set<ActivityListener> = new Set();
private activityLog: ActivityEvent[] = [];
private pendingContext: CodebaseContext | null = null;
private _connectedAgent: ConnectedAgent | null = null;
constructor(private port = 54319) {}
/**
* Connect to the MCP daemon
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(`ws://localhost:${this.port}`);
this.ws.onopen = () => {
console.log('[MCP] Connected to daemon');
this.notifyConnectionListeners(true);
// Send pending context if available
if (this.pendingContext) {
this.sendContext(this.pendingContext);
}
resolve();
};
this.ws.onerror = () => {
this.notifyConnectionListeners(false);
reject(new Error('Failed to connect to MCP bridge'));
};
this.ws.onmessage = (event) => {
try {
const msg: MCPMessage = JSON.parse(event.data);
this.handleMessage(msg);
} catch (error) {
console.error('[MCP] Failed to parse message:', error);
}
};
this.ws.onclose = () => {
this.ws = null;
this.notifyConnectionListeners(false);
};
} catch (error) {
reject(error);
}
});
}
/**
* Send codebase context to daemon
* Call this whenever context changes (new repo loaded, etc.)
*/
sendContext(context: CodebaseContext) {
this.pendingContext = context;
if (this.ws?.readyState === WebSocket.OPEN) {
const msg = {
id: `ctx_${Date.now()}`,
type: 'context',
params: context,
};
this.ws.send(JSON.stringify(msg));
console.log('[MCP] Sent context:', context.projectName);
}
}
/**
* Handle incoming messages from daemon
*/
private async handleMessage(msg: MCPMessage) {
// Handle agent info updates
if (msg.type === 'agent_info' && msg.agentName) {
this._connectedAgent = {
name: msg.agentName,
color: getAgentColor(msg.agentName),
};
console.log('[MCP] Agent connected:', this._connectedAgent);
return;
}
// This is a tool call request from an external agent
if (msg.method && msg.id) {
const handler = this.handlers.get(msg.method);
const startTime = Date.now();
// Get agent info from message or use connected agent
const agentName = msg.agentName || this._connectedAgent?.name || 'Unknown';
const agentColor = getAgentColor(agentName);
// Create activity event with agent info
const activityEvent: ActivityEvent = {
id: msg.id,
tool: msg.method,
params: msg.params || {},
status: 'running',
timestamp: startTime,
agentName,
agentColor,
};
this.logActivity(activityEvent);
if (handler) {
try {
const result = await handler(msg.params || {});
this.send({ id: msg.id, result });
// Update activity with success
this.updateActivity(msg.id, {
status: 'complete',
result,
duration: Date.now() - startTime,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.send({ id: msg.id, error: { message } });
// Update activity with error
this.updateActivity(msg.id, {
status: 'error',
error: message,
duration: Date.now() - startTime,
});
}
} else {
this.send({
id: msg.id,
error: { message: `Unknown tool: ${msg.method}` }
});
// Update activity with error
this.updateActivity(msg.id, {
status: 'error',
error: `Unknown tool: ${msg.method}`,
duration: Date.now() - startTime,
});
}
}
}
/**
* Log an activity event
*/
private logActivity(event: ActivityEvent) {
this.activityLog.push(event);
// Keep max 100 events
if (this.activityLog.length > 100) {
this.activityLog.shift();
}
this.notifyActivityListeners(event);
}
/**
* Update an existing activity event
*/
private updateActivity(id: string, updates: Partial<ActivityEvent>) {
const event = this.activityLog.find(e => e.id === id);
if (event) {
Object.assign(event, updates);
this.notifyActivityListeners(event);
}
}
/**
* Send a message to the daemon
*/
private send(msg: MCPMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
/**
* Register a handler for a tool
*/
registerHandler(method: string, handler: ToolHandler) {
this.handlers.set(method, handler);
}
/**
* Listen for connection state changes
*/
onConnectionChange(listener: (connected: boolean) => void) {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
private notifyConnectionListeners(connected: boolean) {
this.connectionListeners.forEach(listener => listener(connected));
}
/**
* Listen for activity events
*/
onActivity(listener: ActivityListener) {
this.activityListeners.add(listener);
return () => this.activityListeners.delete(listener);
}
private notifyActivityListeners(event: ActivityEvent) {
this.activityListeners.forEach(listener => listener(event));
}
/**
* Get the activity log
*/
getActivityLog(): ActivityEvent[] {
return [...this.activityLog];
}
/**
* Clear the activity log
*/
clearActivityLog() {
this.activityLog = [];
}
/**
* Check if connected
*/
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Get the connected agent info
*/
get connectedAgent(): ConnectedAgent | null {
return this._connectedAgent;
}
/**
* Disconnect from daemon
*/
disconnect() {
this.ws?.close();
this.ws = null;
}
}
// Singleton instance
let mcpClientInstance: MCPBrowserClient | null = null;
export function getMCPClient(): MCPBrowserClient {
if (!mcpClientInstance) {
mcpClientInstance = new MCPBrowserClient();
}
return mcpClientInstance;
}

View file

@ -10,6 +10,7 @@ import type { EmbeddingProgress, SemanticSearchResult } from '../core/embeddings
import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCallInfo, MessageStep } from '../core/llm/types';
import { loadSettings, getActiveProviderConfig } from '../core/llm/settings-service';
import type { AgentMessage } from '../core/llm/agent';
import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants';
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
@ -21,6 +22,15 @@ export interface QueryResult {
executionTime: number;
}
// Animation types for graph nodes
export type AnimationType = 'pulse' | 'ripple' | 'glow';
export interface NodeAnimation {
type: AnimationType;
startTime: number;
duration: number;
}
// Code reference from AI grounding or user selection
export interface CodeReference {
id: string;
@ -66,6 +76,8 @@ interface AppState {
// Filters
visibleLabels: NodeLabel[];
toggleLabelVisibility: (label: NodeLabel) => void;
visibleEdgeTypes: EdgeType[];
toggleEdgeVisibility: (edgeType: EdgeType) => void;
// Depth filter (N hops from selection)
depthFilter: number | null;
@ -86,6 +98,11 @@ interface AppState {
setQueryResult: (result: QueryResult | null) => void;
clearQueryHighlights: () => void;
// Node animations (for MCP tool visual feedback)
animatedNodes: Map<string, NodeAnimation>;
triggerNodeAnimation: (nodeIds: string[], type: AnimationType) => void;
clearAnimations: () => void;
// Progress
progress: PipelineProgress | null;
setProgress: (progress: PipelineProgress | null) => void;
@ -174,6 +191,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
// Filters
const [visibleLabels, setVisibleLabels] = useState<NodeLabel[]>(DEFAULT_VISIBLE_LABELS);
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
// Depth filter
const [depthFilter, setDepthFilter] = useState<number | null>(null);
@ -205,6 +223,45 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
setQueryResult(null);
}, []);
// Node animations (for MCP tool visual feedback)
const [animatedNodes, setAnimatedNodes] = useState<Map<string, NodeAnimation>>(new Map());
const animationTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const triggerNodeAnimation = useCallback((nodeIds: string[], type: AnimationType) => {
const now = Date.now();
const duration = type === 'pulse' ? 2000 : type === 'ripple' ? 3000 : 4000;
setAnimatedNodes(prev => {
const next = new Map(prev);
for (const id of nodeIds) {
next.set(id, { type, startTime: now, duration });
}
return next;
});
// Auto-cleanup after duration
setTimeout(() => {
setAnimatedNodes(prev => {
const next = new Map(prev);
for (const id of nodeIds) {
const anim = next.get(id);
if (anim && anim.startTime === now) {
next.delete(id);
}
}
return next;
});
}, duration + 100);
}, []);
const clearAnimations = useCallback(() => {
setAnimatedNodes(new Map());
if (animationTimerRef.current) {
clearInterval(animationTimerRef.current);
animationTimerRef.current = null;
}
}, []);
// Progress
const [progress, setProgress] = useState<PipelineProgress | null>(null);
@ -921,6 +978,16 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
});
}, []);
const toggleEdgeVisibility = useCallback((edgeType: EdgeType) => {
setVisibleEdgeTypes(prev => {
if (prev.includes(edgeType)) {
return prev.filter(t => t !== edgeType);
} else {
return [...prev, edgeType];
}
});
}, []);
const value: AppState = {
viewMode,
setViewMode,
@ -938,6 +1005,8 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
openChatPanel,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
depthFilter,
setDepthFilter,
highlightedNodeIds,
@ -952,6 +1021,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
queryResult,
setQueryResult,
clearQueryHighlights,
// Node animations
animatedNodes,
triggerNodeAnimation,
clearAnimations,
progress,
setProgress,
projectName,

View file

@ -6,7 +6,8 @@ import forceAtlas2 from 'graphology-layout-forceatlas2';
import noverlap from 'graphology-layout-noverlap';
import EdgeCurveProgram from '@sigma/edge-curve';
import { SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
import type { NodeAnimation } from './useAppState';
import type { EdgeType } from '../lib/constants';
// Helper: Parse hex color to RGB
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
@ -54,6 +55,8 @@ interface UseSigmaOptions {
onStageClick?: () => void;
highlightedNodeIds?: Set<string>;
blastRadiusNodeIds?: Set<string>;
animatedNodes?: Map<string, NodeAnimation>;
visibleEdgeTypes?: EdgeType[];
}
interface UseSigmaReturn {
@ -128,15 +131,45 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const selectedNodeRef = useRef<string | null>(null);
const highlightedRef = useRef<Set<string>>(new Set());
const blastRadiusRef = useRef<Set<string>>(new Set());
const animatedNodesRef = useRef<Map<string, NodeAnimation>>(new Map());
const visibleEdgeTypesRef = useRef<EdgeType[] | null>(null);
const layoutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationFrameRef = useRef<number | null>(null);
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
const [selectedNode, setSelectedNodeState] = useState<string | null>(null);
useEffect(() => {
highlightedRef.current = options.highlightedNodeIds || new Set();
blastRadiusRef.current = options.blastRadiusNodeIds || new Set();
animatedNodesRef.current = options.animatedNodes || new Map();
visibleEdgeTypesRef.current = options.visibleEdgeTypes || null;
sigmaRef.current?.refresh();
}, [options.highlightedNodeIds, options.blastRadiusNodeIds]);
}, [options.highlightedNodeIds, options.blastRadiusNodeIds, options.animatedNodes, options.visibleEdgeTypes]);
// Animation loop for node effects
useEffect(() => {
if (!options.animatedNodes || options.animatedNodes.size === 0) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
const animate = () => {
sigmaRef.current?.refresh();
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [options.animatedNodes]);
const setSelectedNode = useCallback((nodeId: string | null) => {
selectedNodeRef.current = nodeId;
@ -246,11 +279,48 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
const currentSelected = selectedNodeRef.current;
const highlighted = highlightedRef.current;
const blastRadius = blastRadiusRef.current;
const animatedNodes = animatedNodesRef.current;
const hasHighlights = highlighted.size > 0;
const hasBlastRadius = blastRadius.size > 0;
const isQueryHighlighted = highlighted.has(node);
const isBlastRadiusNode = blastRadius.has(node);
// Apply animation effects FIRST (before other highlighting)
const animation = animatedNodes.get(node);
if (animation) {
const now = Date.now();
const elapsed = now - animation.startTime;
const progress = Math.min(elapsed / animation.duration, 1);
// Calculate animation phase (0-1-0-1... oscillation)
const phase = (Math.sin(progress * Math.PI * 4) + 1) / 2;
if (animation.type === 'pulse') {
// Cyan pulse for search results
const sizeMultiplier = 1.5 + phase * 0.8;
res.size = (data.size || 8) * sizeMultiplier;
res.color = phase > 0.5 ? '#06b6d4' : brightenColor('#06b6d4', 1.3);
res.zIndex = 5;
res.highlighted = true;
} else if (animation.type === 'ripple') {
// Red ripple for blast radius
const sizeMultiplier = 1.3 + phase * 1.2;
res.size = (data.size || 8) * sizeMultiplier;
res.color = phase > 0.5 ? '#ef4444' : '#f87171';
res.zIndex = 5;
res.highlighted = true;
} else if (animation.type === 'glow') {
// Purple glow for highlight
const sizeMultiplier = 1.4 + phase * 0.6;
res.size = (data.size || 8) * sizeMultiplier;
res.color = phase > 0.5 ? '#a855f7' : '#c084fc';
res.zIndex = 5;
res.highlighted = true;
}
return res;
}
// Blast radius takes priority (red highlighting)
if (hasBlastRadius && !currentSelected) {
if (isBlastRadiusNode) {
@ -315,6 +385,15 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
edgeReducer: (edge, data) => {
const res = { ...data };
// Check edge type visibility first
const visibleTypes = visibleEdgeTypesRef.current;
if (visibleTypes && data.relationType) {
if (!visibleTypes.includes(data.relationType as EdgeType)) {
res.hidden = true;
return res;
}
}
const currentSelected = selectedNodeRef.current;
const highlighted = highlightedRef.current;
const hasHighlights = highlighted.size > 0;

View file

@ -65,3 +65,34 @@ export const FILTERABLE_LABELS: NodeLabel[] = [
'Interface',
'Import',
];
// Edge/Relation types
export type EdgeType = 'CONTAINS' | 'DEFINES' | 'IMPORTS' | 'CALLS' | 'EXTENDS' | 'IMPLEMENTS';
export const ALL_EDGE_TYPES: EdgeType[] = [
'CONTAINS',
'DEFINES',
'IMPORTS',
'CALLS',
'EXTENDS',
'IMPLEMENTS',
];
// Default visible edges (CALLS hidden by default to reduce clutter)
export const DEFAULT_VISIBLE_EDGES: EdgeType[] = [
'CONTAINS',
'DEFINES',
'IMPORTS',
'EXTENDS',
'IMPLEMENTS',
];
// Edge display info for UI
export const EDGE_INFO: Record<EdgeType, { color: string; label: string }> = {
CONTAINS: { color: '#2d5a3d', label: 'Contains' },
DEFINES: { color: '#0e7490', label: 'Defines' },
IMPORTS: { color: '#1d4ed8', label: 'Imports' },
CALLS: { color: '#7c3aed', label: 'Calls' },
EXTENDS: { color: '#c2410c', label: 'Extends' },
IMPLEMENTS: { color: '#be185d', label: 'Implements' },
};

View file

@ -239,14 +239,8 @@ export const knowledgeGraphToGraphology = (
CALLS: { color: '#7c3aed', sizeMultiplier: 0.8 }, // Violet - function calls
// TYPE RELATIONSHIPS - Warm colors (OOP)
INHERITS: { color: '#b45309', sizeMultiplier: 1.0 }, // Amber - class inheritance
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
// OTHER
USES: { color: '#4338ca', sizeMultiplier: 0.7 }, // Indigo - general usage
OVERRIDES: { color: '#b91c1c', sizeMultiplier: 0.8 }, // Red - method override
DECORATES: { color: '#a16207', sizeMultiplier: 0.6 }, // Yellow/gold - decorators
};
knowledgeGraph.relationships.forEach((rel) => {

17
gitnexus/vercel.json Normal file
View file

@ -0,0 +1,17 @@
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
},
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-corp"
}
]
}
]
}

View file

@ -1,795 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle
} from 'lucide-react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useAppState } from '../hooks/useAppState';
import { ToolCallCard } from './ToolCallCard';
import { isProviderConfigured } from '../core/llm/settings-service';
// Custom syntax theme
const customTheme = {
...vscDarkPlus,
'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10',
margin: 0,
padding: '16px 0',
fontSize: '13px',
lineHeight: '1.6',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
};
export const RightPanel = () => {
const {
isRightPanelOpen,
setRightPanelOpen,
fileContents,
graph,
addCodeReference,
// LLM / chat state
chatMessages,
isChatLoading,
currentToolCalls,
agentError,
isAgentReady,
isAgentInitializing,
sendChatMessage,
clearChat,
} = useAppState();
const [chatInput, setChatInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when messages update or while streaming
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatMessages, isChatLoading]);
const resolveFilePathForUI = useCallback((requestedPath: string): string | null => {
const req = requestedPath.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (!req) return null;
// Exact match first (case-insensitive)
for (const key of fileContents.keys()) {
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (norm === req) return key;
}
// Ends-with match (best for partial paths)
let best: { path: string; score: number } | null = null;
for (const key of fileContents.keys()) {
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (norm.endsWith(req)) {
const score = 1000 - norm.length;
if (!best || score > best.score) best = { path: key, score };
}
}
return best?.path ?? null;
}, [fileContents]);
const findFileNodeIdForUI = useCallback((filePath: string): string | undefined => {
if (!graph) return undefined;
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
const node = graph.nodes.find(
(n) => n.label === 'File' && n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target
);
return node?.id;
}, [graph]);
const handleGroundingClick = useCallback((inner: string) => {
const raw = inner.trim();
if (!raw) return;
let rawPath = raw;
let startLine1: number | undefined;
let endLine1: number | undefined;
// Match line:num or line:num-num (supports both hyphen - and en dash –)
const lineMatch = raw.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
if (lineMatch) {
rawPath = lineMatch[1].trim();
startLine1 = parseInt(lineMatch[2], 10);
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
const resolvedPath = resolveFilePathForUI(rawPath);
if (!resolvedPath) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
addCodeReference({
filePath: resolvedPath,
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
endLine: endLine1 ? Math.max(0, endLine1 - 1) : (startLine1 ? Math.max(0, startLine1 - 1) : undefined),
nodeId,
label: 'File',
name: resolvedPath.split('/').pop() ?? resolvedPath,
source: 'ai',
});
}, [addCodeReference, findFileNodeIdForUI, resolveFilePathForUI]);
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
const handleNodeGroundingClick = useCallback((nodeTypeAndName: string) => {
const raw = nodeTypeAndName.trim();
if (!raw || !graph) return;
// Parse Type:Name format
const match = raw.match(/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/);
if (!match) return;
const [, nodeType, nodeName] = match;
const trimmedName = nodeName.trim();
// Find node in graph by type + name
const node = graph.nodes.find(n =>
n.label === nodeType &&
n.properties.name === trimmedName
);
if (!node) {
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
return;
}
// 1. Highlight in graph (add to AI citation highlights)
// Note: This requires accessing the state setter from parent context
// For now, we'll add to code references which triggers the highlight
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
source: 'ai',
});
}
}
}, [graph, resolveFilePathForUI, addCodeReference]);
const formatMarkdownForDisplay = useCallback((md: string) => {
// Avoid rewriting inside fenced code blocks.
const parts = md.split('```');
for (let i = 0; i < parts.length; i += 2) {
// Pattern 1: File grounding - [[file.ext]] or [[file.ext:line]] or [[file.ext:line-line]]
// Line numbers are optional
parts[i] = parts[i].replace(
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-–]\d+)?)?)\]\]/g,
(_m, inner: string) => {
const trimmed = inner.trim();
const href = `code-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
// Pattern 2: Node grounding - [[Type:Name]] or [[graph:Type:Name]]
// Valid types: Class, Function, Method, Interface, File, Folder, Variable, Enum, Type, CodeElement
parts[i] = parts[i].replace(
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
}
return parts.join('```');
}, []);
const formatRefChipLabel = useCallback((ref: string): string => {
const raw = ref.trim();
if (!raw) return '';
// Drop any scheme prefix we might have accidentally passed through
const withoutScheme = raw.startsWith('code-ref:') ? raw.slice('code-ref:'.length) : raw;
// Strip query/hash
const cleaned = withoutScheme.split('#')[0].split('?')[0];
// Support both hyphen - and en dash – for line ranges
const m = cleaned.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
const path = (m ? m[1] : cleaned).replace(/\\/g, '/');
const base = path.split('/').pop() ?? path;
if (!m) return base;
const start = m[2];
const end = m[3] ?? m[2];
return `${base} ${start}–${end}`;
}, []);
const isLikelyFileRefHref = useCallback((href: string): boolean => {
const h = href.trim();
if (!h) return false;
if (h.startsWith('code-ref:')) return true;
if (/^(https?:|mailto:|tel:|#)/i.test(h)) return false;
if (h.includes('://')) return false;
// Strip query/hash
const cleaned = h.split('#')[0].split('?')[0];
// Looks like: path/to/file.ext or path\to\file.ext:12-34 (supports both hyphen and en dash)
return /[A-Za-z0-9_\-./\\]+\.[A-Za-z0-9]+(?::\d+(?:[-–]\d+)?)?$/.test(cleaned);
}, []);
const extractTextFromChildren = useCallback((children: any): string => {
if (children == null) return '';
if (typeof children === 'string' || typeof children === 'number') return String(children);
if (Array.isArray(children)) return children.map(extractTextFromChildren).join('');
// React element or other objects
return '';
}, []);
const getInternalRefFromLink = useCallback((href: string | undefined, children: any): string | null => {
const hrefStr = (href ?? '').trim();
const textStr = extractTextFromChildren(children).trim();
if (hrefStr && isLikelyFileRefHref(hrefStr)) return hrefStr;
if (textStr && isLikelyFileRefHref(textStr)) return textStr;
return null;
}, [extractTextFromChildren, isLikelyFileRefHref]);
// Auto-resize textarea as user types
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to get accurate scrollHeight
textarea.style.height = 'auto';
// Set to scrollHeight, capped at max
const maxHeight = 160; // ~6 lines
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
// Show scrollbar if content exceeds max
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
}, []);
// Adjust height when input changes
useEffect(() => {
adjustTextareaHeight();
}, [chatInput, adjustTextareaHeight]);
// Chat handlers
const handleSendMessage = async () => {
if (!chatInput.trim()) return;
const text = chatInput.trim();
setChatInput('');
// Reset textarea height after sending
if (textareaRef.current) {
textareaRef.current.style.height = '36px';
textareaRef.current.style.overflowY = 'hidden';
}
await sendChatMessage(text);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const chatSuggestions = [
'Explain the project architecture',
'What does this project do?',
'Show me the most important files',
'Find all API handlers',
];
if (!isRightPanelOpen) return null;
return (
<aside className="w-[40%] min-w-[400px] max-w-[600px] flex flex-col bg-deep border-l border-border-subtle animate-slide-in relative z-30 flex-shrink-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
<div className="flex items-center gap-2.5">
<Sparkles className="w-4 h-4 text-accent" />
<span className="font-medium text-sm">Nexus AI</span>
<span className="text-xs text-text-muted">• Ask about the codebase</span>
</div>
{/* Close button */}
<button
onClick={() => setRightPanelOpen(false)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Close Panel"
>
<PanelRightClose className="w-4 h-4" />
</button>
</div>
{/* Chat Content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Status bar */}
<div className="flex items-center gap-2.5 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
<div className="ml-auto flex items-center gap-2">
{!isAgentReady && (
<span className="text-[11px] px-2 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30">
Configure AI
</span>
)}
{isAgentInitializing && (
<span className="text-[11px] px-2 py-1 rounded-full bg-surface border border-border-subtle flex items-center gap-1 text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" /> Connecting
</span>
)}
</div>
</div>
{/* Status / errors */}
{agentError && (
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>{agentError}</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
🧠
</div>
<h3 className="text-base font-medium mb-2">
Ask me anything
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-5">
I can help you understand the architecture, find functions, or explain connections.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{chatSuggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
) : (
<div className="flex flex-col gap-6">
{chatMessages.map((message) => (
<div
key={message.id}
className="animate-fade-in"
>
{/* User message - compact label style */}
{message.role === 'user' && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-text-muted" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">You</span>
</div>
<div className="pl-6 text-sm text-text-primary">
{message.content}
</div>
</div>
)}
{/* Assistant message - copilot style */}
{message.role === 'assistant' && (
<div>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">Nexus AI</span>
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
<Loader2 className="w-3 h-3 animate-spin text-accent" />
)}
</div>
<div className="pl-6 chat-prose">
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
{message.steps && message.steps.length > 0 ? (
<div className="space-y-4">
{message.steps.map((step) => (
<div key={step.id}>
{step.type === 'reasoning' && step.content && (
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
const label = formatRefChipLabel(inner);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(inner);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
// Handle node grounding: node-ref:Type:Name
if (href && href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleNodeGroundingClick(inner);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-amber-400/15 hover:border-amber-200/70 transition-colors"
title={`View ${inner} in Code panel`}
{...props}
>
<span className="text-inherit">{children}</span>
</a>
);
}
const internalRef = getInternalRefFromLink(href, children);
if (internalRef) {
const label = formatRefChipLabel(internalRef);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(internalRef);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${internalRef}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
// Non-citation link - just render as plain text (not a useless external link)
return <span {...props}>{children}</span>;
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }) => <>{children}</>,
}}
>
{formatMarkdownForDisplay(step.content)}
</ReactMarkdown>
</div>
)}
{step.type === 'tool_call' && step.toolCall && (
<div className="mb-3">
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
</div>
)}
{step.type === 'content' && step.content && (
<div className="text-text-primary text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
const label = formatRefChipLabel(inner);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(inner);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
// Handle node grounding: node-ref:Type:Name
if (href && href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleNodeGroundingClick(inner);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-amber-400/15 hover:border-amber-200/70 transition-colors"
title={`View ${inner} in Code panel`}
{...props}
>
<span className="text-inherit">{children}</span>
</a>
);
}
const internalRef = getInternalRefFromLink(href, children);
if (internalRef) {
const label = formatRefChipLabel(internalRef);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(internalRef);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${internalRef}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
return (
<a
href={href}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }) => <>{children}</>,
}}
>
{formatMarkdownForDisplay(step.content)}
</ReactMarkdown>
</div>
)}
</div>
))}
</div>
) : (
// Fallback: render content + toolCalls separately (old format)
<div className="text-text-primary text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...props }) => {
if (href && href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
const label = formatRefChipLabel(inner);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(inner);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
// Handle node grounding: node-ref:Type:Name
if (href && href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleNodeGroundingClick(inner);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-amber-400/15 hover:border-amber-200/70 transition-colors"
title={`View ${inner} in Code panel`}
{...props}
>
<span className="text-inherit">{children}</span>
</a>
);
}
const internalRef = getInternalRefFromLink(href, children);
if (internalRef) {
const label = formatRefChipLabel(internalRef);
return (
<a
href={href}
onClick={(e) => {
e.preventDefault();
handleGroundingClick(internalRef);
}}
className="code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 font-mono text-[12px] !no-underline hover:!no-underline hover:bg-cyan-400/15 hover:border-cyan-200/70 transition-colors"
title={`Open in Code panel • ${internalRef}`}
{...props}
>
<span className="text-inherit">{label || children}</span>
</a>
);
}
return (
<a
href={href}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }) => <>{children}</>,
}}
>
{formatMarkdownForDisplay(message.content)}
</ReactMarkdown>
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{message.toolCalls.map(tc => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Scroll anchor for auto-scroll */}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 bg-surface border-t border-border-subtle">
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<textarea
ref={textareaRef}
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
rows={1}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button
onClick={clearChat}
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
title="Clear chat"
>
Clear
</button>
<button
onClick={handleSendMessage}
disabled={!chatInput.trim() || isChatLoading || isAgentInitializing}
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
>
{isChatLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-3.5 h-3.5" />}
</button>
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5" />
<span>
{isProviderConfigured()
? 'Initializing AI agent...'
: 'Configure an LLM provider to enable chat.'}
</span>
</div>
)}
</div>
</div>
</aside>
);
};

View file

@ -1,139 +0,0 @@
/**
* MCP Browser Client
*
* WebSocket client that connects to the gitnexus-mcp bridge.
* Receives tool calls from external AI agents and executes them.
*/
export interface MCPMessage {
id: string;
method?: string;
params?: Record<string, any>;
result?: any;
error?: { message: string };
}
type ToolHandler = (params: Record<string, any>) => Promise<any>;
export class MCPBrowserClient {
private ws: WebSocket | null = null;
private handlers: Map<string, ToolHandler> = new Map();
private connectionListeners: Set<(connected: boolean) => void> = new Set();
constructor(private port = 54319) {}
/**
* Connect to the MCP bridge
*/
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(`ws://localhost:${this.port}`);
this.ws.onopen = () => {
this.notifyConnectionListeners(true);
resolve();
};
this.ws.onerror = (event) => {
this.notifyConnectionListeners(false);
reject(new Error('Failed to connect to MCP bridge'));
};
this.ws.onmessage = (event) => {
try {
const msg: MCPMessage = JSON.parse(event.data);
this.handleMessage(msg);
} catch (error) {
console.error('[MCP] Failed to parse message:', error);
}
};
this.ws.onclose = () => {
this.ws = null;
this.notifyConnectionListeners(false);
};
} catch (error) {
reject(error);
}
});
}
/**
* Handle incoming messages from bridge
*/
private async handleMessage(msg: MCPMessage) {
// This is a tool call request from an external agent
if (msg.method && msg.id) {
const handler = this.handlers.get(msg.method);
if (handler) {
try {
const result = await handler(msg.params || {});
this.send({ id: msg.id, result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.send({ id: msg.id, error: { message } });
}
} else {
this.send({
id: msg.id,
error: { message: `Unknown tool: ${msg.method}` }
});
}
}
}
/**
* Send a message to the bridge
*/
private send(msg: MCPMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
/**
* Register a handler for a tool
*/
registerHandler(method: string, handler: ToolHandler) {
this.handlers.set(method, handler);
}
/**
* Listen for connection state changes
*/
onConnectionChange(listener: (connected: boolean) => void) {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
private notifyConnectionListeners(connected: boolean) {
this.connectionListeners.forEach(listener => listener(connected));
}
/**
* Check if connected
*/
get isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
/**
* Disconnect from bridge
*/
disconnect() {
this.ws?.close();
this.ws = null;
}
}
// Singleton instance
let mcpClientInstance: MCPBrowserClient | null = null;
export function getMCPClient(): MCPBrowserClient {
if (!mcpClientInstance) {
mcpClientInstance = new MCPBrowserClient();
}
return mcpClientInstance;
}

Some files were not shown because too many files have changed in this diff Show more