mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
MCP bridge added
This commit is contained in:
parent
df4f9e30c2
commit
e153d30c8f
101 changed files with 5374 additions and 1010 deletions
1994
gitnexus-mcp/package-lock.json
generated
Normal file
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
33
gitnexus-mcp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
173
gitnexus-mcp/src/bridge/daemon-client.ts
Normal file
173
gitnexus-mcp/src/bridge/daemon-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
gitnexus-mcp/src/bridge/protocol.ts
Normal file
36
gitnexus-mcp/src/bridge/protocol.ts
Normal 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;
|
||||
}
|
||||
163
gitnexus-mcp/src/bridge/websocket-server.ts
Normal file
163
gitnexus-mcp/src/bridge/websocket-server.ts
Normal 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
47
gitnexus-mcp/src/cli.ts
Normal 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();
|
||||
|
||||
|
||||
194
gitnexus-mcp/src/commands/daemon.ts
Normal file
194
gitnexus-mcp/src/commands/daemon.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
153
gitnexus-mcp/src/commands/generate-guidance.ts
Normal file
153
gitnexus-mcp/src/commands/generate-guidance.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
33
gitnexus-mcp/src/commands/serve.ts
Normal file
33
gitnexus-mcp/src/commands/serve.ts
Normal 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);
|
||||
}
|
||||
55
gitnexus-mcp/src/commands/setup.ts
Normal file
55
gitnexus-mcp/src/commands/setup.ts
Normal 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'));
|
||||
}
|
||||
103
gitnexus-mcp/src/config/detect-ides.ts
Normal file
103
gitnexus-mcp/src/config/detect-ides.ts
Normal 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;
|
||||
}
|
||||
97
gitnexus-mcp/src/config/inject-config.ts
Normal file
97
gitnexus-mcp/src/config/inject-config.ts
Normal 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));
|
||||
}
|
||||
|
||||
37
gitnexus-mcp/src/config/paths.ts
Normal file
37
gitnexus-mcp/src/config/paths.ts
Normal 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');
|
||||
}
|
||||
220
gitnexus-mcp/src/mcp/server.ts
Normal file
220
gitnexus-mcp/src/mcp/server.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
174
gitnexus-mcp/src/mcp/tools.ts
Normal file
174
gitnexus-mcp/src/mcp/tools.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
69
gitnexus-mcp/test-output/.cursorrules
Normal file
69
gitnexus-mcp/test-output/.cursorrules
Normal 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
|
||||
```
|
||||
98
gitnexus-mcp/test-output/AGENTS.md
Normal file
98
gitnexus-mcp/test-output/AGENTS.md
Normal 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
|
||||
27
gitnexus-mcp/tsconfig.json
Normal file
27
gitnexus-mcp/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
0
package-lock.json → gitnexus/package-lock.json
generated
0
package-lock.json → gitnexus/package-lock.json
generated
202
gitnexus/src/components/ActivityFeed.tsx
Normal file
202
gitnexus/src/components/ActivityFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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] }}
|
||||
/>
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -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();
|
||||
172
gitnexus/src/components/MarkdownRenderer.tsx
Normal file
172
gitnexus/src/components/MarkdownRenderer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
435
gitnexus/src/components/RightPanel.tsx
Normal file
435
gitnexus/src/components/RightPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
*/
|
||||
341
gitnexus/src/core/mcp/mcp-client.ts
Normal file
341
gitnexus/src/core/mcp/mcp-client.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' },
|
||||
};
|
||||
|
|
@ -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
17
gitnexus/vercel.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"headers": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cross-Origin-Opener-Policy",
|
||||
"value": "same-origin"
|
||||
},
|
||||
{
|
||||
"key": "Cross-Origin-Embedder-Policy",
|
||||
"value": "require-corp"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue