mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 09:47:47 +00:00
feat(vscode-ide-companion): implement session message handling and UI improvements
Complete session message handling with JSONL support and UI enhancements - Add JSONL session file reading capability - Improve error handling and authentication flows - Update UI components for better user experience - Fix command identifier references - Enhance MarkdownRenderer with copy functionality - Update Tailwind configuration for better component coverage
This commit is contained in:
@@ -9,6 +9,7 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useVSCode } from './hooks/useVSCode.js';
|
||||
@@ -21,15 +22,13 @@ import { useMessageSubmit } from './hooks/useMessageSubmit.js';
|
||||
import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from './components/PermissionRequest.js';
|
||||
} from './components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { TextMessage } from './hooks/message/useMessageHandling.js';
|
||||
import type { ToolCallData } from './components/ToolCall.js';
|
||||
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
||||
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
|
||||
import { ToolCall } from './components/ToolCall.js';
|
||||
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
|
||||
// import { InProgressToolCall } from './components/InProgressToolCall.js';
|
||||
import { EmptyState } from './components/ui/EmptyState.js';
|
||||
import type { PlanEntry } from './components/PlanDisplay.js';
|
||||
import { type CompletionItem } from './types/CompletionTypes.js';
|
||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||
import { InfoBanner } from './components/ui/InfoBanner.js';
|
||||
@@ -45,6 +44,7 @@ import { InputForm } from './components/InputForm.js';
|
||||
import { SessionSelector } from './components/session/SessionSelector.js';
|
||||
import { FileIcon, UserIcon } from './components/icons/index.js';
|
||||
import type { EditMode } from './types/toolCall.js';
|
||||
import type { PlanEntry } from '../agents/qwenTypes.js';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const vscode = useVSCode();
|
||||
@@ -488,12 +488,138 @@ export const App: React.FC = () => {
|
||||
setThinkingEnabled((prev) => !prev);
|
||||
};
|
||||
|
||||
// Create unified message array containing all types of messages and tool calls
|
||||
const allMessages = useMemo<
|
||||
Array<{
|
||||
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
|
||||
data: TextMessage | ToolCallData;
|
||||
timestamp: number;
|
||||
}>
|
||||
>(() => {
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
type: 'completed-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
return [...regularMessages, ...inProgressTools, ...completedTools].sort(
|
||||
(a, b) => (a.timestamp || 0) - (b.timestamp || 0),
|
||||
);
|
||||
}, [messageHandling.messages, inProgressToolCalls, completedToolCalls]);
|
||||
|
||||
console.log('[App] Rendering messages:', allMessages);
|
||||
|
||||
// Render all messages and tool calls
|
||||
const renderMessages = useCallback<() => React.ReactNode>(
|
||||
() =>
|
||||
allMessages.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
const msg = item.data as TextMessage;
|
||||
const handleFileClick = (path: string): void => {
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path },
|
||||
});
|
||||
};
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const content = (msg.content || '').trim();
|
||||
if (content === 'Interrupted' || content === 'Tool interrupted') {
|
||||
return (
|
||||
<InterruptedMessage key={`message-${index}`} text={content} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={`message-${index}`}
|
||||
content={content}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'in-progress-tool-call':
|
||||
case 'completed-tool-call': {
|
||||
const prev = allMessages[index - 1];
|
||||
const next = allMessages[index + 1];
|
||||
const isToolCallType = (
|
||||
x: unknown,
|
||||
): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } =>
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
'type' in (x as Record<string, unknown>) &&
|
||||
((x as { type: string }).type === 'in-progress-tool-call' ||
|
||||
(x as { type: string }).type === 'completed-tool-call');
|
||||
const isFirst = !isToolCallType(prev);
|
||||
const isLast = !isToolCallType(next);
|
||||
return (
|
||||
<ToolCall
|
||||
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
|
||||
toolCall={item.data as ToolCallData}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
[allMessages, vscode],
|
||||
);
|
||||
|
||||
const hasContent =
|
||||
messageHandling.messages.length > 0 ||
|
||||
messageHandling.isStreaming ||
|
||||
inProgressToolCalls.length > 0 ||
|
||||
completedToolCalls.length > 0 ||
|
||||
planEntries.length > 0;
|
||||
planEntries.length > 0 ||
|
||||
allMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
@@ -508,6 +634,9 @@ export const App: React.FC = () => {
|
||||
sessionManagement.setSessionSearchQuery('');
|
||||
}}
|
||||
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
||||
hasMore={sessionManagement.hasMore}
|
||||
isLoading={sessionManagement.isLoading}
|
||||
onLoadMore={sessionManagement.handleLoadMoreSessions}
|
||||
/>
|
||||
|
||||
<ChatHeader
|
||||
@@ -525,122 +654,8 @@ export const App: React.FC = () => {
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* Create unified message array containing all types of messages and tool calls */}
|
||||
{(() => {
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
type: 'completed-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
const allMessages = [
|
||||
...regularMessages,
|
||||
...inProgressTools,
|
||||
...completedTools,
|
||||
].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
||||
|
||||
console.log('[App] allMessages:', allMessages);
|
||||
|
||||
return allMessages.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
const msg = item.data as TextMessage;
|
||||
const handleFileClick = (path: string) => {
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path },
|
||||
});
|
||||
};
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const content = (msg.content || '').trim();
|
||||
if (
|
||||
content === 'Interrupted' ||
|
||||
content === 'Tool interrupted'
|
||||
) {
|
||||
return (
|
||||
<InterruptedMessage
|
||||
key={`message-${index}`}
|
||||
text={content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={`message-${index}`}
|
||||
content={content}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// case 'in-progress-tool-call':
|
||||
// return (
|
||||
// <InProgressToolCall
|
||||
// key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
|
||||
// toolCall={item.data as ToolCallData}
|
||||
// // onFileClick={handleFileClick}
|
||||
// />
|
||||
// );
|
||||
|
||||
case 'in-progress-tool-call':
|
||||
case 'completed-tool-call':
|
||||
return (
|
||||
<ToolCall
|
||||
key={`completed-${(item.data as ToolCallData).toolCallId}`}
|
||||
toolCall={item.data as ToolCallData}
|
||||
// onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
})()}
|
||||
{/* Render all messages and tool calls */}
|
||||
{renderMessages()}
|
||||
|
||||
{/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WebViewContent } from '../webview/WebViewContent.js';
|
||||
import { CliInstaller } from '../cli/cliInstaller.js';
|
||||
import { getFileName } from './utils/webviewUtils.js';
|
||||
import { authMethod } from '../auth/index.js';
|
||||
import { runQwenCodeCommand } from '../commands/index.js';
|
||||
|
||||
export class WebViewProvider {
|
||||
private panelManager: PanelManager;
|
||||
@@ -25,6 +26,8 @@ export class WebViewProvider {
|
||||
private authStateManager: AuthStateManager;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private agentInitialized = false; // Track if agent has been initialized
|
||||
// Control whether to auto-restore last session on the very first connect of this panel
|
||||
private autoRestoreOnFirstConnect = true;
|
||||
|
||||
constructor(
|
||||
context: vscode.ExtensionContext,
|
||||
@@ -239,6 +242,13 @@ export class WebViewProvider {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress auto-restore once for this panel (used by "New Chat Tab").
|
||||
*/
|
||||
suppressAutoRestoreOnce(): void {
|
||||
this.autoRestoreOnFirstConnect = false;
|
||||
}
|
||||
|
||||
async show(): Promise<void> {
|
||||
const panel = this.panelManager.getPanel();
|
||||
|
||||
@@ -587,6 +597,14 @@ export class WebViewProvider {
|
||||
'[WebViewProvider] Force re-login completed successfully',
|
||||
);
|
||||
|
||||
// Ensure auth state is saved after successful re-login
|
||||
if (this.authStateManager) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
await this.authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log('[WebViewProvider] Auth state saved after re-login');
|
||||
}
|
||||
|
||||
// Send success notification to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'loginSuccess',
|
||||
@@ -681,53 +699,139 @@ export class WebViewProvider {
|
||||
authMethod,
|
||||
);
|
||||
if (hasValidAuth) {
|
||||
console.log(
|
||||
'[WebViewProvider] Found valid cached auth, attempting session restoration',
|
||||
);
|
||||
const allowAutoRestore = this.autoRestoreOnFirstConnect;
|
||||
// Reset for subsequent connects (only once per panel lifecycle unless set again)
|
||||
this.autoRestoreOnFirstConnect = true;
|
||||
|
||||
if (allowAutoRestore) {
|
||||
console.log(
|
||||
'[WebViewProvider] Valid auth found, attempting auto-restore of last session...',
|
||||
);
|
||||
try {
|
||||
const page = await this.agentManager.getSessionListPaged({
|
||||
size: 1,
|
||||
});
|
||||
const item = page.sessions[0] as
|
||||
| { sessionId?: string; id?: string; cwd?: string }
|
||||
| undefined;
|
||||
if (item && (item.sessionId || item.id)) {
|
||||
const targetId = (item.sessionId || item.id) as string;
|
||||
await this.agentManager.loadSessionViaAcp(
|
||||
targetId,
|
||||
(item.cwd as string | undefined) ?? workingDir,
|
||||
);
|
||||
|
||||
this.messageHandler.setCurrentConversationId(targetId);
|
||||
const messages =
|
||||
await this.agentManager.getSessionMessages(targetId);
|
||||
|
||||
// Even if messages array is empty, we should still switch to the session
|
||||
// This ensures we don't lose the session context
|
||||
this.sendMessageToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId: targetId, messages },
|
||||
});
|
||||
console.log(
|
||||
'[WebViewProvider] Auto-restored last session:',
|
||||
targetId,
|
||||
);
|
||||
|
||||
// Ensure auth state is saved after successful session restore
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] Auth state saved after session restore',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
'[WebViewProvider] No sessions to auto-restore, creating new session',
|
||||
);
|
||||
} catch (restoreError) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Auto-restore failed, will create a new session:',
|
||||
restoreError,
|
||||
);
|
||||
|
||||
// Try to get session messages anyway, even if loadSessionViaAcp failed
|
||||
// This can happen if the session exists locally but failed to load in the CLI
|
||||
try {
|
||||
const page = await this.agentManager.getSessionListPaged({
|
||||
size: 1,
|
||||
});
|
||||
const item = page.sessions[0] as
|
||||
| { sessionId?: string; id?: string }
|
||||
| undefined;
|
||||
if (item && (item.sessionId || item.id)) {
|
||||
const targetId = (item.sessionId || item.id) as string;
|
||||
const messages =
|
||||
await this.agentManager.getSessionMessages(targetId);
|
||||
|
||||
// Switch to the session with whatever messages we could get
|
||||
this.messageHandler.setCurrentConversationId(targetId);
|
||||
this.sendMessageToWebView({
|
||||
type: 'qwenSessionSwitched',
|
||||
data: { sessionId: targetId, messages },
|
||||
});
|
||||
console.log(
|
||||
'[WebViewProvider] Partially restored last session:',
|
||||
targetId,
|
||||
);
|
||||
|
||||
// Ensure auth state is saved after partial session restore
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] Auth state saved after partial session restore',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Fallback session restore also failed:',
|
||||
fallbackError,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'[WebViewProvider] Auto-restore suppressed for this panel',
|
||||
);
|
||||
}
|
||||
|
||||
// Create a fresh ACP session (no auto-restore or restore failed)
|
||||
try {
|
||||
// Try to create a session (this will use cached auth)
|
||||
const sessionId = await this.agentManager.createNewSession(
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
console.log('[WebViewProvider] ACP session created successfully');
|
||||
|
||||
if (sessionId) {
|
||||
// Ensure auth state is saved after successful session creation
|
||||
if (this.authStateManager) {
|
||||
await this.authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log(
|
||||
'[WebViewProvider] ACP session restored successfully with ID:',
|
||||
sessionId,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
'[WebViewProvider] ACP session restoration returned no session ID',
|
||||
'[WebViewProvider] Auth state saved after session creation',
|
||||
);
|
||||
}
|
||||
} catch (restoreError) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to restore ACP session:',
|
||||
restoreError,
|
||||
} catch (sessionError) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to create ACP session:',
|
||||
sessionError,
|
||||
);
|
||||
vscode.window.showWarningMessage(
|
||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||
);
|
||||
// Clear invalid auth cache
|
||||
await this.authStateManager.clearAuthState();
|
||||
|
||||
// Fall back to creating a new session
|
||||
try {
|
||||
await this.agentManager.createNewSession(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] ACP session created successfully after restore failure',
|
||||
);
|
||||
} catch (sessionError) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to create ACP session:',
|
||||
sessionError,
|
||||
);
|
||||
vscode.window.showWarningMessage(
|
||||
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
@@ -1067,7 +1171,7 @@ export class WebViewProvider {
|
||||
if (useTerminal) {
|
||||
// In terminal mode, execute the runQwenCode command to open a new terminal
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwen-code.runQwenCode');
|
||||
await vscode.commands.executeCommand(runQwenCodeCommand);
|
||||
console.log('[WebViewProvider] Opened new terminal session');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* In-progress tool call component - displays active tool calls with Claude Code style
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ToolCallData } from './toolcalls/shared/types.js';
|
||||
import { FileLink } from './ui/FileLink.js';
|
||||
import { useVSCode } from '../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../utils/diffUtils.js';
|
||||
|
||||
interface InProgressToolCallProps {
|
||||
toolCall: ToolCallData;
|
||||
onFileClick?: (path: string, line?: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the kind name to a readable label
|
||||
*/
|
||||
const formatKind = (kind: string): string => {
|
||||
const kindMap: Record<string, string> = {
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
edit: 'Edit',
|
||||
execute: 'Execute',
|
||||
bash: 'Execute',
|
||||
command: 'Execute',
|
||||
search: 'Search',
|
||||
grep: 'Search',
|
||||
glob: 'Search',
|
||||
find: 'Search',
|
||||
think: 'Think',
|
||||
thinking: 'Think',
|
||||
fetch: 'Fetch',
|
||||
delete: 'Delete',
|
||||
move: 'Move',
|
||||
};
|
||||
|
||||
return kindMap[kind.toLowerCase()] || 'Tool Call';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get file name from path
|
||||
*/
|
||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
/**
|
||||
* Component to display in-progress tool calls with Claude Code styling
|
||||
* Shows kind, file name, and file locations
|
||||
*/
|
||||
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
|
||||
toolCall,
|
||||
onFileClick: _onFileClick,
|
||||
}) => {
|
||||
const { kind, title, locations, content } = toolCall;
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Format the kind label
|
||||
const kindLabel = formatKind(kind);
|
||||
|
||||
// Map tool kind to a Tailwind text color class (Claude-like palette)
|
||||
const kindColorClass = React.useMemo(() => {
|
||||
const k = kind.toLowerCase();
|
||||
if (k === 'read') {
|
||||
return 'text-[#4ec9b0]';
|
||||
}
|
||||
if (k === 'write' || k === 'edit') {
|
||||
return 'text-[#e5c07b]';
|
||||
}
|
||||
if (k === 'execute' || k === 'bash' || k === 'command') {
|
||||
return 'text-[#c678dd]';
|
||||
}
|
||||
if (k === 'search' || k === 'grep' || k === 'glob' || k === 'find') {
|
||||
return 'text-[#61afef]';
|
||||
}
|
||||
if (k === 'think' || k === 'thinking') {
|
||||
return 'text-[#98c379]';
|
||||
}
|
||||
return 'text-[var(--app-primary-foreground)]';
|
||||
}, [kind]);
|
||||
|
||||
// Get file name from locations or title
|
||||
let fileName: string | null = null;
|
||||
let filePath: string | null = null;
|
||||
let fileLine: number | null = null;
|
||||
|
||||
if (locations && locations.length > 0) {
|
||||
fileName = getFileName(locations[0].path);
|
||||
filePath = locations[0].path;
|
||||
fileLine = locations[0].line || null;
|
||||
} else if (typeof title === 'string') {
|
||||
fileName = title;
|
||||
}
|
||||
|
||||
// Extract content text from content array
|
||||
let contentText: string | null = null;
|
||||
// Extract first diff (if present)
|
||||
let diffData: {
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
} | null = null;
|
||||
if (content && content.length > 0) {
|
||||
// Look for text content
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item.content?.text) {
|
||||
contentText = item.content.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no text content found, look for other content types
|
||||
if (!contentText) {
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item.content) {
|
||||
contentText = JSON.stringify(item.content, null, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for diff content
|
||||
for (const item of content) {
|
||||
if (
|
||||
item.type === 'diff' &&
|
||||
(item.oldText !== undefined || item.newText !== undefined)
|
||||
) {
|
||||
diffData = {
|
||||
path: item.path,
|
||||
oldText: item.oldText ?? null,
|
||||
newText: item.newText,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle open diff
|
||||
const handleOpenDiffInternal = () => {
|
||||
if (!diffData) {
|
||||
return;
|
||||
}
|
||||
const path = diffData.path || filePath || '';
|
||||
handleOpenDiff(vscode, path, diffData.oldText, diffData.newText);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative pl-[30px] py-2 select-text toolcall-container in-progress-toolcall">
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center gap-2 relative min-w-0 toolcall-header">
|
||||
<span
|
||||
className={`text-[14px] leading-none font-bold ${kindColorClass}`}
|
||||
>
|
||||
{kindLabel}
|
||||
</span>
|
||||
{filePath && (
|
||||
<FileLink
|
||||
path={filePath}
|
||||
line={fileLine ?? undefined}
|
||||
showFullPath={false}
|
||||
className="text-[14px]"
|
||||
/>
|
||||
)}
|
||||
{!filePath && fileName && (
|
||||
<span className="text-[14px] leading-none text-[var(--app-secondary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{diffData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDiffInternal}
|
||||
className="text-[11px] px-2 py-0.5 border border-[var(--app-input-border)] rounded-small text-[var(--app-primary-foreground)] bg-transparent hover:bg-[var(--app-ghost-button-hover-background)] cursor-pointer"
|
||||
>
|
||||
Open Diff
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contentText && (
|
||||
<div className="text-[var(--app-secondary-foreground)]">
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="toolcall-content-text flex-shrink-0 w-full">
|
||||
{contentText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -135,7 +135,8 @@
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: var(--corner-radius-small, 4px);
|
||||
padding: 0.2em 0.4em;
|
||||
white-space: nowrap;
|
||||
white-space: pre-wrap; /* 支持自动换行 */
|
||||
word-break: break-word; /* 在必要时断词 */
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
@@ -207,7 +208,8 @@
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
white-space: pre-wrap; /* 支持自动换行 */
|
||||
word-break: break-word; /* 在必要时断词 */
|
||||
}
|
||||
|
||||
.markdown-content .file-path-link {
|
||||
|
||||
@@ -19,11 +19,12 @@ interface MarkdownRendererProps {
|
||||
/**
|
||||
* Regular expressions for parsing content
|
||||
*/
|
||||
// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts
|
||||
const FILE_PATH_REGEX =
|
||||
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||
// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||
// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7
|
||||
const FILE_PATH_WITH_LINES_REGEX =
|
||||
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
|
||||
/**
|
||||
* MarkdownRenderer component - renders markdown content with enhanced features
|
||||
@@ -166,9 +167,22 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
const href = a.getAttribute('href') || '';
|
||||
const text = (a.textContent || '').trim();
|
||||
|
||||
// Helper function to check if a string looks like a code reference
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
// Check if it looks like a code reference (e.g., module.property)
|
||||
// Patterns like "vscode.contribution", "module.submodule.function"
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
// If linkify turned a bare filename into http://<filename>, convert it back
|
||||
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
|
||||
if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) {
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Treat as a file link instead of external URL
|
||||
const filePath = text; // no leading slash
|
||||
a.classList.add('file-path-link');
|
||||
@@ -182,6 +196,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
if (/^(https?|mailto|ftp|data):/i.test(href)) return;
|
||||
|
||||
const candidate = href || text;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(candidate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
|
||||
FILE_PATH_NO_G.test(candidate)
|
||||
@@ -194,6 +214,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if a string looks like a code reference
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
// Check if it looks like a code reference (e.g., module.property)
|
||||
// Patterns like "vscode.contribution", "module.submodule.function"
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
// Do not transform inside existing anchors
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
@@ -218,6 +246,20 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
while ((m = union.exec(text))) {
|
||||
const matchText = m[0];
|
||||
const idx = m.index;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(matchText)) {
|
||||
// Just add the text as-is without creating a link
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
);
|
||||
}
|
||||
frag.appendChild(document.createTextNode(matchText));
|
||||
lastIndex = idx + matchText.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
|
||||
|
||||
interface PermissionDrawerProps {
|
||||
isOpen: boolean;
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission drawer component - Claude Code style bottom sheet
|
||||
*/
|
||||
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
isOpen,
|
||||
options,
|
||||
toolCall,
|
||||
onResponse,
|
||||
onClose,
|
||||
}) => {
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [customMessage, setCustomMessage] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// 将自定义输入的 ref 类型修正为 HTMLInputElement,避免后续强转
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
|
||||
// Prefer file name from locations, fall back to content[].path if present
|
||||
const getAffectedFileName = (): string => {
|
||||
const fromLocations = toolCall.locations?.[0]?.path;
|
||||
if (fromLocations) {
|
||||
return fromLocations.split('/').pop() || fromLocations;
|
||||
}
|
||||
// Some tool calls (e.g. write/edit with diff content) only include path in content
|
||||
const fromContent = Array.isArray(toolCall.content)
|
||||
? (
|
||||
toolCall.content.find(
|
||||
(c: unknown) =>
|
||||
typeof c === 'object' &&
|
||||
c !== null &&
|
||||
'path' in (c as Record<string, unknown>),
|
||||
) as { path?: unknown } | undefined
|
||||
)?.path
|
||||
: undefined;
|
||||
if (typeof fromContent === 'string' && fromContent.length > 0) {
|
||||
return fromContent.split('/').pop() || fromContent;
|
||||
}
|
||||
return 'file';
|
||||
};
|
||||
|
||||
// Get the title for the permission request
|
||||
const getTitle = () => {
|
||||
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Make this edit to{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
|
||||
return 'Allow this bash command?';
|
||||
}
|
||||
if (toolCall.kind === 'read') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Allow read from{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
return toolCall.title || 'Permission Required';
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys 1-9 for quick select
|
||||
const numMatch = e.key.match(/^[1-9]$/);
|
||||
if (
|
||||
numMatch &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
if (index < options.length) {
|
||||
e.preventDefault();
|
||||
onResponse(options[index].optionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys for navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const totalItems = options.length + 1; // +1 for custom input
|
||||
if (e.key === 'ArrowDown') {
|
||||
setFocusedIndex((prev) => (prev + 1) % totalItems);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (focusedIndex < options.length) {
|
||||
onResponse(options[focusedIndex].optionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel permission and close (align with CLI/Claude behavior)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
const rejectOptionId =
|
||||
options.find((o) => o.kind.includes('reject'))?.optionId ||
|
||||
options.find((o) => o.optionId === 'cancel')?.optionId ||
|
||||
'cancel';
|
||||
onResponse(rejectOptionId);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, options, onResponse, onClose, focusedIndex]);
|
||||
|
||||
// Focus container when opened
|
||||
useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
containerRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset focus to the first option when the drawer opens or the options change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
|
||||
{/* Main container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
|
||||
style={{
|
||||
backgroundColor: 'var(--app-input-secondary-background)',
|
||||
borderColor: 'var(--app-input-border)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-focused-index={focusedIndex}
|
||||
>
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="p-2 absolute inset-0 rounded-large"
|
||||
style={{ backgroundColor: 'var(--app-input-background)' }}
|
||||
/>
|
||||
|
||||
{/* Title + Description (from toolCall.title) */}
|
||||
<div className="relative z-[1] px-1 text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
|
||||
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
|
||||
{getTitle()}
|
||||
</div>
|
||||
{(toolCall.kind === 'edit' ||
|
||||
toolCall.kind === 'write' ||
|
||||
toolCall.kind === 'read' ||
|
||||
toolCall.kind === 'execute' ||
|
||||
toolCall.kind === 'bash') &&
|
||||
toolCall.title && (
|
||||
<div
|
||||
/* 13px,常规字重;正常空白折行 + 长词断行;最多 3 行溢出省略 */
|
||||
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
|
||||
title={toolCall.title}
|
||||
>
|
||||
{toolCall.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative z-[1] flex flex-col gap-1 px-1 pb-1">
|
||||
{options.map((option, index) => {
|
||||
const isFocused = focusedIndex === index;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.optionId}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-list-hover-background)] ${
|
||||
isFocused
|
||||
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
}`}
|
||||
onClick={() => onResponse(option.optionId)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
>
|
||||
{/* Number badge */}
|
||||
{/* Plain number badge without hover background */}
|
||||
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold">
|
||||
{index + 1}
|
||||
</span>
|
||||
{/* Option text */}
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
|
||||
{/* Always badge */}
|
||||
{/* {isAlways && <span className="text-sm">⚡</span>} */}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom message input (extracted component) */}
|
||||
{(() => {
|
||||
const isFocused = focusedIndex === options.length;
|
||||
const rejectOptionId = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
)?.optionId;
|
||||
return (
|
||||
<CustomMessageInputRow
|
||||
isFocused={isFocused}
|
||||
customMessage={customMessage}
|
||||
setCustomMessage={setCustomMessage}
|
||||
onFocusRow={() => setFocusedIndex(options.length)}
|
||||
onSubmitReject={() => {
|
||||
if (rejectOptionId) onResponse(rejectOptionId);
|
||||
}}
|
||||
inputRef={customInputRef}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CustomMessageInputRow: 复用的自定义输入行组件(无 hooks)
|
||||
*/
|
||||
interface CustomMessageInputRowProps {
|
||||
isFocused: boolean;
|
||||
customMessage: string;
|
||||
setCustomMessage: (val: string) => void;
|
||||
onFocusRow: () => void; // 鼠标移入或输入框 focus 时设置焦点
|
||||
onSubmitReject: () => void; // Enter 提交时触发(选择 reject 选项)
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
|
||||
isFocused,
|
||||
customMessage,
|
||||
setCustomMessage,
|
||||
onFocusRow,
|
||||
onSubmitReject,
|
||||
inputRef,
|
||||
}) => (
|
||||
<div
|
||||
// 无过渡:hover 样式立即生效;输入行不加 hover 背景,也不加粗文字
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
|
||||
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
|
||||
}`}
|
||||
onMouseEnter={onFocusRow}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{/* 输入行不显示序号徽标 */}
|
||||
{/* Input field */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Tell Qwen what to do instead"
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
|
||||
style={{ color: 'var(--app-input-foreground)' }}
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
onFocus={onFocusRow}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmitReject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionOption {
|
||||
name: string;
|
||||
kind: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
toolCallId?: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequestProps {
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionOption {
|
||||
name: string;
|
||||
kind: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
toolCallId?: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequestProps {
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
}
|
||||
|
||||
// export const PermissionRequest: React.FC<PermissionRequestProps> = ({
|
||||
// options,
|
||||
// toolCall,
|
||||
// onResponse,
|
||||
// }) => {
|
||||
// const [selected, setSelected] = useState<string | null>(null);
|
||||
// const [isResponding, setIsResponding] = useState(false);
|
||||
// const [hasResponded, setHasResponded] = useState(false);
|
||||
|
||||
// const getToolInfo = () => {
|
||||
// if (!toolCall) {
|
||||
// return {
|
||||
// title: 'Permission Request',
|
||||
// description: 'Agent is requesting permission',
|
||||
// icon: '🔐',
|
||||
// };
|
||||
// }
|
||||
|
||||
// const displayTitle =
|
||||
// toolCall.title || toolCall.rawInput?.description || 'Permission Request';
|
||||
|
||||
// const kindIcons: Record<string, string> = {
|
||||
// edit: '✏️',
|
||||
// read: '📖',
|
||||
// fetch: '🌐',
|
||||
// execute: '⚡',
|
||||
// delete: '🗑️',
|
||||
// move: '📦',
|
||||
// search: '🔍',
|
||||
// think: '💭',
|
||||
// other: '🔧',
|
||||
// };
|
||||
|
||||
// return {
|
||||
// title: displayTitle,
|
||||
// icon: kindIcons[toolCall.kind || 'other'] || '🔧',
|
||||
// };
|
||||
// };
|
||||
|
||||
// const { title, icon } = getToolInfo();
|
||||
|
||||
// const handleConfirm = async () => {
|
||||
// if (hasResponded || !selected) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// setIsResponding(true);
|
||||
// try {
|
||||
// await onResponse(selected);
|
||||
// setHasResponded(true);
|
||||
// } catch (error) {
|
||||
// console.error('Error confirming permission:', error);
|
||||
// } finally {
|
||||
// setIsResponding(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// if (!toolCall) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <div className="permission-request-card">
|
||||
// <div className="permission-card-body">
|
||||
// {/* Header with icon and title */}
|
||||
// <div className="permission-header">
|
||||
// <div className="permission-icon-wrapper">
|
||||
// <span className="permission-icon">{icon}</span>
|
||||
// </div>
|
||||
// <div className="permission-info">
|
||||
// <div className="permission-title">{title}</div>
|
||||
// <div className="permission-subtitle">Waiting for your approval</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
// {/* Show command if available */}
|
||||
// {(toolCall.rawInput?.command || toolCall.title) && (
|
||||
// <div className="permission-command-section">
|
||||
// <div className="permission-command-header">
|
||||
// <div className="permission-command-status">
|
||||
// <span className="permission-command-dot">●</span>
|
||||
// <span className="permission-command-label">COMMAND</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// <div className="permission-command-content">
|
||||
// <div className="permission-command-input-section">
|
||||
// <span className="permission-command-io-label">IN</span>
|
||||
// <code className="permission-command-code">
|
||||
// {toolCall.rawInput?.command || toolCall.title}
|
||||
// </code>
|
||||
// </div>
|
||||
// {toolCall.rawInput?.description && (
|
||||
// <div className="permission-command-description">
|
||||
// {toolCall.rawInput.description}
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Show file locations if available */}
|
||||
// {toolCall.locations && toolCall.locations.length > 0 && (
|
||||
// <div className="permission-locations-section">
|
||||
// <div className="permission-locations-label">Affected Files</div>
|
||||
// {toolCall.locations.map((location, index) => (
|
||||
// <div key={index} className="permission-location-item">
|
||||
// <span className="permission-location-icon">📄</span>
|
||||
// <span className="permission-location-path">
|
||||
// {location.path}
|
||||
// </span>
|
||||
// {location.line !== null && location.line !== undefined && (
|
||||
// <span className="permission-location-line">
|
||||
// ::{location.line}
|
||||
// </span>
|
||||
// )}
|
||||
// </div>
|
||||
// ))}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Options */}
|
||||
// {!hasResponded && (
|
||||
// <div className="permission-options-section">
|
||||
// <div className="permission-options-label">Choose an action:</div>
|
||||
// <div className="permission-options-list">
|
||||
// {options && options.length > 0 ? (
|
||||
// options.map((option, index) => {
|
||||
// const isSelected = selected === option.optionId;
|
||||
// const isAllow = option.kind.includes('allow');
|
||||
// const isAlways = option.kind.includes('always');
|
||||
|
||||
// return (
|
||||
// <label
|
||||
// key={option.optionId}
|
||||
// className={`permission-option ${isSelected ? 'selected' : ''} ${
|
||||
// isAllow ? 'allow' : 'reject'
|
||||
// } ${isAlways ? 'always' : ''}`}
|
||||
// >
|
||||
// <input
|
||||
// type="radio"
|
||||
// name="permission"
|
||||
// value={option.optionId}
|
||||
// checked={isSelected}
|
||||
// onChange={() => setSelected(option.optionId)}
|
||||
// className="permission-radio"
|
||||
// />
|
||||
// <span className="permission-option-content">
|
||||
// <span className="permission-option-number">
|
||||
// {index + 1}
|
||||
// </span>
|
||||
// {isAlways && (
|
||||
// <span className="permission-always-badge">⚡</span>
|
||||
// )}
|
||||
// {option.name}
|
||||
// </span>
|
||||
// </label>
|
||||
// );
|
||||
// })
|
||||
// ) : (
|
||||
// <div className="permission-no-options">
|
||||
// No options available
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// <div className="permission-actions">
|
||||
// <button
|
||||
// className="permission-confirm-button"
|
||||
// disabled={!selected || isResponding}
|
||||
// onClick={handleConfirm}
|
||||
// >
|
||||
// {isResponding ? 'Processing...' : 'Confirm'}
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Success message */}
|
||||
// {hasResponded && (
|
||||
// <div className="permission-success">
|
||||
// <span className="permission-success-icon">✓</span>
|
||||
// <span className="permission-success-text">
|
||||
// Response sent successfully
|
||||
// </span>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
@@ -35,4 +35,8 @@ export type { ToolCallContent } from './toolcalls/shared/types.js';
|
||||
*/
|
||||
export const ToolCall: React.FC<{
|
||||
toolCall: import('./toolcalls/shared/types.js').ToolCallData;
|
||||
}> = ({ toolCall }) => <ToolCallRouter toolCall={toolCall} />;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ toolCall, isFirst, isLast }) => (
|
||||
<ToolCallRouter toolCall={toolCall} isFirst={isFirst} isLast={isLast} />
|
||||
);
|
||||
|
||||
@@ -68,12 +68,12 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
paddingLeft: '30px',
|
||||
userSelect: 'text',
|
||||
position: 'relative',
|
||||
paddingTop: '8px',
|
||||
paddingBottom: '8px',
|
||||
// paddingTop: '8px',
|
||||
// paddingBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '100%' }}>
|
||||
<p
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
@@ -83,7 +83,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
}}
|
||||
>
|
||||
<MessageContent content={content} onFileClick={onFileClick} />
|
||||
</p>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50 hover:opacity-100"
|
||||
className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50"
|
||||
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -10,4 +10,3 @@ export { ThinkingMessage } from './ThinkingMessage.js';
|
||||
export { StreamingMessage } from './StreamingMessage.js';
|
||||
export { WaitingMessage } from './Waiting/WaitingMessage.js';
|
||||
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';
|
||||
export { PlanDisplay } from '../PlanDisplay.js';
|
||||
|
||||
@@ -71,9 +71,9 @@ export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
|
||||
aria-hidden
|
||||
className={[
|
||||
'absolute inline-block',
|
||||
'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'left-1/2 top-[10px] -translate-x-1/2 -translate-y-1/2',
|
||||
// Use a literal star; no icon font needed
|
||||
'text-[11px] leading-none text-[#e1c08d] select-none',
|
||||
'text-[16px] leading-none text-[#e1c08d] select-none',
|
||||
].join(' ')}
|
||||
>
|
||||
*
|
||||
|
||||
@@ -64,7 +64,7 @@ export class AuthMessageHandler extends BaseMessageHandler {
|
||||
vscode.window.showInformationMessage(
|
||||
'Please wait while we connect to Qwen Code...',
|
||||
);
|
||||
await vscode.commands.executeCommand('qwenCode.login');
|
||||
await vscode.commands.executeCommand('qwen-code.login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthMessageHandler] Login failed:', error);
|
||||
|
||||
Reference in New Issue
Block a user