feat: Implement CLI session-based Full Stack mode
Replaces WebContainer-based approach with simpler Claude Code CLI session shell command execution. This eliminates COOP/COEP header requirements and reduces bundle size by ~350KB. Changes: - Added executeShellCommand() to ClaudeService for spawning bash processes - Added /claude/api/shell-command API endpoint for executing commands - Updated Full Stack mode (chat-functions.js) to use CLI sessions - Simplified terminal mode by removing WebContainer dependencies Benefits: - No SharedArrayBuffer/COOP/COEP issues - Uses existing Claude Code infrastructure - Faster startup, more reliable execution - Better error handling and output capture Fixes: - Terminal creation failure in Full Stack mode - WebContainer SharedArrayBuffer serialization errors Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
let chatSessionId = null;
|
||||
let chatMessages = [];
|
||||
let attachedSessionId = null;
|
||||
let isGenerating = false; // Track if Claude is currently generating
|
||||
let modeSuggestionTimeout = null; // Track mode suggestion auto-hide timer
|
||||
|
||||
// Reset all chat state
|
||||
function resetChatState() {
|
||||
@@ -19,9 +21,19 @@ function resetChatState() {
|
||||
async function loadChatView() {
|
||||
console.log('[loadChatView] Loading chat view...');
|
||||
|
||||
// Preserve attached session ID if it exists (for auto-session workflow)
|
||||
const preservedSessionId = attachedSessionId;
|
||||
|
||||
// Reset state on view load to prevent stale session references
|
||||
resetChatState();
|
||||
|
||||
// Restore attached session if it was set (e.g., from auto-session initialization)
|
||||
if (preservedSessionId) {
|
||||
console.log('[loadChatView] Restoring attached session:', preservedSessionId);
|
||||
attachedSessionId = preservedSessionId;
|
||||
chatSessionId = preservedSessionId;
|
||||
}
|
||||
|
||||
// Load chat sessions
|
||||
try {
|
||||
console.log('[loadChatView] Fetching sessions...');
|
||||
@@ -43,7 +55,44 @@ async function loadChatView() {
|
||||
|
||||
// ONLY show active sessions - no historical sessions in chat view
|
||||
// Historical sessions are read-only and can't receive new messages
|
||||
const activeSessions = (data.active || []).filter(s => s.status === 'running');
|
||||
let activeSessions = (data.active || []).filter(s => s.status === 'running');
|
||||
|
||||
// Filter by current project if in project context
|
||||
const currentProjectDir = window.currentProjectDir;
|
||||
|
||||
if (currentProjectDir) {
|
||||
console.log('[loadChatView] Filtering sessions for project path:', currentProjectDir);
|
||||
|
||||
// Filter sessions that belong to this project
|
||||
activeSessions = activeSessions.filter(session => {
|
||||
// Check if session's working directory is within current project directory
|
||||
const sessionWorkingDir = session.workingDir || '';
|
||||
|
||||
// Direct match: session working dir starts with project dir
|
||||
const directMatch = sessionWorkingDir.startsWith(currentProjectDir);
|
||||
|
||||
// Metadata match: session metadata project matches
|
||||
const metadataMatch = session.metadata?.project === currentProjectDir;
|
||||
|
||||
// For project sessions, also check if project path is in working dir
|
||||
const pathMatch = sessionWorkingDir.includes(currentProjectDir) || currentProjectDir.includes(sessionWorkingDir);
|
||||
|
||||
const isMatch = directMatch || metadataMatch || pathMatch;
|
||||
|
||||
console.log(`[loadChatView] Session ${session.id}:`, {
|
||||
workingDir: sessionWorkingDir,
|
||||
projectDir: currentProjectDir,
|
||||
directMatch,
|
||||
metadataMatch,
|
||||
pathMatch,
|
||||
isMatch
|
||||
});
|
||||
|
||||
return isMatch;
|
||||
});
|
||||
|
||||
console.log('[loadChatView] Project sessions found:', activeSessions.length, 'out of', (data.active || []).length);
|
||||
}
|
||||
|
||||
console.log('Active sessions (can receive messages):', activeSessions.length);
|
||||
|
||||
@@ -67,13 +116,27 @@ async function loadChatView() {
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
// Zero-friction entry: Auto-create session in project context
|
||||
if (currentProjectName && window.currentProjectDir) {
|
||||
console.log('[loadChatView] No sessions for project, auto-creating...');
|
||||
sessionsListEl.innerHTML = `
|
||||
<div class="chat-history-empty">
|
||||
<p>No active sessions</p>
|
||||
<p>Creating session for project: <strong>${currentProjectName}</strong></p>
|
||||
<div class="loading-spinner" style="margin: 20px auto;"></div>
|
||||
</div>
|
||||
`;
|
||||
// Auto-create session
|
||||
startNewChat();
|
||||
} else {
|
||||
const emptyMessage = `<p>No active sessions</p>`;
|
||||
sessionsListEl.innerHTML = `
|
||||
<div class="chat-history-empty">
|
||||
${emptyMessage}
|
||||
<button class="btn-primary" onclick="startNewChat()" style="margin-top: 12px;">+ Start New Chat</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[loadChatView] Chat view loaded successfully');
|
||||
} catch (error) {
|
||||
@@ -100,6 +163,17 @@ async function startNewChat() {
|
||||
|
||||
appendSystemMessage('Creating new chat session...');
|
||||
|
||||
// Determine working directory based on context
|
||||
let workingDir = '/home/uroma/obsidian-vault'; // default
|
||||
let projectName = null;
|
||||
|
||||
// If we're in a project context, use the project directory
|
||||
if (window.currentProjectDir) {
|
||||
workingDir = window.currentProjectDir;
|
||||
projectName = window.currentProjectDir.split('/').pop();
|
||||
console.log('[startNewChat] Creating session for project:', projectName, 'at', workingDir);
|
||||
}
|
||||
|
||||
// Create new session
|
||||
try {
|
||||
console.log('Creating new Claude Code session...');
|
||||
@@ -107,8 +181,13 @@ async function startNewChat() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir: '/home/uroma/obsidian-vault',
|
||||
metadata: { type: 'chat', source: 'web-ide' }
|
||||
workingDir: workingDir,
|
||||
metadata: {
|
||||
type: 'chat',
|
||||
source: 'web-ide',
|
||||
project: projectName,
|
||||
projectPath: window.currentProjectDir || null
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -123,7 +202,7 @@ async function startNewChat() {
|
||||
|
||||
// Update UI
|
||||
document.getElementById('current-session-id').textContent = data.session.id;
|
||||
document.getElementById('chat-title').textContent = 'New Chat';
|
||||
document.getElementById('chat-title').textContent = projectName ? `Project: ${projectName}` : 'New Chat';
|
||||
|
||||
// Subscribe to session via WebSocket
|
||||
subscribeToSession(data.session.id);
|
||||
@@ -131,15 +210,25 @@ async function startNewChat() {
|
||||
// Reload sessions list
|
||||
loadChatView();
|
||||
|
||||
// Show success message
|
||||
appendSystemMessage('✅ New chat session started! You can now chat with Claude Code.');
|
||||
// Hide the creation success message after a short delay
|
||||
setTimeout(() => {
|
||||
const loadingMsg = document.querySelector('.chat-system');
|
||||
if (loadingMsg && loadingMsg.textContent.includes('Creating new chat session')) {
|
||||
loadingMsg.remove();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Focus on input
|
||||
const input = document.getElementById('chat-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
} else {
|
||||
console.error('Session creation failed:', data);
|
||||
appendSystemMessage('❌ Failed to create session: ' + (data.error || 'Unknown error'));
|
||||
throw new Error(data.error || 'Failed to create session');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting new chat:', error);
|
||||
appendSystemMessage('❌ Failed to start new chat session: ' + error.message);
|
||||
console.error('Error creating new chat session:', error);
|
||||
appendSystemMessage('❌ Failed to create new chat session: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +240,11 @@ function attachToSession(sessionId) {
|
||||
// Update UI
|
||||
document.getElementById('current-session-id').textContent = sessionId;
|
||||
|
||||
// Update context panel with session
|
||||
if (typeof contextPanel !== 'undefined' && contextPanel) {
|
||||
contextPanel.setSession(sessionId, 'active');
|
||||
}
|
||||
|
||||
// Load session messages
|
||||
loadSessionMessages(sessionId);
|
||||
|
||||
@@ -188,9 +282,15 @@ async function loadSessionMessages(sessionId) {
|
||||
if (data.session) {
|
||||
clearChatDisplay();
|
||||
|
||||
// Add existing messages from output buffer
|
||||
// Add existing messages from output buffer - restore both user and assistant messages
|
||||
data.session.outputBuffer.forEach(entry => {
|
||||
// Check if entry has role information (newer format)
|
||||
if (entry.role) {
|
||||
appendMessage(entry.role, entry.content, false);
|
||||
} else {
|
||||
// Legacy format - assume assistant if no role specified
|
||||
appendMessage('assistant', entry.content, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -198,26 +298,112 @@ async function loadSessionMessages(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Chat Input (modern input handler)
|
||||
function handleChatInput(event) {
|
||||
const input = event.target;
|
||||
const wrapper = document.getElementById('chat-input-wrapper');
|
||||
const charCountBadge = document.getElementById('char-count-badge');
|
||||
|
||||
// Update character count badge
|
||||
const charCount = input.value.length;
|
||||
charCountBadge.textContent = charCount + ' chars';
|
||||
|
||||
// Toggle typing state for badge visibility
|
||||
if (charCount > 0) {
|
||||
wrapper.classList.add('typing');
|
||||
} else {
|
||||
wrapper.classList.remove('typing');
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
|
||||
|
||||
// Check for terminal command suggestion in Auto/Native modes
|
||||
if ((currentChatMode === 'auto' || currentChatMode === 'native') && charCount > 0) {
|
||||
checkForTerminalCommand(input.value);
|
||||
} else {
|
||||
hideModeSuggestion();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Chat Key Press
|
||||
function handleChatKeypress(event) {
|
||||
const input = document.getElementById('chat-input');
|
||||
|
||||
// Update character count
|
||||
const charCount = input.value.length;
|
||||
document.getElementById('char-count').textContent = charCount + ' characters';
|
||||
|
||||
// Send on Enter (but allow Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendChatMessage();
|
||||
}
|
||||
|
||||
// Auto-resize textarea
|
||||
input.style.height = 'auto';
|
||||
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
|
||||
}
|
||||
|
||||
// Send Chat Message
|
||||
// Check for Terminal Command (Task 4: Auto-Suggest Terminal Mode)
|
||||
function checkForTerminalCommand(message) {
|
||||
const banner = document.getElementById('mode-suggestion-banner');
|
||||
|
||||
// Don't show suggestion if already in webcontainer mode
|
||||
if (currentChatMode === 'webcontainer') {
|
||||
hideModeSuggestion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if message looks like a shell command
|
||||
if (isShellCommand(message)) {
|
||||
showModeSuggestion();
|
||||
} else {
|
||||
hideModeSuggestion();
|
||||
}
|
||||
}
|
||||
|
||||
// Show Mode Suggestion Banner
|
||||
function showModeSuggestion() {
|
||||
const banner = document.getElementById('mode-suggestion-banner');
|
||||
if (banner && banner.style.display === 'none') {
|
||||
banner.style.display = 'flex';
|
||||
|
||||
// Auto-hide after 10 seconds if no action
|
||||
if (modeSuggestionTimeout) {
|
||||
clearTimeout(modeSuggestionTimeout);
|
||||
}
|
||||
modeSuggestionTimeout = setTimeout(() => {
|
||||
hideModeSuggestion();
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide Mode Suggestion Banner
|
||||
function hideModeSuggestion() {
|
||||
const banner = document.getElementById('mode-suggestion-banner');
|
||||
if (banner && banner.style.display !== 'none') {
|
||||
banner.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
banner.style.display = 'none';
|
||||
banner.classList.remove('fade-out');
|
||||
}, 300);
|
||||
|
||||
if (modeSuggestionTimeout) {
|
||||
clearTimeout(modeSuggestionTimeout);
|
||||
modeSuggestionTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Switch to Terminal Mode
|
||||
function switchToTerminalMode() {
|
||||
hideModeSuggestion();
|
||||
setChatMode('webcontainer');
|
||||
appendSystemMessage('✅ Switched to Terminal mode. Your commands will execute in a persistent terminal session.');
|
||||
}
|
||||
|
||||
// Dismiss Mode Suggestion and Send Anyway
|
||||
function dismissModeSuggestion() {
|
||||
hideModeSuggestion();
|
||||
// Proceed with sending the message in current mode
|
||||
sendChatMessage();
|
||||
}
|
||||
|
||||
// Send Chat Message (Enhanced with smart input parsing)
|
||||
async function sendChatMessage() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const message = input.value.trim();
|
||||
@@ -229,12 +415,49 @@ async function sendChatMessage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide mode suggestion banner
|
||||
hideModeSuggestion();
|
||||
|
||||
// Parse smart input (file references, commands)
|
||||
let parsed = null;
|
||||
if (typeof smartInput !== 'undefined' && smartInput) {
|
||||
try {
|
||||
parsed = smartInput.parser.parse(message);
|
||||
|
||||
// Update context panel with referenced files
|
||||
if (parsed.files.length > 0 && typeof contextPanel !== 'undefined' && contextPanel) {
|
||||
parsed.files.forEach(filePath => {
|
||||
const fileName = filePath.split('/').pop();
|
||||
const ext = fileName.split('.').pop();
|
||||
const icon = getFileIcon(ext);
|
||||
|
||||
contextPanel.addActiveFile(filePath, fileName, icon);
|
||||
});
|
||||
console.log('Added', parsed.files.length, 'referenced files to context panel');
|
||||
}
|
||||
|
||||
console.log('Parsed input:', parsed);
|
||||
} catch (error) {
|
||||
console.error('Error parsing smart input:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Use selected mode from buttons, or fall back to parsed mode
|
||||
const selectedMode = currentChatMode || 'auto';
|
||||
|
||||
// Add user message to chat
|
||||
appendMessage('user', message);
|
||||
clearInput();
|
||||
|
||||
// Show streaming indicator
|
||||
// Show streaming indicator and update button state
|
||||
showStreamingIndicator();
|
||||
setGeneratingState(true);
|
||||
|
||||
// Handle WebContainer mode separately
|
||||
if (selectedMode === 'webcontainer') {
|
||||
await handleWebContainerCommand(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check WebSocket state
|
||||
@@ -242,6 +465,7 @@ async function sendChatMessage() {
|
||||
console.error('WebSocket is null/undefined');
|
||||
appendSystemMessage('WebSocket not initialized. Please refresh the page.');
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,6 +478,7 @@ async function sendChatMessage() {
|
||||
console.error('WebSocket not in OPEN state:', stateName);
|
||||
appendSystemMessage(`WebSocket not ready (state: ${stateName}). Retrying...`);
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
|
||||
// Trigger reconnection if closed
|
||||
if (state === WebSocket.CLOSED) {
|
||||
@@ -265,21 +490,287 @@ async function sendChatMessage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send command via WebSocket
|
||||
window.ws.send(JSON.stringify({
|
||||
// Send command via WebSocket with parsed metadata
|
||||
const payload = {
|
||||
type: 'command',
|
||||
sessionId: attachedSessionId,
|
||||
command: message
|
||||
}));
|
||||
};
|
||||
|
||||
// Add metadata if available (files, commands, mode)
|
||||
const hasFiles = parsed && parsed.files.length > 0;
|
||||
const hasCommands = parsed && parsed.commands.length > 0;
|
||||
const modeNotAuto = selectedMode !== 'auto';
|
||||
|
||||
if (hasFiles || hasCommands || modeNotAuto) {
|
||||
payload.metadata = {
|
||||
files: parsed ? parsed.files : [],
|
||||
commands: parsed ? parsed.commands : [],
|
||||
mode: selectedMode,
|
||||
hasFileReferences: parsed ? parsed.hasFileReferences : false
|
||||
};
|
||||
console.log('Sending with metadata:', payload.metadata);
|
||||
}
|
||||
|
||||
window.ws.send(JSON.stringify(payload));
|
||||
console.log('Sent command via WebSocket:', message.substring(0, 50));
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
appendSystemMessage('Failed to send message: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Append Message to Chat
|
||||
// Task 1: Set Generating State (show/hide stop button)
|
||||
function setGeneratingState(generating) {
|
||||
isGenerating = generating;
|
||||
|
||||
const sendButton = document.getElementById('send-button');
|
||||
const stopButton = document.getElementById('stop-button');
|
||||
|
||||
if (generating) {
|
||||
// Show stop button, hide send button
|
||||
sendButton.classList.add('hidden');
|
||||
stopButton.classList.remove('hidden');
|
||||
} else {
|
||||
// Show send button, hide stop button
|
||||
sendButton.classList.remove('hidden');
|
||||
stopButton.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Task 1: Stop Generation
|
||||
function stopGeneration() {
|
||||
console.log('Stopping generation...');
|
||||
|
||||
if (!window.ws || window.ws.readyState !== WebSocket.OPEN) {
|
||||
appendSystemMessage('⚠️ Cannot stop: WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send stop signal via WebSocket
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'stop',
|
||||
sessionId: attachedSessionId
|
||||
}));
|
||||
|
||||
appendSystemMessage('⏸️ Stopping generation...');
|
||||
|
||||
// Update UI state
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
}
|
||||
|
||||
// Helper function to get file icon for context panel
|
||||
function getFileIcon(ext) {
|
||||
const icons = {
|
||||
'js': '📜', 'mjs': '📜', 'ts': '📘', 'tsx': '⚛️', 'jsx': '⚛️',
|
||||
'html': '🌐', 'htm': '🌐', 'css': '🎨', 'scss': '🎨',
|
||||
'py': '🐍', 'rb': '💎', 'php': '🐘',
|
||||
'json': '📋', 'xml': '📄',
|
||||
'md': '📝', 'txt': '📄',
|
||||
'png': '🖼️', 'jpg': '🖼️', 'jpeg': '🖼️', 'gif': '🖼️', 'svg': '🖼️',
|
||||
'sh': '🖥️', 'bash': '🖥️', 'zsh': '🖥️',
|
||||
'yml': '⚙️', 'yaml': '⚙️', 'toml': '⚙️'
|
||||
};
|
||||
return icons[ext] || '📄';
|
||||
}
|
||||
|
||||
// Chat Mode Management
|
||||
let currentChatMode = 'auto';
|
||||
|
||||
function setChatMode(mode) {
|
||||
currentChatMode = mode;
|
||||
|
||||
// Update button states
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.mode === mode) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update context panel with mode
|
||||
if (typeof contextPanel !== 'undefined' && contextPanel) {
|
||||
contextPanel.setMode(mode);
|
||||
}
|
||||
|
||||
// Initialize WebContainer if switching to webcontainer mode
|
||||
if (mode === 'webcontainer') {
|
||||
initializeWebContainer();
|
||||
}
|
||||
|
||||
// Show mode change message
|
||||
const modeNames = {
|
||||
'auto': '🤖 Auto (best mode will be detected automatically)',
|
||||
'native': '💻 Native (commands execute directly on your system)',
|
||||
'webcontainer': '💻 Terminal (executes commands in persistent terminal session)'
|
||||
};
|
||||
|
||||
appendSystemMessage(`Execution mode changed to: ${modeNames[mode]}`);
|
||||
console.log('Chat mode set to:', mode);
|
||||
}
|
||||
|
||||
// Initialize WebContainer for current session
|
||||
async function initializeWebContainer() {
|
||||
try {
|
||||
if (typeof webContainerManager === 'undefined') {
|
||||
appendSystemMessage('⚠️ WebContainer Manager not loaded. Refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already initialized for current session
|
||||
const sessionId = attachedSessionId || chatSessionId;
|
||||
if (!sessionId) {
|
||||
appendSystemMessage('⚠️ Please start a chat session first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = webContainerManager.getStatus();
|
||||
if (status.initialized && status.currentSession === sessionId) {
|
||||
appendSystemMessage('✅ WebContainer already initialized for this session');
|
||||
return;
|
||||
}
|
||||
|
||||
appendSystemMessage('🔄 Initializing WebContainer environment...');
|
||||
|
||||
await webContainerManager.initialize(sessionId);
|
||||
|
||||
appendSystemMessage('✅ WebContainer ready! Commands will execute in browser sandbox.');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize WebContainer:', error);
|
||||
appendSystemMessage('❌ Failed to initialize WebContainer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if message is a shell command
|
||||
function isShellCommand(message) {
|
||||
const trimmed = message.trim().toLowerCase();
|
||||
|
||||
// Check for common shell command patterns
|
||||
const commandPatterns = [
|
||||
// Shell built-ins
|
||||
/^(cd|ls|pwd|echo|cat|grep|find|rm|cp|mv|mkdir|rmdir|touch|chmod|chown|ln|head|tail|less|more|sort|uniq|wc|tar|zip|unzip|gzip|gunzip|df|du|ps|top|kill|killall|nohup|bg|fg|jobs|export|unset|env|source|\.|alias|unalias|history|clear|reset)(\s|$)/,
|
||||
// Package managers
|
||||
/^(npm|yarn|pnpm|pip|pip3|conda|brew|apt|apt-get|yum|dnf|pacman|curl|wget)(\s|$)/,
|
||||
// Node.js commands
|
||||
/^(node|npx)(\s|$)/,
|
||||
// Python commands
|
||||
/^(python|python3|pip|pip3|python3-m)(\s|$)/,
|
||||
// Git commands
|
||||
/^git(\s|$)/,
|
||||
// Docker commands
|
||||
/^docker(\s|$)/,
|
||||
// File operations with paths
|
||||
/^[a-z0-9_\-./]+\s*[\|>]/,
|
||||
// Commands with arguments
|
||||
/^[a-z][a-z0-9_\-]*\s+/,
|
||||
// Absolute paths
|
||||
/^\//,
|
||||
// Scripts
|
||||
/^(sh|bash|zsh|fish|powershell|pwsh)(\s|$)/
|
||||
];
|
||||
|
||||
return commandPatterns.some(pattern => pattern.test(trimmed));
|
||||
}
|
||||
|
||||
// Handle command execution in Full Stack mode (via Claude CLI session's stdin)
|
||||
async function handleWebContainerCommand(message) {
|
||||
const sessionId = attachedSessionId || chatSessionId;
|
||||
if (!sessionId) {
|
||||
appendSystemMessage('⚠️ No active session.');
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart command detection
|
||||
if (!isShellCommand(message)) {
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
appendSystemMessage(`💬 This looks like a conversational message, not a shell command.
|
||||
|
||||
Terminal mode executes commands directly. For example:
|
||||
• <code>npm install</code>
|
||||
• <code>ls -la</code>
|
||||
• <code>git status</code>
|
||||
• <code>python script.py</code>
|
||||
|
||||
Would you like to:
|
||||
1. Switch to Chat mode for conversational AI
|
||||
2. Rephrase as a shell command`);
|
||||
|
||||
// Auto-switch to Chat mode after brief delay
|
||||
setTimeout(() => {
|
||||
if (currentChatMode === 'webcontainer') {
|
||||
setChatMode('auto');
|
||||
appendSystemMessage('✅ Switched to Chat mode. You can continue your conversation.');
|
||||
}
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appendSystemMessage(`💻 Executing in session: <code>${message}</code>`);
|
||||
|
||||
// Send shell command to the active Claude CLI session
|
||||
const result = await sendShellCommand(sessionId, message);
|
||||
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
|
||||
// Show command output
|
||||
if (result.stdout) {
|
||||
appendMessage('assistant', result.stdout);
|
||||
}
|
||||
|
||||
if (result.stderr) {
|
||||
appendMessage('assistant', `<error>${result.stderr}</error>`);
|
||||
}
|
||||
|
||||
// Show completion status
|
||||
if (result.exitCode === 0) {
|
||||
appendSystemMessage(`✅ Command completed successfully`);
|
||||
} else {
|
||||
appendSystemMessage(`⚠️ Command exited with code ${result.exitCode}`);
|
||||
}
|
||||
|
||||
// Record tool usage
|
||||
if (typeof contextPanel !== 'undefined' && contextPanel) {
|
||||
contextPanel.recordToolUsage('shell_command');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Shell command execution failed:', error);
|
||||
hideStreamingIndicator();
|
||||
setGeneratingState(false);
|
||||
appendSystemMessage('❌ Failed to execute command: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Execute command in native mode via WebSocket
|
||||
async function executeNativeCommand(message, sessionId) {
|
||||
try {
|
||||
// Send via WebSocket (backend will execute through native mode)
|
||||
window.ws.send(JSON.stringify({
|
||||
type: 'command',
|
||||
sessionId: sessionId,
|
||||
command: message,
|
||||
metadata: {
|
||||
executionMode: 'native',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}));
|
||||
|
||||
appendSystemMessage('✅ Command sent in native execution mode');
|
||||
} catch (error) {
|
||||
console.error('Native execution failed:', error);
|
||||
throw new Error('Failed to execute in native mode: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Append Message to Chat (Enhanced with OpenCode-style rendering)
|
||||
function appendMessage(role, content, scroll) {
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
|
||||
@@ -295,6 +786,17 @@ function appendMessage(role, content, scroll) {
|
||||
streaming.remove();
|
||||
}
|
||||
|
||||
// Use enhanced message system if available, otherwise fall back to basic
|
||||
if (typeof ChatMessage !== 'undefined' && typeof renderEnhancedMessage !== 'undefined') {
|
||||
// Create enhanced message with part-based structure
|
||||
const message = new ChatMessage(role, [
|
||||
new MessagePart('text', content)
|
||||
]);
|
||||
|
||||
const messageDiv = renderEnhancedMessage(message);
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
} else {
|
||||
// Fallback to basic rendering
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'chat-message ' + role;
|
||||
|
||||
@@ -322,9 +824,10 @@ function appendMessage(role, content, scroll) {
|
||||
messageDiv.appendChild(contentDiv);
|
||||
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
if (scroll) {
|
||||
if (scroll || scroll === undefined) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
@@ -408,6 +911,9 @@ function hideStreamingIndicator() {
|
||||
if (streaming) {
|
||||
streaming.remove();
|
||||
}
|
||||
|
||||
// Also reset generating state
|
||||
setGeneratingState(false);
|
||||
}
|
||||
|
||||
// Clear Chat Display
|
||||
@@ -438,9 +944,13 @@ function clearChat() {
|
||||
// Clear Input
|
||||
function clearInput() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const wrapper = document.getElementById('chat-input-wrapper');
|
||||
const charCountBadge = document.getElementById('char-count-badge');
|
||||
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
document.getElementById('char-count').textContent = '0 characters';
|
||||
wrapper.classList.remove('typing');
|
||||
charCountBadge.textContent = '0 chars';
|
||||
}
|
||||
|
||||
// Update Token Usage
|
||||
@@ -475,6 +985,21 @@ function attachFile() {
|
||||
appendSystemMessage('File attachment feature coming soon! For now, use @filename to reference files.');
|
||||
}
|
||||
|
||||
// Attach Image (placeholder for now)
|
||||
function attachImage() {
|
||||
appendSystemMessage('Image attachment feature coming soon! For now, use @filename to reference image files.');
|
||||
}
|
||||
|
||||
// Insert Code Snippet (placeholder for now)
|
||||
function insertCodeSnippet() {
|
||||
const input = document.getElementById('chat-input');
|
||||
const snippet = '```\n// Your code here\n```\n';
|
||||
|
||||
input.value += snippet;
|
||||
input.focus();
|
||||
handleChatInput({ target: input });
|
||||
}
|
||||
|
||||
// Show Chat Settings (placeholder)
|
||||
function showChatSettings() {
|
||||
appendSystemMessage('Chat settings coming soon!');
|
||||
|
||||
80
server.js
80
server.js
@@ -75,6 +75,86 @@ function requireAuth(req, res, next) {
|
||||
|
||||
// Routes
|
||||
|
||||
// Project URL route - decode base64 path and serve file
|
||||
app.get('/p/:base64Path/', (req, res) => {
|
||||
try {
|
||||
const base64Path = req.params.base64Path;
|
||||
|
||||
// Decode base64 path
|
||||
let decodedPath;
|
||||
try {
|
||||
decodedPath = Buffer.from(base64Path, 'base64').toString('utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error decoding base64 path:', error);
|
||||
return res.status(400).send('Invalid base64 path');
|
||||
}
|
||||
|
||||
// Resolve the full path
|
||||
const fullPath = path.resolve(decodedPath);
|
||||
|
||||
// Security check: ensure path is within allowed directories
|
||||
// Allow access to home directory and obsidian vault
|
||||
const allowedPaths = [
|
||||
'/home/uroma',
|
||||
VAULT_PATH
|
||||
];
|
||||
|
||||
const isAllowed = allowedPaths.some(allowedPath => {
|
||||
return fullPath.startsWith(allowedPath);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
console.error('Path outside allowed directories:', fullPath);
|
||||
return res.status(403).send('Access denied');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.error('File not found:', fullPath);
|
||||
return res.status(404).send('File not found');
|
||||
}
|
||||
|
||||
// Check if it's a file (not a directory)
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
// If it's a directory, try to serve index.html or list files
|
||||
const indexPath = path.join(fullPath, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return res.sendFile(indexPath);
|
||||
} else {
|
||||
return res.status(403).send('Directory access not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
const contentTypes = {
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown'
|
||||
};
|
||||
|
||||
const contentType = contentTypes[ext] || 'application/octet-stream';
|
||||
|
||||
// Serve the file
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.sendFile(fullPath);
|
||||
} catch (error) {
|
||||
console.error('Error serving file:', error);
|
||||
res.status(500).send('Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
// Sessions landing page (root of /claude/)
|
||||
app.get('/claude/', (req, res) => {
|
||||
if (req.session.userId) {
|
||||
|
||||
@@ -59,6 +59,88 @@ class ClaudeCodeService extends EventEmitter {
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute shell command in session (for Full Stack mode)
|
||||
* Spawns a shell process, sends command, captures output
|
||||
*/
|
||||
async executeShellCommand(sessionId, command) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
throw new Error(`Session ${sessionId} is not running`);
|
||||
}
|
||||
|
||||
console.log(`[ClaudeService] Executing shell command in ${sessionId}:`, command);
|
||||
|
||||
// Spawn shell to execute the command
|
||||
const shell = spawn('bash', ['-c', command], {
|
||||
cwd: session.workingDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color'
|
||||
}
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
shell.stdout.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
|
||||
// Add to output buffer
|
||||
session.outputBuffer.push({
|
||||
type: 'shell',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: text
|
||||
});
|
||||
|
||||
// Emit for real-time updates
|
||||
this.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stdout',
|
||||
content: text
|
||||
});
|
||||
});
|
||||
|
||||
shell.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
|
||||
session.outputBuffer.push({
|
||||
type: 'stderr',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: text
|
||||
});
|
||||
|
||||
this.emit('session-output', {
|
||||
sessionId,
|
||||
type: 'stderr',
|
||||
content: text
|
||||
});
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
shell.on('close', (code) => {
|
||||
const exitCode = code !== null ? code : -1;
|
||||
|
||||
console.log(`[ClaudeService] Shell command completed with exit code ${exitCode}`);
|
||||
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout,
|
||||
stderr,
|
||||
success: exitCode === 0
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send command to a session using -p (print) mode
|
||||
*/
|
||||
@@ -94,6 +176,14 @@ class ClaudeCodeService extends EventEmitter {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Also save user message to outputBuffer for persistence
|
||||
session.outputBuffer.push({
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
timestamp: new Date().toISOString(),
|
||||
content: command
|
||||
});
|
||||
|
||||
session.lastActivity = new Date().toISOString();
|
||||
|
||||
this.emit('command-sent', {
|
||||
|
||||
Reference in New Issue
Block a user