From 5f7890904028c34db962873ac9686553ad511e57 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Tue, 9 Dec 2025 20:27:20 +0100 Subject: [PATCH 01/44] Add terminal bell setting to enable/disable audio notifications --- packages/cli/src/config/settingsSchema.ts | 10 ++++ packages/cli/src/ui/AppContainer.tsx | 1 + .../hooks/useAttentionNotifications.test.ts | 48 +++++++++++++++++++ .../src/ui/hooks/useAttentionNotifications.ts | 16 +++++-- .../cli/src/utils/attentionNotification.ts | 6 +++ 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..49dd8810 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -193,6 +193,16 @@ const SETTINGS_SCHEMA = { { value: 'zh', label: '中文 (Chinese)' }, ], }, + terminalBell: { + type: 'boolean', + label: 'Terminal Bell', + category: 'General', + requiresRestart: false, + default: true, + description: + 'Play terminal bell sound when response completes or needs approval.', + showInDialog: true, + }, }, }, output: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index edda3d4d..0d6757b3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -945,6 +945,7 @@ export const AppContainer = (props: AppContainerProps) => { isFocused, streamingState, elapsedTime, + settings, }); // Dialog close functionality diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts index 1475aa52..e8beb86f 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.test.ts @@ -15,6 +15,23 @@ import { LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS, useAttentionNotifications, } from './useAttentionNotifications.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +const mockSettings: LoadedSettings = { + merged: { + general: { + terminalBell: true, + }, + }, +} as LoadedSettings; + +const mockSettingsDisabled: LoadedSettings = { + merged: { + general: { + terminalBell: false, + }, + }, +} as LoadedSettings; vi.mock('../../utils/attentionNotification.js', () => ({ notifyTerminalAttention: vi.fn(), @@ -40,6 +57,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, ...props, }, }, @@ -53,11 +71,13 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.WaitingForConfirmation, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).toHaveBeenCalledWith( AttentionNotificationReason.ToolApproval, + { enabled: true }, ); }); @@ -72,6 +92,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.WaitingForConfirmation, elapsedTime: 0, + settings: mockSettings, }, }); @@ -86,6 +107,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Responding, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 5, + settings: mockSettings, }, }); @@ -94,11 +116,13 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).toHaveBeenCalledWith( AttentionNotificationReason.LongTaskComplete, + { enabled: true }, ); }); @@ -110,6 +134,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Responding, elapsedTime: LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS + 2, + settings: mockSettings, }, }); @@ -118,6 +143,7 @@ describe('useAttentionNotifications', () => { isFocused: true, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); @@ -135,6 +161,7 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Responding, elapsedTime: 5, + settings: mockSettings, }, }); @@ -143,9 +170,30 @@ describe('useAttentionNotifications', () => { isFocused: false, streamingState: StreamingState.Idle, elapsedTime: 0, + settings: mockSettings, }, }); expect(mockedNotify).not.toHaveBeenCalled(); }); + + it('does not notify when terminalBell setting is disabled', () => { + const { rerender } = render({ + settings: mockSettingsDisabled, + }); + + rerender({ + hookProps: { + isFocused: false, + streamingState: StreamingState.WaitingForConfirmation, + elapsedTime: 0, + settings: mockSettingsDisabled, + }, + }); + + expect(mockedNotify).toHaveBeenCalledWith( + AttentionNotificationReason.ToolApproval, + { enabled: false }, + ); + }); }); diff --git a/packages/cli/src/ui/hooks/useAttentionNotifications.ts b/packages/cli/src/ui/hooks/useAttentionNotifications.ts index e632c827..7c5cd043 100644 --- a/packages/cli/src/ui/hooks/useAttentionNotifications.ts +++ b/packages/cli/src/ui/hooks/useAttentionNotifications.ts @@ -10,6 +10,7 @@ import { notifyTerminalAttention, AttentionNotificationReason, } from '../../utils/attentionNotification.js'; +import type { LoadedSettings } from '../../config/settings.js'; export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20; @@ -17,13 +18,16 @@ interface UseAttentionNotificationsOptions { isFocused: boolean; streamingState: StreamingState; elapsedTime: number; + settings: LoadedSettings; } export const useAttentionNotifications = ({ isFocused, streamingState, elapsedTime, + settings, }: UseAttentionNotificationsOptions) => { + const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true; const awaitingNotificationSentRef = useRef(false); const respondingElapsedRef = useRef(0); @@ -33,14 +37,16 @@ export const useAttentionNotifications = ({ !isFocused && !awaitingNotificationSentRef.current ) { - notifyTerminalAttention(AttentionNotificationReason.ToolApproval); + notifyTerminalAttention(AttentionNotificationReason.ToolApproval, { + enabled: terminalBellEnabled, + }); awaitingNotificationSentRef.current = true; } if (streamingState !== StreamingState.WaitingForConfirmation || isFocused) { awaitingNotificationSentRef.current = false; } - }, [isFocused, streamingState]); + }, [isFocused, streamingState, terminalBellEnabled]); useEffect(() => { if (streamingState === StreamingState.Responding) { @@ -53,11 +59,13 @@ export const useAttentionNotifications = ({ respondingElapsedRef.current >= LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS; if (wasLongTask && !isFocused) { - notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete); + notifyTerminalAttention(AttentionNotificationReason.LongTaskComplete, { + enabled: terminalBellEnabled, + }); } // Reset tracking for next task respondingElapsedRef.current = 0; return; } - }, [streamingState, elapsedTime, isFocused]); + }, [streamingState, elapsedTime, isFocused, terminalBellEnabled]); }; diff --git a/packages/cli/src/utils/attentionNotification.ts b/packages/cli/src/utils/attentionNotification.ts index 26dc2a25..e166444f 100644 --- a/packages/cli/src/utils/attentionNotification.ts +++ b/packages/cli/src/utils/attentionNotification.ts @@ -13,6 +13,7 @@ export enum AttentionNotificationReason { export interface TerminalNotificationOptions { stream?: Pick; + enabled?: boolean; } const TERMINAL_BELL = '\u0007'; @@ -28,6 +29,11 @@ export function notifyTerminalAttention( _reason: AttentionNotificationReason, options: TerminalNotificationOptions = {}, ): boolean { + // Check if terminal bell is enabled (default true for backwards compatibility) + if (options.enabled === false) { + return false; + } + const stream = options.stream ?? process.stdout; if (!stream?.write || stream.isTTY === false) { return false; From 5b8ce440ea1c062de413c8a33c119ffce3e41c2d Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Thu, 11 Dec 2025 12:22:48 +0100 Subject: [PATCH 02/44] Show session resume command on exit --- packages/cli/src/i18n/locales/en.js | 1 + packages/cli/src/i18n/locales/zh.js | 1 + .../components/SessionSummaryDisplay.test.tsx | 8 +++++- .../ui/components/SessionSummaryDisplay.tsx | 27 ++++++++++++++----- .../SessionSummaryDisplay.test.tsx.snap | 8 +++--- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c2217757..dec11869 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -867,6 +867,7 @@ export default { // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!', + 'To continue this session, run': 'To continue this session, run', 'Interaction Summary': 'Interaction Summary', 'Session ID:': 'Session ID:', 'Tool Calls:': 'Tool Calls:', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index adeb85f1..dc00d068 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -820,6 +820,7 @@ export default { // Exit Screen / Stats // ============================================================================ 'Agent powering down. Goodbye!': 'Qwen Code 正在关闭,再见!', + 'To continue this session, run': '要继续此会话,请运行', 'Interaction Summary': '交互摘要', 'Session ID:': '会话 ID:', 'Tool Calls:': '工具调用:', diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 766e851a..b4527703 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -20,9 +20,13 @@ vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); -const renderWithMockedStats = (metrics: SessionMetrics) => { +const renderWithMockedStats = ( + metrics: SessionMetrics, + sessionId: string = 'test-session-id-12345', +) => { useSessionStatsMock.mockReturnValue({ stats: { + sessionId, sessionStartTime: new Date(), metrics, lastPromptTokenCount: 0, @@ -70,6 +74,8 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).toContain('To continue this session, run'); + expect(output).toContain('qwen --resume test-session-id-12345'); expect(output).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index c8d79e0e..a5a1726e 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -5,7 +5,10 @@ */ import type React from 'react'; +import { Box, Text } from 'ink'; import { StatsDisplay } from './StatsDisplay.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { theme } from '../semantic-colors.js'; import { t } from '../../i18n/index.js'; interface SessionSummaryDisplayProps { @@ -14,9 +17,21 @@ interface SessionSummaryDisplayProps { export const SessionSummaryDisplay: React.FC = ({ duration, -}) => ( - -); +}) => { + const { stats } = useSessionStats(); + + return ( + <> + + + + {t('To continue this session, run')}{' '} + qwen --resume {stats.sessionId} + + + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 7c925f72..2e545631 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -6,7 +6,7 @@ exports[` > renders the summary display with a title 1` │ Agent powering down. Goodbye! │ │ │ │ Interaction Summary │ -│ Session ID: │ +│ Session ID: test-session-id-12345 │ │ Tool Calls: 0 ( ✓ 0 x 0 ) │ │ Success Rate: 0.0% │ │ Code Changes: +42 -15 │ @@ -20,11 +20,13 @@ exports[` > renders the summary display with a title 1` │ │ │ Model Usage Reqs Input Tokens Output Tokens │ │ ─────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 10 1,000 2,000 │ +│ gemini-2.5-pro 10 1.000 2.000 │ │ │ │ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ │ » Tip: For a full token breakdown, run \`/stats model\`. │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + +To continue this session, run qwen --resume test-session-id-12345" `; From b67ee32481247d26fb86af545a1177310b89ae25 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Thu, 11 Dec 2025 12:53:30 +0100 Subject: [PATCH 03/44] Update snapshots --- .../__snapshots__/SessionSummaryDisplay.test.tsx.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index 2e545631..dfa39ba8 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -20,7 +20,7 @@ exports[` > renders the summary display with a title 1` │ │ │ Model Usage Reqs Input Tokens Output Tokens │ │ ─────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 10 1.000 2.000 │ +│ gemini-2.5-pro 10 1,000 2,000 │ │ │ │ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ From ba3b5769065b926fa79fcdb2cee12d50a1a66dbb Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Thu, 11 Dec 2025 13:10:01 +0100 Subject: [PATCH 04/44] Hide resume message when session has no messages --- .../components/SessionSummaryDisplay.test.tsx | 35 +++++++++++++++++-- .../ui/components/SessionSummaryDisplay.tsx | 19 ++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index b4527703..19aa3af8 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -23,6 +23,7 @@ const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); const renderWithMockedStats = ( metrics: SessionMetrics, sessionId: string = 'test-session-id-12345', + promptCount: number = 5, ) => { useSessionStatsMock.mockReturnValue({ stats: { @@ -30,10 +31,10 @@ const renderWithMockedStats = ( sessionStartTime: new Date(), metrics, lastPromptTokenCount: 0, - promptCount: 5, + promptCount, }, - getPromptCount: () => 5, + getPromptCount: () => promptCount, startNewPrompt: vi.fn(), }); @@ -78,4 +79,34 @@ describe('', () => { expect(output).toContain('qwen --resume test-session-id-12345'); expect(output).toMatchSnapshot(); }); + + it('does not show resume message when there are no messages', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + // Pass promptCount = 0 to simulate no messages + const { lastFrame } = renderWithMockedStats( + metrics, + 'test-session-id-12345', + 0, + ); + const output = lastFrame(); + + expect(output).toContain('Agent powering down. Goodbye!'); + expect(output).not.toContain('To continue this session, run'); + expect(output).not.toContain('qwen --resume'); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index a5a1726e..c38edc75 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -20,18 +20,25 @@ export const SessionSummaryDisplay: React.FC = ({ }) => { const { stats } = useSessionStats(); + // Only show the resume message if there were messages in the session + const hasMessages = stats.promptCount > 0; + return ( <> - - - {t('To continue this session, run')}{' '} - qwen --resume {stats.sessionId} - - + {hasMessages && ( + + + {t('To continue this session, run')}{' '} + + qwen --resume {stats.sessionId} + + + + )} ); }; From b34894c8eaef6ac43cb4c92f83bd86e62be27576 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 11 Dec 2025 22:56:58 +0800 Subject: [PATCH 05/44] feat(vscode-ide-companion/auth): deduplicate concurrent authentication calls Prevent multiple simultaneous authentication flows by: - Adding static authInFlight promise tracking in AcpConnection - Implementing runExclusiveAuth method in AuthStateManager - Adding sessionCreateInFlight tracking in QwenAgentManager - Ensuring only one auth flow runs at a time across different components This prevents race conditions and duplicate login prompts when multiple components request authentication simultaneously. --- .../vscode-ide-companion/src/extension.ts | 20 +- .../src/services/acpConnection.ts | 25 +- .../src/services/authStateManager.ts | 112 +++-- .../src/services/qwenAgentManager.ts | 213 +++++---- .../vscode-ide-companion/src/utils/logger.ts | 42 ++ .../vscode-ide-companion/src/webview/App.tsx | 24 +- .../src/webview/WebViewProvider.ts | 407 ++++++++++-------- .../webview/handlers/SessionMessageHandler.ts | 35 ++ .../src/webview/hooks/useWebViewMessages.ts | 57 ++- .../src/webview/styles/styles.css | 1 - .../src/webview/utils/logger.ts | 25 ++ .../src/webview/utils/simpleDiff.ts | 92 ---- 12 files changed, 589 insertions(+), 464 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/utils/logger.ts delete mode 100644 packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 2adfaef1..08979099 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -8,7 +8,11 @@ import * as vscode from 'vscode'; import { IDEServer } from './ide-server.js'; import semver from 'semver'; import { DiffContentProvider, DiffManager } from './diff-manager.js'; -import { createLogger } from './utils/logger.js'; +import { + createLogger, + getConsoleLogger, + initSharedConsoleLogger, +} from './utils/logger.js'; import { detectIdeFromEnv, IDE_DEFINITIONS, @@ -105,6 +109,8 @@ async function checkForUpdates( export async function activate(context: vscode.ExtensionContext) { logger = vscode.window.createOutputChannel('Qwen Code Companion'); + initSharedConsoleLogger(context); + const consoleLog = getConsoleLogger(); log = createLogger(context, logger); log('Extension activated'); @@ -142,18 +148,18 @@ export async function activate(context: vscode.ExtensionContext) { webviewPanel: vscode.WebviewPanel, state: unknown, ) { - console.log( + consoleLog( '[Extension] Deserializing WebView panel with state:', state, ); // Create a new provider for the restored panel const provider = createWebViewProvider(); - console.log('[Extension] Provider created for deserialization'); + consoleLog('[Extension] Provider created for deserialization'); // Restore state if available BEFORE restoring the panel if (state && typeof state === 'object') { - console.log('[Extension] Restoring state:', state); + consoleLog('[Extension] Restoring state:', state); provider.restoreState( state as { conversationId: string | null; @@ -161,11 +167,11 @@ export async function activate(context: vscode.ExtensionContext) { }, ); } else { - console.log('[Extension] No state to restore or invalid state'); + consoleLog('[Extension] No state to restore or invalid state'); } await provider.restorePanel(webviewPanel); - console.log('[Extension] Panel restore completed'); + consoleLog('[Extension] Panel restore completed'); log('WebView panel restored from serialization'); }, @@ -206,7 +212,6 @@ export async function activate(context: vscode.ExtensionContext) { } 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) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; @@ -223,7 +228,6 @@ export async function activate(context: vscode.ExtensionContext) { } 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 { diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 5486e14d..4dd35b71 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -31,6 +31,8 @@ export class AcpConnection { private child: ChildProcess | null = null; private pendingRequests = new Map>(); private nextRequestId = { value: 0 }; + // Deduplicate concurrent authenticate calls (across retry paths) + private static authInFlight: Promise | null = null; // Remember the working dir provided at connect() so later ACP calls // that require cwd (e.g. session/list) can include it. private workingDir: string = process.cwd(); @@ -271,12 +273,23 @@ export class AcpConnection { * @returns Authentication response */ async authenticate(methodId?: string): Promise { - return this.sessionManager.authenticate( - methodId, - this.child, - this.pendingRequests, - this.nextRequestId, - ); + if (AcpConnection.authInFlight) { + return AcpConnection.authInFlight; + } + + const p = this.sessionManager + .authenticate( + methodId, + this.child, + this.pendingRequests, + this.nextRequestId, + ) + .finally(() => { + AcpConnection.authInFlight = null; + }); + + AcpConnection.authInFlight = p; + return p; } /** diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts index 566a4afb..aa75fe36 100644 --- a/packages/vscode-ide-companion/src/services/authStateManager.ts +++ b/packages/vscode-ide-companion/src/services/authStateManager.ts @@ -5,6 +5,7 @@ */ import type * as vscode from 'vscode'; +import { createConsoleLogger, getConsoleLogger } from '../utils/logger.js'; interface AuthState { isAuthenticated: boolean; @@ -21,6 +22,9 @@ export class AuthStateManager { private static context: vscode.ExtensionContext | null = null; private static readonly AUTH_STATE_KEY = 'qwen.authState'; private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours + private static consoleLog: (...args: unknown[]) => void = getConsoleLogger(); + // Deduplicate concurrent auth flows (e.g., multiple tabs prompting login) + private static authFlowInFlight: Promise | null = null; private constructor() {} /** @@ -34,11 +38,37 @@ export class AuthStateManager { // If a context is provided, update the static context if (context) { AuthStateManager.context = context; + AuthStateManager.consoleLog = createConsoleLogger( + context, + 'AuthStateManager', + ); } return AuthStateManager.instance; } + /** + * Run an auth-related flow exclusively. If another flow is already running, + * return the same promise to prevent duplicate login prompts. + */ + static runExclusiveAuth(task: () => Promise): Promise { + if (AuthStateManager.authFlowInFlight) { + return AuthStateManager.authFlowInFlight as Promise; + } + + const p = Promise.resolve() + .then(task) + .finally(() => { + // Clear only if this promise is still the active one + if (AuthStateManager.authFlowInFlight === p) { + AuthStateManager.authFlowInFlight = null; + } + }); + + AuthStateManager.authFlowInFlight = p; + return p as Promise; + } + /** * Check if there's a valid cached authentication */ @@ -46,17 +76,19 @@ export class AuthStateManager { const state = await this.getAuthState(); if (!state) { - console.log('[AuthStateManager] No cached auth state found'); + AuthStateManager.consoleLog( + '[AuthStateManager] No cached auth state found', + ); return false; } - console.log('[AuthStateManager] Found cached auth state:', { + AuthStateManager.consoleLog('[AuthStateManager] Found cached auth state:', { workingDir: state.workingDir, authMethod: state.authMethod, timestamp: new Date(state.timestamp).toISOString(), isAuthenticated: state.isAuthenticated, }); - console.log('[AuthStateManager] Checking against:', { + AuthStateManager.consoleLog('[AuthStateManager] Checking against:', { workingDir, authMethod, }); @@ -67,8 +99,8 @@ export class AuthStateManager { now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; if (isExpired) { - console.log('[AuthStateManager] Cached auth expired'); - console.log( + AuthStateManager.consoleLog('[AuthStateManager] Cached auth expired'); + AuthStateManager.consoleLog( '[AuthStateManager] Cache age:', Math.floor((now - state.timestamp) / 1000 / 60), 'minutes', @@ -82,15 +114,29 @@ export class AuthStateManager { state.workingDir === workingDir && state.authMethod === authMethod; if (!isSameContext) { - console.log('[AuthStateManager] Working dir or auth method changed'); - console.log('[AuthStateManager] Cached workingDir:', state.workingDir); - console.log('[AuthStateManager] Current workingDir:', workingDir); - console.log('[AuthStateManager] Cached authMethod:', state.authMethod); - console.log('[AuthStateManager] Current authMethod:', authMethod); + AuthStateManager.consoleLog( + '[AuthStateManager] Working dir or auth method changed', + ); + AuthStateManager.consoleLog( + '[AuthStateManager] Cached workingDir:', + state.workingDir, + ); + AuthStateManager.consoleLog( + '[AuthStateManager] Current workingDir:', + workingDir, + ); + AuthStateManager.consoleLog( + '[AuthStateManager] Cached authMethod:', + state.authMethod, + ); + AuthStateManager.consoleLog( + '[AuthStateManager] Current authMethod:', + authMethod, + ); return false; } - console.log('[AuthStateManager] Valid cached auth found'); + AuthStateManager.consoleLog('[AuthStateManager] Valid cached auth found'); return state.isAuthenticated; } @@ -100,7 +146,10 @@ export class AuthStateManager { */ async debugAuthState(): Promise { const state = await this.getAuthState(); - console.log('[AuthStateManager] DEBUG - Current auth state:', state); + AuthStateManager.consoleLog( + '[AuthStateManager] DEBUG - Current auth state:', + state, + ); if (state) { const now = Date.now(); @@ -108,9 +157,16 @@ export class AuthStateManager { const isExpired = now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; - console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes'); - console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired); - console.log( + AuthStateManager.consoleLog( + '[AuthStateManager] DEBUG - Auth state age:', + age, + 'minutes', + ); + AuthStateManager.consoleLog( + '[AuthStateManager] DEBUG - Auth state expired:', + isExpired, + ); + AuthStateManager.consoleLog( '[AuthStateManager] DEBUG - Auth state valid:', state.isAuthenticated, ); @@ -135,7 +191,7 @@ export class AuthStateManager { timestamp: Date.now(), }; - console.log('[AuthStateManager] Saving auth state:', { + AuthStateManager.consoleLog('[AuthStateManager] Saving auth state:', { workingDir, authMethod, timestamp: new Date(state.timestamp).toISOString(), @@ -145,11 +201,14 @@ export class AuthStateManager { AuthStateManager.AUTH_STATE_KEY, state, ); - console.log('[AuthStateManager] Auth state saved'); + AuthStateManager.consoleLog('[AuthStateManager] Auth state saved'); // Verify the state was saved correctly const savedState = await this.getAuthState(); - console.log('[AuthStateManager] Verified saved state:', savedState); + AuthStateManager.consoleLog( + '[AuthStateManager] Verified saved state:', + savedState, + ); } /** @@ -163,9 +222,9 @@ export class AuthStateManager { ); } - console.log('[AuthStateManager] Clearing auth state'); + AuthStateManager.consoleLog('[AuthStateManager] Clearing auth state'); const currentState = await this.getAuthState(); - console.log( + AuthStateManager.consoleLog( '[AuthStateManager] Current state before clearing:', currentState, ); @@ -174,11 +233,14 @@ export class AuthStateManager { AuthStateManager.AUTH_STATE_KEY, undefined, ); - console.log('[AuthStateManager] Auth state cleared'); + AuthStateManager.consoleLog('[AuthStateManager] Auth state cleared'); // Verify the state was cleared const newState = await this.getAuthState(); - console.log('[AuthStateManager] State after clearing:', newState); + AuthStateManager.consoleLog( + '[AuthStateManager] State after clearing:', + newState, + ); } /** @@ -187,17 +249,15 @@ export class AuthStateManager { private async getAuthState(): Promise { // Ensure we have a valid context if (!AuthStateManager.context) { - console.log( + AuthStateManager.consoleLog( '[AuthStateManager] No context available for getting auth state', ); return undefined; } - const a = AuthStateManager.context.globalState.get( + return AuthStateManager.context.globalState.get( AuthStateManager.AUTH_STATE_KEY, ); - console.log('[AuthStateManager] Auth state:', a); - return a; } /** diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index a57d15b7..b954aed8 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -23,6 +23,7 @@ import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { CliContextManager } from '../cli/cliContextManager.js'; import { authMethod } from '../types/acpTypes.js'; import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js'; +import { getConsoleLogger } from '../utils/logger.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; @@ -45,11 +46,15 @@ export class QwenAgentManager { // Cache the last used AuthStateManager so internal calls (e.g. fallback paths) // can reuse it and avoid forcing a fresh authentication unnecessarily. private defaultAuthStateManager?: AuthStateManager; + // Deduplicate concurrent session/new attempts + private sessionCreateInFlight: Promise | null = null; // Callback storage private callbacks: QwenAgentCallbacks = {}; + private consoleLog: (...args: unknown[]) => void; - constructor() { + constructor(consoleLogger = getConsoleLogger()) { + this.consoleLog = consoleLogger; this.connection = new AcpConnection(); this.sessionReader = new QwenSessionReader(); this.sessionManager = new QwenSessionManager(); @@ -76,7 +81,7 @@ export class QwenAgentManager { ).update; const text = update?.content?.text || ''; if (update?.sessionUpdate === 'user_message_chunk' && text) { - console.log( + this.consoleLog( '[QwenAgentManager] Rehydration: routing user message chunk', ); this.callbacks.onMessage?.({ @@ -87,7 +92,7 @@ export class QwenAgentManager { return; } if (update?.sessionUpdate === 'agent_message_chunk' && text) { - console.log( + this.consoleLog( '[QwenAgentManager] Rehydration: routing agent message chunk', ); this.callbacks.onMessage?.({ @@ -98,7 +103,7 @@ export class QwenAgentManager { return; } // For other types during rehydration, fall through to normal handler - console.log( + this.consoleLog( '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', ); } @@ -257,7 +262,7 @@ export class QwenAgentManager { * @returns Session list */ async getSessionList(): Promise>> { - console.log( + this.consoleLog( '[QwenAgentManager] Getting session list with version-aware strategy', ); @@ -265,7 +270,7 @@ export class QwenAgentManager { const cliContextManager = CliContextManager.getInstance(); const supportsSessionList = cliContextManager.supportsSessionList(); - console.log( + this.consoleLog( '[QwenAgentManager] CLI supports session/list:', supportsSessionList, ); @@ -273,11 +278,14 @@ export class QwenAgentManager { // Try ACP method first if supported if (supportsSessionList) { try { - console.log( + this.consoleLog( '[QwenAgentManager] Attempting to get session list via ACP method', ); const response = await this.connection.listSessions(); - console.log('[QwenAgentManager] ACP session list response:', response); + this.consoleLog( + '[QwenAgentManager] ACP session list response:', + response, + ); // sendRequest resolves with the JSON-RPC "result" directly // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } @@ -295,7 +303,7 @@ export class QwenAgentManager { : []; } - console.log( + this.consoleLog( '[QwenAgentManager] Sessions retrieved via ACP:', res, items.length, @@ -314,7 +322,7 @@ export class QwenAgentManager { cwd: item.cwd, })); - console.log( + this.consoleLog( '[QwenAgentManager] Sessions retrieved via ACP:', sessions.length, ); @@ -330,9 +338,11 @@ export class QwenAgentManager { // Always fall back to file system method try { - console.log('[QwenAgentManager] Getting session list from file system'); + this.consoleLog( + '[QwenAgentManager] Getting session list from file system', + ); const sessions = await this.sessionReader.getAllSessions(undefined, true); - console.log( + this.consoleLog( '[QwenAgentManager] Session list from file system (all projects):', sessions.length, ); @@ -350,7 +360,7 @@ export class QwenAgentManager { }), ); - console.log( + this.consoleLog( '[QwenAgentManager] Sessions retrieved from file system:', result.length, ); @@ -490,7 +500,7 @@ export class QwenAgentManager { const item = list.find( (s) => s.sessionId === sessionId || s.id === sessionId, ); - console.log( + this.consoleLog( '[QwenAgentManager] Session list item for filePath lookup:', item, ); @@ -561,7 +571,7 @@ export class QwenAgentManager { } } // Simple linear reconstruction: filter user/assistant and sort by timestamp - console.log( + this.consoleLog( '[QwenAgentManager] JSONL records read:', records.length, filePath, @@ -718,7 +728,7 @@ export class QwenAgentManager { // Handle other types if needed } - console.log( + this.consoleLog( '[QwenAgentManager] JSONL messages reconstructed:', msgs.length, ); @@ -856,7 +866,7 @@ export class QwenAgentManager { tag: string, ): Promise<{ success: boolean; message?: string }> { try { - console.log( + this.consoleLog( '[QwenAgentManager] Saving session via /chat save command:', sessionId, 'with tag:', @@ -867,7 +877,9 @@ export class QwenAgentManager { // The CLI will handle this as a special command await this.connection.sendPrompt(`/chat save "${tag}"`); - console.log('[QwenAgentManager] /chat save command sent successfully'); + this.consoleLog( + '[QwenAgentManager] /chat save command sent successfully', + ); return { success: true, message: `Session saved with tag: ${tag}`, @@ -914,14 +926,14 @@ export class QwenAgentManager { conversationId: string, ): Promise<{ success: boolean; tag?: string; message?: string }> { try { - console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); - console.log('[QwenAgentManager] Conversation ID:', conversationId); - console.log('[QwenAgentManager] Message count:', messages.length); - console.log( + this.consoleLog('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); + this.consoleLog('[QwenAgentManager] Conversation ID:', conversationId); + this.consoleLog('[QwenAgentManager] Message count:', messages.length); + this.consoleLog( '[QwenAgentManager] Current working dir:', this.currentWorkingDir, ); - console.log( + this.consoleLog( '[QwenAgentManager] Current session ID (from CLI):', this.currentSessionId, ); @@ -998,11 +1010,11 @@ export class QwenAgentManager { try { // Route upcoming session/update messages as discrete messages for replay this.rehydratingSessionId = sessionId; - console.log( + this.consoleLog( '[QwenAgentManager] Rehydration start for session:', sessionId, ); - console.log( + this.consoleLog( '[QwenAgentManager] Attempting session/load via ACP for session:', sessionId, ); @@ -1010,7 +1022,7 @@ export class QwenAgentManager { sessionId, cwdOverride, ); - console.log( + this.consoleLog( '[QwenAgentManager] Session load succeeded. Response:', JSON.stringify(response).substring(0, 200), ); @@ -1050,7 +1062,10 @@ export class QwenAgentManager { throw error; } finally { // End rehydration routing regardless of outcome - console.log('[QwenAgentManager] Rehydration end for session:', sessionId); + this.consoleLog( + '[QwenAgentManager] Rehydration end for session:', + sessionId, + ); this.rehydratingSessionId = null; } } @@ -1063,7 +1078,7 @@ export class QwenAgentManager { * @returns Loaded session messages or null */ async loadSession(sessionId: string): Promise { - console.log( + this.consoleLog( '[QwenAgentManager] Loading session with version-aware strategy:', sessionId, ); @@ -1072,7 +1087,7 @@ export class QwenAgentManager { const cliContextManager = CliContextManager.getInstance(); const supportsSessionLoad = cliContextManager.supportsSessionLoad(); - console.log( + this.consoleLog( '[QwenAgentManager] CLI supports session/load:', supportsSessionLoad, ); @@ -1080,11 +1095,13 @@ export class QwenAgentManager { // Try ACP method first if supported if (supportsSessionLoad) { try { - console.log( + this.consoleLog( '[QwenAgentManager] Attempting to load session via ACP method', ); await this.loadSessionViaAcp(sessionId); - console.log('[QwenAgentManager] Session loaded successfully via ACP'); + this.consoleLog( + '[QwenAgentManager] Session loaded successfully via ACP', + ); // After loading via ACP, we still need to get messages from file system // In future, we might get them directly from the ACP response @@ -1098,11 +1115,11 @@ export class QwenAgentManager { // Always fall back to file system method try { - console.log( + this.consoleLog( '[QwenAgentManager] Loading session messages from file system', ); const messages = await this.loadSessionMessagesFromFile(sessionId); - console.log( + this.consoleLog( '[QwenAgentManager] Session messages loaded successfully from file system', ); return messages; @@ -1125,7 +1142,7 @@ export class QwenAgentManager { sessionId: string, ): Promise { try { - console.log( + this.consoleLog( '[QwenAgentManager] Loading session from file system:', sessionId, ); @@ -1137,7 +1154,7 @@ export class QwenAgentManager { ); if (!session) { - console.log( + this.consoleLog( '[QwenAgentManager] Session not found in file system:', sessionId, ); @@ -1183,93 +1200,67 @@ export class QwenAgentManager { workingDir: string, authStateManager?: AuthStateManager, ): Promise { - console.log('[QwenAgentManager] Creating new session...'); + // Reuse existing session if present + if (this.connection.currentSessionId) { + return this.connection.currentSessionId; + } + // Deduplicate concurrent session/new attempts + if (this.sessionCreateInFlight) { + return this.sessionCreateInFlight; + } - // Check if we have valid cached authentication - let hasValidAuth = false; + this.consoleLog('[QwenAgentManager] Creating new session...'); // Prefer the provided authStateManager, otherwise fall back to the one // remembered during connect(). This prevents accidental re-auth in // fallback paths (e.g. session switching) when the handler didn't pass it. const effectiveAuth = authStateManager || this.defaultAuthStateManager; - if (effectiveAuth) { - hasValidAuth = await effectiveAuth.hasValidAuth(workingDir, authMethod); - console.log( - '[QwenAgentManager] Has valid cached auth for new session:', - hasValidAuth, - ); - } - // Only authenticate if we don't have valid cached auth - if (!hasValidAuth) { - console.log( - '[QwenAgentManager] Authenticating before creating session...', - ); + this.sessionCreateInFlight = (async () => { try { - await this.connection.authenticate(authMethod); - console.log('[QwenAgentManager] Authentication successful'); - - // Save auth state - if (effectiveAuth) { - console.log( - '[QwenAgentManager] Saving auth state after successful authentication', - ); - await effectiveAuth.saveAuthState(workingDir, authMethod); - } - } catch (authError) { - console.error('[QwenAgentManager] Authentication failed:', authError); - // Clear potentially invalid cache - if (effectiveAuth) { - console.log( - '[QwenAgentManager] Clearing auth cache due to authentication failure', - ); - await effectiveAuth.clearAuthState(); - } - throw authError; - } - } else { - console.log( - '[QwenAgentManager] Skipping authentication - using valid cached auth', - ); - } - - // Try to create a new ACP session. If Qwen asks for auth despite our - // cached flag (e.g. fresh process or expired tokens), re-authenticate and retry. - try { - await this.connection.newSession(workingDir); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - const requiresAuth = - msg.includes('Authentication required') || - msg.includes('(code: -32000)'); - - if (requiresAuth) { - console.warn( - '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', - ); + // Try to create a new ACP session. If Qwen asks for auth despite our + // cached flag (e.g. fresh process or expired tokens), re-authenticate and retry. try { - await this.connection.authenticate(authMethod); - // Persist auth cache so subsequent calls can skip the web flow. - if (effectiveAuth) { - await effectiveAuth.saveAuthState(workingDir, authMethod); - } await this.connection.newSession(workingDir); - } catch (reauthErr) { - // Clear potentially stale cache on failure and rethrow - if (effectiveAuth) { - await effectiveAuth.clearAuthState(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const requiresAuth = + msg.includes('Authentication required') || + msg.includes('(code: -32000)'); + + if (requiresAuth) { + console.warn( + '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', + ); + try { + await this.connection.authenticate(authMethod); + // Persist auth cache so subsequent calls can skip the web flow. + if (effectiveAuth) { + await effectiveAuth.saveAuthState(workingDir, authMethod); + } + await this.connection.newSession(workingDir); + } catch (reauthErr) { + // Clear potentially stale cache on failure and rethrow + if (effectiveAuth) { + await effectiveAuth.clearAuthState(); + } + throw reauthErr; + } + } else { + throw err; } - throw reauthErr; } - } else { - throw err; + const newSessionId = this.connection.currentSessionId; + this.consoleLog( + '[QwenAgentManager] New session created with ID:', + newSessionId, + ); + return newSessionId; + } finally { + this.sessionCreateInFlight = null; } - } - const newSessionId = this.connection.currentSessionId; - console.log( - '[QwenAgentManager] New session created with ID:', - newSessionId, - ); - return newSessionId; + })(); + + return this.sessionCreateInFlight; } /** @@ -1285,7 +1276,7 @@ export class QwenAgentManager { * Cancel current prompt */ async cancelCurrentPrompt(): Promise { - console.log('[QwenAgentManager] Cancelling current prompt'); + this.consoleLog('[QwenAgentManager] Cancelling current prompt'); await this.connection.cancelSession(); } diff --git a/packages/vscode-ide-companion/src/utils/logger.ts b/packages/vscode-ide-companion/src/utils/logger.ts index b3f8ad1e..f92cfe8c 100644 --- a/packages/vscode-ide-companion/src/utils/logger.ts +++ b/packages/vscode-ide-companion/src/utils/logger.ts @@ -6,6 +6,11 @@ import * as vscode from 'vscode'; +type ConsoleLogger = (...args: unknown[]) => void; + +// Shared console logger instance, initialized during extension activation. +let sharedConsoleLogger: ConsoleLogger = () => {}; + export function createLogger( context: vscode.ExtensionContext, logger: vscode.OutputChannel, @@ -16,3 +21,40 @@ export function createLogger( } }; } + +/** + * Creates a dev-only logger that writes to the VS Code console (Developer Tools). + */ +export function createConsoleLogger( + context: vscode.ExtensionContext, + scope?: string, +): ConsoleLogger { + return (...args: unknown[]) => { + if (context.extensionMode !== vscode.ExtensionMode.Development) { + return; + } + if (scope) { + console.log(`[${scope}]`, ...args); + return; + } + console.log(...args); + }; +} + +/** + * Initialize the shared console logger so other modules can import it without + * threading the extension context everywhere. + */ +export function initSharedConsoleLogger( + context: vscode.ExtensionContext, + scope?: string, +) { + sharedConsoleLogger = createConsoleLogger(context, scope); +} + +/** + * Get the shared console logger (no-op until initialized). + */ +export function getConsoleLogger(): ConsoleLogger { + return sharedConsoleLogger; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 4b51d6b6..65d978fd 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -45,9 +45,11 @@ import { FileIcon, UserIcon } from './components/icons/index.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/acpTypes.js'; import type { PlanEntry } from '../types/chatTypes.js'; +import { createWebviewConsoleLogger } from './utils/logger.js'; export const App: React.FC = () => { const vscode = useVSCode(); + const consoleLog = useMemo(() => createWebviewConsoleLogger('App'), []); // Core hooks const sessionManagement = useSessionManagement(vscode); @@ -167,7 +169,7 @@ export const App: React.FC = () => { }, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]); // Message submission - const handleSubmit = useMessageSubmit({ + const { handleSubmit: submitMessage } = useMessageSubmit({ inputText, setInputText, messageHandling, @@ -487,6 +489,22 @@ export const App: React.FC = () => { setThinkingEnabled((prev) => !prev); }; + // When user sends a message after scrolling up, re-pin and jump to the bottom + const handleSubmitWithScroll = useCallback( + (e: React.FormEvent) => { + setPinnedToBottom(true); + + const container = messagesContainerRef.current; + if (container) { + const top = container.scrollHeight - container.clientHeight; + container.scrollTo({ top }); + } + + submitMessage(e); + }, + [submitMessage], + ); + // Create unified message array containing all types of messages and tool calls const allMessages = useMemo< Array<{ @@ -524,7 +542,7 @@ export const App: React.FC = () => { ); }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); - console.log('[App] Rendering messages:', allMessages); + consoleLog('[App] Rendering messages:', allMessages); // Render all messages and tool calls const renderMessages = useCallback<() => React.ReactNode>( @@ -686,7 +704,7 @@ export const App: React.FC = () => { onCompositionStart={() => setIsComposing(true)} onCompositionEnd={() => setIsComposing(false)} onKeyDown={() => {}} - onSubmit={handleSubmit.handleSubmit} + onSubmit={handleSubmitWithScroll} onCancel={handleCancel} onToggleEditMode={handleToggleEditMode} onToggleThinking={handleToggleThinking} diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 82629787..11054259 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -16,6 +16,7 @@ import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js'; +import { createConsoleLogger } from '../utils/logger.js'; export class WebViewProvider { private panelManager: PanelManager; @@ -32,12 +33,15 @@ export class WebViewProvider { private pendingPermissionResolve: ((optionId: string) => void) | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; + private consoleLog: (...args: unknown[]) => void; constructor( context: vscode.ExtensionContext, private extensionUri: vscode.Uri, ) { - this.agentManager = new QwenAgentManager(); + const agentConsoleLogger = createConsoleLogger(context, 'QwenAgentManager'); + this.consoleLog = createConsoleLogger(context, 'WebViewProvider'); + this.agentManager = new QwenAgentManager(agentConsoleLogger); this.conversationStore = new ConversationStore(context); this.authStateManager = AuthStateManager.getInstance(context); this.panelManager = new PanelManager(extensionUri, () => { @@ -380,7 +384,7 @@ export class WebViewProvider { // Set up state serialization newPanel.onDidChangeViewState(() => { - console.log( + this.consoleLog( '[WebViewProvider] Panel view state changed, triggering serialization check', ); }); @@ -510,7 +514,7 @@ export class WebViewProvider { } // Attempt to restore authentication state and initialize connection - console.log( + this.consoleLog( '[WebViewProvider] Attempting to restore auth state and connection...', ); await this.attemptAuthStateRestoration(); @@ -532,23 +536,26 @@ export class WebViewProvider { workingDir, authMethod, ); - console.log('[WebViewProvider] Has valid cached auth:', hasValidAuth); + this.consoleLog( + '[WebViewProvider] Has valid cached auth:', + hasValidAuth, + ); if (hasValidAuth) { - console.log( + this.consoleLog( '[WebViewProvider] Valid auth found, attempting connection...', ); // Try to connect with cached auth await this.initializeAgentConnection(); } else { - console.log( + this.consoleLog( '[WebViewProvider] No valid auth found, rendering empty conversation', ); // Render the chat UI immediately without connecting await this.initializeEmptyConversation(); } } else { - console.log( + this.consoleLog( '[WebViewProvider] No auth state manager, rendering empty conversation', ); await this.initializeEmptyConversation(); @@ -565,84 +572,101 @@ export class WebViewProvider { * Can be called from show() or via /login command */ async initializeAgentConnection(): Promise { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - - console.log( - '[WebViewProvider] Starting initialization, workingDir:', - workingDir, - ); - console.log( - '[WebViewProvider] AuthStateManager available:', - !!this.authStateManager, + return AuthStateManager.runExclusiveAuth(() => + this.doInitializeAgentConnection(), ); + } - // Check if CLI is installed before attempting to connect - const cliDetection = await CliDetector.detectQwenCli(); + /** + * Internal: perform actual connection/initialization (no auth locking). + */ + private async doInitializeAgentConnection(): Promise { + const run = async () => { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - if (!cliDetection.isInstalled) { - console.log( - '[WebViewProvider] Qwen CLI not detected, skipping agent connection', + this.consoleLog( + '[WebViewProvider] Starting initialization, workingDir:', + workingDir, ); - console.log('[WebViewProvider] CLI detection error:', cliDetection.error); - - // Show VSCode notification with installation option - await CliInstaller.promptInstallation(); - - // Initialize empty conversation (can still browse history) - await this.initializeEmptyConversation(); - } else { - console.log( - '[WebViewProvider] Qwen CLI detected, attempting connection...', + this.consoleLog( + '[WebViewProvider] AuthStateManager available:', + !!this.authStateManager, ); - console.log('[WebViewProvider] CLI path:', cliDetection.cliPath); - console.log('[WebViewProvider] CLI version:', cliDetection.version); - try { - console.log('[WebViewProvider] Connecting to agent...'); - console.log( - '[WebViewProvider] Using authStateManager:', - !!this.authStateManager, + // Check if CLI is installed before attempting to connect + const cliDetection = await CliDetector.detectQwenCli(); + + if (!cliDetection.isInstalled) { + this.consoleLog( + '[WebViewProvider] Qwen CLI not detected, skipping agent connection', ); - const authInfo = await this.authStateManager.getAuthInfo(); - console.log('[WebViewProvider] Auth cache status:', authInfo); - - // Pass the detected CLI path to ensure we use the correct installation - await this.agentManager.connect( - workingDir, - this.authStateManager, - cliDetection.cliPath, + this.consoleLog( + '[WebViewProvider] CLI detection error:', + cliDetection.error, ); - console.log('[WebViewProvider] Agent connected successfully'); - this.agentInitialized = true; - // Load messages from the current Qwen session - await this.loadCurrentSessionMessages(); + // Show VSCode notification with installation option + await CliInstaller.promptInstallation(); - // Notify webview that agent is connected - this.sendMessageToWebView({ - type: 'agentConnected', - data: {}, - }); - } 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.`, - ); - // Fallback to empty conversation + // Initialize empty conversation (can still browse history) await this.initializeEmptyConversation(); + } else { + this.consoleLog( + '[WebViewProvider] Qwen CLI detected, attempting connection...', + ); + this.consoleLog('[WebViewProvider] CLI path:', cliDetection.cliPath); + this.consoleLog('[WebViewProvider] CLI version:', cliDetection.version); - // Notify webview that agent connection failed - this.sendMessageToWebView({ - type: 'agentConnectionError', - data: { - message: _error instanceof Error ? _error.message : String(_error), - }, - }); + try { + this.consoleLog('[WebViewProvider] Connecting to agent...'); + this.consoleLog( + '[WebViewProvider] Using authStateManager:', + !!this.authStateManager, + ); + const authInfo = await this.authStateManager.getAuthInfo(); + this.consoleLog('[WebViewProvider] Auth cache status:', authInfo); + + // Pass the detected CLI path to ensure we use the correct installation + await this.agentManager.connect( + workingDir, + this.authStateManager, + cliDetection.cliPath, + ); + this.consoleLog('[WebViewProvider] Agent connected successfully'); + this.agentInitialized = true; + + // Load messages from the current Qwen session + await this.loadCurrentSessionMessages(); + + // Notify webview that agent is connected + this.sendMessageToWebView({ + type: 'agentConnected', + data: {}, + }); + } 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.`, + ); + // Fallback to empty conversation + await this.initializeEmptyConversation(); + + // Notify webview that agent connection failed + this.sendMessageToWebView({ + type: 'agentConnectionError', + data: { + message: + _error instanceof Error ? _error.message : String(_error), + }, + }); + } } - } + }; + + return run(); } /** @@ -650,86 +674,97 @@ export class WebViewProvider { * Called when user explicitly uses /login command */ async forceReLogin(): Promise { - console.log('[WebViewProvider] Force re-login requested'); - console.log( + this.consoleLog('[WebViewProvider] Force re-login requested'); + this.consoleLog( '[WebViewProvider] Current authStateManager:', !!this.authStateManager, ); - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Logging in to Qwen Code... ', - cancellable: false, - }, - async (progress) => { - try { - progress.report({ message: 'Preparing sign-in...' }); + // If a login/connection flow is already running, reuse it to avoid double prompts + const p = Promise.resolve( + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + }, + async (progress) => { + try { + progress.report({ message: 'Preparing sign-in...' }); - // Clear existing auth cache - if (this.authStateManager) { - await this.authStateManager.clearAuthState(); - console.log('[WebViewProvider] Auth cache cleared'); - } else { - console.log('[WebViewProvider] No authStateManager to clear'); - } - - // Disconnect existing connection if any - if (this.agentInitialized) { - try { - this.agentManager.disconnect(); - console.log('[WebViewProvider] Existing connection disconnected'); - } catch (_error) { - console.log('[WebViewProvider] Error disconnecting:', _error); + // Clear existing auth cache + if (this.authStateManager) { + await this.authStateManager.clearAuthState(); + this.consoleLog('[WebViewProvider] Auth cache cleared'); + } else { + this.consoleLog('[WebViewProvider] No authStateManager to clear'); } - this.agentInitialized = false; + + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + this.consoleLog( + '[WebViewProvider] Existing connection disconnected', + ); + } catch (_error) { + this.consoleLog( + '[WebViewProvider] Error disconnecting:', + _error, + ); + } + this.agentInitialized = false; + } + + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 300)); + + progress.report({ + message: 'Connecting to CLI and starting sign-in...', + }); + + // Reinitialize connection (will trigger fresh authentication) + await this.doInitializeAgentConnection(); + this.consoleLog( + '[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); + this.consoleLog( + '[WebViewProvider] Auth state saved after re-login', + ); + } + + // Send success notification to WebView + this.sendMessageToWebView({ + type: 'loginSuccess', + data: { message: 'Successfully logged in!' }, + }); + } catch (_error) { + console.error('[WebViewProvider] Force re-login failed:', _error); + console.error( + '[WebViewProvider] Error stack:', + _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)}`, + }, + }); + + throw _error; } - - // Wait a moment for cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 300)); - - progress.report({ - message: 'Connecting to CLI and starting sign-in...', - }); - - // Reinitialize connection (will trigger fresh authentication) - await this.initializeAgentConnection(); - console.log( - '[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', - data: { message: 'Successfully logged in!' }, - }); - } catch (_error) { - console.error('[WebViewProvider] Force re-login failed:', _error); - console.error( - '[WebViewProvider] Error stack:', - _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)}`, - }, - }); - - throw _error; - } - }, + }, + ), ); + + return AuthStateManager.runExclusiveAuth(() => p); } /** @@ -737,15 +772,15 @@ export class WebViewProvider { * Called when restoring WebView after VSCode restart */ async refreshConnection(): Promise { - console.log('[WebViewProvider] Refresh connection requested'); + this.consoleLog('[WebViewProvider] Refresh connection requested'); // Disconnect existing connection if any if (this.agentInitialized) { try { this.agentManager.disconnect(); - console.log('[WebViewProvider] Existing connection disconnected'); + this.consoleLog('[WebViewProvider] Existing connection disconnected'); } catch (_error) { - console.log('[WebViewProvider] Error disconnecting:', _error); + this.consoleLog('[WebViewProvider] Error disconnecting:', _error); } this.agentInitialized = false; } @@ -756,7 +791,7 @@ export class WebViewProvider { // Reinitialize connection (will use cached auth if available) try { await this.initializeAgentConnection(); - console.log( + this.consoleLog( '[WebViewProvider] Connection refresh completed successfully', ); @@ -786,35 +821,41 @@ export class WebViewProvider { */ private async loadCurrentSessionMessages(): Promise { try { - console.log( + this.consoleLog( '[WebViewProvider] Initializing with new session (skipping restoration)', ); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - // Skip session restoration entirely and create a new session directly - try { - await this.agentManager.createNewSession( - workingDir, - this.authStateManager, - ); - console.log('[WebViewProvider] ACP session created successfully'); + // avoid creating another session if connect() already created one. + if (!this.agentManager.currentSessionId) { + try { + await this.agentManager.createNewSession( + workingDir, + this.authStateManager, + ); + this.consoleLog('[WebViewProvider] ACP session created successfully'); - // Ensure auth state is saved after successful session creation - if (this.authStateManager) { - await this.authStateManager.saveAuthState(workingDir, authMethod); - console.log( - '[WebViewProvider] Auth state saved after session creation', + // Ensure auth state is saved after successful session creation + if (this.authStateManager) { + await this.authStateManager.saveAuthState(workingDir, authMethod); + this.consoleLog( + '[WebViewProvider] Auth state saved after session creation', + ); + } + } 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.`, ); } - } 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 { + this.consoleLog( + '[WebViewProvider] Existing ACP session detected, skipping new session creation', ); } @@ -837,14 +878,14 @@ export class WebViewProvider { */ private async initializeEmptyConversation(): Promise { try { - console.log('[WebViewProvider] Initializing empty conversation'); + this.consoleLog('[WebViewProvider] Initializing empty conversation'); const newConv = await this.conversationStore.createConversation(); this.messageHandler.setCurrentConversationId(newConv.id); this.sendMessageToWebView({ type: 'conversationLoaded', data: newConv, }); - console.log( + this.consoleLog( '[WebViewProvider] Empty conversation initialized:', this.messageHandler.getCurrentConversationId(), ); @@ -968,7 +1009,7 @@ export class WebViewProvider { * Call this when auth cache is cleared to force re-authentication */ resetAgentState(): void { - console.log('[WebViewProvider] Resetting agent state'); + this.consoleLog('[WebViewProvider] Resetting agent state'); this.agentInitialized = false; // Disconnect existing connection this.agentManager.disconnect(); @@ -978,7 +1019,7 @@ export class WebViewProvider { * Clear authentication cache for this WebViewProvider instance */ async clearAuthCache(): Promise { - console.log('[WebViewProvider] Clearing auth cache for this instance'); + this.consoleLog('[WebViewProvider] Clearing auth cache for this instance'); if (this.authStateManager) { await this.authStateManager.clearAuthState(); this.resetAgentState(); @@ -990,8 +1031,8 @@ export class WebViewProvider { * This sets up the panel with all event listeners */ async restorePanel(panel: vscode.WebviewPanel): Promise { - console.log('[WebViewProvider] Restoring WebView panel'); - console.log( + this.consoleLog('[WebViewProvider] Restoring WebView panel'); + this.consoleLog( '[WebViewProvider] Current authStateManager in restore:', !!this.authStateManager, ); @@ -1122,10 +1163,10 @@ export class WebViewProvider { // Capture the tab reference on restore this.panelManager.captureTab(); - console.log('[WebViewProvider] Panel restored successfully'); + this.consoleLog('[WebViewProvider] Panel restored successfully'); // Attempt to restore authentication state and initialize connection - console.log( + this.consoleLog( '[WebViewProvider] Attempting to restore auth state and connection after restore...', ); await this.attemptAuthStateRestoration(); @@ -1139,12 +1180,12 @@ export class WebViewProvider { conversationId: string | null; agentInitialized: boolean; } { - console.log('[WebViewProvider] Getting state for serialization'); - console.log( + this.consoleLog('[WebViewProvider] Getting state for serialization'); + this.consoleLog( '[WebViewProvider] Current conversationId:', this.messageHandler.getCurrentConversationId(), ); - console.log( + this.consoleLog( '[WebViewProvider] Current agentInitialized:', this.agentInitialized, ); @@ -1152,7 +1193,7 @@ export class WebViewProvider { conversationId: this.messageHandler.getCurrentConversationId(), agentInitialized: this.agentInitialized, }; - console.log('[WebViewProvider] Returning state:', state); + this.consoleLog('[WebViewProvider] Returning state:', state); return state; } @@ -1170,10 +1211,10 @@ export class WebViewProvider { conversationId: string | null; agentInitialized: boolean; }): void { - console.log('[WebViewProvider] Restoring state:', state); + this.consoleLog('[WebViewProvider] Restoring state:', state); this.messageHandler.setCurrentConversationId(state.conversationId); this.agentInitialized = state.agentInitialized; - console.log( + this.consoleLog( '[WebViewProvider] State restored. agentInitialized:', this.agentInitialized, ); @@ -1206,8 +1247,6 @@ export class WebViewProvider { type: 'conversationCleared', data: {}, }); - - 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}`); diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 741d9684..a46febcd 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -291,6 +291,41 @@ export class SessionMessageHandler extends BaseMessageHandler { return; } + // Ensure an ACP session exists before sending prompt + if (!this.agentManager.currentSessionId) { + try { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + await this.agentManager.createNewSession(workingDir); + } catch (createErr) { + console.error( + '[SessionMessageHandler] Failed to create session before sending message:', + createErr, + ); + const errorMsg = + createErr instanceof Error ? createErr.message : String(createErr); + if ( + errorMsg.includes('Authentication required') || + errorMsg.includes('(code: -32000)') + ) { + const result = await vscode.window.showWarningMessage( + 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + 'Login Now', + ); + if (result === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + } + return; + } + vscode.window.showErrorMessage(`Failed to create session: ${errorMsg}`); + return; + } + } + // Send to agent try { this.resetStreamContent(); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index cd312361..a00fa0e5 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -14,6 +14,7 @@ import type { import type { ToolCallUpdate } from '../../types/chatTypes.js'; import type { ApprovalModeValue } from '../../types/acpTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; +import { createWebviewConsoleLogger } from '../utils/logger.js'; interface UseWebViewMessagesProps { // Session management @@ -129,6 +130,7 @@ export const useWebViewMessages = ({ }: UseWebViewMessagesProps) => { // VS Code API for posting messages back to the extension host const vscode = useVSCode(); + const consoleLog = useRef(createWebviewConsoleLogger('WebViewMessages')); // Track active long-running tool calls (execute/bash/command) so we can // keep the bottom "waiting" message visible until all of them complete. const activeExecToolCallsRef = useRef>(new Set()); @@ -227,40 +229,26 @@ export const useWebViewMessages = ({ break; } - // case 'cliNotInstalled': { - // // Show CLI not installed message - // const errorMsg = - // (message?.data?.error as string) || - // 'Qwen Code CLI is not installed. Please install it to enable full functionality.'; + case 'agentConnected': { + // Agent connected successfully; clear any pending spinner + handlers.messageHandling.clearWaitingForResponse(); + break; + } - // handlers.messageHandling.addMessage({ - // role: 'assistant', - // content: `Qwen CLI is not installed. Please install it to enable full functionality.\n\nError: ${errorMsg}\n\nInstallation instructions:\n1. Install via npm:\n npm install -g @qwen-code/qwen-code@latest\n\n2. After installation, reload VS Code or restart the extension.`, - // timestamp: Date.now(), - // }); - // break; - // } + case 'agentConnectionError': { + // Agent connection failed; surface the error and unblock the UI + handlers.messageHandling.clearWaitingForResponse(); + const errorMsg = + (message?.data?.message as string) || + 'Failed to connect to Qwen agent.'; - // case 'agentConnected': { - // // Agent connected successfully - // handlers.messageHandling.clearWaitingForResponse(); - // break; - // } - - // case 'agentConnectionError': { - // // Agent connection failed - // handlers.messageHandling.clearWaitingForResponse(); - // const errorMsg = - // (message?.data?.message as string) || - // 'Failed to connect to Qwen agent.'; - - // handlers.messageHandling.addMessage({ - // role: 'assistant', - // content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, - // timestamp: Date.now(), - // }); - // break; - // } + handlers.messageHandling.addMessage({ + role: 'assistant', + content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, + timestamp: Date.now(), + }); + break; + } case 'loginError': { // Clear loading state and show error notice @@ -765,7 +753,10 @@ export const useWebViewMessages = ({ path: string; }>; if (files) { - console.log('[WebView] Received workspaceFiles:', files.length); + consoleLog.current( + '[WebView] Received workspaceFiles:', + files.length, + ); handlers.fileContext.setWorkspaceFiles(files); } break; diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css index 4c3db053..956912cb 100644 --- a/packages/vscode-ide-companion/src/webview/styles/styles.css +++ b/packages/vscode-ide-companion/src/webview/styles/styles.css @@ -5,7 +5,6 @@ */ /* Import component styles */ -@import '../components/messages/Assistant/AssistantMessage.css'; @import './timeline.css'; @import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; diff --git a/packages/vscode-ide-companion/src/webview/utils/logger.ts b/packages/vscode-ide-companion/src/webview/utils/logger.ts new file mode 100644 index 00000000..40d1793e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/utils/logger.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Creates a dev-only console logger for the WebView bundle. + * In production builds it becomes a no-op to avoid noisy logs. + */ +export function createWebviewConsoleLogger(scope?: string) { + return (...args: unknown[]) => { + const env = (globalThis as { process?: { env?: Record } }) + .process?.env; + const isProduction = env?.NODE_ENV === 'production'; + if (isProduction) { + return; + } + if (scope) { + console.log(`[${scope}]`, ...args); + return; + } + console.log(...args); + }; +} diff --git a/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts b/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts deleted file mode 100644 index 0231f383..00000000 --- a/packages/vscode-ide-companion/src/webview/utils/simpleDiff.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Minimal line-diff utility for webview previews. - * - * This is a lightweight LCS-based algorithm to compute add/remove operations - * between two texts. It intentionally avoids heavy dependencies and is - * sufficient for rendering a compact preview inside the chat. - */ - -export type DiffOp = - | { type: 'add'; line: string; newIndex: number } - | { type: 'remove'; line: string; oldIndex: number }; - -/** - * Compute a minimal line-diff (added/removed only). - * - Equal lines are omitted from output by design (we only preview changes). - * - Order of operations follows the new text progression so the preview feels natural. - */ -export function computeLineDiff( - oldText: string | null | undefined, - newText: string | undefined, -): DiffOp[] { - const a = (oldText || '').split('\n'); - const b = (newText || '').split('\n'); - - const n = a.length; - const m = b.length; - - // Build LCS DP table - const dp: number[][] = Array.from({ length: n + 1 }, () => - new Array(m + 1).fill(0), - ); - for (let i = n - 1; i >= 0; i--) { - for (let j = m - 1; j >= 0; j--) { - if (a[i] === b[j]) { - dp[i][j] = dp[i + 1][j + 1] + 1; - } else { - dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - } - - // Walk to produce operations - const ops: DiffOp[] = []; - let i = 0; - let j = 0; - while (i < n && j < m) { - if (a[i] === b[j]) { - i++; - j++; - } else if (dp[i + 1][j] >= dp[i][j + 1]) { - // remove a[i] - ops.push({ type: 'remove', line: a[i], oldIndex: i }); - i++; - } else { - // add b[j] - ops.push({ type: 'add', line: b[j], newIndex: j }); - j++; - } - } - - // Remaining tails - while (i < n) { - ops.push({ type: 'remove', line: a[i], oldIndex: i }); - i++; - } - while (j < m) { - ops.push({ type: 'add', line: b[j], newIndex: j }); - j++; - } - - return ops; -} - -/** - * Truncate a long list of operations for preview purposes. - * Keeps first `head` and last `tail` operations, inserting a gap marker. - */ -export function truncateOps( - ops: T[], - head = 120, - tail = 80, -): { items: T[]; truncated: boolean; omitted: number } { - if (ops.length <= head + tail) { - return { items: ops, truncated: false, omitted: 0 }; - } - const items = [...ops.slice(0, head), ...ops.slice(-tail)]; - return { items, truncated: true, omitted: ops.length - head - tail }; -} From c20df192a855575c694989df45877c59ccd0382c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 11 Dec 2025 23:57:21 +0800 Subject: [PATCH 06/44] chore(vscode-ide-companion): revert some log util, will continue next time --- .../vscode-ide-companion/src/extension.ts | 20 ++-- .../src/services/acpConnection.ts | 1 + .../src/services/authStateManager.ts | 88 +++++--------- .../src/services/qwenAgentManager.ts | 91 ++++++--------- .../vscode-ide-companion/src/utils/logger.ts | 42 ------- .../vscode-ide-companion/src/webview/App.tsx | 4 +- .../src/webview/WebViewProvider.ts | 108 ++++++++---------- .../messages/toolcalls/shared/utils.ts | 19 --- .../src/webview/hooks/useWebViewMessages.ts | 7 +- .../src/webview/utils/logger.ts | 25 ---- 10 files changed, 123 insertions(+), 282 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/utils/logger.ts diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 08979099..2adfaef1 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -8,11 +8,7 @@ import * as vscode from 'vscode'; import { IDEServer } from './ide-server.js'; import semver from 'semver'; import { DiffContentProvider, DiffManager } from './diff-manager.js'; -import { - createLogger, - getConsoleLogger, - initSharedConsoleLogger, -} from './utils/logger.js'; +import { createLogger } from './utils/logger.js'; import { detectIdeFromEnv, IDE_DEFINITIONS, @@ -109,8 +105,6 @@ async function checkForUpdates( export async function activate(context: vscode.ExtensionContext) { logger = vscode.window.createOutputChannel('Qwen Code Companion'); - initSharedConsoleLogger(context); - const consoleLog = getConsoleLogger(); log = createLogger(context, logger); log('Extension activated'); @@ -148,18 +142,18 @@ export async function activate(context: vscode.ExtensionContext) { webviewPanel: vscode.WebviewPanel, state: unknown, ) { - consoleLog( + console.log( '[Extension] Deserializing WebView panel with state:', state, ); // Create a new provider for the restored panel const provider = createWebViewProvider(); - consoleLog('[Extension] Provider created for deserialization'); + console.log('[Extension] Provider created for deserialization'); // Restore state if available BEFORE restoring the panel if (state && typeof state === 'object') { - consoleLog('[Extension] Restoring state:', state); + console.log('[Extension] Restoring state:', state); provider.restoreState( state as { conversationId: string | null; @@ -167,11 +161,11 @@ export async function activate(context: vscode.ExtensionContext) { }, ); } else { - consoleLog('[Extension] No state to restore or invalid state'); + console.log('[Extension] No state to restore or invalid state'); } await provider.restorePanel(webviewPanel); - consoleLog('[Extension] Panel restore completed'); + console.log('[Extension] Panel restore completed'); log('WebView panel restored from serialization'); }, @@ -212,6 +206,7 @@ export async function activate(context: vscode.ExtensionContext) { } 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) => { const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; @@ -228,6 +223,7 @@ export async function activate(context: vscode.ExtensionContext) { } 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 { diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 4dd35b71..a9992de4 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -31,6 +31,7 @@ export class AcpConnection { private child: ChildProcess | null = null; private pendingRequests = new Map>(); private nextRequestId = { value: 0 }; + // Deduplicate concurrent authenticate calls (across retry paths) private static authInFlight: Promise | null = null; // Remember the working dir provided at connect() so later ACP calls diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts index aa75fe36..824a642c 100644 --- a/packages/vscode-ide-companion/src/services/authStateManager.ts +++ b/packages/vscode-ide-companion/src/services/authStateManager.ts @@ -5,7 +5,6 @@ */ import type * as vscode from 'vscode'; -import { createConsoleLogger, getConsoleLogger } from '../utils/logger.js'; interface AuthState { isAuthenticated: boolean; @@ -22,7 +21,6 @@ export class AuthStateManager { private static context: vscode.ExtensionContext | null = null; private static readonly AUTH_STATE_KEY = 'qwen.authState'; private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - private static consoleLog: (...args: unknown[]) => void = getConsoleLogger(); // Deduplicate concurrent auth flows (e.g., multiple tabs prompting login) private static authFlowInFlight: Promise | null = null; private constructor() {} @@ -38,10 +36,6 @@ export class AuthStateManager { // If a context is provided, update the static context if (context) { AuthStateManager.context = context; - AuthStateManager.consoleLog = createConsoleLogger( - context, - 'AuthStateManager', - ); } return AuthStateManager.instance; @@ -76,19 +70,17 @@ export class AuthStateManager { const state = await this.getAuthState(); if (!state) { - AuthStateManager.consoleLog( - '[AuthStateManager] No cached auth state found', - ); + console.log('[AuthStateManager] No cached auth state found'); return false; } - AuthStateManager.consoleLog('[AuthStateManager] Found cached auth state:', { + console.log('[AuthStateManager] Found cached auth state:', { workingDir: state.workingDir, authMethod: state.authMethod, timestamp: new Date(state.timestamp).toISOString(), isAuthenticated: state.isAuthenticated, }); - AuthStateManager.consoleLog('[AuthStateManager] Checking against:', { + console.log('[AuthStateManager] Checking against:', { workingDir, authMethod, }); @@ -99,8 +91,8 @@ export class AuthStateManager { now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; if (isExpired) { - AuthStateManager.consoleLog('[AuthStateManager] Cached auth expired'); - AuthStateManager.consoleLog( + console.log('[AuthStateManager] Cached auth expired'); + console.log( '[AuthStateManager] Cache age:', Math.floor((now - state.timestamp) / 1000 / 60), 'minutes', @@ -114,29 +106,15 @@ export class AuthStateManager { state.workingDir === workingDir && state.authMethod === authMethod; if (!isSameContext) { - AuthStateManager.consoleLog( - '[AuthStateManager] Working dir or auth method changed', - ); - AuthStateManager.consoleLog( - '[AuthStateManager] Cached workingDir:', - state.workingDir, - ); - AuthStateManager.consoleLog( - '[AuthStateManager] Current workingDir:', - workingDir, - ); - AuthStateManager.consoleLog( - '[AuthStateManager] Cached authMethod:', - state.authMethod, - ); - AuthStateManager.consoleLog( - '[AuthStateManager] Current authMethod:', - authMethod, - ); + console.log('[AuthStateManager] Working dir or auth method changed'); + console.log('[AuthStateManager] Cached workingDir:', state.workingDir); + console.log('[AuthStateManager] Current workingDir:', workingDir); + console.log('[AuthStateManager] Cached authMethod:', state.authMethod); + console.log('[AuthStateManager] Current authMethod:', authMethod); return false; } - AuthStateManager.consoleLog('[AuthStateManager] Valid cached auth found'); + console.log('[AuthStateManager] Valid cached auth found'); return state.isAuthenticated; } @@ -146,10 +124,7 @@ export class AuthStateManager { */ async debugAuthState(): Promise { const state = await this.getAuthState(); - AuthStateManager.consoleLog( - '[AuthStateManager] DEBUG - Current auth state:', - state, - ); + console.log('[AuthStateManager] DEBUG - Current auth state:', state); if (state) { const now = Date.now(); @@ -157,16 +132,9 @@ export class AuthStateManager { const isExpired = now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; - AuthStateManager.consoleLog( - '[AuthStateManager] DEBUG - Auth state age:', - age, - 'minutes', - ); - AuthStateManager.consoleLog( - '[AuthStateManager] DEBUG - Auth state expired:', - isExpired, - ); - AuthStateManager.consoleLog( + console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes'); + console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired); + console.log( '[AuthStateManager] DEBUG - Auth state valid:', state.isAuthenticated, ); @@ -191,7 +159,7 @@ export class AuthStateManager { timestamp: Date.now(), }; - AuthStateManager.consoleLog('[AuthStateManager] Saving auth state:', { + console.log('[AuthStateManager] Saving auth state:', { workingDir, authMethod, timestamp: new Date(state.timestamp).toISOString(), @@ -201,14 +169,11 @@ export class AuthStateManager { AuthStateManager.AUTH_STATE_KEY, state, ); - AuthStateManager.consoleLog('[AuthStateManager] Auth state saved'); + console.log('[AuthStateManager] Auth state saved'); // Verify the state was saved correctly const savedState = await this.getAuthState(); - AuthStateManager.consoleLog( - '[AuthStateManager] Verified saved state:', - savedState, - ); + console.log('[AuthStateManager] Verified saved state:', savedState); } /** @@ -222,9 +187,9 @@ export class AuthStateManager { ); } - AuthStateManager.consoleLog('[AuthStateManager] Clearing auth state'); + console.log('[AuthStateManager] Clearing auth state'); const currentState = await this.getAuthState(); - AuthStateManager.consoleLog( + console.log( '[AuthStateManager] Current state before clearing:', currentState, ); @@ -233,14 +198,11 @@ export class AuthStateManager { AuthStateManager.AUTH_STATE_KEY, undefined, ); - AuthStateManager.consoleLog('[AuthStateManager] Auth state cleared'); + console.log('[AuthStateManager] Auth state cleared'); // Verify the state was cleared const newState = await this.getAuthState(); - AuthStateManager.consoleLog( - '[AuthStateManager] State after clearing:', - newState, - ); + console.log('[AuthStateManager] State after clearing:', newState); } /** @@ -249,15 +211,17 @@ export class AuthStateManager { private async getAuthState(): Promise { // Ensure we have a valid context if (!AuthStateManager.context) { - AuthStateManager.consoleLog( + console.log( '[AuthStateManager] No context available for getting auth state', ); return undefined; } - return AuthStateManager.context.globalState.get( + const a = AuthStateManager.context.globalState.get( AuthStateManager.AUTH_STATE_KEY, ); + console.log('[AuthStateManager] Auth state:', a); + return a; } /** diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index b954aed8..89214139 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -23,7 +23,6 @@ import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { CliContextManager } from '../cli/cliContextManager.js'; import { authMethod } from '../types/acpTypes.js'; import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js'; -import { getConsoleLogger } from '../utils/logger.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; @@ -51,10 +50,8 @@ export class QwenAgentManager { // Callback storage private callbacks: QwenAgentCallbacks = {}; - private consoleLog: (...args: unknown[]) => void; - constructor(consoleLogger = getConsoleLogger()) { - this.consoleLog = consoleLogger; + constructor() { this.connection = new AcpConnection(); this.sessionReader = new QwenSessionReader(); this.sessionManager = new QwenSessionManager(); @@ -81,7 +78,7 @@ export class QwenAgentManager { ).update; const text = update?.content?.text || ''; if (update?.sessionUpdate === 'user_message_chunk' && text) { - this.consoleLog( + console.log( '[QwenAgentManager] Rehydration: routing user message chunk', ); this.callbacks.onMessage?.({ @@ -92,7 +89,7 @@ export class QwenAgentManager { return; } if (update?.sessionUpdate === 'agent_message_chunk' && text) { - this.consoleLog( + console.log( '[QwenAgentManager] Rehydration: routing agent message chunk', ); this.callbacks.onMessage?.({ @@ -103,7 +100,7 @@ export class QwenAgentManager { return; } // For other types during rehydration, fall through to normal handler - this.consoleLog( + console.log( '[QwenAgentManager] Rehydration: non-text update, forwarding to handler', ); } @@ -262,7 +259,7 @@ export class QwenAgentManager { * @returns Session list */ async getSessionList(): Promise>> { - this.consoleLog( + console.log( '[QwenAgentManager] Getting session list with version-aware strategy', ); @@ -270,7 +267,7 @@ export class QwenAgentManager { const cliContextManager = CliContextManager.getInstance(); const supportsSessionList = cliContextManager.supportsSessionList(); - this.consoleLog( + console.log( '[QwenAgentManager] CLI supports session/list:', supportsSessionList, ); @@ -278,14 +275,11 @@ export class QwenAgentManager { // Try ACP method first if supported if (supportsSessionList) { try { - this.consoleLog( + console.log( '[QwenAgentManager] Attempting to get session list via ACP method', ); const response = await this.connection.listSessions(); - this.consoleLog( - '[QwenAgentManager] ACP session list response:', - response, - ); + console.log('[QwenAgentManager] ACP session list response:', response); // sendRequest resolves with the JSON-RPC "result" directly // Newer CLI returns an object: { items: [...], nextCursor?, hasMore } @@ -303,7 +297,7 @@ export class QwenAgentManager { : []; } - this.consoleLog( + console.log( '[QwenAgentManager] Sessions retrieved via ACP:', res, items.length, @@ -322,7 +316,7 @@ export class QwenAgentManager { cwd: item.cwd, })); - this.consoleLog( + console.log( '[QwenAgentManager] Sessions retrieved via ACP:', sessions.length, ); @@ -338,11 +332,9 @@ export class QwenAgentManager { // Always fall back to file system method try { - this.consoleLog( - '[QwenAgentManager] Getting session list from file system', - ); + console.log('[QwenAgentManager] Getting session list from file system'); const sessions = await this.sessionReader.getAllSessions(undefined, true); - this.consoleLog( + console.log( '[QwenAgentManager] Session list from file system (all projects):', sessions.length, ); @@ -360,7 +352,7 @@ export class QwenAgentManager { }), ); - this.consoleLog( + console.log( '[QwenAgentManager] Sessions retrieved from file system:', result.length, ); @@ -500,7 +492,7 @@ export class QwenAgentManager { const item = list.find( (s) => s.sessionId === sessionId || s.id === sessionId, ); - this.consoleLog( + console.log( '[QwenAgentManager] Session list item for filePath lookup:', item, ); @@ -571,7 +563,7 @@ export class QwenAgentManager { } } // Simple linear reconstruction: filter user/assistant and sort by timestamp - this.consoleLog( + console.log( '[QwenAgentManager] JSONL records read:', records.length, filePath, @@ -728,7 +720,7 @@ export class QwenAgentManager { // Handle other types if needed } - this.consoleLog( + console.log( '[QwenAgentManager] JSONL messages reconstructed:', msgs.length, ); @@ -866,7 +858,7 @@ export class QwenAgentManager { tag: string, ): Promise<{ success: boolean; message?: string }> { try { - this.consoleLog( + console.log( '[QwenAgentManager] Saving session via /chat save command:', sessionId, 'with tag:', @@ -877,9 +869,7 @@ export class QwenAgentManager { // The CLI will handle this as a special command await this.connection.sendPrompt(`/chat save "${tag}"`); - this.consoleLog( - '[QwenAgentManager] /chat save command sent successfully', - ); + console.log('[QwenAgentManager] /chat save command sent successfully'); return { success: true, message: `Session saved with tag: ${tag}`, @@ -926,14 +916,14 @@ export class QwenAgentManager { conversationId: string, ): Promise<{ success: boolean; tag?: string; message?: string }> { try { - this.consoleLog('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); - this.consoleLog('[QwenAgentManager] Conversation ID:', conversationId); - this.consoleLog('[QwenAgentManager] Message count:', messages.length); - this.consoleLog( + console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); + console.log('[QwenAgentManager] Conversation ID:', conversationId); + console.log('[QwenAgentManager] Message count:', messages.length); + console.log( '[QwenAgentManager] Current working dir:', this.currentWorkingDir, ); - this.consoleLog( + console.log( '[QwenAgentManager] Current session ID (from CLI):', this.currentSessionId, ); @@ -1010,11 +1000,11 @@ export class QwenAgentManager { try { // Route upcoming session/update messages as discrete messages for replay this.rehydratingSessionId = sessionId; - this.consoleLog( + console.log( '[QwenAgentManager] Rehydration start for session:', sessionId, ); - this.consoleLog( + console.log( '[QwenAgentManager] Attempting session/load via ACP for session:', sessionId, ); @@ -1022,7 +1012,7 @@ export class QwenAgentManager { sessionId, cwdOverride, ); - this.consoleLog( + console.log( '[QwenAgentManager] Session load succeeded. Response:', JSON.stringify(response).substring(0, 200), ); @@ -1062,10 +1052,7 @@ export class QwenAgentManager { throw error; } finally { // End rehydration routing regardless of outcome - this.consoleLog( - '[QwenAgentManager] Rehydration end for session:', - sessionId, - ); + console.log('[QwenAgentManager] Rehydration end for session:', sessionId); this.rehydratingSessionId = null; } } @@ -1078,7 +1065,7 @@ export class QwenAgentManager { * @returns Loaded session messages or null */ async loadSession(sessionId: string): Promise { - this.consoleLog( + console.log( '[QwenAgentManager] Loading session with version-aware strategy:', sessionId, ); @@ -1087,7 +1074,7 @@ export class QwenAgentManager { const cliContextManager = CliContextManager.getInstance(); const supportsSessionLoad = cliContextManager.supportsSessionLoad(); - this.consoleLog( + console.log( '[QwenAgentManager] CLI supports session/load:', supportsSessionLoad, ); @@ -1095,13 +1082,11 @@ export class QwenAgentManager { // Try ACP method first if supported if (supportsSessionLoad) { try { - this.consoleLog( + console.log( '[QwenAgentManager] Attempting to load session via ACP method', ); await this.loadSessionViaAcp(sessionId); - this.consoleLog( - '[QwenAgentManager] Session loaded successfully via ACP', - ); + console.log('[QwenAgentManager] Session loaded successfully via ACP'); // After loading via ACP, we still need to get messages from file system // In future, we might get them directly from the ACP response @@ -1115,11 +1100,11 @@ export class QwenAgentManager { // Always fall back to file system method try { - this.consoleLog( + console.log( '[QwenAgentManager] Loading session messages from file system', ); const messages = await this.loadSessionMessagesFromFile(sessionId); - this.consoleLog( + console.log( '[QwenAgentManager] Session messages loaded successfully from file system', ); return messages; @@ -1142,7 +1127,7 @@ export class QwenAgentManager { sessionId: string, ): Promise { try { - this.consoleLog( + console.log( '[QwenAgentManager] Loading session from file system:', sessionId, ); @@ -1154,7 +1139,7 @@ export class QwenAgentManager { ); if (!session) { - this.consoleLog( + console.log( '[QwenAgentManager] Session not found in file system:', sessionId, ); @@ -1209,7 +1194,7 @@ export class QwenAgentManager { return this.sessionCreateInFlight; } - this.consoleLog('[QwenAgentManager] Creating new session...'); + console.log('[QwenAgentManager] Creating new session...'); // Prefer the provided authStateManager, otherwise fall back to the one // remembered during connect(). This prevents accidental re-auth in // fallback paths (e.g. session switching) when the handler didn't pass it. @@ -1250,7 +1235,7 @@ export class QwenAgentManager { } } const newSessionId = this.connection.currentSessionId; - this.consoleLog( + console.log( '[QwenAgentManager] New session created with ID:', newSessionId, ); @@ -1276,7 +1261,7 @@ export class QwenAgentManager { * Cancel current prompt */ async cancelCurrentPrompt(): Promise { - this.consoleLog('[QwenAgentManager] Cancelling current prompt'); + console.log('[QwenAgentManager] Cancelling current prompt'); await this.connection.cancelSession(); } diff --git a/packages/vscode-ide-companion/src/utils/logger.ts b/packages/vscode-ide-companion/src/utils/logger.ts index f92cfe8c..b3f8ad1e 100644 --- a/packages/vscode-ide-companion/src/utils/logger.ts +++ b/packages/vscode-ide-companion/src/utils/logger.ts @@ -6,11 +6,6 @@ import * as vscode from 'vscode'; -type ConsoleLogger = (...args: unknown[]) => void; - -// Shared console logger instance, initialized during extension activation. -let sharedConsoleLogger: ConsoleLogger = () => {}; - export function createLogger( context: vscode.ExtensionContext, logger: vscode.OutputChannel, @@ -21,40 +16,3 @@ export function createLogger( } }; } - -/** - * Creates a dev-only logger that writes to the VS Code console (Developer Tools). - */ -export function createConsoleLogger( - context: vscode.ExtensionContext, - scope?: string, -): ConsoleLogger { - return (...args: unknown[]) => { - if (context.extensionMode !== vscode.ExtensionMode.Development) { - return; - } - if (scope) { - console.log(`[${scope}]`, ...args); - return; - } - console.log(...args); - }; -} - -/** - * Initialize the shared console logger so other modules can import it without - * threading the extension context everywhere. - */ -export function initSharedConsoleLogger( - context: vscode.ExtensionContext, - scope?: string, -) { - sharedConsoleLogger = createConsoleLogger(context, scope); -} - -/** - * Get the shared console logger (no-op until initialized). - */ -export function getConsoleLogger(): ConsoleLogger { - return sharedConsoleLogger; -} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 65d978fd..4bdf6622 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -45,11 +45,9 @@ import { FileIcon, UserIcon } from './components/icons/index.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/acpTypes.js'; import type { PlanEntry } from '../types/chatTypes.js'; -import { createWebviewConsoleLogger } from './utils/logger.js'; export const App: React.FC = () => { const vscode = useVSCode(); - const consoleLog = useMemo(() => createWebviewConsoleLogger('App'), []); // Core hooks const sessionManagement = useSessionManagement(vscode); @@ -542,7 +540,7 @@ export const App: React.FC = () => { ); }, [messageHandling.messages, inProgressToolCalls, completedToolCalls]); - consoleLog('[App] Rendering messages:', allMessages); + console.log('[App] Rendering messages:', allMessages); // Render all messages and tool calls const renderMessages = useCallback<() => React.ReactNode>( diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 11054259..faa81226 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -16,7 +16,6 @@ import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js'; -import { createConsoleLogger } from '../utils/logger.js'; export class WebViewProvider { private panelManager: PanelManager; @@ -33,15 +32,12 @@ export class WebViewProvider { private pendingPermissionResolve: ((optionId: string) => void) | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; - private consoleLog: (...args: unknown[]) => void; constructor( context: vscode.ExtensionContext, private extensionUri: vscode.Uri, ) { - const agentConsoleLogger = createConsoleLogger(context, 'QwenAgentManager'); - this.consoleLog = createConsoleLogger(context, 'WebViewProvider'); - this.agentManager = new QwenAgentManager(agentConsoleLogger); + this.agentManager = new QwenAgentManager(); this.conversationStore = new ConversationStore(context); this.authStateManager = AuthStateManager.getInstance(context); this.panelManager = new PanelManager(extensionUri, () => { @@ -384,7 +380,7 @@ export class WebViewProvider { // Set up state serialization newPanel.onDidChangeViewState(() => { - this.consoleLog( + console.log( '[WebViewProvider] Panel view state changed, triggering serialization check', ); }); @@ -514,7 +510,7 @@ export class WebViewProvider { } // Attempt to restore authentication state and initialize connection - this.consoleLog( + console.log( '[WebViewProvider] Attempting to restore auth state and connection...', ); await this.attemptAuthStateRestoration(); @@ -536,26 +532,23 @@ export class WebViewProvider { workingDir, authMethod, ); - this.consoleLog( - '[WebViewProvider] Has valid cached auth:', - hasValidAuth, - ); + console.log('[WebViewProvider] Has valid cached auth:', hasValidAuth); if (hasValidAuth) { - this.consoleLog( + console.log( '[WebViewProvider] Valid auth found, attempting connection...', ); // Try to connect with cached auth await this.initializeAgentConnection(); } else { - this.consoleLog( + console.log( '[WebViewProvider] No valid auth found, rendering empty conversation', ); // Render the chat UI immediately without connecting await this.initializeEmptyConversation(); } } else { - this.consoleLog( + console.log( '[WebViewProvider] No auth state manager, rendering empty conversation', ); await this.initializeEmptyConversation(); @@ -585,11 +578,11 @@ export class WebViewProvider { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - this.consoleLog( + console.log( '[WebViewProvider] Starting initialization, workingDir:', workingDir, ); - this.consoleLog( + console.log( '[WebViewProvider] AuthStateManager available:', !!this.authStateManager, ); @@ -598,10 +591,10 @@ export class WebViewProvider { const cliDetection = await CliDetector.detectQwenCli(); if (!cliDetection.isInstalled) { - this.consoleLog( + console.log( '[WebViewProvider] Qwen CLI not detected, skipping agent connection', ); - this.consoleLog( + console.log( '[WebViewProvider] CLI detection error:', cliDetection.error, ); @@ -612,20 +605,20 @@ export class WebViewProvider { // Initialize empty conversation (can still browse history) await this.initializeEmptyConversation(); } else { - this.consoleLog( + console.log( '[WebViewProvider] Qwen CLI detected, attempting connection...', ); - this.consoleLog('[WebViewProvider] CLI path:', cliDetection.cliPath); - this.consoleLog('[WebViewProvider] CLI version:', cliDetection.version); + console.log('[WebViewProvider] CLI path:', cliDetection.cliPath); + console.log('[WebViewProvider] CLI version:', cliDetection.version); try { - this.consoleLog('[WebViewProvider] Connecting to agent...'); - this.consoleLog( + console.log('[WebViewProvider] Connecting to agent...'); + console.log( '[WebViewProvider] Using authStateManager:', !!this.authStateManager, ); const authInfo = await this.authStateManager.getAuthInfo(); - this.consoleLog('[WebViewProvider] Auth cache status:', authInfo); + console.log('[WebViewProvider] Auth cache status:', authInfo); // Pass the detected CLI path to ensure we use the correct installation await this.agentManager.connect( @@ -633,7 +626,7 @@ export class WebViewProvider { this.authStateManager, cliDetection.cliPath, ); - this.consoleLog('[WebViewProvider] Agent connected successfully'); + console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; // Load messages from the current Qwen session @@ -674,8 +667,8 @@ export class WebViewProvider { * Called when user explicitly uses /login command */ async forceReLogin(): Promise { - this.consoleLog('[WebViewProvider] Force re-login requested'); - this.consoleLog( + console.log('[WebViewProvider] Force re-login requested'); + console.log( '[WebViewProvider] Current authStateManager:', !!this.authStateManager, ); @@ -694,23 +687,20 @@ export class WebViewProvider { // Clear existing auth cache if (this.authStateManager) { await this.authStateManager.clearAuthState(); - this.consoleLog('[WebViewProvider] Auth cache cleared'); + console.log('[WebViewProvider] Auth cache cleared'); } else { - this.consoleLog('[WebViewProvider] No authStateManager to clear'); + console.log('[WebViewProvider] No authStateManager to clear'); } // Disconnect existing connection if any if (this.agentInitialized) { try { this.agentManager.disconnect(); - this.consoleLog( + console.log( '[WebViewProvider] Existing connection disconnected', ); } catch (_error) { - this.consoleLog( - '[WebViewProvider] Error disconnecting:', - _error, - ); + console.log('[WebViewProvider] Error disconnecting:', _error); } this.agentInitialized = false; } @@ -724,7 +714,7 @@ export class WebViewProvider { // Reinitialize connection (will trigger fresh authentication) await this.doInitializeAgentConnection(); - this.consoleLog( + console.log( '[WebViewProvider] Force re-login completed successfully', ); @@ -733,9 +723,7 @@ export class WebViewProvider { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); await this.authStateManager.saveAuthState(workingDir, authMethod); - this.consoleLog( - '[WebViewProvider] Auth state saved after re-login', - ); + console.log('[WebViewProvider] Auth state saved after re-login'); } // Send success notification to WebView @@ -772,15 +760,15 @@ export class WebViewProvider { * Called when restoring WebView after VSCode restart */ async refreshConnection(): Promise { - this.consoleLog('[WebViewProvider] Refresh connection requested'); + console.log('[WebViewProvider] Refresh connection requested'); // Disconnect existing connection if any if (this.agentInitialized) { try { this.agentManager.disconnect(); - this.consoleLog('[WebViewProvider] Existing connection disconnected'); + console.log('[WebViewProvider] Existing connection disconnected'); } catch (_error) { - this.consoleLog('[WebViewProvider] Error disconnecting:', _error); + console.log('[WebViewProvider] Error disconnecting:', _error); } this.agentInitialized = false; } @@ -791,7 +779,7 @@ export class WebViewProvider { // Reinitialize connection (will use cached auth if available) try { await this.initializeAgentConnection(); - this.consoleLog( + console.log( '[WebViewProvider] Connection refresh completed successfully', ); @@ -821,7 +809,7 @@ export class WebViewProvider { */ private async loadCurrentSessionMessages(): Promise { try { - this.consoleLog( + console.log( '[WebViewProvider] Initializing with new session (skipping restoration)', ); @@ -835,12 +823,12 @@ export class WebViewProvider { workingDir, this.authStateManager, ); - this.consoleLog('[WebViewProvider] ACP session created successfully'); + console.log('[WebViewProvider] ACP session created successfully'); // Ensure auth state is saved after successful session creation if (this.authStateManager) { await this.authStateManager.saveAuthState(workingDir, authMethod); - this.consoleLog( + console.log( '[WebViewProvider] Auth state saved after session creation', ); } @@ -854,7 +842,7 @@ export class WebViewProvider { ); } } else { - this.consoleLog( + console.log( '[WebViewProvider] Existing ACP session detected, skipping new session creation', ); } @@ -878,14 +866,14 @@ export class WebViewProvider { */ private async initializeEmptyConversation(): Promise { try { - this.consoleLog('[WebViewProvider] Initializing empty conversation'); + console.log('[WebViewProvider] Initializing empty conversation'); const newConv = await this.conversationStore.createConversation(); this.messageHandler.setCurrentConversationId(newConv.id); this.sendMessageToWebView({ type: 'conversationLoaded', data: newConv, }); - this.consoleLog( + console.log( '[WebViewProvider] Empty conversation initialized:', this.messageHandler.getCurrentConversationId(), ); @@ -1009,7 +997,7 @@ export class WebViewProvider { * Call this when auth cache is cleared to force re-authentication */ resetAgentState(): void { - this.consoleLog('[WebViewProvider] Resetting agent state'); + console.log('[WebViewProvider] Resetting agent state'); this.agentInitialized = false; // Disconnect existing connection this.agentManager.disconnect(); @@ -1019,7 +1007,7 @@ export class WebViewProvider { * Clear authentication cache for this WebViewProvider instance */ async clearAuthCache(): Promise { - this.consoleLog('[WebViewProvider] Clearing auth cache for this instance'); + console.log('[WebViewProvider] Clearing auth cache for this instance'); if (this.authStateManager) { await this.authStateManager.clearAuthState(); this.resetAgentState(); @@ -1031,8 +1019,8 @@ export class WebViewProvider { * This sets up the panel with all event listeners */ async restorePanel(panel: vscode.WebviewPanel): Promise { - this.consoleLog('[WebViewProvider] Restoring WebView panel'); - this.consoleLog( + console.log('[WebViewProvider] Restoring WebView panel'); + console.log( '[WebViewProvider] Current authStateManager in restore:', !!this.authStateManager, ); @@ -1163,10 +1151,10 @@ export class WebViewProvider { // Capture the tab reference on restore this.panelManager.captureTab(); - this.consoleLog('[WebViewProvider] Panel restored successfully'); + console.log('[WebViewProvider] Panel restored successfully'); // Attempt to restore authentication state and initialize connection - this.consoleLog( + console.log( '[WebViewProvider] Attempting to restore auth state and connection after restore...', ); await this.attemptAuthStateRestoration(); @@ -1180,12 +1168,12 @@ export class WebViewProvider { conversationId: string | null; agentInitialized: boolean; } { - this.consoleLog('[WebViewProvider] Getting state for serialization'); - this.consoleLog( + console.log('[WebViewProvider] Getting state for serialization'); + console.log( '[WebViewProvider] Current conversationId:', this.messageHandler.getCurrentConversationId(), ); - this.consoleLog( + console.log( '[WebViewProvider] Current agentInitialized:', this.agentInitialized, ); @@ -1193,7 +1181,7 @@ export class WebViewProvider { conversationId: this.messageHandler.getCurrentConversationId(), agentInitialized: this.agentInitialized, }; - this.consoleLog('[WebViewProvider] Returning state:', state); + console.log('[WebViewProvider] Returning state:', state); return state; } @@ -1211,10 +1199,10 @@ export class WebViewProvider { conversationId: string | null; agentInitialized: boolean; }): void { - this.consoleLog('[WebViewProvider] Restoring state:', state); + console.log('[WebViewProvider] Restoring state:', state); this.messageHandler.setCurrentConversationId(state.conversationId); this.agentInitialized = state.agentInitialized; - this.consoleLog( + console.log( '[WebViewProvider] State restored. agentInitialized:', this.agentInitialized, ); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts index 4ae9efd6..ceb2cb2b 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/utils.ts @@ -61,25 +61,6 @@ export const safeTitle = (title: unknown): string => { return ''; }; -/** - * Get icon emoji for a given tool kind - */ -export const getKindIcon = (kind: string): string => { - const kindMap: Record = { - edit: '✏️', - write: '✏️', - read: '📖', - execute: '⚡', - fetch: '🌐', - delete: '🗑️', - move: '📦', - search: '🔍', - think: '💭', - diff: '📝', - }; - return kindMap[kind.toLowerCase()] || '🔧'; -}; - /** * Check if a tool call should be displayed * Hides internal tool calls diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index a00fa0e5..7a3f7e06 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -14,7 +14,6 @@ import type { import type { ToolCallUpdate } from '../../types/chatTypes.js'; import type { ApprovalModeValue } from '../../types/acpTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; -import { createWebviewConsoleLogger } from '../utils/logger.js'; interface UseWebViewMessagesProps { // Session management @@ -130,7 +129,6 @@ export const useWebViewMessages = ({ }: UseWebViewMessagesProps) => { // VS Code API for posting messages back to the extension host const vscode = useVSCode(); - const consoleLog = useRef(createWebviewConsoleLogger('WebViewMessages')); // Track active long-running tool calls (execute/bash/command) so we can // keep the bottom "waiting" message visible until all of them complete. const activeExecToolCallsRef = useRef>(new Set()); @@ -753,10 +751,7 @@ export const useWebViewMessages = ({ path: string; }>; if (files) { - consoleLog.current( - '[WebView] Received workspaceFiles:', - files.length, - ); + console.log('[WebView] Received workspaceFiles:', files.length); handlers.fileContext.setWorkspaceFiles(files); } break; diff --git a/packages/vscode-ide-companion/src/webview/utils/logger.ts b/packages/vscode-ide-companion/src/webview/utils/logger.ts deleted file mode 100644 index 40d1793e..00000000 --- a/packages/vscode-ide-companion/src/webview/utils/logger.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Creates a dev-only console logger for the WebView bundle. - * In production builds it becomes a no-op to avoid noisy logs. - */ -export function createWebviewConsoleLogger(scope?: string) { - return (...args: unknown[]) => { - const env = (globalThis as { process?: { env?: Record } }) - .process?.env; - const isProduction = env?.NODE_ENV === 'production'; - if (isProduction) { - return; - } - if (scope) { - console.log(`[${scope}]`, ...args); - return; - } - console.log(...args); - }; -} From 60a58ad8e5107293c67d45cdec04c4ce1d704b40 Mon Sep 17 00:00:00 2001 From: DragonnZhang <731557579@qq.com> Date: Fri, 12 Dec 2025 00:57:33 +0800 Subject: [PATCH 07/44] feat: add support for the channel field to CLI parameters and configurations --- packages/cli/src/config/config.ts | 13 +++++++++++++ packages/cli/src/gemini.test.tsx | 1 + packages/core/src/config/config.ts | 7 +++++++ .../provider/dashscope.ts | 3 +++ .../openaiContentGenerator/provider/types.ts | 1 + .../src/telemetry/qwen-logger/qwen-logger.ts | 3 +++ .../src/transport/ProcessTransport.ts | 1 + .../src/services/acpConnection.ts | 18 ++++++++++++++---- 8 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3212996d..ab4f087d 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -138,6 +138,7 @@ export interface CliArgs { coreTools: string[] | undefined; excludeTools: string[] | undefined; authType: string | undefined; + channel: string | undefined; } function normalizeOutputFormat( @@ -297,6 +298,11 @@ export async function parseArguments(settings: Settings): Promise { type: 'boolean', description: 'Starts the agent in ACP mode', }) + .option('channel', { + type: 'string', + choices: ['VSCode', 'ACP', 'SDK', 'CI'], + description: 'Channel identifier (VSCode, ACP, SDK, CI)', + }) .option('allowed-mcp-server-names', { type: 'array', string: true, @@ -559,6 +565,12 @@ export async function parseArguments(settings: Settings): Promise { // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument + + // Apply ACP fallback: if experimental-acp is present but no explicit --channel, treat as ACP + if (result['experimentalAcp'] && !result['channel']) { + (result as Record)['channel'] = 'ACP'; + } + return result as unknown as CliArgs; } @@ -983,6 +995,7 @@ export async function loadCliConfig( output: { format: outputSettingsFormat, }, + channel: argv.channel, }); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f602d17d..205a3d88 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -485,6 +485,7 @@ describe('gemini.tsx main function kitty protocol', () => { excludeTools: undefined, authType: undefined, maxSessionTurns: undefined, + channel: undefined, }); await main(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6383cb17..d0332f27 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -349,6 +349,7 @@ export interface ConfigParameters { skipStartupContext?: boolean; sdkMode?: boolean; sessionSubagents?: SubagentConfig[]; + channel?: string; } function normalizeConfigOutputFormat( @@ -485,6 +486,7 @@ export class Config { private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; + private readonly channel: string | undefined; constructor(params: ConfigParameters) { this.sessionId = params.sessionId ?? randomUUID(); @@ -598,6 +600,7 @@ export class Config { this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; this.useSmartEdit = params.useSmartEdit ?? false; this.extensionManagement = params.extensionManagement ?? true; + this.channel = params.channel; this.storage = new Storage(this.targetDir); this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; @@ -1144,6 +1147,10 @@ export class Config { return this.cliVersion; } + getChannel(): string | undefined { + return this.channel; + } + /** * Get the current FileSystemService */ diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index 2df72221..4a5b7748 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -130,10 +130,13 @@ export class DashScopeOpenAICompatibleProvider } buildMetadata(userPromptId: string): DashScopeRequestMetadata { + const channel = this.cliConfig.getChannel?.(); + return { metadata: { sessionId: this.cliConfig.getSessionId?.(), promptId: userPromptId, + ...(channel ? { channel } : {}), }, }; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/types.ts b/packages/core/src/core/openaiContentGenerator/provider/types.ts index ea7c434d..362ec69a 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/types.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/types.ts @@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = { metadata: { sessionId?: string; promptId: string; + channel?: string; }; }; diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index b6a97a2e..f0fb94f1 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -249,6 +249,9 @@ export class QwenLogger { authType === AuthType.USE_OPENAI ? this.config?.getContentGeneratorConfig().baseUrl || '' : '', + ...(this.config?.getChannel?.() + ? { channel: this.config.getChannel() } + : {}), }, _v: `qwen-code@${version}`, } as RumPayload; diff --git a/packages/sdk-typescript/src/transport/ProcessTransport.ts b/packages/sdk-typescript/src/transport/ProcessTransport.ts index c54d9104..43ff09da 100644 --- a/packages/sdk-typescript/src/transport/ProcessTransport.ts +++ b/packages/sdk-typescript/src/transport/ProcessTransport.ts @@ -139,6 +139,7 @@ export class ProcessTransport implements Transport { 'stream-json', '--output-format', 'stream-json', + '--channel=SDK', ]; if (this.options.model) { diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 5486e14d..f4c95948 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -94,7 +94,12 @@ export class AcpConnection { if (cliPath.startsWith('npx ')) { const parts = cliPath.split(' '); spawnCommand = isWindows ? 'npx.cmd' : 'npx'; - spawnArgs = [...parts.slice(1), '--experimental-acp', ...extraArgs]; + spawnArgs = [ + ...parts.slice(1), + '--experimental-acp', + '--channel=VSCode', + ...extraArgs, + ]; } else { // For qwen CLI, ensure we use the correct Node.js version // Handle various Node.js version managers (nvm, n, manual installations) @@ -103,11 +108,16 @@ export class AcpConnection { const nodePathResult = determineNodePathForCli(cliPath); if (nodePathResult.path) { spawnCommand = nodePathResult.path; - spawnArgs = [cliPath, '--experimental-acp', ...extraArgs]; + spawnArgs = [ + cliPath, + '--experimental-acp', + '--channel=VSCode', + ...extraArgs, + ]; } else { // Fallback to direct execution spawnCommand = cliPath; - spawnArgs = ['--experimental-acp', ...extraArgs]; + spawnArgs = ['--experimental-acp', '--channel=VSCode', ...extraArgs]; // Log any error for debugging if (nodePathResult.error) { @@ -118,7 +128,7 @@ export class AcpConnection { } } else { spawnCommand = cliPath; - spawnArgs = ['--experimental-acp', ...extraArgs]; + spawnArgs = ['--experimental-acp', '--channel=VSCode', ...extraArgs]; } } From 25261ab88d7e061fef2345899829f941be64541b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 12 Dec 2025 01:14:28 +0800 Subject: [PATCH 08/44] fix(vscode-ide-companion): slight delay to ensure auth state settlement --- .../src/services/authStateManager.ts | 36 ++- .../src/services/qwenAgentManager.ts | 1 + .../src/services/qwenConnectionHandler.ts | 6 +- .../src/webview/WebViewProvider.ts | 4 +- .../webview/handlers/SessionMessageHandler.ts | 221 +++++------------- 5 files changed, 93 insertions(+), 175 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts index 824a642c..e56f9fdf 100644 --- a/packages/vscode-ide-companion/src/services/authStateManager.ts +++ b/packages/vscode-ide-companion/src/services/authStateManager.ts @@ -21,8 +21,8 @@ export class AuthStateManager { private static context: vscode.ExtensionContext | null = null; private static readonly AUTH_STATE_KEY = 'qwen.authState'; private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - // Deduplicate concurrent auth flows (e.g., multiple tabs prompting login) - private static authFlowInFlight: Promise | null = null; + // Deduplicate concurrent auth processes (e.g., multiple tabs prompting login) + private static authProcessInFlight: Promise | null = null; private constructor() {} /** @@ -42,24 +42,38 @@ export class AuthStateManager { } /** - * Run an auth-related flow exclusively. If another flow is already running, - * return the same promise to prevent duplicate login prompts. + * Run an auth-related flow with optional queueing. + * - 默认:复用在跑的 promise,避免重复弹窗。 + * - forceNew: true 时,等待当前 flow 结束后再串行启动新的,用于强制重登。 */ - static runExclusiveAuth(task: () => Promise): Promise { - if (AuthStateManager.authFlowInFlight) { - return AuthStateManager.authFlowInFlight as Promise; + static runExclusiveAuth( + task: () => Promise, + options?: { forceNew?: boolean }, + ): Promise { + if (AuthStateManager.authProcessInFlight) { + if (!options?.forceNew) { + return AuthStateManager.authProcessInFlight as Promise; + } + // queue a new flow after current finishes + const next = AuthStateManager.authProcessInFlight + .catch(() => { + /* ignore previous failure for next run */ + }) + .then(() => + AuthStateManager.runExclusiveAuth(task, { forceNew: false }), + ); + return next as Promise; } const p = Promise.resolve() .then(task) .finally(() => { - // Clear only if this promise is still the active one - if (AuthStateManager.authFlowInFlight === p) { - AuthStateManager.authFlowInFlight = null; + if (AuthStateManager.authProcessInFlight === p) { + AuthStateManager.authProcessInFlight = null; } }); - AuthStateManager.authFlowInFlight = p; + AuthStateManager.authProcessInFlight = p; return p as Promise; } diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 89214139..7851d926 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1222,6 +1222,7 @@ export class QwenAgentManager { if (effectiveAuth) { await effectiveAuth.saveAuthState(workingDir, authMethod); } + await setTimeout(() => Promise.resolve(), 100); // slight delay to ensure auth state is settled await this.connection.newSession(workingDir); } catch (reauthErr) { // Clear potentially stale cache on failure and rethrow diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 11e7199a..9e2f5a81 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -119,11 +119,6 @@ export class QwenConnectionHandler { // Save auth state if (authStateManager) { - console.log( - '[QwenAgentManager] Saving auth state after successful authentication', - ); - console.log('[QwenAgentManager] Working dir for save:', workingDir); - console.log('[QwenAgentManager] Auth method for save:', authMethod); await authStateManager.saveAuthState(workingDir, authMethod); console.log('[QwenAgentManager] Auth state save completed'); } @@ -145,6 +140,7 @@ export class QwenConnectionHandler { } try { + await setTimeout(() => Promise.resolve(), 100); // slight delay to ensure auth state is settled console.log( '[QwenAgentManager] Creating new session after authentication...', ); diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index faa81226..bfa9a567 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -134,7 +134,7 @@ export class WebViewProvider { // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager // and sent via onStreamChunk callback this.agentManager.onToolCall((update) => { - // Always surface tool calls; they are part of the live assistant flow. + // Always surface tool calls; they are part of the live assistant process. // Cast update to access sessionUpdate property const updateData = update as unknown as Record; @@ -673,7 +673,7 @@ export class WebViewProvider { !!this.authStateManager, ); - // If a login/connection flow is already running, reuse it to avoid double prompts + // If a login/connection process is already running, reuse it to avoid double prompts const p = Promise.resolve( vscode.window.withProgress( { diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index a46febcd..0df3e0da 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -149,6 +149,50 @@ export class SessionMessageHandler extends BaseMessageHandler { return this.isSavingCheckpoint; } + /** + * Prompt user to login and invoke the registered login handler/command. + * Returns true if a login was initiated. + */ + private async promptLogin(message: string): Promise { + const result = await vscode.window.showWarningMessage(message, 'Login Now'); + if (result === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + return true; + } + return false; + } + + /** + * Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'. + * When login is chosen, it triggers the login handler/command. + */ + private async promptLoginOrOffline( + message: string, + ): Promise<'login' | 'offline' | 'dismiss'> { + const selection = await vscode.window.showWarningMessage( + message, + 'Login Now', + 'View Offline', + ); + + if (selection === 'Login Now') { + if (this.loginHandler) { + await this.loginHandler(); + } else { + await vscode.commands.executeCommand('qwen-code.login'); + } + return 'login'; + } + if (selection === 'View Offline') { + return 'offline'; + } + return 'dismiss'; + } + /** * Handle send message request */ @@ -271,23 +315,7 @@ export class SessionMessageHandler extends BaseMessageHandler { console.warn('[SessionMessageHandler] Agent not connected'); // Show non-modal notification with Login button - const result = await vscode.window.showWarningMessage( - 'You need to login first to use Qwen Code.', - 'Login Now', - ); - - if (result === 'Login Now') { - // Use login handler directly - if (this.loginHandler) { - await this.loginHandler(); - } else { - // Fallback to command - vscode.window.showInformationMessage( - 'Please wait while we connect to Qwen Code...', - ); - await vscode.commands.executeCommand('qwen-code.login'); - } - } + await this.promptLogin('You need to login first to use Qwen Code.'); return; } @@ -308,17 +336,9 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('Authentication required') || errorMsg.includes('(code: -32000)') ) { - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } return; } vscode.window.showErrorMessage(`Failed to create session: ${errorMsg}`); @@ -426,19 +446,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('Invalid token') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -463,17 +474,10 @@ export class SessionMessageHandler extends BaseMessageHandler { // Ensure connection (login) before creating a new session if (!this.agentManager.isConnected) { - const result = await vscode.window.showWarningMessage( + const proceeded = await this.promptLogin( 'You need to login before creating a new session.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } else { + if (!proceeded) { return; } } @@ -524,19 +528,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to create a new session.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -560,19 +555,11 @@ export class SessionMessageHandler extends BaseMessageHandler { // If not connected yet, offer to login or view offline if (!this.agentManager.isConnected) { - const selection = await vscode.window.showWarningMessage( + const choice = await this.promptLoginOrOffline( 'You are not logged in. Login now to fully restore this session, or view it offline.', - 'Login Now', - 'View Offline', ); - if (selection === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } else if (selection === 'View Offline') { + if (choice === 'offline') { // Show messages from local cache only const messages = await this.agentManager.getSessionMessages(sessionId); @@ -585,7 +572,7 @@ export class SessionMessageHandler extends BaseMessageHandler { 'Showing cached session content. Login to interact with the AI.', ); return; - } else { + } else if (choice !== 'login') { // User dismissed; do nothing return; } @@ -672,19 +659,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to switch sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -741,19 +719,10 @@ export class SessionMessageHandler extends BaseMessageHandler { createErrorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to switch sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -790,19 +759,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to switch sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -854,19 +814,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to view sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -918,19 +869,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to save sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -966,19 +908,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to save sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -1031,19 +964,11 @@ export class SessionMessageHandler extends BaseMessageHandler { try { // If not connected, offer to login or view offline if (!this.agentManager.isConnected) { - const selection = await vscode.window.showWarningMessage( + const choice = await this.promptLoginOrOffline( 'You are not logged in. Login now to fully restore this session, or view it offline.', - 'Login Now', - 'View Offline', ); - if (selection === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } else if (selection === 'View Offline') { + if (choice === 'offline') { const messages = await this.agentManager.getSessionMessages(sessionId); this.currentConversationId = sessionId; @@ -1055,7 +980,7 @@ export class SessionMessageHandler extends BaseMessageHandler { 'Showing cached session content. Login to interact with the AI.', ); return; - } else { + } else if (choice !== 'login') { return; } } @@ -1089,19 +1014,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to resume sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', @@ -1140,19 +1056,10 @@ export class SessionMessageHandler extends BaseMessageHandler { errorMsg.includes('No active ACP session') ) { // Show a more user-friendly error message for expired sessions - const result = await vscode.window.showWarningMessage( + await this.promptLogin( 'Your login session has expired or is invalid. Please login again to resume sessions.', - 'Login Now', ); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); - } else { - await vscode.commands.executeCommand('qwen-code.login'); - } - } - // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', From 02234f5434228dbc781d42c4cc65abafe5fbb2c4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 12 Dec 2025 01:17:38 +0800 Subject: [PATCH 09/44] chore(vscode-ide-companion): change comments and delays --- .../vscode-ide-companion/src/services/authStateManager.ts | 4 ++-- .../vscode-ide-companion/src/services/qwenAgentManager.ts | 2 +- .../src/services/qwenConnectionHandler.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts index e56f9fdf..c46bcb83 100644 --- a/packages/vscode-ide-companion/src/services/authStateManager.ts +++ b/packages/vscode-ide-companion/src/services/authStateManager.ts @@ -43,8 +43,8 @@ export class AuthStateManager { /** * Run an auth-related flow with optional queueing. - * - 默认:复用在跑的 promise,避免重复弹窗。 - * - forceNew: true 时,等待当前 flow 结束后再串行启动新的,用于强制重登。 + * - Default: Reuse existing promise to avoid duplicate popups. + * - When forceNew: true, wait for current flow to finish before starting a new one serially, used for forced re-login. */ static runExclusiveAuth( task: () => Promise, diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 7851d926..dd0712b7 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -1222,7 +1222,7 @@ export class QwenAgentManager { if (effectiveAuth) { await effectiveAuth.saveAuthState(workingDir, authMethod); } - await setTimeout(() => Promise.resolve(), 100); // slight delay to ensure auth state is settled + await setTimeout(() => Promise.resolve(), 300); // slight delay to ensure auth state is settled await this.connection.newSession(workingDir); } catch (reauthErr) { // Clear potentially stale cache on failure and rethrow diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 9e2f5a81..278032a2 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -140,7 +140,7 @@ export class QwenConnectionHandler { } try { - await setTimeout(() => Promise.resolve(), 100); // slight delay to ensure auth state is settled + await setTimeout(() => Promise.resolve(), 300); // slight delay to ensure auth state is settled console.log( '[QwenAgentManager] Creating new session after authentication...', ); From bb8447edd7c9b48e9d9902a03121ef6650b795f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Fri, 12 Dec 2025 11:36:15 +0800 Subject: [PATCH 10/44] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9C=A8docker?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E4=B8=AD=E6=97=A0=E6=B3=95=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?ide=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vscode-ide-companion/src/ide-server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 8324f802..4905a941 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -164,6 +164,7 @@ export class IDEServer { const allowedHosts = [ `localhost:${this.port}`, `127.0.0.1:${this.port}`, + `host.docker.internal:${this.port}`, // 添加Docker支持 ]; if (!allowedHosts.includes(host)) { return res.status(403).json({ error: 'Invalid Host header' }); From d754767e733de62d865b9a4acf8d0c82335747e3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 12 Dec 2025 13:40:18 +0800 Subject: [PATCH 11/44] chore(vscode-ide-companion): rm authState manager in vscode-ide-companion to simplify the login architecture --- .../src/services/acpConnection.ts | 25 +- .../src/services/authStateManager.ts | 253 ------------------ .../src/services/qwenAgentManager.ts | 43 +-- .../src/services/qwenConnectionHandler.ts | 94 +------ .../src/webview/WebViewProvider.ts | 246 ++++++----------- 5 files changed, 102 insertions(+), 559 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/services/authStateManager.ts diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index a9992de4..9fd548cc 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -32,8 +32,6 @@ export class AcpConnection { private pendingRequests = new Map>(); private nextRequestId = { value: 0 }; - // Deduplicate concurrent authenticate calls (across retry paths) - private static authInFlight: Promise | null = null; // Remember the working dir provided at connect() so later ACP calls // that require cwd (e.g. session/list) can include it. private workingDir: string = process.cwd(); @@ -274,23 +272,12 @@ export class AcpConnection { * @returns Authentication response */ async authenticate(methodId?: string): Promise { - if (AcpConnection.authInFlight) { - return AcpConnection.authInFlight; - } - - const p = this.sessionManager - .authenticate( - methodId, - this.child, - this.pendingRequests, - this.nextRequestId, - ) - .finally(() => { - AcpConnection.authInFlight = null; - }); - - AcpConnection.authInFlight = p; - return p; + return this.sessionManager.authenticate( + methodId, + this.child, + this.pendingRequests, + this.nextRequestId, + ); } /** diff --git a/packages/vscode-ide-companion/src/services/authStateManager.ts b/packages/vscode-ide-companion/src/services/authStateManager.ts deleted file mode 100644 index c46bcb83..00000000 --- a/packages/vscode-ide-companion/src/services/authStateManager.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type * as vscode from 'vscode'; - -interface AuthState { - isAuthenticated: boolean; - authMethod: string; - timestamp: number; - workingDir?: string; -} - -/** - * Manages authentication state caching to avoid repeated logins - */ -export class AuthStateManager { - private static instance: AuthStateManager | null = null; - private static context: vscode.ExtensionContext | null = null; - private static readonly AUTH_STATE_KEY = 'qwen.authState'; - private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - // Deduplicate concurrent auth processes (e.g., multiple tabs prompting login) - private static authProcessInFlight: Promise | null = null; - private constructor() {} - - /** - * Get singleton instance of AuthStateManager - */ - static getInstance(context?: vscode.ExtensionContext): AuthStateManager { - if (!AuthStateManager.instance) { - AuthStateManager.instance = new AuthStateManager(); - } - - // If a context is provided, update the static context - if (context) { - AuthStateManager.context = context; - } - - return AuthStateManager.instance; - } - - /** - * Run an auth-related flow with optional queueing. - * - Default: Reuse existing promise to avoid duplicate popups. - * - When forceNew: true, wait for current flow to finish before starting a new one serially, used for forced re-login. - */ - static runExclusiveAuth( - task: () => Promise, - options?: { forceNew?: boolean }, - ): Promise { - if (AuthStateManager.authProcessInFlight) { - if (!options?.forceNew) { - return AuthStateManager.authProcessInFlight as Promise; - } - // queue a new flow after current finishes - const next = AuthStateManager.authProcessInFlight - .catch(() => { - /* ignore previous failure for next run */ - }) - .then(() => - AuthStateManager.runExclusiveAuth(task, { forceNew: false }), - ); - return next as Promise; - } - - const p = Promise.resolve() - .then(task) - .finally(() => { - if (AuthStateManager.authProcessInFlight === p) { - AuthStateManager.authProcessInFlight = null; - } - }); - - AuthStateManager.authProcessInFlight = p; - return p as Promise; - } - - /** - * Check if there's a valid cached authentication - */ - async hasValidAuth(workingDir: string, authMethod: string): Promise { - const state = await this.getAuthState(); - - if (!state) { - console.log('[AuthStateManager] No cached auth state found'); - return false; - } - - console.log('[AuthStateManager] Found cached auth state:', { - workingDir: state.workingDir, - authMethod: state.authMethod, - timestamp: new Date(state.timestamp).toISOString(), - isAuthenticated: state.isAuthenticated, - }); - console.log('[AuthStateManager] Checking against:', { - workingDir, - authMethod, - }); - - // Check if auth is still valid (within cache duration) - const now = Date.now(); - const isExpired = - now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; - - if (isExpired) { - console.log('[AuthStateManager] Cached auth expired'); - console.log( - '[AuthStateManager] Cache age:', - Math.floor((now - state.timestamp) / 1000 / 60), - 'minutes', - ); - await this.clearAuthState(); - return false; - } - - // Check if it's for the same working directory and auth method - const isSameContext = - state.workingDir === workingDir && state.authMethod === authMethod; - - if (!isSameContext) { - console.log('[AuthStateManager] Working dir or auth method changed'); - console.log('[AuthStateManager] Cached workingDir:', state.workingDir); - console.log('[AuthStateManager] Current workingDir:', workingDir); - console.log('[AuthStateManager] Cached authMethod:', state.authMethod); - console.log('[AuthStateManager] Current authMethod:', authMethod); - return false; - } - - console.log('[AuthStateManager] Valid cached auth found'); - return state.isAuthenticated; - } - - /** - * Force check auth state without clearing cache - * This is useful for debugging to see what's actually cached - */ - async debugAuthState(): Promise { - const state = await this.getAuthState(); - console.log('[AuthStateManager] DEBUG - Current auth state:', state); - - if (state) { - const now = Date.now(); - const age = Math.floor((now - state.timestamp) / 1000 / 60); - const isExpired = - now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION; - - console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes'); - console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired); - console.log( - '[AuthStateManager] DEBUG - Auth state valid:', - state.isAuthenticated, - ); - } - } - - /** - * Save successful authentication state - */ - async saveAuthState(workingDir: string, authMethod: string): Promise { - // Ensure we have a valid context - if (!AuthStateManager.context) { - throw new Error( - '[AuthStateManager] No context available for saving auth state', - ); - } - - const state: AuthState = { - isAuthenticated: true, - authMethod, - workingDir, - timestamp: Date.now(), - }; - - console.log('[AuthStateManager] Saving auth state:', { - workingDir, - authMethod, - timestamp: new Date(state.timestamp).toISOString(), - }); - - await AuthStateManager.context.globalState.update( - AuthStateManager.AUTH_STATE_KEY, - state, - ); - console.log('[AuthStateManager] Auth state saved'); - - // Verify the state was saved correctly - const savedState = await this.getAuthState(); - console.log('[AuthStateManager] Verified saved state:', savedState); - } - - /** - * Clear authentication state - */ - async clearAuthState(): Promise { - // Ensure we have a valid context - if (!AuthStateManager.context) { - throw new Error( - '[AuthStateManager] No context available for clearing auth state', - ); - } - - console.log('[AuthStateManager] Clearing auth state'); - const currentState = await this.getAuthState(); - console.log( - '[AuthStateManager] Current state before clearing:', - currentState, - ); - - await AuthStateManager.context.globalState.update( - AuthStateManager.AUTH_STATE_KEY, - undefined, - ); - console.log('[AuthStateManager] Auth state cleared'); - - // Verify the state was cleared - const newState = await this.getAuthState(); - console.log('[AuthStateManager] State after clearing:', newState); - } - - /** - * Get current auth state - */ - private async getAuthState(): Promise { - // Ensure we have a valid context - if (!AuthStateManager.context) { - console.log( - '[AuthStateManager] No context available for getting auth state', - ); - return undefined; - } - - const a = AuthStateManager.context.globalState.get( - AuthStateManager.AUTH_STATE_KEY, - ); - console.log('[AuthStateManager] Auth state:', a); - return a; - } - - /** - * Get auth state info for debugging - */ - async getAuthInfo(): Promise { - const state = await this.getAuthState(); - if (!state) { - return 'No cached auth'; - } - - const age = Math.floor((Date.now() - state.timestamp) / 1000 / 60); - return `Auth cached ${age}m ago, method: ${state.authMethod}`; - } -} diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index dd0712b7..5ddd5612 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -11,7 +11,6 @@ import type { } from '../types/acpTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionManager } from './qwenSessionManager.js'; -import type { AuthStateManager } from './authStateManager.js'; import type { ChatMessage, PlanEntry, @@ -42,9 +41,7 @@ export class QwenAgentManager { // session/update notifications. We set this flag to route message chunks // (user/assistant) as discrete chat messages instead of live streaming. private rehydratingSessionId: string | null = null; - // Cache the last used AuthStateManager so internal calls (e.g. fallback paths) - // can reuse it and avoid forcing a fresh authentication unnecessarily. - private defaultAuthStateManager?: AuthStateManager; + // CLI is now the single source of truth for authentication state // Deduplicate concurrent session/new attempts private sessionCreateInFlight: Promise | null = null; @@ -165,22 +162,14 @@ export class QwenAgentManager { * Connect to Qwen service * * @param workingDir - Working directory - * @param authStateManager - Authentication state manager (optional) * @param cliPath - CLI path (optional, if provided will override the path in configuration) */ - async connect( - workingDir: string, - authStateManager?: AuthStateManager, - _cliPath?: string, - ): Promise { + async connect(workingDir: string, _cliPath?: string): Promise { this.currentWorkingDir = workingDir; - // Remember the provided authStateManager for future calls - this.defaultAuthStateManager = authStateManager; await this.connectionHandler.connect( this.connection, this.sessionReader, workingDir, - authStateManager, _cliPath, ); } @@ -1181,10 +1170,7 @@ export class QwenAgentManager { * @param workingDir - Working directory * @returns Newly created session ID */ - async createNewSession( - workingDir: string, - authStateManager?: AuthStateManager, - ): Promise { + async createNewSession(workingDir: string): Promise { // Reuse existing session if present if (this.connection.currentSessionId) { return this.connection.currentSessionId; @@ -1195,15 +1181,10 @@ export class QwenAgentManager { } console.log('[QwenAgentManager] Creating new session...'); - // Prefer the provided authStateManager, otherwise fall back to the one - // remembered during connect(). This prevents accidental re-auth in - // fallback paths (e.g. session switching) when the handler didn't pass it. - const effectiveAuth = authStateManager || this.defaultAuthStateManager; this.sessionCreateInFlight = (async () => { try { - // Try to create a new ACP session. If Qwen asks for auth despite our - // cached flag (e.g. fresh process or expired tokens), re-authenticate and retry. + // Try to create a new ACP session. If Qwen asks for auth, let it handle authentication. try { await this.connection.newSession(workingDir); } catch (err) { @@ -1217,18 +1198,16 @@ export class QwenAgentManager { '[QwenAgentManager] session/new requires authentication. Retrying with authenticate...', ); try { + // Let CLI handle authentication - it's the single source of truth await this.connection.authenticate(authMethod); - // Persist auth cache so subsequent calls can skip the web flow. - if (effectiveAuth) { - await effectiveAuth.saveAuthState(workingDir, authMethod); - } - await setTimeout(() => Promise.resolve(), 300); // slight delay to ensure auth state is settled + // Add a slight delay to ensure auth state is settled + await new Promise((resolve) => setTimeout(resolve, 300)); await this.connection.newSession(workingDir); } catch (reauthErr) { - // Clear potentially stale cache on failure and rethrow - if (effectiveAuth) { - await effectiveAuth.clearAuthState(); - } + console.error( + '[QwenAgentManager] Re-authentication failed:', + reauthErr, + ); throw reauthErr; } } else { diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 278032a2..e8b6a978 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -13,7 +13,6 @@ import * as vscode from 'vscode'; import type { AcpConnection } from './acpConnection.js'; import type { QwenSessionReader } from '../services/qwenSessionReader.js'; -import type { AuthStateManager } from '../services/authStateManager.js'; import { CliVersionManager, MIN_CLI_VERSION_FOR_SESSION_METHODS, @@ -32,14 +31,12 @@ export class QwenConnectionHandler { * @param connection - ACP connection instance * @param sessionReader - Session reader instance * @param workingDir - Working directory - * @param authStateManager - Authentication state manager (optional) * @param cliPath - CLI path (optional, if provided will override the path in configuration) */ async connect( connection: AcpConnection, sessionReader: QwenSessionReader, workingDir: string, - authStateManager?: AuthStateManager, cliPath?: string, ): Promise { const connectId = Date.now(); @@ -72,21 +69,6 @@ export class QwenConnectionHandler { await connection.connect(effectiveCliPath, workingDir, extraArgs); - // Check if we have valid cached authentication - if (authStateManager) { - console.log('[QwenAgentManager] Checking for cached authentication...'); - console.log('[QwenAgentManager] Working dir:', workingDir); - console.log('[QwenAgentManager] Auth method:', authMethod); - - const hasValidAuth = await authStateManager.hasValidAuth( - workingDir, - authMethod, - ); - console.log('[QwenAgentManager] Has valid auth:', hasValidAuth); - } else { - console.log('[QwenAgentManager] No authStateManager provided'); - } - // Try to restore existing session or create new session // Note: Auto-restore on connect is disabled to avoid surprising loads // when user opens a "New Chat" tab. Restoration is now an explicit action @@ -99,77 +81,15 @@ export class QwenConnectionHandler { '[QwenAgentManager] no sessionRestored, Creating new session...', ); - // Check if we have valid cached authentication - let hasValidAuth = false; - if (authStateManager) { - hasValidAuth = await authStateManager.hasValidAuth( - workingDir, - authMethod, - ); - } - - // Only authenticate if we don't have valid cached auth - if (!hasValidAuth) { - console.log( - '[QwenAgentManager] Authenticating before creating session...', - ); - try { - await connection.authenticate(authMethod); - console.log('[QwenAgentManager] Authentication successful'); - - // Save auth state - if (authStateManager) { - await authStateManager.saveAuthState(workingDir, authMethod); - console.log('[QwenAgentManager] Auth state save completed'); - } - } catch (authError) { - console.error('[QwenAgentManager] Authentication failed:', authError); - // Clear potentially invalid cache - if (authStateManager) { - console.log( - '[QwenAgentManager] Clearing auth cache due to authentication failure', - ); - await authStateManager.clearAuthState(); - } - throw authError; - } - } else { - console.log( - '[QwenAgentManager] Skipping authentication - using valid cached auth', - ); - } - try { - await setTimeout(() => Promise.resolve(), 300); // slight delay to ensure auth state is settled console.log( - '[QwenAgentManager] Creating new session after authentication...', - ); - await this.newSessionWithRetry( - connection, - workingDir, - 3, - authMethod, - authStateManager, + '[QwenAgentManager] Creating new session (letting CLI handle authentication)...', ); + await this.newSessionWithRetry(connection, workingDir, 3, authMethod); console.log('[QwenAgentManager] New session created successfully'); - - // Ensure auth state is saved (prevent repeated authentication) - if (authStateManager) { - console.log( - '[QwenAgentManager] Saving auth state after successful session creation', - ); - await authStateManager.saveAuthState(workingDir, authMethod); - } } catch (sessionError) { console.log(`\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`); console.log(`[QwenAgentManager] Error details:`, sessionError); - - // Clear cache - if (authStateManager) { - console.log('[QwenAgentManager] Clearing auth cache due to failure'); - await authStateManager.clearAuthState(); - } - throw sessionError; } } @@ -191,7 +111,6 @@ export class QwenConnectionHandler { workingDir: string, maxRetries: number, authMethod: string, - authStateManager?: AuthStateManager, ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -219,10 +138,10 @@ export class QwenConnectionHandler { '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', ); try { + // Let CLI handle authentication - it's the single source of truth await connection.authenticate(authMethod); - if (authStateManager) { - await authStateManager.saveAuthState(workingDir, authMethod); - } + // Add a slight delay to ensure auth state is settled + await new Promise((resolve) => setTimeout(resolve, 300)); // Retry immediately after successful auth await connection.newSession(workingDir); console.log( @@ -234,9 +153,6 @@ export class QwenConnectionHandler { '[QwenAgentManager] Re-authentication failed:', authErr, ); - if (authStateManager) { - await authStateManager.clearAuthState(); - } // Fall through to retry logic below } } diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index bfa9a567..12b5a99c 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -9,20 +9,18 @@ import { QwenAgentManager } from '../services/qwenAgentManager.js'; import { ConversationStore } from '../services/conversationStore.js'; import type { AcpPermissionRequest } from '../types/acpTypes.js'; import { CliDetector } from '../cli/cliDetector.js'; -import { AuthStateManager } from '../services/authStateManager.js'; import { PanelManager } from '../webview/PanelManager.js'; import { MessageHandler } from '../webview/MessageHandler.js'; import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; -import { authMethod, type ApprovalModeValue } from '../types/acpTypes.js'; +import { type ApprovalModeValue } from '../types/acpTypes.js'; export class WebViewProvider { private panelManager: PanelManager; private messageHandler: MessageHandler; private agentManager: QwenAgentManager; private conversationStore: ConversationStore; - 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 @@ -39,7 +37,6 @@ export class WebViewProvider { ) { this.agentManager = new QwenAgentManager(); this.conversationStore = new ConversationStore(context); - this.authStateManager = AuthStateManager.getInstance(context); this.panelManager = new PanelManager(extensionUri, () => { // Panel dispose callback this.disposables.forEach((d) => d.dispose()); @@ -519,43 +516,21 @@ export class WebViewProvider { /** * Attempt to restore authentication state and initialize connection * This is called when the webview is first shown + * + * In the new architecture, let CLI handle authentication state management */ private async attemptAuthStateRestoration(): Promise { try { - if (this.authStateManager) { - // Debug current auth state - await this.authStateManager.debugAuthState(); - - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); - const hasValidAuth = await this.authStateManager.hasValidAuth( - workingDir, - authMethod, - ); - console.log('[WebViewProvider] Has valid cached auth:', hasValidAuth); - - if (hasValidAuth) { - console.log( - '[WebViewProvider] Valid auth found, attempting connection...', - ); - // Try to connect with cached auth - await this.initializeAgentConnection(); - } else { - console.log( - '[WebViewProvider] No valid auth found, rendering empty conversation', - ); - // Render the chat UI immediately without connecting - await this.initializeEmptyConversation(); - } - } else { - console.log( - '[WebViewProvider] No auth state manager, rendering empty conversation', - ); - await this.initializeEmptyConversation(); - } - } catch (_error) { - console.error('[WebViewProvider] Auth state restoration failed:', _error); - // Fallback to rendering empty conversation + console.log( + '[WebViewProvider] Attempting connection (letting CLI handle authentication)...', + ); + // In the new architecture, always attempt connection and let CLI handle authentication + await this.initializeAgentConnection(); + } catch (error) { + console.error( + '[WebViewProvider] Error in attemptAuthStateRestoration:', + error, + ); await this.initializeEmptyConversation(); } } @@ -565,9 +540,8 @@ export class WebViewProvider { * Can be called from show() or via /login command */ async initializeAgentConnection(): Promise { - return AuthStateManager.runExclusiveAuth(() => - this.doInitializeAgentConnection(), - ); + // In the new architecture, let CLI handle authentication without local state caching + return this.doInitializeAgentConnection(); } /** @@ -582,10 +556,7 @@ export class WebViewProvider { '[WebViewProvider] Starting initialization, workingDir:', workingDir, ); - console.log( - '[WebViewProvider] AuthStateManager available:', - !!this.authStateManager, - ); + console.log('[WebViewProvider] Using CLI-managed authentication'); // Check if CLI is installed before attempting to connect const cliDetection = await CliDetector.detectQwenCli(); @@ -613,19 +584,10 @@ export class WebViewProvider { try { console.log('[WebViewProvider] Connecting to agent...'); - console.log( - '[WebViewProvider] Using authStateManager:', - !!this.authStateManager, - ); - const authInfo = await this.authStateManager.getAuthInfo(); - console.log('[WebViewProvider] Auth cache status:', authInfo); // Pass the detected CLI path to ensure we use the correct installation - await this.agentManager.connect( - workingDir, - this.authStateManager, - cliDetection.cliPath, - ); + // In the new architecture, let CLI handle authentication without local state caching + await this.agentManager.connect(workingDir, cliDetection.cliPath); console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; @@ -639,8 +601,6 @@ export class WebViewProvider { }); } 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.`, ); @@ -668,91 +628,65 @@ export class WebViewProvider { */ async forceReLogin(): Promise { console.log('[WebViewProvider] Force re-login requested'); - console.log( - '[WebViewProvider] Current authStateManager:', - !!this.authStateManager, - ); - // If a login/connection process is already running, reuse it to avoid double prompts - const p = Promise.resolve( - vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - cancellable: false, - }, - async (progress) => { - try { - progress.report({ message: 'Preparing sign-in...' }); + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Logging in to Qwen Code... ', + cancellable: false, + }, + async (progress) => { + try { + progress.report({ message: 'Preparing sign-in...' }); - // Clear existing auth cache - if (this.authStateManager) { - await this.authStateManager.clearAuthState(); - console.log('[WebViewProvider] Auth cache cleared'); - } else { - console.log('[WebViewProvider] No authStateManager to clear'); + // Disconnect existing connection if any + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + console.log('[WebViewProvider] Existing connection disconnected'); + } catch (_error) { + console.log('[WebViewProvider] Error disconnecting:', _error); } - - // Disconnect existing connection if any - if (this.agentInitialized) { - try { - this.agentManager.disconnect(); - console.log( - '[WebViewProvider] Existing connection disconnected', - ); - } catch (_error) { - console.log('[WebViewProvider] Error disconnecting:', _error); - } - this.agentInitialized = false; - } - - // Wait a moment for cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 300)); - - progress.report({ - message: 'Connecting to CLI and starting sign-in...', - }); - - // Reinitialize connection (will trigger fresh authentication) - await this.doInitializeAgentConnection(); - console.log( - '[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', - data: { message: 'Successfully logged in!' }, - }); - } catch (_error) { - console.error('[WebViewProvider] Force re-login failed:', _error); - console.error( - '[WebViewProvider] Error stack:', - _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)}`, - }, - }); - - throw _error; + this.agentInitialized = false; } - }, - ), - ); - return AuthStateManager.runExclusiveAuth(() => p); + // Wait a moment for cleanup to complete + await new Promise((resolve) => setTimeout(resolve, 300)); + + progress.report({ + message: 'Connecting to CLI and starting sign-in...', + }); + + // Reinitialize connection (will trigger fresh authentication) + await this.doInitializeAgentConnection(); + console.log( + '[WebViewProvider] Force re-login completed successfully', + ); + + // Send success notification to WebView + this.sendMessageToWebView({ + type: 'loginSuccess', + data: { message: 'Successfully logged in!' }, + }); + } catch (_error) { + console.error('[WebViewProvider] Force re-login failed:', _error); + console.error( + '[WebViewProvider] Error stack:', + _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)}`, + }, + }); + + throw _error; + } + }, + ); } /** @@ -819,19 +753,14 @@ export class WebViewProvider { // avoid creating another session if connect() already created one. if (!this.agentManager.currentSessionId) { try { - await this.agentManager.createNewSession( - workingDir, - this.authStateManager, - ); + await this.agentManager.createNewSession(workingDir); console.log('[WebViewProvider] ACP session created successfully'); - // Ensure auth state is saved after successful session creation - if (this.authStateManager) { - await this.authStateManager.saveAuthState(workingDir, authMethod); - console.log( - '[WebViewProvider] Auth state saved after session creation', - ); - } + // In the new architecture, CLI handles authentication state + // No need to save auth state locally anymore + console.log( + '[WebViewProvider] Session created successfully (CLI manages auth state)', + ); } catch (sessionError) { console.error( '[WebViewProvider] Failed to create ACP session:', @@ -1003,17 +932,6 @@ export class WebViewProvider { this.agentManager.disconnect(); } - /** - * Clear authentication cache for this WebViewProvider instance - */ - async clearAuthCache(): Promise { - console.log('[WebViewProvider] Clearing auth cache for this instance'); - if (this.authStateManager) { - await this.authStateManager.clearAuthState(); - this.resetAgentState(); - } - } - /** * Restore an existing WebView panel (called during VSCode restart) * This sets up the panel with all event listeners @@ -1021,8 +939,7 @@ export class WebViewProvider { async restorePanel(panel: vscode.WebviewPanel): Promise { console.log('[WebViewProvider] Restoring WebView panel'); console.log( - '[WebViewProvider] Current authStateManager in restore:', - !!this.authStateManager, + '[WebViewProvider] Using CLI-managed authentication in restore', ); this.panelManager.setPanel(panel); @@ -1225,10 +1142,7 @@ export class WebViewProvider { const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); // Create new Qwen session via agent manager - await this.agentManager.createNewSession( - workingDir, - this.authStateManager, - ); + await this.agentManager.createNewSession(workingDir); // Clear current conversation UI this.sendMessageToWebView({ From d812c9dcf22aa0572c09d62cde9aac49f7ae708b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 12 Dec 2025 13:51:14 +0800 Subject: [PATCH 12/44] chore(vscode-ide-companion): add fixme comment for auth delay --- .../vscode-ide-companion/src/services/qwenConnectionHandler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index e8b6a978..78731734 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -140,6 +140,8 @@ export class QwenConnectionHandler { try { // Let CLI handle authentication - it's the single source of truth await connection.authenticate(authMethod); + // FIXME: @yiliang114 If there is no delay for a while, immediately executing + // newSession may cause the cli authorization jump to be triggered again // Add a slight delay to ensure auth state is settled await new Promise((resolve) => setTimeout(resolve, 300)); // Retry immediately after successful auth From 89be6edb5ec7eab1a83443b4af2f62827bd0e6be Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 12 Dec 2025 13:59:05 +0800 Subject: [PATCH 13/44] chore(vscode-ide-companion): add comment --- .../src/services/acpConnection.ts | 1 - .../src/services/qwenConnectionHandler.ts | 1 - .../src/webview/WebViewProvider.ts | 16 +++------------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 9fd548cc..5486e14d 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -31,7 +31,6 @@ export class AcpConnection { private child: ChildProcess | null = null; private pendingRequests = new Map>(); private nextRequestId = { value: 0 }; - // Remember the working dir provided at connect() so later ACP calls // that require cwd (e.g. session/list) can include it. private workingDir: string = process.cwd(); diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 78731734..6a74cd56 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -138,7 +138,6 @@ export class QwenConnectionHandler { '[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...', ); try { - // Let CLI handle authentication - it's the single source of truth await connection.authenticate(authMethod); // FIXME: @yiliang114 If there is no delay for a while, immediately executing // newSession may cause the cli authorization jump to be triggered again diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 12b5a99c..b4da60ab 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -131,7 +131,7 @@ export class WebViewProvider { // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager // and sent via onStreamChunk callback this.agentManager.onToolCall((update) => { - // Always surface tool calls; they are part of the live assistant process. + // Always surface tool calls; they are part of the live assistant flow. // Cast update to access sessionUpdate property const updateData = update as unknown as Record; @@ -516,15 +516,13 @@ export class WebViewProvider { /** * Attempt to restore authentication state and initialize connection * This is called when the webview is first shown - * - * In the new architecture, let CLI handle authentication state management */ private async attemptAuthStateRestoration(): Promise { try { console.log( - '[WebViewProvider] Attempting connection (letting CLI handle authentication)...', + '[WebViewProvider] Attempting connection (CLI handle authentication)...', ); - // In the new architecture, always attempt connection and let CLI handle authentication + //always attempt connection and let CLI handle authentication await this.initializeAgentConnection(); } catch (error) { console.error( @@ -540,7 +538,6 @@ export class WebViewProvider { * Can be called from show() or via /login command */ async initializeAgentConnection(): Promise { - // In the new architecture, let CLI handle authentication without local state caching return this.doInitializeAgentConnection(); } @@ -586,7 +583,6 @@ export class WebViewProvider { console.log('[WebViewProvider] Connecting to agent...'); // Pass the detected CLI path to ensure we use the correct installation - // In the new architecture, let CLI handle authentication without local state caching await this.agentManager.connect(workingDir, cliDetection.cliPath); console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; @@ -755,12 +751,6 @@ export class WebViewProvider { try { await this.agentManager.createNewSession(workingDir); console.log('[WebViewProvider] ACP session created successfully'); - - // In the new architecture, CLI handles authentication state - // No need to save auth state locally anymore - console.log( - '[WebViewProvider] Session created successfully (CLI manages auth state)', - ); } catch (sessionError) { console.error( '[WebViewProvider] Failed to create ACP session:', From 2b62b1e8bcab744af190713b5e7374c797481991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BC=9F=E5=85=89?= Date: Fri, 12 Dec 2025 14:40:30 +0800 Subject: [PATCH 14/44] =?UTF-8?q?feat:=20=E5=B0=86=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=88=90=E8=8B=B1=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/vscode-ide-companion/src/ide-server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 4905a941..69fabbc4 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -164,7 +164,7 @@ export class IDEServer { const allowedHosts = [ `localhost:${this.port}`, `127.0.0.1:${this.port}`, - `host.docker.internal:${this.port}`, // 添加Docker支持 + `host.docker.internal:${this.port}`, // Add Docker support ]; if (!allowedHosts.includes(host)) { return res.status(403).json({ error: 'Invalid Host header' }); From d0be8b43d799f60a51ac4005411c28ec36860de0 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 12 Dec 2025 16:29:50 +0800 Subject: [PATCH 15/44] pump version to 0.5.0 --- package-lock.json | 60 ++++++++----------- package.json | 4 +- packages/cli/package.json | 4 +- packages/core/package.json | 2 +- packages/sdk-typescript/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- .../src/cli/cliVersionManager.ts | 2 +- 8 files changed, 33 insertions(+), 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index 14da548b..29429f81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.5.0", "workspaces": [ "packages/*" ], @@ -568,7 +568,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -592,7 +591,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2157,7 +2155,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3671,7 +3668,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4142,7 +4138,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4153,7 +4148,6 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4359,7 +4353,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5135,7 +5128,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5530,7 +5522,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -6865,6 +6858,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7982,7 +7976,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8518,6 +8511,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8579,6 +8573,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8588,6 +8583,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8597,6 +8593,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -8763,6 +8760,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8781,6 +8779,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8789,13 +8788,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9909,7 +9910,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -11864,6 +11864,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -13162,7 +13163,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -13821,7 +13823,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13832,7 +13833,6 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13866,7 +13866,6 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15932,7 +15931,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16112,8 +16110,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16121,7 +16118,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16316,7 +16312,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16623,6 +16618,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16678,7 +16674,6 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16792,7 +16787,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16806,7 +16800,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17485,7 +17478,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17501,7 +17493,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -17616,7 +17608,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.4.1", + "version": "0.5.0", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -17747,7 +17739,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17757,7 +17748,7 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.1.0", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4" @@ -18324,7 +18315,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -18746,7 +18736,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -19623,7 +19612,6 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -20189,7 +20177,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.1", + "version": "0.5.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -20201,7 +20189,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.4.1", + "version": "0.5.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 7b842b10..661afeda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.5.0", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index ab98e974..685c6e90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.4.1", + "version": "0.5.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.1" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/core/package.json b/packages/core/package.json index 42def511..c8b7b1e7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.4.1", + "version": "0.5.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index b0f35709..f6ed8198 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.0", + "version": "0.5.0", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 7365c059..64e89c6d 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.4.1", + "version": "0.5.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index c278976f..09c5ff66 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.4.1", + "version": "0.5.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/vscode-ide-companion/src/cli/cliVersionManager.ts b/packages/vscode-ide-companion/src/cli/cliVersionManager.ts index 72ef3d2e..0cd6ca2c 100644 --- a/packages/vscode-ide-companion/src/cli/cliVersionManager.ts +++ b/packages/vscode-ide-companion/src/cli/cliVersionManager.ts @@ -7,7 +7,7 @@ import semver from 'semver'; import { CliDetector, type CliDetectionResult } from './cliDetector.js'; -export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.4.0'; +export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.5.0'; export interface CliFeatureFlags { supportsSessionList: boolean; From 7bb9bc1e5e373a8574c72d09cc47a63672c2f2da Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 14:29:37 +0100 Subject: [PATCH 16/44] Remove redundant if-check and add tests for tool conversion in converter.ts --- .../openaiContentGenerator/converter.test.ts | 342 +++++++++++++++++- .../core/openaiContentGenerator/converter.ts | 12 +- 2 files changed, 346 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index e29b4640..54420fbb 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -7,7 +7,13 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OpenAIContentConverter } from './converter.js'; import type { StreamingToolCallParser } from './streamingToolCallParser.js'; -import type { GenerateContentParameters, Content } from '@google/genai'; +import { + Type, + type GenerateContentParameters, + type Content, + type Tool, + type CallableTool, +} from '@google/genai'; import type OpenAI from 'openai'; describe('OpenAIContentConverter', () => { @@ -202,4 +208,338 @@ describe('OpenAIContentConverter', () => { ); }); }); + + describe('convertGeminiToolsToOpenAI', () => { + it('should convert Gemini tools with parameters field', async () => { + const geminiTools = [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: Type.OBJECT, + properties: { + location: { type: Type.STRING }, + }, + required: ['location'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: 'object', + properties: { + location: { type: 'string' }, + }, + required: ['location'], + }, + }, + }); + }); + + it('should convert MCP tools with parametersJsonSchema field', async () => { + // MCP tools use parametersJsonSchema which contains plain JSON schema (not Gemini types) + const mcpTools = [ + { + functionDeclarations: [ + { + name: 'read_file', + description: 'Read a file from disk', + parametersJsonSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'function', + function: { + name: 'read_file', + description: 'Read a file from disk', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + }, + required: ['path'], + }, + }, + }); + }); + + it('should handle CallableTool by resolving tool function', async () => { + const callableTools = [ + { + tool: async () => ({ + functionDeclarations: [ + { + name: 'dynamic_tool', + description: 'A dynamically resolved tool', + parameters: { + type: Type.OBJECT, + properties: {}, + }, + }, + ], + }), + }, + ] as CallableTool[]; + + const result = await converter.convertGeminiToolsToOpenAI(callableTools); + + expect(result).toHaveLength(1); + expect(result[0].function.name).toBe('dynamic_tool'); + }); + + it('should skip functions without name or description', async () => { + const geminiTools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: 'missing_description', + // no description + }, + { + // no name + description: 'Missing name', + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0].function.name).toBe('valid_tool'); + }); + + it('should handle tools without functionDeclarations', async () => { + const emptyTools: Tool[] = [ + {} as Tool, + { functionDeclarations: [] }, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(emptyTools); + + expect(result).toHaveLength(0); + }); + + it('should handle functions without parameters', async () => { + const geminiTools: Tool[] = [ + { + functionDeclarations: [ + { + name: 'no_params_tool', + description: 'A tool without parameters', + }, + ], + }, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(geminiTools); + + expect(result).toHaveLength(1); + expect(result[0].function.parameters).toBeUndefined(); + }); + + it('should not mutate original parametersJsonSchema', async () => { + const originalSchema = { + type: 'object', + properties: { foo: { type: 'string' } }, + }; + const mcpTools: Tool[] = [ + { + functionDeclarations: [ + { + name: 'test_tool', + description: 'Test tool', + parametersJsonSchema: originalSchema, + }, + ], + } as Tool, + ]; + + const result = await converter.convertGeminiToolsToOpenAI(mcpTools); + + // Verify the result is a copy, not the same reference + expect(result[0].function.parameters).not.toBe(originalSchema); + expect(result[0].function.parameters).toEqual(originalSchema); + }); + }); + + describe('convertGeminiToolParametersToOpenAI', () => { + it('should convert type names to lowercase', () => { + const params = { + type: 'OBJECT', + properties: { + count: { type: 'INTEGER' }, + amount: { type: 'NUMBER' }, + name: { type: 'STRING' }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + + expect(result).toEqual({ + type: 'object', + properties: { + count: { type: 'integer' }, + amount: { type: 'number' }, + name: { type: 'string' }, + }, + }); + }); + + it('should convert string numeric constraints to numbers', () => { + const params = { + type: 'object', + properties: { + value: { + type: 'number', + minimum: '0', + maximum: '100', + multipleOf: '0.5', + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + + expect(properties?.['value']).toEqual({ + type: 'number', + minimum: 0, + maximum: 100, + multipleOf: 0.5, + }); + }); + + it('should convert string length constraints to integers', () => { + const params = { + type: 'object', + properties: { + text: { + type: 'string', + minLength: '1', + maxLength: '100', + }, + items: { + type: 'array', + minItems: '0', + maxItems: '10', + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + + expect(properties?.['text']).toEqual({ + type: 'string', + minLength: 1, + maxLength: 100, + }); + expect(properties?.['items']).toEqual({ + type: 'array', + minItems: 0, + maxItems: 10, + }); + }); + + it('should handle nested objects', () => { + const params = { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + deep: { + type: 'INTEGER', + minimum: '0', + }, + }, + }, + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + const properties = result?.['properties'] as Record; + const nested = properties?.['nested'] as Record; + const nestedProperties = nested?.['properties'] as Record; + + expect(nestedProperties?.['deep']).toEqual({ + type: 'integer', + minimum: 0, + }); + }); + + it('should handle arrays', () => { + const params = { + type: 'array', + items: { + type: 'INTEGER', + }, + }; + + const result = converter.convertGeminiToolParametersToOpenAI(params); + + expect(result).toEqual({ + type: 'array', + items: { + type: 'integer', + }, + }); + }); + + it('should return undefined for null or non-object input', () => { + expect( + converter.convertGeminiToolParametersToOpenAI( + null as unknown as Record, + ), + ).toBeNull(); + expect( + converter.convertGeminiToolParametersToOpenAI( + undefined as unknown as Record, + ), + ).toBeUndefined(); + }); + + it('should not mutate the original parameters', () => { + const original = { + type: 'OBJECT', + properties: { + count: { type: 'INTEGER' }, + }, + }; + const originalCopy = JSON.parse(JSON.stringify(original)); + + converter.convertGeminiToolParametersToOpenAI(original); + + expect(original).toEqual(originalCopy); + }); + }); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index b22eb963..2de99d80 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -193,13 +193,11 @@ export class OpenAIContentConverter { // Handle both Gemini tools (parameters) and MCP tools (parametersJsonSchema) if (func.parametersJsonSchema) { // MCP tool format - use parametersJsonSchema directly - if (func.parametersJsonSchema) { - // Create a shallow copy to avoid mutating the original object - const paramsCopy = { - ...(func.parametersJsonSchema as Record), - }; - parameters = paramsCopy; - } + // Create a shallow copy to avoid mutating the original object + const paramsCopy = { + ...(func.parametersJsonSchema as Record), + }; + parameters = paramsCopy; } else if (func.parameters) { // Gemini tool format - convert parameters to OpenAI format parameters = this.convertGeminiToolParametersToOpenAI( From 3ff916a5f1660fafa5714983a0853e490b955e3c Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 15:28:43 +0100 Subject: [PATCH 17/44] Add tests for /language command and fix regex parsing --- .../cli/src/test-utils/mockCommandContext.ts | 1 + .../src/ui/commands/languageCommand.test.ts | 587 ++++++++++++++++++ .../cli/src/ui/commands/languageCommand.ts | 9 +- 3 files changed, 593 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/ui/commands/languageCommand.test.ts diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 59f26cf2..eced7df1 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -60,6 +60,7 @@ export const createMockCommandContext = ( toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), + reloadCommands: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts new file mode 100644 index 00000000..d0133c3b --- /dev/null +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -0,0 +1,587 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { type CommandContext, CommandKind } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +// Mock i18n module +vi.mock('../../i18n/index.js', () => ({ + setLanguageAsync: vi.fn().mockResolvedValue(undefined), + getCurrentLanguage: vi.fn().mockReturnValue('en'), + t: vi.fn((key: string) => key), +})); + +// Mock settings module to avoid Storage side effect +vi.mock('../../config/settings.js', () => ({ + SettingScope: { + User: 'user', + Workspace: 'workspace', + Default: 'default', + }, +})); + +// Mock fs module +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + default: { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + }, + }; +}); + +// Mock Storage from core +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Storage: { + getGlobalQwenDir: vi.fn().mockReturnValue('/mock/.qwen'), + getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/.qwen/settings.json'), + }, + }; +}); + +// Import modules after mocking +import * as i18n from '../../i18n/index.js'; +import { languageCommand } from './languageCommand.js'; + +describe('languageCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.clearAllMocks(); + mockContext = createMockCommandContext({ + services: { + config: { + getModel: vi.fn().mockReturnValue('test-model'), + }, + settings: { + merged: {}, + setValue: vi.fn(), + }, + }, + }); + + // Reset i18n mocks + vi.mocked(i18n.getCurrentLanguage).mockReturnValue('en'); + vi.mocked(i18n.t).mockImplementation((key: string) => key); + + // Reset fs mocks + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('command metadata', () => { + it('should have the correct name', () => { + expect(languageCommand.name).toBe('language'); + }); + + it('should have a description', () => { + expect(languageCommand.description).toBeDefined(); + expect(typeof languageCommand.description).toBe('string'); + }); + + it('should be a built-in command', () => { + expect(languageCommand.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have subcommands', () => { + expect(languageCommand.subCommands).toBeDefined(); + expect(languageCommand.subCommands?.length).toBe(2); + }); + + it('should have ui and output subcommands', () => { + const subCommandNames = languageCommand.subCommands?.map((c) => c.name); + expect(subCommandNames).toContain('ui'); + expect(subCommandNames).toContain('output'); + }); + }); + + describe('main command action - no arguments', () => { + it('should show current language settings when no arguments provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Current UI language:'), + }); + }); + + it('should show available subcommands in help', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('/language ui'), + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('/language output'), + }); + }); + + it('should show LLM output language when set', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY', + ); + + // Make t() function handle interpolation for this test + vi.mocked(i18n.t).mockImplementation( + (key: string, params?: Record) => { + if (params && key.includes('{{lang}}')) { + return key.replace('{{lang}}', params['lang'] || ''); + } + return key; + }, + ); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Current UI language:'), + }); + // Verify it correctly parses "Chinese" from the template format + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Chinese'), + }); + }); + }); + + describe('main command action - config not available', () => { + it('should return error when config is null', async () => { + mockContext.services.config = null; + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Configuration not available'), + }); + }); + }); + + describe('/language ui subcommand', () => { + it('should show help when no language argument provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Usage: /language ui'), + }); + }); + + it('should set English with "en"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(mockContext.services.settings.setValue).toHaveBeenCalled(); + expect(mockContext.ui.reloadCommands).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with "en-US"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui en-US'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with "english"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui english'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "zh"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui zh'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "zh-CN"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui zh-CN'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set Chinese with "chinese"', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui chinese'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should return error for invalid language', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'ui invalid'); + + expect(i18n.setLanguageAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Invalid language'), + }); + }); + + it('should persist setting to user scope', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + await languageCommand.action(mockContext, 'ui en'); + + expect(mockContext.services.settings.setValue).toHaveBeenCalledWith( + expect.anything(), // SettingScope.User + 'general.language', + 'en', + ); + }); + }); + + describe('/language output subcommand', () => { + it('should show help when no language argument provided', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Usage: /language output'), + }); + }); + + it('should create LLM output language rule file', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output Chinese'); + + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Chinese'), + 'utf-8', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('LLM output language rule file generated'), + }); + }); + + it('should include restart notice in success message', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output Japanese'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('restart'), + }); + }); + + it('should handle file write errors gracefully', async () => { + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'output German'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Failed to generate'), + }); + }); + }); + + describe('backward compatibility - direct language arguments', () => { + it('should set Chinese with direct "zh" argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'zh'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should set English with direct "en" argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should return error for unknown direct argument', async () => { + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, 'unknown'); + + expect(i18n.setLanguageAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Invalid command'), + }); + }); + }); + + describe('ui subcommand object', () => { + const uiSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'ui', + ); + + it('should have correct metadata', () => { + expect(uiSubcommand).toBeDefined(); + expect(uiSubcommand?.name).toBe('ui'); + expect(uiSubcommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have nested language subcommands', () => { + const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name); + expect(nestedNames).toContain('zh-CN'); + expect(nestedNames).toContain('en-US'); + }); + + it('should have action that sets language', async () => { + if (!uiSubcommand?.action) { + throw new Error('UI subcommand must have an action.'); + } + + const result = await uiSubcommand.action(mockContext, 'en'); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + }); + + describe('output subcommand object', () => { + const outputSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'output', + ); + + it('should have correct metadata', () => { + expect(outputSubcommand).toBeDefined(); + expect(outputSubcommand?.name).toBe('output'); + expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN); + }); + + it('should have action that generates rule file', async () => { + if (!outputSubcommand?.action) { + throw new Error('Output subcommand must have an action.'); + } + + // Ensure mocks are properly set for this test + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const result = await outputSubcommand.action(mockContext, 'French'); + + expect(fs.writeFileSync).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('LLM output language rule file generated'), + }); + }); + }); + + describe('nested ui language subcommands', () => { + const uiSubcommand = languageCommand.subCommands?.find( + (c) => c.name === 'ui', + ); + const zhCNSubcommand = uiSubcommand?.subCommands?.find( + (c) => c.name === 'zh-CN', + ); + const enUSSubcommand = uiSubcommand?.subCommands?.find( + (c) => c.name === 'en-US', + ); + + it('zh-CN should have aliases', () => { + expect(zhCNSubcommand?.altNames).toContain('zh'); + expect(zhCNSubcommand?.altNames).toContain('chinese'); + }); + + it('en-US should have aliases', () => { + expect(enUSSubcommand?.altNames).toContain('en'); + expect(enUSSubcommand?.altNames).toContain('english'); + }); + + it('zh-CN action should set Chinese', async () => { + if (!zhCNSubcommand?.action) { + throw new Error('zh-CN subcommand must have an action.'); + } + + const result = await zhCNSubcommand.action(mockContext, ''); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('zh'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('en-US action should set English', async () => { + if (!enUSSubcommand?.action) { + throw new Error('en-US subcommand must have an action.'); + } + + const result = await enUSSubcommand.action(mockContext, ''); + + expect(i18n.setLanguageAsync).toHaveBeenCalledWith('en'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('UI language changed'), + }); + }); + + it('should reject extra arguments', async () => { + if (!zhCNSubcommand?.action) { + throw new Error('zh-CN subcommand must have an action.'); + } + + const result = await zhCNSubcommand.action(mockContext, 'extra args'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('do not accept additional arguments'), + }); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index ba04920b..f7f2e7cd 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -81,8 +81,9 @@ function getCurrentLlmOutputLanguage(): string | null { if (fs.existsSync(filePath)) { try { const content = fs.readFileSync(filePath, 'utf-8'); - // Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese") - const match = content.match(/^#\s+(.+?)\s+Response Rules/i); + // Extract language name from the first line + // Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY" + const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i); if (match) { return match[1]; } @@ -127,7 +128,7 @@ async function setUiLanguage( context.ui.reloadCommands(); // Map language codes to friendly display names - const langDisplayNames: Record = { + const langDisplayNames: Record = { zh: '中文(zh-CN)', en: 'English(en-US)', }; @@ -136,7 +137,7 @@ async function setUiLanguage( type: 'message', messageType: 'info', content: t('UI language changed to {{lang}}', { - lang: langDisplayNames[lang], + lang: langDisplayNames[lang] || lang, }), }; } From a5039d15bf2b62b51742573db4f5296dcd8955a9 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 15:36:50 +0100 Subject: [PATCH 18/44] Revert to SupportedLanguage --- packages/cli/src/ui/commands/languageCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index f7f2e7cd..6827b6d2 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -128,7 +128,7 @@ async function setUiLanguage( context.ui.reloadCommands(); // Map language codes to friendly display names - const langDisplayNames: Record = { + const langDisplayNames: Partial> = { zh: '中文(zh-CN)', en: 'English(en-US)', }; From d25af87eaf16fa2a55d759ca6a716c9efbdd7062 Mon Sep 17 00:00:00 2001 From: Alexander Farber Date: Fri, 12 Dec 2025 15:46:37 +0100 Subject: [PATCH 19/44] Fix the copyright --- packages/cli/src/ui/commands/languageCommand.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index d0133c3b..001ccd8e 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen * SPDX-License-Identifier: Apache-2.0 */ From 026fd468b10c6a7cdfc392ef9d696fab0dbc9dbc Mon Sep 17 00:00:00 2001 From: Fazil Date: Fri, 12 Dec 2025 21:10:57 +0300 Subject: [PATCH 20/44] feat(i18n): add Russian language support --- docs/cli/language.md | 5 +- packages/cli/src/config/settingsSchema.ts | 1 + packages/cli/src/i18n/index.ts | 4 +- packages/cli/src/i18n/locales/ru.js | 1121 +++++++++++++++++ .../cli/src/ui/commands/languageCommand.ts | 49 +- 5 files changed, 1173 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/i18n/locales/ru.js diff --git a/docs/cli/language.md b/docs/cli/language.md index 7fb1e7f0..e4d403f0 100644 --- a/docs/cli/language.md +++ b/docs/cli/language.md @@ -10,19 +10,21 @@ The `/language` command allows you to customize the language settings for both t To change the UI language of Qwen Code, use the `ui` subcommand: ``` -/language ui [zh-CN|en-US] +/language ui [zh-CN|en-US|ru-RU] ``` ### Available UI Languages - **zh-CN**: Simplified Chinese (简体中文) - **en-US**: English +- **ru-RU**: Russian (Русский) ### Examples ``` /language ui zh-CN # Set UI language to Simplified Chinese /language ui en-US # Set UI language to English +/language ui ru-RU # Set UI language to Russian ``` ### UI Language Subcommands @@ -31,6 +33,7 @@ You can also use direct subcommands for convenience: - `/language ui zh-CN` or `/language ui zh` or `/language ui 中文` - `/language ui en-US` or `/language ui en` or `/language ui english` +- `/language ui ru-RU` or `/language ui ru` or `/language ui русский` ## LLM Output Language Settings diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..a596e793 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -191,6 +191,7 @@ const SETTINGS_SCHEMA = { { value: 'auto', label: 'Auto (detect from system)' }, { value: 'en', label: 'English' }, { value: 'zh', label: '中文 (Chinese)' }, + { value: 'ru', label: 'Русский (Russian)' }, ], }, }, diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 2cad8dec..7436336b 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -9,7 +9,7 @@ import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { homedir } from 'node:os'; -export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes +export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes // State let currentLanguage: SupportedLanguage = 'en'; @@ -51,10 +51,12 @@ export function detectSystemLanguage(): SupportedLanguage { const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG']; if (envLang?.startsWith('zh')) return 'zh'; if (envLang?.startsWith('en')) return 'en'; + if (envLang?.startsWith('ru')) return 'ru'; try { const locale = Intl.DateTimeFormat().resolvedOptions().locale; if (locale.startsWith('zh')) return 'zh'; + if (locale.startsWith('ru')) return 'ru'; } catch { // Fallback to default } diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js new file mode 100644 index 00000000..009578be --- /dev/null +++ b/packages/cli/src/i18n/locales/ru.js @@ -0,0 +1,1121 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Русский перевод для Qwen Code CLI +// Ключ служит одновременно ключом перевода и текстом по умолчанию + +export default { + // ============================================================================ + // Справка / Компоненты интерфейса + // ============================================================================ + 'Basics:': 'Основы:', + 'Add context': 'Добавить контекст', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Используйте {{symbol}} для добавления файлов в контекст (например, {{example}}) для выбора конкретных файлов или папок).', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Режим терминала', + 'YOLO mode': 'Режим YOLO', + 'plan mode': 'Режим планирования', + 'auto-accept edits': 'Режим принятия правок', + 'Accepting edits': 'Принятие правок', + '(shift + tab to cycle)': '(shift + tab для переключения)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Выполняйте команды терминала через {{symbol}} (например, {{example1}}) или используйте естественный язык (например, {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'Команды:', + 'shell command': 'команда терминала', + 'Model Context Protocol command (from external servers)': + 'Команда Model Context Protocol (из внешних серверов)', + 'Keyboard Shortcuts:': 'Горячие клавиши:', + 'Jump through words in the input': 'Переход по словам во вводе', + 'Close dialogs, cancel requests, or quit application': + 'Закрыть диалоги, отменить запросы или выйти из приложения', + 'New line': 'Новая строка', + 'New line (Alt+Enter works for certain linux distros)': + 'Новая строка (Alt+Enter работает только в некоторых дистрибутивах Linux)', + 'Clear the screen': 'Очистить экран', + 'Open input in external editor': 'Открыть ввод во внешнем редакторе', + 'Send message': 'Отправить сообщение', + 'Initializing...': 'Инициализация...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Подключение к MCP-серверам... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Введите сообщение или @путь/к/файлу', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Нажмите 'i' для режима ВСТАВКА и 'Esc' для ОБЫЧНОГО режима.", + 'Cancel operation / Clear input (double press)': + 'Отменить операцию / Очистить ввод (двойное нажатие)', + 'Cycle approval modes': 'Переключение режимов подтверждения', + 'Cycle through your prompt history': 'Пролистать историю запросов', + 'For a full list of shortcuts, see {{docPath}}': + 'Полный список горячих клавиш см. в {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'Справка по Qwen Code', + 'show version info': 'Просмотр информации о версии', + 'submit a bug report': 'Отправка отчёта об ошибке', + 'About Qwen Code': 'Об Qwen Code', + + // ============================================================================ + // Поля системной информации + // ============================================================================ + 'CLI Version': 'Версия CLI', + 'Git Commit': 'Git-коммит', + Model: 'Модель', + Sandbox: 'Песочница', + 'OS Platform': 'Платформа ОС', + 'OS Arch': 'Архитектура ОС', + 'OS Release': 'Версия ОС', + 'Node.js Version': 'Версия Node.js', + 'NPM Version': 'Версия NPM', + 'Session ID': 'ID сессии', + 'Auth Method': 'Метод авторизации', + 'Base URL': 'Базовый URL', + 'Memory Usage': 'Использование памяти', + 'IDE Client': 'Клиент IDE', + + // ============================================================================ + // Команды - Общие + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Анализ проекта и создание адаптированного файла QWEN.md', + 'list available Qwen Code tools. Usage: /tools [desc]': + 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', + 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', + 'No tools available': 'Нет доступных инструментов', + 'View or change the approval mode for tool usage': + 'Просмотр или изменение режима подтверждения для использования инструментов', + 'View or change the language setting': + 'Просмотр или изменение настроек языка', + 'change the theme': 'Изменение темы', + 'Select Theme': 'Выбор темы', + Preview: 'Предпросмотр', + '(Use Enter to select, Tab to configure scope)': + '(Enter для выбора, Tab для настройки области)', + '(Use Enter to apply scope, Tab to select theme)': + '(Enter для применения области, Tab для выбора темы)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Настройка темы недоступна из-за переменной окружения NO_COLOR.', + 'Theme "{{themeName}}" not found.': 'Тема "{{themeName}}" не найдена.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Тема "{{themeName}}" не найдена в выбранной области.', + 'clear the screen and conversation history': + 'Очистка экрана и истории диалога', + 'Compresses the context by replacing it with a summary.': + 'Сжатие контекста заменой на краткую сводку', + 'open full Qwen Code documentation in your browser': + 'Открытие полной документации Qwen Code в браузере', + 'Configuration not available.': 'Конфигурация недоступна.', + 'change the auth method': 'Изменение метода авторизации', + 'Copy the last result or code snippet to clipboard': + 'Копирование последнего результата или фрагмента кода в буфер обмена', + + // ============================================================================ + // Команды - Агенты + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Управление подагентами для делегирования специализированных задач', + 'Manage existing subagents (view, edit, delete).': + 'Управление существующими подагентами (просмотр, правка, удаление)', + 'Create a new subagent with guided setup.': + 'Создание нового подагента с пошаговой настройкой', + + // ============================================================================ + // Агенты - Диалог управления + // ============================================================================ + Agents: 'Агенты', + 'Choose Action': 'Выберите действие', + 'Edit {{name}}': 'Редактировать {{name}}', + 'Edit Tools: {{name}}': 'Редактировать инструменты: {{name}}', + 'Edit Color: {{name}}': 'Редактировать цвет: {{name}}', + 'Delete {{name}}': 'Удалить {{name}}', + 'Unknown Step': 'Неизвестный шаг', + 'Esc to close': 'Esc для закрытия', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter для выбора, ↑↓ для навигации, Esc для закрытия', + 'Esc to go back': 'Esc для возврата', + 'Enter to confirm, Esc to cancel': 'Enter для подтверждения, Esc для отмены', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter для выбора, ↑↓ для навигации, Esc для возврата', + 'Invalid step: {{step}}': 'Неверный шаг: {{step}}', + 'No subagents found.': 'Подагенты не найдены.', + "Use '/agents create' to create your first subagent.": + "Используйте '/agents create' для создания первого подагента.", + '(built-in)': '(встроенный)', + '(overridden by project level agent)': + '(переопределен агентом уровня проекта)', + 'Project Level ({{path}})': 'Уровень проекта ({{path}})', + 'User Level ({{path}})': 'Уровень пользователя ({{path}})', + 'Built-in Agents': 'Встроенные агенты', + 'Using: {{count}} agents': 'Используется: {{count}} агент(ов)', + 'View Agent': 'Просмотреть агента', + 'Edit Agent': 'Редактировать агента', + 'Delete Agent': 'Удалить агента', + Back: 'Назад', + 'No agent selected': 'Агент не выбран', + 'File Path: ': 'Путь к файлу: ', + 'Tools: ': 'Инструменты: ', + 'Color: ': 'Цвет: ', + 'Description:': 'Описание:', + 'System Prompt:': 'Системный промпт:', + 'Open in editor': 'Открыть в редакторе', + 'Edit tools': 'Редактировать инструменты', + 'Edit color': 'Редактировать цвет', + '❌ Error:': '❌ Ошибка:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Вы уверены, что хотите удалить агента "{{name}}"?', + // ============================================================================ + // Агенты - Мастер создания + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Уровень проекта (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'Уровень пользователя (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Подагент успешно создан!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Подагент "{{name}}" сохранен на уровне {{level}}.', + 'Name: ': 'Имя: ', + 'Location: ': 'Расположение: ', + '❌ Error saving subagent:': '❌ Ошибка сохранения подагента:', + 'Warnings:': 'Предупреждения:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Имя "{{name}}" уже существует на уровне {{level}} - существующий подагент будет перезаписан', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Имя "{{name}}" существует на уровне пользователя - уровень проекта будет иметь приоритет', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Имя "{{name}}" существует на уровне проекта - существующий подагент будет иметь приоритет', + 'Description is over {{length}} characters': + 'Описание превышает {{length}} символов', + 'System prompt is over {{length}} characters': + 'Системный промпт превышает {{length}} символов', + // Агенты - Шаги мастера создания + 'Step {{n}}: Choose Location': 'Шаг {{n}}: Выберите расположение', + 'Step {{n}}: Choose Generation Method': 'Шаг {{n}}: Выберите метод генерации', + 'Generate with Qwen Code (Recommended)': + 'Сгенерировать с помощью Qwen Code (Рекомендуется)', + 'Manual Creation': 'Ручное создание', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Опишите, что должен делать этот подагент и когда его следует использовать. (Будьте подробны для лучших результатов)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'например, Экспертный ревьювер кода, проверяющий код на соответствие лучшим практикам...', + 'Generating subagent configuration...': 'Генерация конфигурации подагента...', + 'Failed to generate subagent: {{error}}': + 'Не удалось сгенерировать подагента: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Шаг {{n}}: Опишите подагента', + 'Step {{n}}: Enter Subagent Name': 'Шаг {{n}}: Введите имя подагента', + 'Step {{n}}: Enter System Prompt': 'Шаг {{n}}: Введите системный промпт', + 'Step {{n}}: Enter Description': 'Шаг {{n}}: Введите описание', + // Агенты - Выбор инструментов + 'Step {{n}}: Select Tools': 'Шаг {{n}}: Выберите инструменты', + 'All Tools (Default)': 'Все инструменты (по умолчанию)', + 'All Tools': 'Все инструменты', + 'Read-only Tools': 'Инструменты только для чтения', + 'Read & Edit Tools': 'Инструменты для чтения и редактирования', + 'Read & Edit & Execution Tools': + 'Инструменты для чтения, редактирования и выполнения', + 'All tools selected, including MCP tools': + 'Все инструменты выбраны, включая инструменты MCP', + 'Selected tools:': 'Выбранные инструменты:', + 'Read-only tools:': 'Инструменты только для чтения:', + 'Edit tools:': 'Инструменты редактирования:', + 'Execution tools:': 'Инструменты выполнения:', + 'Step {{n}}: Choose Background Color': 'Шаг {{n}}: Выберите цвет фона', + 'Step {{n}}: Confirm and Save': 'Шаг {{n}}: Подтвердите и сохраните', + // Агенты - Навигация и инструкции + 'Esc to cancel': 'Esc для отмены', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Enter для сохранения, e для сохранения и редактирования, Esc для возврата', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Enter для продолжения, {{navigation}}Esc для {{action}}', + cancel: 'отмены', + 'go back': 'возврата', + '↑↓ to navigate, ': '↑↓ для навигации, ', + 'Enter a clear, unique name for this subagent.': + 'Введите четкое, уникальное имя для этого подагента.', + 'e.g., Code Reviewer': 'например, Ревьювер кода', + 'Name cannot be empty.': 'Имя не может быть пустым.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'Напишите системный промпт, определяющий поведение подагента. Будьте подробны для лучших результатов.', + 'e.g., You are an expert code reviewer...': + 'например, Вы экспертный ревьювер кода...', + 'System prompt cannot be empty.': 'Системный промпт не может быть пустым.', + 'Describe when and how this subagent should be used.': + 'Опишите, когда и как следует использовать этого подагента.', + 'e.g., Reviews code for best practices and potential bugs.': + 'например, Проверяет код на соответствие лучшим практикам и потенциальные ошибки.', + 'Description cannot be empty.': 'Описание не может быть пустым.', + 'Failed to launch editor: {{error}}': + 'Не удалось запустить редактор: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Не удалось сохранить и отредактировать подагента: {{error}}', + + // ============================================================================ + // Команды - Общие (продолжение) + // ============================================================================ + 'View and edit Qwen Code settings': 'Просмотр и изменение настроек Qwen Code', + Settings: 'Настройки', + '(Use Enter to select{{tabText}})': '(Enter для выбора{{tabText}})', + ', Tab to change focus': ', Tab для смены фокуса', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'Для применения изменений необходимо перезапустить Qwen Code. Нажмите r для выхода и применения изменений.', + + // ============================================================================ + // Метки настроек + // ============================================================================ + 'Vim Mode': 'Режим Vim', + 'Disable Auto Update': 'Отключить автообновление', + 'Enable Prompt Completion': 'Включить автодополнение промптов', + 'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки', + Language: 'Язык', + 'Output Format': 'Формат вывода', + 'Hide Window Title': 'Скрыть заголовок окна', + 'Show Status in Title': 'Показывать статус в заголовке', + 'Hide Tips': 'Скрыть подсказки', + 'Hide Banner': 'Скрыть баннер', + 'Hide Context Summary': 'Скрыть сводку контекста', + 'Hide CWD': 'Скрыть текущую директорию', + 'Hide Sandbox Status': 'Скрыть статус песочницы', + 'Hide Model Info': 'Скрыть информацию о модели', + 'Hide Footer': 'Скрыть нижний колонтитул', + 'Show Memory Usage': 'Показывать использование памяти', + 'Show Line Numbers': 'Показывать номера строк', + 'Show Citations': 'Показывать цитаты', + 'Custom Witty Phrases': 'Пользовательские остроумные фразы', + 'Enable Welcome Back': 'Включить приветствие при возврате', + 'Disable Loading Phrases': 'Отключить фразы при загрузке', + 'Screen Reader Mode': 'Режим программы чтения с экрана', + 'IDE Mode': 'Режим IDE', + 'Max Session Turns': 'Макс. количество ходов сессии', + 'Skip Next Speaker Check': 'Пропустить проверку следующего говорящего', + 'Skip Loop Detection': 'Пропустить обнаружение циклов', + 'Skip Startup Context': 'Пропустить начальный контекст', + 'Enable OpenAI Logging': 'Включить логирование OpenAI', + 'OpenAI Logging Directory': 'Директория логов OpenAI', + Timeout: 'Таймаут', + 'Max Retries': 'Макс. количество попыток', + 'Disable Cache Control': 'Отключить управление кэшем', + 'Memory Discovery Max Dirs': 'Макс. директорий для поиска в памяти', + 'Load Memory From Include Directories': + 'Загружать память из включенных директорий', + 'Respect .gitignore': 'Учитывать .gitignore', + 'Respect .qwenignore': 'Учитывать .qwenignore', + 'Enable Recursive File Search': 'Включить рекурсивный поиск файлов', + 'Disable Fuzzy Search': 'Отключить нечеткий поиск', + 'Enable Interactive Shell': 'Включить интерактивный терминал', + 'Show Color': 'Показывать цвета', + 'Auto Accept': 'Автоподтверждение', + 'Use Ripgrep': 'Использовать Ripgrep', + 'Use Builtin Ripgrep': 'Использовать встроенный Ripgrep', + 'Enable Tool Output Truncation': 'Включить обрезку вывода инструментов', + 'Tool Output Truncation Threshold': 'Порог обрезки вывода инструментов', + 'Tool Output Truncation Lines': 'Лимит строк вывода инструментов', + 'Folder Trust': 'Доверие к папке', + 'Vision Model Preview': 'Визуальная модель (предпросмотр)', + // Варианты перечислений настроек + 'Auto (detect from system)': 'Авто (определить из системы)', + Text: 'Текст', + JSON: 'JSON', + Plan: 'План', + Default: 'По умолчанию', + 'Auto Edit': 'Авторедактирование', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'Включение/выключение режима vim', + 'check session stats. Usage: /stats [model|tools]': + 'Просмотр статистики сессии. Использование: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Показать статистику использования модели.', + 'Show tool-specific usage statistics.': + 'Показать статистику использования инструментов.', + 'exit the cli': 'Выход из CLI', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth', + 'Manage workspace directories': + 'Управление директориями рабочего пространства', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Добавить директории в рабочее пространство. Используйте запятую для разделения путей', + 'Show all directories in the workspace': + 'Показать все директории в рабочем пространстве', + 'set external editor preference': + 'Установка предпочитаемого внешнего редактора', + 'Manage extensions': 'Управление расширениями', + 'List active extensions': 'Показать активные расширения', + 'Update extensions. Usage: update |--all': + 'Обновить расширения. Использование: update |--all', + 'manage IDE integration': 'Управление интеграцией с IDE', + 'check status of IDE integration': 'Проверить статус интеграции с IDE', + 'install required IDE companion for {{ideName}}': + 'Установить необходимый компаньон IDE для {{ideName}}', + 'enable IDE integration': 'Включение интеграции с IDE', + 'disable IDE integration': 'Отключение интеграции с IDE', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'Интеграция с IDE не поддерживается в вашем окружении. Для использования этой функции запустите Qwen Code в одной из поддерживаемых IDE: VS Code или форках VS Code.', + 'Set up GitHub Actions': 'Настройка GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Настройка привязки клавиш терминала для многострочного ввода (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Пожалуйста, перезапустите терминал для применения изменений.', + 'Failed to configure terminal: {{error}}': + 'Не удалось настроить терминал: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Не удалось определить путь конфигурации {{terminalName}} в Windows: переменная окружения APPDATA не установлена.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json существует, но не является корректным массивом JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'File: {{file}}': 'Файл: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Не удалось разобрать {{terminalName}} keybindings.json. Файл содержит некорректный JSON. Пожалуйста, исправьте файл вручную или удалите его для автоматической настройки.', + 'Error: {{error}}': 'Ошибка: {{error}}', + 'Shift+Enter binding already exists': 'Привязка Shift+Enter уже существует', + 'Ctrl+Enter binding already exists': 'Привязка Ctrl+Enter уже существует', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Обнаружены существующие привязки клавиш. Не будут изменены во избежание конфликтов.', + 'Please check and modify manually if needed: {{file}}': + 'Пожалуйста, проверьте и измените вручную при необходимости: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Добавлены привязки Shift+Enter и Ctrl+Enter для {{terminalName}}.', + 'Modified: {{file}}': 'Изменено: {{file}}', + '{{terminalName}} keybindings already configured.': + 'Привязки клавиш {{terminalName}} уже настроены.', + 'Failed to configure {{terminalName}}.': + 'Не удалось настроить {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Терминал "{{terminal}}" еще не поддерживается.', + + // ============================================================================ + // Команды - Язык + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'Неверный язык. Доступны: en-US, zh-CN, ru-RU', + 'Language subcommands do not accept additional arguments.': + 'Подкоманды языка не принимают дополнительных аргументов.', + 'Current UI language: {{lang}}': 'Текущий язык интерфейса: {{lang}}', + 'Current LLM output language: {{lang}}': 'Текущий язык вывода LLM: {{lang}}', + 'LLM output language not set': 'Язык вывода LLM не установлен', + 'Set UI language': 'Установка языка интерфейса', + 'Set LLM output language': 'Установка языка вывода LLM', + 'Usage: /language ui [zh-CN|en-US]': + 'Использование: /language ui [zh-CN|en-US|ru-RU]', + 'Usage: /language output ': 'Использование: /language output ', + 'Example: /language output 中文': 'Пример: /language output 中文', + 'Example: /language output English': 'Пример: /language output English', + 'Example: /language output 日本語': 'Пример: /language output 日本語', + 'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'Файл правил языка вывода LLM создан в {{path}}', + 'Please restart the application for the changes to take effect.': + 'Пожалуйста, перезапустите приложение для применения изменений.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Не удалось создать файл правил языка вывода LLM: {{error}}', + 'Invalid command. Available subcommands:': + 'Неверная команда. Доступные подкоманды:', + 'Available subcommands:': 'Доступные подкоманды:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.', + 'Available options:': 'Доступные варианты:', + ' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский', + ' - en-US: English': ' - en-US: Английский', + ' - ru-RU: Russian': ' - ru-RU: Русский', + 'Set UI language to Simplified Chinese (zh-CN)': + 'Установить язык интерфейса на упрощенный китайский (zh-CN)', + 'Set UI language to English (en-US)': + 'Установить язык интерфейса на английский (en-US)', + + // ============================================================================ + // Команды - Режим подтверждения + // ============================================================================ + 'Approval Mode': 'Режим подтверждения', + 'Current approval mode: {{mode}}': 'Текущий режим подтверждения: {{mode}}', + 'Available approval modes:': 'Доступные режимы подтверждения:', + 'Approval mode changed to: {{mode}}': + 'Режим подтверждения изменен на: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Режим подтверждения изменен на: {{mode}} (сохранено в настройках {{scope}}{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Использование: /approval-mode [--session|--user|--project]', + 'Scope subcommands do not accept additional arguments.': + 'Подкоманды области не принимают дополнительных аргументов.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Режим планирования - только анализ, без изменения файлов или выполнения команд', + 'Default mode - Require approval for file edits or shell commands': + 'Режим по умолчанию - требуется подтверждение для редактирования файлов или команд терминала', + 'Auto-edit mode - Automatically approve file edits': + 'Режим авторедактирования - автоматическое подтверждение изменений файлов', + 'YOLO mode - Automatically approve all tools': + 'Режим YOLO - автоматическое подтверждение всех инструментов', + '{{mode}} mode': 'Режим {{mode}}', + 'Settings service is not available; unable to persist the approval mode.': + 'Служба настроек недоступна; невозможно сохранить режим подтверждения.', + 'Failed to save approval mode: {{error}}': + 'Не удалось сохранить режим подтверждения: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Не удалось изменить режим подтверждения: {{error}}', + 'Apply to current session only (temporary)': + 'Применить только к текущей сессии (временно)', + 'Persist for this project/workspace': + 'Сохранить для этого проекта/рабочего пространства', + 'Persist for this user on this machine': + 'Сохранить для этого пользователя на этой машине', + 'Analyze only, do not modify files or execute commands': + 'Только анализ, без изменения файлов или выполнения команд', + 'Require approval for file edits or shell commands': + 'Требуется подтверждение для редактирования файлов или команд терминала', + 'Automatically approve file edits': + 'Автоматически подтверждать изменения файлов', + 'Automatically approve all tools': + 'Автоматически подтверждать все инструменты', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.', + '(Use Enter to select, Tab to change focus)': + '(Enter для выбора, Tab для смены фокуса)', + 'Apply To': 'Применить к', + 'User Settings': 'Настройки пользователя', + 'Workspace Settings': 'Настройки рабочего пространства', + + // ============================================================================ + // Команды - Память + // ============================================================================ + 'Commands for interacting with memory.': + 'Команды для взаимодействия с памятью', + 'Show the current memory contents.': 'Показать текущее содержимое памяти.', + 'Show project-level memory contents.': 'Показать память уровня проекта.', + 'Show global memory contents.': 'Показать глобальную память.', + 'Add content to project-level memory.': + 'Добавить содержимое в память уровня проекта.', + 'Add content to global memory.': 'Добавить содержимое в глобальную память.', + 'Refresh the memory from the source.': 'Обновить память из источника.', + 'Usage: /memory add --project ': + 'Использование: /memory add --project <текст для запоминания>', + 'Usage: /memory add --global ': + 'Использование: /memory add --global <текст для запоминания>', + 'Attempting to save to project memory: "{{text}}"': + 'Попытка сохранить в память проекта: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Попытка сохранить в глобальную память: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Текущее содержимое памяти из {{count}} файла(ов):', + 'Memory is currently empty.': 'Память в настоящее время пуста.', + 'Project memory file not found or is currently empty.': + 'Файл памяти проекта не найден или в настоящее время пуст.', + 'Global memory file not found or is currently empty.': + 'Файл глобальной памяти не найден или в настоящее время пуст.', + 'Global memory is currently empty.': + 'Глобальная память в настоящее время пуста.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Содержимое глобальной памяти:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Содержимое памяти проекта из {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': + 'Память проекта в настоящее время пуста.', + 'Refreshing memory from source files...': + 'Обновление памяти из исходных файлов...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Добавить содержимое в память. Используйте --global для глобальной памяти или --project для памяти проекта.', + 'Usage: /memory add [--global|--project] ': + 'Использование: /memory add [--global|--project] <текст для запоминания>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Попытка сохранить в память {{scope}}: "{{fact}}"', + + // ============================================================================ + // Команды - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Авторизоваться на MCP-сервере с поддержкой OAuth', + 'List configured MCP servers and tools': + 'Просмотр настроенных MCP-серверов и инструментов', + 'Restarts MCP servers.': 'Перезапустить MCP-серверы.', + 'Config not loaded.': 'Конфигурация не загружена.', + 'Could not retrieve tool registry.': + 'Не удалось получить реестр инструментов.', + 'No MCP servers configured with OAuth authentication.': + 'Нет MCP-серверов, настроенных с авторизацией OAuth.', + 'MCP servers with OAuth authentication:': 'MCP-серверы с авторизацией OAuth:', + 'Use /mcp auth to authenticate.': + 'Используйте /mcp auth <имя-сервера> для авторизации.', + "MCP server '{{name}}' not found.": "MCP-сервер '{{name}}' не найден.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Успешно авторизовано и обновлены инструменты для '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Повторное обнаружение инструментов от '{{name}}'...", + + // ============================================================================ + // Команды - Чат + // ============================================================================ + 'Manage conversation history.': 'Управление историей диалогов.', + 'List saved conversation checkpoints': + 'Показать сохраненные точки восстановления диалога', + 'No saved conversation checkpoints found.': + 'Не найдено сохраненных точек восстановления диалога.', + 'List of saved conversations:': 'Список сохраненных диалогов:', + 'Note: Newest last, oldest first': + 'Примечание: новые последними, старые первыми', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Сохранить текущий диалог как точку восстановления. Использование: /chat save <тег>', + 'Missing tag. Usage: /chat save ': + 'Отсутствует тег. Использование: /chat save <тег>', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Удалить точку восстановления диалога. Использование: /chat delete <тег>', + 'Missing tag. Usage: /chat delete ': + 'Отсутствует тег. Использование: /chat delete <тег>', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Точка восстановления диалога '{{tag}}' удалена.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Ошибка: точка восстановления с тегом '{{tag}}' не найдена.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Возобновить диалог из точки восстановления. Использование: /chat resume <тег>', + 'Missing tag. Usage: /chat resume ': + 'Отсутствует тег. Использование: /chat resume <тег>', + 'No saved checkpoint found with tag: {{tag}}.': + 'Не найдена сохраненная точка восстановления с тегом: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'Точка восстановления с тегом {{tag}} уже существует. Перезаписать?', + 'No chat client available to save conversation.': + 'Нет доступного клиента чата для сохранения диалога.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Точка восстановления диалога сохранена с тегом: {{tag}}.', + 'No conversation found to save.': 'Нет диалога для сохранения.', + 'No chat client available to share conversation.': + 'Нет доступного клиента чата для экспорта диалога.', + 'Invalid file format. Only .md and .json are supported.': + 'Неверный формат файла. Поддерживаются только .md и .json.', + 'Error sharing conversation: {{error}}': + 'Ошибка при экспорте диалога: {{error}}', + 'Conversation shared to {{filePath}}': 'Диалог экспортирован в {{filePath}}', + 'No conversation found to share.': 'Нет диалога для экспорта.', + 'Share the current conversation to a markdown or json file. Usage: /chat share <путь-к-файлу>': + 'Экспортировать текущий диалог в markdown или json файл. Использование: /chat share <путь-к-файлу>', + + // ============================================================================ + // Команды - Резюме + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Сгенерировать сводку проекта и сохранить её в .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'Нет доступного чат-клиента для генерации сводки.', + 'Already generating summary, wait for previous request to complete': + 'Генерация сводки уже выполняется, дождитесь завершения предыдущего запроса', + 'No conversation found to summarize.': + 'Не найдено диалогов для создания сводки.', + 'Failed to generate project context summary: {{error}}': + 'Не удалось сгенерировать сводку контекста проекта: {{error}}', + + // ============================================================================ + // Команды - Модель + // ============================================================================ + 'Switch the model for this session': 'Переключение модели для этой сессии', + 'Content generator configuration not available.': + 'Конфигурация генератора содержимого недоступна.', + 'Authentication type not available.': 'Тип авторизации недоступен.', + 'No models available for the current authentication type ({{authType}}).': + 'Нет доступных моделей для текущего типа авторизации ({{authType}}).', + + // ============================================================================ + // Команды - Очистка + // ============================================================================ + 'Clearing terminal and resetting chat.': 'Очистка терминала и сброс чата.', + 'Clearing terminal.': 'Очистка терминала.', + + // ============================================================================ + // Команды - Сжатие + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Уже выполняется сжатие, дождитесь завершения предыдущего запроса', + 'Failed to compress chat history.': 'Не удалось сжать историю чата.', + 'Failed to compress chat history: {{error}}': + 'Не удалось сжать историю чата: {{error}}', + 'Compressing chat history': 'Сжатие истории чата', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'История чата сжата с {{originalTokens}} до {{newTokens}} токенов.', + 'Compression was not beneficial for this history size.': + 'Сжатие не было полезным для этого размера истории.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Сжатие истории чата не уменьшило размер. Это может указывать на проблемы с промптом сжатия.', + 'Could not compress chat history due to a token counting error.': + 'Не удалось сжать историю чата из-за ошибки подсчета токенов.', + 'Chat history is already compressed.': 'История чата уже сжата.', + + // ============================================================================ + // Команды - Директория + // ============================================================================ + 'Configuration is not available.': 'Конфигурация недоступна.', + 'Please provide at least one path to add.': + 'Пожалуйста, укажите хотя бы один путь для добавления.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'Команда /directory add не поддерживается в ограничительных профилях песочницы. Пожалуйста, используйте --include-directories при запуске сессии.', + "Error adding '{{path}}': {{error}}": + "Ошибка при добавлении '{{path}}': {{error}}", + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': + 'Успешно добавлены файлы GEMINI.md из следующих директорий (если они есть):\n- {{directories}}', + 'Error refreshing memory: {{error}}': + 'Ошибка при обновлении памяти: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Успешно добавлены директории:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Текущие директории рабочего пространства:\n{{directories}}', + + // ============================================================================ + // Команды - Документация + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Пожалуйста, откройте следующий URL в браузере для просмотра документации:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Открытие документации в браузере: {{url}}', + + // ============================================================================ + // Диалоги - Подтверждение инструментов + // ============================================================================ + 'Do you want to proceed?': 'Вы хотите продолжить?', + 'Yes, allow once': 'Да, разрешить один раз', + 'Allow always': 'Всегда разрешать', + No: 'Нет', + 'No (esc)': 'Нет (esc)', + 'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии', + 'Modify in progress:': 'Идет изменение:', + 'Save and close external editor to continue': + 'Сохраните и закройте внешний редактор для продолжения', + 'Apply this change?': 'Применить это изменение?', + 'Yes, allow always': 'Да, всегда разрешать', + 'Modify with external editor': 'Изменить во внешнем редакторе', + 'No, suggest changes (esc)': 'Нет, предложить изменения (esc)', + "Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?", + 'Yes, allow always ...': 'Да, всегда разрешать ...', + 'Yes, and auto-accept edits': 'Да, и автоматически принимать правки', + 'Yes, and manually approve edits': 'Да, и вручную подтверждать правки', + 'No, keep planning (esc)': 'Нет, продолжить планирование (esc)', + 'URLs to fetch:': 'URL для загрузки:', + 'MCP Server: {{server}}': 'MCP-сервер: {{server}}', + 'Tool: {{tool}}': 'Инструмент: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Разрешить выполнение инструмента MCP "{{tool}}" с сервера "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Да, всегда разрешать инструмент "{{tool}}" с сервера "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Да, всегда разрешать все инструменты с сервера "{{server}}"', + + // ============================================================================ + // Диалоги - Подтверждение оболочки + // ============================================================================ + 'Shell Command Execution': 'Выполнение команды терминала', + 'A custom command wants to run the following shell commands:': + 'Пользовательская команда хочет выполнить следующие команды терминала:', + + // ============================================================================ + // Диалоги - Квота подписки Pro + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Исчерпана квота подписки Pro для {{model}}.', + 'Change auth (executes the /auth command)': + 'Изменить авторизацию (выполняет команду /auth)', + 'Continue with {{model}}': 'Продолжить с {{model}}', + + // ============================================================================ + // Диалоги - Приветствие при возвращении + // ============================================================================ + 'Current Plan:': 'Текущий план:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Прогресс: {{done}}/{{total}} задач выполнено', + ', {{inProgress}} in progress': ', {{inProgress}} в процессе', + 'Pending Tasks:': 'Ожидающие задачи:', + 'What would you like to do?': 'Что вы хотите сделать?', + 'Choose how to proceed with your session:': + 'Выберите, как продолжить сессию:', + 'Start new chat session': 'Начать новую сессию чата', + 'Continue previous conversation': 'Продолжить предыдущий диалог', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 С возвращением! (Последнее обновление: {{timeAgo}})', + '🎯 Overall Goal:': '🎯 Общая цель:', + + // ============================================================================ + // Диалоги - Авторизация + // ============================================================================ + 'Get started': 'Начать', + 'How would you like to authenticate for this project?': + 'Как вы хотите авторизоваться для этого проекта?', + 'OpenAI API key is required to use OpenAI authentication.': + 'Для использования авторизации OpenAI требуется ключ API OpenAI.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'Вы должны выбрать метод авторизации для продолжения. Нажмите Ctrl+C снова для выхода.', + '(Use Enter to Set Auth)': '(Enter для установки авторизации)', + 'Terms of Services and Privacy Notice for Qwen Code': + 'Условия обслуживания и уведомление о конфиденциальности для Qwen Code', + 'Qwen OAuth': 'Qwen OAuth', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Не удалось войти. Сообщение: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Авторизация должна быть {{enforcedType}}, но вы сейчас используете {{currentType}}.', + 'Qwen OAuth authentication timed out. Please try again.': + 'Время ожидания авторизации Qwen OAuth истекло. Пожалуйста, попробуйте снова.', + 'Qwen OAuth authentication cancelled.': 'Авторизация Qwen OAuth отменена.', + 'Qwen OAuth Authentication': 'Авторизация Qwen OAuth', + 'Please visit this URL to authorize:': + 'Пожалуйста, посетите этот URL для авторизации:', + 'Or scan the QR code below:': 'Или отсканируйте QR-код ниже:', + 'Waiting for authorization': 'Ожидание авторизации', + 'Time remaining:': 'Осталось времени:', + '(Press ESC or CTRL+C to cancel)': '(Нажмите ESC или CTRL+C для отмены)', + 'Qwen OAuth Authentication Timeout': 'Таймаут авторизации Qwen OAuth', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'Токен OAuth истек (более {{seconds}} секунд). Пожалуйста, выберите метод авторизации снова.', + 'Press any key to return to authentication type selection.': + 'Нажмите любую клавишу для возврата к выбору типа авторизации.', + 'Waiting for Qwen OAuth authentication...': + 'Ожидание авторизации Qwen OAuth...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'Примечание: Ваш существующий ключ API в settings.json не будет удален при использовании Qwen OAuth. Вы можете переключиться обратно на авторизацию OpenAI позже при необходимости.', + 'Authentication timed out. Please try again.': + 'Время ожидания авторизации истекло. Пожалуйста, попробуйте снова.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Ожидание авторизации... (Нажмите ESC или CTRL+C для отмены)', + 'Failed to authenticate. Message: {{message}}': + 'Не удалось авторизоваться. Сообщение: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Успешно авторизовано с учетными данными {{authType}}.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Неверное значение QWEN_DEFAULT_AUTH_TYPE: "{{value}}". Допустимые значения: {{validValues}}', + 'OpenAI Configuration Required': 'Требуется конфигурация OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Пожалуйста, введите конфигурацию OpenAI. Вы можете получить ключ API на', + 'API Key:': 'Ключ API:', + 'Invalid credentials: {{errorMessage}}': + 'Неверные учетные данные: {{errorMessage}}', + 'Failed to validate credentials': 'Не удалось проверить учетные данные', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Enter для продолжения, Tab/↑↓ для навигации, Esc для отмены', + + // ============================================================================ + // Диалоги - Модель + // ============================================================================ + 'Select Model': 'Выбрать модель', + '(Press Esc to close)': '(Нажмите Esc для закрытия)', + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': + 'Последняя модель Qwen Coder от Alibaba Cloud ModelStudio (версия: qwen3-coder-plus-2025-09-23)', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'Последняя модель Qwen Vision от Alibaba Cloud ModelStudio (версия: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Диалоги - Разрешения + // ============================================================================ + 'Manage folder trust settings': 'Управление настройками доверия к папкам', + + // ============================================================================ + // Строка состояния + // ============================================================================ + 'Using:': 'Используется:', + '{{count}} open file': '{{count}} открытый файл', + '{{count}} open files': '{{count}} открытых файла(ов)', + '(ctrl+g to view)': '(ctrl+g для просмотра)', + '{{count}} {{name}} file': '{{count}} файл {{name}}', + '{{count}} {{name}} files': '{{count}} файла(ов) {{name}}', + '{{count}} MCP server': '{{count}} MCP-сервер', + '{{count}} MCP servers': '{{count}} MCP-сервера(ов)', + '{{count}} Blocked': '{{count}} заблокирован(о)', + '(ctrl+t to view)': '(ctrl+t для просмотра)', + '(ctrl+t to toggle)': '(ctrl+t для переключения)', + 'Press Ctrl+C again to exit.': 'Нажмите Ctrl+C снова для выхода.', + 'Press Ctrl+D again to exit.': 'Нажмите Ctrl+D снова для выхода.', + 'Press Esc again to clear.': 'Нажмите Esc снова для очистки.', + + // ============================================================================ + // Статус MCP + // ============================================================================ + 'No MCP servers configured.': 'Не настроено MCP-серверов.', + 'Please view MCP documentation in your browser:': + 'Пожалуйста, просмотрите документацию MCP в браузере:', + 'or use the cli /docs command': 'или используйте команду cli /docs', + '⏳ MCP servers are starting up ({{count}} initializing)...': + '⏳ MCP-серверы запускаются ({{count}} инициализируется)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Примечание: Первый запуск может занять больше времени. Доступность инструментов обновится автоматически.', + 'Configured MCP servers:': 'Настроенные MCP-серверы:', + Ready: 'Готов', + 'Starting... (first startup may take longer)': + 'Запуск... (первый запуск может занять больше времени)', + Disconnected: 'Отключен', + '{{count}} tool': '{{count}} инструмент', + '{{count}} tools': '{{count}} инструмента(ов)', + '{{count}} prompt': '{{count}} промпт', + '{{count}} prompts': '{{count}} промпта(ов)', + '(from {{extensionName}})': '(от {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth истек', + 'OAuth not authenticated': 'OAuth не авторизован', + 'tools and prompts will appear when ready': + 'инструменты и промпты появятся, когда будут готовы', + '{{count}} tools cached': '{{count}} инструмента(ов) в кэше', + 'Tools:': 'Инструменты:', + 'Parameters:': 'Параметры:', + 'Prompts:': 'Промпты:', + Blocked: 'Заблокировано', + '💡 Tips:': '💡 Подсказки:', + Use: 'Используйте', + 'to show server and tool descriptions': + 'для показа описаний сервера и инструментов', + 'to show tool parameter schemas': 'для показа схем параметров инструментов', + 'to hide descriptions': 'для скрытия описаний', + 'to authenticate with OAuth-enabled servers': + 'для авторизации на серверах с поддержкой OAuth', + Press: 'Нажмите', + 'to toggle tool descriptions on/off': + 'для переключения описаний инструментов', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Начало авторизации OAuth для MCP-сервера '{{name}}'...", + 'Restarting MCP servers...': 'Перезапуск MCP-серверов...', + + // ============================================================================ + // Подсказки при запуске + // ============================================================================ + 'Tips for getting started:': 'Подсказки для начала работы:', + '1. Ask questions, edit files, or run commands.': + '1. Задавайте вопросы, редактируйте файлы или выполняйте команды.', + '2. Be specific for the best results.': + '2. Будьте конкретны для лучших результатов.', + 'files to customize your interactions with Qwen Code.': + 'файлы для настройки взаимодействия с Qwen Code.', + 'for more information.': 'для получения дополнительной информации.', + + // ============================================================================ + // Экран выхода / Статистика + // ============================================================================ + 'Agent powering down. Goodbye!': 'Агент завершает работу. До свидания!', + 'Interaction Summary': 'Сводка взаимодействия', + 'Session ID:': 'ID сессии:', + 'Tool Calls:': 'Вызовы инструментов:', + 'Success Rate:': 'Процент успеха:', + 'User Agreement:': 'Согласие пользователя:', + reviewed: 'проверено', + 'Code Changes:': 'Изменения кода:', + Performance: 'Производительность', + 'Wall Time:': 'Общее время:', + 'Agent Active:': 'Активность агента:', + 'API Time:': 'Время API:', + 'Tool Time:': 'Время инструментов:', + 'Session Stats': 'Статистика сессии', + 'Model Usage': 'Использование модели', + Reqs: 'Запросов', + 'Input Tokens': 'Входных токенов', + 'Output Tokens': 'Выходных токенов', + 'Savings Highlight:': 'Экономия:', + 'of input tokens were served from the cache, reducing costs.': + 'входных токенов обслужено из кэша, снижая затраты.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Подсказка: Для полной разбивки токенов выполните `/stats model`.', + 'Model Stats For Nerds': 'Статистика модели для гиков', + 'Tool Stats For Nerds': 'Статистика инструментов для гиков', + Metric: 'Метрика', + API: 'API', + Requests: 'Запросы', + Errors: 'Ошибки', + 'Avg Latency': 'Средняя задержка', + Tokens: 'Токены', + Total: 'Всего', + Prompt: 'Промпт', + Cached: 'Кэшировано', + Thoughts: 'Размышления', + Tool: 'Инструмент', + Output: 'Вывод', + 'No API calls have been made in this session.': + 'В этой сессии не было вызовов API.', + 'Tool Name': 'Имя инструмента', + Calls: 'Вызовы', + 'Success Rate': 'Процент успеха', + 'Avg Duration': 'Средняя длительность', + 'User Decision Summary': 'Сводка решений пользователя', + 'Total Reviewed Suggestions:': 'Всего проверено предложений:', + ' » Accepted:': ' » Принято:', + ' » Rejected:': ' » Отклонено:', + ' » Modified:': ' » Изменено:', + ' Overall Agreement Rate:': ' Общий процент согласия:', + 'No tool calls have been made in this session.': + 'В этой сессии не было вызовов инструментов.', + 'Session start time is unavailable, cannot calculate stats.': + 'Время начала сессии недоступно, невозможно рассчитать статистику.', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': + 'Ожидание подтверждения от пользователя...', + '(esc to cancel, {{time}})': '(esc для отмены, {{time}})', + "I'm Feeling Lucky": 'Мне повезёт!', + 'Shipping awesomeness... ': 'Доставляем крутизну... ', + 'Painting the serifs back on...': 'Рисуем засечки на буквах...', + 'Navigating the slime mold...': 'Пробираемся через слизевиков..', + 'Consulting the digital spirits...': 'Советуемся с цифровыми духами...', + 'Reticulating splines...': 'Сглаживание сплайнов...', + 'Warming up the AI hamsters...': 'Разогреваем ИИ-хомячков...', + 'Asking the magic conch shell...': 'Спрашиваем волшебную ракушку...', + 'Generating witty retort...': 'Генерируем остроумный ответ...', + 'Polishing the algorithms...': 'Полируем алгоритмы...', + "Don't rush perfection (or my code)...": + 'Не торопите совершенство (или мой код)...', + 'Brewing fresh bytes...': 'Завариваем свежие байты...', + 'Counting electrons...': 'Пересчитываем электроны...', + 'Engaging cognitive processors...': 'Задействуем когнитивные процессоры...', + 'Checking for syntax errors in the universe...': + 'Ищем синтаксические ошибки во вселенной...', + 'One moment, optimizing humor...': 'Секундочку, оптимизируем юмор...', + 'Shuffling punchlines...': 'Перетасовываем панчлайны...', + 'Untangling neural nets...': 'Распутаваем нейросети...', + 'Compiling brilliance...': 'Компилируем гениальность...', + 'Loading wit.exe...': 'Загружаем yumor.exe...', + 'Summoning the cloud of wisdom...': 'Призываем облако мудрости...', + 'Preparing a witty response...': 'Готовим остроумный ответ...', + "Just a sec, I'm debugging reality...": 'Секунду, идёт отладка реальности...', + 'Confuzzling the options...': 'Запутываем варианты...', + 'Tuning the cosmic frequencies...': 'Настраиваем космические частоты...', + 'Crafting a response worthy of your patience...': + 'Создаем ответ, достойный вашего терпения...', + 'Compiling the 1s and 0s...': 'Компилируем единички и нолики...', + 'Resolving dependencies... and existential crises...': + 'Разрешаем зависимости... и экзистенциальные кризисы...', + 'Defragmenting memories... both RAM and personal...': + 'Дефрагментация памяти... и оперативной, и личной...', + 'Rebooting the humor module...': 'Перезагрузка модуля юмора...', + 'Caching the essentials (mostly cat memes)...': + 'Кэшируем самое важное (в основном мемы с котиками)...', + 'Optimizing for ludicrous speed': 'Оптимизация для безумной скорости', + "Swapping bits... don't tell the bytes...": + 'Меняем биты... только байтам не говорите...', + 'Garbage collecting... be right back...': 'Сборка мусора... скоро вернусь...', + 'Assembling the interwebs...': 'Сборка интернетов...', + 'Converting coffee into code...': 'Превращаем кофе в код...', + 'Updating the syntax for reality...': 'Обновляем синтаксис реальности...', + 'Rewiring the synapses...': 'Переподключаем синапсы...', + 'Looking for a misplaced semicolon...': 'Ищем лишнюю точку с запятой...', + "Greasin' the cogs of the machine...": 'Смазываем шестерёнки машины...', + 'Pre-heating the servers...': 'Разогреваем серверы...', + 'Calibrating the flux capacitor...': 'Калибруем потоковый накопитель...', + 'Engaging the improbability drive...': 'Включаем двигатель невероятности...', + 'Channeling the Force...': 'Направляем Силу...', + 'Aligning the stars for optimal response...': + 'Выравниваем звёзды для оптимального ответа...', + 'So say we all...': 'Так скажем мы все...', + 'Loading the next great idea...': 'Загрузка следующей великой идеи...', + "Just a moment, I'm in the zone...": 'Минутку, я в потоке...', + 'Preparing to dazzle you with brilliance...': + 'Готовлюсь ослепить вас гениальностью...', + "Just a tick, I'm polishing my wit...": 'Секунду, полирую остроумие...', + "Hold tight, I'm crafting a masterpiece...": 'Держитесь, создаю шедевр...', + "Just a jiffy, I'm debugging the universe...": + 'Мигом, отлаживаю вселенную...', + "Just a moment, I'm aligning the pixels...": 'Момент, выравниваю пиксели...', + "Just a sec, I'm optimizing the humor...": 'Секунду, оптимизирую юмор...', + "Just a moment, I'm tuning the algorithms...": + 'Момент, настраиваю алгоритмы...', + 'Warp speed engaged...': 'Варп-скорость включена...', + 'Mining for more Dilithium crystals...': 'Добываем кристаллы дилития...', + "Don't panic...": 'Без паники...', + 'Following the white rabbit...': 'Следуем за белым кроликом...', + 'The truth is in here... somewhere...': 'Истина где-то здесь... внутри...', + 'Blowing on the cartridge...': 'Продуваем картридж...', + 'Loading... Do a barrel roll!': 'Загрузка... Сделай бочку!', + 'Waiting for the respawn...': 'Ждем респауна...', + 'Finishing the Kessel Run in less than 12 parsecs...': + 'Делаем Дугу Кесселя менее чем за 12 парсеков...', + "The cake is not a lie, it's just still loading...": + 'Тортик — не ложь, он просто ещё грузится...', + 'Fiddling with the character creation screen...': + 'Возимся с экраном создания персонажа...', + "Just a moment, I'm finding the right meme...": + 'Минутку, ищу подходящий мем...', + "Pressing 'A' to continue...": "Нажимаем 'A' для продолжения...", + 'Herding digital cats...': 'Пасём цифровых котов...', + 'Polishing the pixels...': 'Полируем пиксели...', + 'Finding a suitable loading screen pun...': + 'Ищем подходящий каламбур для экрана загрузки...', + 'Distracting you with this witty phrase...': + 'Отвлекаем вас этой остроумной фразой...', + 'Almost there... probably...': 'Почти готово... вроде...', + 'Our hamsters are working as fast as they can...': + 'Наши хомячки работают изо всех сил...', + 'Giving Cloudy a pat on the head...': 'Гладим Облачко по голове...', + 'Petting the cat...': 'Гладим кота...', + 'Rickrolling my boss...': 'Рикроллим начальника...', + 'Never gonna give you up, never gonna let you down...': + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...': 'Лабаем бас-гитару...', + 'Tasting the snozberries...': 'Пробуем снузберри на вкус...', + "I'm going the distance, I'm going for speed...": + 'Иду до конца, иду на скорость...', + 'Is this the real life? Is this just fantasy?...': + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...": 'У меня хорошее предчувствие...', + 'Poking the bear...': 'Дразним медведя... (Не лезь...)', + 'Doing research on the latest memes...': 'Изучаем свежие мемы...', + 'Figuring out how to make this more witty...': + 'Думаем, как сделать это остроумнее...', + 'Hmmm... let me think...': 'Хмм... дайте подумать...', + 'What do you call a fish with no eyes? A fsh...': + 'Как называется бумеранг, который не возвращается? Палка...', + 'Why did the computer go to therapy? It had too many bytes...': + 'Почему компьютер простудился? Потому что оставил окна открытыми...', + "Why don't programmers like nature? It has too many bugs...": + 'Почему программисты не любят гулять на улице? Там среда не настроена...', + 'Why do programmers prefer dark mode? Because light attracts bugs...': + 'Почему программисты предпочитают тёмную тему? Потому что в темноте не видно багов...', + 'Why did the developer go broke? Because they used up all their cache...': + 'Почему разработчик разорился? Потому что потратил весь свой кэш...', + "What can you do with a broken pencil? Nothing, it's pointless...": + 'Что можно делать со сломанным карандашом? Ничего — он тупой...', + 'Applying percussive maintenance...': 'Провожу настройку методом тыка...', + 'Searching for the correct USB orientation...': + 'Ищем, какой стороной вставлять флешку...', + 'Ensuring the magic smoke stays inside the wires...': + 'Следим, чтобы волшебный дым не вышел из проводов...', + 'Rewriting in Rust for no particular reason...': + 'Переписываем всё на Rust без особой причины...', + 'Trying to exit Vim...': 'Пытаемся выйти из Vim...', + 'Spinning up the hamster wheel...': 'Раскручиваем колесо для хомяка...', + "That's not a bug, it's an undocumented feature...": 'Это не баг, а фича...', + 'Engage.': 'Поехали!', + "I'll be back... with an answer.": 'Я вернусь... с ответом.', + 'My other process is a TARDIS...': 'Мой другой процесс — это ТАРДИС...', + 'Communing with the machine spirit...': 'Общаемся с духом машины...', + 'Letting the thoughts marinate...': 'Даем мыслям замариноваться...', + 'Just remembered where I put my keys...': + 'Только что вспомнил, куда положил ключи...', + 'Pondering the orb...': 'Размышляю над сферой...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.": + 'Я видел такое, во что вы, люди, просто не поверите... например, пользователя, читающего сообщения загрузки.', + 'Initiating thoughtful gaze...': 'Инициируем задумчивый взгляд...', + "What's a computer's favorite snack? Microchips.": + 'Что сервер заказывает в баре? Пинг-коладу.', + "Why do Java developers wear glasses? Because they don't C#.": + 'Почему Java-разработчики не убираются дома? Они ждут сборщик мусора...', + 'Charging the laser... pew pew!': 'Заряжаем лазер... пиу-пиу!', + 'Dividing by zero... just kidding!': 'Делим на ноль... шучу!', + 'Looking for an adult superviso... I mean, processing.': + 'Ищу взрослых для присмот... в смысле, обрабатываю.', + 'Making it go beep boop.': 'Делаем бип-буп.', + 'Buffering... because even AIs need a moment.': + 'Буферизация... даже ИИ нужно мгновение.', + 'Entangling quantum particles for a faster response...': + 'Запутываем квантовые частицы для быстрого ответа...', + 'Polishing the chrome... on the algorithms.': + 'Полируем хром... на алгоритмах.', + 'Are you not entertained? (Working on it!)': + 'Вы ещё не развлеклись?! Разве вы не за этим сюда пришли?!', + 'Summoning the code gremlins... to help, of course.': + 'Призываем гремлинов кода... для помощи, конечно же.', + 'Just waiting for the dial-up tone to finish...': + 'Ждем, пока закончится звук dial-up модема...', + 'Recalibrating the humor-o-meter.': 'Перекалибровка юморометра.', + 'My other loading screen is even funnier.': + 'Мой другой экран загрузки ещё смешнее.', + "Pretty sure there's a cat walking on the keyboard somewhere...": + 'Кажется, где-то по клавиатуре гуляет кот...', + 'Enhancing... Enhancing... Still loading.': + 'Улучшаем... Ещё улучшаем... Всё ещё грузится.', + "It's not a bug, it's a feature... of this loading screen.": + 'Это не баг, это фича... экрана загрузки.', + 'Have you tried turning it off and on again? (The loading screen, not me.)': + 'Пробовали выключить и включить снова? (Экран загрузки, не меня!)', + 'Constructing additional pylons...': 'Нужно построить больше пилонов...', +}; diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index ba04920b..7c1bd9bd 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -130,6 +130,7 @@ async function setUiLanguage( const langDisplayNames: Record = { zh: '中文(zh-CN)', en: 'English(en-US)', + ru: 'Русский (ru-RU)', }; return { @@ -216,7 +217,7 @@ export const languageCommand: SlashCommand = { : t('LLM output language not set'), '', t('Available subcommands:'), - ` /language ui [zh-CN|en-US] - ${t('Set UI language')}`, + ` /language ui [zh-CN|en-US|ru-RU] - ${t('Set UI language')}`, ` /language output - ${t('Set LLM output language')}`, ].join('\n'); @@ -232,7 +233,7 @@ export const languageCommand: SlashCommand = { const subcommand = parts[0].toLowerCase(); if (subcommand === 'ui') { - // Handle /language ui [zh-CN|en-US] + // Handle /language ui [zh-CN|en-US|ru-RU] if (parts.length === 1) { // Show UI language subcommand help return { @@ -241,11 +242,12 @@ export const languageCommand: SlashCommand = { content: [ t('Set UI language'), '', - t('Usage: /language ui [zh-CN|en-US]'), + t('Usage: /language ui [zh-CN|en-US|ru-RU]'), '', t('Available options:'), t(' - zh-CN: Simplified Chinese'), t(' - en-US: English'), + t(' - ru-RU: Russian'), '', t( 'To request additional UI language packs, please open an issue on GitHub.', @@ -266,11 +268,18 @@ export const languageCommand: SlashCommand = { langArg === 'zh-cn' ) { targetLang = 'zh'; + } else if ( + langArg === 'ru' || + langArg === 'ru-RU' || + langArg === 'russian' || + langArg === 'русский' + ) { + targetLang = 'ru'; } else { return { type: 'message', messageType: 'error', - content: t('Invalid language. Available: en-US, zh-CN'), + content: t('Invalid language. Available: en-US, zh-CN, ru-RU'), }; } @@ -307,13 +316,20 @@ export const languageCommand: SlashCommand = { langArg === 'zh-cn' ) { targetLang = 'zh'; + } else if ( + langArg === 'ru' || + langArg === 'ru-RU' || + langArg === 'russian' || + langArg === 'русский' + ) { + targetLang = 'ru'; } else { return { type: 'message', messageType: 'error', content: [ t('Invalid command. Available subcommands:'), - ' - /language ui [zh-CN|en-US] - ' + t('Set UI language'), + ' - /language ui [zh-CN|en-US|ru-RU] - ' + t('Set UI language'), ' - /language output - ' + t('Set LLM output language'), ].join('\n'), }; @@ -423,6 +439,29 @@ export const languageCommand: SlashCommand = { return setUiLanguage(context, 'en'); }, }, + { + name: 'ru-RU', + altNames: ['ru', 'russian', 'русский'], + get description() { + return t('Set UI language to Russian (ru-RU)'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, 'ru'); + }, + }, ], }, { From f5306339f69d400b171a7d1d82902350dac469c7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 13 Dec 2025 00:01:05 +0800 Subject: [PATCH 21/44] refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file feat(vscode-ide-companion/file-context): improve file context handling and search Enhance file context hook to better handle search queries and reduce redundant requests. Track last query to optimize when to refetch full file list. Improve logging for debugging purposes. --- .../src/services/acpConnection.ts | 2 +- .../src/services/acpSessionManager.ts | 2 +- .../src/services/qwenAgentManager.ts | 2 +- .../src/services/qwenSessionUpdateHandler.ts | 3 +- .../src/types/acpTypes.ts | 3 +- .../src/types/approvalModeValueTypes.ts | 11 ++++++ .../src/types/chatTypes.ts | 3 +- .../vscode-ide-companion/src/webview/App.tsx | 39 +++++++++++++++---- .../src/webview/WebViewProvider.ts | 2 +- .../webview/components/layout/InputForm.tsx | 2 +- .../webview/handlers/SessionMessageHandler.ts | 30 ++++++++++++++ .../src/webview/hooks/file/useFileContext.ts | 28 ++++++++----- .../src/webview/hooks/useWebViewMessages.ts | 2 +- 13 files changed, 103 insertions(+), 26 deletions(-) create mode 100644 packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index f4c95948..464f8bcb 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -10,8 +10,8 @@ import type { AcpPermissionRequest, AcpResponse, AcpSessionUpdate, - ApprovalModeValue, } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; import { spawn } from 'child_process'; import type { diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts index 8812282a..55b1d2b5 100644 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -14,8 +14,8 @@ import type { AcpRequest, AcpNotification, AcpResponse, - ApprovalModeValue, } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { AGENT_METHODS } from '../constants/acpSchema.js'; import type { PendingRequest } from '../types/connectionTypes.js'; import type { ChildProcess } from 'child_process'; diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 5ddd5612..c3aa6525 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -7,8 +7,8 @@ import { AcpConnection } from './acpConnection.js'; import type { AcpSessionUpdate, AcpPermissionRequest, - ApprovalModeValue, } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionManager } from './qwenSessionManager.js'; import type { diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index e27fbe67..d7b24bb2 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -10,7 +10,8 @@ * Handles session updates from ACP and dispatches them to appropriate callbacks */ -import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js'; +import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { QwenAgentCallbacks } from '../types/chatTypes.js'; /** diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 1fb4de17..252f3d5d 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -3,6 +3,7 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export const JSONRPC_VERSION = '2.0' as const; export const authMethod = 'qwen-oauth'; @@ -138,8 +139,6 @@ export interface PlanUpdate extends BaseSessionUpdate { }; } -export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; - export { ApprovalMode, APPROVAL_MODE_MAP, diff --git a/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts new file mode 100644 index 00000000..fe1f37e1 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Type for approval mode values + * Used in ACP protocol for controlling agent behavior + */ +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 90ebbb87..bafe154d 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -3,7 +3,8 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ -import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js'; +import type { AcpPermissionRequest } from './acpTypes.js'; +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { role: 'user' | 'assistant'; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 4bdf6622..1db91d39 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -43,7 +43,7 @@ import { InputForm } from './components/layout/InputForm.js'; import { SessionSelector } from './components/layout/SessionSelector.js'; import { FileIcon, UserIcon } from './components/icons/index.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; -import type { ApprovalModeValue } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../types/chatTypes.js'; export const App: React.FC = () => { @@ -90,9 +90,13 @@ export const App: React.FC = () => { const getCompletionItems = React.useCallback( async (trigger: '@' | '/', query: string): Promise => { if (trigger === '@') { - if (!fileContext.hasRequestedFiles) { - fileContext.requestWorkspaceFiles(); - } + console.log('[App] getCompletionItems @ called', { + query, + requested: fileContext.hasRequestedFiles, + workspaceFiles: fileContext.workspaceFiles.length, + }); + // 始终根据当前 query 触发请求,让 hook 判断是否需要真正请求 + fileContext.requestWorkspaceFiles(query); const fileIcon = ; const allItems: CompletionItem[] = fileContext.workspaceFiles.map( @@ -109,7 +113,6 @@ export const App: React.FC = () => { ); if (query && query.length >= 1) { - fileContext.requestWorkspaceFiles(query); const lowerQuery = query.toLowerCase(); return allItems.filter( (item) => @@ -154,17 +157,39 @@ export const App: React.FC = () => { const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + // Track a lightweight signature of workspace files to detect content changes even when length is unchanged + const workspaceFilesSignature = useMemo( + () => + fileContext.workspaceFiles + .map( + (file) => + `${file.id}|${file.label}|${file.description ?? ''}|${file.path}`, + ) + .join('||'), + [fileContext.workspaceFiles], + ); + // When workspace files update while menu open for @, refresh items so the first @ shows the list // Note: Avoid depending on the entire `completion` object here, since its identity // changes on every render which would retrigger this effect and can cause a refresh loop. useEffect(() => { - if (completion.isOpen && completion.triggerChar === '@') { + // Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search + if ( + completion.isOpen && + completion.triggerChar === '@' && + !completion.query + ) { // Only refresh items; do not change other completion state to avoid re-renders loops completion.refreshCompletion(); } // Only re-run when the actual data source changes, not on every render // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]); + }, [ + workspaceFilesSignature, + completion.isOpen, + completion.triggerChar, + completion.query, + ]); // Message submission const { handleSubmit: submitMessage } = useMessageSubmit({ diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index b4da60ab..f2b36ab0 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -14,7 +14,7 @@ import { MessageHandler } from '../webview/MessageHandler.js'; import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; -import { type ApprovalModeValue } from '../types/acpTypes.js'; +import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; export class WebViewProvider { private panelManager: PanelManager; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index fe86ea99..5c4a889a 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -20,7 +20,7 @@ import { import { CompletionMenu } from '../layout/CompletionMenu.js'; import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; -import type { ApprovalModeValue } from '../../../types/acpTypes.js'; +import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; interface InputFormProps { inputText: string; diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index 0df3e0da..75ebe0b9 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { BaseMessageHandler } from './BaseMessageHandler.js'; import type { ChatMessage } from '../../services/qwenAgentManager.js'; +import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; /** * Session message handler @@ -29,6 +30,8 @@ export class SessionMessageHandler extends BaseMessageHandler { 'cancelStreaming', // UI action: open a new chat tab (new WebviewPanel) 'openNewChatTab', + // Settings-related messages + 'setApprovalMode', ].includes(messageType); } @@ -112,6 +115,14 @@ export class SessionMessageHandler extends BaseMessageHandler { await this.handleCancelStreaming(); break; + case 'setApprovalMode': + await this.handleSetApprovalMode( + message.data as { + modeId?: ApprovalModeValue; + }, + ); + break; + default: console.warn( '[SessionMessageHandler] Unknown message type:', @@ -1073,4 +1084,23 @@ export class SessionMessageHandler extends BaseMessageHandler { } } } + + /** + * Set approval mode via agent (ACP session/set_mode) + */ + private async handleSetApprovalMode(data?: { + modeId?: ApprovalModeValue; + }): Promise { + try { + const modeId = data?.modeId || 'default'; + await this.agentManager.setApprovalModeFromUi(modeId); + // No explicit response needed; WebView listens for modeChanged + } catch (error) { + console.error('[SessionMessageHandler] Failed to set mode:', error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to set mode: ${error}` }, + }); + } + } } diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts index eca8437d..8bccc658 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -34,6 +34,9 @@ export const useFileContext = (vscode: VSCodeAPI) => { // Whether workspace files have been requested const hasRequestedFilesRef = useRef(false); + // Last non-empty query to decide when to refetch full list + const lastQueryRef = useRef(undefined); + // Search debounce timer const searchTimerRef = useRef(null); @@ -42,12 +45,10 @@ export const useFileContext = (vscode: VSCodeAPI) => { */ const requestWorkspaceFiles = useCallback( (query?: string) => { - if (!hasRequestedFilesRef.current && !query) { - hasRequestedFilesRef.current = true; - } + const normalizedQuery = query?.trim(); // If there's a query, clear previous timer and set up debounce - if (query && query.length >= 1) { + if (normalizedQuery && normalizedQuery.length >= 1) { if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } @@ -55,14 +56,23 @@ export const useFileContext = (vscode: VSCodeAPI) => { searchTimerRef.current = setTimeout(() => { vscode.postMessage({ type: 'getWorkspaceFiles', - data: { query }, + data: { query: normalizedQuery }, }); }, 300); + lastQueryRef.current = normalizedQuery; } else { - vscode.postMessage({ - type: 'getWorkspaceFiles', - data: query ? { query } : {}, - }); + // For empty query, request once initially and whenever we are returning from a search + const shouldRequestFullList = + !hasRequestedFilesRef.current || lastQueryRef.current !== undefined; + + if (shouldRequestFullList) { + lastQueryRef.current = undefined; + hasRequestedFilesRef.current = true; + vscode.postMessage({ + type: 'getWorkspaceFiles', + data: {}, + }); + } } }, [vscode], diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 7a3f7e06..1ee50b27 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -12,7 +12,7 @@ import type { ToolCall as PermissionToolCall, } from '../components/PermissionDrawer/PermissionRequest.js'; import type { ToolCallUpdate } from '../../types/chatTypes.js'; -import type { ApprovalModeValue } from '../../types/acpTypes.js'; +import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; interface UseWebViewMessagesProps { From 3191cf73b3c1464d7fcfb11c6b5f3b435ede71cd Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 13 Dec 2025 00:01:30 +0800 Subject: [PATCH 22/44] feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic Implement item comparison to prevent unnecessary re-renders when completion items haven't actually changed. Optimize refresh logic to only trigger when workspace files content changes. Improve completion menu stability and responsiveness. refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler to reduce code duplication and simplify message handling architecture. Remove unused settings-related imports and clean up message router configuration. chore(vscode-ide-companion/ui): minor UI improvements and code cleanup Consolidate imports in SessionSelector component. Remove debug console log statement from FileMessageHandler. Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file. Clean up completion menu CSS classes. --- .../components/layout/CompletionMenu.tsx | 3 +- .../components/layout/SessionSelector.tsx | 6 +- .../webview/handlers/FileMessageHandler.ts | 1 - .../src/webview/handlers/MessageRouter.ts | 9 -- .../handlers/SettingsMessageHandler.ts | 101 ------------------ .../src/webview/hooks/useCompletionTrigger.ts | 45 +++++++- .../src/webview/utils/sessionGrouping.ts | 35 ++++++ .../src/webview/utils/timeUtils.ts | 40 ------- 8 files changed, 84 insertions(+), 156 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts delete mode 100644 packages/vscode-ide-companion/src/webview/utils/timeUtils.ts diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx index 167a376d..f667b849 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx @@ -92,9 +92,8 @@ export const CompletionMenu: React.FC = ({ ref={containerRef} role="menu" className={[ - // Semantic class name for readability (no CSS attached) 'completion-menu', - // Positioning and container styling (Tailwind) + // Positioning and container styling 'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden', 'rounded-large border bg-[var(--app-menu-background)]', 'border-[var(--app-input-border)] max-h-[50vh] z-[1000]', diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx index ab7f6d51..1b744c1d 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx @@ -5,8 +5,10 @@ */ import React from 'react'; -import { groupSessionsByDate } from '../../utils/sessionGrouping.js'; -import { getTimeAgo } from '../../utils/timeUtils.js'; +import { + getTimeAgo, + groupSessionsByDate, +} from '../../utils/sessionGrouping.js'; import { SearchIcon } from '../icons/index.js'; interface SessionSelectorProps { diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index f82525f7..28ecbbd3 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -49,7 +49,6 @@ export class FileMessageHandler extends BaseMessageHandler { break; case 'openDiff': - console.log('[FileMessageHandler ===== ] openDiff called with:', data); await this.handleOpenDiff(data); break; diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index adf94e29..327da6c3 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -11,7 +11,6 @@ import { SessionMessageHandler } from './SessionMessageHandler.js'; import { FileMessageHandler } from './FileMessageHandler.js'; import { EditorMessageHandler } from './EditorMessageHandler.js'; import { AuthMessageHandler } from './AuthMessageHandler.js'; -import { SettingsMessageHandler } from './SettingsMessageHandler.js'; /** * Message Router @@ -63,20 +62,12 @@ export class MessageRouter { sendToWebView, ); - const settingsHandler = new SettingsMessageHandler( - agentManager, - conversationStore, - currentConversationId, - sendToWebView, - ); - // Register handlers in order of priority this.handlers = [ this.sessionHandler, fileHandler, editorHandler, this.authHandler, - settingsHandler, ]; } diff --git a/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts deleted file mode 100644 index 7ea8e732..00000000 --- a/packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as vscode from 'vscode'; -import { BaseMessageHandler } from './BaseMessageHandler.js'; -import type { ApprovalModeValue } from '../../types/acpTypes.js'; - -/** - * Settings message handler - * Handles all settings-related messages - */ -export class SettingsMessageHandler extends BaseMessageHandler { - canHandle(messageType: string): boolean { - return ['openSettings', 'recheckCli', 'setApprovalMode'].includes( - messageType, - ); - } - - async handle(message: { type: string; data?: unknown }): Promise { - switch (message.type) { - case 'openSettings': - await this.handleOpenSettings(); - break; - - case 'recheckCli': - await this.handleRecheckCli(); - break; - - case 'setApprovalMode': - await this.handleSetApprovalMode( - message.data as { - modeId?: ApprovalModeValue; - }, - ); - break; - - default: - console.warn( - '[SettingsMessageHandler] Unknown message type:', - message.type, - ); - break; - } - } - - /** - * Open settings page - */ - private async handleOpenSettings(): Promise { - try { - // Open settings in a side panel - await vscode.commands.executeCommand('workbench.action.openSettings', { - query: 'qwenCode', - }); - } catch (error) { - console.error('[SettingsMessageHandler] Failed to open settings:', error); - vscode.window.showErrorMessage(`Failed to open settings: ${error}`); - } - } - - /** - * Recheck CLI - */ - private async handleRecheckCli(): Promise { - try { - await vscode.commands.executeCommand('qwenCode.recheckCli'); - this.sendToWebView({ - type: 'cliRechecked', - data: { success: true }, - }); - } catch (error) { - console.error('[SettingsMessageHandler] Failed to recheck CLI:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to recheck CLI: ${error}` }, - }); - } - } - - /** - * Set approval mode via agent (ACP session/set_mode) - */ - private async handleSetApprovalMode(data?: { - modeId?: ApprovalModeValue; - }): Promise { - try { - const modeId = data?.modeId || 'default'; - await this.agentManager.setApprovalModeFromUi(modeId); - // No explicit response needed; WebView listens for modeChanged - } catch (error) { - console.error('[SettingsMessageHandler] Failed to set mode:', error); - this.sendToWebView({ - type: 'error', - data: { message: `Failed to set mode: ${error}` }, - }); - } - } -} diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 8f6848c1..b18843ef 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -131,12 +131,55 @@ export function useCompletionTrigger( [getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM], ); + // Helper function to compare completion items arrays + const areItemsEqual = ( + items1: CompletionItem[], + items2: CompletionItem[], + ): boolean => { + if (items1.length !== items2.length) { + return false; + } + + // Compare each item by stable fields (ignore non-deterministic props like icons) + for (let i = 0; i < items1.length; i++) { + const a = items1[i]; + const b = items2[i]; + if (a.id !== b.id) { + return false; + } + if (a.label !== b.label) { + return false; + } + if ((a.description ?? '') !== (b.description ?? '')) { + return false; + } + if (a.type !== b.type) { + return false; + } + if ((a.value ?? '') !== (b.value ?? '')) { + return false; + } + if ((a.path ?? '') !== (b.path ?? '')) { + return false; + } + } + + return true; + }; + const refreshCompletion = useCallback(async () => { if (!state.isOpen || !state.triggerChar) { return; } const items = await getCompletionItems(state.triggerChar, state.query); - setState((prev) => ({ ...prev, items })); + + // Only update state if items have actually changed + setState((prev) => { + if (areItemsEqual(prev.items, items)) { + return prev; + } + return { ...prev, items }; + }); }, [state.isOpen, state.triggerChar, state.query, getCompletionItems]); useEffect(() => { diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts index 31326cc6..e11f4bce 100644 --- a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -62,3 +62,38 @@ export const groupSessionsByDate = ( .filter(([, sessions]) => sessions.length > 0) .map(([label, sessions]) => ({ label, sessions })); }; + +/** + * Time ago formatter + * + * @param timestamp - ISO timestamp string + * @returns Formatted time string + */ +export const getTimeAgo = (timestamp: string): string => { + if (!timestamp) { + return ''; + } + const now = new Date().getTime(); + const then = new Date(timestamp).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'now'; + } + if (diffMins < 60) { + return `${diffMins}m`; + } + if (diffHours < 24) { + return `${diffHours}h`; + } + if (diffDays === 1) { + return 'Yesterday'; + } + if (diffDays < 7) { + return `${diffDays}d`; + } + return new Date(timestamp).toLocaleDateString(); +}; diff --git a/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts b/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts deleted file mode 100644 index b1610597..00000000 --- a/packages/vscode-ide-companion/src/webview/utils/timeUtils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Time ago formatter - * - * @param timestamp - ISO timestamp string - * @returns Formatted time string - */ -export const getTimeAgo = (timestamp: string): string => { - if (!timestamp) { - return ''; - } - const now = new Date().getTime(); - const then = new Date(timestamp).getTime(); - const diffMs = now - then; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) { - return 'now'; - } - if (diffMins < 60) { - return `${diffMins}m`; - } - if (diffHours < 24) { - return `${diffHours}h`; - } - if (diffDays === 1) { - return 'Yesterday'; - } - if (diffDays < 7) { - return `${diffDays}d`; - } - return new Date(timestamp).toLocaleDateString(); -}; From e895c49f5c723f5eb71b9921de7067181e4a2c75 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 13 Dec 2025 09:56:18 +0800 Subject: [PATCH 23/44] fix(vscode-ide-companion): resolve all ESLint errors Fixed unused variable errors in SessionMessageHandler.ts: - Commented out unused conversation and messages variables Also includes previous commits: 1. feat(vscode-ide-companion): add upgrade button to CLI version warning 2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component When the Qwen Code CLI version is below the minimum required version, the warning message now includes an "Upgrade Now" button that opens a terminal and runs the npm install command to upgrade the CLI. Added tests to verify the functionality works correctly. --- .../src/services/qwenAgentManager.ts | 92 +------- .../src/services/qwenConnectionHandler.ts | 11 +- .../src/services/qwenSessionManager.ts | 126 +---------- .../src/services/qwenSessionReader.ts | 196 +++++++++++++++++- .../src/webview/MessageHandler.ts | 7 - .../webview/components/layout/InputForm.tsx | 11 +- .../src/webview/handlers/MessageRouter.ts | 7 - .../webview/handlers/SessionMessageHandler.ts | 111 ---------- 8 files changed, 213 insertions(+), 348 deletions(-) diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index c3aa6525..2475e309 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -336,8 +336,10 @@ export class QwenAgentManager { name: this.sessionReader.getSessionTitle(session), startTime: session.startTime, lastUpdated: session.lastUpdated, - messageCount: session.messages.length, + messageCount: session.messageCount ?? session.messages.length, projectHash: session.projectHash, + filePath: session.filePath, + cwd: session.cwd, }), ); @@ -452,8 +454,10 @@ export class QwenAgentManager { name: this.sessionReader.getSessionTitle(x.raw), startTime: x.raw.startTime, lastUpdated: x.raw.lastUpdated, - messageCount: x.raw.messages.length, + messageCount: x.raw.messageCount ?? x.raw.messages.length, projectHash: x.raw.projectHash, + filePath: x.raw.filePath, + cwd: x.raw.cwd, })); const nextCursorVal = page.length > 0 ? page[page.length - 1].mtime : undefined; @@ -891,80 +895,6 @@ export class QwenAgentManager { return this.saveSessionViaCommand(sessionId, tag); } - /** - * Save session as checkpoint (using CLI format) - * Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json - * Saves two copies with sessionId and conversationId to ensure recovery via either ID - * - * @param messages - Current session messages - * @param conversationId - Conversation ID (from VSCode extension) - * @returns Save result - */ - async saveCheckpoint( - messages: ChatMessage[], - conversationId: string, - ): Promise<{ success: boolean; tag?: string; message?: string }> { - try { - console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); - console.log('[QwenAgentManager] Conversation ID:', conversationId); - console.log('[QwenAgentManager] Message count:', messages.length); - console.log( - '[QwenAgentManager] Current working dir:', - this.currentWorkingDir, - ); - console.log( - '[QwenAgentManager] Current session ID (from CLI):', - this.currentSessionId, - ); - // In ACP mode, the CLI does not accept arbitrary slash commands like - // "/chat save". To ensure we never block on unsupported features, - // persist checkpoints directly to ~/.qwen/tmp using our SessionManager. - const qwenMessages = messages.map((m) => ({ - // Generate minimal QwenMessage shape expected by the writer - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - timestamp: new Date().toISOString(), - type: m.role === 'user' ? ('user' as const) : ('qwen' as const), - content: m.content, - })); - - const tag = await this.sessionManager.saveCheckpoint( - qwenMessages, - conversationId, - this.currentWorkingDir, - this.currentSessionId || undefined, - ); - - return { success: true, tag }; - } catch (error) { - console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED ====='); - console.error('[QwenAgentManager] Error:', error); - console.error( - '[QwenAgentManager] Error stack:', - error instanceof Error ? error.stack : 'N/A', - ); - return { - success: false, - message: error instanceof Error ? error.message : String(error), - }; - } - } - - /** - * Save session directly to file system (without relying on ACP) - * - * @param messages - Current session messages - * @param sessionName - Session name - * @returns Save result - */ - async saveSessionDirect( - messages: ChatMessage[], - sessionName: string, - ): Promise<{ success: boolean; sessionId?: string; message?: string }> { - // Use checkpoint format instead of session format - // This matches CLI's /chat save behavior - return this.saveCheckpoint(messages, sessionName); - } - /** * Try to load session via ACP session/load method * This method will only be used if CLI version supports it @@ -1152,16 +1082,6 @@ export class QwenAgentManager { } } - /** - * Load session, preferring ACP method if CLI version supports it - * - * @param sessionId - Session ID - * @returns Loaded session messages or null - */ - async loadSessionDirect(sessionId: string): Promise { - return this.loadSession(sessionId); - } - /** * Create new session * diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 6a74cd56..91d4c6bf 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -54,9 +54,18 @@ export class QwenConnectionHandler { // Show warning if CLI version is below minimum requirement if (!versionInfo.isSupported) { // Wait to determine release version number - vscode.window.showWarningMessage( + const selection = await vscode.window.showWarningMessage( `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, + 'Upgrade Now', ); + + // Handle the user's selection + if (selection === 'Upgrade Now') { + // Open terminal and run npm install command + const terminal = vscode.window.createTerminal('Qwen Code CLI Upgrade'); + terminal.show(); + terminal.sendText('npm install -g @qwen-code/qwen-code@latest'); + } } const config = vscode.workspace.getConfiguration('qwenCode'); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 2bd609bb..9336a060 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -51,131 +51,7 @@ export class QwenSessionManager { } /** - * Save current conversation as a checkpoint (matching CLI's /chat save format) - * Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility - * - * @param messages - Current conversation messages - * @param conversationId - Conversation ID (from VSCode extension) - * @param sessionId - Session ID (from CLI tmp session file, optional) - * @param workingDir - Current working directory - * @returns Checkpoint tag - */ - async saveCheckpoint( - messages: QwenMessage[], - conversationId: string, - workingDir: string, - sessionId?: string, - ): Promise { - try { - console.log('[QwenSessionManager] ===== SAVEPOINT START ====='); - console.log('[QwenSessionManager] Conversation ID:', conversationId); - console.log( - '[QwenSessionManager] Session ID:', - sessionId || 'not provided', - ); - console.log('[QwenSessionManager] Working dir:', workingDir); - console.log('[QwenSessionManager] Message count:', messages.length); - - // Get project directory (parent of chats directory) - const projectHash = this.getProjectHash(workingDir); - console.log('[QwenSessionManager] Project hash:', projectHash); - - const projectDir = path.join(this.qwenDir, 'tmp', projectHash); - console.log('[QwenSessionManager] Project dir:', projectDir); - - if (!fs.existsSync(projectDir)) { - console.log('[QwenSessionManager] Creating project directory...'); - fs.mkdirSync(projectDir, { recursive: true }); - console.log('[QwenSessionManager] Directory created'); - } else { - console.log('[QwenSessionManager] Project directory already exists'); - } - - // Convert messages to checkpoint format (Gemini-style messages) - console.log( - '[QwenSessionManager] Converting messages to checkpoint format...', - ); - const checkpointMessages = messages.map((msg, index) => { - console.log( - `[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`, - ); - return { - role: msg.type === 'user' ? 'user' : 'model', - parts: [ - { - text: msg.content, - }, - ], - }; - }); - - console.log( - '[QwenSessionManager] Converted', - checkpointMessages.length, - 'messages', - ); - - const jsonContent = JSON.stringify(checkpointMessages, null, 2); - console.log( - '[QwenSessionManager] JSON content length:', - jsonContent.length, - ); - - // Save with conversationId as primary tag - const convFilename = `checkpoint-${conversationId}.json`; - const convFilePath = path.join(projectDir, convFilename); - console.log( - '[QwenSessionManager] Saving checkpoint with conversationId:', - convFilePath, - ); - fs.writeFileSync(convFilePath, jsonContent, 'utf-8'); - - // Also save with sessionId if provided (for compatibility with CLI session/load) - if (sessionId) { - const sessionFilename = `checkpoint-${sessionId}.json`; - const sessionFilePath = path.join(projectDir, sessionFilename); - console.log( - '[QwenSessionManager] Also saving checkpoint with sessionId:', - sessionFilePath, - ); - fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8'); - } - - // Verify primary file exists - if (fs.existsSync(convFilePath)) { - const stats = fs.statSync(convFilePath); - console.log( - '[QwenSessionManager] Primary checkpoint verified, size:', - stats.size, - ); - } else { - console.error( - '[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!', - ); - } - - console.log('[QwenSessionManager] ===== CHECKPOINT SAVED ====='); - console.log('[QwenSessionManager] Primary path:', convFilePath); - if (sessionId) { - console.log( - '[QwenSessionManager] Secondary path (sessionId):', - path.join(projectDir, `checkpoint-${sessionId}.json`), - ); - } - return conversationId; - } catch (error) { - console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED ====='); - console.error('[QwenSessionManager] Error:', error); - console.error( - '[QwenSessionManager] Error stack:', - error instanceof Error ? error.stack : 'N/A', - ); - throw error; - } - } - - /** - * Save current conversation as a named session (checkpoint-like functionality) + * Save current conversation as a named session * * @param messages - Current conversation messages * @param sessionName - Name/tag for the saved session diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 6e2d065d..3fc4e484 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -7,6 +7,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as readline from 'readline'; +import * as crypto from 'crypto'; export interface QwenMessage { id: string; @@ -32,6 +34,9 @@ export interface QwenSession { lastUpdated: string; messages: QwenMessage[]; filePath?: string; + messageCount?: number; + firstUserText?: string; + cwd?: string; } export class QwenSessionReader { @@ -96,11 +101,17 @@ export class QwenSessionReader { return sessions; } - const files = fs - .readdirSync(chatsDir) - .filter((f) => f.startsWith('session-') && f.endsWith('.json')); + const files = fs.readdirSync(chatsDir); - for (const file of files) { + const jsonSessionFiles = files.filter( + (f) => f.startsWith('session-') && f.endsWith('.json'), + ); + + const jsonlSessionFiles = files.filter((f) => + /^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f), + ); + + for (const file of jsonSessionFiles) { const filePath = path.join(chatsDir, file); try { const content = fs.readFileSync(filePath, 'utf-8'); @@ -116,6 +127,23 @@ export class QwenSessionReader { } } + // Support new JSONL session format produced by the CLI + for (const file of jsonlSessionFiles) { + const filePath = path.join(chatsDir, file); + try { + const session = await this.readJsonlSession(filePath, false); + if (session) { + sessions.push(session); + } + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read JSONL session file:', + filePath, + error, + ); + } + } + return sessions; } @@ -128,7 +156,25 @@ export class QwenSessionReader { ): Promise { // First try to find in all projects const sessions = await this.getAllSessions(undefined, true); - return sessions.find((s) => s.sessionId === sessionId) || null; + const found = sessions.find((s) => s.sessionId === sessionId); + + if (!found) { + return null; + } + + // If the session points to a JSONL file, load full content on demand + if ( + found.filePath && + found.filePath.endsWith('.jsonl') && + found.messages.length === 0 + ) { + const hydrated = await this.readJsonlSession(found.filePath, true); + if (hydrated) { + return hydrated; + } + } + + return found; } /** @@ -136,7 +182,6 @@ export class QwenSessionReader { * Qwen CLI uses SHA256 hash of project path */ private async getProjectHash(workingDir: string): Promise { - const crypto = await import('crypto'); return crypto.createHash('sha256').update(workingDir).digest('hex'); } @@ -144,6 +189,14 @@ export class QwenSessionReader { * Get session title (based on first user message) */ getSessionTitle(session: QwenSession): string { + // Prefer cached prompt text to avoid loading messages for JSONL sessions + if (session.firstUserText) { + return ( + session.firstUserText.substring(0, 50) + + (session.firstUserText.length > 50 ? '...' : '') + ); + } + const firstUserMessage = session.messages.find((m) => m.type === 'user'); if (firstUserMessage) { // Extract first 50 characters as title @@ -155,6 +208,137 @@ export class QwenSessionReader { return 'Untitled Session'; } + /** + * Parse a JSONL session file written by the CLI. + * When includeMessages is false, only lightweight metadata is returned. + */ + private async readJsonlSession( + filePath: string, + includeMessages: boolean, + ): Promise { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + const stats = fs.statSync(filePath); + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const messages: QwenMessage[] = []; + const seenUuids = new Set(); + let sessionId: string | undefined; + let startTime: string | undefined; + let firstUserText: string | undefined; + let cwd: string | undefined; + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (!sessionId && typeof obj.sessionId === 'string') { + sessionId = obj.sessionId; + } + if (!startTime && typeof obj.timestamp === 'string') { + startTime = obj.timestamp; + } + if (!cwd && typeof obj.cwd === 'string') { + cwd = obj.cwd; + } + + const type = typeof obj.type === 'string' ? obj.type : ''; + if (type === 'user' || type === 'assistant') { + const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined; + if (uuid) { + seenUuids.add(uuid); + } + + const text = this.contentToText(obj.message); + if (includeMessages) { + messages.push({ + id: uuid || `${messages.length}`, + timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '', + type: type === 'user' ? 'user' : 'qwen', + content: text, + }); + } + + if (!firstUserText && type === 'user' && text) { + firstUserText = text; + } + } + } + + // Ensure stream is closed + rl.close(); + + if (!sessionId) { + return null; + } + + const projectHash = cwd + ? await this.getProjectHash(cwd) + : path.basename(path.dirname(path.dirname(filePath))); + + return { + sessionId, + projectHash, + startTime: startTime || new Date(stats.birthtimeMs).toISOString(), + lastUpdated: new Date(stats.mtimeMs).toISOString(), + messages: includeMessages ? messages : [], + filePath, + messageCount: seenUuids.size, + firstUserText, + cwd, + }; + } catch (error) { + console.error( + '[QwenSessionReader] Failed to parse JSONL session:', + error, + ); + return null; + } + } + + // Extract plain text from CLI Content structure + private contentToText(message: unknown): string { + try { + if (typeof message !== 'object' || message === null) { + return ''; + } + + const typed = message as { parts?: unknown[] }; + const parts = Array.isArray(typed.parts) ? typed.parts : []; + const texts: string[] = []; + for (const part of parts) { + if (typeof part !== 'object' || part === null) { + continue; + } + const p = part as Record; + if (typeof p.text === 'string') { + texts.push(p.text); + } else if (typeof p.data === 'string') { + texts.push(p.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + /** * Delete session file */ diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts index 1eca4a20..77d330b6 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -73,11 +73,4 @@ export class MessageHandler { appendStreamContent(chunk: string): void { this.router.appendStreamContent(chunk); } - - /** - * Check if saving checkpoint - */ - getIsSavingCheckpoint(): boolean { - return this.router.getIsSavingCheckpoint(); - } } diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 5c4a889a..73f3fa26 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -11,7 +11,7 @@ import { PlanModeIcon, CodeBracketsIcon, HideContextIcon, - ThinkingIcon, + // ThinkingIcon, // Temporarily disabled SlashCommandIcon, LinkIcon, ArrowUpIcon, @@ -92,7 +92,7 @@ export const InputForm: React.FC = ({ isWaitingForResponse, isComposing, editMode, - thinkingEnabled, + // thinkingEnabled, // Temporarily disabled activeFileName, activeSelection, skipAutoActiveContext, @@ -103,7 +103,7 @@ export const InputForm: React.FC = ({ onSubmit, onCancel, onToggleEditMode, - onToggleThinking, + // onToggleThinking, // Temporarily disabled onToggleSkipAutoActiveContext, onShowCommandMenu, onAttachContext, @@ -236,15 +236,16 @@ export const InputForm: React.FC = ({ {/* Spacer */}
+ {/* @yiliang114. closed temporarily */} {/* Thinking button */} - + */} {/* Command button */} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx b/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx new file mode 100644 index 00000000..945f86a4 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx @@ -0,0 +1,81 @@ +import type React from 'react'; +import { generateIconUrl } from '../../utils/resourceUrl.js'; + +interface OnboardingPageProps { + onLogin: () => void; +} + +export const OnboardingPage: React.FC = ({ onLogin }) => { + const iconUri = generateIconUrl('icon.png'); + + return ( +
+
+
+
+ Qwen Code Logo +
+ + + +
+
+ +
+

+ Welcome to Qwen Code +

+

+ Qwen Code helps you understand, navigate, and transform your + codebase with AI assistance. +

+
+ + {/*
+
+

Get Started

+
    +
  • +
    + Understand complex codebases faster +
  • +
  • +
    + Navigate with AI-powered suggestions +
  • +
  • +
    + Transform code with confidence +
  • +
+
+ +
*/} + + + + {/*
+

+ By logging in, you agree to the Terms of Service and Privacy + Policy. +

+
*/} +
+
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts index 9f67bcc8..a91594c0 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -14,6 +14,7 @@ interface UseMessageSubmitProps { setInputText: (text: string) => void; inputFieldRef: React.RefObject; isStreaming: boolean; + isWaitingForResponse: boolean; // When true, do NOT auto-attach the active editor file/selection to context skipAutoActiveContext?: boolean; @@ -40,6 +41,7 @@ export const useMessageSubmit = ({ setInputText, inputFieldRef, isStreaming, + isWaitingForResponse, skipAutoActiveContext = false, fileContext, messageHandling, @@ -48,7 +50,7 @@ export const useMessageSubmit = ({ (e: React.FormEvent) => { e.preventDefault(); - if (!inputText.trim() || isStreaming) { + if (!inputText.trim() || isStreaming || isWaitingForResponse) { return; } @@ -56,7 +58,10 @@ export const useMessageSubmit = ({ if (inputText.trim() === '/login') { setInputText(''); if (inputFieldRef.current) { - inputFieldRef.current.textContent = ''; + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); } vscode.postMessage({ type: 'login', @@ -142,7 +147,10 @@ export const useMessageSubmit = ({ setInputText(''); if (inputFieldRef.current) { - inputFieldRef.current.textContent = ''; + // Use a zero-width space to maintain the height of the contentEditable element + inputFieldRef.current.textContent = '\u200B'; + // Set the data-empty attribute to show the placeholder + inputFieldRef.current.setAttribute('data-empty', 'true'); } fileContext.clearFileReferences(); }, @@ -154,6 +162,7 @@ export const useMessageSubmit = ({ vscode, fileContext, skipAutoActiveContext, + isWaitingForResponse, messageHandling, ], ); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 7a3f7e06..a227e470 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -109,6 +109,8 @@ interface UseWebViewMessagesProps { setInputText: (text: string) => void; // Edit mode setter (maps ACP modes to UI modes) setEditMode?: (mode: ApprovalModeValue) => void; + // Authentication state setter + setIsAuthenticated?: (authenticated: boolean | null) => void; } /** @@ -126,6 +128,7 @@ export const useWebViewMessages = ({ inputFieldRef, setInputText, setEditMode, + setIsAuthenticated, }: UseWebViewMessagesProps) => { // VS Code API for posting messages back to the extension host const vscode = useVSCode(); @@ -141,6 +144,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + setIsAuthenticated, }); // Track last "Updated Plan" snapshot toolcall to support merge/dedupe @@ -185,6 +189,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + setIsAuthenticated, }; }); @@ -216,6 +221,7 @@ export const useWebViewMessages = ({ } break; } + case 'loginSuccess': { // Clear loading state and show a short assistant notice handlers.messageHandling.clearWaitingForResponse(); @@ -224,12 +230,16 @@ export const useWebViewMessages = ({ content: 'Successfully logged in. You can continue chatting.', timestamp: Date.now(), }); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); break; } case 'agentConnected': { // Agent connected successfully; clear any pending spinner handlers.messageHandling.clearWaitingForResponse(); + // Set authentication state to true + handlers.setIsAuthenticated?.(true); break; } @@ -245,6 +255,8 @@ export const useWebViewMessages = ({ content: `Failed to connect to Qwen agent: ${errorMsg}\nYou can still use the chat UI, but messages won't be sent to AI.`, timestamp: Date.now(), }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); break; } @@ -259,6 +271,20 @@ export const useWebViewMessages = ({ content: errorMsg, timestamp: Date.now(), }); + // Set authentication state to false + handlers.setIsAuthenticated?.(false); + break; + } + + case 'authState': { + const state = ( + message?.data as { authenticated?: boolean | null } | undefined + )?.authenticated; + if (typeof state === 'boolean') { + handlers.setIsAuthenticated?.(state); + } else { + handlers.setIsAuthenticated?.(null); + } break; } @@ -303,6 +329,7 @@ export const useWebViewMessages = ({ } } } + console.log('[useWebViewMessages1111]__________ other message:', msg); break; } @@ -336,7 +363,7 @@ export const useWebViewMessages = ({ const reason = ( (message.data as { reason?: string } | undefined)?.reason || '' ).toLowerCase(); - if (reason === 'user_cancelled') { + if (reason === 'user_cancelled' || reason === 'cancelled') { activeExecToolCallsRef.current.clear(); handlers.messageHandling.clearWaitingForResponse(); break; diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index 4c4b5e08..46d803d5 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -51,8 +51,7 @@ .composer-form:focus-within { /* match existing highlight behavior */ border-color: var(--app-input-highlight); - box-shadow: 0 1px 2px - color-mix(in srgb, var(--app-input-highlight), transparent 80%); + box-shadow: 0 1px 2px color-mix(in srgb, var(--app-input-highlight), transparent 80%); } /* Composer: input editable area */ @@ -67,7 +66,7 @@ The data attribute is needed because some browsers insert a
in contentEditable, which breaks :empty matching. */ .composer-input:empty:before, - .composer-input[data-empty='true']::before { + .composer-input[data-empty="true"]::before { content: attr(data-placeholder); color: var(--app-input-placeholder-foreground); pointer-events: none; @@ -81,7 +80,7 @@ outline: none; } .composer-input:disabled, - .composer-input[contenteditable='false'] { + .composer-input[contenteditable="false"] { color: #999; cursor: not-allowed; } @@ -111,7 +110,7 @@ } .btn-text-compact > svg { height: 1em; - width: 1em; + width: 1em; flex-shrink: 0; } .btn-text-compact > span { diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css index 25d5cc85..033e82d2 100644 --- a/packages/vscode-ide-companion/src/webview/styles/timeline.css +++ b/packages/vscode-ide-companion/src/webview/styles/timeline.css @@ -88,6 +88,22 @@ z-index: 0; } +/* Single-item AI sequence (both a start and an end): hide the connector entirely */ +.qwen-message.message-item:not(.user-message-container):is( + :first-child, + .user-message-container + + .qwen-message.message-item:not(.user-message-container), + .chat-messages + > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container) + ):is( + :has(+ .user-message-container), + :has(+ :not(.qwen-message.message-item)), + :last-child + )::after { + display: none; +} + /* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */ .qwen-message.message-item:not(.user-message-container):first-child::after, .user-message-container + .qwen-message.message-item:not(.user-message-container)::after, @@ -123,4 +139,4 @@ position: relative; padding-top: 8px; padding-bottom: 8px; -} \ No newline at end of file +} From bca288e742723e2e3938994f544a16bf6baf60a7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 13 Dec 2025 16:28:58 +0800 Subject: [PATCH 26/44] wip(vscode-ide-companion): OnboardingPage --- .../src/cli/cliDetector.ts | 57 +++++++++----- .../src/cli/cliVersionChecker.ts | 78 +++++++++++++++++++ .../src/services/acpConnection.ts | 4 +- .../src/services/acpMessageHandler.ts | 10 --- .../src/services/qwenAgentManager.ts | 40 ++-------- .../src/services/qwenConnectionHandler.ts | 11 +-- .../src/utils/authErrors.ts | 35 +++++++-- .../vscode-ide-companion/src/webview/App.tsx | 4 +- .../src/webview/WebViewProvider.ts | 30 ++++++- .../webview/components/layout/InputForm.tsx | 5 +- .../{OnboardingPage.tsx => Onboarding.tsx} | 40 +++------- .../src/webview/hooks/useWebViewMessages.ts | 59 +++++++++----- 12 files changed, 235 insertions(+), 138 deletions(-) create mode 100644 packages/vscode-ide-companion/src/cli/cliVersionChecker.ts rename packages/vscode-ide-companion/src/webview/components/layout/{OnboardingPage.tsx => Onboarding.tsx} (51%) diff --git a/packages/vscode-ide-companion/src/cli/cliDetector.ts b/packages/vscode-ide-companion/src/cli/cliDetector.ts index c1de868a..c59389d8 100644 --- a/packages/vscode-ide-companion/src/cli/cliDetector.ts +++ b/packages/vscode-ide-companion/src/cli/cliDetector.ts @@ -25,17 +25,36 @@ export class CliDetector { private static readonly CACHE_DURATION_MS = 30000; // 30 seconds /** - * Lightweight check if the Qwen Code CLI is installed - * This version only checks for CLI existence without getting version info for faster performance - * @param forceRefresh - Force a new check, ignoring cache - * @returns Detection result with installation status and path + * Lightweight CLI Detection Method + * + * This method is designed for performance optimization, checking only if the CLI exists + * without retrieving version information. + * Suitable for quick detection scenarios, such as pre-checks before initializing connections. + * + * Compared to the full detectQwenCli method, this method: + * - Omits version information retrieval step + * - Uses shorter timeout (3 seconds) + * - Faster response time + * + * @param forceRefresh - Whether to force refresh cached results, default is false + * @returns Promise - Detection result containing installation status and path + * + * @example + * ```typescript + * const result = await CliDetector.detectQwenCliLightweight(); + * if (result.isInstalled) { + * console.log('CLI installed at:', result.cliPath); + * } else { + * console.log('CLI not found:', result.error); + * } + * ``` */ static async detectQwenCliLightweight( forceRefresh = false, ): Promise { const now = Date.now(); - // Return cached result if available and not expired + // Check if cached result is available and not expired (30-second validity) if ( !forceRefresh && this.cachedResult && @@ -56,7 +75,7 @@ export class CliDetector { // Check if qwen command exists try { - // Use simpler detection without NVM for speed + // Use simplified detection without NVM for speed const detectionCommand = isWindows ? `${whichCommand} qwen` : `${whichCommand} qwen`; @@ -66,32 +85,34 @@ export class CliDetector { detectionCommand, ); + // Execute command to detect CLI path, set shorter timeout (3 seconds) const { stdout } = await execAsync(detectionCommand, { timeout: 3000, // Reduced timeout for faster detection shell: isWindows ? undefined : '/bin/bash', }); - // The output may contain multiple lines - // We want the first line which should be the actual path + // Output may contain multiple lines, get first line as actual path const lines = stdout .trim() .split('\n') .filter((line) => line.trim()); - const cliPath = lines[0]; // Just take the first path + const cliPath = lines[0]; // Take only the first path console.log('[CliDetector] Found CLI at:', cliPath); + // Build successful detection result, note no version information this.cachedResult = { isInstalled: true, cliPath, - // Version is not retrieved in lightweight detection + // Version information not retrieved in lightweight detection }; this.lastCheckTime = now; return this.cachedResult; } catch (detectionError) { console.log('[CliDetector] CLI not found, error:', detectionError); - // CLI not found - let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`; + + // CLI not found, build error message + let error = `Qwen Code CLI not found in PATH. Please install using: npm install -g @qwen-code/qwen-code@latest`; // Provide specific guidance for permission errors if (detectionError instanceof Error) { @@ -100,11 +121,11 @@ export class CliDetector { errorMessage.includes('EACCES') || errorMessage.includes('Permission denied') ) { - error += `\n\nThis may be due to permission issues. Possible solutions: - \n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest + error += `\n\nThis may be due to permission issues. Solutions: + \n1. Reinstall CLI without sudo: npm install -g @qwen-code/qwen-code@latest \n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code \n3. Use nvm for Node.js version management to avoid permission issues - \n4. Check your PATH environment variable includes npm's global bin directory`; + \n4. Check PATH environment variable includes npm's global bin directory`; } } @@ -127,11 +148,11 @@ export class CliDetector { errorMessage.includes('EACCES') || errorMessage.includes('Permission denied') ) { - userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions: - \n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest + userFriendlyError += `\n\nThis may be due to permission issues. Solutions: + \n1. Reinstall CLI without sudo: npm install -g @qwen-code/qwen-code@latest \n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code \n3. Use nvm for Node.js version management to avoid permission issues - \n4. Check your PATH environment variable includes npm's global bin directory`; + \n4. Check PATH environment variable includes npm's global bin directory`; } this.cachedResult = { diff --git a/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts b/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts new file mode 100644 index 00000000..3a9db333 --- /dev/null +++ b/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { CliContextManager } from './cliContextManager.js'; +import { CliVersionManager } from './cliVersionManager.js'; +import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from './cliVersionManager.js'; +import type { CliVersionInfo } from './cliVersionManager.js'; + +/** + * Check CLI version and show warning if below minimum requirement + * + * @returns Version information + */ +export async function checkCliVersionAndWarn(): Promise { + try { + const cliContextManager = CliContextManager.getInstance(); + const versionInfo = + await CliVersionManager.getInstance().detectCliVersion(true); + cliContextManager.setCurrentVersionInfo(versionInfo); + + if (!versionInfo.isSupported) { + vscode.window.showWarningMessage( + `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, + ); + } + } catch (error) { + console.error('[CliVersionChecker] Failed to check CLI version:', error); + } +} + +/** + * Process server version information from initialize response + * + * @param init - Initialize response object + */ +export function processServerVersion(init: unknown): void { + try { + const obj = (init || {}) as Record; + + // Extract version information from initialize response + const serverVersion = + obj['version'] || obj['serverVersion'] || obj['cliVersion']; + if (serverVersion) { + console.log( + '[CliVersionChecker] Server version from initialize response:', + serverVersion, + ); + + // Update CLI context with version info from server + const cliContextManager = CliContextManager.getInstance(); + + // Create version info directly without async call + const versionInfo: CliVersionInfo = { + version: String(serverVersion), + isSupported: true, // Assume supported for now + features: { + supportsSessionList: true, + supportsSessionLoad: true, + }, + detectionResult: { + isInstalled: true, + version: String(serverVersion), + }, + }; + + cliContextManager.setCurrentVersionInfo(versionInfo); + } + } catch (error) { + console.error( + '[CliVersionChecker] Failed to process server version:', + error, + ); + } +} diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index f999c602..f4c95948 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -42,9 +42,7 @@ export class AcpConnection { onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ optionId: string; }> = () => Promise.resolve({ optionId: 'allow' }); - onEndTurn: (reason?: string) => void = (reason?: string | undefined) => { - console.log('[ACP] onEndTurn__________ reason:', reason || 'unknown'); - }; + onEndTurn: () => void = () => {}; // Called after successful initialize() with the initialize result onInitialized: (init: unknown) => void = () => {}; diff --git a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts index e07cedfb..7e4a0ef0 100644 --- a/packages/vscode-ide-companion/src/services/acpMessageHandler.ts +++ b/packages/vscode-ide-companion/src/services/acpMessageHandler.ts @@ -111,16 +111,6 @@ export class AcpMessageHandler { message.result, ); - console.log( - '[ACP] Response for message.result:', - message.result, - message.result && - typeof message.result === 'object' && - 'stopReason' in message.result, - - !!callbacks.onEndTurn, - ); - if (message.result && typeof message.result === 'object') { const stopReasonValue = (message.result as { stopReason?: unknown }).stopReason ?? diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 3c701331..a8bb61fe 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -24,10 +24,8 @@ import { import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; import { CliContextManager } from '../cli/cliContextManager.js'; import { authMethod } from '../types/acpTypes.js'; -import { - MIN_CLI_VERSION_FOR_SESSION_METHODS, - type CliVersionInfo, -} from '../cli/cliVersionManager.js'; +import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from '../cli/cliVersionManager.js'; +import { processServerVersion } from '../cli/cliVersionChecker.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; @@ -149,37 +147,10 @@ export class QwenAgentManager { // Initialize callback to surface available modes and current mode to UI this.connection.onInitialized = (init: unknown) => { try { + // Process server version information + processServerVersion(init); + const obj = (init || {}) as Record; - - // Extract version information from initialize response - const serverVersion = - obj['version'] || obj['serverVersion'] || obj['cliVersion']; - if (serverVersion) { - console.log( - '[QwenAgentManager] Server version from initialize response:', - serverVersion, - ); - - // Update CLI context with version info from server - const cliContextManager = CliContextManager.getInstance(); - - // Create version info directly without async call - const versionInfo: CliVersionInfo = { - version: String(serverVersion), - isSupported: true, // Assume supported for now - features: { - supportsSessionList: true, - supportsSessionLoad: true, - }, - detectionResult: { - isInstalled: true, - version: String(serverVersion), - }, - }; - - cliContextManager.setCurrentVersionInfo(versionInfo); - } - const modes = obj['modes'] as | { currentModeId?: 'plan' | 'default' | 'auto-edit' | 'yolo'; @@ -1369,7 +1340,6 @@ export class QwenAgentManager { * @param callback - Called when ACP stopReason is reported */ onEndTurn(callback: (reason?: string) => void): void { - console.log('[QwenAgentManager] onEndTurn__________ callback:', callback); this.callbacks.onEndTurn = callback; this.sessionUpdateHandler.updateCallbacks(this.callbacks); } diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index e35ce64c..2fbdd406 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -10,12 +10,12 @@ * Handles Qwen Agent connection establishment, authentication, and session creation */ -// import * as vscode from 'vscode'; import type { AcpConnection } from './acpConnection.js'; import type { QwenSessionReader } from '../services/qwenSessionReader.js'; import { CliDetector } from '../cli/cliDetector.js'; import { authMethod } from '../types/acpTypes.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +import { checkCliVersionAndWarn } from '../cli/cliVersionChecker.js'; export interface QwenConnectionResult { sessionCreated: boolean; @@ -59,19 +59,12 @@ export class QwenConnectionHandler { } console.log('[QwenAgentManager] CLI detected at:', detectionResult.cliPath); - // TODO: @yiliang114. closed temporarily // Show warning if CLI version is below minimum requirement - // if (!versionInfo.isSupported) { - // // Wait to determine release version number - // vscode.window.showWarningMessage( - // `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, - // ); - // } + await checkCliVersionAndWarn(); // Build extra CLI arguments (only essential parameters) const extraArgs: string[] = []; - // TODO: await connection.connect(cliPath!, workingDir, extraArgs); // Try to restore existing session or create new session diff --git a/packages/vscode-ide-companion/src/utils/authErrors.ts b/packages/vscode-ide-companion/src/utils/authErrors.ts index 4b7de266..d598d8cc 100644 --- a/packages/vscode-ide-companion/src/utils/authErrors.ts +++ b/packages/vscode-ide-companion/src/utils/authErrors.ts @@ -4,23 +4,48 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Authentication Error Utility + * + * Used to uniformly identify and handle various authentication-related error messages. + * Determines if re-authentication is needed by matching predefined error patterns. + * + * @param error - The error object or string to check + * @returns true if it's an authentication-related error, false otherwise + */ const AUTH_ERROR_PATTERNS = [ - 'Authentication required', - '(code: -32000)', - 'Unauthorized', - 'Invalid token', - 'Session expired', + 'Authentication required', // Standard authentication request message + '(code: -32000)', // RPC error code -32000 indicates authentication failure + 'Unauthorized', // HTTP unauthorized error + 'Invalid token', // Invalid token + 'Session expired', // Session expired ]; +/** + * Determines if the given error is authentication-related + * + * This function detects various forms of authentication errors, including: + * - Direct error objects + * - String-form error messages + * - Other types of errors converted to strings for pattern matching + * + * @param error - The error object to check, can be an Error instance, string, or other type + * @returns boolean - true if the error is authentication-related, false otherwise + */ export const isAuthenticationRequiredError = (error: unknown): boolean => { + // Null check to avoid unnecessary processing if (!error) { return false; } + + // Extract error message text const message = error instanceof Error ? error.message : typeof error === 'string' ? error : String(error); + + // Match authentication-related errors using predefined patterns return AUTH_ERROR_PATTERNS.some((pattern) => message.includes(pattern)); }; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index d083740c..ca9b86b4 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -29,7 +29,7 @@ import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js'; import { EmptyState } from './components/layout/EmptyState.js'; -import { OnboardingPage } from './components/layout/OnboardingPage.js'; +import { Onboarding } from './components/layout/Onboarding.js'; import { type CompletionItem } from '../types/completionItemTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { ChatHeader } from './components/layout/ChatHeader.js'; @@ -670,7 +670,7 @@ export const App: React.FC = () => { > {!hasContent ? ( isAuthenticated === false ? ( - { vscode.postMessage({ type: 'login', data: {} }); messageHandling.setWaitingForResponse( diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 34e6d24f..fa9506f1 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -17,6 +17,19 @@ import { getFileName } from './utils/webviewUtils.js'; import { type ApprovalModeValue } from '../types/acpTypes.js'; import { isAuthenticationRequiredError } from '../utils/authErrors.js'; +/** + * WebView Provider Class + * + * Manages the WebView panel lifecycle, agent connection, and message handling. + * Acts as the central coordinator between VS Code extension and WebView UI. + * + * Key responsibilities: + * - WebView panel creation and management + * - Qwen agent connection and session management + * - Message routing between extension and WebView + * - Authentication state handling + * - Permission request processing + */ export class WebViewProvider { private panelManager: PanelManager; private messageHandler: MessageHandler; @@ -122,7 +135,6 @@ export class WebViewProvider { // Setup end-turn handler from ACP stopReason notifications this.agentManager.onEndTurn((reason) => { - console.log(' ============== ', reason); // Ensure WebView exits streaming state even if no explicit streamEnd was emitted elsewhere this.sendMessageToWebView({ type: 'streamEnd', @@ -521,6 +533,12 @@ export class WebViewProvider { /** * Attempt to restore authentication state and initialize connection * This is called when the webview is first shown + * + * This method tries to establish a connection without forcing authentication, + * allowing detection of existing authentication state. If connection fails, + * initializes an empty conversation to allow browsing history. + * + * @returns Promise - Resolves when auth state restoration attempt is complete */ private async attemptAuthStateRestoration(): Promise { try { @@ -550,6 +568,16 @@ export class WebViewProvider { /** * Internal: perform actual connection/initialization (no auth locking). + * + * This method handles the complete agent connection and initialization workflow: + * 1. Detects if Qwen CLI is installed + * 2. If CLI is not installed, prompts user for installation + * 3. If CLI is installed, attempts to connect to the agent + * 4. Handles authentication requirements and session creation + * 5. Notifies WebView of connection status + * + * @param options - Connection options including auto-authentication setting + * @returns Promise - Resolves when initialization is complete */ private async doInitializeAgentConnection(options?: { autoAuthenticate?: boolean; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 997c3f9a..1982b18f 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -172,7 +172,7 @@ export const InputForm: React.FC = ({
= ({ : 'false' } onInput={(e) => { - if (composerDisabled) { - return; - } const target = e.target as HTMLDivElement; // Filter out zero-width space that we use to maintain height const text = target.textContent?.replace(/\u200B/g, '') || ''; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx similarity index 51% rename from packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx rename to packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index 945f86a4..5bafcfc3 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/OnboardingPage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -1,23 +1,30 @@ -import type React from 'react'; +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import { generateIconUrl } from '../../utils/resourceUrl.js'; interface OnboardingPageProps { onLogin: () => void; } -export const OnboardingPage: React.FC = ({ onLogin }) => { +export const Onboarding: React.FC = ({ onLogin }) => { const iconUri = generateIconUrl('icon.png'); return (
+ {/* Application icon container with brand logo and decorative close icon */}
Qwen Code Logo + {/* Decorative close icon for enhanced visual effect */}
= ({ onLogin }) => {
+ {/* Text content area */}

Welcome to Qwen Code @@ -40,40 +48,12 @@ export const OnboardingPage: React.FC = ({ onLogin }) => {

- {/*
-
-

Get Started

-
    -
  • -
    - Understand complex codebases faster -
  • -
  • -
    - Navigate with AI-powered suggestions -
  • -
  • -
    - Transform code with confidence -
  • -
-
- -
*/} - - - {/*
-

- By logging in, you agree to the Terms of Service and Privacy - Policy. -

-
*/}
diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index a227e470..dc9a33b7 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -329,7 +329,6 @@ export const useWebViewMessages = ({ } } } - console.log('[useWebViewMessages1111]__________ other message:', msg); break; } @@ -351,30 +350,42 @@ export const useWebViewMessages = ({ } case 'streamEnd': { - // Always end local streaming state and collapse any thoughts + // Always end local streaming state and clear thinking state handlers.messageHandling.endStreaming(); handlers.messageHandling.clearThinking(); - // 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. + // If stream ended due to explicit user cancellation, proactively clear + // waiting indicator and reset tracked execution calls. + // This avoids UI getting stuck with Stop button visible after + // rejecting a permission request. try { const reason = ( (message.data as { reason?: string } | undefined)?.reason || '' ).toLowerCase(); + + /** + * Handle different types of stream end reasons: + * - 'user_cancelled': User explicitly cancelled operation + * - 'cancelled': General cancellation + * For these cases, immediately clear all active states + */ if (reason === 'user_cancelled' || reason === 'cancelled') { + // Clear active execution tool call tracking, reset state activeExecToolCallsRef.current.clear(); + // Clear waiting response state to ensure UI returns to normal handlers.messageHandling.clearWaitingForResponse(); break; } } catch (_error) { - // best-effort + // Best-effort handling, errors don't affect main flow } - // 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. + /** + * For other types of stream end (non-user cancellation): + * Only clear generic waiting indicator when 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(); } @@ -575,15 +586,21 @@ export const useWebViewMessages = ({ // While long-running tools (e.g., execute/bash/command) are in progress, // surface a lightweight loading indicator and expose the Stop button. try { + const id = (toolCallData.toolCallId || '').toString(); const kind = (toolCallData.kind || '').toString().toLowerCase(); - const isExec = + const isExecKind = kind === 'execute' || kind === 'bash' || kind === 'command'; + // CLI sometimes omits kind in tool_call_update payloads; fall back to + // whether we've already tracked this ID as an exec tool. + const wasTrackedExec = activeExecToolCallsRef.current.has(id); + const isExec = isExecKind || wasTrackedExec; - if (isExec) { - const id = (toolCallData.toolCallId || '').toString(); + if (!isExec || !id) { + break; + } - // Maintain the active set by status - if (status === 'pending' || status === 'in_progress') { + if (status === 'pending' || status === 'in_progress') { + if (isExecKind) { activeExecToolCallsRef.current.add(id); // Build a helpful hint from rawInput @@ -597,14 +614,14 @@ export const useWebViewMessages = ({ } const hint = cmd ? `Running: ${cmd}` : 'Running command...'; handlers.messageHandling.setWaitingForResponse(hint); - } else if (status === 'completed' || status === 'failed') { - activeExecToolCallsRef.current.delete(id); } + } else if (status === 'completed' || status === 'failed') { + activeExecToolCallsRef.current.delete(id); + } - // If no active exec tool remains, clear the waiting message. - if (activeExecToolCallsRef.current.size === 0) { - handlers.messageHandling.clearWaitingForResponse(); - } + // If no active exec tool remains, clear the waiting message. + if (activeExecToolCallsRef.current.size === 0) { + handlers.messageHandling.clearWaitingForResponse(); } } catch (_error) { // Best-effort UI hint; ignore errors From f8aeb068237418f38a7c378e845f87ebfbbc2ac1 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 13 Dec 2025 16:41:03 +0800 Subject: [PATCH 27/44] remove obsolete corgi mode --- .../src/services/BuiltinCommandLoader.test.ts | 1 - .../cli/src/services/BuiltinCommandLoader.ts | 2 -- .../cli/src/test-utils/mockCommandContext.ts | 1 - packages/cli/src/ui/AppContainer.tsx | 5 --- .../cli/src/ui/commands/corgiCommand.test.ts | 34 ------------------- packages/cli/src/ui/commands/corgiCommand.ts | 17 ---------- packages/cli/src/ui/commands/types.ts | 2 -- .../cli/src/ui/components/Composer.test.tsx | 3 +- packages/cli/src/ui/components/Footer.tsx | 12 ------- .../cli/src/ui/contexts/UIStateContext.tsx | 1 - .../ui/hooks/slashCommandProcessor.test.ts | 2 -- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 -- .../src/ui/noninteractive/nonInteractiveUi.ts | 1 - packages/cli/tsconfig.json | 1 - 14 files changed, 1 insertion(+), 83 deletions(-) delete mode 100644 packages/cli/src/ui/commands/corgiCommand.test.ts delete mode 100644 packages/cli/src/ui/commands/corgiCommand.ts diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index 9d649b2f..7c8e6fc5 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -58,7 +58,6 @@ vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} })); vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} })); vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} })); vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} })); -vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} })); vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} })); vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} })); vi.mock('../ui/commands/extensionsCommand.js', () => ({ diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 100fbef9..d3877a8a 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -15,7 +15,6 @@ import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; -import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; @@ -63,7 +62,6 @@ export class BuiltinCommandLoader implements ICommandLoader { clearCommand, compressCommand, copyCommand, - corgiCommand, docsCommand, directoryCommand, editorCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index eced7df1..fd825b9d 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -56,7 +56,6 @@ export const createMockCommandContext = ( pendingItem: null, setPendingItem: vi.fn(), loadHistory: vi.fn(), - toggleCorgiMode: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index edda3d4d..236daf23 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -136,7 +136,6 @@ export const AppContainer = (props: AppContainerProps) => { const { settings, config, initializationResult } = props; const historyManager = useHistory(); useMemoryMonitor(historyManager); - const [corgiMode, setCorgiMode] = useState(false); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null @@ -485,7 +484,6 @@ export const AppContainer = (props: AppContainerProps) => { }, 100); }, setDebugMessage, - toggleCorgiMode: () => setCorgiMode((prev) => !prev), dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, openSubagentCreateDialog, @@ -498,7 +496,6 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openModelDialog, setDebugMessage, - setCorgiMode, dispatchExtensionStateUpdate, openPermissionsDialog, openApprovalModeDialog, @@ -1218,7 +1215,6 @@ export const AppContainer = (props: AppContainerProps) => { qwenAuthState, editorError, isEditorDialogOpen, - corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, @@ -1309,7 +1305,6 @@ export const AppContainer = (props: AppContainerProps) => { qwenAuthState, editorError, isEditorDialogOpen, - corgiMode, debugMessage, quittingMessages, isSettingsDialogOpen, diff --git a/packages/cli/src/ui/commands/corgiCommand.test.ts b/packages/cli/src/ui/commands/corgiCommand.test.ts deleted file mode 100644 index 3c25e8cd..00000000 --- a/packages/cli/src/ui/commands/corgiCommand.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { corgiCommand } from './corgiCommand.js'; -import { type CommandContext } from './types.js'; -import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; - -describe('corgiCommand', () => { - let mockContext: CommandContext; - - beforeEach(() => { - mockContext = createMockCommandContext(); - vi.spyOn(mockContext.ui, 'toggleCorgiMode'); - }); - - it('should call the toggleCorgiMode function on the UI context', async () => { - if (!corgiCommand.action) { - throw new Error('The corgi command must have an action.'); - } - - await corgiCommand.action(mockContext, ''); - - expect(mockContext.ui.toggleCorgiMode).toHaveBeenCalledTimes(1); - }); - - it('should have the correct name and description', () => { - expect(corgiCommand.name).toBe('corgi'); - expect(corgiCommand.description).toBe('Toggles corgi mode.'); - }); -}); diff --git a/packages/cli/src/ui/commands/corgiCommand.ts b/packages/cli/src/ui/commands/corgiCommand.ts deleted file mode 100644 index 2da6ad3e..00000000 --- a/packages/cli/src/ui/commands/corgiCommand.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandKind, type SlashCommand } from './types.js'; - -export const corgiCommand: SlashCommand = { - name: 'corgi', - description: 'Toggles corgi mode.', - hidden: true, - kind: CommandKind.BUILT_IN, - action: (context, _args) => { - context.ui.toggleCorgiMode(); - }, -}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index a2a352cb..f2ec2173 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -64,8 +64,6 @@ export interface CommandContext { * @param history The array of history items to load. */ loadHistory: UseHistoryManagerReturn['loadHistory']; - /** Toggles a special display mode. */ - toggleCorgiMode: () => void; toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 084cd746..d660d704 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => }, branchName: 'main', debugMessage: '', - corgiMode: false, errorCount: 0, nightly: false, isTrustedFolder: true, @@ -183,6 +182,7 @@ describe('Composer', () => { const { lastFrame } = renderComposer(uiState, settings); + // Smoke check that the Footer renders when enabled. expect(lastFrame()).toContain('Footer'); }); @@ -200,7 +200,6 @@ describe('Composer', () => { it('passes correct props to Footer including vim mode when enabled', async () => { const uiState = createMockUIState({ branchName: 'feature-branch', - corgiMode: true, errorCount: 2, sessionStats: { sessionId: 'test-session', diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 776817a6..71f278df 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -33,7 +33,6 @@ export const Footer: React.FC = () => { debugMode, branchName, debugMessage, - corgiMode, errorCount, showErrorDetails, promptTokenCount, @@ -45,7 +44,6 @@ export const Footer: React.FC = () => { debugMode: config.getDebugMode(), branchName: uiState.branchName, debugMessage: uiState.debugMessage, - corgiMode: uiState.corgiMode, errorCount: uiState.errorCount, showErrorDetails: uiState.showErrorDetails, promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -153,16 +151,6 @@ export const Footer: React.FC = () => { {showMemoryUsage && } - {corgiMode && ( - - | - - - - `) - - - )} {!showErrorDetails && errorCount > 0 && ( | diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ac2f5f10..62e54204 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -54,7 +54,6 @@ export interface UIState { qwenAuthState: QwenAuthState; editorError: string | null; isEditorDialogOpen: boolean; - corgiMode: boolean; debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 55fec0c3..42ce4099 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -153,7 +153,6 @@ describe('useSlashCommandProcessor', () => { openModelDialog: mockOpenModelDialog, quit: mockSetQuittingMessages, setDebugMessage: vi.fn(), - toggleCorgiMode: vi.fn(), }, ), ); @@ -909,7 +908,6 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // openThemeDialog mockOpenAuthDialog, vi.fn(), // openEditorDialog - vi.fn(), // toggleCorgiMode mockSetQuittingMessages, vi.fn(), // openSettingsDialog vi.fn(), // openModelSelectionDialog diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 553accb7..6439c934 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -68,7 +68,6 @@ interface SlashCommandProcessorActions { openApprovalModeDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; - toggleCorgiMode: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; @@ -206,7 +205,6 @@ export const useSlashCommandProcessor = ( setDebugMessage: actions.setDebugMessage, pendingItem, setPendingItem, - toggleCorgiMode: actions.toggleCorgiMode, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index fc75924a..77929333 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { loadHistory: (_newHistory) => {}, pendingItem: null, setPendingItem: (_item) => {}, - toggleCorgiMode: () => {}, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b507c9c5..073f2aa1 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -38,7 +38,6 @@ "src/ui/commands/clearCommand.test.ts", "src/ui/commands/compressCommand.test.ts", "src/ui/commands/copyCommand.test.ts", - "src/ui/commands/corgiCommand.test.ts", "src/ui/commands/docsCommand.test.ts", "src/ui/commands/editorCommand.test.ts", "src/ui/commands/extensionsCommand.test.ts", From f6f4b24356de7e0d3cce0ebe2df5ef2989e652d8 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 13 Dec 2025 16:49:43 +0800 Subject: [PATCH 28/44] Optimize CLI version warning to avoid repetitive notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added tracking mechanism to prevent showing the same version warning multiple times - This resolves the issue where users were getting frequent warnings when opening new sessions - Kept the existing cache mechanism for version detection performance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../src/cli/cliVersionChecker.ts | 14 +++++++++++--- .../src/webview/components/layout/EmptyState.tsx | 16 ++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts b/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts index 3a9db333..bb760d62 100644 --- a/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts +++ b/packages/vscode-ide-companion/src/cli/cliVersionChecker.ts @@ -10,6 +10,9 @@ import { CliVersionManager } from './cliVersionManager.js'; import { MIN_CLI_VERSION_FOR_SESSION_METHODS } from './cliVersionManager.js'; import type { CliVersionInfo } from './cliVersionManager.js'; +// Track which versions have already been warned about to avoid repetitive warnings +const warnedVersions = new Set(); + /** * Check CLI version and show warning if below minimum requirement * @@ -23,9 +26,14 @@ export async function checkCliVersionAndWarn(): Promise { cliContextManager.setCurrentVersionInfo(versionInfo); if (!versionInfo.isSupported) { - vscode.window.showWarningMessage( - `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, - ); + // Only show warning if we haven't already warned about this specific version + const versionKey = versionInfo.version || 'unknown'; + if (!warnedVersions.has(versionKey)) { + vscode.window.showWarningMessage( + `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, + ); + warnedVersions.add(versionKey); + } } } catch (error) { console.error('[CliVersionChecker] Failed to check CLI version:', error); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx index f1b15c4c..4c4a486e 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/EmptyState.tsx @@ -27,6 +27,16 @@ export const EmptyState: React.FC = ({ return (
+ {/* Loading overlay */} + {loadingMessage && ( +
+
+
+

{loadingMessage}

+
+
+ )} +
{/* Qwen Logo */}
@@ -39,12 +49,6 @@ export const EmptyState: React.FC = ({
{description}
- {loadingMessage && ( -
- - {loadingMessage} -
- )}
From 65796e27995fad5f9479a1ff14bf4615742c8387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sat, 13 Dec 2025 17:37:45 +0800 Subject: [PATCH 29/44] Fix/vscode ide companion completion menu content (#1243) * refactor(vscode-ide-companion/types): move ApprovalModeValue type to dedicated file feat(vscode-ide-companion/file-context): improve file context handling and search Enhance file context hook to better handle search queries and reduce redundant requests. Track last query to optimize when to refetch full file list. Improve logging for debugging purposes. * feat(vscode-ide-companion/completion): enhance completion menu performance and refresh logic Implement item comparison to prevent unnecessary re-renders when completion items haven't actually changed. Optimize refresh logic to only trigger when workspace files content changes. Improve completion menu stability and responsiveness. refactor(vscode-ide-companion/handlers): remove SettingsMessageHandler and consolidate functionality Move setApprovalMode functionality from SettingsMessageHandler to SessionMessageHandler to reduce code duplication and simplify message handling architecture. Remove unused settings-related imports and clean up message router configuration. chore(vscode-ide-companion/ui): minor UI improvements and code cleanup Consolidate imports in SessionSelector component. Remove debug console log statement from FileMessageHandler. Move getTimeAgo utility function to sessionGrouping file and remove obsolete timeUtils file. Clean up completion menu CSS classes. * fix(vscode-ide-companion): resolve all ESLint errors Fixed unused variable errors in SessionMessageHandler.ts: - Commented out unused conversation and messages variables Also includes previous commits: 1. feat(vscode-ide-companion): add upgrade button to CLI version warning 2. fix(vscode-ide-companion): resolve ESLint errors in InputForm component When the Qwen Code CLI version is below the minimum required version, the warning message now includes an "Upgrade Now" button that opens a terminal and runs the npm install command to upgrade the CLI. Added tests to verify the functionality works correctly. --- .../src/services/acpConnection.ts | 2 +- .../src/services/acpSessionManager.ts | 2 +- .../src/services/qwenAgentManager.ts | 94 +-------- .../src/services/qwenConnectionHandler.ts | 11 +- .../src/services/qwenSessionManager.ts | 126 +---------- .../src/services/qwenSessionReader.ts | 196 +++++++++++++++++- .../src/services/qwenSessionUpdateHandler.ts | 3 +- .../src/types/acpTypes.ts | 3 +- .../src/types/approvalModeValueTypes.ts | 11 + .../src/types/chatTypes.ts | 3 +- .../vscode-ide-companion/src/webview/App.tsx | 39 +++- .../src/webview/MessageHandler.ts | 7 - .../src/webview/WebViewProvider.ts | 2 +- .../components/layout/CompletionMenu.tsx | 3 +- .../webview/components/layout/InputForm.tsx | 13 +- .../components/layout/SessionSelector.tsx | 6 +- .../webview/handlers/FileMessageHandler.ts | 1 - .../src/webview/handlers/MessageRouter.ts | 16 -- .../webview/handlers/SessionMessageHandler.ts | 141 +++---------- .../handlers/SettingsMessageHandler.ts | 101 --------- .../src/webview/hooks/file/useFileContext.ts | 28 ++- .../src/webview/hooks/useCompletionTrigger.ts | 45 +++- .../src/webview/hooks/useWebViewMessages.ts | 2 +- .../src/webview/utils/sessionGrouping.ts | 35 ++++ .../src/webview/utils/timeUtils.ts | 40 ---- 25 files changed, 400 insertions(+), 530 deletions(-) create mode 100644 packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts delete mode 100644 packages/vscode-ide-companion/src/webview/handlers/SettingsMessageHandler.ts delete mode 100644 packages/vscode-ide-companion/src/webview/utils/timeUtils.ts diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index f4c95948..464f8bcb 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -10,8 +10,8 @@ import type { AcpPermissionRequest, AcpResponse, AcpSessionUpdate, - ApprovalModeValue, } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; import { spawn } from 'child_process'; import type { diff --git a/packages/vscode-ide-companion/src/services/acpSessionManager.ts b/packages/vscode-ide-companion/src/services/acpSessionManager.ts index 8812282a..55b1d2b5 100644 --- a/packages/vscode-ide-companion/src/services/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/acpSessionManager.ts @@ -14,8 +14,8 @@ import type { AcpRequest, AcpNotification, AcpResponse, - ApprovalModeValue, } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { AGENT_METHODS } from '../constants/acpSchema.js'; import type { PendingRequest } from '../types/connectionTypes.js'; import type { ChildProcess } from 'child_process'; diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 5ddd5612..2475e309 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -7,8 +7,8 @@ import { AcpConnection } from './acpConnection.js'; import type { AcpSessionUpdate, AcpPermissionRequest, - ApprovalModeValue, } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionManager } from './qwenSessionManager.js'; import type { @@ -336,8 +336,10 @@ export class QwenAgentManager { name: this.sessionReader.getSessionTitle(session), startTime: session.startTime, lastUpdated: session.lastUpdated, - messageCount: session.messages.length, + messageCount: session.messageCount ?? session.messages.length, projectHash: session.projectHash, + filePath: session.filePath, + cwd: session.cwd, }), ); @@ -452,8 +454,10 @@ export class QwenAgentManager { name: this.sessionReader.getSessionTitle(x.raw), startTime: x.raw.startTime, lastUpdated: x.raw.lastUpdated, - messageCount: x.raw.messages.length, + messageCount: x.raw.messageCount ?? x.raw.messages.length, projectHash: x.raw.projectHash, + filePath: x.raw.filePath, + cwd: x.raw.cwd, })); const nextCursorVal = page.length > 0 ? page[page.length - 1].mtime : undefined; @@ -891,80 +895,6 @@ export class QwenAgentManager { return this.saveSessionViaCommand(sessionId, tag); } - /** - * Save session as checkpoint (using CLI format) - * Saves to ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json - * Saves two copies with sessionId and conversationId to ensure recovery via either ID - * - * @param messages - Current session messages - * @param conversationId - Conversation ID (from VSCode extension) - * @returns Save result - */ - async saveCheckpoint( - messages: ChatMessage[], - conversationId: string, - ): Promise<{ success: boolean; tag?: string; message?: string }> { - try { - console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); - console.log('[QwenAgentManager] Conversation ID:', conversationId); - console.log('[QwenAgentManager] Message count:', messages.length); - console.log( - '[QwenAgentManager] Current working dir:', - this.currentWorkingDir, - ); - console.log( - '[QwenAgentManager] Current session ID (from CLI):', - this.currentSessionId, - ); - // In ACP mode, the CLI does not accept arbitrary slash commands like - // "/chat save". To ensure we never block on unsupported features, - // persist checkpoints directly to ~/.qwen/tmp using our SessionManager. - const qwenMessages = messages.map((m) => ({ - // Generate minimal QwenMessage shape expected by the writer - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - timestamp: new Date().toISOString(), - type: m.role === 'user' ? ('user' as const) : ('qwen' as const), - content: m.content, - })); - - const tag = await this.sessionManager.saveCheckpoint( - qwenMessages, - conversationId, - this.currentWorkingDir, - this.currentSessionId || undefined, - ); - - return { success: true, tag }; - } catch (error) { - console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED ====='); - console.error('[QwenAgentManager] Error:', error); - console.error( - '[QwenAgentManager] Error stack:', - error instanceof Error ? error.stack : 'N/A', - ); - return { - success: false, - message: error instanceof Error ? error.message : String(error), - }; - } - } - - /** - * Save session directly to file system (without relying on ACP) - * - * @param messages - Current session messages - * @param sessionName - Session name - * @returns Save result - */ - async saveSessionDirect( - messages: ChatMessage[], - sessionName: string, - ): Promise<{ success: boolean; sessionId?: string; message?: string }> { - // Use checkpoint format instead of session format - // This matches CLI's /chat save behavior - return this.saveCheckpoint(messages, sessionName); - } - /** * Try to load session via ACP session/load method * This method will only be used if CLI version supports it @@ -1152,16 +1082,6 @@ export class QwenAgentManager { } } - /** - * Load session, preferring ACP method if CLI version supports it - * - * @param sessionId - Session ID - * @returns Loaded session messages or null - */ - async loadSessionDirect(sessionId: string): Promise { - return this.loadSession(sessionId); - } - /** * Create new session * diff --git a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts index 6a74cd56..91d4c6bf 100644 --- a/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenConnectionHandler.ts @@ -54,9 +54,18 @@ export class QwenConnectionHandler { // Show warning if CLI version is below minimum requirement if (!versionInfo.isSupported) { // Wait to determine release version number - vscode.window.showWarningMessage( + const selection = await vscode.window.showWarningMessage( `Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`, + 'Upgrade Now', ); + + // Handle the user's selection + if (selection === 'Upgrade Now') { + // Open terminal and run npm install command + const terminal = vscode.window.createTerminal('Qwen Code CLI Upgrade'); + terminal.show(); + terminal.sendText('npm install -g @qwen-code/qwen-code@latest'); + } } const config = vscode.workspace.getConfiguration('qwenCode'); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 2bd609bb..9336a060 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -51,131 +51,7 @@ export class QwenSessionManager { } /** - * Save current conversation as a checkpoint (matching CLI's /chat save format) - * Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility - * - * @param messages - Current conversation messages - * @param conversationId - Conversation ID (from VSCode extension) - * @param sessionId - Session ID (from CLI tmp session file, optional) - * @param workingDir - Current working directory - * @returns Checkpoint tag - */ - async saveCheckpoint( - messages: QwenMessage[], - conversationId: string, - workingDir: string, - sessionId?: string, - ): Promise { - try { - console.log('[QwenSessionManager] ===== SAVEPOINT START ====='); - console.log('[QwenSessionManager] Conversation ID:', conversationId); - console.log( - '[QwenSessionManager] Session ID:', - sessionId || 'not provided', - ); - console.log('[QwenSessionManager] Working dir:', workingDir); - console.log('[QwenSessionManager] Message count:', messages.length); - - // Get project directory (parent of chats directory) - const projectHash = this.getProjectHash(workingDir); - console.log('[QwenSessionManager] Project hash:', projectHash); - - const projectDir = path.join(this.qwenDir, 'tmp', projectHash); - console.log('[QwenSessionManager] Project dir:', projectDir); - - if (!fs.existsSync(projectDir)) { - console.log('[QwenSessionManager] Creating project directory...'); - fs.mkdirSync(projectDir, { recursive: true }); - console.log('[QwenSessionManager] Directory created'); - } else { - console.log('[QwenSessionManager] Project directory already exists'); - } - - // Convert messages to checkpoint format (Gemini-style messages) - console.log( - '[QwenSessionManager] Converting messages to checkpoint format...', - ); - const checkpointMessages = messages.map((msg, index) => { - console.log( - `[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`, - ); - return { - role: msg.type === 'user' ? 'user' : 'model', - parts: [ - { - text: msg.content, - }, - ], - }; - }); - - console.log( - '[QwenSessionManager] Converted', - checkpointMessages.length, - 'messages', - ); - - const jsonContent = JSON.stringify(checkpointMessages, null, 2); - console.log( - '[QwenSessionManager] JSON content length:', - jsonContent.length, - ); - - // Save with conversationId as primary tag - const convFilename = `checkpoint-${conversationId}.json`; - const convFilePath = path.join(projectDir, convFilename); - console.log( - '[QwenSessionManager] Saving checkpoint with conversationId:', - convFilePath, - ); - fs.writeFileSync(convFilePath, jsonContent, 'utf-8'); - - // Also save with sessionId if provided (for compatibility with CLI session/load) - if (sessionId) { - const sessionFilename = `checkpoint-${sessionId}.json`; - const sessionFilePath = path.join(projectDir, sessionFilename); - console.log( - '[QwenSessionManager] Also saving checkpoint with sessionId:', - sessionFilePath, - ); - fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8'); - } - - // Verify primary file exists - if (fs.existsSync(convFilePath)) { - const stats = fs.statSync(convFilePath); - console.log( - '[QwenSessionManager] Primary checkpoint verified, size:', - stats.size, - ); - } else { - console.error( - '[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!', - ); - } - - console.log('[QwenSessionManager] ===== CHECKPOINT SAVED ====='); - console.log('[QwenSessionManager] Primary path:', convFilePath); - if (sessionId) { - console.log( - '[QwenSessionManager] Secondary path (sessionId):', - path.join(projectDir, `checkpoint-${sessionId}.json`), - ); - } - return conversationId; - } catch (error) { - console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED ====='); - console.error('[QwenSessionManager] Error:', error); - console.error( - '[QwenSessionManager] Error stack:', - error instanceof Error ? error.stack : 'N/A', - ); - throw error; - } - } - - /** - * Save current conversation as a named session (checkpoint-like functionality) + * Save current conversation as a named session * * @param messages - Current conversation messages * @param sessionName - Name/tag for the saved session diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 6e2d065d..3fc4e484 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -7,6 +7,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as readline from 'readline'; +import * as crypto from 'crypto'; export interface QwenMessage { id: string; @@ -32,6 +34,9 @@ export interface QwenSession { lastUpdated: string; messages: QwenMessage[]; filePath?: string; + messageCount?: number; + firstUserText?: string; + cwd?: string; } export class QwenSessionReader { @@ -96,11 +101,17 @@ export class QwenSessionReader { return sessions; } - const files = fs - .readdirSync(chatsDir) - .filter((f) => f.startsWith('session-') && f.endsWith('.json')); + const files = fs.readdirSync(chatsDir); - for (const file of files) { + const jsonSessionFiles = files.filter( + (f) => f.startsWith('session-') && f.endsWith('.json'), + ); + + const jsonlSessionFiles = files.filter((f) => + /^[0-9a-fA-F-]{32,36}\.jsonl$/.test(f), + ); + + for (const file of jsonSessionFiles) { const filePath = path.join(chatsDir, file); try { const content = fs.readFileSync(filePath, 'utf-8'); @@ -116,6 +127,23 @@ export class QwenSessionReader { } } + // Support new JSONL session format produced by the CLI + for (const file of jsonlSessionFiles) { + const filePath = path.join(chatsDir, file); + try { + const session = await this.readJsonlSession(filePath, false); + if (session) { + sessions.push(session); + } + } catch (error) { + console.error( + '[QwenSessionReader] Failed to read JSONL session file:', + filePath, + error, + ); + } + } + return sessions; } @@ -128,7 +156,25 @@ export class QwenSessionReader { ): Promise { // First try to find in all projects const sessions = await this.getAllSessions(undefined, true); - return sessions.find((s) => s.sessionId === sessionId) || null; + const found = sessions.find((s) => s.sessionId === sessionId); + + if (!found) { + return null; + } + + // If the session points to a JSONL file, load full content on demand + if ( + found.filePath && + found.filePath.endsWith('.jsonl') && + found.messages.length === 0 + ) { + const hydrated = await this.readJsonlSession(found.filePath, true); + if (hydrated) { + return hydrated; + } + } + + return found; } /** @@ -136,7 +182,6 @@ export class QwenSessionReader { * Qwen CLI uses SHA256 hash of project path */ private async getProjectHash(workingDir: string): Promise { - const crypto = await import('crypto'); return crypto.createHash('sha256').update(workingDir).digest('hex'); } @@ -144,6 +189,14 @@ export class QwenSessionReader { * Get session title (based on first user message) */ getSessionTitle(session: QwenSession): string { + // Prefer cached prompt text to avoid loading messages for JSONL sessions + if (session.firstUserText) { + return ( + session.firstUserText.substring(0, 50) + + (session.firstUserText.length > 50 ? '...' : '') + ); + } + const firstUserMessage = session.messages.find((m) => m.type === 'user'); if (firstUserMessage) { // Extract first 50 characters as title @@ -155,6 +208,137 @@ export class QwenSessionReader { return 'Untitled Session'; } + /** + * Parse a JSONL session file written by the CLI. + * When includeMessages is false, only lightweight metadata is returned. + */ + private async readJsonlSession( + filePath: string, + includeMessages: boolean, + ): Promise { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + const stats = fs.statSync(filePath); + const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + const messages: QwenMessage[] = []; + const seenUuids = new Set(); + let sessionId: string | undefined; + let startTime: string | undefined; + let firstUserText: string | undefined; + let cwd: string | undefined; + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (!sessionId && typeof obj.sessionId === 'string') { + sessionId = obj.sessionId; + } + if (!startTime && typeof obj.timestamp === 'string') { + startTime = obj.timestamp; + } + if (!cwd && typeof obj.cwd === 'string') { + cwd = obj.cwd; + } + + const type = typeof obj.type === 'string' ? obj.type : ''; + if (type === 'user' || type === 'assistant') { + const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined; + if (uuid) { + seenUuids.add(uuid); + } + + const text = this.contentToText(obj.message); + if (includeMessages) { + messages.push({ + id: uuid || `${messages.length}`, + timestamp: typeof obj.timestamp === 'string' ? obj.timestamp : '', + type: type === 'user' ? 'user' : 'qwen', + content: text, + }); + } + + if (!firstUserText && type === 'user' && text) { + firstUserText = text; + } + } + } + + // Ensure stream is closed + rl.close(); + + if (!sessionId) { + return null; + } + + const projectHash = cwd + ? await this.getProjectHash(cwd) + : path.basename(path.dirname(path.dirname(filePath))); + + return { + sessionId, + projectHash, + startTime: startTime || new Date(stats.birthtimeMs).toISOString(), + lastUpdated: new Date(stats.mtimeMs).toISOString(), + messages: includeMessages ? messages : [], + filePath, + messageCount: seenUuids.size, + firstUserText, + cwd, + }; + } catch (error) { + console.error( + '[QwenSessionReader] Failed to parse JSONL session:', + error, + ); + return null; + } + } + + // Extract plain text from CLI Content structure + private contentToText(message: unknown): string { + try { + if (typeof message !== 'object' || message === null) { + return ''; + } + + const typed = message as { parts?: unknown[] }; + const parts = Array.isArray(typed.parts) ? typed.parts : []; + const texts: string[] = []; + for (const part of parts) { + if (typeof part !== 'object' || part === null) { + continue; + } + const p = part as Record; + if (typeof p.text === 'string') { + texts.push(p.text); + } else if (typeof p.data === 'string') { + texts.push(p.data); + } + } + return texts.join('\n'); + } catch { + return ''; + } + } + /** * Delete session file */ diff --git a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts index e27fbe67..d7b24bb2 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionUpdateHandler.ts @@ -10,7 +10,8 @@ * Handles session updates from ACP and dispatches them to appropriate callbacks */ -import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js'; +import type { AcpSessionUpdate } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { QwenAgentCallbacks } from '../types/chatTypes.js'; /** diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index 1fb4de17..252f3d5d 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -3,6 +3,7 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export const JSONRPC_VERSION = '2.0' as const; export const authMethod = 'qwen-oauth'; @@ -138,8 +139,6 @@ export interface PlanUpdate extends BaseSessionUpdate { }; } -export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; - export { ApprovalMode, APPROVAL_MODE_MAP, diff --git a/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts new file mode 100644 index 00000000..fe1f37e1 --- /dev/null +++ b/packages/vscode-ide-companion/src/types/approvalModeValueTypes.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Type for approval mode values + * Used in ACP protocol for controlling agent behavior + */ +export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo'; diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 90ebbb87..bafe154d 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -3,7 +3,8 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ -import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js'; +import type { AcpPermissionRequest } from './acpTypes.js'; +import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { role: 'user' | 'assistant'; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 4bdf6622..1db91d39 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -43,7 +43,7 @@ import { InputForm } from './components/layout/InputForm.js'; import { SessionSelector } from './components/layout/SessionSelector.js'; import { FileIcon, UserIcon } from './components/icons/index.js'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; -import type { ApprovalModeValue } from '../types/acpTypes.js'; +import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../types/chatTypes.js'; export const App: React.FC = () => { @@ -90,9 +90,13 @@ export const App: React.FC = () => { const getCompletionItems = React.useCallback( async (trigger: '@' | '/', query: string): Promise => { if (trigger === '@') { - if (!fileContext.hasRequestedFiles) { - fileContext.requestWorkspaceFiles(); - } + console.log('[App] getCompletionItems @ called', { + query, + requested: fileContext.hasRequestedFiles, + workspaceFiles: fileContext.workspaceFiles.length, + }); + // 始终根据当前 query 触发请求,让 hook 判断是否需要真正请求 + fileContext.requestWorkspaceFiles(query); const fileIcon = ; const allItems: CompletionItem[] = fileContext.workspaceFiles.map( @@ -109,7 +113,6 @@ export const App: React.FC = () => { ); if (query && query.length >= 1) { - fileContext.requestWorkspaceFiles(query); const lowerQuery = query.toLowerCase(); return allItems.filter( (item) => @@ -154,17 +157,39 @@ export const App: React.FC = () => { const completion = useCompletionTrigger(inputFieldRef, getCompletionItems); + // Track a lightweight signature of workspace files to detect content changes even when length is unchanged + const workspaceFilesSignature = useMemo( + () => + fileContext.workspaceFiles + .map( + (file) => + `${file.id}|${file.label}|${file.description ?? ''}|${file.path}`, + ) + .join('||'), + [fileContext.workspaceFiles], + ); + // When workspace files update while menu open for @, refresh items so the first @ shows the list // Note: Avoid depending on the entire `completion` object here, since its identity // changes on every render which would retrigger this effect and can cause a refresh loop. useEffect(() => { - if (completion.isOpen && completion.triggerChar === '@') { + // Only auto-refresh when there's no query (first @ popup) to avoid repeated refreshes during search + if ( + completion.isOpen && + completion.triggerChar === '@' && + !completion.query + ) { // Only refresh items; do not change other completion state to avoid re-renders loops completion.refreshCompletion(); } // Only re-run when the actual data source changes, not on every render // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]); + }, [ + workspaceFilesSignature, + completion.isOpen, + completion.triggerChar, + completion.query, + ]); // Message submission const { handleSubmit: submitMessage } = useMessageSubmit({ diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts index 1eca4a20..77d330b6 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -73,11 +73,4 @@ export class MessageHandler { appendStreamContent(chunk: string): void { this.router.appendStreamContent(chunk); } - - /** - * Check if saving checkpoint - */ - getIsSavingCheckpoint(): boolean { - return this.router.getIsSavingCheckpoint(); - } } diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index b4da60ab..f2b36ab0 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -14,7 +14,7 @@ import { MessageHandler } from '../webview/MessageHandler.js'; import { WebViewContent } from '../webview/WebViewContent.js'; import { CliInstaller } from '../cli/cliInstaller.js'; import { getFileName } from './utils/webviewUtils.js'; -import { type ApprovalModeValue } from '../types/acpTypes.js'; +import { type ApprovalModeValue } from '../types/approvalModeValueTypes.js'; export class WebViewProvider { private panelManager: PanelManager; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx index 167a376d..f667b849 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx @@ -92,9 +92,8 @@ export const CompletionMenu: React.FC = ({ ref={containerRef} role="menu" className={[ - // Semantic class name for readability (no CSS attached) 'completion-menu', - // Positioning and container styling (Tailwind) + // Positioning and container styling 'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden', 'rounded-large border bg-[var(--app-menu-background)]', 'border-[var(--app-input-border)] max-h-[50vh] z-[1000]', diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index fe86ea99..73f3fa26 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -11,7 +11,7 @@ import { PlanModeIcon, CodeBracketsIcon, HideContextIcon, - ThinkingIcon, + // ThinkingIcon, // Temporarily disabled SlashCommandIcon, LinkIcon, ArrowUpIcon, @@ -20,7 +20,7 @@ import { import { CompletionMenu } from '../layout/CompletionMenu.js'; import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; -import type { ApprovalModeValue } from '../../../types/acpTypes.js'; +import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; interface InputFormProps { inputText: string; @@ -92,7 +92,7 @@ export const InputForm: React.FC = ({ isWaitingForResponse, isComposing, editMode, - thinkingEnabled, + // thinkingEnabled, // Temporarily disabled activeFileName, activeSelection, skipAutoActiveContext, @@ -103,7 +103,7 @@ export const InputForm: React.FC = ({ onSubmit, onCancel, onToggleEditMode, - onToggleThinking, + // onToggleThinking, // Temporarily disabled onToggleSkipAutoActiveContext, onShowCommandMenu, onAttachContext, @@ -236,15 +236,16 @@ export const InputForm: React.FC = ({ {/* Spacer */}
+ {/* @yiliang114. closed temporarily */} {/* Thinking button */} - + */} {/* Command button */}