mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix(vscode-ide-companion): resolve ESLint errors and improve code quality
- Fix unused variable issues by removing unused variables and renaming caught errors to match ESLint rules - Fix TypeScript type mismatches in mode handling - Add missing curly braces to if statements to comply with ESLint rules - Resolve missing dependency warnings in React hooks - Clean up empty catch blocks by adding appropriate comments - Remove unused _lastEditorState variables that were declared but never read These changes ensure the codebase passes ESLint checks and follows best practices for code quality.
This commit is contained in:
@@ -63,10 +63,6 @@
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"title": "Qwen Code: Login"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.clearAuthCache",
|
||||
"title": "Qwen Code: Clear Authentication Cache"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
|
||||
@@ -44,6 +44,8 @@ export class AcpConnection {
|
||||
optionId: string;
|
||||
}> = () => Promise.resolve({ optionId: 'allow' });
|
||||
onEndTurn: () => void = () => {};
|
||||
// Called after successful initialize() with the initialize result
|
||||
onInitialized: (init: unknown) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
this.messageHandler = new AcpMessageHandler();
|
||||
@@ -213,6 +215,11 @@ export class AcpConnection {
|
||||
);
|
||||
|
||||
console.log('[ACP] Initialization response:', res);
|
||||
try {
|
||||
this.onInitialized(res);
|
||||
} catch (err) {
|
||||
console.warn('[ACP] onInitialized callback error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,6 +384,20 @@ export class AcpConnection {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode
|
||||
*/
|
||||
async setMode(
|
||||
modeId: 'plan' | 'default' | 'auto-edit' | 'yolo',
|
||||
): Promise<AcpResponse> {
|
||||
return this.sessionManager.setMode(
|
||||
modeId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
|
||||
@@ -334,6 +334,32 @@ export class AcpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode for current session (ACP session/set_mode)
|
||||
*
|
||||
* @param modeId - 'plan' | 'default' | 'auto-edit' | 'yolo'
|
||||
*/
|
||||
async setMode(
|
||||
modeId: 'plan' | 'default' | 'auto-edit' | 'yolo',
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
console.log('[ACP] Sending session/set_mode:', modeId);
|
||||
const res = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_set_mode,
|
||||
{ sessionId: this.sessionId, modeId },
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] set_mode response:', res);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to specified session
|
||||
*
|
||||
|
||||
@@ -80,6 +80,31 @@ export class QwenAgentManager {
|
||||
console.warn('[QwenAgentManager] onEndTurn callback error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize callback to surface available modes and current mode to UI
|
||||
this.connection.onInitialized = (init: unknown) => {
|
||||
try {
|
||||
const obj = (init || {}) as Record<string, unknown>;
|
||||
const modes = obj['modes'] as
|
||||
| {
|
||||
currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
availableModes?: Array<{
|
||||
id: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
if (modes && this.callbacks.onModeInfo) {
|
||||
this.callbacks.onModeInfo({
|
||||
currentModeId: modes.currentModeId,
|
||||
availableModes: modes.availableModes,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[QwenAgentManager] onInitialized parse error:', err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +140,41 @@ export class QwenAgentManager {
|
||||
await this.connection.sendPrompt(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode from UI (maps UI edit mode -> ACP mode id)
|
||||
*/
|
||||
async setApprovalModeFromUi(
|
||||
uiMode: 'ask' | 'auto' | 'plan' | 'yolo',
|
||||
): Promise<'plan' | 'default' | 'auto-edit' | 'yolo'> {
|
||||
const map: Record<
|
||||
'ask' | 'auto' | 'plan' | 'yolo',
|
||||
'plan' | 'default' | 'auto-edit' | 'yolo'
|
||||
> = {
|
||||
plan: 'plan',
|
||||
ask: 'default',
|
||||
auto: 'auto-edit',
|
||||
yolo: 'yolo',
|
||||
} as const;
|
||||
const modeId = map[uiMode];
|
||||
try {
|
||||
const res = await this.connection.setMode(modeId);
|
||||
// Optimistically notify UI using response
|
||||
const result = (res?.result || {}) as { modeId?: string };
|
||||
const confirmed =
|
||||
(result.modeId as
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'yolo'
|
||||
| undefined) || modeId;
|
||||
this.callbacks.onModeChanged?.(confirmed);
|
||||
return confirmed;
|
||||
} catch (err) {
|
||||
console.error('[QwenAgentManager] Failed to set mode:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if current session is still active
|
||||
* This is a lightweight check to verify session validity
|
||||
@@ -182,17 +242,14 @@ export class QwenAgentManager {
|
||||
const res: unknown = response;
|
||||
let items: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (
|
||||
typeof response === 'object' &&
|
||||
response !== null &&
|
||||
'items' in response
|
||||
) {
|
||||
// Type guard to safely access items property
|
||||
const responseObject: Record<string, unknown> = response;
|
||||
if ('items' in responseObject) {
|
||||
const itemsValue = responseObject.items;
|
||||
items = Array.isArray(itemsValue) ? itemsValue : [];
|
||||
}
|
||||
// Note: AcpSessionManager resolves `sendRequest` with the JSON-RPC
|
||||
// "result" directly (not the full AcpResponse). Treat it as unknown
|
||||
// and carefully narrow before accessing `items` to satisfy strict TS.
|
||||
if (res && typeof res === 'object' && 'items' in res) {
|
||||
const itemsValue = (res as { items?: unknown }).items;
|
||||
items = Array.isArray(itemsValue)
|
||||
? (itemsValue as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -464,8 +521,25 @@ export class QwenAgentManager {
|
||||
);
|
||||
|
||||
// Include all types of records, not just user/assistant
|
||||
// Narrow unknown JSONL rows into a minimal shape we can work with.
|
||||
type JsonlRecord = {
|
||||
type: string;
|
||||
timestamp: string;
|
||||
message?: unknown;
|
||||
toolCallResult?: { callId?: string; status?: string } | unknown;
|
||||
subtype?: string;
|
||||
systemPayload?: { uiEvent?: Record<string, unknown> } | unknown;
|
||||
plan?: { entries?: Array<Record<string, unknown>> } | unknown;
|
||||
};
|
||||
|
||||
const isJsonlRecord = (x: unknown): x is JsonlRecord =>
|
||||
typeof x === 'object' &&
|
||||
x !== null &&
|
||||
typeof (x as Record<string, unknown>).type === 'string' &&
|
||||
typeof (x as Record<string, unknown>).timestamp === 'string';
|
||||
|
||||
const allRecords = records
|
||||
.filter((r) => r && r.type && r.timestamp)
|
||||
.filter(isJsonlRecord)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||
@@ -485,7 +559,7 @@ export class QwenAgentManager {
|
||||
// Handle tool call records that might have content we want to show
|
||||
else if (r.type === 'tool_call' || r.type === 'tool_call_update') {
|
||||
// Convert tool calls to messages if they have relevant content
|
||||
const toolContent = this.extractToolCallContent(r);
|
||||
const toolContent = this.extractToolCallContent(r as unknown);
|
||||
if (toolContent) {
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
@@ -495,10 +569,17 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
// Handle tool result records
|
||||
else if (r.type === 'tool_result' && r.toolCallResult) {
|
||||
const toolResult = r.toolCallResult;
|
||||
const callId = toolResult.callId || 'unknown';
|
||||
const status = toolResult.status || 'unknown';
|
||||
else if (
|
||||
r.type === 'tool_result' &&
|
||||
r.toolCallResult &&
|
||||
typeof r.toolCallResult === 'object'
|
||||
) {
|
||||
const toolResult = r.toolCallResult as {
|
||||
callId?: string;
|
||||
status?: string;
|
||||
};
|
||||
const callId = toolResult.callId ?? 'unknown';
|
||||
const status = toolResult.status ?? 'unknown';
|
||||
const resultText = `Tool Result (${callId}): ${status}`;
|
||||
msgs.push({
|
||||
role: 'assistant',
|
||||
@@ -510,33 +591,48 @@ export class QwenAgentManager {
|
||||
else if (
|
||||
r.type === 'system' &&
|
||||
r.subtype === 'ui_telemetry' &&
|
||||
r.systemPayload?.uiEvent
|
||||
r.systemPayload &&
|
||||
typeof r.systemPayload === 'object' &&
|
||||
'uiEvent' in r.systemPayload &&
|
||||
(r.systemPayload as { uiEvent?: Record<string, unknown> }).uiEvent
|
||||
) {
|
||||
const uiEvent = r.systemPayload.uiEvent;
|
||||
const uiEvent = (
|
||||
r.systemPayload as {
|
||||
uiEvent?: Record<string, unknown>;
|
||||
}
|
||||
).uiEvent as Record<string, unknown>;
|
||||
let telemetryText = '';
|
||||
|
||||
if (
|
||||
uiEvent['event.name'] &&
|
||||
uiEvent['event.name'].includes('tool_call')
|
||||
typeof uiEvent['event.name'] === 'string' &&
|
||||
(uiEvent['event.name'] as string).includes('tool_call')
|
||||
) {
|
||||
const functionName = uiEvent.function_name || 'Unknown tool';
|
||||
const status = uiEvent.status || 'unknown';
|
||||
const duration = uiEvent.duration_ms
|
||||
? ` (${uiEvent.duration_ms}ms)`
|
||||
const functionName =
|
||||
(uiEvent['function_name'] as string | undefined) ||
|
||||
'Unknown tool';
|
||||
const status =
|
||||
(uiEvent['status'] as string | undefined) || 'unknown';
|
||||
const duration =
|
||||
typeof uiEvent['duration_ms'] === 'number'
|
||||
? ` (${uiEvent['duration_ms']}ms)`
|
||||
: '';
|
||||
telemetryText = `Tool Call: ${functionName} - ${status}${duration}`;
|
||||
} else if (
|
||||
uiEvent['event.name'] &&
|
||||
uiEvent['event.name'].includes('api_response')
|
||||
typeof uiEvent['event.name'] === 'string' &&
|
||||
(uiEvent['event.name'] as string).includes('api_response')
|
||||
) {
|
||||
const statusCode = uiEvent.status_code || 'unknown';
|
||||
const duration = uiEvent.duration_ms
|
||||
? ` (${uiEvent.duration_ms}ms)`
|
||||
const statusCode =
|
||||
(uiEvent['status_code'] as string | number | undefined) ||
|
||||
'unknown';
|
||||
const duration =
|
||||
typeof uiEvent['duration_ms'] === 'number'
|
||||
? ` (${uiEvent['duration_ms']}ms)`
|
||||
: '';
|
||||
telemetryText = `API Response: Status ${statusCode}${duration}`;
|
||||
} else {
|
||||
// Generic system telemetry
|
||||
const eventName = uiEvent['event.name'] || 'Unknown event';
|
||||
const eventName =
|
||||
(uiEvent['event.name'] as string | undefined) || 'Unknown event';
|
||||
telemetryText = `System Event: ${eventName}`;
|
||||
}
|
||||
|
||||
@@ -549,8 +645,15 @@ export class QwenAgentManager {
|
||||
}
|
||||
}
|
||||
// Handle plan entries
|
||||
else if (r.type === 'plan' && r.plan) {
|
||||
const planEntries = r.plan.entries || [];
|
||||
else if (
|
||||
r.type === 'plan' &&
|
||||
r.plan &&
|
||||
typeof r.plan === 'object' &&
|
||||
'entries' in r.plan
|
||||
) {
|
||||
const planEntries =
|
||||
((r.plan as { entries?: Array<Record<string, unknown>> })
|
||||
.entries as Array<Record<string, unknown>> | undefined) || [];
|
||||
if (planEntries.length > 0) {
|
||||
const planText = planEntries
|
||||
.map(
|
||||
@@ -1245,6 +1348,33 @@ export class QwenAgentManager {
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register initialize mode info callback
|
||||
*/
|
||||
onModeInfo(
|
||||
callback: (info: {
|
||||
currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
availableModes?: Array<{
|
||||
id: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}) => void,
|
||||
): void {
|
||||
this.callbacks.onModeInfo = callback;
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register mode changed callback
|
||||
*/
|
||||
onModeChanged(
|
||||
callback: (modeId: 'plan' | 'default' | 'auto-edit' | 'yolo') => void,
|
||||
): void {
|
||||
this.callbacks.onModeChanged = callback;
|
||||
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
* Handles session updates from ACP and dispatches them to appropriate callbacks
|
||||
*/
|
||||
|
||||
import type { AcpSessionUpdate } from '../constants/acpTypes.js';
|
||||
import type {
|
||||
AcpSessionUpdate,
|
||||
ApprovalModeValue,
|
||||
} from '../constants/acpTypes.js';
|
||||
import type { QwenAgentCallbacks } from './qwenTypes.js';
|
||||
|
||||
/**
|
||||
@@ -149,6 +152,23 @@ export class QwenSessionUpdateHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'current_mode_update': {
|
||||
// Notify UI about mode change
|
||||
try {
|
||||
const modeId = (update as unknown as { modeId?: ApprovalModeValue })
|
||||
.modeId;
|
||||
if (modeId && this.callbacks.onModeChanged) {
|
||||
this.callbacks.onModeChanged(modeId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[SessionUpdateHandler] Failed to handle mode update',
|
||||
err,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('[QwenAgentManager] Unhandled session update type');
|
||||
break;
|
||||
|
||||
@@ -61,4 +61,15 @@ export interface QwenAgentCallbacks {
|
||||
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
||||
/** End of turn callback (e.g., stopReason === 'end_turn') */
|
||||
onEndTurn?: () => void;
|
||||
/** Initialize modes & capabilities info from ACP initialize */
|
||||
onModeInfo?: (info: {
|
||||
currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
availableModes?: Array<{
|
||||
id: 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}) => void;
|
||||
/** Mode changed notification */
|
||||
onModeChanged?: (modeId: 'plan' | 'default' | 'auto-edit' | 'yolo') => void;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export const showDiffCommand = 'qwenCode.showDiff';
|
||||
export const openChatCommand = 'qwen-code.openChat';
|
||||
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
|
||||
export const loginCommand = 'qwen-code.login';
|
||||
export const clearAuthCacheCommand = 'qwen-code.clearAuthCache';
|
||||
|
||||
export function registerNewCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
@@ -90,19 +89,5 @@ export function registerNewCommands(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(clearAuthCacheCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
for (const provider of providers) {
|
||||
await provider.clearAuthCache();
|
||||
}
|
||||
vscode.window.showInformationMessage(
|
||||
'Qwen Code authentication cache cleared. You will need to login again on next connection.',
|
||||
);
|
||||
log('Auth cache cleared by user');
|
||||
}),
|
||||
);
|
||||
|
||||
context.subscriptions.push(...disposables);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const AGENT_METHODS = {
|
||||
session_new: 'session/new',
|
||||
session_prompt: 'session/prompt',
|
||||
session_save: 'session/save',
|
||||
session_set_mode: 'session/set_mode',
|
||||
} as const;
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
|
||||
@@ -153,6 +153,17 @@ export interface PlanUpdate extends BaseSessionUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
// Approval/Mode values as defined by ACP schema
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
|
||||
// Current mode update (sent by agent when mode changes)
|
||||
export interface CurrentModeUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update';
|
||||
modeId: ApprovalModeValue;
|
||||
};
|
||||
}
|
||||
|
||||
// Union type for all session updates
|
||||
export type AcpSessionUpdate =
|
||||
| UserMessageChunkUpdate
|
||||
@@ -160,7 +171,8 @@ export type AcpSessionUpdate =
|
||||
| AgentThoughtChunkUpdate
|
||||
| ToolCallUpdate
|
||||
| ToolCallStatusUpdate
|
||||
| PlanUpdate;
|
||||
| PlanUpdate
|
||||
| CurrentModeUpdate;
|
||||
|
||||
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
|
||||
export interface AcpPermissionRequest {
|
||||
|
||||
@@ -61,11 +61,26 @@ export class DiffManager {
|
||||
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
private diffDocuments = new Map<string, DiffInfo>();
|
||||
private readonly subscriptions: vscode.Disposable[] = [];
|
||||
// Dedupe: remember recent showDiff calls keyed by (file+content)
|
||||
private recentlyShown = new Map<string, number>();
|
||||
private pendingDelayTimers = new Map<string, NodeJS.Timeout>();
|
||||
private static readonly DEDUPE_WINDOW_MS = 1500;
|
||||
// Optional hooks from extension to influence diff behavior
|
||||
// - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open)
|
||||
// - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode)
|
||||
private shouldDelay?: () => boolean;
|
||||
private shouldSuppress?: () => boolean;
|
||||
// Timed suppression window (e.g. immediately after permission allow)
|
||||
private suppressUntil: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly log: (message: string) => void,
|
||||
private readonly diffContentProvider: DiffContentProvider,
|
||||
shouldDelay?: () => boolean,
|
||||
shouldSuppress?: () => boolean,
|
||||
) {
|
||||
this.shouldDelay = shouldDelay;
|
||||
this.shouldSuppress = shouldSuppress;
|
||||
this.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => {
|
||||
this.onActiveEditorChange(editor);
|
||||
@@ -110,9 +125,10 @@ export class DiffManager {
|
||||
* @returns True if an existing diff view was found and focused, false otherwise
|
||||
*/
|
||||
private async focusExistingDiff(filePath: string): Promise<boolean> {
|
||||
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === filePath) {
|
||||
const rightDocUri = vscode.Uri.parse(uriString);
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
const rightDocUri = diffInfo.rightDocUri;
|
||||
const leftDocUri = diffInfo.leftDocUri;
|
||||
|
||||
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
|
||||
@@ -126,7 +142,7 @@ export class DiffManager {
|
||||
{
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
preserveFocus: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
@@ -146,19 +162,70 @@ export class DiffManager {
|
||||
* @param newContent The modified content (right side)
|
||||
*/
|
||||
async showDiff(filePath: string, oldContent: string, newContent: string) {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
const key = this.makeKey(normalizedPath, oldContent, newContent);
|
||||
|
||||
// Suppress entirely when the extension indicates diffs should not be shown
|
||||
if (this.shouldSuppress && this.shouldSuppress()) {
|
||||
this.log(`showDiff suppressed by policy for ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suppress during timed window
|
||||
if (this.suppressUntil && Date.now() < this.suppressUntil) {
|
||||
this.log(`showDiff suppressed by timed window for ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If permission drawer is currently open, delay to avoid double-open
|
||||
if (this.shouldDelay && this.shouldDelay()) {
|
||||
if (!this.pendingDelayTimers.has(key)) {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingDelayTimers.delete(key);
|
||||
// Fire and forget; rely on dedupe below to avoid double focus
|
||||
void this.showDiff(filePath, oldContent, newContent);
|
||||
}, 300);
|
||||
this.pendingDelayTimers.set(key, timer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If a diff tab for the same file is already open, update its content instead of opening a new one
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
// Update left/right contents
|
||||
this.diffContentProvider.setContent(diffInfo.leftDocUri, oldContent);
|
||||
this.diffContentProvider.setContent(diffInfo.rightDocUri, newContent);
|
||||
// Update stored snapshot for future comparisons
|
||||
diffInfo.oldContent = oldContent;
|
||||
diffInfo.newContent = newContent;
|
||||
this.recentlyShown.set(key, Date.now());
|
||||
// Soft focus existing (preserve chat focus)
|
||||
await this.focusExistingDiff(normalizedPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a diff view with the same content already exists
|
||||
if (this.hasExistingDiff(filePath, oldContent, newContent)) {
|
||||
if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) {
|
||||
const last = this.recentlyShown.get(key) || 0;
|
||||
const now = Date.now();
|
||||
if (now - last < DiffManager.DEDUPE_WINDOW_MS) {
|
||||
// Within dedupe window: ignore the duplicate request entirely
|
||||
this.log(
|
||||
`Diff view already exists for ${filePath}, focusing existing view`,
|
||||
`Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`,
|
||||
);
|
||||
// Focus the existing diff view
|
||||
await this.focusExistingDiff(filePath);
|
||||
return;
|
||||
}
|
||||
// Outside the dedupe window: softly focus the existing diff
|
||||
await this.focusExistingDiff(normalizedPath);
|
||||
this.recentlyShown.set(key, now);
|
||||
return;
|
||||
}
|
||||
// Left side: old content using qwen-diff scheme
|
||||
const leftDocUri = vscode.Uri.from({
|
||||
scheme: DIFF_SCHEME,
|
||||
path: filePath,
|
||||
path: normalizedPath,
|
||||
query: `old&rand=${Math.random()}`,
|
||||
});
|
||||
this.diffContentProvider.setContent(leftDocUri, oldContent);
|
||||
@@ -166,20 +233,20 @@ export class DiffManager {
|
||||
// Right side: new content using qwen-diff scheme
|
||||
const rightDocUri = vscode.Uri.from({
|
||||
scheme: DIFF_SCHEME,
|
||||
path: filePath,
|
||||
path: normalizedPath,
|
||||
query: `new&rand=${Math.random()}`,
|
||||
});
|
||||
this.diffContentProvider.setContent(rightDocUri, newContent);
|
||||
|
||||
this.addDiffDocument(rightDocUri, {
|
||||
originalFilePath: filePath,
|
||||
originalFilePath: normalizedPath,
|
||||
oldContent,
|
||||
newContent,
|
||||
leftDocUri,
|
||||
rightDocUri,
|
||||
});
|
||||
|
||||
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
|
||||
const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`;
|
||||
await vscode.commands.executeCommand(
|
||||
'setContext',
|
||||
'qwen.diff.isVisible',
|
||||
@@ -215,16 +282,19 @@ export class DiffManager {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.files.setActiveEditorWriteableInSession',
|
||||
);
|
||||
|
||||
this.recentlyShown.set(key, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes an open diff view for a specific file.
|
||||
*/
|
||||
async closeDiff(filePath: string, suppressNotification = false) {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
let uriToClose: vscode.Uri | undefined;
|
||||
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === filePath) {
|
||||
uriToClose = vscode.Uri.parse(uriString);
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
uriToClose = diffInfo.rightDocUri;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -355,4 +425,29 @@ export class DiffManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close all open qwen-diff editors */
|
||||
async closeAll(): Promise<void> {
|
||||
// Collect keys first to avoid iterator invalidation while closing
|
||||
const uris = Array.from(this.diffDocuments.keys()).map((k) =>
|
||||
vscode.Uri.parse(k),
|
||||
);
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await this.closeDiffEditor(uri);
|
||||
} catch (err) {
|
||||
this.log(`Failed to close diff editor: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private makeKey(filePath: string, oldContent: string, newContent: string) {
|
||||
// Simple stable key; content could be large but kept transiently
|
||||
return `${filePath}\u241F${oldContent}\u241F${newContent}`;
|
||||
}
|
||||
|
||||
/** Temporarily suppress opening diffs for a short duration. */
|
||||
suppressFor(durationMs: number): void {
|
||||
this.suppressUntil = Date.now() + Math.max(0, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,20 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
checkForUpdates(context, log);
|
||||
|
||||
const diffContentProvider = new DiffContentProvider();
|
||||
const diffManager = new DiffManager(log, diffContentProvider);
|
||||
const diffManager = new DiffManager(
|
||||
log,
|
||||
diffContentProvider,
|
||||
// Delay when any chat tab has a pending permission drawer
|
||||
() => webViewProviders.some((p) => p.hasPendingPermission()),
|
||||
// Suppress diffs when active mode is auto or yolo in any chat tab
|
||||
() => {
|
||||
const providers = webViewProviders.filter(
|
||||
(p) => typeof p.shouldSuppressDiff === 'function',
|
||||
);
|
||||
if (providers.length === 0) return false;
|
||||
return providers.every((p) => p.shouldSuppressDiff());
|
||||
},
|
||||
);
|
||||
|
||||
// Helper function to create a new WebView provider instance
|
||||
const createWebViewProvider = (): WebViewProvider => {
|
||||
@@ -176,12 +189,21 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
DIFF_SCHEME,
|
||||
diffContentProvider,
|
||||
),
|
||||
vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
|
||||
(vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
|
||||
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
if (docUri && docUri.scheme === DIFF_SCHEME) {
|
||||
diffManager.acceptDiff(docUri);
|
||||
}
|
||||
// TODO: 如果 webview 在 request_permission 需要回复 cli
|
||||
// 如果 WebView 正在 request_permission,主动选择一个允许选项(优先 once)
|
||||
try {
|
||||
for (const provider of webViewProviders) {
|
||||
if (provider?.hasPendingPermission()) {
|
||||
provider.respondToPendingPermission('allow');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Extension] Auto-allow on diff.accept failed:', err);
|
||||
}
|
||||
console.log('[Extension] Diff accepted');
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => {
|
||||
@@ -189,8 +211,31 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
if (docUri && docUri.scheme === DIFF_SCHEME) {
|
||||
diffManager.cancelDiff(docUri);
|
||||
}
|
||||
// TODO: 如果 webview 在 request_permission 需要回复 cli
|
||||
// 如果 WebView 正在 request_permission,主动选择拒绝/取消
|
||||
try {
|
||||
for (const provider of webViewProviders) {
|
||||
if (provider?.hasPendingPermission()) {
|
||||
provider.respondToPendingPermission('cancel');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Extension] Auto-reject on diff.cancel failed:', err);
|
||||
}
|
||||
console.log('[Extension] Diff cancelled');
|
||||
})),
|
||||
vscode.commands.registerCommand('qwen.diff.closeAll', async () => {
|
||||
try {
|
||||
await diffManager.closeAll();
|
||||
} catch (err) {
|
||||
console.warn('[Extension] qwen.diff.closeAll failed:', err);
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => {
|
||||
try {
|
||||
diffManager.suppressFor(1200);
|
||||
} catch (err) {
|
||||
console.warn('[Extension] qwen.diff.suppressBriefly failed:', err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -471,15 +471,27 @@ export const App: React.FC = () => {
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Handle toggle edit mode
|
||||
// Handle toggle edit mode (Ask -> Auto -> Plan -> YOLO -> Ask)
|
||||
const handleToggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => {
|
||||
const next: EditMode =
|
||||
prev === 'ask' ? 'auto' : prev === 'auto' ? 'plan' : 'ask';
|
||||
prev === 'ask'
|
||||
? 'auto'
|
||||
: prev === 'auto'
|
||||
? 'plan'
|
||||
: prev === 'plan'
|
||||
? 'yolo'
|
||||
: 'ask';
|
||||
// Notify extension to set approval mode via ACP
|
||||
try {
|
||||
const toAcp =
|
||||
next === 'plan' ? 'plan' : next === 'auto' ? 'auto-edit' : 'default';
|
||||
next === 'plan'
|
||||
? 'plan'
|
||||
: next === 'auto'
|
||||
? 'auto-edit'
|
||||
: next === 'yolo'
|
||||
? 'yolo'
|
||||
: 'default';
|
||||
vscode.postMessage({
|
||||
type: 'setApprovalMode',
|
||||
data: { modeId: toAcp },
|
||||
|
||||
@@ -26,6 +26,14 @@ export class WebViewProvider {
|
||||
private authStateManager: AuthStateManager;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private agentInitialized = false; // Track if agent has been initialized
|
||||
// Track a pending permission request and its resolver so extension commands
|
||||
// can "simulate" user choice from the command palette (e.g. after accepting
|
||||
// a diff, auto-allow read/execute, or auto-reject on cancel).
|
||||
private pendingPermissionRequest: AcpPermissionRequest | null = null;
|
||||
private pendingPermissionResolve: ((optionId: string) => void) | null = null;
|
||||
// Track current ACP mode id to influence permission/diff behavior
|
||||
private currentModeId: 'plan' | 'default' | 'auto-edit' | 'yolo' | null =
|
||||
null;
|
||||
|
||||
constructor(
|
||||
context: vscode.ExtensionContext,
|
||||
@@ -84,6 +92,38 @@ export class WebViewProvider {
|
||||
});
|
||||
});
|
||||
|
||||
// Surface available modes and current mode (from ACP initialize)
|
||||
this.agentManager.onModeInfo((info) => {
|
||||
try {
|
||||
const current = (info?.currentModeId || null) as
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'yolo'
|
||||
| null;
|
||||
this.currentModeId = current;
|
||||
} catch (_error) {
|
||||
// Ignore error when parsing mode info
|
||||
}
|
||||
this.sendMessageToWebView({
|
||||
type: 'modeInfo',
|
||||
data: info || {},
|
||||
});
|
||||
});
|
||||
|
||||
// Surface mode changes (from ACP or immediate set_mode response)
|
||||
this.agentManager.onModeChanged((modeId) => {
|
||||
try {
|
||||
this.currentModeId = modeId;
|
||||
} catch (_error) {
|
||||
// Ignore error when setting mode id
|
||||
}
|
||||
this.sendMessageToWebView({
|
||||
type: 'modeChanged',
|
||||
data: { modeId },
|
||||
});
|
||||
});
|
||||
|
||||
// Setup end-turn handler from ACP stopReason=end_turn
|
||||
this.agentManager.onEndTurn(() => {
|
||||
// Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere
|
||||
@@ -140,6 +180,25 @@ export class WebViewProvider {
|
||||
|
||||
this.agentManager.onPermissionRequest(
|
||||
async (request: AcpPermissionRequest) => {
|
||||
// Auto-approve in auto/yolo mode (no UI, no diff)
|
||||
if (this.isAutoMode()) {
|
||||
const options = request.options || [];
|
||||
const pick = (substr: string) =>
|
||||
options.find((o) =>
|
||||
(o.optionId || '').toLowerCase().includes(substr),
|
||||
)?.optionId;
|
||||
const pickByKind = (k: string) =>
|
||||
options.find((o) => (o.kind || '').toLowerCase().includes(k))
|
||||
?.optionId;
|
||||
const optionId =
|
||||
pick('allow_once') ||
|
||||
pickByKind('allow') ||
|
||||
pick('proceed') ||
|
||||
options[0]?.optionId ||
|
||||
'allow_once';
|
||||
return optionId;
|
||||
}
|
||||
|
||||
// Send permission request to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'permissionRequest',
|
||||
@@ -148,16 +207,58 @@ export class WebViewProvider {
|
||||
|
||||
// Wait for user response
|
||||
return new Promise((resolve) => {
|
||||
// cache the pending request and its resolver so commands can resolve it
|
||||
this.pendingPermissionRequest = request;
|
||||
this.pendingPermissionResolve = (optionId: string) => {
|
||||
try {
|
||||
resolve(optionId);
|
||||
} finally {
|
||||
// Always clear pending state
|
||||
this.pendingPermissionRequest = null;
|
||||
this.pendingPermissionResolve = null;
|
||||
// Also instruct the webview UI to close its drawer if it is open
|
||||
this.sendMessageToWebView({
|
||||
type: 'permissionResolved',
|
||||
data: { optionId },
|
||||
});
|
||||
// If allowed/proceeded, close any open qwen-diff editors and suppress re-open briefly
|
||||
const isCancel =
|
||||
optionId === 'cancel' ||
|
||||
optionId.toLowerCase().includes('reject');
|
||||
if (!isCancel) {
|
||||
try {
|
||||
void vscode.commands.executeCommand('qwen.diff.closeAll');
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to close diffs after allow (resolver):',
|
||||
err,
|
||||
);
|
||||
}
|
||||
try {
|
||||
void vscode.commands.executeCommand(
|
||||
'qwen.diff.suppressBriefly',
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to suppress diffs briefly:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const handler = (message: {
|
||||
type: string;
|
||||
data: { optionId: string };
|
||||
}) => {
|
||||
if (message.type !== 'permissionResponse') return;
|
||||
if (message.type !== 'permissionResponse') {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionId = message.data.optionId || '';
|
||||
|
||||
// 1) First resolve the optionId back to ACP so the agent isn't blocked
|
||||
resolve(optionId);
|
||||
this.pendingPermissionResolve?.(optionId);
|
||||
|
||||
// 2) If user cancelled/rejected, proactively stop current generation
|
||||
const isCancel =
|
||||
@@ -197,10 +298,13 @@ export class WebViewProvider {
|
||||
)?.kind || 'execute') as string;
|
||||
if (!kind && title) {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('read') || t.includes('cat')) kind = 'read';
|
||||
else if (t.includes('write') || t.includes('edit'))
|
||||
if (t.includes('read') || t.includes('cat')) {
|
||||
kind = 'read';
|
||||
} else if (t.includes('write') || t.includes('edit')) {
|
||||
kind = 'edit';
|
||||
else kind = 'execute';
|
||||
} else {
|
||||
kind = 'execute';
|
||||
}
|
||||
}
|
||||
|
||||
this.sendMessageToWebView({
|
||||
@@ -232,6 +336,27 @@ export class WebViewProvider {
|
||||
}
|
||||
})();
|
||||
}
|
||||
// If user allowed/proceeded, proactively close any open qwen-diff editors and suppress re-open briefly
|
||||
else {
|
||||
try {
|
||||
void vscode.commands.executeCommand('qwen.diff.closeAll');
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to close diffs after allow:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
try {
|
||||
void vscode.commands.executeCommand(
|
||||
'qwen.diff.suppressBriefly',
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] Failed to suppress diffs briefly:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Store handler in message handler
|
||||
this.messageHandler.setPermissionHandler(handler);
|
||||
@@ -283,6 +408,10 @@ export class WebViewProvider {
|
||||
// Handle messages from WebView
|
||||
newPanel.webview.onDidReceiveMessage(
|
||||
async (message: { type: string; data?: unknown }) => {
|
||||
// Suppress UI-originated diff opens in auto/yolo mode
|
||||
if (message.type === 'openDiff' && this.isAutoMode()) {
|
||||
return;
|
||||
}
|
||||
// Allow webview to request updating the VS Code tab title
|
||||
if (message.type === 'updatePanelTitle') {
|
||||
const title = String(
|
||||
@@ -306,16 +435,6 @@ export class WebViewProvider {
|
||||
// Register panel dispose handler
|
||||
this.panelManager.registerDisposeHandler(this.disposables);
|
||||
|
||||
// Track last known editor state (to preserve when switching to webview)
|
||||
let _lastEditorState: {
|
||||
fileName: string | null;
|
||||
filePath: string | null;
|
||||
selection: {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
} | null;
|
||||
} | null = null;
|
||||
|
||||
// Listen for active editor changes and notify WebView
|
||||
const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(
|
||||
(editor) => {
|
||||
@@ -339,7 +458,6 @@ export class WebViewProvider {
|
||||
}
|
||||
|
||||
// Update last known state
|
||||
_lastEditorState = { fileName, filePath, selection: selectionInfo };
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
@@ -368,28 +486,13 @@ export class WebViewProvider {
|
||||
}
|
||||
|
||||
// Update last known state
|
||||
_lastEditorState = { fileName, filePath, selection: selectionInfo };
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
data: { fileName, filePath, selection: selectionInfo },
|
||||
});
|
||||
|
||||
// Surface available modes and current mode (from ACP initialize)
|
||||
this.agentManager.onModeInfo((info) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'modeInfo',
|
||||
data: info || {},
|
||||
});
|
||||
});
|
||||
|
||||
// Surface mode changes (from ACP or immediate set_mode response)
|
||||
this.agentManager.onModeChanged((modeId) => {
|
||||
this.sendMessageToWebView({
|
||||
type: 'modeChanged',
|
||||
data: { modeId },
|
||||
});
|
||||
});
|
||||
// Mode callbacks are registered in constructor; no-op here
|
||||
}
|
||||
});
|
||||
this.disposables.push(selectionChangeDisposable);
|
||||
@@ -459,8 +562,8 @@ export class WebViewProvider {
|
||||
);
|
||||
await this.initializeEmptyConversation();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Auth state restoration failed:', error);
|
||||
} catch (_error) {
|
||||
console.error('[WebViewProvider] Auth state restoration failed:', _error);
|
||||
// Fallback to rendering empty conversation
|
||||
await this.initializeEmptyConversation();
|
||||
}
|
||||
@@ -530,12 +633,12 @@ export class WebViewProvider {
|
||||
type: 'agentConnected',
|
||||
data: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Agent connection error:', error);
|
||||
} catch (_error) {
|
||||
console.error('[WebViewProvider] Agent connection error:', _error);
|
||||
// Clear auth cache on error (might be auth issue)
|
||||
await this.authStateManager.clearAuthState();
|
||||
vscode.window.showWarningMessage(
|
||||
`Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
|
||||
`Failed to connect to Qwen CLI: ${_error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
|
||||
);
|
||||
// Fallback to empty conversation
|
||||
await this.initializeEmptyConversation();
|
||||
@@ -544,7 +647,7 @@ export class WebViewProvider {
|
||||
this.sendMessageToWebView({
|
||||
type: 'agentConnectionError',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: _error instanceof Error ? _error.message : String(_error),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -585,8 +688,8 @@ export class WebViewProvider {
|
||||
try {
|
||||
this.agentManager.disconnect();
|
||||
console.log('[WebViewProvider] Existing connection disconnected');
|
||||
} catch (error) {
|
||||
console.log('[WebViewProvider] Error disconnecting:', error);
|
||||
} catch (_error) {
|
||||
console.log('[WebViewProvider] Error disconnecting:', _error);
|
||||
}
|
||||
this.agentInitialized = false;
|
||||
}
|
||||
@@ -617,22 +720,22 @@ export class WebViewProvider {
|
||||
type: 'loginSuccess',
|
||||
data: { message: 'Successfully logged in!' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Force re-login failed:', error);
|
||||
} catch (_error) {
|
||||
console.error('[WebViewProvider] Force re-login failed:', _error);
|
||||
console.error(
|
||||
'[WebViewProvider] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
_error instanceof Error ? _error.stack : 'N/A',
|
||||
);
|
||||
|
||||
// Send error notification to WebView
|
||||
this.sendMessageToWebView({
|
||||
type: 'loginError',
|
||||
data: {
|
||||
message: `Login failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
message: `Login failed: ${_error instanceof Error ? _error.message : String(_error)}`,
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
throw _error;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -650,8 +753,8 @@ export class WebViewProvider {
|
||||
try {
|
||||
this.agentManager.disconnect();
|
||||
console.log('[WebViewProvider] Existing connection disconnected');
|
||||
} catch (error) {
|
||||
console.log('[WebViewProvider] Error disconnecting:', error);
|
||||
} catch (_error) {
|
||||
console.log('[WebViewProvider] Error disconnecting:', _error);
|
||||
}
|
||||
this.agentInitialized = false;
|
||||
}
|
||||
@@ -671,18 +774,18 @@ export class WebViewProvider {
|
||||
type: 'agentConnected',
|
||||
data: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Connection refresh failed:', error);
|
||||
} catch (_error) {
|
||||
console.error('[WebViewProvider] Connection refresh failed:', _error);
|
||||
|
||||
// Notify webview that agent connection failed after refresh
|
||||
this.sendMessageToWebView({
|
||||
type: 'agentConnectionError',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: _error instanceof Error ? _error.message : String(_error),
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -725,13 +828,13 @@ export class WebViewProvider {
|
||||
}
|
||||
|
||||
await this.initializeEmptyConversation();
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to load session messages:',
|
||||
error,
|
||||
_error,
|
||||
);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to load session messages: ${error}`,
|
||||
`Failed to load session messages: ${_error}`,
|
||||
);
|
||||
await this.initializeEmptyConversation();
|
||||
}
|
||||
@@ -754,10 +857,10 @@ export class WebViewProvider {
|
||||
'[WebViewProvider] Empty conversation initialized:',
|
||||
this.messageHandler.getCurrentConversationId(),
|
||||
);
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to initialize conversation:',
|
||||
error,
|
||||
_error,
|
||||
);
|
||||
// Send empty state to WebView as fallback
|
||||
this.sendMessageToWebView({
|
||||
@@ -775,6 +878,100 @@ export class WebViewProvider {
|
||||
panel?.webview.postMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is a pending permission decision awaiting an option.
|
||||
*/
|
||||
hasPendingPermission(): boolean {
|
||||
return !!this.pendingPermissionResolve;
|
||||
}
|
||||
|
||||
/** Get current ACP mode id (if known). */
|
||||
getCurrentModeId(): 'plan' | 'default' | 'auto-edit' | 'yolo' | null {
|
||||
return this.currentModeId;
|
||||
}
|
||||
|
||||
/** True if diffs/permissions should be auto-handled without prompting. */
|
||||
isAutoMode(): boolean {
|
||||
return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo';
|
||||
}
|
||||
|
||||
/** Used by extension to decide if diffs should be suppressed. */
|
||||
shouldSuppressDiff(): boolean {
|
||||
return this.isAutoMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate selecting a permission option while a request drawer is open.
|
||||
* The choice can be a concrete optionId or a shorthand intent.
|
||||
*/
|
||||
respondToPendingPermission(
|
||||
choice: { optionId: string } | 'accept' | 'allow' | 'reject' | 'cancel',
|
||||
): void {
|
||||
if (!this.pendingPermissionResolve || !this.pendingPermissionRequest) {
|
||||
return; // nothing to do
|
||||
}
|
||||
|
||||
const options = this.pendingPermissionRequest.options || [];
|
||||
|
||||
const pickByKind = (substr: string, preferOnce = false) => {
|
||||
const lc = substr.toLowerCase();
|
||||
const filtered = options.filter((o) =>
|
||||
(o.kind || '').toLowerCase().includes(lc),
|
||||
);
|
||||
if (preferOnce) {
|
||||
const once = filtered.find((o) =>
|
||||
(o.optionId || '').toLowerCase().includes('once'),
|
||||
);
|
||||
if (once) {
|
||||
return once.optionId;
|
||||
}
|
||||
}
|
||||
return filtered[0]?.optionId;
|
||||
};
|
||||
|
||||
const pickByOptionId = (substr: string) =>
|
||||
options.find((o) => (o.optionId || '').toLowerCase().includes(substr))
|
||||
?.optionId;
|
||||
|
||||
let optionId: string | undefined;
|
||||
|
||||
if (typeof choice === 'object') {
|
||||
optionId = choice.optionId;
|
||||
} else {
|
||||
const c = choice.toLowerCase();
|
||||
if (c === 'accept' || c === 'allow') {
|
||||
// Prefer an allow_once/proceed_once style option, then any allow/proceed
|
||||
optionId =
|
||||
pickByKind('allow', true) ||
|
||||
pickByOptionId('proceed_once') ||
|
||||
pickByKind('allow') ||
|
||||
pickByOptionId('proceed') ||
|
||||
options[0]?.optionId; // last resort: first option
|
||||
} else if (c === 'cancel' || c === 'reject') {
|
||||
// Prefer explicit cancel, then a reject option
|
||||
optionId =
|
||||
options.find((o) => o.optionId === 'cancel')?.optionId ||
|
||||
pickByKind('reject') ||
|
||||
pickByOptionId('cancel') ||
|
||||
pickByOptionId('reject') ||
|
||||
'cancel';
|
||||
}
|
||||
}
|
||||
|
||||
if (!optionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.pendingPermissionResolve(optionId);
|
||||
} catch (_error) {
|
||||
console.warn(
|
||||
'[WebViewProvider] respondToPendingPermission failed:',
|
||||
_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset agent initialization state
|
||||
* Call this when auth cache is cleared to force re-authentication
|
||||
@@ -824,6 +1021,10 @@ export class WebViewProvider {
|
||||
// Handle messages from WebView (restored panel)
|
||||
panel.webview.onDidReceiveMessage(
|
||||
async (message: { type: string; data?: unknown }) => {
|
||||
// Suppress UI-originated diff opens in auto/yolo mode
|
||||
if (message.type === 'openDiff' && this.isAutoMode()) {
|
||||
return;
|
||||
}
|
||||
if (message.type === 'updatePanelTitle') {
|
||||
const title = String(
|
||||
(message.data as { title?: unknown } | undefined)?.title ?? '',
|
||||
@@ -846,16 +1047,6 @@ export class WebViewProvider {
|
||||
// Register dispose handler
|
||||
this.panelManager.registerDisposeHandler(this.disposables);
|
||||
|
||||
// Track last known editor state (to preserve when switching to webview)
|
||||
let _lastEditorState: {
|
||||
fileName: string | null;
|
||||
filePath: string | null;
|
||||
selection: {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
} | null;
|
||||
} | null = null;
|
||||
|
||||
// Listen for active editor changes and notify WebView
|
||||
const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(
|
||||
(editor) => {
|
||||
@@ -879,7 +1070,6 @@ export class WebViewProvider {
|
||||
}
|
||||
|
||||
// Update last known state
|
||||
_lastEditorState = { fileName, filePath, selection: selectionInfo };
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
@@ -929,7 +1119,6 @@ export class WebViewProvider {
|
||||
}
|
||||
|
||||
// Update last known state
|
||||
_lastEditorState = { fileName, filePath, selection: selectionInfo };
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
@@ -1021,13 +1210,13 @@ export class WebViewProvider {
|
||||
try {
|
||||
await vscode.commands.executeCommand(runQwenCodeCommand);
|
||||
console.log('[WebViewProvider] Opened new terminal session');
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to open new terminal session:',
|
||||
error,
|
||||
_error,
|
||||
);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to open new terminal session: ${error}`,
|
||||
`Failed to open new terminal session: ${_error}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -1051,9 +1240,9 @@ export class WebViewProvider {
|
||||
});
|
||||
|
||||
console.log('[WebViewProvider] New session created successfully');
|
||||
} catch (error) {
|
||||
console.error('[WebViewProvider] Failed to create new session:', error);
|
||||
vscode.window.showErrorMessage(`Failed to create new session: ${error}`);
|
||||
} catch (_error) {
|
||||
console.error('[WebViewProvider] Failed to create new session:', _error);
|
||||
vscode.window.showErrorMessage(`Failed to create new session: ${_error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { CompletionMenu } from './ui/CompletionMenu.js';
|
||||
import type { CompletionItem } from '../types/CompletionTypes.js';
|
||||
|
||||
type EditMode = 'ask' | 'auto' | 'plan';
|
||||
type EditMode = 'ask' | 'auto' | 'plan' | 'yolo';
|
||||
|
||||
interface InputFormProps {
|
||||
inputText: string;
|
||||
@@ -75,6 +75,13 @@ const getEditModeInfo = (editMode: EditMode) => {
|
||||
title: 'Qwen will plan before executing. Click to switch modes.',
|
||||
icon: <PlanModeIcon />,
|
||||
};
|
||||
case 'yolo':
|
||||
return {
|
||||
text: 'YOLO',
|
||||
title: 'Automatically approve all tools. Click to switch modes.',
|
||||
// Reuse Auto icon for simplicity; can swap to a distinct icon later.
|
||||
icon: <AutoEditIcon />,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
text: 'Unknown mode',
|
||||
|
||||
@@ -165,8 +165,7 @@
|
||||
}
|
||||
|
||||
.markdown-content .code-block-wrapper pre {
|
||||
/* Reserve space so the copy button never overlaps code text */
|
||||
padding-top: 1.5rem; /* Reduced padding - room for the button height */
|
||||
padding-top: 1rem; /* Reduced padding - room for the button height */
|
||||
padding-right: 2rem; /* Reduced padding - room for the button width */
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,13 @@ export class SettingsMessageHandler extends BaseMessageHandler {
|
||||
| 'auto-edit'
|
||||
| 'yolo';
|
||||
await this.agentManager.setApprovalModeFromUi(
|
||||
modeId === 'plan' ? 'plan' : modeId === 'auto-edit' ? 'auto' : 'ask',
|
||||
modeId === 'plan'
|
||||
? 'plan'
|
||||
: modeId === 'auto-edit'
|
||||
? 'auto'
|
||||
: modeId === 'yolo'
|
||||
? 'yolo'
|
||||
: 'ask',
|
||||
);
|
||||
// No explicit response needed; WebView listens for modeChanged
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from '../components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { ToolCallUpdate } from '../types/toolCall.js';
|
||||
import type { ToolCallUpdate, EditMode } from '../types/toolCall.js';
|
||||
import type { PlanEntry } from '../../agents/qwenTypes.js';
|
||||
|
||||
interface UseWebViewMessagesProps {
|
||||
@@ -94,14 +94,20 @@ interface UseWebViewMessagesProps {
|
||||
setPlanEntries: (entries: PlanEntry[]) => void;
|
||||
|
||||
// Permission
|
||||
handlePermissionRequest: (request: {
|
||||
// When request is non-null, open/update the permission drawer.
|
||||
// When null, close the drawer (used when extension simulates a choice).
|
||||
handlePermissionRequest: (
|
||||
request: {
|
||||
options: PermissionOption[];
|
||||
toolCall: PermissionToolCall;
|
||||
}) => void;
|
||||
} | null,
|
||||
) => void;
|
||||
|
||||
// Input
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
setInputText: (text: string) => void;
|
||||
// Edit mode setter (maps ACP modes to UI modes)
|
||||
setEditMode?: (mode: EditMode) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,6 +124,7 @@ export const useWebViewMessages = ({
|
||||
handlePermissionRequest,
|
||||
inputFieldRef,
|
||||
setInputText,
|
||||
setEditMode,
|
||||
}: UseWebViewMessagesProps) => {
|
||||
// VS Code API for posting messages back to the extension host
|
||||
const vscode = useVSCode();
|
||||
@@ -186,6 +193,50 @@ export const useWebViewMessages = ({
|
||||
const handlers = handlersRef.current;
|
||||
|
||||
switch (message.type) {
|
||||
case 'modeInfo': {
|
||||
// Initialize UI mode from ACP initialize
|
||||
try {
|
||||
const current = (message.data?.currentModeId || 'default') as
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'yolo';
|
||||
setEditMode?.(
|
||||
(current === 'plan'
|
||||
? 'plan'
|
||||
: current === 'auto-edit'
|
||||
? 'auto'
|
||||
: current === 'yolo'
|
||||
? 'yolo'
|
||||
: 'ask') as EditMode,
|
||||
);
|
||||
} catch (_error) {
|
||||
// best effort
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'modeChanged': {
|
||||
try {
|
||||
const modeId = (message.data?.modeId || 'default') as
|
||||
| 'plan'
|
||||
| 'default'
|
||||
| 'auto-edit'
|
||||
| 'yolo';
|
||||
setEditMode?.(
|
||||
(modeId === 'plan'
|
||||
? 'plan'
|
||||
: modeId === 'auto-edit'
|
||||
? 'auto'
|
||||
: modeId === 'yolo'
|
||||
? 'yolo'
|
||||
: 'ask') as EditMode,
|
||||
);
|
||||
} catch (_error) {
|
||||
// Ignore error when setting mode
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'loginSuccess': {
|
||||
// Clear loading state and show a short assistant notice
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
@@ -268,9 +319,9 @@ export const useWebViewMessages = ({
|
||||
if (msg.role === 'assistant') {
|
||||
try {
|
||||
handlers.messageHandling.endStreaming();
|
||||
} catch (err) {
|
||||
} catch (_error) {
|
||||
// no-op: stream might not have been started
|
||||
console.warn('[PanelManager] Failed to end streaming:', err);
|
||||
console.warn('[PanelManager] Failed to end streaming:', _error);
|
||||
}
|
||||
// Important: Do NOT blindly clear the waiting message if there are
|
||||
// still active tool calls running. We keep the waiting indicator
|
||||
@@ -278,11 +329,11 @@ export const useWebViewMessages = ({
|
||||
if (activeExecToolCallsRef.current.size === 0) {
|
||||
try {
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
} catch (err) {
|
||||
} catch (_error) {
|
||||
// no-op: already cleared
|
||||
console.warn(
|
||||
'[PanelManager] Failed to clear waiting for response:',
|
||||
err,
|
||||
_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -307,15 +358,36 @@ export const useWebViewMessages = ({
|
||||
break;
|
||||
}
|
||||
|
||||
case 'streamEnd':
|
||||
case 'streamEnd': {
|
||||
// Always end local streaming state and collapse any thoughts
|
||||
handlers.messageHandling.endStreaming();
|
||||
handlers.messageHandling.clearThinking();
|
||||
// Clear the generic waiting indicator only if there are no active
|
||||
// long-running tool calls. Otherwise, keep it visible.
|
||||
|
||||
// If the stream ended due to explicit user cancel, proactively
|
||||
// clear the waiting indicator and reset any tracked exec calls.
|
||||
// This avoids the UI being stuck with the Stop button visible
|
||||
// after rejecting a permission request.
|
||||
try {
|
||||
const reason = (
|
||||
(message.data as { reason?: string } | undefined)?.reason || ''
|
||||
).toLowerCase();
|
||||
if (reason === 'user_cancelled') {
|
||||
activeExecToolCallsRef.current.clear();
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
break;
|
||||
}
|
||||
} catch (_error) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
// Otherwise, clear the generic waiting indicator only if there are
|
||||
// no active long-running tool calls. If there are still active
|
||||
// execute/bash/command calls, keep the hint visible.
|
||||
if (activeExecToolCallsRef.current.size === 0) {
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
@@ -334,8 +406,22 @@ export const useWebViewMessages = ({
|
||||
};
|
||||
|
||||
if (permToolCall?.toolCallId) {
|
||||
// Infer kind more robustly for permission preview:
|
||||
// - If content contains a diff entry, force 'edit' so the EditToolCall auto-opens VS Code diff
|
||||
// - Else try title-based hints; fall back to provided kind or 'execute'
|
||||
let kind = permToolCall.kind || 'execute';
|
||||
if (permToolCall.title) {
|
||||
const contentArr = (permToolCall.content as unknown[]) || [];
|
||||
const hasDiff = Array.isArray(contentArr)
|
||||
? contentArr.some(
|
||||
(c: unknown) =>
|
||||
!!c &&
|
||||
typeof c === 'object' &&
|
||||
(c as { type?: string }).type === 'diff',
|
||||
)
|
||||
: false;
|
||||
if (hasDiff) {
|
||||
kind = 'edit';
|
||||
} else if (permToolCall.title) {
|
||||
const title = permToolCall.title.toLowerCase();
|
||||
if (title.includes('touch') || title.includes('echo')) {
|
||||
kind = 'execute';
|
||||
@@ -371,6 +457,19 @@ export const useWebViewMessages = ({
|
||||
break;
|
||||
}
|
||||
|
||||
case 'permissionResolved': {
|
||||
// Extension proactively resolved a pending permission; close drawer.
|
||||
try {
|
||||
handlers.handlePermissionRequest(null);
|
||||
} catch (_error) {
|
||||
console.warn(
|
||||
'[useWebViewMessages] failed to close permission UI:',
|
||||
_error,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plan':
|
||||
if (message.data.entries && Array.isArray(message.data.entries)) {
|
||||
const entries = message.data.entries as PlanEntry[];
|
||||
@@ -428,10 +527,10 @@ export const useWebViewMessages = ({
|
||||
|
||||
// Split assistant message segments, keep rendering blocks independent
|
||||
handlers.messageHandling.breakAssistantSegment?.();
|
||||
} catch (err) {
|
||||
} catch (_error) {
|
||||
console.warn(
|
||||
'[useWebViewMessages] failed to push/merge plan snapshot toolcall:',
|
||||
err,
|
||||
_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -489,7 +588,7 @@ export const useWebViewMessages = ({
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
} catch (_error) {
|
||||
// Best-effort UI hint; ignore errors
|
||||
}
|
||||
break;
|
||||
@@ -554,6 +653,12 @@ export const useWebViewMessages = ({
|
||||
handlers.messageHandling.clearMessages();
|
||||
}
|
||||
|
||||
// Clear any waiting message that might be displayed from previous session
|
||||
handlers.messageHandling.clearWaitingForResponse();
|
||||
|
||||
// Clear active tool calls tracking
|
||||
activeExecToolCallsRef.current.clear();
|
||||
|
||||
// Clear and restore tool calls if provided in session data
|
||||
handlers.clearToolCalls();
|
||||
if (message.data.toolCalls && Array.isArray(message.data.toolCalls)) {
|
||||
@@ -682,7 +787,7 @@ export const useWebViewMessages = ({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[inputFieldRef, setInputText, vscode],
|
||||
[inputFieldRef, setInputText, vscode, setEditMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -36,4 +36,4 @@ export interface ToolCallUpdate {
|
||||
/**
|
||||
* Edit mode type
|
||||
*/
|
||||
export type EditMode = 'ask' | 'auto' | 'plan';
|
||||
export type EditMode = 'ask' | 'auto' | 'plan' | 'yolo';
|
||||
|
||||
Reference in New Issue
Block a user