Implement terminal approval UI system
Phase 1: Backend approval tracking - Add PendingApprovalsManager class to track pending approvals - Add approval-request, approval-response, approval-expired WebSocket handlers - Add requestApproval() method to ClaudeCodeService - Add event forwarding for approval requests Phase 2: Frontend approval card component - Create approval-card.js with interactive UI - Create approval-card.css with styled component - Add Approve, Custom Instructions, Reject buttons - Add expandable custom command input Phase 3: Wire up approval flow end-to-end - Add handleApprovalRequest, handleApprovalConfirmed, handleApprovalExpired handlers - Add detectApprovalRequest() to parse AI approval request patterns - Integrate approval card into WebSocket message flow - Route approval responses based on source (server vs AI conversational) This allows the AI agent to request command approval through a clean UI instead of confusing conversational text responses. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -291,6 +291,15 @@ function handleWebSocketMessage(data) {
|
||||
case 'operation-progress':
|
||||
handleOperationProgress(data);
|
||||
break;
|
||||
case 'approval-request':
|
||||
handleApprovalRequest(data);
|
||||
break;
|
||||
case 'approval-confirmed':
|
||||
handleApprovalConfirmed(data);
|
||||
break;
|
||||
case 'approval-expired':
|
||||
handleApprovalExpired(data);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('WebSocket error:', data.error);
|
||||
// Show error in chat if attached
|
||||
@@ -390,6 +399,141 @@ function handleOperationProgress(data) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approval request event
|
||||
*/
|
||||
function handleApprovalRequest(data) {
|
||||
console.log('Approval request received:', data);
|
||||
|
||||
// Only handle if we're attached to this session
|
||||
if (data.sessionId !== attachedSessionId) return;
|
||||
|
||||
// Render approval card in chat
|
||||
if (typeof ApprovalCard !== 'undefined' && ApprovalCard.render) {
|
||||
const card = ApprovalCard.render(data);
|
||||
|
||||
// Add to chat messages container
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
if (messagesContainer) {
|
||||
messagesContainer.appendChild(card);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
} else {
|
||||
console.error('[ApprovalCard] Component not loaded');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approval confirmed event
|
||||
*/
|
||||
function handleApprovalConfirmed(data) {
|
||||
console.log('Approval confirmed:', data);
|
||||
|
||||
// Only handle if we're attached to this session
|
||||
if (data.sessionId !== attachedSessionId) return;
|
||||
|
||||
// Show confirmation message
|
||||
const message = data.approved
|
||||
? (data.customCommand
|
||||
? `✅ Approved with custom command: ${data.customCommand}`
|
||||
: '✅ Command approved')
|
||||
: '❌ Command rejected';
|
||||
|
||||
if (typeof appendSystemMessage === 'function') {
|
||||
appendSystemMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle approval expired event
|
||||
*/
|
||||
function handleApprovalExpired(data) {
|
||||
console.log('Approval expired:', data);
|
||||
|
||||
// Update the approval card to show expired state
|
||||
if (typeof ApprovalCard !== 'undefined' && ApprovalCard.handleExpired) {
|
||||
ApprovalCard.handleExpired(data.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect approval request in AI response
|
||||
* @param {string} content - AI response content
|
||||
* @returns {Object|null} - Approval request data or null
|
||||
*/
|
||||
function detectApprovalRequest(content) {
|
||||
if (!content || typeof content !== 'string') return null;
|
||||
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
// Common approval request patterns
|
||||
const approvalPatterns = [
|
||||
// "Would you like me to proceed with X"
|
||||
/would\s+(?:you\s+)?like\s+me\s+to\s+proceed\s+(?:with\s+)?(?:executing\s+)?(?:running\s+)?["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||||
/should\s+i\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||||
/do\s+(?:you\s+)?want\s+me\s+to\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||||
/shall\s+i\s+(?:run|execute)\s+["']?([a-z0-9._\s\-\/]+)["']?/i,
|
||||
// "Proceed with X?"
|
||||
/proceed\s+(?:with\s+)?["']?([a-z0-9._\s\-\/]+)["']?\?/i,
|
||||
// "Execute X?"
|
||||
/execute\s+["']?([a-z0-9._\s\-\/]+)["']?\?/i,
|
||||
// "Run X?"
|
||||
/run\s+["']?([a-z0-9._\s\-\/]+)["']?\?/i
|
||||
];
|
||||
|
||||
for (const pattern of approvalPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
let command = match[1].trim();
|
||||
|
||||
// Clean up the command
|
||||
command = command.replace(/[?\s\.]+$/, '').trim();
|
||||
|
||||
// Extract explanation from the content
|
||||
let explanation = '';
|
||||
const explanationMatch = content.match(/(?:this\s+(?:will|is)\s+(.+?)(?:\.|\n|$))|(?:network\s+operation|file\s+operation|system\s+operation)\s*[:\-]\s*(.+?)(?:\.|\n|$))/i);
|
||||
if (explanationMatch) {
|
||||
explanation = (explanationMatch[1] || explanationMatch[2] || '').trim();
|
||||
}
|
||||
|
||||
// Generate a reasonable explanation if not found
|
||||
if (!explanation) {
|
||||
if (command.includes('ping')) {
|
||||
explanation = 'Network operation - will send ICMP packets to the target host';
|
||||
} else if (command.includes('rm') || command.includes('delete')) {
|
||||
explanation = 'File deletion operation - files will be permanently removed';
|
||||
} else if (command.includes('curl') || command.includes('wget') || command.includes('http')) {
|
||||
explanation = 'Network operation - will make HTTP request(s)';
|
||||
} else if (command.includes('ssh') || command.includes('scp')) {
|
||||
explanation = 'Remote connection operation - will connect to external host';
|
||||
} else {
|
||||
explanation = `Execute command: ${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
explanation,
|
||||
originalText: content
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} - Escaped text
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Streaming message state for accumulating response chunks
|
||||
let streamingMessageElement = null;
|
||||
let streamingMessageContent = '';
|
||||
@@ -410,6 +554,69 @@ function handleSessionOutput(data) {
|
||||
|
||||
const content = data.data.content || '';
|
||||
|
||||
// ============================================================
|
||||
// SEMANTIC VALIDATION - Detect confusing UX messages
|
||||
// ============================================================
|
||||
if (window.semanticValidator && content) {
|
||||
const confusingOutput = window.semanticValidator.detectConfusingOutput(content);
|
||||
if (confusingOutput) {
|
||||
// Report to bug tracker
|
||||
window.semanticValidator.reportSemanticError(confusingOutput);
|
||||
|
||||
// Log for debugging
|
||||
console.warn('[SemanticValidator] Confusing UX message detected:', confusingOutput);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// APPROVAL REQUEST DETECTION - Intercept AI approval requests
|
||||
// ============================================================
|
||||
// Check if AI is asking for approval
|
||||
const approvalRequest = detectApprovalRequest(content);
|
||||
if (approvalRequest && window.ApprovalCard) {
|
||||
console.log('[ApprovalRequest] Detected:', approvalRequest);
|
||||
|
||||
// Don't show the raw AI message - show approval card instead
|
||||
// But we need to still show the explanation part
|
||||
|
||||
// Check if we already have a pending approval for this command
|
||||
const existingCard = document.querySelector(`[data-pending-command="${escapeHtml(approvalRequest.command)}"]`);
|
||||
if (!existingCard) {
|
||||
// Generate a unique ID for this approval
|
||||
const approvalId = `approval-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Render approval card
|
||||
const card = window.ApprovalCard.render({
|
||||
id: approvalId,
|
||||
sessionId: data.sessionId,
|
||||
command: approvalRequest.command,
|
||||
explanation: approvalRequest.explanation || 'AI agent requests approval to execute this command'
|
||||
});
|
||||
|
||||
// Add tracking attribute
|
||||
card.dataset.pendingCommand = approvalRequest.command;
|
||||
|
||||
// Add to chat
|
||||
const messagesContainer = document.getElementById('chat-messages');
|
||||
if (messagesContainer) {
|
||||
messagesContainer.appendChild(card);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
// Store the approval for later response
|
||||
window._pendingApprovals = window._pendingApprovals || {};
|
||||
window._pendingApprovals[approvalId] = {
|
||||
command: approvalRequest.command,
|
||||
sessionId: data.sessionId,
|
||||
originalMessage: content
|
||||
};
|
||||
|
||||
// Don't add the AI's raw text to the chat
|
||||
// Just skip to next iteration
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate streaming content
|
||||
if (streamingMessageElement && streamingMessageElement.isConnected) {
|
||||
// Append to existing message
|
||||
@@ -436,8 +643,50 @@ function handleSessionOutput(data) {
|
||||
if (typeof setGeneratingState === 'function') {
|
||||
setGeneratingState(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// COMMAND TRACKER - Complete command execution
|
||||
// ============================================================
|
||||
if (window.commandTracker && window._pendingCommandId) {
|
||||
// Try to extract exit code from the last output
|
||||
const exitCode = extractExitCode(streamingMessageContent);
|
||||
|
||||
// Complete the command tracking
|
||||
window.commandTracker.completeCommand(
|
||||
window._pendingCommandId,
|
||||
exitCode,
|
||||
streamingMessageContent
|
||||
);
|
||||
|
||||
// Clear pending command ID
|
||||
window._pendingCommandId = null;
|
||||
|
||||
console.log('[CommandTracker] Command completed via stream timeout');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract exit code from command output
|
||||
* @param {string} output - Command output
|
||||
* @returns {number|null} - Exit code or null
|
||||
*/
|
||||
function extractExitCode(output) {
|
||||
if (!output) return null;
|
||||
|
||||
// Look for exit code patterns
|
||||
const exitCodeMatch = output.match(/exited with code (\d+|undefined|null)/i);
|
||||
if (exitCodeMatch) {
|
||||
const code = exitCodeMatch[1];
|
||||
if (code === 'undefined' || code === 'null') {
|
||||
return null; // Undefined/null exit code
|
||||
}
|
||||
return parseInt(code, 10);
|
||||
}
|
||||
|
||||
// If no explicit exit code found, assume success (0)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
|
||||
Reference in New Issue
Block a user