mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(vscode-ide-companion): 实现自定义权限请求 UI 并添加文件读写功能
- 新增 fs/read_text_file 和 fs/write_text_file 方法处理 - 实现精美的 Claude 风格权限请求 UI - 优化权限请求处理逻辑,支持取消操作 - 添加日志输出以便调试
This commit is contained in:
@@ -286,7 +286,23 @@ export class AcpConnection {
|
|||||||
params as AcpPermissionRequest,
|
params as AcpPermissionRequest,
|
||||||
);
|
);
|
||||||
break;
|
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:
|
default:
|
||||||
|
console.warn(`[ACP] Unhandled method: ${method}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,12 +333,19 @@ export class AcpConnection {
|
|||||||
try {
|
try {
|
||||||
const response = await this.onPermissionRequest(params);
|
const response = await this.onPermissionRequest(params);
|
||||||
const optionId = response.optionId;
|
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 {
|
return {
|
||||||
outcome: {
|
outcome: {
|
||||||
outcome,
|
outcome,
|
||||||
optionId,
|
optionId: optionId === 'cancel' ? 'reject_once' : optionId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} 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<null> {
|
||||||
|
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<AcpResponse> {
|
private async initialize(): Promise<AcpResponse> {
|
||||||
const initializeParams = {
|
const initializeParams = {
|
||||||
protocolVersion: 1,
|
protocolVersion: 1,
|
||||||
|
|||||||
@@ -338,3 +338,149 @@ body {
|
|||||||
font-family: monospace;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export const App: React.FC = () => {
|
|||||||
Array<Record<string, unknown>>
|
Array<Record<string, unknown>>
|
||||||
>([]);
|
>([]);
|
||||||
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
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<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handlePermissionRequest = React.useCallback(
|
const handlePermissionRequest = React.useCallback(
|
||||||
@@ -26,19 +30,21 @@ export const App: React.FC = () => {
|
|||||||
options: Array<{ name: string; kind: string; optionId: string }>;
|
options: Array<{ name: string; kind: string; optionId: string }>;
|
||||||
toolCall: { title?: string };
|
toolCall: { title?: string };
|
||||||
}) => {
|
}) => {
|
||||||
const optionNames = request.options.map((opt) => opt.name).join(', ');
|
console.log('[WebView] Permission request received:', request);
|
||||||
const confirmed = window.confirm(
|
// Show custom modal instead of window.confirm()
|
||||||
`Tool permission request:\n${request.toolCall.title || 'Tool Call'}\n\nOptions: ${optionNames}\n\nAllow?`,
|
setPermissionRequest(request);
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedOption = confirmed
|
const handlePermissionResponse = React.useCallback(
|
||||||
? request.options.find((opt) => opt.kind === 'allow_once')
|
(optionId: string) => {
|
||||||
: request.options.find((opt) => opt.kind === 'reject_once');
|
console.log('[WebView] Sending permission response:', optionId);
|
||||||
|
|
||||||
vscode.postMessage({
|
vscode.postMessage({
|
||||||
type: 'permissionResponse',
|
type: 'permissionResponse',
|
||||||
data: { optionId: selectedOption?.optionId || 'reject_once' },
|
data: { optionId },
|
||||||
});
|
});
|
||||||
|
setPermissionRequest(null);
|
||||||
},
|
},
|
||||||
[vscode],
|
[vscode],
|
||||||
);
|
);
|
||||||
@@ -244,6 +250,45 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Claude-style Inline Permission Request */}
|
||||||
|
{permissionRequest && (
|
||||||
|
<div className="permission-request-inline">
|
||||||
|
<div className="permission-card">
|
||||||
|
<div className="permission-card-header">
|
||||||
|
<div className="permission-icon-wrapper">
|
||||||
|
<span className="permission-icon">🔧</span>
|
||||||
|
</div>
|
||||||
|
<div className="permission-info">
|
||||||
|
<div className="permission-tool-title">
|
||||||
|
{permissionRequest.toolCall.title || 'Tool Request'}
|
||||||
|
</div>
|
||||||
|
<div className="permission-subtitle">
|
||||||
|
Waiting for your approval
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="permission-actions-row">
|
||||||
|
{permissionRequest.options.map((option) => {
|
||||||
|
const isAllow = option.kind.includes('allow');
|
||||||
|
const isAlways = option.kind.includes('always');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.optionId}
|
||||||
|
onClick={() => handlePermissionResponse(option.optionId)}
|
||||||
|
className={`permission-btn-inline ${isAllow ? 'allow' : 'reject'} ${isAlways ? 'always' : ''}`}
|
||||||
|
>
|
||||||
|
{isAlways && <span className="always-badge">⚡</span>}
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isStreaming && currentStreamContent && (
|
{isStreaming && currentStreamContent && (
|
||||||
<div className="message assistant streaming">
|
<div className="message assistant streaming">
|
||||||
<div className="message-content">{currentStreamContent}</div>
|
<div className="message-content">{currentStreamContent}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user