diff --git a/.gitignore b/.gitignore index 0e7f289..4b5d092 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,12 @@ node_modules/ # Build output dist/ -# Config with credentials (separate per instance) -config/ +# Claude credentials (separate per instance) +config/.claude/ +config/.config/ + +# Hosts config with real IPs (use hosts.example.json as template) +config/hosts.json # Environment files .env diff --git a/backend/server.js b/backend/server.js index f52548d..c292947 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,7 @@ import { spawn } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import cors from 'cors'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; -import { join } from 'path'; +import { join, basename } from 'path'; const app = express(); app.use(cors()); @@ -14,27 +14,114 @@ app.use(express.json()); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; +// Load hosts configuration +const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json'; +let hostsConfig = { hosts: {}, defaults: { scanSubdirs: true, maxDepth: 1 } }; + +function loadConfig() { + try { + if (existsSync(CONFIG_PATH)) { + hostsConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); + console.log('Loaded hosts config:', Object.keys(hostsConfig.hosts)); + } else { + console.log('No hosts.json found, using defaults'); + } + } catch (err) { + console.error('Error loading config:', err); + } +} +loadConfig(); + // Store active Claude sessions const sessions = new Map(); const server = createServer(app); const wss = new WebSocketServer({ server }); -// REST endpoint to list available projects +// Scan directory for projects +function scanProjects(basePath, depth = 0, maxDepth = 1) { + const projects = []; + + if (!existsSync(basePath)) return projects; + + try { + const entries = readdirSync(basePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) { + const fullPath = join(basePath, entry.name); + projects.push({ + path: fullPath, + name: entry.name, + type: 'directory' + }); + + // Recurse if not at max depth + if (depth < maxDepth - 1) { + projects.push(...scanProjects(fullPath, depth + 1, maxDepth)); + } + } + } + } catch (err) { + console.error(`Error scanning ${basePath}:`, err.message); + } + + return projects; +} + +// REST endpoint to list hosts +app.get('/api/hosts', (req, res) => { + const hosts = Object.entries(hostsConfig.hosts).map(([id, host]) => ({ + id, + name: host.name, + description: host.description, + color: host.color, + icon: host.icon, + connectionType: host.connection.type, + isLocal: host.connection.type === 'local' + })); + res.json({ hosts, defaultHost: hostsConfig.defaults?.host || 'neko' }); +}); + +// REST endpoint to list projects for a host app.get('/api/projects', (req, res) => { - const baseDirs = [ - { path: '/projects', name: 'projects', description: 'Development projects' }, - { path: '/docker', name: 'docker', description: 'Docker configurations' }, - { path: '/stacks', name: 'stacks', description: 'Production stacks' } - ]; + const hostId = req.query.host || hostsConfig.defaults?.host || 'neko'; + const host = hostsConfig.hosts[hostId]; + + if (!host) { + return res.status(404).json({ error: `Host '${hostId}' not found` }); + } + + // Only local hosts for now + if (host.connection.type !== 'local') { + return res.json({ + projects: [], + host: hostId, + message: 'SSH hosts not yet supported for project listing' + }); + } const projects = []; - for (const dir of baseDirs) { - if (existsSync(dir.path)) { - projects.push({ ...dir, type: 'directory' }); + const scanSubdirs = hostsConfig.defaults?.scanSubdirs ?? true; + const maxDepth = hostsConfig.defaults?.maxDepth ?? 1; + + for (const basePath of host.basePaths) { + // Add base path itself + if (existsSync(basePath)) { + projects.push({ + path: basePath, + name: basename(basePath), + type: 'base', + isBase: true + }); + + // Scan subdirectories if enabled + if (scanSubdirs) { + projects.push(...scanProjects(basePath, 0, maxDepth)); + } } } - res.json(projects); + + res.json({ projects, host: hostId, hostInfo: { name: host.name, color: host.color } }); }); // Health check diff --git a/config/hosts.example.json b/config/hosts.example.json new file mode 100644 index 0000000..0d9768c --- /dev/null +++ b/config/hosts.example.json @@ -0,0 +1,37 @@ +{ + "hosts": { + "local": { + "name": "Local", + "description": "Local machine", + "connection": { + "type": "local" + }, + "basePaths": [ + "/projects", + "/docker" + ], + "color": "#f97316", + "icon": "cat" + }, + "remote-example": { + "name": "Remote Server", + "description": "Example remote host via SSH", + "connection": { + "type": "ssh", + "host": "192.168.1.100", + "user": "username", + "port": 22 + }, + "basePaths": [ + "/home/username/projects" + ], + "color": "#22c55e", + "icon": "server" + } + }, + "defaults": { + "host": "local", + "scanSubdirs": true, + "maxDepth": 1 + } +} diff --git a/docker-compose.yml b/docker-compose.yml index dfca0b1..457247f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: # Separate config for WebUI Claude (NOT Neko's config!) - ./config/.claude:/home/node/.claude:rw - ./config/.config/claude:/home/node/.config/claude:rw + # Hosts configuration + - ./config/hosts.json:/app/config/hosts.json:ro # Project directories for Claude to work in - /home/sumdex/projects:/projects:rw - /home/sumdex/docker:/docker:rw diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8b876e8..a9514a1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,89 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { useClaudeSession } from './hooks/useClaudeSession'; import { MessageList } from './components/MessageList'; import { ChatInput } from './components/ChatInput'; import { Sidebar } from './components/Sidebar'; import { Header } from './components/Header'; +// Slash command definitions +const SLASH_COMMANDS = { + clear: { + description: 'Clear chat history (UI only)', + execute: ({ clearMessages, addSystemMessage }) => { + clearMessages(); + addSystemMessage('Chat cleared'); + } + }, + help: { + description: 'Show available commands', + execute: ({ addSystemMessage }) => { + const helpText = Object.entries(SLASH_COMMANDS) + .map(([cmd, { description }]) => `/${cmd} - ${description}`) + .join('\n'); + addSystemMessage(`Available commands:\n${helpText}`); + } + }, + export: { + description: 'Export chat as Markdown', + execute: ({ messages, addSystemMessage }) => { + const markdown = messages.map(m => { + if (m.type === 'user') return `**You:** ${m.content}`; + if (m.type === 'assistant') return `**Claude:** ${m.content}`; + if (m.type === 'tool_use') return `> Tool: ${m.tool}`; + if (m.type === 'system') return `_${m.content}_`; + return ''; + }).filter(Boolean).join('\n\n'); + + const blob = new Blob([markdown], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `claude-chat-${new Date().toISOString().slice(0,10)}.md`; + a.click(); + URL.revokeObjectURL(url); + addSystemMessage('Chat exported as Markdown'); + } + }, + scroll: { + description: 'Scroll to top or bottom (/scroll top|bottom)', + execute: ({ args, addSystemMessage }) => { + const direction = args[0] || 'bottom'; + const container = document.querySelector('.overflow-y-auto'); + if (container) { + if (direction === 'top') { + container.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); + } + } + addSystemMessage(`Scrolled to ${direction}`); + } + }, + new: { + description: 'Start a new session (clears history)', + execute: ({ clearMessages, stopSession, startSession, selectedProject, addSystemMessage }) => { + stopSession(); + clearMessages(); + setTimeout(() => { + startSession(selectedProject, false); // false = don't resume + }, 500); + addSystemMessage('Starting new session...'); + } + }, + info: { + description: 'Show session info', + execute: ({ connected, sessionActive, currentProject, messages, addSystemMessage }) => { + const info = [ + `Connected: ${connected ? 'Yes' : 'No'}`, + `Session: ${sessionActive ? 'Active' : 'Inactive'}`, + `Project: ${currentProject || 'None'}`, + `Messages: ${messages.length}` + ].join('\n'); + addSystemMessage(info); + } + } +}; + function App() { const { connected, @@ -17,21 +96,67 @@ function App() { sendMessage, stopSession, clearMessages, - setError + setError, + setMessages } = useClaudeSession(); const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui'); const [sidebarOpen, setSidebarOpen] = useState(true); const [resumeSession, setResumeSession] = useState(true); + // Add system message helper + const addSystemMessage = useCallback((content) => { + setMessages(prev => [...prev, { + type: 'system', + content, + timestamp: Date.now() + }]); + }, [setMessages]); + const handleStartSession = () => { startSession(selectedProject, resumeSession); }; - const handleSendMessage = (message) => { - if (message.trim()) { - sendMessage(message); + // Handle slash commands + const handleCommand = useCallback((command, args) => { + const cmd = SLASH_COMMANDS[command.toLowerCase()]; + if (cmd) { + cmd.execute({ + clearMessages, + addSystemMessage, + messages, + stopSession, + startSession, + selectedProject, + connected, + sessionActive, + currentProject, + args + }); + return true; } + return false; + }, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]); + + const handleSendMessage = (message) => { + if (!message.trim()) return; + + // Check for slash command + if (message.startsWith('/')) { + const parts = message.slice(1).split(' '); + const command = parts[0]; + const args = parts.slice(1); + + if (handleCommand(command, args)) { + return; // Command handled + } else { + addSystemMessage(`Unknown command: /${command}. Type /help for available commands.`); + return; + } + } + + // Regular message + sendMessage(message); }; return ( diff --git a/frontend/src/components/ChatInput.jsx b/frontend/src/components/ChatInput.jsx index 6cff086..5af2dc7 100644 --- a/frontend/src/components/ChatInput.jsx +++ b/frontend/src/components/ChatInput.jsx @@ -1,8 +1,21 @@ import { useState, useRef, useEffect } from 'react'; -import { Send, Loader2 } from 'lucide-react'; +import { Send, Loader2, Command } from 'lucide-react'; + +// Available slash commands for autocomplete +const COMMANDS = [ + { name: 'help', description: 'Show available commands' }, + { name: 'clear', description: 'Clear chat history' }, + { name: 'export', description: 'Export chat as Markdown' }, + { name: 'scroll', description: 'Scroll to top or bottom' }, + { name: 'new', description: 'Start a new session' }, + { name: 'info', description: 'Show session info' }, +]; export function ChatInput({ onSend, disabled, placeholder }) { const [message, setMessage] = useState(''); + const [showCommands, setShowCommands] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [filteredCommands, setFilteredCommands] = useState(COMMANDS); const textareaRef = useRef(null); // Auto-resize textarea @@ -14,15 +27,60 @@ export function ChatInput({ onSend, disabled, placeholder }) { } }, [message]); + // Handle command filtering + useEffect(() => { + if (message.startsWith('/')) { + const query = message.slice(1).toLowerCase(); + const filtered = COMMANDS.filter(cmd => + cmd.name.toLowerCase().startsWith(query) + ); + setFilteredCommands(filtered); + setShowCommands(filtered.length > 0 && message.length > 0); + setSelectedIndex(0); + } else { + setShowCommands(false); + } + }, [message]); + const handleSubmit = (e) => { e.preventDefault(); if (message.trim() && !disabled) { onSend(message); setMessage(''); + setShowCommands(false); } }; + const selectCommand = (cmdName) => { + setMessage(`/${cmdName} `); + setShowCommands(false); + textareaRef.current?.focus(); + }; + const handleKeyDown = (e) => { + // Handle command selection + if (showCommands) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(i => Math.min(i + 1, filteredCommands.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(i => Math.max(i - 1, 0)); + return; + } + if (e.key === 'Tab' || (e.key === 'Enter' && filteredCommands.length > 0)) { + e.preventDefault(); + selectCommand(filteredCommands[selectedIndex].name); + return; + } + if (e.key === 'Escape') { + setShowCommands(false); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); @@ -33,6 +91,25 @@ export function ChatInput({ onSend, disabled, placeholder }) {
+ {/* Command autocomplete dropdown */} + {showCommands && ( +
+ {filteredCommands.map((cmd, index) => ( + + ))} +
+ )} +