feat: Major UI improvements and SSH-only mode

- Tool rendering: Unified tool_use/tool_result cards with collapsible results
- Special rendering for WebSearch, WebFetch, Task, Write tools
- File upload support with drag & drop
- Permission dialog for tool approvals
- Status bar with session stats and permission mode toggle
- SSH-only mode: Removed local container execution
- Host switching disabled during active session with visual indicator
- Directory browser: Browse remote directories via SSH
- Recent directories dropdown with localStorage persistence
- Follow-up messages during generation
- Improved scroll behavior with "back to bottom" button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-17 10:33:25 +01:00
parent 9eb0ecfb57
commit 960f2e137d
16 changed files with 3108 additions and 278 deletions

View File

@@ -12,6 +12,7 @@
"express": "^4.18.2",
"ws": "^8.14.2",
"cors": "^2.8.5",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"multer": "^1.4.5-lts.1"
}
}

View File

@@ -4,8 +4,9 @@ import { createServer } from 'http';
import { spawn } from 'child_process';
import { v4 as uuidv4 } from 'uuid';
import cors from 'cors';
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join, basename } from 'path';
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
import { join, basename, extname } from 'path';
import multer from 'multer';
const app = express();
app.use(cors());
@@ -13,6 +14,68 @@ app.use(express.json());
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
const DEBUG = process.env.DEBUG === 'true';
// Upload to /projects/.claude-uploads so Claude can access them via mounted volume
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/projects/.claude-uploads';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Allowed file types
const ALLOWED_TYPES = {
// Images
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
// Text/Code
'text/plain': '.txt',
'text/markdown': '.md',
'text/csv': '.csv',
'text/html': '.html',
'text/css': '.css',
'text/javascript': '.js',
'application/json': '.json',
'application/xml': '.xml',
'text/xml': '.xml',
'application/x-yaml': '.yaml',
'text/yaml': '.yaml'
};
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const sessionId = req.params.sessionId || 'default';
const sessionDir = join(UPLOAD_DIR, sessionId);
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
cb(null, sessionDir);
},
filename: (req, file, cb) => {
// Preserve original filename with timestamp prefix to avoid collisions
const timestamp = Date.now();
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
cb(null, `${timestamp}-${safeName}`);
}
});
const upload = multer({
storage,
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, cb) => {
if (ALLOWED_TYPES[file.mimetype]) {
cb(null, true);
} else {
// Also allow common code file extensions
const ext = extname(file.originalname).toLowerCase();
const codeExtensions = ['.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.sh', '.bash', '.zsh', '.yml', '.yaml', '.toml', '.ini', '.conf', '.md', '.txt', '.json', '.xml', '.html', '.css', '.scss', '.less', '.sql', '.rb', '.php', '.swift', '.kt', '.scala', '.r', '.lua', '.pl', '.pm'];
if (codeExtensions.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`));
}
}
}
});
// Load hosts configuration
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
@@ -35,6 +98,12 @@ loadConfig();
// Store active Claude sessions
const sessions = new Map();
// Control request counter for unique IDs
let controlRequestCounter = 0;
function generateRequestId() {
return `req_${++controlRequestCounter}_${Date.now().toString(16)}`;
}
const server = createServer(app);
const wss = new WebSocketServer({ server });
@@ -140,6 +209,144 @@ app.get('/api/health', (req, res) => {
});
});
// Browse directories on a host (for directory picker)
app.get('/api/browse', async (req, res) => {
const hostId = req.query.host || hostsConfig.defaults?.host || 'neko';
const path = req.query.path || '~';
const host = hostsConfig.hosts[hostId];
if (!host) {
return res.status(404).json({ error: `Host '${hostId}' not found` });
}
// For SSH hosts, execute ls command remotely
if (host.connection.type === 'ssh') {
const { host: sshHost, user, port = 22 } = host.connection;
// Expand ~ to home directory, list only directories, format as JSON-friendly output
const lsCmd = `cd ${path} 2>/dev/null && pwd && ls -1F 2>/dev/null | grep '/$' | sed 's/\\/$//'`;
const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port} ${user}@${sshHost} "${lsCmd}"`;
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
const { stdout, stderr } = await execAsync(sshCmd, { timeout: 10000 });
const lines = stdout.trim().split('\n').filter(Boolean);
const currentPath = lines[0] || path;
const directories = lines.slice(1).map(name => ({
name,
path: currentPath === '/' ? `/${name}` : `${currentPath}/${name}`,
type: 'directory'
}));
// Add parent directory if not at root
if (currentPath !== '/') {
const parentPath = currentPath.split('/').slice(0, -1).join('/') || '/';
directories.unshift({
name: '..',
path: parentPath,
type: 'parent'
});
}
res.json({
currentPath,
directories,
host: hostId
});
} catch (err) {
console.error('Browse error:', err.message);
res.status(500).json({ error: `Failed to browse: ${err.message}` });
}
} else {
// Local browsing
try {
const resolvedPath = path === '~' ? process.env.HOME || '/home' : path;
if (!existsSync(resolvedPath)) {
return res.status(404).json({ error: `Path not found: ${resolvedPath}` });
}
const entries = readdirSync(resolvedPath);
const directories = entries
.filter(name => {
try {
return statSync(join(resolvedPath, name)).isDirectory() && !name.startsWith('.');
} catch {
return false;
}
})
.map(name => ({
name,
path: join(resolvedPath, name),
type: 'directory'
}));
// Add parent directory
if (resolvedPath !== '/') {
const parentPath = join(resolvedPath, '..');
directories.unshift({
name: '..',
path: parentPath,
type: 'parent'
});
}
res.json({
currentPath: resolvedPath,
directories,
host: hostId
});
} catch (err) {
res.status(500).json({ error: `Failed to browse: ${err.message}` });
}
}
});
// File upload endpoint
app.post('/api/upload/:sessionId', upload.array('files', 5), (req, res) => {
try {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const uploadedFiles = req.files.map(file => {
// Convert container path to host path for Claude
// /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/...
const hostPath = file.path.replace('/projects/', '/home/sumdex/projects/');
return {
originalName: file.originalname,
savedName: file.filename,
path: hostPath, // Use host path so Claude can read it
containerPath: file.path, // Keep container path for reference
size: file.size,
mimeType: file.mimetype,
isImage: file.mimetype.startsWith('image/')
};
});
console.log(`[Upload] Session ${req.params.sessionId}: ${uploadedFiles.length} files uploaded`);
res.json({ files: uploadedFiles });
} catch (err) {
console.error('[Upload] Error:', err);
res.status(500).json({ error: err.message });
}
});
// Error handler for multer
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB` });
}
return res.status(400).json({ error: err.message });
}
if (err) {
return res.status(400).json({ error: err.message });
}
next();
});
// Parse history content into messages
function parseHistoryContent(content) {
const lines = content.split('\n').filter(l => l.trim());
@@ -290,10 +497,54 @@ wss.on('connection', (ws, req) => {
let claudeProcess = null;
let currentProject = null;
let currentHostId = null; // Track current host for restart
let isInitialized = false;
let currentPermissionMode = 'default';
let savedPermissionMode = 'default'; // Store mode before plan mode switch
let inPlanMode = false; // Track if we're in plan mode (to require approval for ExitPlanMode)
const pendingControlRequests = new Map();
const pendingPermissionRequests = new Map(); // Track tool permission requests
// Cleanup stale pending requests (TTL: 30 seconds)
const REQUEST_TTL = 30000;
const cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [id, req] of pendingControlRequests) {
if (req.createdAt && now - req.createdAt > REQUEST_TTL) {
pendingControlRequests.delete(id);
}
}
for (const [id, req] of pendingPermissionRequests) {
if (req.createdAt && now - req.createdAt > REQUEST_TTL) {
pendingPermissionRequests.delete(id);
}
}
}, 60000);
// Helper to set permission mode via control protocol (needs claudeProcess)
const setPermissionModeViaControl = (mode) => {
if (!claudeProcess) return;
const modeRequestId = generateRequestId();
const modeRequest = {
type: 'control_request',
request_id: modeRequestId,
request: {
subtype: 'set_permission_mode',
mode: mode
}
};
console.log(`[${sessionId}] Auto-switching permission mode to: ${mode}`);
claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n');
pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() });
};
const sendToClient = (type, data) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
try {
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
} catch (err) {
console.error(`[${sessionId}] WebSocket send failed:`, err.message);
}
}
};
@@ -304,6 +555,7 @@ wss.on('connection', (ws, req) => {
}
currentProject = projectPath;
currentHostId = hostId; // Save for potential restart
// Get host config
const host = hostId ? hostsConfig.hosts[hostId] : null;
@@ -316,8 +568,8 @@ wss.on('connection', (ws, req) => {
'--output-format', 'stream-json',
'--input-format', 'stream-json',
'--include-partial-messages',
'--verbose',
'--dangerously-skip-permissions'
'--verbose'
// Note: No --dangerously-skip-permissions - we handle permissions via control protocol
];
// Add continue flag to resume most recent conversation
@@ -362,26 +614,133 @@ wss.on('connection', (ws, req) => {
project: projectPath
});
// Send control protocol initialization after process starts
const initializeControlProtocol = () => {
const requestId = generateRequestId();
const initRequest = {
type: 'control_request',
request_id: requestId,
request: {
subtype: 'initialize',
hooks: null
}
};
console.log(`[${sessionId}] Sending control initialization`);
claudeProcess.stdin.write(JSON.stringify(initRequest) + '\n');
pendingControlRequests.set(requestId, { type: 'initialize', createdAt: Date.now() });
};
// Small delay to ensure process is ready
setTimeout(initializeControlProtocol, 100);
// Handle stdout (JSON events)
let buffer = '';
let pendingLine = '';
claudeProcess.stdout.on('data', (data) => {
const chunk = data.toString();
console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200));
if (DEBUG) console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200));
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
const parts = (pendingLine + chunk).split('\n');
pendingLine = parts.pop() || '';
for (const line of lines) {
for (const line of parts) {
if (line.trim()) {
console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
if (DEBUG) console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
try {
const event = JSON.parse(line);
console.log(`[${sessionId}] Event type:`, event.type);
if (DEBUG) console.log(`[${sessionId}] Event type:`, event.type);
// Handle control protocol requests (permission prompts from CLI)
if (event.type === 'control_request') {
const request = event.request;
const requestId = event.request_id;
if (request?.subtype === 'can_use_tool') {
if (DEBUG) console.log(`[${sessionId}] Permission request for tool: ${request.tool_name}`);
// Special handling for ExitPlanMode - always send to UI for approval
const isPlanApproval = request.tool_name === 'ExitPlanMode';
// Track plan mode state for ExitPlanMode
if (isPlanApproval && !inPlanMode) {
savedPermissionMode = currentPermissionMode;
inPlanMode = true;
if (DEBUG) console.log(`[${sessionId}] Entering plan mode, saved mode: ${savedPermissionMode}`);
}
// Store the request so we know which tool is being approved/denied
pendingPermissionRequests.set(requestId, {
toolName: request.tool_name,
toolInput: request.input,
isPlanApproval,
createdAt: Date.now()
});
// Send to frontend for user approval
sendToClient('permission_request', {
requestId,
toolName: request.tool_name,
toolInput: request.input,
permissionSuggestions: request.permission_suggestions,
blockedPath: request.blocked_path,
isPlanApproval // Flag for special UI treatment
});
}
continue;
}
// Handle control protocol responses
if (event.type === 'control_response') {
const response = event.response;
const requestId = response?.request_id;
const pending = pendingControlRequests.get(requestId);
if (pending) {
pendingControlRequests.delete(requestId);
if (response.subtype === 'success') {
if (pending.type === 'initialize') {
isInitialized = true;
console.log(`[${sessionId}] Control protocol initialized`);
// Send available commands/models to frontend
sendToClient('control_initialized', {
commands: response.response?.commands,
models: response.response?.models,
account: response.response?.account
});
} else if (pending.type === 'set_permission_mode') {
currentPermissionMode = pending.mode;
console.log(`[${sessionId}] Permission mode changed to: ${currentPermissionMode}`);
sendToClient('permission_mode_changed', {
mode: currentPermissionMode
});
}
} else if (response.subtype === 'error') {
console.error(`[${sessionId}] Control request error:`, response.error);
sendToClient('control_error', {
requestId,
error: response.error
});
}
}
// Don't forward control responses as regular events
continue;
}
// Debug: log all tool_use blocks in assistant messages
if (event.type === 'assistant' && event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_use') {
if (DEBUG) console.log(`[${sessionId}] Tool use detected: ${block.name}`);
}
}
}
// Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool)
// to avoid tool_result/control_response confusion and message compaction issues
sendToClient('claude_event', { event });
} catch (e) {
// Non-JSON output, send as raw
console.log(`[${sessionId}] Raw output:`, line.substring(0, 100));
if (DEBUG) console.log(`[${sessionId}] Raw output:`, line.substring(0, 100));
sendToClient('raw_output', { content: line });
}
}
@@ -391,7 +750,7 @@ wss.on('connection', (ws, req) => {
// Handle stderr
claudeProcess.stderr.on('data', (data) => {
const content = data.toString();
console.log(`[${sessionId}] stderr:`, content);
if (DEBUG) console.log(`[${sessionId}] stderr:`, content);
sendToClient('stderr', { content });
});
@@ -411,7 +770,7 @@ wss.on('connection', (ws, req) => {
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
console.log(`[${sessionId}] Received:`, data.type);
if (DEBUG) console.log(`[${sessionId}] Received:`, data.type);
switch (data.type) {
case 'start_session':
@@ -432,7 +791,7 @@ wss.on('connection', (ws, req) => {
}
};
console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...');
if (DEBUG) console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...');
claudeProcess.stdin.write(JSON.stringify(payload) + '\n');
break;
@@ -443,6 +802,139 @@ wss.on('connection', (ws, req) => {
}
break;
case 'stop_generation':
// Kill the process and restart with --continue to resume session
if (claudeProcess) {
console.log(`[${sessionId}] Stop generation: killing process and restarting`);
// Save current state for restart
const restartProject = currentProject;
const restartHost = currentHostId;
const restartPermissionMode = currentPermissionMode;
// Kill the process
claudeProcess.kill('SIGKILL');
claudeProcess = null;
isInitialized = false;
// Notify frontend
sendToClient('generation_stopped', {
message: 'Generation stopped, reconnecting...',
timestamp: Date.now()
});
// Restart after a short delay
setTimeout(() => {
console.log(`[${sessionId}] Restarting session with --continue`);
startClaudeSession(restartProject, true, restartHost);
// Restore permission mode after initialization
savedPermissionMode = restartPermissionMode;
}, 500);
} else {
sendToClient('generation_stopped', {
message: 'No active process',
timestamp: Date.now()
});
}
break;
case 'set_permission_mode':
if (!claudeProcess) {
sendToClient('error', { message: 'No active Claude session' });
return;
}
if (!isInitialized) {
sendToClient('error', { message: 'Control protocol not yet initialized' });
return;
}
// Don't allow mode change while in plan mode - plan mode takes priority
if (inPlanMode) {
console.log(`[${sessionId}] Ignoring set_permission_mode while in plan mode (saving ${data.mode} for later)`);
// Save what the user wanted so we can restore it after plan approval
savedPermissionMode = data.mode;
// Notify frontend that we're still in plan mode
sendToClient('permission_mode', { mode: 'plan', reason: 'plan_mode_active' });
return;
}
const mode = data.mode;
const validModes = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
if (!validModes.includes(mode)) {
sendToClient('error', { message: `Invalid permission mode: ${mode}. Valid: ${validModes.join(', ')}` });
return;
}
const modeRequestId = generateRequestId();
const modeRequest = {
type: 'control_request',
request_id: modeRequestId,
request: {
subtype: 'set_permission_mode',
mode: mode
}
};
console.log(`[${sessionId}] Setting permission mode to: ${mode}`);
claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n');
pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() });
break;
case 'get_permission_mode':
sendToClient('permission_mode', { mode: currentPermissionMode });
break;
case 'permission_response':
if (!claudeProcess) {
sendToClient('error', { message: 'No active Claude session' });
return;
}
const pendingPerm = pendingPermissionRequests.get(data.requestId);
if (pendingPerm) {
pendingPermissionRequests.delete(data.requestId);
}
// All permission responses (including ExitPlanMode/plan approval) use control_response
// This avoids tool_result issues when message history is compacted
const permResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: data.requestId,
response: data.allow
? { behavior: 'allow', updated_input: data.updatedInput || null }
: { behavior: 'deny', message: data.message || 'User denied permission' }
}
};
console.log(`[${sessionId}] Sending permission response: ${data.allow ? 'allow' : 'deny'} for ${data.requestId}`);
claudeProcess.stdin.write(JSON.stringify(permResponse) + '\n');
// Handle plan mode state updates for ExitPlanMode
if (pendingPerm?.isPlanApproval) {
if (data.allow) {
const modeToRestore = savedPermissionMode || 'default';
console.log(`[${sessionId}] ExitPlanMode approved - restoring mode to '${modeToRestore}'`);
inPlanMode = false;
savedPermissionMode = null;
// Restore previous permission mode after a small delay
setTimeout(() => {
setPermissionModeViaControl(modeToRestore);
}, 100);
sendToClient('plan_mode_exited', { approved: true });
} else {
console.log(`[${sessionId}] ExitPlanMode rejected`);
sendToClient('plan_mode_exited', { approved: false });
}
}
break;
// Note: plan_approval case removed - now handled via permission_response with isPlanApproval flag
default:
console.log(`[${sessionId}] Unknown message type:`, data.type);
}