diff --git a/packages/vscode-ide-companion/src/acp/AcpConnection.ts b/packages/vscode-ide-companion/src/acp/AcpConnection.ts index 8b0d2593..41cb0fd5 100644 --- a/packages/vscode-ide-companion/src/acp/AcpConnection.ts +++ b/packages/vscode-ide-companion/src/acp/AcpConnection.ts @@ -286,7 +286,23 @@ export class AcpConnection { params as AcpPermissionRequest, ); break; + case 'fs/read_text_file': + result = await this.handleReadTextFile( + params as { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }, + ); + break; + case 'fs/write_text_file': + result = await this.handleWriteTextFile( + params as { path: string; content: string; sessionId: string }, + ); + break; default: + console.warn(`[ACP] Unhandled method: ${method}`); break; } @@ -317,12 +333,19 @@ export class AcpConnection { try { const response = await this.onPermissionRequest(params); const optionId = response.optionId; - const outcome = optionId.includes('reject') ? 'rejected' : 'selected'; + + // Handle cancel, reject, or allow + let outcome: string; + if (optionId.includes('reject') || optionId === 'cancel') { + outcome = 'rejected'; + } else { + outcome = 'selected'; + } return { outcome: { outcome, - optionId, + optionId: optionId === 'cancel' ? 'reject_once' : optionId, }, }; } catch (_error) { @@ -335,6 +358,83 @@ export class AcpConnection { } } + private async handleReadTextFile(params: { + path: string; + sessionId: string; + line: number | null; + limit: number | null; + }): Promise<{ content: string }> { + const fs = await import('fs/promises'); + + console.log(`[ACP] fs/read_text_file request received for: ${params.path}`); + console.log(`[ACP] Parameters:`, { + line: params.line, + limit: params.limit, + sessionId: params.sessionId, + }); + + try { + const content = await fs.readFile(params.path, 'utf-8'); + console.log( + `[ACP] Successfully read file: ${params.path} (${content.length} bytes)`, + ); + + // Handle line offset and limit if specified + if (params.line !== null || params.limit !== null) { + const lines = content.split('\n'); + const startLine = params.line || 0; + const endLine = params.limit ? startLine + params.limit : lines.length; + const selectedLines = lines.slice(startLine, endLine); + const result = { content: selectedLines.join('\n') }; + console.log(`[ACP] Returning ${selectedLines.length} lines`); + return result; + } + + const result = { content }; + console.log(`[ACP] Returning full file content`); + return result; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg); + + // Throw a proper error that will be caught by handleIncomingRequest + throw new Error(`Failed to read file '${params.path}': ${errorMsg}`); + } + } + + private async handleWriteTextFile(params: { + path: string; + content: string; + sessionId: string; + }): Promise { + const fs = await import('fs/promises'); + const path = await import('path'); + + console.log( + `[ACP] fs/write_text_file request received for: ${params.path}`, + ); + console.log(`[ACP] Content size: ${params.content.length} bytes`); + + try { + // Ensure directory exists + const dirName = path.dirname(params.path); + console.log(`[ACP] Ensuring directory exists: ${dirName}`); + await fs.mkdir(dirName, { recursive: true }); + + // Write file + await fs.writeFile(params.path, params.content, 'utf-8'); + + console.log(`[ACP] Successfully wrote file: ${params.path}`); + return null; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg); + + // Throw a proper error that will be caught by handleIncomingRequest + throw new Error(`Failed to write file '${params.path}': ${errorMsg}`); + } + } + private async initialize(): Promise { const initializeParams = { protocolVersion: 1, diff --git a/packages/vscode-ide-companion/src/webview/App.css b/packages/vscode-ide-companion/src/webview/App.css index 1f12993c..ccde6afd 100644 --- a/packages/vscode-ide-companion/src/webview/App.css +++ b/packages/vscode-ide-companion/src/webview/App.css @@ -338,3 +338,149 @@ body { font-family: monospace; } +/* Claude-style Inline Permission Request */ +.permission-request-inline { + margin: 16px 0; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.permission-card { + background: linear-gradient( + 135deg, + rgba(79, 134, 247, 0.08) 0%, + rgba(79, 134, 247, 0.03) 100% + ); + border: 1.5px solid rgba(79, 134, 247, 0.3); + border-radius: 10px; + padding: 16px; + backdrop-filter: blur(10px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.permission-card-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.permission-icon-wrapper { + width: 40px; + height: 40px; + border-radius: 8px; + background: linear-gradient(135deg, rgba(79, 134, 247, 0.2), rgba(79, 134, 247, 0.1)); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.permission-info { + flex: 1; +} + +.permission-tool-title { + font-size: 14px; + font-weight: 600; + color: var(--vscode-editor-foreground); + margin-bottom: 2px; +} + +.permission-subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.permission-actions-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.permission-btn-inline { + flex: 1; + min-width: 100px; + padding: 10px 16px; + border: 1.5px solid transparent; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-family: var(--vscode-font-family); + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + position: relative; + overflow: hidden; +} + +.permission-btn-inline::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + transform: translate(-50%, -50%); + transition: width 0.3s, height 0.3s; +} + +.permission-btn-inline:hover::before { + width: 300px; + height: 300px; +} + +.permission-btn-inline.allow { + background: linear-gradient(135deg, rgba(46, 160, 67, 0.25), rgba(46, 160, 67, 0.15)); + color: #4ec9b0; + border-color: rgba(46, 160, 67, 0.4); +} + +.permission-btn-inline.allow:hover { + background: linear-gradient(135deg, rgba(46, 160, 67, 0.35), rgba(46, 160, 67, 0.25)); + border-color: rgba(46, 160, 67, 0.6); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(46, 160, 67, 0.3); +} + +.permission-btn-inline.reject { + background: linear-gradient(135deg, rgba(200, 40, 40, 0.25), rgba(200, 40, 40, 0.15)); + color: #f48771; + border-color: rgba(200, 40, 40, 0.4); +} + +.permission-btn-inline.reject:hover { + background: linear-gradient(135deg, rgba(200, 40, 40, 0.35), rgba(200, 40, 40, 0.25)); + border-color: rgba(200, 40, 40, 0.6); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(200, 40, 40, 0.3); +} + +.permission-btn-inline.always { + border-style: dashed; +} + +.always-badge { + font-size: 14px; + animation: pulse 1.5s ease-in-out infinite; +} + +.permission-btn-inline:active { + transform: translateY(0); +} + diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index e3e17d4b..af3eabc9 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -19,6 +19,10 @@ export const App: React.FC = () => { Array> >([]); const [showSessionSelector, setShowSessionSelector] = useState(false); + const [permissionRequest, setPermissionRequest] = useState<{ + options: Array<{ name: string; kind: string; optionId: string }>; + toolCall: { title?: string }; + } | null>(null); const messagesEndRef = useRef(null); const handlePermissionRequest = React.useCallback( @@ -26,19 +30,21 @@ export const App: React.FC = () => { options: Array<{ name: string; kind: string; optionId: string }>; toolCall: { title?: string }; }) => { - const optionNames = request.options.map((opt) => opt.name).join(', '); - const confirmed = window.confirm( - `Tool permission request:\n${request.toolCall.title || 'Tool Call'}\n\nOptions: ${optionNames}\n\nAllow?`, - ); - - const selectedOption = confirmed - ? request.options.find((opt) => opt.kind === 'allow_once') - : request.options.find((opt) => opt.kind === 'reject_once'); + console.log('[WebView] Permission request received:', request); + // Show custom modal instead of window.confirm() + setPermissionRequest(request); + }, + [], + ); + const handlePermissionResponse = React.useCallback( + (optionId: string) => { + console.log('[WebView] Sending permission response:', optionId); vscode.postMessage({ type: 'permissionResponse', - data: { optionId: selectedOption?.optionId || 'reject_once' }, + data: { optionId }, }); + setPermissionRequest(null); }, [vscode], ); @@ -244,6 +250,45 @@ export const App: React.FC = () => { ))} + {/* Claude-style Inline Permission Request */} + {permissionRequest && ( +
+
+
+
+ 🔧 +
+
+
+ {permissionRequest.toolCall.title || 'Tool Request'} +
+
+ Waiting for your approval +
+
+
+ +
+ {permissionRequest.options.map((option) => { + const isAllow = option.kind.includes('allow'); + const isAlways = option.kind.includes('always'); + + return ( + + ); + })} +
+
+
+ )} + {isStreaming && currentStreamContent && (
{currentStreamContent}