Initial commit: Obsidian Web Interface for Claude Code
- Full IDE with terminal integration using xterm.js - Session management with local and web sessions - HTML preview functionality - Multi-terminal support with session picker Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
543
public/claude-ide/tag-renderer.js
Normal file
543
public/claude-ide/tag-renderer.js
Normal file
@@ -0,0 +1,543 @@
|
||||
/**
|
||||
* Tag Renderer - Display and manage dyad-style operations in UI
|
||||
*/
|
||||
|
||||
class TagRenderer {
|
||||
constructor() {
|
||||
this.pendingOperations = null;
|
||||
this.isExecuting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tags from text response
|
||||
*/
|
||||
parseTags(response) {
|
||||
const tags = {
|
||||
writes: this.extractWriteTags(response),
|
||||
renames: this.extractRenameTags(response),
|
||||
deletes: this.extractDeleteTags(response),
|
||||
dependencies: this.extractDependencyTags(response),
|
||||
commands: this.extractCommandTags(response)
|
||||
};
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dyad-write tags
|
||||
*/
|
||||
extractWriteTags(response) {
|
||||
const regex = /<dyad-write\s+path="([^"]+)">([\s\S]*?)<\/dyad-write>/g;
|
||||
const tags = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'write',
|
||||
path: match[1],
|
||||
content: match[2].trim()
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dyad-rename tags
|
||||
*/
|
||||
extractRenameTags(response) {
|
||||
const regex = /<dyad-rename\s+from="([^"]+)"\s+to="([^"]+)">/g;
|
||||
const tags = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'rename',
|
||||
from: match[1],
|
||||
to: match[2]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dyad-delete tags
|
||||
*/
|
||||
extractDeleteTags(response) {
|
||||
const regex = /<dyad-delete\s+path="([^"]+)">/g;
|
||||
const tags = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'delete',
|
||||
path: match[1]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dyad-add-dependency tags
|
||||
*/
|
||||
extractDependencyTags(response) {
|
||||
const regex = /<dyad-add-dependency\s+packages="([^"]+)">/g;
|
||||
const tags = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'install',
|
||||
packages: match[1].split(' ').filter(p => p.trim())
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dyad-command tags
|
||||
*/
|
||||
extractCommandTags(response) {
|
||||
const regex = /<dyad-command\s+type="([^"]+)">/g;
|
||||
const tags = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(response)) !== null) {
|
||||
tags.push({
|
||||
type: 'command',
|
||||
command: match[1]
|
||||
});
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate operation summary for display
|
||||
*/
|
||||
generateOperationsSummary(tags) {
|
||||
const operations = [];
|
||||
|
||||
tags.deletes.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'delete',
|
||||
description: `Delete ${tag.path}`,
|
||||
tag,
|
||||
status: 'pending'
|
||||
});
|
||||
});
|
||||
|
||||
tags.renames.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'rename',
|
||||
description: `Rename ${tag.from} → ${tag.to}`,
|
||||
tag,
|
||||
status: 'pending'
|
||||
});
|
||||
});
|
||||
|
||||
tags.writes.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'write',
|
||||
description: `Create/update ${tag.path}`,
|
||||
tag,
|
||||
status: 'pending'
|
||||
});
|
||||
});
|
||||
|
||||
tags.dependencies.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'install',
|
||||
description: `Install packages: ${tag.packages.join(', ')}`,
|
||||
tag,
|
||||
status: 'pending'
|
||||
});
|
||||
});
|
||||
|
||||
tags.commands.forEach(tag => {
|
||||
operations.push({
|
||||
type: 'command',
|
||||
description: `Execute command: ${tag.command}`,
|
||||
tag,
|
||||
status: 'pending'
|
||||
});
|
||||
});
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip tags from response for clean display
|
||||
*/
|
||||
stripTags(response) {
|
||||
let stripped = response;
|
||||
|
||||
stripped = stripped.replace(/<dyad-write\s+path="[^"]+">[\s\S]*?<\/dyad-write>/g, '[File: code]');
|
||||
stripped = stripped.replace(/<dyad-rename\s+from="[^"]+"\s+to="[^"]+">/g, '[Rename]');
|
||||
stripped = stripped.replace(/<dyad-delete\s+path="[^"]+">/g, '[Delete]');
|
||||
stripped = stripped.replace(/<dyad-add-dependency\s+packages="[^"]+">/g, '[Install]');
|
||||
stripped = stripped.replace(/<dyad-command\s+type="[^"]+">/g, '[Command]');
|
||||
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render operations panel HTML
|
||||
*/
|
||||
renderOperationsPanel(operations) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'operations-panel';
|
||||
panel.innerHTML = `
|
||||
<div class="operations-header">
|
||||
<h3>Pending Operations (${operations.length})</h3>
|
||||
<div class="operations-actions">
|
||||
<button class="btn btn-approve-all" onclick="approveAllOperations()">
|
||||
✓ Approve All
|
||||
</button>
|
||||
<button class="btn btn-reject-all" onclick="rejectAllOperations()">
|
||||
✗ Reject All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operations-list">
|
||||
${operations.map((op, index) => this.renderOperationItem(op, index)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render single operation item
|
||||
*/
|
||||
renderOperationItem(operation, index) {
|
||||
const icon = this.getOperationIcon(operation.type);
|
||||
const colorClass = this.getOperationColorClass(operation.type);
|
||||
|
||||
return `
|
||||
<div class="operation-item ${colorClass}" data-index="${index}">
|
||||
<div class="operation-icon">${icon}</div>
|
||||
<div class="operation-details">
|
||||
<div class="operation-description">${operation.description}</div>
|
||||
${operation.tag.content ? `<button class="btn-view-code" onclick="viewCode(${index})">View Code</button>` : ''}
|
||||
</div>
|
||||
<div class="operation-actions">
|
||||
<button class="btn btn-approve" onclick="approveOperation(${index})" title="Approve">✓</button>
|
||||
<button class="btn btn-reject" onclick="rejectOperation(${index})" title="Reject">✗</button>
|
||||
</div>
|
||||
<div class="operation-status">
|
||||
<span class="status-pending">Pending</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for operation type
|
||||
*/
|
||||
getOperationIcon(type) {
|
||||
const icons = {
|
||||
write: '📄',
|
||||
rename: '✏️',
|
||||
delete: '🗑️',
|
||||
install: '📦',
|
||||
command: '⚡'
|
||||
};
|
||||
return icons[type] || '⚙️';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for operation type
|
||||
*/
|
||||
getOperationColorClass(type) {
|
||||
const classes = {
|
||||
write: 'op-write',
|
||||
rename: 'op-rename',
|
||||
delete: 'op-delete',
|
||||
install: 'op-install',
|
||||
command: 'op-command'
|
||||
};
|
||||
return classes[type] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show operations panel
|
||||
*/
|
||||
showOperationsPanel(operations, response) {
|
||||
this.pendingOperations = operations;
|
||||
window.currentOperationsResponse = response;
|
||||
|
||||
// Remove existing panel
|
||||
const existing = document.querySelector('.operations-panel');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
// Add new panel
|
||||
const chatContainer = document.getElementById('chat-messages');
|
||||
const panel = this.renderOperationsPanel(operations);
|
||||
chatContainer.appendChild(panel);
|
||||
|
||||
// Make it visible
|
||||
panel.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update operation status
|
||||
*/
|
||||
updateOperationStatus(index, status) {
|
||||
const operationItem = document.querySelector(`.operation-item[data-index="${index}"]`);
|
||||
if (!operationItem) return;
|
||||
|
||||
const statusElement = operationItem.querySelector('.operation-status');
|
||||
statusElement.innerHTML = `<span class="status-${status}">${status.charAt(0).toUpperCase() + status.slice(1)}</span>`;
|
||||
|
||||
if (status === 'approved' || status === 'rejected') {
|
||||
// Disable buttons
|
||||
const approveBtn = operationItem.querySelector('.btn-approve');
|
||||
const rejectBtn = operationItem.querySelector('.btn-reject');
|
||||
approveBtn.disabled = true;
|
||||
rejectBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all operations status
|
||||
*/
|
||||
updateAllOperationsStatus(status) {
|
||||
if (!this.pendingOperations) return;
|
||||
|
||||
this.pendingOperations.forEach((_, index) => {
|
||||
this.updateOperationStatus(index, status);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide operations panel
|
||||
*/
|
||||
hideOperationsPanel() {
|
||||
const panel = document.querySelector('.operations-panel');
|
||||
if (panel) {
|
||||
panel.remove();
|
||||
}
|
||||
this.pendingOperations = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show progress indicator
|
||||
*/
|
||||
showProgress(message) {
|
||||
const chatContainer = document.getElementById('chat-messages');
|
||||
|
||||
const progress = document.createElement('div');
|
||||
progress.className = 'operation-progress';
|
||||
progress.innerHTML = `
|
||||
<div class="progress-spinner"></div>
|
||||
<div class="progress-message">${message}</div>
|
||||
`;
|
||||
|
||||
chatContainer.appendChild(progress);
|
||||
progress.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress message
|
||||
*/
|
||||
updateProgress(message) {
|
||||
const progressElement = document.querySelector('.operation-progress');
|
||||
if (progressElement) {
|
||||
const messageElement = progressElement.querySelector('.progress-message');
|
||||
if (messageElement) {
|
||||
messageElement.textContent = message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide progress indicator
|
||||
*/
|
||||
hideProgress() {
|
||||
const progressElement = document.querySelector('.operation-progress');
|
||||
if (progressElement) {
|
||||
progressElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show completion message
|
||||
*/
|
||||
showCompletion(results) {
|
||||
const chatContainer = document.querySelector('.chat-container');
|
||||
|
||||
const completion = document.createElement('div');
|
||||
completion.className = 'operation-completion';
|
||||
|
||||
const successCount = results.operations.filter(op => op.success).length;
|
||||
const errorCount = results.errors.length;
|
||||
|
||||
completion.innerHTML = `
|
||||
<div class="completion-header">
|
||||
<span class="completion-icon">✓</span>
|
||||
<h4>Operations Complete</h4>
|
||||
</div>
|
||||
<div class="completion-summary">
|
||||
<span class="success">${successCount} succeeded</span>
|
||||
${errorCount > 0 ? `<span class="error">${errorCount} failed</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
chatContainer.appendChild(completion);
|
||||
completion.style.display = 'block';
|
||||
|
||||
// Auto-hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
completion.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(message) {
|
||||
const chatContainer = document.querySelector('.chat-container');
|
||||
|
||||
const error = document.createElement('div');
|
||||
error.className = 'operation-error';
|
||||
error.innerHTML = `
|
||||
<div class="error-header">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<h4>Operation Failed</h4>
|
||||
</div>
|
||||
<div class="error-message">${message}</div>
|
||||
`;
|
||||
|
||||
chatContainer.appendChild(error);
|
||||
error.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
const tagRenderer = new TagRenderer();
|
||||
|
||||
// Store current response for execution
|
||||
window.currentOperationsResponse = null;
|
||||
|
||||
// Global functions for onclick handlers
|
||||
window.approveOperation = async function(index) {
|
||||
if (!tagRenderer.pendingOperations) return;
|
||||
|
||||
tagRenderer.updateOperationStatus(index, 'approved');
|
||||
};
|
||||
|
||||
window.rejectOperation = function(index) {
|
||||
if (!tagRenderer.pendingOperations) return;
|
||||
|
||||
tagRenderer.updateOperationStatus(index, 'rejected');
|
||||
};
|
||||
|
||||
window.approveAllOperations = async function() {
|
||||
if (!tagRenderer.pendingOperations) return;
|
||||
|
||||
tagRenderer.updateAllOperationsStatus('approved');
|
||||
|
||||
// Execute operations
|
||||
await executeApprovedOperations();
|
||||
};
|
||||
|
||||
window.rejectAllOperations = function() {
|
||||
if (!tagRenderer.pendingOperations) return;
|
||||
|
||||
tagRenderer.hideOperationsPanel();
|
||||
window.currentOperationsResponse = null;
|
||||
};
|
||||
|
||||
window.viewCode = function(index) {
|
||||
if (!tagRenderer.pendingOperations) return;
|
||||
|
||||
const operation = tagRenderer.pendingOperations[index];
|
||||
if (operation.tag && operation.tag.content) {
|
||||
// Show code in modal
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'code-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>${operation.tag.path}</h3>
|
||||
<button class="btn-close" onclick="this.closest('.code-modal').remove()">✕</button>
|
||||
</div>
|
||||
<pre><code>${escapeHtml(operation.tag.content)}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute approved operations
|
||||
*/
|
||||
async function executeApprovedOperations() {
|
||||
if (!window.currentOperationsResponse) {
|
||||
console.error('No response to execute');
|
||||
tagRenderer.showError('No operations to execute');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use attachedSessionId (from chat-functions.js) instead of chatSessionId
|
||||
const sessionId = window.attachedSessionId || window.chatSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
console.error('No session ID - not attached to a session');
|
||||
tagRenderer.showError('Not attached to a session. Please start or attach to a session first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = window.currentOperationsResponse;
|
||||
|
||||
console.log('Executing operations for session:', sessionId);
|
||||
|
||||
try {
|
||||
// Show progress
|
||||
tagRenderer.showProgress('Executing operations...');
|
||||
|
||||
// Call execute API with credentials
|
||||
const res = await fetch(`/claude/api/claude/sessions/${sessionId}/operations/execute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ response })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Execute API error:', data);
|
||||
throw new Error(data.error || 'Failed to execute operations');
|
||||
}
|
||||
|
||||
console.log('Operations executed successfully:', data.results);
|
||||
|
||||
// The WebSocket will handle showing completion
|
||||
// when it receives the operations-executed event
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error executing operations:', error);
|
||||
tagRenderer.hideProgress();
|
||||
tagRenderer.showError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other files
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TagRenderer;
|
||||
}
|
||||
Reference in New Issue
Block a user