Compare commits

...

250 Commits

Author SHA1 Message Date
tanzhenxin
4cbb57a793 Merge pull request #1249 from QwenLM/feat/vscode-ide-companion-package-script
fix(vscode-ide-companion): improve cross-platform compatibility in prepackage script
2025-12-14 00:02:05 +08:00
yiliang114
9280739a85 fix(vscode-ide-companion): improve cross-platform compatibility in prepackage script
- Enable shell option conditionally for Windows platform
- Use 'node' command directly and template literals for path handling
2025-12-14 00:00:06 +08:00
tanzhenxin
b6128ef2d2 Merge pull request #1248 from QwenLM/feat/vscode-ide-companion-borading
refactor(vscode-ide-companion): optimize CLI detection and version management
2025-12-13 23:37:06 +08:00
tanzhenxin
f7ef720e3b test 2025-12-13 23:18:49 +08:00
tanzhenxin
0e6ebe85e4 test 2025-12-13 23:04:00 +08:00
yiliang114
3138aa1fe3 chore(vscode-ide-companion): wip 2025-12-13 22:30:10 +08:00
yiliang114
0f2f1faee5 chore(vscode-ide-companion): wip 2025-12-13 22:19:54 +08:00
yiliang114
641dd03689 chore(vscode-ide-companion): wip 2025-12-13 22:03:16 +08:00
yiliang114
44fef93399 chore(vscode-ide-companion): wip 2025-12-13 21:39:41 +08:00
yiliang114
b073b2db79 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 21:37:18 +08:00
yiliang114
ccc6192164 chore(vscode-ide-companion): wip 2025-12-13 21:37:10 +08:00
tanzhenxin
a5f8c66c35 Merge pull request #1246 from QwenLM/feat/bundle-cli-in-vscode
Bundle CLI into VSCode release package
2025-12-13 21:25:58 +08:00
yiliang114
c8d18591b0 Merge branch 'feat/bundle-cli-in-vscode' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 21:21:53 +08:00
tanzhenxin
e9036daa8d fix failed unit tests 2025-12-13 21:14:05 +08:00
tanzhenxin
8e29cc88f9 bundle cli in vscode 2025-12-13 20:56:18 +08:00
yiliang114
61ce586117 refactor(vscode-ide-companion/cli): consolidate CLI detection and version management
- Replace separate CliDetector, CliVersionChecker, and CliVersionManager classes with unified CliManager
- Remove redundant code and simplify CLI detection logic
- Maintain all existing functionality while improving code organization
- Update imports in dependent files to use CliManager

This change reduces complexity by consolidating CLI-related functionality into a single manager class.
2025-12-13 20:42:59 +08:00
yiliang114
90fc4c33f0 fix(vscode-ide-companion/session): improve timeout configuration for different methods
Extend timeout duration to 2 minutes for both session_prompt and initialize methods
to prevent timeouts during longer operations. Default timeout remains at 60 seconds
for other methods.

This change improves reliability of session management by providing adequate
time for initialization and prompt operations to complete.
2025-12-13 20:06:02 +08:00
yiliang114
389d8dd9c4 Remove CLI version checker and status bar display, revert to original notification approach
This change removes the CliVersionChecker class and all related status bar functionality, reverting to the original approach that uses vscode.window.showInformationMessage for version-related notifications, as was implemented in the main branch.

The changes include:
1. Removing CliVersionChecker import
2. Removing status bar item creation and update logic
3. Removing CLI version check on activation
4. Removing showCliVersionInfo command

This addresses the issue where version detection notifications should use vscode.window.showInformationMessage instead of status bar display.
2025-12-13 19:49:04 +08:00
yiliang114
4590138a1e fix(vscode-ide-companion/cli): improve Windows compatibility for shell commands
fix(vscode-ide-companion/session): improve timeout configuration for different methods
2025-12-13 18:30:14 +08:00
yiliang114
0ac191e2db chore(vscode-ide-companion): wip 2025-12-13 17:50:15 +08:00
yiliang114
9e392b3035 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 17:40:16 +08:00
易良
65796e2799 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.
2025-12-13 17:37:45 +08:00
yiliang114
8fdcd53f53 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 17:05:40 +08:00
pomelo
693a58d517 Merge pull request #1194 from afarber/add-audio-notification-bell
feat: add terminal bell setting to enable/disable audio notifications
2025-12-13 17:03:35 +08:00
pomelo
c7f4c462f4 Merge pull request #1219 from afarber/1181-resume-session-id
feat: show session resume command on exit
2025-12-13 17:02:16 +08:00
yiliang114
c94708e448 Implement authenticate/update message handling for Qwen OAuth authentication
- Added authenticate_update method to ACP schema constants
- Added AuthenticateUpdateNotification type definitions
- Updated ACP message handler to process authenticate/update notifications
- Added VS Code notification handler with 'Open in Browser' and 'Copy Link' options
- Integrated authenticate update handling in QwenAgentManager

This implementation allows users to easily authenticate with Qwen OAuth when automatic browser launch fails by providing a notification with direct link and copy functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 16:59:30 +08:00
tanzhenxin
0b854e494f Merge pull request #1245 from QwenLM/feat/remove-corgi-mode
Remove obsolete “corgi mode”
2025-12-13 16:57:10 +08:00
yiliang114
f6f4b24356 Optimize CLI version warning to avoid repetitive notifications
- 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 <noreply@anthropic.com>
2025-12-13 16:49:43 +08:00
yiliang114
bc5dd87eb4 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/vscode-ide-companion-borading 2025-12-13 16:44:29 +08:00
tanzhenxin
f8aeb06823 remove obsolete corgi mode 2025-12-13 16:41:03 +08:00
yiliang114
bca288e742 wip(vscode-ide-companion): OnboardingPage 2025-12-13 16:28:58 +08:00
yiliang114
5841370b1a wip(vscode-ide-companion): OnboardingPage 2025-12-13 15:51:34 +08:00
tanzhenxin
0d90d5c118 Merge pull request #1240 from QwenLM/feat/acp-authenticate-update
Add ACP authenticate update message
2025-12-13 14:41:59 +08:00
pomelo
cc0d688c8b Merge pull request #1235 from afarber/501-remove-duplicated-if-check
fix: remove redundant if-check and add tests for OpenAI converter
2025-12-13 12:07:24 +08:00
tanzhenxin
4eb7aa5448 add acp authenticate update message 2025-12-13 12:01:46 +08:00
pomelo
9978fe107b Merge pull request #1236 from afarber/1102-add-slash-language
test(cli): add tests for /language command and fix LLM output language parsing
2025-12-13 12:00:18 +08:00
pomelo
b95bb2cd95 Merge pull request #1238 from fazilus/feat/russian
feat(i18n): add Russian language support
2025-12-13 11:44:02 +08:00
yiliang114
e895c49f5c 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.
2025-12-13 09:56:18 +08:00
yiliang114
3191cf73b3 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.
2025-12-13 09:19:18 +08:00
yiliang114
f5306339f6 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.
2025-12-13 09:19:09 +08:00
Fazil
026fd468b1 feat(i18n): add Russian language support 2025-12-12 21:12:37 +03:00
Alexander Farber
d25af87eaf Fix the copyright 2025-12-12 15:46:37 +01:00
Alexander Farber
a5039d15bf Revert to SupportedLanguage 2025-12-12 15:36:50 +01:00
Alexander Farber
3ff916a5f1 Add tests for /language command and fix regex parsing 2025-12-12 15:28:43 +01:00
Alexander Farber
7bb9bc1e5e Remove redundant if-check and add tests for tool conversion in converter.ts 2025-12-12 14:29:37 +01:00
tanzhenxin
8b29dd130e Merge pull request #1233 from QwenLM/chore/v0.5.0
pump version to 0.5.0
2025-12-12 16:32:13 +08:00
tanzhenxin
d0be8b43d7 pump version to 0.5.0 2025-12-12 16:29:50 +08:00
tanzhenxin
3095442eb3 Merge pull request #1223 from QwenLM/fix/vscode-ide-companion-login-twice
fix(vscode-ide-companion/auth): deduplicate concurrent authentication calls
2025-12-12 16:19:25 +08:00
tanzhenxin
2ceecab503 Merge pull request #1226 from QwenLM/feat/support-channel-field
feat: Add channel field support for client identification
2025-12-12 16:16:36 +08:00
pomelo
e5ed0334ab Merge pull request #1230 from BlockHand/docker-ide 2025-12-12 16:16:23 +08:00
刘伟光
2b62b1e8bc feat: 将注释修改成英文 2025-12-12 14:40:30 +08:00
yiliang114
89be6edb5e chore(vscode-ide-companion): add comment 2025-12-12 13:59:05 +08:00
yiliang114
d812c9dcf2 chore(vscode-ide-companion): add fixme comment for auth delay 2025-12-12 13:51:14 +08:00
yiliang114
d754767e73 chore(vscode-ide-companion): rm authState manager in vscode-ide-companion to simplify the login architecture 2025-12-12 13:40:18 +08:00
刘伟光
bb8447edd7 fix: 修复在docker环境中无法连接ide的问题 2025-12-12 11:36:15 +08:00
yiliang114
02234f5434 chore(vscode-ide-companion): change comments and delays 2025-12-12 01:17:38 +08:00
yiliang114
25261ab88d fix(vscode-ide-companion): slight delay to ensure auth state settlement 2025-12-12 01:14:28 +08:00
DragonnZhang
60a58ad8e5 feat: add support for the channel field to CLI parameters and configurations 2025-12-12 01:06:00 +08:00
yiliang114
c20df192a8 chore(vscode-ide-companion): revert some log util, will continue next time 2025-12-11 23:57:21 +08:00
yiliang114
b34894c8ea 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.
2025-12-11 22:56:58 +08:00
Alexander Farber
ba3b576906 Hide resume message when session has no messages 2025-12-11 13:10:01 +01:00
Alexander Farber
b67ee32481 Update snapshots 2025-12-11 12:53:30 +01:00
Alexander Farber
5b8ce440ea Show session resume command on exit 2025-12-11 12:22:48 +01:00
tanzhenxin
58d3a9c253 Merge pull request #1176 from QwenLM/feat/acp-usage-metadata
Feat/acp usage metadata
2025-12-11 14:09:43 +08:00
TianHao Zhang
d06a6d7ef9 feat: update references from Gemini to Qwen in setup commands and gitignore handling (#1156) 2025-12-11 14:09:22 +08:00
pomelo
ae9753a326 Merge pull request #1189 from afarber/security-fixes
fix: update vulnerable dependencies (glob, jws, tar, js-yaml)
2025-12-11 09:42:30 +08:00
pomelo
a02c4b2765 Merge pull request #1059 from yiliang114/feat/jinjing/write-and-read-file-in-vscode
feat: VSCode Extension Implementation
2025-12-10 16:15:28 +08:00
pomelo
0055399cba Merge pull request #1191 from afarber/remove-vertical-borders
feat(ui): remove vertical borders from input prompt for easier copy/paste
2025-12-10 16:08:04 +08:00
Alexander Farber
5f78909040 Add terminal bell setting to enable/disable audio notifications 2025-12-09 20:27:20 +01:00
yiliang114
5ef3d32f16 refactor(vscode-ide-companion/qwenAgentManager): remove saveCheckpointViaCommand method
refactor(vscode-ide-companion/webview): improve message handling during checkpoint saves
feat(vscode-ide-companion/markdown): enhance file path link handling with line numbers support
feat(vscode-ide-companion/message): add enableFileLinks prop to MessageContent component
feat(vscode-ide-companion/user-message): disable file links in user messages
2025-12-10 01:31:39 +08:00
xuewenjie
49c032492a fix: prefer UTF-8 encoding for shell output on Windows when detected
pump versionm to 0.4.1 (#1177)
2025-12-10 01:31:32 +08:00
yiliang114
4345b9370e feat(vscode-ide-companion): enhance panel manager group tracking and ui improvements
chore(vscode-ide-companion): remove unused todo comment in auth state manager
2025-12-10 01:31:27 +08:00
yiliang114
d2e2a07327 chore(vscode-ide-companion): bump version to 0.4.1 and add semver dependency and improve cli version checking with semver package 2025-12-10 01:31:09 +08:00
Alexander Farber
5b74422be6 Do not skip the tests 2025-12-09 16:46:18 +01:00
Alexander Farber
06c398a015 Remove vertical borders from input prompt for easier copy/paste 2025-12-09 16:37:32 +01:00
Alexander Farber
aec5d6463a Update vulnerable dependencies (glob, jws, tar, js-yaml) 2025-12-09 15:14:20 +01:00
yiliang114
29032d2c6a chore(vscode-ide-companion): bump version to 0.4.1 2025-12-09 21:48:40 +08:00
yiliang114
e91ea3ac1a refactor(vscode-ide-companion): simplify openDiff tool implementation
- Remove redundant file reading logic from ide-server
- Leverage diffManager's new capability to read old content internally
- Simplify openDiff tool call site to pass only newContent
- Update comments to reflect the simplified implementation
2025-12-09 15:53:41 +08:00
yiliang114
f2a74c74b6 feat(vscode-ide-companion): enhance panel manager group tracking and cleanup
- Add panelGroupViewColumn tracking for precise group identification
- Implement improved tab capture mechanism with delayed execution
- Add robust group cleanup logic to close empty locked columns
- Implement focusGroupByColumn helper for reliable group focusing
- Add proper error handling and fallback mechanisms for group operations
2025-12-09 15:53:19 +08:00
yiliang114
21651410c8 feat(vscode-ide-companion): update approval mode cycling behavior
- Add NEXT_APPROVAL_MODE constant for cyclic mode mapping
- Remove 'plan' mode from public toggle sequence
- Simplify handleToggleEditMode implementation using NEXT_APPROVAL_MODE mapping
- Update import statements to include NEXT_APPROVAL_MODE
- Remove unused comment about plan mode in toggle sequence
2025-12-09 15:53:06 +08:00
yiliang114
09cefbcf67 feat(vscode-ide-companion): add showDiff overload and file reading capability
- Add overloaded showDiff method to support calling with only newContent
- Implement readOldContentFromFs helper to read existing file content
- Simplify IDE server openDiff tool to use minimal call site
- Improve diff manager documentation and code clarity
2025-12-09 15:52:52 +08:00
tanzhenxin
5fddcd509c pump versionm to 0.4.1 (#1177) 2025-12-09 10:25:07 +08:00
tanzhenxin
d7b9466516 #1129, add usage update in ACP mode 2025-12-09 09:58:19 +08:00
yiliang114
fcd4bb9c03 style(vscode-ide-companion): adjust EmptyState component layout
- Remove max-width constraint from EmptyState component wrapper
- Improve responsive layout for empty state view
2025-12-09 00:15:56 +08:00
yiliang114
828b760820 refactor(vscode-ide-companion): clean up code and improve type safety
- Remove commented out error handling code in acpMessageHandler
- Simplify session update handler by removing redundant comments
- Clean up chat types interface definitions
- Simplify null check in MessageRouter
- Improve type safety in SettingsMessageHandler with ApprovalModeValue type
2025-12-09 00:15:44 +08:00
yiliang114
ef3d7b92d0 feat(vscode-ide-companion): improve message handling and diff auto-opening
- Ignore messages during checkpoint saves in WebViewProvider
- Move diff auto-opening logic from useEffect to useWebViewMessages hook
- Remove unused imports and variables in EditToolCall component
- Enhance tool call handling for edit operations with diff content
2025-12-09 00:15:30 +08:00
yiliang114
58b9e477bc feat(vscode-ide-companion): implement session rehydration for loading past conversations
- Add rehydratingSessionId flag to track session loading state
- Route message chunks as discrete messages during rehydration instead of streaming
- Update session handlers to properly manage conversation switching
- Improve session manager documentation
2025-12-09 00:14:35 +08:00
yiliang114
f4edcc5cd2 fix(vscode-ide-companion): global auth state 2025-12-08 23:38:16 +08:00
yiliang114
7adb9ed7ff refactor(vscode-ide-companion): introduce ApprovalMode enum and improve mode handling
- Create new approvalModeTypes.ts with ApprovalMode enum and helper functions
- Replace hardcoded string literals with ApprovalModeValue type
- Consolidate mode mapping logic using new helper functions
2025-12-08 23:12:04 +08:00
yiliang114
f146f062cb chore(vscode-ide-companion): rm markdown code copy button 2025-12-08 21:00:29 +08:00
yiliang114
111234eb24 refactor(vscode-ide-companion): simplify ACP connection and cleanup configurations
- Remove .claude from .gitignore
- Update CSS file path in eslint config
- Simplify VS Code extension title
- Remove unused keybinding for openChat command
- Delete unused auth constants file
- Simplify ACP connection by removing backend parameter
- Move authMethod to acpTypes
- Restrict ACP backend to Qwen only
- Remove backend property from connection state
- Minor formatting update in webview index.tsx
2025-12-08 20:44:48 +08:00
pomelo
a6a572336c Merge pull request #1157 from QwenLM/fix/windows-shell-output-garbled 2025-12-08 16:45:05 +08:00
yiliang114
96cd685b1b chore(vscode-ide-companion): rm comment about claude 2025-12-08 13:04:06 +08:00
yiliang114
e8b4ee111c fix(vscode-ide-companion): rm useless component 2025-12-08 12:58:05 +08:00
yiliang114
ac0d5206ba chore(vscode-ide-companion): rm useTerminal & some useless code about openDiff 2025-12-08 12:46:42 +08:00
xuewenjie
e5e1e6a3da Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-shell-output-garbled 2025-12-08 11:35:47 +08:00
yiliang114
6269415e7b chore(vscode-ide-companion): reorder dlv dependency in package-lock.json 2025-12-08 11:31:58 +08:00
yiliang114
efccd44cb4 fix(vscode-ide-companion/permission-drawer): improve ref typing for input element 2025-12-08 11:31:50 +08:00
tanzhenxin
efbf50554d Merge branch 'main' into feat/acp-usage-metadata 2025-12-08 10:27:38 +08:00
yiliang114
63e4794633 chore(vscode-ide-companion): temporarily annotate some logic to suppress showDiff 2025-12-08 10:18:55 +08:00
yiliang114
be71976a1f chore(vscode-ide-companion): refactor directory structure 2025-12-08 00:54:26 +08:00
yiliang114
e47263f7c9 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode 2025-12-07 23:05:36 +08:00
yiliang114
51b4de0c23 fix(vscode-ide-companion): resolve ESLint errors and improve code quality
- Fix unused variable issues by removing unused variables and renaming caught errors to match ESLint rules
- Fix TypeScript type mismatches in mode handling
- Add missing curly braces to if statements to comply with ESLint rules
- Resolve missing dependency warnings in React hooks
- Clean up empty catch blocks by adding appropriate comments
- Remove unused _lastEditorState variables that were declared but never read

These changes ensure the codebase passes ESLint checks and follows best practices for code quality.
2025-12-07 16:57:40 +08:00
yiliang114
67eee14ca9 style(vscode-ide-companion): adjust chat session initialization logic and optimize tool invocation component style 2025-12-06 22:44:31 +08:00
yiliang114
ed44520e51 fix(vscode-ide-companion): clean up deprecated permission drawer file
Remove obsolete PermissionDrawer.tsx file that has been replaced by the new directory structure
2025-12-06 21:46:31 +08:00
yiliang114
7cd26f728d feat(vscode-ide-companion): implement session message handling and UI improvements
Complete session message handling with JSONL support and UI enhancements

- Add JSONL session file reading capability

- Improve error handling and authentication flows

- Update UI components for better user experience

- Fix command identifier references

- Enhance MarkdownRenderer with copy functionality

- Update Tailwind configuration for better component coverage
2025-12-06 21:46:14 +08:00
yiliang114
ad79b9bcab refactor(vscode-ide-companion): restructure tool call components
Restructure tool call components with dedicated container implementations

- Move tool call components to done subdirectory

- Implement specialized ToolCallContainer for each tool type

- Update component routing in ToolCallRouter

- Add isFirst/isLast props for better layout control

- Improve shared types and layout components
2025-12-06 21:45:51 +08:00
yiliang114
ad301963a6 feat(vscode-ide-companion): enhance session management with pagination support
Implement cursor-based pagination for session listing and improve session handling

- Add pagination state management in useSessionManagement hook

- Implement handleLoadMoreSessions for infinite scrolling

- Update SessionMessageHandler to support paged session requests

- Add ChatHeader component for improved UI layout

- Fix session title duplication issue

- Improve error handling in session operations
2025-12-06 21:45:36 +08:00
yiliang114
e538a3d1bf fix(vscode-ide-companion): improve ACP connection and session management
Enhance session/load and session/list ACP methods with proper cwd handling and pagination support

- Add workingDir tracking in AcpConnection

- Improve parameter handling in loadSession and listSessions

- Add pagination support for session listing

- Fix null/undefined checks in message handling
2025-12-06 21:45:18 +08:00
yiliang114
413c143004 feat(vscode-ide-companion): update command identifiers to use kebab-case
Standardize command naming convention by changing qwenCode.* to qwen-code.* in package.json configuration
2025-12-06 21:45:04 +08:00
Mingholy
b4be2c6c7f Merge pull request #1166 from QwenLM/mingholy/fix/unstable-e2e-test
test: skip unstable e2e test
2025-12-06 18:42:29 +08:00
mingholy.lmh
8b5b8d2b90 test: skip unstable e2e test 2025-12-06 18:41:19 +08:00
Mingholy
6e826b815e Merge pull request #1165 from QwenLM/mingholy/fix/sdk-timeout
fix: update timeout settings and default logging level in SDK
2025-12-06 18:18:23 +08:00
mingholy.lmh
86b166bb1d fix: adjust e2e tests via timeout option 2025-12-06 17:53:31 +08:00
yiliang114
57a684ad97 WIP: All changes including session and toolcall improvements 2025-12-06 16:53:40 +08:00
mingholy.lmh
bf6abf7752 fix: update timeout settings and default logging level in SDK 2025-12-06 12:27:16 +08:00
yiliang114
541d0b22e5 chore(vscode-ide-companion): code style & command register bugfix 2025-12-06 01:32:52 +08:00
yiliang114
96b275a756 fix(vscode-ide-companion): fix bugs & support terminal mode operation 2025-12-06 00:30:22 +08:00
yiliang114
be44e7af56 refactor(vscode-ide-companion): update message handling and configuration 2025-12-05 18:03:46 +08:00
yiliang114
ac9cb3a6d3 style(vscode-ide-companion/ui): improve component styling and layout 2025-12-05 18:03:37 +08:00
yiliang114
13aa4b03c7 feat(vscode-ide-companion/ui): improve permission drawer UI and logic 2025-12-05 18:03:29 +08:00
yiliang114
75fd2a5dcc feat(vscode-ide-companion/markdown): add copy button to code blocks in markdown renderer 2025-12-05 18:03:23 +08:00
yiliang114
811b332bc3 feat(vscode-ide-companion/ui): add context toggle control to input form 2025-12-05 18:03:18 +08:00
yiliang114
bf4673b00b feat(vscode-ide-companion/webview): enhance text selection tracking and restore path 2025-12-05 18:03:08 +08:00
yiliang114
645a5b181a fix(vscode-ide-companion/panel): update chat webview placement strategy to dedicated left group 2025-12-05 18:03:03 +08:00
yiliang114
2957058521 feat(vscode-ide-companion/webview): persist info banner dismissal state and add context control 2025-12-05 18:02:54 +08:00
yiliang114
e7b92622ce feat(vscode-ide-companion/diff): implement diff auto-open functionality in preferred editor group 2025-12-05 18:02:46 +08:00
yiliang114
82f97fe56d fix(vscode-ide-companion/acp): correct optionId mapping and outcome handling in acpMessageHandler 2025-12-05 18:02:40 +08:00
xuewenjie
2c1a836f18 fix: prefer UTF-8 encoding for shell output on Windows when detected 2025-12-05 16:49:26 +08:00
tanzhenxin
3a7b1159ae feat: add usage metadata in acp session/update event 2025-12-05 15:40:49 +08:00
yiliang114
c6f5a4585e style(vscode-ide-companion/ui): improve InfoBanner layout and centering
Add maxWidth constraint and auto margins to center the InfoBanner component
for better visual presentation.
2025-12-05 12:44:56 +08:00
yiliang114
b1a439e38f feat(vscode-ide-companion/read): implement diff auto-open functionality
Add diff viewing capabilities to Read tool calls with automatic opening of
VS Code diff tabs when diff content is detected. Includes:
- Import necessary React hooks and VS Code context
- Memoize content grouping for performance
- Auto-open diff in VS Code with useEffect
- Handle diff messages with useCallback
- Display compact UI when diff content is present
2025-12-05 12:42:58 +08:00
yiliang114
a6467e7f9b fix(vscode-ide-companion/ui): remove active background from focused permission options
Remove bg-[var(--app-list-active-background)] from focused permission drawer
options to improve visual consistency.
2025-12-05 12:42:50 +08:00
yiliang114
5ed60348d6 refactor(vscode-ide-companion/extension): disable unused path import and diff relay
Temporarily disable unused path import and diff event relaying functionality
that was causing issues.

TODO: 没有生效 - temporarily disabled due to commented out usage
2025-12-05 12:42:44 +08:00
yiliang114
0851ab572d fix(vscode-ide-companion/acp): correct optionId mapping in acpMessageHandler
Simplify the optionId mapping logic to directly use the provided optionId
rather than transforming 'reject_once' to 'cancel'. This ensures the
original optionId value is preserved in the outcome.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 11:41:33 +08:00
yiliang114
8203f6582f feat(vscode-ide-companion/extension): relay diff accept/cancel events to chat webview
Added functionality to relay diff accepted/cancelled events from the IDE to the chat webview
so users get immediate feedback when they accept or cancel diffs in the Claude Code style.
2025-12-05 02:45:44 +08:00
yiliang114
2d844d11df fix(vscode-ide-companion): improve message logging and permission handling
- Increase message logging truncation limit from 500 to 1500 characters
- Fix permission option mapping logic for reject_once/cancel options
- Add TODO comments for diff accept/cancel responses during permission requests

Resolves issues with permission handling and improves debugging capabilities.
2025-12-05 02:15:48 +08:00
yiliang114
4145f45c7c fix(vscode-ide-companion): fix send/stop button state timing issue
- Show stop button immediately when request is submitted (isWaitingForResponse=true)
- Prevent confusing state where send button appears disabled instead of stop button
- Improve UX by providing immediate visual feedback on request submission

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 12:47:18 +08:00
yiliang114
d56923b657 refactor(vscode-ide-companion): remove deprecated file locations
- Removed old InfoBanner.tsx file location
- Removed old ReadToolCall.tsx file location

These files have been moved to new directory structures.
2025-12-04 08:29:34 +08:00
yiliang114
32258f2f04 fix(vscode-ide-companion): ensure restored panel title resets to default
- Added code to reset restored panel title to 'Qwen Code' on initialization
- Added error handling for title reset operation

This ensures consistent panel labeling when restoring previous sessions.
2025-12-04 08:29:22 +08:00
yiliang114
5dec3e653c feat(vscode-ide-companion): add specialized tool call components
- Added ExecuteNodeToolCall for specialized node/npm command execution
- Added UpdatedPlanToolCall for enhanced plan visualization with checkboxes
- Created ExecuteNode CSS styling
- Refactored ReadToolCall to new directory structure
- Updated tool call router to handle new component types
- Enhanced LayoutComponents with className prop support
- Added specialized handling for todo_write and updated_plan tool kinds

These additions improve the visualization and handling of various tool call types in the UI.
2025-12-04 08:29:07 +08:00
yiliang114
3053e6c41f style(vscode-ide-companion): update UI components and styling
- Updated chat message container spacing and styling
- Adjusted AssistantMessage text color handling
- Enhanced QwenMessageTimeline CSS rules for better visual flow
- Moved InfoBanner to ui directory for better organization
- Updated InputForm styling
- Added new CSS classes and updated tailwind configuration
- Improved timeline connection lines and message item spacing

These changes enhance the visual appearance and user experience of the chat interface.
2025-12-04 08:28:54 +08:00
yiliang114
86cd06ef43 feat(vscode-ide-companion): add MarkdownRenderer component for rich message formatting
- Added MarkdownRenderer component with markdown-it integration
- Updated MessageContent to use MarkdownRenderer instead of custom parsing
- Added CSS styling for markdown-rendered content

This improves message display with proper markdown rendering support.
2025-12-04 08:28:42 +08:00
yiliang114
7270983821 feat(vscode-ide-companion): add markdown-it dependency for markdown rendering
- Added markdown-it as a dependency for enhanced markdown processing
- Updated @types/markdown-it as dev dependency
- Updated NOTICES.txt with new dependency licenses

This enables rich markdown rendering capabilities in the VS Code extension.
2025-12-04 08:28:30 +08:00
yiliang114
b1901f103f refactor(vscode-ide-companion): remove merged timeline CSS file
- Remove MergedSimpleTimeline.css in favor of SimpleTimeline.css
2025-12-04 01:54:16 +08:00
yiliang114
5701a3c897 refactor(vscode-ide-companion): update timeline CSS files
- Replace MergedSimpleTimeline.css with SimpleTimeline.css
- Update timeline styling for tool calls and messages
2025-12-04 01:54:07 +08:00
yiliang114
2145b28f8b style(vscode-ide-companion): update message components and layout styling
- Add qwen-message class to AssistantMessage container
- Update LayoutComponents styling
- Add custom timeline styles for message items
2025-12-04 01:53:42 +08:00
yiliang114
e3c456a430 feat(vscode-ide-companion): add cancel streaming functionality
- Add handleCancel callback to App component
- Implement cancelStreaming message posting to VS Code
- Add onCancel prop to InputForm component
- Replace send button with stop button during streaming
2025-12-04 01:53:19 +08:00
yiliang114
35f98723ca style(vscode-ide-companion): bash toolcall 2025-12-04 00:23:19 +08:00
yiliang114
b9b3b6d62e style(vscode-ide-companion): header & empty state 2025-12-04 00:18:04 +08:00
yiliang114
cec6b8691a Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode 2025-12-03 23:14:44 +08:00
yiliang114
05f5189bb4 refactor(vscode-ide-companion/panel): reimplement editor group locking with proper error handling 2025-12-03 01:03:10 +08:00
yiliang114
c6299bf135 feat(vscode-ide-companion): improve CLI path detection and error handling
- Move determineNodePathForCli function to dedicated cliPathDetector.ts file
- Enhance error handling with specific guidance for permission issues
- Add detailed error messages for different failure scenarios
- Improve logging for debugging CLI path detection issues

This change improves the reliability of CLI path detection by providing better error messages and handling edge cases more gracefully.
2025-12-03 00:27:20 +08:00
yiliang114
2e449f4d45 wip(vscode-ide-companion): timelint 2025-12-02 14:41:00 +08:00
yiliang114
90fc53a9df refactor(webview): Refactoring Input Form and Timeline Components 2025-12-02 01:29:33 +08:00
yiliang114
ed0d5f67db style(vscode-ide-companion): form component style opt 2025-12-01 00:15:18 +08:00
yiliang114
1b37d729cb style(vscode-ide-companion): use tailwind to refactor some ui components 2025-11-30 23:06:37 +08:00
yiliang114
1acc24bc17 fix(vscode-ide-companion): Interactive unification of first login and login 2025-11-30 22:26:04 +08:00
yiliang114
b1e74e5732 style(vscode-ide-companion): optimize header & message style 2025-11-30 21:27:55 +08:00
yiliang114
82205034cc chore(vscode-ide-companion): remove the ui component of the active save session temporarily 2025-11-29 18:29:43 +08:00
yiliang114
c038745897 fix(vscode-ide-companion): 修复 Tailwind 可重用组件类和 ESLint 配置, 调整 ChatHeader 按钮样式
- 在 tailwind.css 中正确定义可重用的 Tailwind 组件类
- 修复 ChatHeader 组件中的按钮样式,确保 hover 效果正常工作
- 修复 ESLint 配置中的 importPlugin 导入问题
- 清理 App.css 中重复的 CSS 变量定义
- 为 btn-ghost 类设置 4px border radius
- 为按钮内的 span 添加左右 4px padding (使用 px-1)
- 确保按钮 hover 时有背景色效果
2025-11-29 18:13:50 +08:00
yiliang114
6885138cf0 refactor(vscode-ide-companion): Refactoring the project structure and updating dependencies 2025-11-29 13:16:58 +08:00
yiliang114
9ae45c01a6 refactor(vscode): 重构消息排序和展示逻辑
- 移除旧的消息排序改进总结文档
- 重新组织消息渲染逻辑,合并所有类型的消息按时间戳排序
- 优化工具调用处理流程,添加时间戳支持
- 改进会话保存机制,直接使用SessionManager保存检查点
- 重构部分组件以提高可维护性
2025-11-28 22:35:31 +08:00
yiliang114
5ce40085d5 fix(vscode-ide-companion): 优化 CLI 检测和连接逻辑 2025-11-28 17:44:18 +08:00
yiliang114
627f5fb43a refactor(vscode-ide-companion): 优化代码结构和性能
- 移除未使用的依赖项 qwen-code
- 优化 completion 刷新逻辑,避免渲染循环
- 更新 CompletionMenu 组件,增加空状态提示
2025-11-28 10:04:29 +08:00
yiliang114
9cc48f12da feat(vscode-ide-companion): 改进消息排序和显示逻辑
- 添加时间戳支持,确保消息按时间顺序排列
- 更新工具调用处理逻辑,自动添加和保留时间戳
- 修改消息渲染逻辑,将所有类型的消息合并排序后统一渲染
- 优化完成的工具调用显示,修复显示顺序问题
- 调整进行中的工具调用显示,统一到消息流中展示
- 移除重复的计划展示逻辑,避免最新块重复出现
- 重构消息处理和渲染代码,提高可维护性
2025-11-28 09:55:06 +08:00
yiliang114
dc340daf8b feat(vscode-ide-companion): 0.2.4 版本ACP 协议检测和实现
- 新增 session/list 方法支持
- 改进 session/load 方法兼容性
- 优化代理环境变量设置
- 调整 CLI 安装流程
- 移除未使用的随机加载消息功能
2025-11-28 01:17:55 +08:00
yiliang114
f78b1eff93 build(vscode-ide-companion): 更新包准备命令
- 将 "prepare1" 命令重命名为 "prepare",以提高可读性和一致性
- 优化了包生成流程,确保在构建前生成必要的 notices
2025-11-28 01:14:36 +08:00
yiliang114
8bc9bea5a1 feat(cli): 添加 CLI 版本检测和会话验证功能
- 新增 CLI 版本检测功能,支持检测 CLI 版本并缓存结果
- 实现会话验证方法,用于检查当前会话是否有效
- 在连接处理中集成 CLI 版本检测和会话验证逻辑
- 优化 WebViewProvider 中的初始化流程,支持背景初始化
- 更新消息处理逻辑,增加与 CLI 相关的错误处理
2025-11-28 01:13:57 +08:00
yiliang114
b986692f94 feat(auth): 优化认证流程并添加认证状态管理
- 新增 AuthStateManager 类用于管理认证状态
- 修改 createNewSession 方法以使用缓存的认证信息
- 添加清除认证缓存的功能
- 优化登录命令处理,增加加载状态显示
- 新增登录成功和失败的消息处理
2025-11-28 01:06:36 +08:00
yiliang114
4f63d92bb1 Add unit tests for CLI modules and fix ESLint issues
- Add comprehensive unit tests for all CLI-related modules:
  - CliContextManager
  - CliVersionManager
  - cliDetector
  - CliInstaller
- Fix ESLint issues by replacing @ts-ignore with @ts-expect-error
- Fix any type issues in test files
- Add tests for diff-manager functionality
- Improve loading messages random selection stability

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 01:06:21 +08:00
yiliang114
3c09ad46ca refactor(vscode-ide-companion): translate Chinese comments to English
- Translate all Chinese comments in TypeScript files to English for better code readability
- Update documentation comments to be in English
- Maintain code functionality while improving internationalization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 01:01:47 +08:00
yiliang114
d5ede56e62 Revert "fix(vscode-ide-companion): 解决 mac 环境多个 node 版本的安装问题"
This reverts commit 530039c517.
2025-11-28 00:57:33 +08:00
yiliang114
530039c517 fix(vscode-ide-companion): 解决 mac 环境多个 node 版本的安装问题 2025-11-25 20:24:26 +08:00
yiliang114
0cbf95d6b3 chore(vscode-ide-companion): update dependencies in package-lock.json
Added new dependencies including:
- @cfworker/json-schema
- @parcel/watcher and related platform-specific packages
- autoprefixer
- browserslist
- chokidar
- Various other utility packages

These updates likely support enhanced functionality and improved compatibility.
2025-11-25 15:30:36 +08:00
yiliang114
579772197a chore(vscode-ide-companion): 更新 .gitignore 文件
- 移除了 pnpm-lock.yaml 文件的忽略规则
- 保留了 .claude 目录的忽略规则
2025-11-25 13:46:38 +08:00
yiliang114
934365c41f style(vscode-ide-companion): improve UI styling for chat components 2025-11-25 13:39:49 +08:00
yiliang114
f623bfbb34 chore(vscode-ide-companion): add qwen-code dependency to package files 2025-11-25 13:39:07 +08:00
yiliang114
f503eb2520 feat(vscode-ide-companion): split module & notes in english 2025-11-25 00:32:51 +08:00
yiliang114
3cf22c065f Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode 2025-11-24 20:40:19 +08:00
yiliang114
a1ec1227cc refactor(vscode-ide-companion): reorganize component structure
重构组件结构,优化目录组织:
- 将 ChatHeader 从 ui/ 移动到 layouts/ 目录
- 删除未使用的 Button 和 Card 组件
- 更新 tailwind.config.js 扫描路径为 layouts/
- 简化组件目录结构,提高可维护性
2025-11-24 20:38:36 +08:00
yiliang114
36af718616 chore(vscode-ide-companion): add testing infrastructure and improve config files
添加测试基础设施和改进配置文件:
- 添加 vitest.config.js 用于单元测试配置
- 配置测试覆盖率报告(v8 provider)
- 修复 postcss.config.js 的 eslint 警告
- 设置测试环境为 Node.js
2025-11-24 20:38:35 +08:00
yiliang114
795e7fa2c5 fix(vscode-ide-companion): improve type safety in webview components
修复 webview 组件的类型安全问题:
- App.tsx: 规范化工具调用状态为联合类型
- InProgressToolCall.tsx: 安全处理非字符串类型的 title 属性
- InputForm.tsx: 修正 RefObject 泛型类型声明
- ReadToolCall.tsx: 添加空 children 避免 ToolCallContainer 警告
2025-11-24 20:38:35 +08:00
yiliang114
b6914c6b33 feat(vscode-ide-companion): enhance WebViewProvider with force re-login functionality
增强 WebViewProvider 的重新登录功能:
- 修复 _lastEditorState 变量声明错误(const -> let)
- 在强制重新登录后添加清理等待时间
- 添加登录成功/失败的 WebView 消息通知
- 改进错误处理和日志记录
- 为用户提供更好的登录状态反馈
2025-11-24 20:38:35 +08:00
yiliang114
f11d054a47 feat(vscode-ide-companion): improve authentication flow with cached auth state
优化认证流程,支持缓存认证状态避免重复认证:
- 在 qwenAgentManager 中创建会话前先进行认证
- 在 qwenConnectionHandler 中检查缓存的认证状态
- 只在没有有效缓存时触发新的认证流程
- 认证失败时清除无效缓存
- 添加详细的日志记录用于调试
2025-11-24 20:32:08 +08:00
yiliang114
4ad377b0d8 chore(vscode-ide-companion): update lock 2025-11-24 10:04:03 +08:00
yiliang114
b7f9acf0ff refactor(vscode-ide-companion): migrate session save to CLI /chat save command
- Replace manual checkpoint file writing with CLI's native /chat save command
- Add saveCheckpointViaCommand method to use CLI's built-in save functionality
- Deprecate saveSessionViaAcp as CLI doesn't support session/save ACP method
- Update saveCheckpoint to delegate to CLI command for complete context preservation
- Enhanced error logging in acpSessionManager session load
- Mark saveSessionViaAcp as deprecated with fallback to command-based save
- Fix ESLint errors: remove unused imports and catch variables, wrap case block declarations

This ensures checkpoints are saved with complete session context including tool calls,
leveraging CLI's native save functionality instead of manual file operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 01:00:31 +08:00
yiliang114
4dfbdcddca feat(vscode-ide-companion): 增强工具调用与输入表单组件功能
- 新增 InProgressToolCall 组件用于展示进行中的工具调用状态
- 重构 InputForm 为独立组件,提升代码可维护性
- 改进 tool_call_update 处理逻辑,支持创建缺失的初始工具调用
- 添加思考块(thought chunk)日志以便调试 AI 思维过程
- 更新样式以支持新的进行中工具调用卡片显示
- 在权限请求时自动创建对应的工具调用记录
```
2025-11-23 22:28:11 +08:00
yiliang114
826516581b feat(vscode-ide-companion): send initial active editor state to WebView on initialization
- Send activeEditorChanged message with initial editor state when WebView is created
- Include fileName, filePath, and selection info if available
- Applied in both side panel and editor panel initialization

This ensures the WebView displays the current file context immediately on load.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:45:44 +08:00
yiliang114
4f964b5281 refactor(vscode-ide-companion): extract AssistantMessage as standalone component with Claude Code styles
- Extract AssistantMessage component from inline implementation
- Add status prop support (default, success, error, warning, loading)
- Implement bullet point indicator using CSS pseudo-elements (::before)
- Use inline styles for layout to prevent Tailwind override
- Add AssistantMessage.css with pseudo-element styles for different states
- Import AssistantMessage.css in ClaudeCodeStyles.css

Restores Claude Code DOM structure and styling:
- Outer container with padding-left: 30px for bullet spacing
- Bullet point colors based on status (green, red, yellow, gray)
- Loading state with pulse animation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:44:40 +08:00
yiliang114
de8ea0678d feat(vscode-ide-companion): refactor message components with modular architecture
Refactor UI message rendering by extracting message types into dedicated components. Add ChatHeader component for better session management interface.

- Extract message components: UserMessage, AssistantMessage, ThinkingMessage, StreamingMessage, WaitingMessage
- Add ChatHeader component with session selector and action buttons
- Delete MessageContent.css and consolidate styles into App.scss
- Update Tailwind config for component styling
- Improve message rendering with proper TypeScript typing
2025-11-23 20:56:15 +08:00
yiliang114
c4bcd178a4 feat(vscode-ide-companion): implement manual login via /login command
BREAKING CHANGE: Login is no longer automatic when opening webview

Changes:
- Remove auto-login on webview open and restore
- Add /login slash command for manual authentication
- Add VSCode progress notification during login process
- Add warning notification when user tries to chat without login
- Implement pending message auto-retry after successful login
- Add NotLoggedInMessage component (for future use)
- Improve InfoBanner close button styling consistency

User flow:
1. Open webview - no automatic login
2. Type /login or select from completion menu to login
3. Show "Logging in to Qwen Code..." progress notification
4. After login, show success message and auto-retry pending messages
5. If user tries to chat without login, show warning with "Login Now" button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 19:20:01 +08:00
yiliang114
e5729b0420 Merge branch 'feat/jinjing/implement-ui-from-cc-vscode-extension' of https://github.com/yiliang114/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-23 18:37:00 +08:00
yiliang114
aceb857436 refactor(vscode-ide-companion): extract InfoBanner as standalone component
Move info banner UI from App.tsx to dedicated component with open settings functionality.
2025-11-23 18:11:47 +08:00
yiliang114
e15dd2f5c9 refactor(vscode-ide-companion): extract InfoBanner as standalone component
Move info banner UI from App.tsx to dedicated component with open settings functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 18:09:43 +08:00
yiliang114
8ac38aad92 chore(vscode-ide-companion): tailwind base 2025-11-23 17:33:10 +08:00
yiliang114
38fd303b07 docs(vscode-ide-companion): add Tailwind CSS integration guides
添加 Tailwind CSS 渐进式集成文档,包括:
- 渐进式采用策略文档
- 集成计划文档
- 渐进式集成指南
2025-11-23 16:41:22 +08:00
yiliang114
9899d872a2 feat(vscode-ide-companion): add Tailwind CSS demo components
添加 Tailwind CSS 示例组件和重构的 PermissionDrawer:
- TailwindDemo: 展示 Tailwind CSS 基础用法
- PermissionDrawer.tailwind: 使用 Tailwind CSS 重构的权限抽屉组件
2025-11-23 16:41:09 +08:00
yiliang114
36a96a7b5c feat(vscode-ide-companion): add shadcn/ui components and utilities
添加 shadcn/ui 基础组件库和 cn 工具函数,包括:
- Button 按钮组件
- Dialog 对话框组件
- cn 类名合并工具函数
2025-11-23 16:40:55 +08:00
yiliang114
951f6b2829 feat(vscode-ide-companion): add global Tailwind CSS styles
添加全局 Tailwind CSS 样式文件,包含基础层、组件层和工具层
2025-11-23 16:40:42 +08:00
yiliang114
eff01819a8 build(vscode-ide-companion): add Tailwind CSS configuration
添加 Tailwind CSS、PostCSS 和 Autoprefixer 配置,为渐进式采用 Tailwind CSS 做准备
2025-11-23 16:40:30 +08:00
yiliang114
31f8ca07b6 chore: remove root package-lock.json
移除根目录的 package-lock.json 文件,采用各子包独立管理依赖的策略
2025-11-23 16:40:17 +08:00
yiliang114
39adaaff11 refactor(vscode-ide-companion): minor adjustments to SaveSessionDialog and SessionManager components 2025-11-23 15:17:40 +08:00
yiliang114
fd2e5b0933 feat(vscode-ide-companion): improve PermissionDrawer UI with enhanced styling and responsiveness 2025-11-23 15:17:17 +08:00
yiliang114
49a2be195d chore: add .claude/ to gitignore 2025-11-23 15:17:05 +08:00
yiliang114
ce07fb2b3f feat(session): 实现会话保存和加载功能
- 在 AcpConnection 和 AcpSessionManager 中添加会话保存方法
- 在 QwenAgentManager 中实现通过 ACP 和直接保存会话的功能
- 在前端添加保存会话对话框和相关交互逻辑
- 新增 QwenSessionManager 用于直接操作文件系统保存和加载会话
2025-11-21 23:51:48 +08:00
yiliang114
e2beecb9c4 feat(vscode-ide-companion): 更新核心服务和扩展功能
- 增强 extension.ts,集成新增功能
- 优化 ide-server.ts,改进服务端逻辑
- 更新 diff-manager.ts,提升差异管理能力
- 改进 ACP 连接和消息处理
- 更新会话处理器,支持新的交互模式

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:54:24 +08:00
yiliang114
ecc6e22002 feat(vscode-ide-companion): 更新 UI 样式
- 重构 PlanDisplay 组件和样式
- 更新 PermissionRequest 组件逻辑
- 增强 PermissionDrawer 样式,提升视觉体验

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:54:03 +08:00
yiliang114
99f93b457c feat(vscode-ide-companion): 更新主应用界面和消息处理
- 重构 App.tsx,集成新增的 UI 组件
- 增强 MessageHandler,支持更多消息类型处理
- 优化 FileOperations,改进文件操作逻辑

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:53:46 +08:00
yiliang114
748ad8f4dd refactor(vscode-ide-companion): 重构工具调用组件
- 重构 ExecuteToolCall、GenericToolCall、ReadToolCall 等组件
- 统一工具调用组件的展示样式和交互逻辑
- 优化代码结构,提高可维护性

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:53:25 +08:00
yiliang114
a33187ed7a feat(vscode-ide-companion): 新增时间线组件
- 新增 Timeline 组件用于显示会话历史
- 支持展示消息、工具调用等事件
- 提供清晰的时间轴视图

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:53:05 +08:00
yiliang114
088c766c22 feat(vscode-ide-companion): 新增自动完成功能
- 新增 CompletionMenu 组件支持 @ 和 / 触发补全
- 新增 useCompletionTrigger hook 处理补全触发逻辑
- 支持实时查询和过滤补全项

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:52:47 +08:00
yiliang114
b82ef5b73f feat(vscode-ide-companion): 新增上下文附件管理功能
- 新增 ContextAttachmentManager 管理上下文附件
- 新增 ContextPills 组件用于显示上下文标签
- 支持文件、符号、选区等多种上下文类型

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:52:29 +08:00
yiliang114
328924f578 feat(vscode-ide-companion): 新增 DiffDisplay 组件和 diff 统计工具
- 增强 DiffDisplay 组件,支持更丰富的差异展示
- 新增 diffStats.ts 工具,提供差异统计功能
- 新增样式文件 DiffDisplay.css

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:52:10 +08:00
yiliang114
1eedd36542 feat(vscode-ide-companion): 新增共享 UI 组件 FileLink
- 新增 FileLink 组件用于显示文件链接
- 更新 LayoutComponents 增加通用布局组件
- 新增 utils.ts 提供工具函数

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 01:51:50 +08:00
yiliang114
9ba99177b9 refactor(vscode-ide-companion): 重构主动完成和会话管理
- 移除了 QwenAgentManager 中的冗余类型导出
- 优化了 App 组件中的会话管理和标题更新逻辑
- 改进了消息输入框的中文输入法支持
- 调整了活动文件指示器的样式
2025-11-20 23:50:41 +08:00
yiliang114
7d2411e72f feat(vscode-ide-companion): 优化 Qwen Code 聊天窗口创建逻辑
- 修改 createPanel 方法返回值类型,使其支持异步操作
- 实现聚焦当前激活编辑器的功能
- 优化多窗口创建逻辑,允许在已有 Qwen Code 窗口旁边创建新窗口
- 移除自动锁定编辑器组的功能,以支持多个 Qwen Code 标签页
- 在 UI 中添加聚焦当前文件的按钮
2025-11-20 23:41:41 +08:00
yiliang114
5a9f5e3432 fix(vscode-ide-companion): 修复新建会话按钮,在同一 view column 创建新 tab
问题:
- 之前的实现会复用现有 panel 并清空当前会话
- 期望行为是在同一 view column(不创建分屏)中创建新的 VS Code tab

解决方案:
1. 修改 qwenCode.openNewChatTab 命令
   - 总是创建新的 WebviewProvider 和 WebviewPanel
   - PanelManager 的 findExistingQwenCodeViewColumn() 确保在同一 column 打开
2. 修改 MessageHandler 中的 openNewChatTab 处理
   - 调用 VS Code 命令创建新 panel/tab
3. 移除不再需要的 createNewSession 方法

效果:
- 点击新建会话按钮会在同一 view column 中创建新的 VS Code tab
- 类似 Claude Code 的交互方式
- 不会创建新的分屏

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:14:40 +08:00
yiliang114
95b67bbebd fix(vscode-ide-companion): 修复新建会话按钮创建新分屏的问题
问题:
- 点击新建会话按钮时,会在 VS Code 中创建一个新的 webview panel(分屏)
- 期望行为是在同一个 panel 内创建新会话,类似 Claude Code 的交互方式

解决方案:
1. 修改 extension.ts 中的 openNewChatTab 命令
   - 检查是否已有 webview panel 打开
   - 如果有,则在现有 panel 中创建新会话
   - 如果没有,才创建新 panel
2. 在 WebViewProvider 中添加 createNewSession 方法
   - 通过 agentManager 创建新会话
   - 清空当前对话 UI
3. 修改 MessageHandler 中的 openNewChatTab 处理
   - 直接调用 handleNewQwenSession 创建新会话
   - 不再执行 VS Code 命令创建新 panel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 23:07:56 +08:00
yiliang114
492c56a780 test(vscode-ide-companion): registerWebviewPanelSerializer 添加 mock 实现 2025-11-20 14:31:20 +08:00
yiliang114
06a8580361 refactor(vscode-ide-companion): 重构 WebViewProvider 组件 2025-11-20 11:37:28 +08:00
yiliang114
dcc10eb0a9 fix(vscode-ide-companion): 重构 useVSCode hook 实现, 解决 webview 中 vscode api 重复声明 2025-11-20 11:07:09 +08:00
yiliang114
805e5f92c1 docs(readme): 添加 VS Code 扩展相关信息
- 在主 README 中添加 VS Code 扩展部分,介绍扩展的功能和用途
- 更新 VS Code 扩展的 README,详细说明调试和开发指南
- 优化扩展开发流程说明,提供两种调试选项
2025-11-20 10:41:23 +08:00
yiliang114
8cb7ea0d3d Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-20 10:06:14 +08:00
yiliang114
b534bd2b18 chore: rm wip doc 2025-11-20 10:05:47 +08:00
yiliang114
6286b8b6e8 feat(vscode-ide-companion): 增加代码编辑功能和文件操作支持
- 实现了与 Claude Code 类似的代码编辑功能
- 添加了文件打开、保存等操作的支持
- 优化了消息显示,增加了代码高亮和文件路径点击功能
- 改进了用户界面,增加了编辑模式切换和思考模式功能
2025-11-20 01:04:11 +08:00
yiliang114
e81255e589 feat(vscode-ide-companion): 优化权限请求组件并添加错误处理功能
- 移动权限请求组件到抽屉中,优化用户体验
- 为权限选项添加编号,提高可识别性
- 实现错误对象的特殊处理,提取更有意义的错误信息
- 优化工具调用错误内容的展示,提高错误信息的可读性
2025-11-20 00:01:18 +08:00
yiliang114
018990b7f6 build(vscode-ide-companion): 添加 SCSS 支持
- 在 esbuild.js 中添加 SCSS 文件处理逻辑
- 在 package.json 中添加 sass 依赖
- 新增代码使用 sass 编译 SCSS 文件,并将其注入到页面中
2025-11-19 23:34:05 +08:00
yiliang114
bc2b503e8d test(vscode-ide-companion): 尝试通过 session/load 旧会话
- 修改了 WebViewProvider 中的逻辑,先尝试通过 ACP 加载旧会话
- 如果加载失败,则创建新会话作为回退方案
- 在 AcpConnection 中添加了初始化响应的日志输出
- 在 QwenAgentManager 中添加了新的 loadSessionViaAcp 方法,用于测试 ACP 的 session/load 功能
2025-11-19 17:08:25 +08:00
yiliang114
454cbfdde4 refactor(webview): 重构工具调用显示逻辑
- 新增多个工具调用组件,分别处理不同类型的工具调用
- 优化工具调用卡片的样式和布局
- 添加加载状态和随机加载消息
- 重构 App 组件,支持新的工具调用显示逻辑
2025-11-19 15:42:35 +08:00
yiliang114
04dfad7ab5 docs(implementation): 更新实现总结文档 2025-11-19 13:50:52 +08:00
yiliang114
e02866d06f refactor(vscode-ide-companion): 重构代码并更新文件命名
- 更新文件命名规则,使用小写字母和下划线
- 修复部分代码导入路径
- 删除未使用的 WEBVIEW_PIN_FEATURE.md 文件
2025-11-19 10:40:16 +08:00
yiliang114
9fcdd3fa77 Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-19 10:08:21 +08:00
yiliang114
754ae30939 refactor(vscode-ide-companion): 重构 WebViewProvider 初始化逻辑
- 抽离初始化代理连接逻辑到单独的方法中
- 优化面板恢复时的代理连接流程
- 移除 EmptyState 组件中的信息横幅
- 在 App 组件中添加可关闭的信息横幅
- 调整输入表单样式,移除冗余样式
2025-11-19 00:40:48 +08:00
yiliang114
0577fe6f36 refactor(vscode-ide-companion): 重构 WebViewProvider 初始化逻辑
- 抽离初始化代理连接逻辑到单独的方法中
- 优化面板恢复时的代理连接流程
- 移除 EmptyState 组件中的信息横幅
- 在 App 组件中添加可关闭的信息横幅
- 调整输入表单样式,移除冗余样式
2025-11-19 00:34:45 +08:00
yiliang114
732220e651 wip(vscode-ide-companion): 实现 quick win 功能
- 将 WebView 调整到编辑器右侧
- 添加 ChatHeader 组件,实现会话下拉菜单
- 替换模态框为紧凑型下拉菜单
- 更新会话切换逻辑,显示当前标题
- 清理旧的会话选择器样式
基于 Claude Code v2.0.43 UI 分析实现。
2025-11-19 00:16:45 +08:00
yiliang114
729a3d0ab3 Merge branch 'feat/jinjing/write-and-read-file-in-vscode' into feat/jinjing/implement-ui-from-cc-vscode-extension 2025-11-18 19:15:18 +08:00
yiliang114
0e3759fbd2 chore(vscode-ide-companion): 新建文件版权头更新 2025-11-18 19:14:38 +08:00
yiliang114
f8db157a5d Merge branch 'feat/jinjing/qwen-code-vscode-extension-init' into feat/jinjing/write-and-read-file-in-vscode 2025-11-18 19:13:11 +08:00
yiliang114
f827aadd76 Revert "refactor(vscode-ide-companion): 文件版权头 Copyright 2025 Google LLC"
This reverts commit 91af599823.
2025-11-18 19:10:07 +08:00
yiliang114
39426be9a1 wip 2025-11-18 14:25:05 +08:00
yiliang114
f95f6e63bb Merge branch 'feat/jinjing/qwen-code-vscode-extension-init' into feat/jinjing/write-and-read-file-in-vscode 2025-11-18 10:40:59 +08:00
yiliang114
91af599823 refactor(vscode-ide-companion): 文件版权头 Copyright 2025 Google LLC 2025-11-18 10:34:27 +08:00
yiliang114
ad8d7aae8a refactor(vscode-ide-companion): 文件版权头 Copyright 2025 Google LLC 2025-11-18 10:33:40 +08:00
yiliang114
d22d07a840 feat(vscode-ide-companion): 添加 Qwen Code CLI 安装检测和提示功能
- 新增 CliDetector 类用于检测 Qwen Code CLI 安装状态
- 在 WebViewProvider 中集成 CLI 检测逻辑
- 添加 CLI 未安装时的提示和安装引导功能
- 优化 agent 连接流程,增加 CLI 安装检测步骤
2025-11-18 01:52:46 +08:00
yiliang114
28892996b3 feat(vscode): 重构 Qwen 交互模型并优化权限请求 UI
- 重构 QwenAgentManager 类,支持处理多种类型的消息更新
- 改进权限请求界面,增加详细信息展示和选项选择功能
- 新增工具调用卡片组件,用于展示工具调用相关信息
- 优化消息流处理逻辑,支持不同类型的内容块
- 调整会话切换和新会话创建的处理方式
2025-11-18 01:00:25 +08:00
yiliang114
eeeb1d490a feat(vscode-ide-companion): 实现自定义权限请求 UI 并添加文件读写功能
- 新增 fs/read_text_file 和 fs/write_text_file 方法处理
- 实现精美的 Claude 风格权限请求 UI
- 优化权限请求处理逻辑,支持取消操作
- 添加日志输出以便调试
2025-11-17 21:44:39 +08:00
yiliang114
247c237647 fix(vscode-ide-companion): 优化缓存 Qwen Chat UI 的登录态机制 2025-11-17 20:00:52 +08:00
yiliang114
c423e12aa7 feat(vscode-ide-companion): update qwen logo 2025-11-17 19:10:17 +08:00
yiliang114
dc40995e70 feat(vscode-ide-companion): import chat chat customEditor to vscode extension folder 2025-11-17 18:53:00 +08:00
202 changed files with 23849 additions and 613 deletions

2
.vscode/launch.json vendored
View File

@@ -27,7 +27,7 @@
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js"
],
"preLaunchTask": "npm: build: vscode-ide-companion"
"preLaunchTask": "launch: vscode-ide-companion (copy+build)"
},
{
"name": "Attach",

16
.vscode/tasks.json vendored
View File

@@ -20,6 +20,22 @@
"problemMatcher": [],
"label": "npm: build: vscode-ide-companion",
"detail": "npm run build -w packages/vscode-ide-companion"
},
{
"label": "copy: bundled-cli (dev)",
"type": "shell",
"command": "node",
"args": ["packages/vscode-ide-companion/scripts/copy-bundled-cli.js"],
"problemMatcher": []
},
{
"label": "launch: vscode-ide-companion (copy+build)",
"dependsOrder": "sequence",
"dependsOn": [
"copy: bundled-cli (dev)",
"npm: build: vscode-ide-companion"
],
"problemMatcher": []
}
]
}

View File

@@ -88,6 +88,12 @@ npm install -g .
brew install qwen-code
```
## VS Code Extension
In addition to the CLI tool, Qwen Code also provides a **VS Code extension** that brings AI-powered coding assistance directly into your editor with features like file system operations, native diffing, interactive chat, and more.
> 📦 The extension is currently in development. For installation, features, and development guide, see the [VS Code Extension README](./packages/vscode-ide-companion/README.md).
## Quick Start
```bash

View File

@@ -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

View File

@@ -75,6 +75,8 @@ export default tseslint.config(
},
},
rules: {
// We use TypeScript for React components; prop-types are unnecessary
'react/prop-types': 'off',
// General Best Practice Rules (subset adapted for flat config)
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'arrow-body-style': ['error', 'as-needed'],
@@ -111,10 +113,14 @@ export default tseslint.config(
{
allow: [
'react-dom/test-utils',
'react-dom/client',
'memfs/lib/volume.js',
'yargs/**',
'msw/node',
'**/generated/**'
'**/generated/**',
'./styles/tailwind.css',
'./styles/App.css',
'./styles/style.css'
],
},
],

View File

@@ -25,6 +25,14 @@ type PendingRequest = {
timeout: NodeJS.Timeout;
};
type UsageMetadata = {
promptTokens?: number | null;
completionTokens?: number | null;
thoughtsTokens?: number | null;
totalTokens?: number | null;
cachedTokens?: number | null;
};
type SessionUpdateNotification = {
sessionId?: string;
update?: {
@@ -39,6 +47,9 @@ type SessionUpdateNotification = {
text?: string;
};
modeId?: string;
_meta?: {
usage?: UsageMetadata;
};
};
};
@@ -587,4 +598,52 @@ function setupAcpTest(
await cleanup();
}
});
it('receives usage metadata in agent_message_chunk updates', async () => {
const rig = new TestRig();
rig.setup('acp usage metadata');
const { sendRequest, cleanup, stderr, sessionUpdates } = setupAcpTest(rig);
try {
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say "hello".' }],
});
await delay(500);
// Find updates with usage metadata
const updatesWithUsage = sessionUpdates.filter(
(u) =>
u.update?.sessionUpdate === 'agent_message_chunk' &&
u.update?._meta?.usage,
);
expect(updatesWithUsage.length).toBeGreaterThan(0);
const usage = updatesWithUsage[0].update?._meta?.usage;
expect(usage).toBeDefined();
expect(
typeof usage?.promptTokens === 'number' ||
typeof usage?.totalTokens === 'number',
).toBe(true);
} catch (e) {
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
throw e;
} finally {
await cleanup();
}
});
});

View File

@@ -1195,7 +1195,7 @@ describe('Permission Control (E2E)', () => {
});
describe('mode comparison tests', () => {
it(
it.skip(
'should demonstrate different behaviors across all modes for write operations',
async () => {
const modes: Array<'default' | 'auto-edit' | 'yolo'> = [

1252
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.4.0",
"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.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -93,7 +93,7 @@
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^10.4.5",
"glob": "^10.5.0",
"globals": "^16.0.0",
"google-artifactregistry-auth": "^3.4.0",
"husky": "^9.1.7",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.4.0",
"version": "0.5.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.4.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.5.0"
},
"dependencies": {
"@google/genai": "1.16.0",
@@ -47,7 +47,7 @@
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fzf": "^0.5.2",
"glob": "^10.4.5",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
"ink": "^6.2.3",
"ink-gradient": "^3.0.0",
@@ -63,7 +63,7 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.1",
"tar": "^7.5.2",
"undici": "^7.10.0",
"extract-zip": "^2.0.1",
"update-notifier": "^7.3.1",

View File

@@ -88,6 +88,16 @@ export class AgentSideConnection implements Client {
);
}
/**
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
*/
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
return await this.#connection.sendNotification(
schema.CLIENT_METHODS.authenticate_update,
params,
);
}
/**
* Request permission before running a tool
*
@@ -241,9 +251,11 @@ class Connection {
).toResult();
}
let errorName;
let details;
if (error instanceof Error) {
errorName = error.name;
details = error.message;
} else if (
typeof error === 'object' &&
@@ -254,6 +266,10 @@ class Connection {
details = error.message;
}
if (errorName === 'TokenManagerError') {
return RequestError.authRequired(details).toResult();
}
return RequestError.internalError(details).toResult();
}
}
@@ -357,6 +373,7 @@ export interface Client {
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;

View File

@@ -6,15 +6,19 @@
import type { ReadableStream, WritableStream } from 'node:stream/web';
import type { Config, ConversationRecord } from '@qwen-code/qwen-code-core';
import {
APPROVAL_MODE_INFO,
APPROVAL_MODES,
AuthType,
clearCachedCredentialFile,
QwenOAuth2Event,
qwenOAuth2Events,
MCPServerConfig,
SessionService,
buildApiHistoryFromConversation,
type Config,
type ConversationRecord,
type DeviceAuthorizationData,
} from '@qwen-code/qwen-code-core';
import type { ApprovalModeValue } from './schema.js';
import * as acp from './acp.js';
@@ -123,13 +127,33 @@ class GeminiAgent {
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
let authUri: string | undefined;
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
authUri = deviceAuth.verification_uri_complete;
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
void this.client.authenticateUpdate({ _meta: { authUri } });
};
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.once(QwenOAuth2Event.AuthUri, authUriHandler);
}
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
try {
await this.config.refreshAuth(method);
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
} finally {
// Ensure we don't leak listeners if auth fails early.
if (method === AuthType.QWEN_OAUTH) {
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
}
}
return;
}
async newSession({
@@ -268,14 +292,17 @@ class GeminiAgent {
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
if (!selectedType) {
throw acp.RequestError.authRequired();
throw acp.RequestError.authRequired('No Selected Type');
}
try {
await config.refreshAuth(selectedType);
// Use true for the second argument to ensure only cached credentials are used
await config.refreshAuth(selectedType, true);
} catch (e) {
console.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
throw acp.RequestError.authRequired(
'Authentication failed: ' + (e as Error).message,
);
}
}

View File

@@ -20,6 +20,7 @@ export const AGENT_METHODS = {
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
};
@@ -57,8 +58,6 @@ export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
@@ -247,7 +246,13 @@ export const authenticateRequestSchema = z.object({
methodId: z.string(),
});
export const authenticateResponseSchema = z.null();
export const authenticateUpdateSchema = z.object({
_meta: z.object({
authUri: z.string(),
}),
});
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
@@ -316,6 +321,23 @@ export const annotationsSchema = z.object({
priority: z.number().optional().nullable(),
});
export const usageSchema = z.object({
promptTokens: z.number().optional().nullable(),
completionTokens: z.number().optional().nullable(),
thoughtsTokens: z.number().optional().nullable(),
totalTokens: z.number().optional().nullable(),
cachedTokens: z.number().optional().nullable(),
});
export type Usage = z.infer<typeof usageSchema>;
export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
});
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
export const requestPermissionResponseSchema = z.object({
outcome: requestPermissionOutcomeSchema,
});
@@ -500,10 +522,12 @@ export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('agent_message_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}),
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('agent_thought_chunk'),
_meta: sessionUpdateMetaSchema.optional().nullable(),
}),
z.object({
content: z.array(toolCallContentSchema).optional(),
@@ -536,7 +560,6 @@ export const sessionUpdateSchema = z.union([
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,

View File

@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type { FileSystemService } from '@qwen-code/qwen-code-core';
import { AcpFileSystemService } from './filesystem.js';
const createFallback = (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
findFiles: vi.fn().mockReturnValue([]),
});
describe('AcpFileSystemService', () => {
describe('readTextFile ENOENT handling', () => {
it('parses path from ACP ENOENT message (quoted)', async () => {
const client = {
readTextFile: vi
.fn()
.mockResolvedValue({ content: 'ERROR: ENOENT: "/remote/file.txt"' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-1',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(svc.readTextFile('/local/file.txt')).rejects.toMatchObject({
code: 'ENOENT',
path: '/remote/file.txt',
});
});
it('falls back to requested path when none provided', async () => {
const client = {
readTextFile: vi.fn().mockResolvedValue({ content: 'ERROR: ENOENT:' }),
} as unknown as import('../acp.js').Client;
const svc = new AcpFileSystemService(
client,
'session-2',
{ readTextFile: true, writeTextFile: true },
createFallback(),
);
await expect(
svc.readTextFile('/fallback/path.txt'),
).rejects.toMatchObject({
code: 'ENOENT',
path: '/fallback/path.txt',
});
});
});
});

View File

@@ -30,6 +30,20 @@ export class AcpFileSystemService implements FileSystemService {
limit: null,
});
if (response.content.startsWith('ERROR: ENOENT:')) {
// Treat ACP error strings as structured ENOENT errors without
// assuming a specific platform format.
const match = /^ERROR:\s*ENOENT:\s*(?<path>.*)$/i.exec(response.content);
const err = new Error(response.content) as NodeJS.ErrnoException;
err.code = 'ENOENT';
err.errno = -2;
const rawPath = match?.groups?.['path']?.trim();
err['path'] = rawPath
? rawPath.replace(/^['"]|['"]$/g, '') || filePath
: filePath;
throw err;
}
return response.content;
}

View File

@@ -411,4 +411,48 @@ describe('HistoryReplayer', () => {
]);
});
});
describe('usage metadata replay', () => {
it('should emit usage metadata after assistant message content', async () => {
const record: ChatRecord = {
uuid: 'assistant-uuid',
parentUuid: 'user-uuid',
sessionId: 'test-session',
timestamp: new Date().toISOString(),
type: 'assistant',
cwd: '/test',
version: '1.0.0',
message: {
role: 'model',
parts: [{ text: 'Hello!' }],
},
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
};
await replayer.replay([record]);
expect(sendUpdateSpy).toHaveBeenCalledTimes(2);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello!' },
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: undefined,
totalTokens: 150,
cachedTokens: undefined,
},
},
});
});
});
});

View File

@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord } from '@qwen-code/qwen-code-core';
import type { Content } from '@google/genai';
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
import type {
Content,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import type { SessionContext } from './types.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
@@ -52,6 +55,9 @@ export class HistoryReplayer {
if (record.message) {
await this.replayContent(record.message, 'assistant');
}
if (record.usageMetadata) {
await this.replayUsageMetadata(record.usageMetadata);
}
break;
case 'tool_result':
@@ -88,11 +94,22 @@ export class HistoryReplayer {
toolName: functionName,
callId,
args: part.functionCall.args as Record<string, unknown>,
status: 'in_progress',
});
}
}
}
/**
* Replays usage metadata.
* @param usageMetadata - The usage metadata to replay
*/
private async replayUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
): Promise<void> {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
/**
* Replays a tool result record.
*/
@@ -118,6 +135,54 @@ export class HistoryReplayer {
// Note: args aren't stored in tool_result records by default
args: undefined,
});
// Special handling: Task tool execution summary contains token usage
const { resultDisplay } = result ?? {};
if (
!!resultDisplay &&
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
(resultDisplay as { type?: unknown }).type === 'task_execution'
) {
await this.emitTaskUsageFromResultDisplay(
resultDisplay as TaskResultDisplay,
);
}
}
/**
* Emits token usage from a TaskResultDisplay execution summary, if present.
*/
private async emitTaskUsageFromResultDisplay(
resultDisplay: TaskResultDisplay,
): Promise<void> {
const summary = resultDisplay.executionSummary;
if (!summary) {
return;
}
const usageMetadata: GenerateContentResponseUsageMetadata = {};
if (Number.isFinite(summary.inputTokens)) {
usageMetadata.promptTokenCount = summary.inputTokens;
}
if (Number.isFinite(summary.outputTokens)) {
usageMetadata.candidatesTokenCount = summary.outputTokens;
}
if (Number.isFinite(summary.thoughtTokens)) {
usageMetadata.thoughtsTokenCount = summary.thoughtTokens;
}
if (Number.isFinite(summary.cachedTokens)) {
usageMetadata.cachedContentTokenCount = summary.cachedTokens;
}
if (Number.isFinite(summary.totalTokens)) {
usageMetadata.totalTokenCount = summary.totalTokens;
}
// Only emit if we captured at least one token metric
if (Object.keys(usageMetadata).length > 0) {
await this.messageEmitter.emitUsageMetadata(usageMetadata);
}
}
/**

View File

@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, FunctionCall, Part } from '@google/genai';
import type {
Content,
FunctionCall,
GenerateContentResponseUsageMetadata,
Part,
} from '@google/genai';
import type {
Config,
GeminiChat,
@@ -55,6 +60,7 @@ import type { SessionContext, ToolCallStartParams } from './types.js';
import { HistoryReplayer } from './HistoryReplayer.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { PlanEmitter } from './emitters/PlanEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import { SubAgentTracker } from './SubAgentTracker.js';
/**
@@ -79,6 +85,7 @@ export class Session implements SessionContext {
private readonly historyReplayer: HistoryReplayer;
private readonly toolCallEmitter: ToolCallEmitter;
private readonly planEmitter: PlanEmitter;
private readonly messageEmitter: MessageEmitter;
// Implement SessionContext interface
readonly sessionId: string;
@@ -96,6 +103,7 @@ export class Session implements SessionContext {
this.toolCallEmitter = new ToolCallEmitter(this);
this.planEmitter = new PlanEmitter(this);
this.historyReplayer = new HistoryReplayer(this);
this.messageEmitter = new MessageEmitter(this);
}
getId(): string {
@@ -192,6 +200,8 @@ export class Session implements SessionContext {
}
const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try {
const responseStream = await chat.sendMessageStream(
@@ -222,20 +232,18 @@ export class Session implements SessionContext {
continue;
}
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
};
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
this.messageEmitter.emitMessage(
part.text,
'assistant',
part.thought,
);
}
}
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
usageMetadata = resp.value.usageMetadata;
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
}
@@ -251,6 +259,15 @@ export class Session implements SessionContext {
throw error;
}
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
@@ -444,7 +461,9 @@ export class Session implements SessionContext {
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
this.config.getApprovalMode() !== ApprovalMode.YOLO
? await invocation.shouldConfirmExecute(abortSignal)
: false;
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
@@ -522,6 +541,7 @@ export class Session implements SessionContext {
callId,
toolName: fc.name,
args,
status: 'in_progress',
};
await this.toolCallEmitter.emitStart(startParams);
}

View File

@@ -208,7 +208,7 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
status: 'pending',
title: 'read_file',
content: [],
locations: [],

View File

@@ -9,6 +9,7 @@ import type {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
@@ -20,6 +21,7 @@ import {
import { z } from 'zod';
import type { SessionContext } from './types.js';
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
import { MessageEmitter } from './emitters/MessageEmitter.js';
import type * as acp from '../acp.js';
/**
@@ -62,6 +64,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
*/
export class SubAgentTracker {
private readonly toolCallEmitter: ToolCallEmitter;
private readonly messageEmitter: MessageEmitter;
private readonly toolStates = new Map<
string,
{
@@ -76,6 +79,7 @@ export class SubAgentTracker {
private readonly client: acp.Client,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
}
/**
@@ -92,16 +96,19 @@ export class SubAgentTracker {
const onToolCall = this.createToolCallHandler(abortSignal);
const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
// Clean up any remaining states
this.toolStates.clear();
},
@@ -252,6 +259,20 @@ export class SubAgentTracker {
};
}
/**
* Creates a handler for usage metadata events.
*/
private createUsageMetadataHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
};
}
/**
* Converts confirmation details to permission options for the client.
*/

View File

@@ -148,4 +148,59 @@ describe('MessageEmitter', () => {
});
});
});
describe('emitUsageMetadata', () => {
it('should emit agent_message_chunk with _meta.usage containing token counts', async () => {
const usageMetadata = {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 25,
totalTokenCount: 175,
cachedContentTokenCount: 10,
};
await emitter.emitUsageMetadata(usageMetadata);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: '' },
_meta: {
usage: {
promptTokens: 100,
completionTokens: 50,
thoughtsTokens: 25,
totalTokens: 175,
cachedTokens: 10,
},
},
});
});
it('should include durationMs in _meta when provided', async () => {
const usageMetadata = {
promptTokenCount: 10,
candidatesTokenCount: 5,
thoughtsTokenCount: 2,
totalTokenCount: 17,
cachedContentTokenCount: 1,
};
await emitter.emitUsageMetadata(usageMetadata, 'done', 1234);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' },
_meta: {
usage: {
promptTokens: 10,
completionTokens: 5,
thoughtsTokens: 2,
totalTokens: 17,
cachedTokens: 1,
},
durationMs: 1234,
},
});
});
});
});

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { Usage } from '../../schema.js';
import { BaseEmitter } from './BaseEmitter.js';
/**
@@ -24,6 +26,16 @@ export class MessageEmitter extends BaseEmitter {
});
}
/**
* Emits an agent thought chunk.
*/
async emitAgentThought(text: string): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
});
}
/**
* Emits an agent message chunk.
*/
@@ -35,12 +47,28 @@ export class MessageEmitter extends BaseEmitter {
}
/**
* Emits an agent thought chunk.
* Emits usage metadata.
*/
async emitAgentThought(text: string): Promise<void> {
async emitUsageMetadata(
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
): Promise<void> {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
completionTokens: usageMetadata.candidatesTokenCount,
thoughtsTokens: usageMetadata.thoughtsTokenCount,
totalTokens: usageMetadata.totalTokenCount,
cachedTokens: usageMetadata.cachedContentTokenCount,
};
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
_meta: meta,
});
}

View File

@@ -71,7 +71,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-123',
status: 'in_progress',
status: 'pending',
title: 'unknown_tool', // Falls back to tool name
content: [],
locations: [],
@@ -94,7 +94,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-456',
status: 'in_progress',
status: 'pending',
title: 'edit_file: Test tool description',
content: [],
locations: [{ path: '/test/file.ts', line: 10 }],
@@ -144,7 +144,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call',
toolCallId: 'call-fail',
status: 'in_progress',
status: 'pending',
title: 'failing_tool', // Fallback to tool name
content: [],
locations: [], // Fallback to empty
@@ -493,7 +493,7 @@ describe('ToolCallEmitter', () => {
type: 'content',
content: {
type: 'text',
text: '{"output":"test output"}',
text: 'test output',
},
},
],
@@ -650,7 +650,7 @@ describe('ToolCallEmitter', () => {
content: [
{
type: 'content',
content: { type: 'text', text: '{"output":"Function output"}' },
content: { type: 'text', text: 'Function output' },
},
],
rawOutput: 'raw result',

View File

@@ -59,7 +59,7 @@ export class ToolCallEmitter extends BaseEmitter {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: params.callId,
status: 'in_progress',
status: params.status || 'pending',
title,
content: [],
locations,
@@ -275,7 +275,18 @@ export class ToolCallEmitter extends BaseEmitter {
// Handle functionResponse parts - stringify the response
if ('functionResponse' in part && part.functionResponse) {
try {
const responseText = JSON.stringify(part.functionResponse.response);
const resp = part.functionResponse.response as Record<
string,
unknown
>;
const outputField = resp['output'];
const errorField = resp['error'];
const responseText =
typeof outputField === 'string'
? outputField
: typeof errorField === 'string'
? errorField
: JSON.stringify(resp);
result.push({
type: 'content',
content: { type: 'text', text: responseText },

View File

@@ -35,6 +35,8 @@ export interface ToolCallStartParams {
callId: string;
/** Arguments passed to the tool */
args?: Record<string, unknown>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
}
/**

View File

@@ -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<CliArgs> {
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<CliArgs> {
// 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<string, unknown>)['channel'] = 'ACP';
}
return result as unknown as CliArgs;
}
@@ -983,6 +995,7 @@ export async function loadCliConfig(
output: {
format: outputSettingsFormat,
},
channel: argv.channel,
});
}

View File

@@ -191,8 +191,19 @@ const SETTINGS_SCHEMA = {
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
],
},
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: {

View File

@@ -485,6 +485,7 @@ describe('gemini.tsx main function kitty protocol', () => {
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
channel: undefined,
});
await main();

View File

@@ -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
}

View File

@@ -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:',

File diff suppressed because it is too large Load Diff

View File

@@ -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:': '工具调用:',

View File

@@ -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', () => ({

View File

@@ -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,

View File

@@ -56,10 +56,10 @@ export const createMockCommandContext = (
pendingItem: null,
setPendingItem: vi.fn(),
loadHistory: vi.fn(),
toggleCorgiMode: vi.fn(),
toggleVimEnabled: vi.fn(),
extensionsUpdateState: new Map(),
setExtensionsUpdateState: vi.fn(),
reloadCommands: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
session: {

View File

@@ -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<string>('');
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,
@@ -945,6 +942,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFocused,
streamingState,
elapsedTime,
settings,
});
// Dialog close functionality
@@ -1218,7 +1216,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,
@@ -1309,7 +1306,6 @@ export const AppContainer = (props: AppContainerProps) => {
qwenAuthState,
editorError,
isEditorDialogOpen,
corgiMode,
debugMessage,
quittingMessages,
isSettingsDialogOpen,

View File

@@ -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.');
});
});

View File

@@ -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();
},
};

View File

@@ -0,0 +1,587 @@
/**
* @license
* Copyright 2025 Qwen
* 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<typeof import('node:fs')>();
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<typeof import('@qwen-code/qwen-code-core')>();
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<string, string>) => {
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'),
});
});
});
});

View File

@@ -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,16 +128,17 @@ async function setUiLanguage(
context.ui.reloadCommands();
// Map language codes to friendly display names
const langDisplayNames: Record<SupportedLanguage, string> = {
const langDisplayNames: Partial<Record<SupportedLanguage, string>> = {
zh: '中文zh-CN',
en: 'Englishen-US',
ru: 'Русский (ru-RU)',
};
return {
type: 'message',
messageType: 'info',
content: t('UI language changed to {{lang}}', {
lang: langDisplayNames[lang],
lang: langDisplayNames[lang] || lang,
}),
};
}
@@ -216,7 +218,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 <language> - ${t('Set LLM output language')}`,
].join('\n');
@@ -232,7 +234,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 +243,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 +269,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 +317,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 <language> - ' + t('Set LLM output language'),
].join('\n'),
};
@@ -423,6 +440,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<MessageActionReturn> => {
if (args.trim().length > 0) {
return {
type: 'message',
messageType: 'error',
content: t(
'Language subcommands do not accept additional arguments.',
),
};
}
return setUiLanguage(context, 'ru');
},
},
],
},
{

View File

@@ -84,7 +84,7 @@ describe('setupGithubCommand', async () => {
const expectedSubstrings = [
`set -eEuo pipefail`,
`fakeOpenCommand "https://github.com/google-github-actions/run-gemini-cli`,
`fakeOpenCommand "https://github.com/QwenLM/qwen-code-action`,
];
for (const substring of expectedSubstrings) {
@@ -112,7 +112,7 @@ describe('setupGithubCommand', async () => {
if (gitignoreExists) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('.gemini/');
expect(gitignoreContent).toContain('.qwen/');
expect(gitignoreContent).toContain('gha-creds-*.json');
}
});
@@ -135,7 +135,7 @@ describe('updateGitignore', () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
expect(content).toBe('.qwen/\ngha-creds-*.json\n');
});
it('appends entries to existing .gitignore file', async () => {
@@ -148,13 +148,13 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe(
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
'# Existing content\nnode_modules/\n\n.qwen/\ngha-creds-*.json\n',
);
});
it('does not add duplicate entries', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
const existingContent = '.qwen/\nsome-other-file\ngha-creds-*.json\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -166,7 +166,7 @@ describe('updateGitignore', () => {
it('adds only missing entries when some already exist', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\n';
const existingContent = '.qwen/\nsome-other-file\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
@@ -174,17 +174,17 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add only the missing gha-creds-*.json entry
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toBe('.qwen/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toContain('gha-creds-*.json');
// Should not duplicate .gemini/ entry
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
// Should not duplicate .qwen/ entry
expect((content.match(/\.qwen\//g) || []).length).toBe(1);
});
it('does not get confused by entries in comments or as substrings', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = [
'# This is a comment mentioning .gemini/ folder',
'my-app.gemini/config',
'# This is a comment mentioning .qwen/ folder',
'my-app.qwen/config',
'# Another comment with gha-creds-*.json pattern',
'some-other-gha-creds-file.json',
'',
@@ -196,7 +196,7 @@ describe('updateGitignore', () => {
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add both entries since they don't actually exist as gitignore rules
expect(content).toContain('.gemini/');
expect(content).toContain('.qwen/');
expect(content).toContain('gha-creds-*.json');
// Verify the entries were added (not just mentioned in comments)
@@ -204,9 +204,9 @@ describe('updateGitignore', () => {
.split('\n')
.map((line) => line.split('#')[0].trim())
.filter((line) => line);
expect(lines).toContain('.gemini/');
expect(lines).toContain('.qwen/');
expect(lines).toContain('gha-creds-*.json');
expect(lines).toContain('my-app.gemini/config');
expect(lines).toContain('my-app.qwen/config');
expect(lines).toContain('some-other-gha-creds-file.json');
});

View File

@@ -23,11 +23,11 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { t } from '../../i18n/index.js';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
'qwen-dispatch/qwen-dispatch.yml',
'qwen-assistant/qwen-invoke.yml',
'issue-triage/qwen-triage.yml',
'issue-triage/qwen-scheduled-triage.yml',
'pr-review/qwen-review.yml',
];
// Generate OS-specific commands to open the GitHub pages needed for setup.
@@ -50,9 +50,9 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
return commands;
}
// Add Gemini CLI specific entries to .gitignore file
// Add Qwen Code specific entries to .gitignore file
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
const gitignoreEntries = ['.qwen/', 'gha-creds-*.json'];
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
try {
@@ -121,7 +121,7 @@ export const setupGithubCommand: SlashCommand = {
// Get the latest release tag from GitHub
const proxy = context?.services?.config?.getProxy();
const releaseTag = await getLatestGitHubRelease(proxy);
const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`;
const readmeUrl = `https://github.com/QwenLM/qwen-code-action/blob/${releaseTag}/README.md#quick-start`;
// Create the .github/workflows directory to download the files into
const githubWorkflowsDir = path.join(gitRepoRoot, '.github', 'workflows');
@@ -143,7 +143,7 @@ export const setupGithubCommand: SlashCommand = {
for (const workflow of GITHUB_WORKFLOW_PATHS) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const endpoint = `https://raw.githubusercontent.com/QwenLM/qwen-code-action/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
const response = await fetch(endpoint, {
method: 'GET',
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
@@ -204,8 +204,9 @@ export const setupGithubCommand: SlashCommand = {
toolName: 'run_shell_command',
toolArgs: {
description:
'Setting up GitHub Actions to triage issues and review PRs with Gemini.',
'Setting up GitHub Actions to triage issues and review PRs with Qwen.',
command,
is_background: false,
},
};
},

View File

@@ -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<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;

View File

@@ -120,7 +120,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): 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',

View File

@@ -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 && <MemoryUsageDisplay />}
</Box>
<Box alignItems="center" paddingLeft={2}>
{corgiMode && (
<Text>
<Text color={theme.ui.symbol}>| </Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}>▼ </Text>
</Text>
)}
{!showErrorDetails && errorCount > 0 && (
<Box>
<Text color={theme.ui.symbol}>| </Text>

View File

@@ -1307,7 +1307,7 @@ describe('InputPrompt', () => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = [0, 8]; // cursor after '👍' (length is 6 + 2 for emoji)
mockBuffer.visualCursor = [0, 7]; // cursor after '👍' (emoji is 1 code point, so total is 7)
const { stdout, unmount } = renderWithProviders(
<InputPrompt {...props} />,

View File

@@ -707,15 +707,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
statusText = t('Accepting edits');
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
return (
<>
<Box
borderStyle="round"
borderColor={
isShellFocused && !isEmbeddedShellFocused
? (statusColor ?? theme.border.focused)
: theme.border.default
}
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={borderColor}
paddingX={1}
>
<Text
@@ -829,9 +834,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}

View File

@@ -20,16 +20,21 @@ 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',
promptCount: number = 5,
) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId,
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
promptCount,
},
getPromptCount: () => 5,
getPromptCount: () => promptCount,
startNewPrompt: vi.fn(),
});
@@ -70,6 +75,38 @@ describe('<SessionSummaryDisplay />', () => {
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();
});
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');
});
});

View File

@@ -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,28 @@ interface SessionSummaryDisplayProps {
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
);
}) => {
const { stats } = useSessionStats();
// Only show the resume message if there were messages in the session
const hasMessages = stats.promptCount > 0;
return (
<>
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
{hasMessages && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('To continue this session, run')}{' '}
<Text color={theme.text.accent}>
qwen --resume {stats.sessionId}
</Text>
</Text>
</Box>
)}
</>
);
};

View File

@@ -1461,7 +1461,7 @@ describe('SettingsDialog', () => {
context: {
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: true,
respectQwenIgnore: true,
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
@@ -1535,7 +1535,7 @@ describe('SettingsDialog', () => {
loadMemoryFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwemIgnore: false,
respectQwenIgnore: false,
enableRecursiveFileSearch: false,
disableFuzzySearch: false,
},

View File

@@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
──────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
──────────────────────────────────────────────────────────────────────────────────────────────────
"────────────────────────────────────────────────────────────────────────────────────────────────────
(r:) commit
────────────────────────────────────────────────────────────────────────────────────────────────────
git commit -m "feat: add search" in src/app"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
! Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
* Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = `
"──────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
──────────────────────────────────────────────────────────────────────────────────────────────────"
"────────────────────────────────────────────────────────────────────────────────────────────────────
> Type your message or @path/to/file
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;

View File

@@ -6,7 +6,7 @@ exports[`<SessionSummaryDisplay /> > 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 │
@@ -26,5 +26,7 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
To continue this session, run qwen --resume test-session-id-12345"
`;

View File

@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -82,14 +82,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -116,14 +116,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false* │
│ │
│ ▼ │
│ │
│ │
@@ -150,14 +150,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -184,14 +184,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -218,14 +218,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -252,14 +252,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -286,14 +286,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title false │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips false │
│ │
│ ▼ │
│ │
│ │
@@ -320,14 +320,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ │
│ Language Auto (detect from system) │
│ │
│ Terminal Bell true │
│ │
│ Output Format Text │
│ │
│ Hide Window Title true* │
│ │
│ Show Status in Title false │
│ │
│ Hide Tips true* │
│ │
│ ▼ │
│ │
│ │

View File

@@ -54,7 +54,6 @@ export interface UIState {
qwenAuthState: QwenAuthState;
editorError: string | null;
isEditorDialogOpen: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;

View File

@@ -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

View File

@@ -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,

View File

@@ -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 },
);
});
});

View File

@@ -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]);
};

View File

@@ -20,7 +20,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
loadHistory: (_newHistory) => {},
pendingItem: null,
setPendingItem: (_item) => {},
toggleCorgiMode: () => {},
toggleVimEnabled: async () => false,
setGeminiMdFileCount: (_count) => {},
reloadCommands: () => {},

View File

@@ -13,6 +13,7 @@ export enum AttentionNotificationReason {
export interface TerminalNotificationOptions {
stream?: Pick<NodeJS.WriteStream, 'write' | 'isTTY'>;
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;

View File

@@ -58,7 +58,7 @@ export const getLatestGitHubRelease = async (
try {
const controller = new AbortController();
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
const endpoint = `https://api.github.com/repos/QwenLM/qwen-code-action/releases/latest`;
const response = await fetch(endpoint, {
method: 'GET',
@@ -83,9 +83,12 @@ export const getLatestGitHubRelease = async (
}
return releaseTag;
} catch (_error) {
console.debug(`Failed to determine latest run-gemini-cli release:`, _error);
console.debug(
`Failed to determine latest qwen-code-action release:`,
_error,
);
throw new Error(
`Unable to determine the latest run-gemini-cli release on GitHub.`,
`Unable to determine the latest qwen-code-action release on GitHub.`,
);
}
};

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.4.0",
"version": "0.5.0",
"description": "Qwen Code Core",
"repository": {
"type": "git",
@@ -47,7 +47,7 @@
"fast-uri": "^3.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"glob": "^10.4.5",
"glob": "^10.5.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"https-proxy-agent": "^7.0.6",

View File

@@ -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
*/

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const nested = properties?.['nested'] as Record<string, unknown>;
const nestedProperties = nested?.['properties'] as Record<string, unknown>;
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<string, unknown>,
),
).toBeNull();
expect(
converter.convertGeminiToolParametersToOpenAI(
undefined as unknown as Record<string, unknown>,
),
).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);
});
});
});

View File

@@ -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<string, unknown>),
};
parameters = paramsCopy;
}
// Create a shallow copy to avoid mutating the original object
const paramsCopy = {
...(func.parametersJsonSchema as Record<string, unknown>),
};
parameters = paramsCopy;
} else if (func.parameters) {
// Gemini tool format - convert parameters to OpenAI format
parameters = this.convertGeminiToolParametersToOpenAI(

View File

@@ -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 } : {}),
},
};
}

View File

@@ -28,5 +28,6 @@ export type DashScopeRequestMetadata = {
metadata: {
sessionId?: string;
promptId: string;
channel?: string;
};
};

View File

@@ -761,7 +761,6 @@ describe('getQwenOAuthClient', () => {
});
it('should load cached credentials if available', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
@@ -769,10 +768,6 @@ describe('getQwenOAuthClient', () => {
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to use cached credentials
const mockTokenManager = {
getValidCredentials: vi.fn().mockResolvedValue(mockCredentials),
@@ -792,18 +787,6 @@ describe('getQwenOAuthClient', () => {
});
it('should handle cached credentials refresh failure', async () => {
const fs = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'expired-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000, // Valid expiry time so loadCachedQwenCredentials returns true
};
vi.mocked(fs.promises.readFile).mockResolvedValue(
JSON.stringify(mockCredentials),
);
// Mock SharedTokenManager to fail with a specific error
const mockTokenManager = {
getValidCredentials: vi
@@ -833,6 +816,35 @@ describe('getQwenOAuthClient', () => {
SharedTokenManager.getInstance = originalGetInstance;
});
it('should not start device flow when requireCachedCredentials is true', async () => {
// Make SharedTokenManager fail so we hit the fallback path
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No credentials')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi.fn().mockReturnValue(mockTokenManager);
// If requireCachedCredentials is honored, device-flow network requests should not start
vi.mocked(global.fetch).mockResolvedValue({ ok: true } as Response);
await expect(
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig, {
requireCachedCredentials: true,
}),
),
).rejects.toThrow(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
expect(global.fetch).not.toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
});
describe('CredentialsClearRequiredError', () => {
@@ -1574,178 +1586,6 @@ describe('Credential Caching Functions', () => {
expect(updatedCredentials.access_token).toBe('new-token');
});
});
describe('loadCachedQwenCredentials', () => {
it('should load and validate cached credentials successfully', async () => {
const { promises: fs } = await import('node:fs');
const mockCredentials = {
access_token: 'cached-token',
refresh_token: 'cached-refresh',
token_type: 'Bearer',
expiry_date: Date.now() + 3600000,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials));
// Test through getQwenOAuthClient which calls loadCachedQwenCredentials
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
// Make SharedTokenManager fail to test the fallback
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock successful auth flow after cache load fails
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email model.completion',
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
expect(fs.readFile).toHaveBeenCalled();
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle invalid cached credentials gracefully', async () => {
const { promises: fs } = await import('node:fs');
// Mock file read to return invalid JSON
vi.mocked(fs.readFile).mockResolvedValue('invalid-json');
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock auth flow
const mockAuthResponse = {
ok: true,
json: async () => ({
device_code: 'test-device-code',
user_code: 'TEST123',
verification_uri: 'https://chat.qwen.ai/device',
verification_uri_complete: 'https://chat.qwen.ai/device?code=TEST123',
expires_in: 1800,
}),
};
const mockTokenResponse = {
ok: true,
json: async () => ({
access_token: 'new-token',
refresh_token: 'new-refresh',
token_type: 'Bearer',
expires_in: 3600,
}),
};
global.fetch = vi
.fn()
.mockResolvedValueOnce(mockAuthResponse as Response)
.mockResolvedValue(mockTokenResponse as Response);
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
it('should handle file access errors', async () => {
const { promises: fs } = await import('node:fs');
vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found'));
const mockConfig = {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(true),
} as unknown as Config;
const mockTokenManager = {
getValidCredentials: vi
.fn()
.mockRejectedValue(new Error('No cached creds')),
};
const originalGetInstance = SharedTokenManager.getInstance;
SharedTokenManager.getInstance = vi
.fn()
.mockReturnValue(mockTokenManager);
// Mock device flow to fail quickly
const mockAuthResponse = {
ok: true,
json: async () => ({
error: 'invalid_request',
error_description: 'Invalid request parameters',
}),
};
global.fetch = vi.fn().mockResolvedValue(mockAuthResponse as Response);
// Should proceed to device flow when cache loading fails
try {
await import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
);
} catch {
// Expected to fail in test environment
}
SharedTokenManager.getInstance = originalGetInstance;
});
});
});
describe('Enhanced Error Handling and Edge Cases', () => {

View File

@@ -514,26 +514,14 @@ export async function getQwenOAuthClient(
}
}
// If shared manager fails, check if we have cached credentials for device flow
if (await loadCachedQwenCredentials(client)) {
// We have cached credentials but they might be expired
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
// interactive device authorization (unless explicitly forbidden above).
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -689,6 +677,19 @@ async function authWithQwenDeviceFlow(
// Cache the new tokens
await cacheQwenCredentials(credentials);
// IMPORTANT:
// SharedTokenManager maintains an in-memory cache and throttles file checks.
// If we only write the creds file here, a subsequent `getQwenOAuthClient()`
// call in the same process (within the throttle window) may not re-read the
// updated file and could incorrectly re-trigger device auth.
// Clearing the cache forces the next call to reload from disk.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// In unit tests we sometimes mock SharedTokenManager.getInstance() with a
// minimal stub; cache invalidation is best-effort and should not break auth.
}
// Emit auth progress success event
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
@@ -847,27 +848,6 @@ async function authWithQwenDeviceFlow(
}
}
async function loadCachedQwenCredentials(
client: QwenOAuth2Client,
): Promise<boolean> {
try {
const keyFile = getQwenCachedCredentialPath();
const creds = await fs.readFile(keyFile, 'utf-8');
const credentials = JSON.parse(creds) as QwenCredentials;
client.setCredentials(credentials);
// Verify that the credentials are still valid
const { token } = await client.getAccessToken();
if (!token) {
return false;
}
return true;
} catch (_) {
return false;
}
}
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
try {
@@ -913,6 +893,14 @@ export async function clearQwenCredentials(): Promise<void> {
}
// Log other errors but don't throw - clearing credentials should be non-critical
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
} finally {
// Also clear SharedTokenManager in-memory cache to prevent stale credentials
// from being reused within the same process after the file is removed.
try {
SharedTokenManager.getInstance().clearCache();
} catch {
// Best-effort; don't fail credential clearing if SharedTokenManager is mocked.
}
}
}

View File

@@ -58,6 +58,7 @@ export type {
SubAgentStartEvent,
SubAgentRoundEvent,
SubAgentStreamTextEvent,
SubAgentUsageEvent,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentFinishEvent,

View File

@@ -10,7 +10,7 @@ import type {
ToolConfirmationOutcome,
ToolResultDisplay,
} from '../tools/tools.js';
import type { Part } from '@google/genai';
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
export type SubAgentEvent =
| 'start'
@@ -20,6 +20,7 @@ export type SubAgentEvent =
| 'tool_call'
| 'tool_result'
| 'tool_waiting_approval'
| 'usage_metadata'
| 'finish'
| 'error';
@@ -31,6 +32,7 @@ export enum SubAgentEventType {
TOOL_CALL = 'tool_call',
TOOL_RESULT = 'tool_result',
TOOL_WAITING_APPROVAL = 'tool_waiting_approval',
USAGE_METADATA = 'usage_metadata',
FINISH = 'finish',
ERROR = 'error',
}
@@ -57,6 +59,14 @@ export interface SubAgentStreamTextEvent {
timestamp: number;
}
export interface SubAgentUsageEvent {
subagentId: string;
round: number;
usage: GenerateContentResponseUsageMetadata;
durationMs?: number;
timestamp: number;
}
export interface SubAgentToolCallEvent {
subagentId: string;
round: number;

View File

@@ -50,6 +50,15 @@ describe('SubagentStatistics', () => {
expect(summary.outputTokens).toBe(600);
expect(summary.totalTokens).toBe(1800);
});
it('should track thought and cached tokens', () => {
stats.recordTokens(100, 50, 10, 5);
const summary = stats.getSummary();
expect(summary.thoughtTokens).toBe(10);
expect(summary.cachedTokens).toBe(5);
expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5
});
});
describe('tool usage statistics', () => {
@@ -93,14 +102,14 @@ describe('SubagentStatistics', () => {
stats.start(baseTime);
stats.setRounds(2);
stats.recordToolCall('file_read', true, 100);
stats.recordTokens(1000, 500);
stats.recordTokens(1000, 500, 20, 10);
const result = stats.formatCompact('Test task', baseTime + 5000);
expect(result).toContain('📋 Task Completed: Test task');
expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success');
expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2');
expect(result).toContain('🔢 Tokens: 1,500 (in 1000, out 500)');
expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)');
});
it('should handle zero tool calls', () => {

View File

@@ -23,6 +23,8 @@ export interface SubagentStatsSummary {
successRate: number;
inputTokens: number;
outputTokens: number;
thoughtTokens: number;
cachedTokens: number;
totalTokens: number;
estimatedCost: number;
toolUsage: ToolUsageStats[];
@@ -36,6 +38,8 @@ export class SubagentStatistics {
private failedToolCalls = 0;
private inputTokens = 0;
private outputTokens = 0;
private thoughtTokens = 0;
private cachedTokens = 0;
private toolUsage = new Map<string, ToolUsageStats>();
start(now = Date.now()) {
@@ -74,9 +78,16 @@ export class SubagentStatistics {
this.toolUsage.set(name, tu);
}
recordTokens(input: number, output: number) {
recordTokens(
input: number,
output: number,
thought: number = 0,
cached: number = 0,
) {
this.inputTokens += Math.max(0, input || 0);
this.outputTokens += Math.max(0, output || 0);
this.thoughtTokens += Math.max(0, thought || 0);
this.cachedTokens += Math.max(0, cached || 0);
}
getSummary(now = Date.now()): SubagentStatsSummary {
@@ -86,7 +97,11 @@ export class SubagentStatistics {
totalToolCalls > 0
? (this.successfulToolCalls / totalToolCalls) * 100
: 0;
const totalTokens = this.inputTokens + this.outputTokens;
const totalTokens =
this.inputTokens +
this.outputTokens +
this.thoughtTokens +
this.cachedTokens;
const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5;
return {
rounds: this.rounds,
@@ -97,6 +112,8 @@ export class SubagentStatistics {
successRate,
inputTokens: this.inputTokens,
outputTokens: this.outputTokens,
thoughtTokens: this.thoughtTokens,
cachedTokens: this.cachedTokens,
totalTokens,
estimatedCost,
toolUsage: Array.from(this.toolUsage.values()),
@@ -116,8 +133,12 @@ export class SubagentStatistics {
`⏱️ Duration: ${this.fmtDuration(stats.totalDurationMs)} | 🔁 Rounds: ${stats.rounds}`,
];
if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${stats.inputTokens || stats.outputTokens ? ` (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})` : ''}`,
`🔢 Tokens: ${stats.totalTokens.toLocaleString()}${parts.length ? ` (${parts.join(', ')})` : ''}`,
);
}
return lines.join('\n');
@@ -152,8 +173,12 @@ export class SubagentStatistics {
`🔧 Tools: ${stats.totalToolCalls} calls, ${sr.toFixed(1)}% success (${stats.successfulToolCalls} ok, ${stats.failedToolCalls} failed)`,
);
if (typeof stats.totalTokens === 'number') {
const parts = [
`in ${stats.inputTokens ?? 0}`,
`out ${stats.outputTokens ?? 0}`,
];
lines.push(
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (in ${stats.inputTokens ?? 0}, out ${stats.outputTokens ?? 0})`,
`🔢 Tokens: ${stats.totalTokens.toLocaleString()} (${parts.join(', ')})`,
);
}
if (stats.toolUsage && stats.toolUsage.length) {

View File

@@ -41,6 +41,7 @@ import type {
SubAgentToolResultEvent,
SubAgentStreamTextEvent,
SubAgentErrorEvent,
SubAgentUsageEvent,
} from './subagent-events.js';
import {
type SubAgentEventEmitter,
@@ -369,6 +370,7 @@ export class SubAgentScope {
},
};
const roundStreamStart = Date.now();
const responseStream = await chat.sendMessageStream(
this.modelConfig.model ||
this.runtimeContext.getModel() ||
@@ -439,10 +441,19 @@ export class SubAgentScope {
if (lastUsage) {
const inTok = Number(lastUsage.promptTokenCount || 0);
const outTok = Number(lastUsage.candidatesTokenCount || 0);
if (isFinite(inTok) || isFinite(outTok)) {
const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0);
const cachedTok = Number(lastUsage.cachedContentTokenCount || 0);
if (
isFinite(inTok) ||
isFinite(outTok) ||
isFinite(thoughtTok) ||
isFinite(cachedTok)
) {
this.stats.recordTokens(
isFinite(inTok) ? inTok : 0,
isFinite(outTok) ? outTok : 0,
isFinite(thoughtTok) ? thoughtTok : 0,
isFinite(cachedTok) ? cachedTok : 0,
);
// mirror legacy fields for compatibility
this.executionStats.inputTokens =
@@ -453,11 +464,20 @@ export class SubAgentScope {
(isFinite(outTok) ? outTok : 0);
this.executionStats.totalTokens =
(this.executionStats.inputTokens || 0) +
(this.executionStats.outputTokens || 0);
(this.executionStats.outputTokens || 0) +
(isFinite(thoughtTok) ? thoughtTok : 0) +
(isFinite(cachedTok) ? cachedTok : 0);
this.executionStats.estimatedCost =
(this.executionStats.inputTokens || 0) * 3e-5 +
(this.executionStats.outputTokens || 0) * 6e-5;
}
this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, {
subagentId: this.subagentId,
round: turnCounter,
usage: lastUsage,
durationMs: Date.now() - roundStreamStart,
timestamp: Date.now(),
} as SubAgentUsageEvent);
}
if (functionCalls.length > 0) {

View File

@@ -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;

View File

@@ -23,6 +23,12 @@ export type UiEvent =
| (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR })
| (ToolCallEvent & { 'event.name': typeof EVENT_TOOL_CALL });
export {
EVENT_API_ERROR,
EVENT_API_RESPONSE,
EVENT_TOOL_CALL,
} from './constants.js';
export interface ToolCallStats {
count: number;
success: number;

View File

@@ -391,6 +391,19 @@ describe('Shell Command Processor - Encoding Functions', () => {
expect(result).toBe('windows-1252');
});
it('should prioritize UTF-8 detection over Windows system encoding', () => {
mockedOsPlatform.mockReturnValue('win32');
mockedExecSync.mockReturnValue('Active code page: 936'); // GBK
const buffer = Buffer.from('test');
// Mock chardet to return UTF-8
mockedChardetDetect.mockReturnValue('UTF-8');
const result = getCachedEncodingForBuffer(buffer);
expect(result).toBe('utf-8');
});
it('should cache null system encoding result', () => {
// Reset the cache specifically for this test
resetEncodingCache();

View File

@@ -34,6 +34,15 @@ export function getCachedEncodingForBuffer(buffer: Buffer): string {
// If we have a cached system encoding, use it
if (cachedSystemEncoding) {
// If the system encoding is not UTF-8 (e.g. Windows CP936), but the buffer
// is detected as UTF-8, prefer UTF-8. This handles tools like 'git' which
// often output UTF-8 regardless of the system code page.
if (cachedSystemEncoding !== 'utf-8') {
const detected = detectEncodingFromBuffer(buffer);
if (detected === 'utf-8') {
return 'utf-8';
}
}
return cachedSystemEncoding;
}

View File

@@ -59,7 +59,7 @@ Creates a new query session with the Qwen Code.
| `model` | `string` | - | The AI model to use (e.g., `'qwen-max'`, `'qwen-plus'`, `'qwen-turbo'`). Takes precedence over `OPENAI_MODEL` and `QWEN_MODEL` environment variables. |
| `pathToQwenExecutable` | `string` | Auto-detected | Path to the Qwen Code executable. Supports multiple formats: `'qwen'` (native binary from PATH), `'/path/to/qwen'` (explicit path), `'/path/to/cli.js'` (Node.js bundle), `'node:/path/to/cli.js'` (force Node.js runtime), `'bun:/path/to/cli.js'` (force Bun runtime). If not provided, auto-detects from: `QWEN_CODE_CLI_PATH` env var, `~/.volta/bin/qwen`, `~/.npm-global/bin/qwen`, `/usr/local/bin/qwen`, `~/.local/bin/qwen`, `~/node_modules/.bin/qwen`, `~/.yarn/bin/qwen`. |
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 30 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
@@ -76,12 +76,12 @@ Creates a new query session with the Qwen Code.
The SDK enforces the following default timeouts:
| Timeout | Default | Description |
| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `canUseTool` | 30 seconds | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. |
| `controlRequest` | 30 seconds | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
| Timeout | Default | Description |
| ---------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `canUseTool` | 1 minute | Maximum time for `canUseTool` callback to respond. If exceeded, the tool request is auto-denied. |
| `mcpRequest` | 1 minute | Maximum time for SDK MCP tool calls to complete. |
| `controlRequest` | 1 minute | Maximum time for control operations like `initialize()`, `setModel()`, `setPermissionMode()`, and `interrupt()` to complete. |
| `streamClose` | 1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
You can customize these timeouts via the `timeout` option:

View File

@@ -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",

View File

@@ -5,9 +5,9 @@
* Implements AsyncIterator protocol for message consumption.
*/
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 30_000;
const DEFAULT_CAN_USE_TOOL_TIMEOUT = 60_000;
const DEFAULT_MCP_REQUEST_TIMEOUT = 60_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 30_000;
const DEFAULT_CONTROL_REQUEST_TIMEOUT = 60_000;
const DEFAULT_STREAM_CLOSE_TIMEOUT = 60_000;
import { randomUUID } from 'node:crypto';
@@ -434,8 +434,9 @@ export class Query implements AsyncIterable<SDKMessage> {
try {
const canUseToolTimeout =
this.options.timeout?.canUseTool ?? DEFAULT_CAN_USE_TOOL_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
timeoutId = setTimeout(
() => reject(new Error('Permission callback timeout')),
canUseToolTimeout,
);
@@ -451,6 +452,10 @@ export class Query implements AsyncIterable<SDKMessage> {
timeoutPromise,
]);
if (timeoutId) {
clearTimeout(timeoutId);
}
if (result.behavior === 'allow') {
return {
behavior: 'allow',
@@ -789,14 +794,20 @@ export class Query implements AsyncIterable<SDKMessage> {
) {
const streamCloseTimeout =
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
await Promise.race([
this.firstResultReceivedPromise,
new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, streamCloseTimeout);
}),
]);
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(() => {
logger.info('streamCloseTimeout resolved');
resolve();
}, streamCloseTimeout);
});
await Promise.race([this.firstResultReceivedPromise, timeoutPromise]);
if (timeoutId) {
clearTimeout(timeoutId);
}
}
this.endInput();

View File

@@ -139,6 +139,7 @@ export class ProcessTransport implements Transport {
'stream-json',
'--output-format',
'stream-json',
'--channel=SDK',
];
if (this.options.model) {

View File

@@ -316,7 +316,7 @@ export interface QueryOptions {
/**
* Logging level for the SDK.
* Controls the verbosity of log messages output by the SDK.
* @default 'info'
* @default 'error'
*/
logLevel?: 'debug' | 'info' | 'warn' | 'error';

View File

@@ -22,7 +22,7 @@ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
export class SdkLogger {
private static config: LoggerConfig = {};
private static effectiveLevel: LogLevel = 'info';
private static effectiveLevel: LogLevel = 'error';
static configure(config: LoggerConfig): void {
this.config = config;
@@ -47,7 +47,7 @@ export class SdkLogger {
return 'debug';
}
return 'info';
return 'error';
}
private static isValidLogLevel(level: string): boolean {

View File

@@ -542,13 +542,16 @@ describe('Query', () => {
const canUseTool = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ behavior: 'allow' }), 35000); // Exceeds 30s timeout
setTimeout(() => resolve({ behavior: 'allow' }), 15000);
}),
);
const query = new Query(transport, {
cwd: '/test',
canUseTool,
timeout: {
canUseTool: 10000,
},
});
const controlReq = createControlRequest('can_use_tool', 'perm-req-4');
@@ -567,7 +570,7 @@ describe('Query', () => {
});
}
},
{ timeout: 35000 },
{ timeout: 15000 },
);
await query.close();
@@ -1204,7 +1207,12 @@ describe('Query', () => {
});
it('should handle control request timeout', async () => {
const query = new Query(transport, { cwd: '/test' });
const query = new Query(transport, {
cwd: '/test',
timeout: {
controlRequest: 10000,
},
});
// Respond to initialize
await vi.waitFor(() => {
@@ -1224,7 +1232,7 @@ describe('Query', () => {
await expect(interruptPromise).rejects.toThrow(/timeout/i);
await query.close();
}, 35000);
}, 15000);
it('should handle malformed control responses', async () => {
const query = new Query(transport, { cwd: '/test' });

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",

View File

@@ -1,5 +1,6 @@
**
!dist/
!dist/**
../
../../
!LICENSE

View File

@@ -1,5 +1,26 @@
This file contains third-party software notices and license terms.
============================================================
semver@7.7.2
(git+https://github.com/npm/node-semver.git)
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
@modelcontextprotocol/sdk@1.15.1
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
@@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
markdown-it@14.1.0
(No repository found)
Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
============================================================
argparse@2.0.1
(No repository found)
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see http://www.opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the Internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the Internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
============================================================
entities@4.5.0
(git://github.com/fb55/entities.git)
Copyright (c) Felix Böhm
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
============================================================
linkify-it@5.0.0
(No repository found)
Copyright (c) 2015 Vitaly Puzrin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
============================================================
uc.micro@2.1.0
(No repository found)
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
mdurl@2.0.0
(No repository found)
Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
.parse() is based on Joyent's node.js `url` code:
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
============================================================
punycode.js@2.3.1
(https://github.com/mathiasbynens/punycode.js.git)
Copyright Mathias Bynens <https://mathiasbynens.be/>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
react@19.1.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
react-dom@19.1.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
============================================================
scheduler@0.26.0
(https://github.com/facebook/react.git)
MIT License
Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -19,6 +19,63 @@ To use this extension, you'll need:
- VS Code version 1.101.0 or newer
- Qwen Code (installed separately) running within the VS Code integrated terminal
# Development and Debugging
To debug and develop this extension locally:
1. **Clone the repository**
```bash
git clone https://github.com/QwenLM/qwen-code.git
cd qwen-code
```
2. **Install dependencies**
```bash
npm install
# or if using pnpm
pnpm install
```
3. **Start debugging**
```bash
code . # Open the project root in VS Code
```
- Open the `packages/vscode-ide-companion/src/extension.ts` file
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
- Press `F5` to launch Extension Development Host
4. **Make changes and reload**
- Edit the source code in the original VS Code window
- To see your changes, reload the Extension Development Host window by:
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
- Or clicking the "Reload" button in the debug toolbar
5. **View logs and debug output**
- Open the Debug Console in the original VS Code window to see extension logs
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
## Build for Production
To build the extension for distribution:
```bash
npm run compile
# or
pnpm run compile
```
To package the extension as a VSIX file:
```bash
npx vsce package
# or
pnpm vsce package
```
# Terms of Service and Privacy Notice
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).

View File

@@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = {
},
};
/**
* @type {import('esbuild').Plugin}
*/
const cssInjectPlugin = {
name: 'css-inject',
setup(build) {
// Handle CSS files
build.onLoad({ filter: /\.css$/ }, async (args) => {
const fs = await import('fs');
const postcss = (await import('postcss')).default;
const tailwindcss = (await import('tailwindcss')).default;
const autoprefixer = (await import('autoprefixer')).default;
let css = await fs.promises.readFile(args.path, 'utf8');
// For styles.css, we need to resolve @import statements
if (args.path.endsWith('styles.css')) {
// Read all imported CSS files and inline them
const importRegex = /@import\s+'([^']+)';/g;
let match;
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
while ((match = importRegex.exec(css)) !== null) {
const importPath = match[1];
// Resolve relative paths correctly
let fullPath;
if (importPath.startsWith('./')) {
fullPath = basePath + importPath.substring(1);
} else if (importPath.startsWith('../')) {
fullPath = basePath + '/' + importPath;
} else {
fullPath = basePath + '/' + importPath;
}
try {
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
css = css.replace(match[0], importedCss);
} catch (err) {
console.warn(`Could not import ${fullPath}: ${err.message}`);
}
}
}
// Process with PostCSS (Tailwind + Autoprefixer)
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
from: args.path,
to: args.path,
});
return {
contents: `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(result.css)};
document.head.appendChild(style);
`,
loader: 'js',
};
});
},
};
async function main() {
const ctx = await esbuild.context({
// Build extension
const extensionCtx = await esbuild.context({
entryPoints: ['src/extension.ts'],
bundle: true,
format: 'cjs',
@@ -55,11 +116,30 @@ async function main() {
],
loader: { '.node': 'file' },
});
// Build webview
const webviewCtx = await esbuild.context({
entryPoints: ['src/webview/index.tsx'],
bundle: true,
format: 'iife',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'browser',
outfile: 'dist/webview.js',
logLevel: 'silent',
plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin],
jsx: 'automatic', // Use new JSX transform (React 17+)
define: {
'process.env.NODE_ENV': production ? '"production"' : '"development"',
},
});
if (watch) {
await ctx.watch();
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
} else {
await ctx.rebuild();
await ctx.dispose();
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
}
}

View File

@@ -6,20 +6,44 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import importPlugin from 'eslint-plugin-import';
export default [
{
files: ['**/*.ts'],
files: ['**/*.ts', '**/*.tsx'],
},
{
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
globals: {
module: 'readonly',
require: 'readonly',
__dirname: 'readonly',
__filename: 'readonly',
process: 'readonly',
console: 'readonly',
},
},
},
{
plugins: {
'@typescript-eslint': typescriptEslint,
'react-hooks': reactHooks,
import: importPlugin,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
@@ -30,6 +54,17 @@ export default [
format: ['camelCase', 'PascalCase'],
},
],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Restrict deep imports but allow known-safe exceptions used by the webview
// - react-dom/client: required for React 18's createRoot API
// - ./styles/**: local CSS modules loaded by the webview
'import/no-internal-modules': [
'error',
{
allow: ['react-dom/client', './styles/**'],
},
],
curly: 'warn',
eqeqeq: 'warn',

View File

@@ -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.0",
"version": "0.5.0",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {
@@ -54,6 +54,15 @@
{
"command": "qwen-code.showNotices",
"title": "Qwen Code: View Third-Party Notices"
},
{
"command": "qwen-code.openChat",
"title": "Qwen Code: Open",
"icon": "./assets/icon.png"
},
{
"command": "qwen-code.login",
"title": "Qwen Code: Login"
}
],
"menus": {
@@ -65,6 +74,10 @@
{
"command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible"
},
{
"command": "qwen-code.login",
"when": "false"
}
],
"editor/title": [
@@ -77,6 +90,10 @@
"command": "qwen.diff.cancel",
"when": "qwen.diff.isVisible",
"group": "navigation"
},
{
"command": "qwen-code.openChat",
"group": "navigation"
}
]
},
@@ -96,7 +113,7 @@
"main": "./dist/extension.cjs",
"type": "module",
"scripts": {
"prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod",
"prepackage": "node ./scripts/prepackage.js",
"build": "npm run build:dev",
"build:dev": "npm run check-types && npm run lint && node esbuild.js",
"build:prod": "node esbuild.js --production",
@@ -115,21 +132,33 @@
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "20.x",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.1",
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.6.0",
"autoprefixer": "^10.4.22",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"eslint-plugin-react-hooks": "^5.2.0",
"npm-run-all2": "^8.0.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"dependencies": {
"semver": "^7.7.2",
"@modelcontextprotocol/sdk": "^1.15.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"markdown-it": "^14.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zod": "^3.25.76"
}
}

View File

@@ -0,0 +1,13 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable no-undef */
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Copy the already-built root dist/ folder into the extension dist/qwen-cli/.
*
* Assumes repoRoot/dist already exists (e.g. produced by `npm run bundle` and
* optionally `npm run prepare:package`).
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs/promises';
import { existsSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const rootDistDir = path.join(repoRoot, 'dist');
const extensionDistDir = path.join(extensionRoot, 'dist');
const bundledCliDir = path.join(extensionDistDir, 'qwen-cli');
async function main() {
const cliJs = path.join(rootDistDir, 'cli.js');
const vendorDir = path.join(rootDistDir, 'vendor');
if (!existsSync(cliJs) || !existsSync(vendorDir)) {
throw new Error(
`[copy-bundled-cli] Missing root dist artifacts. Expected:\n- ${cliJs}\n- ${vendorDir}\n\nRun root "npm run bundle" first.`,
);
}
await fs.mkdir(extensionDistDir, { recursive: true });
const existingNodeModules = path.join(bundledCliDir, 'node_modules');
const tmpNodeModules = path.join(
extensionDistDir,
'qwen-cli.node_modules.tmp',
);
const keepNodeModules = existsSync(existingNodeModules);
// Preserve destination node_modules if it exists (e.g. after packaging install).
if (keepNodeModules) {
await fs.rm(tmpNodeModules, { recursive: true, force: true });
await fs.rename(existingNodeModules, tmpNodeModules);
}
await fs.rm(bundledCliDir, { recursive: true, force: true });
await fs.mkdir(bundledCliDir, { recursive: true });
await fs.cp(rootDistDir, bundledCliDir, { recursive: true });
if (keepNodeModules) {
await fs.rename(tmpNodeModules, existingNodeModules);
}
console.log(`[copy-bundled-cli] Copied ${rootDistDir} -> ${bundledCliDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* VS Code extension packaging orchestration.
*
* We bundle the CLI into the extension so users don't need a global install.
* To match the published CLI layout, we need to:
* - build root bundle (dist/cli.js + vendor/ + sandbox profiles)
* - run root prepare:package (dist/package.json + locales + README/LICENSE)
* - install production deps into root dist/ (dist/node_modules) so runtime deps
* like optional node-pty are present inside the VSIX payload.
*
* Then we generate notices and build the extension.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const extensionRoot = path.resolve(__dirname, '..');
const repoRoot = path.resolve(extensionRoot, '..', '..');
const bundledCliDir = path.join(extensionRoot, 'dist', 'qwen-cli');
function npmBin() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
stdio: 'inherit',
shell: process.platform === 'win32' ? true : false,
...opts,
});
if (res.error) {
throw res.error;
}
if (typeof res.status === 'number' && res.status !== 0) {
throw new Error(
`Command failed (${res.status}): ${cmd} ${args.map((a) => JSON.stringify(a)).join(' ')}`,
);
}
}
function main() {
const npm = npmBin();
console.log('[prepackage] Bundling root CLI...');
run(npm, ['--prefix', repoRoot, 'run', 'bundle'], { cwd: repoRoot });
console.log('[prepackage] Preparing root dist/ package metadata...');
run(npm, ['--prefix', repoRoot, 'run', 'prepare:package'], { cwd: repoRoot });
console.log('[prepackage] Generating notices...');
run(npm, ['run', 'generate:notices'], { cwd: extensionRoot });
console.log('[prepackage] Typechecking...');
run(npm, ['run', 'check-types'], { cwd: extensionRoot });
console.log('[prepackage] Linting...');
run(npm, ['run', 'lint'], { cwd: extensionRoot });
console.log('[prepackage] Building extension (production)...');
run(npm, ['run', 'build:prod'], { cwd: extensionRoot });
console.log('[prepackage] Copying bundled CLI dist/ into extension...');
run(
'node',
[`${path.join(extensionRoot, 'scripts', 'copy-bundled-cli.js')}`],
{
cwd: extensionRoot,
},
);
console.log(
'[prepackage] Installing production deps into extension dist/qwen-cli...',
);
run(
npm,
[
'--prefix',
bundledCliDir,
'install',
'--omit=dev',
'--no-audit',
'--no-fund',
],
{ cwd: bundledCliDir },
);
}
main();

View File

@@ -0,0 +1,80 @@
import * as vscode from 'vscode';
import type { DiffManager } from '../diff-manager.js';
import type { WebViewProvider } from '../webview/WebViewProvider.js';
type Logger = (message: string) => void;
export const runQwenCodeCommand = 'qwen-code.runQwenCode';
export const showDiffCommand = 'qwenCode.showDiff';
export const openChatCommand = 'qwen-code.openChat';
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
export const loginCommand = 'qwen-code.login';
export function registerNewCommands(
context: vscode.ExtensionContext,
log: Logger,
diffManager: DiffManager,
getWebViewProviders: () => WebViewProvider[],
createWebViewProvider: () => WebViewProvider,
): void {
const disposables: vscode.Disposable[] = [];
disposables.push(
vscode.commands.registerCommand(openChatCommand, async () => {
const providers = getWebViewProviders();
if (providers.length > 0) {
await providers[providers.length - 1].show();
} else {
const provider = createWebViewProvider();
await provider.show();
}
}),
);
disposables.push(
vscode.commands.registerCommand(
showDiffCommand,
async (args: { path: string; oldText: string; newText: string }) => {
try {
let absolutePath = args.path;
if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (workspaceFolder) {
absolutePath = vscode.Uri.joinPath(
workspaceFolder.uri,
args.path,
).fsPath;
}
}
log(`[Command] Showing diff for ${absolutePath}`);
await diffManager.showDiff(absolutePath, args.oldText, args.newText);
} catch (error) {
log(`[Command] Error showing diff: ${error}`);
vscode.window.showErrorMessage(`Failed to show diff: ${error}`);
}
},
),
);
disposables.push(
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
const provider = createWebViewProvider();
// Session restoration is now disabled by default, so no need to suppress it
await provider.show();
}),
);
disposables.push(
vscode.commands.registerCommand(loginCommand, async () => {
const providers = getWebViewProviders();
if (providers.length > 0) {
await providers[providers.length - 1].forceReLogin();
} else {
vscode.window.showInformationMessage(
'Please open Qwen Code chat first before logging in.',
);
}
}),
);
context.subscriptions.push(...disposables);
}

View File

@@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const AGENT_METHODS = {
authenticate: 'authenticate',
initialize: 'initialize',
session_cancel: 'session/cancel',
session_list: 'session/list',
session_load: 'session/load',
session_new: 'session/new',
session_prompt: 'session/prompt',
session_save: 'session/save',
session_set_mode: 'session/set_mode',
} as const;
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
authenticate_update: 'authenticate/update',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;

View File

@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Loading messages from Qwen Code CLI
* Source: packages/cli/src/ui/hooks/usePhraseCycler.ts
*/
export const WITTY_LOADING_PHRASES = [
"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...',
'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...',
"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...",
'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...',
'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?...',
"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...',
'Trying to exit 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#.",
'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...',
'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...',
"New line? That's Ctrl+J.",
];
export const getRandomLoadingMessage = (): string =>
WITTY_LOADING_PHRASES[
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
];

View File

@@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
import * as path from 'node:path';
import * as vscode from 'vscode';
import { DIFF_SCHEME } from './extension.js';
import {
findLeftGroupOfChatWebview,
ensureLeftGroupOfChatWebview,
} from './utils/editorGroupUtils.js';
export class DiffContentProvider implements vscode.TextDocumentContentProvider {
private content = new Map<string, string>();
@@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider {
// Information about a diff view that is currently open.
interface DiffInfo {
originalFilePath: string;
oldContent: string;
newContent: string;
leftDocUri: vscode.Uri;
rightDocUri: vscode.Uri;
}
@@ -55,11 +61,26 @@ export class DiffManager {
readonly onDidChange = this.onDidChangeEmitter.event;
private diffDocuments = new Map<string, DiffInfo>();
private readonly subscriptions: vscode.Disposable[] = [];
// Dedupe: remember recent showDiff calls keyed by (file+content)
private recentlyShown = new Map<string, number>();
private pendingDelayTimers = new Map<string, NodeJS.Timeout>();
private static readonly DEDUPE_WINDOW_MS = 1500;
// Optional hooks from extension to influence diff behavior
// - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open)
// - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode)
private shouldDelay?: () => boolean;
private shouldSuppress?: () => boolean;
// Timed suppression window (e.g. immediately after permission allow)
private suppressUntil: number | null = null;
constructor(
private readonly log: (message: string) => void,
private readonly diffContentProvider: DiffContentProvider,
shouldDelay?: () => boolean,
shouldSuppress?: () => boolean,
) {
this.shouldDelay = shouldDelay;
this.shouldSuppress = shouldSuppress;
this.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((editor) => {
this.onActiveEditorChange(editor);
@@ -75,43 +96,142 @@ export class DiffManager {
}
/**
* Creates and shows a new diff view.
* Checks if a diff view already exists for the given file path and content
* @param filePath Path to the file being diffed
* @param oldContent The original content (left side)
* @param newContent The modified content (right side)
* @returns True if a diff view with the same content already exists, false otherwise
*/
async showDiff(filePath: string, newContent: string) {
const fileUri = vscode.Uri.file(filePath);
private hasExistingDiff(
filePath: string,
oldContent: string,
newContent: string,
): boolean {
for (const diffInfo of this.diffDocuments.values()) {
if (
diffInfo.originalFilePath === filePath &&
diffInfo.oldContent === oldContent &&
diffInfo.newContent === newContent
) {
return true;
}
}
return false;
}
/**
* Finds an existing diff view for the given file path and focuses it
* @param filePath Path to the file being diffed
* @returns True if an existing diff view was found and focused, false otherwise
*/
private async focusExistingDiff(filePath: string): Promise<boolean> {
const normalizedPath = path.normalize(filePath);
for (const [, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === normalizedPath) {
const rightDocUri = diffInfo.rightDocUri;
const leftDocUri = diffInfo.leftDocUri;
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
try {
await vscode.commands.executeCommand(
'vscode.diff',
leftDocUri,
rightDocUri,
diffTitle,
{
viewColumn: vscode.ViewColumn.Beside,
preview: false,
preserveFocus: true,
},
);
return true;
} catch (error) {
this.log(`Failed to focus existing diff: ${error}`);
return false;
}
}
}
return false;
}
/**
* Creates and shows a new diff view.
* - Overload 1: showDiff(filePath, newContent)
* - Overload 2: showDiff(filePath, oldContent, newContent)
* If only newContent is provided, the old content will be read from the
* filesystem (empty string when file does not exist).
*/
async showDiff(filePath: string, newContent: string): Promise<void>;
async showDiff(
filePath: string,
oldContent: string,
newContent: string,
): Promise<void>;
async showDiff(filePath: string, a: string, b?: string): Promise<void> {
const haveOld = typeof b === 'string';
const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath);
const newContent = haveOld ? (b as string) : a;
const normalizedPath = path.normalize(filePath);
const key = this.makeKey(normalizedPath, oldContent, newContent);
// Check if a diff view with the same content already exists
if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) {
const last = this.recentlyShown.get(key) || 0;
const now = Date.now();
if (now - last < DiffManager.DEDUPE_WINDOW_MS) {
// Within dedupe window: ignore the duplicate request entirely
this.log(
`Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`,
);
return;
}
// Outside the dedupe window: softly focus the existing diff
await this.focusExistingDiff(normalizedPath);
this.recentlyShown.set(key, now);
return;
}
// Left side: old content using qwen-diff scheme
const leftDocUri = vscode.Uri.from({
scheme: DIFF_SCHEME,
path: normalizedPath,
query: `old&rand=${Math.random()}`,
});
this.diffContentProvider.setContent(leftDocUri, oldContent);
// Right side: new content using qwen-diff scheme
const rightDocUri = vscode.Uri.from({
scheme: DIFF_SCHEME,
path: filePath,
// cache busting
query: `rand=${Math.random()}`,
path: normalizedPath,
query: `new&rand=${Math.random()}`,
});
this.diffContentProvider.setContent(rightDocUri, newContent);
this.addDiffDocument(rightDocUri, {
originalFilePath: filePath,
originalFilePath: normalizedPath,
oldContent,
newContent,
leftDocUri,
rightDocUri,
});
const diffTitle = `${path.basename(filePath)} ↔ Modified`;
const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`;
await vscode.commands.executeCommand(
'setContext',
'qwen.diff.isVisible',
true,
);
let leftDocUri;
try {
await vscode.workspace.fs.stat(fileUri);
leftDocUri = fileUri;
} catch {
// We need to provide an empty document to diff against.
// Using the 'untitled' scheme is one way to do this.
leftDocUri = vscode.Uri.from({
scheme: 'untitled',
path: filePath,
});
// Prefer opening the diff adjacent to the chat webview (so we don't
// replace content inside the locked webview group). We try the group to
// the left of the chat webview first; if none exists we fall back to
// ViewColumn.Beside. With the chat locked in the leftmost group, this
// fallback opens diffs to the right of the chat.
let targetViewColumn = findLeftGroupOfChatWebview();
if (targetViewColumn === undefined) {
// If there is no left neighbor, create one to satisfy the requirement of
// opening diffs to the left of the chat webview.
targetViewColumn = await ensureLeftGroupOfChatWebview();
}
await vscode.commands.executeCommand(
@@ -120,6 +240,10 @@ export class DiffManager {
rightDocUri,
diffTitle,
{
// If a left-of-webview group was found, target it explicitly so the
// diff opens there while keeping focus on the webview. Otherwise, use
// the default "open to side" behavior.
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
preview: false,
preserveFocus: true,
},
@@ -127,16 +251,19 @@ export class DiffManager {
await vscode.commands.executeCommand(
'workbench.action.files.setActiveEditorWriteableInSession',
);
this.recentlyShown.set(key, Date.now());
}
/**
* Closes an open diff view for a specific file.
*/
async closeDiff(filePath: string, suppressNotification = false) {
const normalizedPath = path.normalize(filePath);
let uriToClose: vscode.Uri | undefined;
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === filePath) {
uriToClose = vscode.Uri.parse(uriString);
for (const [, diffInfo] of this.diffDocuments.entries()) {
if (diffInfo.originalFilePath === normalizedPath) {
uriToClose = diffInfo.rightDocUri;
break;
}
}
@@ -267,4 +394,40 @@ export class DiffManager {
}
}
}
/** Close all open qwen-diff editors */
async closeAll(): Promise<void> {
// Collect keys first to avoid iterator invalidation while closing
const uris = Array.from(this.diffDocuments.keys()).map((k) =>
vscode.Uri.parse(k),
);
for (const uri of uris) {
try {
await this.closeDiffEditor(uri);
} catch (err) {
this.log(`Failed to close diff editor: ${err}`);
}
}
}
// Read the current content of file from the workspace; return empty string if not found
private async readOldContentFromFs(filePath: string): Promise<string> {
try {
const fileUri = vscode.Uri.file(filePath);
const document = await vscode.workspace.openTextDocument(fileUri);
return document.getText();
} catch {
return '';
}
}
private makeKey(filePath: string, oldContent: string, newContent: string) {
// Simple stable key; content could be large but kept transiently
return `${filePath}\u241F${oldContent}\u241F${newContent}`;
}
/** Temporarily suppress opening diffs for a short duration. */
suppressFor(durationMs: number): void {
this.suppressUntil = Date.now() + Math.max(0, durationMs);
}
}

Some files were not shown because too many files have changed in this diff Show More