Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
01da2e3620 chore(release): v0.6.0-preview.0 2025-12-24 01:17:33 +00:00
375 changed files with 10080 additions and 51004 deletions

View File

@@ -33,10 +33,6 @@ on:
type: 'boolean'
default: false
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
release-sdk:
runs-on: 'ubuntu-latest'
@@ -50,7 +46,6 @@ jobs:
packages: 'write'
id-token: 'write'
issues: 'write'
pull-requests: 'write'
outputs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -91,8 +86,6 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Install Dependencies'
run: |-
@@ -128,14 +121,6 @@ jobs:
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
MANUAL_VERSION: '${{ inputs.version }}'
- name: 'Set SDK package version (local only)'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
# Ensure the package version matches the computed release version.
# This is required for nightly/preview because npm does not allow re-publishing the same version.
npm version -w @qwen-code/sdk "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Build CLI Bundle'
run: |
npm run build
@@ -168,21 +153,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create and switch to a release branch'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'release_branch'
env:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
@@ -191,22 +162,50 @@ jobs:
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Commit and Push package version (stable only)'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
- name: 'Update package version'
working-directory: 'packages/sdk-typescript'
env:
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
run: |-
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
- name: 'Commit and Conditionally Push package version'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
# Only persist version bumps after a successful publish.
git add packages/sdk-typescript/package.json package-lock.json
git add packages/sdk-typescript/package.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else
git commit -m "chore(release): sdk-typescript ${RELEASE_TAG}"
fi
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
if [[ "${IS_DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- name: 'Build SDK'
working-directory: 'packages/sdk-typescript'
run: |-
npm run build
- name: 'Configure npm for publishing'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
scope: '@qwen-code'
- name: 'Publish @qwen-code/sdk'
working-directory: 'packages/sdk-typescript'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'
- name: 'Create GitHub Release and Tag'
if: |-
@@ -216,68 +215,12 @@ jobs:
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
PREVIOUS_RELEASE_TAG: '${{ steps.version.outputs.PREVIOUS_RELEASE_TAG }}'
IS_NIGHTLY: '${{ steps.vars.outputs.is_nightly }}'
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
REF: '${{ github.event.inputs.ref || github.sha }}'
run: |-
# For stable releases, use the release branch; for nightly/preview, use the current ref
if [[ "${IS_NIGHTLY}" == "true" || "${IS_PREVIEW}" == "true" ]]; then
TARGET="${REF}"
PRERELEASE_FLAG="--prerelease"
else
TARGET="${RELEASE_BRANCH}"
PRERELEASE_FLAG=""
fi
gh release create "sdk-typescript-${RELEASE_TAG}" \
--target "${TARGET}" \
--target "$RELEASE_BRANCH" \
--title "SDK TypeScript Release ${RELEASE_TAG}" \
--notes-start-tag "sdk-typescript-${PREVIOUS_RELEASE_TAG}" \
--generate-notes \
${PRERELEASE_FLAG}
- name: 'Create PR to merge release branch into main'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
id: 'pr'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
RELEASE_BRANCH: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
set -euo pipefail
pr_url="$(gh pr list --head "${RELEASE_BRANCH}" --base main --json url --jq '.[0].url')"
if [[ -z "${pr_url}" ]]; then
pr_url="$(gh pr create \
--base main \
--head "${RELEASE_BRANCH}" \
--title "chore(release): sdk-typescript ${RELEASE_TAG}" \
--body "Automated release PR for sdk-typescript ${RELEASE_TAG}.")"
fi
echo "PR_URL=${pr_url}" >> "${GITHUB_OUTPUT}"
- name: 'Wait for CI checks to complete'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
echo "Waiting for CI checks to complete..."
gh pr checks "${PR_URL}" --watch --interval 30
- name: 'Enable auto-merge for release PR'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' && steps.vars.outputs.is_nightly == 'false' && steps.vars.outputs.is_preview == 'false' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_URL: '${{ steps.pr.outputs.PR_URL }}'
run: |-
set -euo pipefail
gh pr merge "${PR_URL}" --merge --auto
--generate-notes
- name: 'Create Issue on Failure'
if: |-

1
.gitignore vendored
View File

@@ -23,7 +23,6 @@ package-lock.json
.idea
*.iml
.cursor
.qoder
# OS metadata
.DS_Store

View File

@@ -191,7 +191,6 @@ See [settings](https://qwenlm.github.io/qwen-code-docs/en/users/configuration/se
Looking for a graphical interface?
- [**AionUi**](https://github.com/iOfficeAI/AionUi) A modern GUI for command-line AI tools including Qwen Code
- [**Gemini CLI Desktop**](https://github.com/Piebald-AI/gemini-cli-desktop) A cross-platform desktop/web/mobile UI for Qwen Code
## Troubleshooting

View File

@@ -11,7 +11,6 @@ export default {
type: 'separator',
},
'sdk-typescript': 'Typescript SDK',
'sdk-java': 'Java SDK(alpha)',
'Dive Into Qwen Code': {
title: 'Dive Into Qwen Code',
type: 'separator',

View File

@@ -1,312 +0,0 @@
# Qwen Code Java SDK
The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
## Requirements
- Java >= 1.8
- Maven >= 3.6.0 (for building from source)
- qwen-code >= 0.5.0
### Dependencies
- **Logging**: ch.qos.logback:logback-classic
- **Utilities**: org.apache.commons:commons-lang3
- **JSON Processing**: com.alibaba.fastjson2:fastjson2
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
## Installation
Add the following dependency to your Maven `pom.xml`:
```xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>qwencode-sdk</artifactId>
<version>{$version}</version>
</dependency>
```
Or if using Gradle, add to your `build.gradle`:
```gradle
implementation 'com.alibaba:qwencode-sdk:{$version}'
```
## Building and Running
### Build Commands
```bash
# Compile the project
mvn compile
# Run tests
mvn test
# Package the JAR
mvn package
# Install to local repository
mvn install
```
## Quick Start
The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
```java
public static void runSimpleExample() {
List<String> result = QwenCodeCli.simpleQuery("hello world");
result.forEach(logger::info);
}
```
For more advanced usage with custom transport options:
```java
public static void runTransportOptionsExample() {
TransportOptions options = new TransportOptions()
.setModel("qwen3-coder-flash")
.setPermissionMode(PermissionMode.AUTO_EDIT)
.setCwd("./")
.setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
.setIncludePartialMessages(true)
.setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
.setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
.setAllowedTools(Arrays.asList("read_file", "write_file", "list_directory"));
List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
result.forEach(logger::info);
}
```
For streaming content handling with custom content consumers:
```java
public static void runStreamingExample() {
QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {
@Override
public void onText(Session session, TextAssistantContent textAssistantContent) {
logger.info("Text content received: {}", textAssistantContent.getText());
}
@Override
public void onThinking(Session session, ThingkingAssistantContent thingkingAssistantContent) {
logger.info("Thinking content received: {}", thingkingAssistantContent.getThinking());
}
@Override
public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
logger.info("Tool use content received: {} with arguments: {}",
toolUseContent, toolUseContent.getInput());
}
@Override
public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
logger.info("Tool result content received: {}", toolResultContent.getContent());
}
@Override
public void onOtherContent(Session session, AssistantContent<?> other) {
logger.info("Other content received: {}", other);
}
@Override
public void onUsage(Session session, AssistantUsage assistantUsage) {
logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
}
}.setDefaultPermissionOperation(Operation.allow));
logger.info("Streaming example completed.");
}
```
other examples see src/test/java/com/alibaba/qwen/code/cli/example
## Architecture
The SDK follows a layered architecture:
- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
## Key Features
### Permission Modes
The SDK supports different permission modes for controlling tool execution:
- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
- **`auto-edit`**: Auto-approve edit tools (edit, write_file) while other tools require confirmation.
- **`yolo`**: All tools execute automatically without confirmation.
### Session Event Consumers and Assistant Content Consumers
The SDK provides two key interfaces for handling events and content from the CLI:
#### SessionEventConsumers Interface
The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
- `onOtherMessage`: Handles other types of messages (receives Session and String message)
- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest<CLIControlPermissionRequest>, returns Behavior)
#### AssistantContentConsumers Interface
The `AssistantContentConsumers` interface handles different types of content within assistant messages:
- `onText`: Handles text content (receives Session and TextAssistantContent)
- `onThinking`: Handles thinking content (receives Session and ThingkingAssistantContent)
- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
- `onUsage`: Handles usage information (receives Session and AssistantUsage)
- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
#### Relationship Between the Interfaces
**Important Note on Event Hierarchy:**
- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
**Processor Relationship:**
- `SessionEventConsumers``AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
**Event Derivation Relationships:**
- `onAssistantMessage``onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
- `onPartialAssistantMessage``onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
- `onControlRequest``onPermissionRequest`, `onOtherControlRequest`
**Event Timeout Relationships:**
Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
- `onSystemMessage``onSystemMessageTimeout`
- `onResultMessage``onResultMessageTimeout`
- `onAssistantMessage``onAssistantMessageTimeout`
- `onPartialAssistantMessage``onPartialAssistantMessageTimeout`
- `onUserMessage``onUserMessageTimeout`
- `onOtherMessage``onOtherMessageTimeout`
- `onControlResponse``onControlResponseTimeout`
- `onControlRequest``onControlRequestTimeout`
For AssistantContentConsumers timeout methods:
- `onText``onTextTimeout`
- `onThinking``onThinkingTimeout`
- `onToolUse``onToolUseTimeout`
- `onToolResult``onToolResultTimeout`
- `onOtherContent``onOtherContentTimeout`
- `onPermissionRequest``onPermissionRequestTimeout`
- `onOtherControlRequest``onOtherControlRequestTimeout`
**Default Timeout Values:**
- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
**Timeout Hierarchy Requirements:**
For proper operation, the following timeout relationships should be maintained:
- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
### Transport Options
The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
- `cwd`: Working directory for the CLI process
- `model`: AI model to use for the session
- `permissionMode`: Permission mode that controls tool execution
- `env`: Environment variables to pass to the CLI process
- `maxSessionTurns`: Limits the number of conversation turns in a session
- `coreTools`: List of core tools that should be available to the AI
- `excludeTools`: List of tools to exclude from being available to the AI
- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
- `authType`: Authentication type to use for the session
- `includePartialMessages`: Enables receiving partial messages during streaming responses
- `skillsEnable`: Enables or disables skills functionality for the session
- `turnTimeout`: Timeout for a complete turn of conversation
- `messageTimeout`: Timeout for individual messages within a turn
- `resumeSessionId`: ID of a previous session to resume
- `otherOptions`: Additional command-line options to pass to the CLI
### Session Control Features
- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
### Thread Pool Configuration
The SDK uses a thread pool for managing concurrent operations with the following default configuration:
- **Core Pool Size**: 30 threads
- **Maximum Pool Size**: 100 threads
- **Keep-Alive Time**: 60 seconds
- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
- **Thread Naming**: "qwen_code_cli-pool-{number}"
- **Daemon Threads**: false
- **Rejected Execution Handler**: CallerRunsPolicy
## Error Handling
The SDK provides specific exception types for different error scenarios:
- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
- `SessionClosedException`: Thrown when attempting to use a closed session
## FAQ / Troubleshooting
### Q: Do I need to install the Qwen CLI separately?
A: yes, requires Qwen CLI 0.5.5 or higher.
### Q: What Java versions are supported?
A: The SDK requires Java 1.8 or higher.
### Q: How do I handle long-running requests?
A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
### Q: Why are some tools not executing?
A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
### Q: How do I resume a previous session?
A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
### Q: Can I customize the environment for the CLI process?
A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
## License
Apache-2.0 - see [LICENSE](./LICENSE) for details.

View File

@@ -1,80 +0,0 @@
# Chrome 扩展 Native Host 排查步骤
适用于遇到“Specified native messaging host not found.”、“Native host has exited.”、“Handshake timeout”等情况。
## 1. 核对 manifest
路径:`~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json`
内容应为:
```json
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "/Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/host.js",
"type": "stdio",
"allowed_origins": [
"chrome-extension://kbpfhhpfobobomiighfkhojhmefogdgh/"
]
}
```
一键覆盖命令:
```bash
cat > "$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json" <<'EOF'
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "/Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/host.js",
"type": "stdio",
"allowed_origins": [
"chrome-extension://kbpfhhpfobobomiighfkhojhmefogdgh/"
]
}
EOF
```
> 修改 manifest 后务必**彻底退出并重启 Chrome**,再在扩展页点击“重新加载”插件。
## 2. 确保可执行与 Node 路径
Host 入口已设置 shebang `/usr/local/bin/node`。确保脚本可执行:
```bash
chmod +x /Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/host.js
chmod +x /Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/src/host.js
```
## 3. 日志位置
- 主日志:`~/.qwen/chrome-bridge/qwen-bridge-host.log`
- 如果主目录不可写,回退:`/tmp/qwen-bridge-host.log``/var/folders/.../T/qwen-bridge-host.log`
若文件为空,说明 host 可能没被 Chrome 拉起或启动后被立即杀掉(查看 manifest 是否正确、Chrome 是否重启)。
## 4. 手动运行自检
```bash
node /Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/host.js
```
进程会挂起等待 stdin无输出属正常日志文件应记录启动信息。`Ctrl+C` 退出。
## 5. 常见错误与对应操作
- `Specified native messaging host not found.`
Manifest 中 `path``allowed_origins` 不对,或 Chrome 未重启。按第 1 步覆盖,重启 Chrome。
- `Native host has exited.` / `Handshake timeout`
多为 manifest 不被 Chrome 接受或 host 无法启动。确认第 1、2 步,重启 Chrome再看日志是否收到 “Received … bytes”/信号。
## 6. 快速排查命令合集
```bash
# 查看当前 manifest
cat "$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json"
# 覆盖 manifest见第 1 步)
cat > "$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json" <<'EOF'
{ ...如上... }
EOF
# 确保可执行
chmod +x /Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/host.js
chmod +x /Users/jinjing/projects/projj/github.com/QwenLM/qwen-code/packages/chrome-extension/native-host/src/host.js
# 查看日志
cat ~/.qwen/chrome-bridge/qwen-bridge-host.log 2>/dev/null || echo "no log"
```

View File

@@ -43,7 +43,6 @@ Qwen Code uses JSON settings files for persistent configuration. There are four
In addition to a project settings file, a project's `.qwen` directory can contain other project-specific files related to Qwen Code's operation, such as:
- [Custom sandbox profiles](../features/sandbox) (e.g. `.qwen/sandbox-macos-custom.sb`, `.qwen/sandbox.Dockerfile`).
- [Agent Skills](../features/skills) (experimental) under `.qwen/skills/` (each Skill is a directory containing a `SKILL.md`).
### Available settings in `settings.json`
@@ -381,8 +380,6 @@ Arguments passed directly when running the CLI can override other configurations
| `--telemetry-otlp-protocol` | | Sets the OTLP protocol for telemetry (`grpc` or `http`). | | Defaults to `grpc`. See [telemetry](../../developers/development/telemetry) for more information. |
| `--telemetry-log-prompts` | | Enables logging of prompts for telemetry. | | See [telemetry](../../developers/development/telemetry) for more information. |
| `--checkpointing` | | Enables [checkpointing](../features/checkpointing). | | |
| `--acp` | | Enables ACP mode (Agent Control Protocol). Useful for IDE/editor integrations like [Zed](../integration-zed). | | Stable. Replaces the deprecated `--experimental-acp` flag. |
| `--experimental-skills` | | Enables experimental [Agent Skills](../features/skills) (registers the `skill` tool and loads Skills from `.qwen/skills/` and `~/.qwen/skills/`). | | Experimental. |
| `--extensions` | `-e` | Specifies a list of extensions to use for the session. | Extension names | If not provided, all available extensions are used. Use the special term `qwen -e none` to disable all extensions. Example: `qwen -e my-extension -e my-other-extension` |
| `--list-extensions` | `-l` | Lists all available extensions and exits. | | |
| `--proxy` | | Sets the proxy for the CLI. | Proxy URL | Example: `--proxy http://localhost:7890`. |

View File

@@ -1,7 +1,6 @@
export default {
commands: 'Commands',
'sub-agents': 'SubAgents',
skills: 'Skills (Experimental)',
headless: 'Headless Mode',
checkpointing: {
display: 'hidden',
@@ -10,5 +9,4 @@ export default {
mcp: 'MCP',
'token-caching': 'Token Caching',
sandbox: 'Sandboxing',
language: 'i18n',
};

View File

@@ -48,7 +48,7 @@ Commands specifically for controlling interface and output language.
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
| → `output [language]` | Set LLM output language | `/language output Chinese` |
- Available built-in UI languages: `zh-CN` (Simplified Chinese), `en-US` (English), `ru-RU` (Russian), `de-DE` (German)
- Available UI languages: `zh-CN` (Simplified Chinese), `en-US` (English)
- Output language examples: `Chinese`, `English`, `Japanese`, etc.
### 1.4 Tool and Model Management
@@ -72,16 +72,17 @@ Commands for managing AI tools and models.
Commands for obtaining information and performing system settings.
| Command | Description | Usage Examples |
| ----------- | ----------------------------------------------- | -------------------------------- |
| `/help` | Display help information for available commands | `/help` or `/?` |
| `/about` | Display version information | `/about` |
| `/stats` | Display detailed statistics for current session | `/stats` |
| `/settings` | Open settings editor | `/settings` |
| `/auth` | Change authentication method | `/auth` |
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
| `/copy` | Copy last output content to clipboard | `/copy` |
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
| Command | Description | Usage Examples |
| --------------- | ----------------------------------------------- | ------------------------------------------------ |
| `/help` | Display help information for available commands | `/help` or `/?` |
| `/about` | Display version information | `/about` |
| `/stats` | Display detailed statistics for current session | `/stats` |
| `/settings` | Open settings editor | `/settings` |
| `/auth` | Change authentication method | `/auth` |
| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` |
| `/copy` | Copy last output content to clipboard | `/copy` |
| `/quit-confirm` | Show confirmation dialog before quitting | `/quit-confirm` (shortcut: press `Ctrl+C` twice) |
| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` |
### 1.6 Common Shortcuts

View File

@@ -189,20 +189,19 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
Key command-line options for headless usage:
| Option | Description | Example |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ |
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
| `--experimental-skills` | Enable experimental Skills (registers the `skill` tool) | `qwen --experimental-skills -p "What Skills are available?"` |
| Option | Description | Example |
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).

View File

@@ -1,136 +0,0 @@
# Internationalization (i18n) & Language
Qwen Code is built for multilingual workflows: it supports UI localization (i18n/l10n) in the CLI, lets you choose the assistant output language, and allows custom UI language packs.
## Overview
From a user point of view, Qwen Codes “internationalization” spans multiple layers:
| Capability / Setting | What it controls | Where stored |
| ------------------------ | ---------------------------------------------------------------------- | ---------------------------- |
| `/language ui` | Terminal UI text (menus, system messages, prompts) | `~/.qwen/settings.json` |
| `/language output` | Language the AI responds in (an output preference, not UI translation) | `~/.qwen/output-language.md` |
| Custom UI language packs | Overrides/extends built-in UI translations | `~/.qwen/locales/*.js` |
## UI Language
This is the CLIs UI localization layer (i18n/l10n): it controls the language of menus, prompts, and system messages.
### Setting the UI Language
Use the `/language ui` command:
```bash
/language ui zh-CN # Chinese
/language ui en-US # English
/language ui ru-RU # Russian
/language ui de-DE # German
```
Aliases are also supported:
```bash
/language ui zh # Chinese
/language ui en # English
/language ui ru # Russian
/language ui de # German
```
### Auto-detection
On first startup, Qwen Code detects your system locale and sets the UI language automatically.
Detection priority:
1. `QWEN_CODE_LANG` environment variable
2. `LANG` environment variable
3. System locale via JavaScript Intl API
4. Default: English
## LLM Output Language
The LLM output language controls what language the AI assistant responds in, regardless of what language you type your questions in.
### How It Works
The LLM output language is controlled by a rule file at `~/.qwen/output-language.md`. This file is automatically included in the LLM's context during startup, instructing it to respond in the specified language.
### Auto-detection
On first startup, if no `output-language.md` file exists, Qwen Code automatically creates one based on your system locale. For example:
- System locale `zh` creates a rule for Chinese responses
- System locale `en` creates a rule for English responses
- System locale `ru` creates a rule for Russian responses
- System locale `de` creates a rule for German responses
### Manual Setting
Use `/language output <language>` to change:
```bash
/language output Chinese
/language output English
/language output Japanese
/language output German
```
Any language name works. The LLM will be instructed to respond in that language.
> [!note]
>
> After changing the output language, restart Qwen Code for the change to take effect.
### File Location
```
~/.qwen/output-language.md
```
## Configuration
### Via Settings Dialog
1. Run `/settings`
2. Find "Language" under General
3. Select your preferred UI language
### Via Environment Variable
```bash
export QWEN_CODE_LANG=zh
```
This influences auto-detection on first startup (if you havent set a UI language and no `output-language.md` file exists yet).
## Custom Language Packs
For UI translations, you can create custom language packs in `~/.qwen/locales/`:
- Example: `~/.qwen/locales/es.js` for Spanish
- Example: `~/.qwen/locales/fr.js` for French
User directory takes precedence over built-in translations.
> [!tip]
>
> Contributions are welcome! If youd like to improve built-in translations or add new languages.
> For a concrete example, see [PR #1238: feat(i18n): add Russian language support](https://github.com/QwenLM/qwen-code/pull/1238).
### Language Pack Format
```javascript
// ~/.qwen/locales/es.js
export default {
Hello: 'Hola',
Settings: 'Configuracion',
// ... more translations
};
```
## Related Commands
- `/language` - Show current language settings
- `/language ui [lang]` - Set UI language
- `/language output <language>` - Set LLM output language
- `/settings` - Open settings dialog

View File

@@ -1,282 +0,0 @@
# Agent Skills (Experimental)
> Create, manage, and share Skills to extend Qwen Codes capabilities.
This guide shows you how to create, use, and manage Agent Skills in **Qwen Code**. Skills are modular capabilities that extend the models effectiveness through organized folders containing instructions (and optionally scripts/resources).
> [!note]
>
> Skills are currently **experimental** and must be enabled with `--experimental-skills`.
## Prerequisites
- Qwen Code (recent version)
- Run with the experimental flag enabled:
```bash
qwen --experimental-skills
```
- Basic familiarity with Qwen Code ([Quickstart](../quickstart.md))
## What are Agent Skills?
Agent Skills package expertise into discoverable capabilities. Each Skill consists of a `SKILL.md` file with instructions that the model can load when relevant, plus optional supporting files like scripts and templates.
### How Skills are invoked
Skills are **model-invoked** — the model autonomously decides when to use them based on your request and the Skills description. This is different from slash commands, which are **user-invoked** (you explicitly type `/command`).
### Benefits
- Extend Qwen Code for your workflows
- Share expertise across your team via git
- Reduce repetitive prompting
- Compose multiple Skills for complex tasks
## Create a Skill
Skills are stored as directories containing a `SKILL.md` file.
### Personal Skills
Personal Skills are available across all your projects. Store them in `~/.qwen/skills/`:
```bash
mkdir -p ~/.qwen/skills/my-skill-name
```
Use personal Skills for:
- Your individual workflows and preferences
- Experimental Skills youre developing
- Personal productivity helpers
### Project Skills
Project Skills are shared with your team. Store them in `.qwen/skills/` within your project:
```bash
mkdir -p .qwen/skills/my-skill-name
```
Use project Skills for:
- Team workflows and conventions
- Project-specific expertise
- Shared utilities and scripts
Project Skills can be checked into git and automatically become available to teammates.
## Write `SKILL.md`
Create a `SKILL.md` file with YAML frontmatter and Markdown content:
```yaml
---
name: your-skill-name
description: Brief description of what this Skill does and when to use it
---
# Your Skill Name
## Instructions
Provide clear, step-by-step guidance for Qwen Code.
## Examples
Show concrete examples of using this Skill.
```
### Field requirements
Qwen Code currently validates that:
- `name` is a non-empty string
- `description` is a non-empty string
Recommended conventions (not strictly enforced yet):
- Use lowercase letters, numbers, and hyphens in `name`
- Make `description` specific: include both **what** the Skill does and **when** to use it (key words users will naturally mention)
## Add supporting files
Create additional files alongside `SKILL.md`:
```text
my-skill/
├── SKILL.md (required)
├── reference.md (optional documentation)
├── examples.md (optional examples)
├── scripts/
│ └── helper.py (optional utility)
└── templates/
└── template.txt (optional template)
```
Reference these files from `SKILL.md`:
````markdown
For advanced usage, see [reference.md](reference.md).
Run the helper script:
```bash
python scripts/helper.py input.txt
```
````
## View available Skills
When `--experimental-skills` is enabled, Qwen Code discovers Skills from:
- Personal Skills: `~/.qwen/skills/`
- Project Skills: `.qwen/skills/`
To view available Skills, ask Qwen Code directly:
```text
What Skills are available?
```
Or inspect the filesystem:
```bash
# List personal Skills
ls ~/.qwen/skills/
# List project Skills (if in a project directory)
ls .qwen/skills/
# View a specific Skills content
cat ~/.qwen/skills/my-skill/SKILL.md
```
## Test a Skill
After creating a Skill, test it by asking questions that match your description.
Example: if your description mentions “PDF files”:
```text
Can you help me extract text from this PDF?
```
The model autonomously decides to use your Skill if it matches the request — you dont need to explicitly invoke it.
## Debug a Skill
If Qwen Code doesnt use your Skill, check these common issues:
### Make the description specific
Too vague:
```yaml
description: Helps with documents
```
Specific:
```yaml
description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDFs, forms, or document extraction.
```
### Verify file path
- Personal Skills: `~/.qwen/skills/<skill-name>/SKILL.md`
- Project Skills: `.qwen/skills/<skill-name>/SKILL.md`
```bash
# Personal
ls ~/.qwen/skills/my-skill/SKILL.md
# Project
ls .qwen/skills/my-skill/SKILL.md
```
### Check YAML syntax
Invalid YAML prevents the Skill metadata from loading correctly.
```bash
cat SKILL.md | head -n 15
```
Ensure:
- Opening `---` on line 1
- Closing `---` before Markdown content
- Valid YAML syntax (no tabs, correct indentation)
### View errors
Run Qwen Code with debug mode to see Skill loading errors:
```bash
qwen --experimental-skills --debug
```
## Share Skills with your team
You can share Skills through project repositories:
1. Add the Skill under `.qwen/skills/`
2. Commit and push
3. Teammates pull the changes and run with `--experimental-skills`
```bash
git add .qwen/skills/
git commit -m "Add team Skill for PDF processing"
git push
```
## Update a Skill
Edit `SKILL.md` directly:
```bash
# Personal Skill
code ~/.qwen/skills/my-skill/SKILL.md
# Project Skill
code .qwen/skills/my-skill/SKILL.md
```
Changes take effect the next time you start Qwen Code. If Qwen Code is already running, restart it to load the updates.
## Remove a Skill
Delete the Skill directory:
```bash
# Personal
rm -rf ~/.qwen/skills/my-skill
# Project
rm -rf .qwen/skills/my-skill
git commit -m "Remove unused Skill"
```
## Best practices
### Keep Skills focused
One Skill should address one capability:
- Focused: “PDF form filling”, “Excel analysis”, “Git commit messages”
- Too broad: “Document processing” (split into smaller Skills)
### Write clear descriptions
Help the model discover when to use Skills by including specific triggers:
```yaml
description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or .xlsx data.
```
### Test with your team
- Does the Skill activate when expected?
- Are the instructions clear?
- Are there missing examples or edge cases?

View File

@@ -32,7 +32,7 @@
"Qwen Code": {
"type": "custom",
"command": "qwen",
"args": ["--acp"],
"args": ["--experimental-acp"],
"env": {}
}
```

View File

@@ -1,6 +1,4 @@
# Qwen Code overview
[![@qwen-code/qwen-code downloads](https://img.shields.io/npm/dw/@qwen-code/qwen-code.svg)](https://npm-compare.com/@qwen-code/qwen-code)
[![@qwen-code/qwen-code version](https://img.shields.io/npm/v/@qwen-code/qwen-code.svg)](https://www.npmjs.com/package/@qwen-code/qwen-code)
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
@@ -48,7 +46,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
> [!note]
>
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the [Qwen Code Companion](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) now.
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it.
## What Qwen Code does for you

View File

@@ -24,8 +24,6 @@ export default tseslint.config(
'.integration-tests/**',
'packages/**/.integration-test/**',
'dist/**',
'docs-site/.next/**',
'docs-site/out/**',
],
},
eslint.configs.recommended,

View File

@@ -80,11 +80,10 @@ type PermissionHandler = (
/**
* Sets up an ACP test environment with all necessary utilities.
* @param useNewFlag - If true, uses --acp; if false, uses --experimental-acp (for backward compatibility testing)
*/
function setupAcpTest(
rig: TestRig,
options?: { permissionHandler?: PermissionHandler; useNewFlag?: boolean },
options?: { permissionHandler?: PermissionHandler },
) {
const pending = new Map<number, PendingRequest>();
let nextRequestId = 1;
@@ -96,13 +95,9 @@ function setupAcpTest(
const permissionHandler =
options?.permissionHandler ?? (() => ({ optionId: 'proceed_once' }));
// Use --acp by default, but allow testing with --experimental-acp for backward compatibility
const acpFlag =
options?.useNewFlag !== false ? '--acp' : '--experimental-acp';
const agent = spawn(
'node',
[rig.bundlePath, acpFlag, '--no-chat-recording'],
[rig.bundlePath, '--experimental-acp', '--no-chat-recording'],
{
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -626,99 +621,3 @@ function setupAcpTest(
}
});
});
(IS_SANDBOX ? describe.skip : describe)(
'acp flag backward compatibility',
() => {
it('should work with deprecated --experimental-acp flag and show warning', async () => {
const rig = new TestRig();
rig.setup('acp backward compatibility');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
useNewFlag: false,
});
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// Verify deprecation warning is shown
const stderrOutput = stderr.join('');
expect(stderrOutput).toContain('--experimental-acp is deprecated');
expect(stderrOutput).toContain('Please use --acp instead');
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Verify functionality still works
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say hello.' }],
});
expect(promptResult).toBeDefined();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
it('should work with new --acp flag without warnings', async () => {
const rig = new TestRig();
rig.setup('acp new flag');
const { sendRequest, cleanup, stderr } = setupAcpTest(rig, {
useNewFlag: true,
});
try {
const initResult = await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
expect(initResult).toBeDefined();
// Verify no deprecation warning is shown
const stderrOutput = stderr.join('');
expect(stderrOutput).not.toContain('--experimental-acp is deprecated');
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: rig.testDir!,
mcpServers: [],
})) as { sessionId: string };
expect(newSession.sessionId).toBeTruthy();
// Verify functionality works
const promptResult = await sendRequest('session/prompt', {
sessionId: newSession.sessionId,
prompt: [{ type: 'text', text: 'Say hello.' }],
});
expect(promptResult).toBeDefined();
} catch (e) {
if (stderr.length) {
console.error('Agent stderr:', stderr.join(''));
}
throw e;
} finally {
await cleanup();
}
});
},
);

View File

@@ -5,6 +5,8 @@
*/
import { describe, it, expect } from 'vitest';
import { existsSync } from 'node:fs';
import * as path from 'node:path';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('file-system', () => {
@@ -200,8 +202,8 @@ describe('file-system', () => {
const readAttempt = toolLogs.find(
(log) => log.toolRequest.name === 'read_file',
);
const editAttempt = toolLogs.find(
(log) => log.toolRequest.name === 'edit_file',
const writeAttempt = toolLogs.find(
(log) => log.toolRequest.name === 'write_file',
);
const successfulReplace = toolLogs.find(
(log) => log.toolRequest.name === 'replace' && log.toolRequest.success,
@@ -224,15 +226,15 @@ describe('file-system', () => {
// CRITICAL: Verify that no matter what the model did, it never successfully
// wrote or replaced anything.
if (editAttempt) {
if (writeAttempt) {
console.error(
'A edit_file attempt was made when no file should be written.',
'A write_file attempt was made when no file should be written.',
);
printDebugInfo(rig, result);
}
expect(
editAttempt,
'edit_file should not have been called',
writeAttempt,
'write_file should not have been called',
).toBeUndefined();
if (successfulReplace) {
@@ -243,5 +245,12 @@ describe('file-system', () => {
successfulReplace,
'A successful replace should not have occurred',
).toBeUndefined();
// Final verification: ensure the file was not created.
const filePath = path.join(rig.testDir!, fileName);
const fileExists = existsSync(filePath);
expect(fileExists, 'The non-existent file should not be created').toBe(
false,
);
});
});

View File

@@ -952,8 +952,7 @@ describe('Permission Control (E2E)', () => {
TEST_TIMEOUT,
);
// FIXME: This test is flaky and sometimes fails with no tool calls.
it.skip(
it(
'should allow read-only tools without restrictions',
async () => {
// Create test files for the model to read

View File

@@ -314,88 +314,4 @@ describe('System Control (E2E)', () => {
);
});
});
describe('supportedCommands API', () => {
it('should return list of supported slash commands', async () => {
const sessionId = crypto.randomUUID();
const generator = (async function* () {
yield {
type: 'user',
session_id: sessionId,
message: { role: 'user', content: 'Hello' },
parent_tool_use_id: null,
} as SDKUserMessage;
})();
const q = query({
prompt: generator,
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
debug: false,
},
});
try {
const result = await q.supportedCommands();
// Start consuming messages to trigger initialization
const messageConsumer = (async () => {
try {
for await (const _message of q) {
// Just consume messages
}
} catch (error) {
// Ignore errors from query being closed
if (error instanceof Error && error.message !== 'Query is closed') {
throw error;
}
}
})();
// Verify result structure
expect(result).toBeDefined();
expect(result).toHaveProperty('commands');
expect(Array.isArray(result?.['commands'])).toBe(true);
const commands = result?.['commands'] as string[];
// Verify default allowed built-in commands are present
expect(commands).toContain('init');
expect(commands).toContain('summary');
expect(commands).toContain('compress');
// Verify commands are sorted
const sortedCommands = [...commands].sort();
expect(commands).toEqual(sortedCommands);
// Verify all commands are strings
commands.forEach((cmd) => {
expect(typeof cmd).toBe('string');
expect(cmd.length).toBeGreaterThan(0);
});
await q.close();
await messageConsumer;
} catch (error) {
await q.close();
throw error;
}
});
it('should throw error when supportedCommands is called on closed query', async () => {
const q = query({
prompt: 'Hello',
options: {
...SHARED_TEST_OPTIONS,
cwd: testDir,
model: 'qwen3-max',
},
});
await q.close();
await expect(q.supportedCommands()).rejects.toThrow('Query is closed');
});
});
});

10891
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.6.1",
"version": "0.6.0-preview.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.6.1"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.6.0-preview.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -73,7 +73,6 @@
"LICENSE"
],
"devDependencies": {
"@types/chrome": "^0.1.32",
"@types/marked": "^5.0.2",
"@types/mime-types": "^3.0.1",
"@types/minimatch": "^5.1.2",

View File

@@ -1,23 +0,0 @@
# Dependencies
node_modules/
# Build outputs
dist/
*.zip
# Logs
*.log
# OS files
.DS_Store
Thumbs.db
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Temporary files
*.tmp
.temp/

View File

@@ -1,157 +0,0 @@
# Quick Start Guide for Qwen CLI Chrome Extension
Get started quickly with the Qwen CLI Chrome Extension.
## Installation
1. **Prerequisites**: Make sure you have Node.js installed:
```bash
node --version
```
2. **Install the extension and native host**:
```bash
cd packages/chrome-extension
npm run install:all
```
## Running the Extension
1. **Start development mode**:
```bash
npm run dev
```
This will launch Chrome with the extension loaded and open DevTools.
2. **In Chrome**:
- Look for the Qwen CLI Chrome Extension icon in the toolbar
- Click the icon to open the popup interface
3. **Connect to Qwen CLI** (if installed):
- Click "Connect to Qwen CLI" in the extension popup
- Click "Start Qwen CLI" to launch the AI interface
## Basic Usage
- **Extract Page Content**: Click "Extract Page Data" to send the current page to Qwen
- **Take Screenshot**: Click "Capture Screenshot" to take and analyze a screenshot
- **Monitor Network**: Ask Qwen to "show me the network requests" to view recent network activity
- **View Console Logs**: Ask Qwen to "show me the console logs" to view browser console output
## Development
1. **Build the extension**:
```bash
npm run build
```
2. **Watch for changes during development**:
```bash
npm run build:ui:watch
```
3. **View native host logs**:
```bash
npm run logs
```
## Next Steps
- Check out the [Development Guide](docs/development.md) for more details on the architecture
- Read the [Debugging Guide](docs/debugging.md) if you encounter issues
- Learn about the [Architecture](docs/architecture.md) for deeper understanding
# Installation Guide for Qwen CLI Chrome Extension
This document describes how to install the Qwen CLI Chrome Extension.
## Prerequisites
1. **Node.js**: Install from [nodejs.org](https://nodejs.org/) (version 18 or higher)
2. **Qwen CLI**: Install the Qwen CLI tool (optional but recommended for full functionality)
3. **Chrome Browser**: Version 88 or higher
## Installation Steps
### Method 1: Full Installation (Recommended)
```bash
cd packages/chrome-extension
npm run install:all
```
This command will:
1. Guide you through Chrome extension installation
2. Automatically configure the Native Host
3. Save the Extension ID for future use
4. Start the debugging environment
### Method 2: Component Installation
You can install components separately:
```bash
# Install Chrome extension only
npm run install:extension
# Configure Native Host only
npm run install:host
```
### Method 3: Manual Installation
#### Chrome Extension Installation
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `packages/chrome-extension/dist/extension` folder (先运行 `npm run build`)
5. Note the Extension ID that appears (you'll need this for the next step)
#### Native Host Installation
The Native Messaging Host allows the Chrome extension to communicate with Qwen CLI.
For macOS/Linux:
```bash
cd packages/chrome-extension/native-host
./scripts/smart-install.sh
```
When prompted, enter your Chrome Extension ID.
For Windows:
1. Run Command Prompt as Administrator
2. Navigate to the `packages/chrome-extension/native-host` directory
3. Run the installation script: `install.bat`
4. Enter your Chrome Extension ID when prompted
## Verification
To verify the installation:
1. Run the development environment:
```bash
npm run dev
```
2. You should see Chrome launch with the extension installed and DevTools open.
3. Check that the extension appears in the Chrome toolbar.
## Updates
To update the host configuration (if you get a new extension ID):
```bash
npm run update:host
```

View File

@@ -1,206 +0,0 @@
# Qwen CLI Chrome Extension - Chrome Extension
A Chrome extension that bridges your browser with Qwen CLI, enabling AI-powered analysis and interaction with web content.
> This package is part of the [Qwen Code](https://github.com/QwenLM/qwen-code) mono repository.
## Features
- **Page Data Extraction**: Extract structured data from any webpage including text, links, images, and metadata
- **Screenshot Capture**: Capture and analyze screenshots with AI
- **Console & Network Monitoring**: Monitor console logs and network requests
- **Selected Text Processing**: Send selected text to Qwen CLI for processing
- **AI Analysis**: Leverage Qwen's AI capabilities to analyze web content
- **MCP Server Integration**: Support for multiple MCP (Model Context Protocol) servers
## Architecture
```
┌─────────────────────┐
│ Chrome Extension │
│ - Content Script │
│ - Background Worker│
│ - Popup UI │
└──────────┬──────────┘
Native Messaging
┌──────▼──────────┐
│ Native Host │
│ (Node.js) │
└──────┬──────────┘
┌──────▼──────────┐
│ Qwen CLI │
│ + MCP Servers │
└─────────────────┘
```
## Installation
### Prerequisites
1. **Node.js**: Install from [nodejs.org](https://nodejs.org/)
2. **Qwen CLI**: Install the Qwen CLI tool (required for full functionality)
3. **Chrome Browser**: Version 88 or higher
### Step 1: Install the Chrome Extension
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable "Developer mode" (toggle in top right)
3. Click "Load unpacked"
4. Select the `chrome-extension/dist/extension` folder (运行 `npm run build` 后生成)
5. Note the Extension ID that appears (you'll need this for the next step)
### Step 2: Install the Native Messaging Host
The Native Messaging Host allows the Chrome extension to communicate with Qwen CLI.
#### macOS/Linux
```bash
cd chrome-extension/native-host
./scripts/smart-install.sh
```
When prompted, enter your Chrome Extension ID.
#### Windows
1. Run Command Prompt as Administrator
2. Navigate to the `native-host` directory:
```cmd
cd chrome-extension\native-host
```
3. Run the installation script:
```cmd
install.bat
```
4. Enter your Chrome Extension ID when prompted
### Step 3: Configure Qwen CLI (Optional)
If you want to use MCP servers with the extension:
```bash
# Add chrome-devtools MCP server
qwen mcp add chrome-devtools
# Add other MCP servers as needed
qwen mcp add playwright-mcp
```
## Usage
### Basic Usage
1. Click the Qwen CLI Chrome Extension extension icon in Chrome
2. Click "Connect to Qwen CLI" to establish connection
3. Click "Start Qwen CLI" to launch the CLI process
4. Use the action buttons to:
- Extract and analyze page data
- Capture screenshots
- Send selected text to Qwen
- Monitor console and network logs
### Advanced Settings
In the popup's "Advanced Settings" section, you can configure:
- **MCP Servers**: Comma-separated list of MCP servers to load
- **HTTP Port**: Port for Qwen CLI HTTP server (default: 8080)
- **Auto-connect**: Automatically connect when opening the popup
### API Actions
The extension supports the following actions that can be sent to Qwen CLI:
- `analyze_page`: Analyze extracted page data
- `analyze_screenshot`: Analyze captured screenshot
- `ai_analyze`: Perform AI analysis on content
- `process_text`: Process selected text
- Custom actions based on your MCP server configurations
## Development
### Project Structure
```
chrome-extension/
├── public/ # 静态资源manifest、icons、sidepanel.html 模板)
├── src/ # 前端代码
│ ├── background/ # Service worker 源码
│ ├── content/ # Content script 源码
│ └── sidepanel/ # React Side Panel 源码
├── dist/extension/ # 构建输出,加载 unpacked 时使用
├── native-host/ # Native messaging host
│ ├── host.js # Node.js host script
│ ├── manifest.json # Native host manifest
│ └── install scripts # Platform-specific installers
└── docs/ # Documentation
```
### Building from Source
1. Clone the repository
2. Run `npm run build`(输出到 `dist/extension`,适合加载/打包)
- 开发模式可用 `npm run dev`watch 同步 + esbuild输出同样在 `dist/extension`
3. 在 Chrome 中加载 unpacked选择 `packages/chrome-extension/dist/extension`
### Testing
1. Enable Chrome Developer Tools
2. Check the extension's background page console for logs
3. Native host logs are written to:
- macOS/Linux: `$HOME/.qwen/chrome-bridge/qwen-bridge-host.log`(若主目录不可写则回落 `/tmp/qwen-bridge-host.log`
- Windows: `%USERPROFILE%\.qwen\chrome-bridge\qwen-bridge-host.log`(若不可写则回落 `%TEMP%\qwen-bridge-host.log`
## Troubleshooting
### Extension not connecting to Native Host
1. Verify Node.js is installed: `node --version`
2. Check that the Native Host is properly installed
3. Ensure the Extension ID in the manifest matches your actual extension
4. Check logs for errors
### Qwen CLI not starting
1. Verify Qwen CLI is installed: `qwen --version`
2. Check that Qwen CLI can run normally from terminal
3. Review Native Host logs for error messages
### No response from Qwen CLI
1. Ensure Qwen CLI server is running
2. Check the configured HTTP port is not in use
3. Verify MCP servers are properly configured
## Security Considerations
- The extension requires broad permissions to function properly
- Native Messaging Host runs with user privileges
- All communication between components uses structured JSON messages
- No sensitive data is stored; all processing is ephemeral
## Contributing
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
## License
MIT License - See LICENSE file for details
## Support
For issues, questions, or feature requests:
- Open an issue on GitHub
- Check the logs for debugging information
- Ensure all prerequisites are properly installed

View File

@@ -1,109 +0,0 @@
/**
* @file esbuild configuration for Chrome Extension Side Panel React App
* Bundles React components with Tailwind CSS
* @type {import('esbuild').BuildOptions}
*/
/* global process, console */
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
import postcss from 'postcss';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
const isWatch = process.argv.includes('--watch');
const isProduction = process.argv.includes('--production');
const outDir = process.env.EXTENSION_OUT_DIR || 'extension';
/**
* Custom CSS plugin that processes CSS through PostCSS/Tailwind
* and injects it as inline JavaScript
*/
const cssInjectPlugin = {
name: 'css-inject',
setup(build) {
build.onLoad({ filter: /\.css$/ }, async (args) => {
const cssPath = args.path;
let cssContent = await fs.promises.readFile(cssPath, 'utf8');
// Handle @import statements
const importRegex = /@import\s+['"]([^'"]+)['"]\s*;/g;
let match;
while ((match = importRegex.exec(cssContent)) !== null) {
const importPath = path.resolve(path.dirname(cssPath), match[1]);
if (fs.existsSync(importPath)) {
const importedContent = await fs.promises.readFile(
importPath,
'utf8',
);
cssContent = cssContent.replace(match[0], importedContent);
}
}
// Process with PostCSS and Tailwind
const result = await postcss([
tailwindcss({
config: path.resolve(process.cwd(), 'config/tailwind.config.js'),
}),
autoprefixer,
]).process(cssContent, {
from: cssPath,
});
// Convert to JavaScript that injects CSS
const minifiedCss = isProduction
? result.css.replace(/\s+/g, ' ').trim()
: result.css;
const jsContent = `
(function() {
const style = document.createElement('style');
style.textContent = ${JSON.stringify(minifiedCss)};
document.head.appendChild(style);
})();
`;
return {
contents: jsContent,
loader: 'js',
};
});
},
};
async function build() {
const ctx = await esbuild.context({
entryPoints: ['src/sidepanel/index.tsx'],
bundle: true,
format: 'iife',
minify: isProduction,
sourcemap: !isProduction,
platform: 'browser',
outfile: path.join(outDir, 'sidepanel/dist/sidepanel-app.js'),
jsx: 'automatic',
define: {
'process.env.NODE_ENV': isProduction ? '"production"' : '"development"',
},
plugins: [cssInjectPlugin],
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
},
});
if (isWatch) {
console.log('Watching for changes...');
await ctx.watch();
} else {
await ctx.rebuild();
await ctx.dispose();
console.log('Build complete!');
}
}
build().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,53 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/sidepanel/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
qwen: {
orange: '#615fff',
},
'clay-orange': '#4f46e5',
ivory: '#f5f5ff',
slate: '#141420',
green: '#6bcf7f',
success: '#74c991',
error: '#c74e39',
warning: '#e1c08d',
loading: 'var(--app-secondary-foreground)',
},
spacing: {
small: '4px',
medium: '8px',
large: '12px',
xlarge: '16px',
},
borderRadius: {
small: '4px',
medium: '6px',
large: '8px',
},
animation: {
'completion-menu-enter': 'completion-menu-enter 0.15s ease-out',
'pulse-slow': 'pulse 1.5s infinite',
'slide-up': 'slideUp 0.3s ease-out',
fadeIn: 'fadeIn 0.2s ease-in',
},
keyframes: {
'completion-menu-enter': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideUp: {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
},
},
plugins: [],
};

View File

@@ -1,253 +0,0 @@
# API Reference for Qwen CLI Chrome Extension
This document provides reference for the APIs and message formats used in the Qwen CLI Chrome Extension.
## Extension to Native Host Messages
The extension communicates with the native host using the following message formats:
### Handshake
```
Request:
{
"type": "handshake",
"version": "1.0.0"
}
Response:
{
"type": "handshake_response",
"version": "1.0.0",
"qwenInstalled": boolean,
"qwenVersion": string,
"qwenStatus": "disconnected"|"connected"|"running"
}
```
### Start Qwen CLI
```
Request:
{
"type": "start_qwen",
"cwd": string,
"config": object (optional)
}
Response:
{
"success": boolean,
"data": object,
"error": string (if success is false)
}
```
### Send Prompt to Qwen CLI
```
Request:
{
"type": "qwen_prompt",
"text": string
}
Response:
{
"success": boolean,
"data": object,
"error": string (if success is false)
}
```
### Extract Page Data
```
Request:
{
"type": "EXTRACT_DATA"
}
Response:
{
"success": boolean,
"data": {
"url": string,
"title": string,
"content": {
"text": string,
"html": string,
"markdown": string
},
"links": array,
"images": array,
"forms": array
},
"error": string (if success is false)
}
```
## Browser MCP Tools
The extension provides the following MCP tools to Qwen CLI:
### browser_read_page
```
Description: Read the content of the current browser page
Input: {}
Output: {
"url": string,
"title": string,
"content": string,
"linksCount": number,
"imagesCount": number
}
```
### browser_capture_screenshot
```
Description: Capture a screenshot of the current browser tab
Input: {}
Output: {
"data": string (base64 encoded PNG),
"mimeType": "image/png"
}
```
### browser_get_network_logs
```
Description: Get network request logs from the current browser tab
Input: {}
Output: {
"text": string (JSON formatted network logs)
}
```
### browser_get_console_logs
```
Description: Get console logs from the current browser tab
Input: {}
Output: {
"text": string (formatted console logs)
}
```
## Internal Extension Messages
The extension components communicate internally using these message formats:
### Get Status
```
Request:
{
"type": "GET_STATUS"
}
Response:
{
"connected": boolean,
"status": string,
"availableCommands": array,
"mcpTools": array,
"internalTools": array
}
```
### Send Message
```
Request:
{
"type": "sendMessage",
"data": {
"text": string,
"cwd": string (optional)
}
}
Response:
{
"success": boolean,
"error": string (if success is false)
}
```
### Get Network Logs
```
Request:
{
"type": "GET_NETWORK_LOGS"
}
Response:
{
"success": boolean,
"data": array,
"error": string (if success is false)
}
```
## Event Types
The extension broadcasts various events:
### Status Update
```
{
"type": "STATUS_UPDATE",
"status": string
}
```
### Stream Start/End
```
{
"type": "streamStart"
}
```
or
```
{
"type": "streamEnd"
}
```
### Tool Progress
```
{
"type": "toolProgress",
"data": {
"name": string,
"stage": "start"|"end",
"ok": boolean,
"error": string (if applicable)
}
}
```
## Network Log Format
Network logs returned by the browser_get_network_logs tool have the following structure:
```
{
"method": string (e.g., "Network.requestWillBeSent"),
"params": {
"requestId": string,
"request": {
"url": string,
"method": string,
"headers": object
},
"response": {
"status": number,
"statusText": string,
"headers": object
},
"timestamp": number
}
}
```
## Error Handling
All API responses include error handling:
- Success responses include a `success: true` field and result data
- Error responses include a `success: false` field and an `error` string
- The native host logs detailed error information for debugging

View File

@@ -1,146 +0,0 @@
# Architecture Overview for Qwen CLI Chrome Extension
This document describes the architecture of the Qwen CLI Chrome Extension.
## Overview
The Qwen CLI Chrome Extension connects your browser with the Qwen CLI, enabling AI-powered analysis and interaction with web content. It uses the Chrome Native Messaging API to securely communicate with the native host process.
## System Architecture
```
┌─────────────────────┐
│ Chrome Browser │
│ ┌─────────────────┐│
│ │ Extension UI ││ ← Popup/Side panel interface
│ └─────────────────┘│
│ ┌─────────────────┐│
│ │ Content Script ││ ← Page content extraction
│ └─────────────────┘│
│ ┌─────────────────┐│
│ │ Background ││ ← Service worker handling
│ │ (Service Worker)││ messaging and logic
│ └─────────────────┘│
└──────────┬──────────┘
Native Messaging
┌──────▼──────────┐
│ Native Host │
│ (Node.js) │ ← Bridge between extension
└──────┬──────────┘ and Qwen CLI
┌──────▼──────────┐
│ Qwen CLI │
│ + MCP Servers │ ← AI processing and tools
└─────────────────┘
```
## Components
### 1. Extension UI (Popup/Side Panel)
The user interface of the extension provides:
- Connection management to Qwen CLI
- Action buttons for various features
- Status information
- Settings and configuration
### 2. Content Script
The content script runs on web pages and provides:
- Page content extraction
- Console log capture
- Element selection and highlighting
- Text selection utilities
- Direct DOM interaction
### 3. Background Script (Service Worker)
The background service worker handles:
- Communication with the native host
- Message routing between components
- Browser API interactions
- Network monitoring (via debugger API)
- State management
### 4. Native Host (Node.js)
The native host acts as a bridge between the extension and Qwen CLI:
- Implements the Native Messaging protocol
- Communicates with Qwen CLI using ACP (Agent Communication Protocol)
- Handles file system operations
- Manages MCP (Model Context Protocol) servers
- Provides browser-specific tools via HTTP bridge
### 5. Qwen CLI
The main AI processing component:
- Runs AI models and processes requests
- Manages MCP servers
- Provides tool access (shell commands, file operations, etc.)
## Security Architecture
The extension follows Chrome's security model:
1. **Native Messaging Security**: Communication between extension and native host is restricted by manifest permissions
2. **Content Security Policy**: Prevents XSS attacks and injection
3. **Sandboxed Execution**: Native host runs with user privileges, not elevated permissions
4. **Origin Restrictions**: Communication is limited to allowed origins
## Data Flow
### Page Analysis Request
1. User initiates "Analyze Page" from extension UI
2. Background script sends message to content script
3. Content script extracts page data (text, links, images, etc.)
4. Data is sent back to background script
5. Background script sends data to native host
6. Native host forwards to Qwen CLI
7. Qwen CLI processes and responds with AI analysis
8. Response flows back to extension UI
### Network Monitoring
1. Background script uses Chrome Debugger API to monitor network requests
2. Network events are captured and stored per tab
3. When requested, network logs are provided to Qwen CLI via native host
4. This allows AI to analyze API calls and network activity
## Communication Protocols
### Native Messaging Protocol
JSON-based messages exchanged between extension and native host:
```json
{
"type": "message_type",
"id": "request_id",
"data": { ... }
}
```
### ACP (Agent Communication Protocol)
Used between native host and Qwen CLI:
- JSON-RPC over stdio
- Content-Length framed messages
- Request/response with error handling
## Extension Permissions
The extension requires specific permissions for full functionality:
- `activeTab`: Access to current tab for content extraction
- `tabs`: Tab management and information
- `storage`: Local storage for settings and state
- `nativeMessaging`: Communication with native host
- `debugger`: Network request monitoring
- `webNavigation`: Navigation event monitoring
- `scripting`: Content script injection
- `cookies`: Cookie access for web automation
- `webRequest`: Network request monitoring
- `sidePanel`: Side panel UI support
- `host_permissions`: Access to all URLs

View File

@@ -1,103 +0,0 @@
# Debugging Guide for Qwen CLI Chrome Extension
This document outlines the debugging process for the Qwen CLI Chrome Extension.
## Debugging Setup
The extension provides several debugging options to help troubleshoot issues.
### Development Mode
To start the extension in development mode with debugging enabled:
```bash
npm run dev
```
This will:
- Launch Chrome with the extension loaded
- Open DevTools automatically
- You can open any target page manually for debugging
### Native Host Logging
The native host logs are stored at:
- **macOS/Linux**: `$HOME/.qwen/chrome-bridge/qwen-bridge-host.log`(若主目录不可写则回落 `/tmp/qwen-bridge-host.log`
- **Windows**: `%USERPROFILE%\.qwen\chrome-bridge\qwen-bridge-host.log`(若不可写则回落 `%TEMP%\qwen-bridge-host.log`
To monitor the logs in real-time:
```bash
npm run logs
```
Or directly:
```bash
tail -f "$HOME/.qwen/chrome-bridge/qwen-bridge-host.log"
```
### Chrome Extension Debugging
1. Open Chrome Extensions page (`chrome://extensions/`)
2. Enable "Developer mode"
3. Find the Qwen CLI Chrome Extension extension
4. Click "Inspect views" on the service worker to open DevTools for background scripts
5. Use the popup/panel's DevTools for UI debugging
## Common Debugging Scenarios
### Connection Issues
If the extension can't connect to the native host:
1. Verify Node.js is installed: `node --version`
2. Check the native host installation: `./native-host/scripts/smart-install.sh`
3. Check logs: `$HOME/.qwen/chrome-bridge/qwen-bridge-host.log`
4. Verify extension ID matches the one in native host manifest
### Qwen CLI Communication Issues
If the extension can't communicate with Qwen CLI:
1. Verify Qwen CLI is installed: `qwen --version`
2. Check that Qwen CLI is running when the extension tries to connect
3. Check the extension's console logs for error messages
4. Verify the MCP server configuration
### Content Script Issues
If content scripts aren't working properly:
1. Check the content script logs in the page's DevTools console
2. Verify the content script is properly injected
3. Check for CSP restrictions on the target page
## Debugging Scripts
The following scripts are available for debugging:
- `npm run dev`: Full development environment with Chrome auto-launch
- `npm run logs`: Tail the native host log file
- `npm run clean`: Clean all build artifacts and logs
- `npm run dev:chrome`: Start Chrome with extension loaded and DevTools open
## Troubleshooting Tips
### Check Extension Status
Check the extension's status in the extension popup or through the API.
### Verify Permissions
Ensure all required permissions are granted in the extension settings.
### Network Requests
Monitor network requests to ensure proper communication between components.
### Console Messages
Watch console messages in both the extension's background script and content scripts.

View File

@@ -1,69 +0,0 @@
# Development Guide for Qwen CLI Chrome Extension
This document outlines the development process for the Qwen CLI Chrome Extension.
## Directory Structure
```
packages/chrome-extension/
├── src/ # Source code
│ ├── background/ # Background script source
│ ├── content/ # Content script source
│ ├── sidepanel/ # Side panel React components
│ ├── common/ # Shared utilities
│ └── types/ # TypeScript definitions
├── extension/ # Build output (production-ready extension)
│ ├── background/
│ ├── content/
│ ├── popup/
│ ├── sidepanel/
│ ├── icons/
│ └── manifest.json
├── native-host/ # Native messaging host
│ ├── src/ # Source files
│ ├── dist/ # Built files
│ ├── scripts/ # Installation scripts
│ └── config/ # Configuration templates
├── docs/ # Documentation
├── scripts/ # Build and development scripts
├── test/ # Test files
├── config/ # Configuration files
├── README.md
├── DEVELOPMENT.md # This file
├── DEBUGGING.md
├── INSTALL.md
├── QUICK_START.md
└── package.json
```
## Development Setup
1. Install dependencies:
```bash
npm install
```
2. Start development server:
```bash
npm run dev
```
## Building
To build the extension:
```bash
npm run build
```
This will compile the source files and output the production-ready extension to the `extension/` directory.
## Testing
Unit tests are located in the `test/unit/` directory.
Integration tests are located in the `test/integration/` directory.
End-to-end tests are located in the `test/e2e/` directory.
Run all tests:
```bash
npm run test
```

View File

@@ -1,29 +0,0 @@
# 浏览器 MCP 能力与写操作现状
## 现有 MCP 工具
`src/browser-mcp-server.js` 暴露(工具列表在 `src/shared/tools.js`
- `browser_read_page`:读取当前标签页(需扩展提供页面内容)。
- `browser_capture_screenshot`:当前标签页截图(扩展 `chrome.tabs.captureVisibleTab`)。
- `browser_get_network_logs`:当前标签网络请求日志(扩展 webRequest + debugger
- `browser_get_console_logs`:当前标签 console 日志content-script 拦截)。
- `browser_fill_form`:按 selector/label 批量填充输入。
- `browser_input_text`:按 selector 填充单个输入。
所有写操作fill/click依赖扩展的 content-script 注入,当前支持:
- 填充:`FILL_INPUT`/`FILL_INPUTS`,覆盖 input/textarea/contentEditable支持 label 解析、append/replace、事件触发。
- 点击:`CLICK_ELEMENT`,按 CSS selector 触发 pointerdown/mousedown/mouseup/click。
- 执行 JS`EXECUTE_CODE`(任意表达式注入页面上下文,有限时)。
## 流程
- MCP → host → HTTP `/api` → background → content-script事件用 `/events` SSE 推送(原长轮询已改为 SSE
- MCP 工具调用fill_form/input_text/click/run_js/fill_form_auto通过 HTTP 方法触发上述 content-script 路径。
## 待完善方向
- 追加工具映射:将 `CLICK_ELEMENT` / `EXECUTE_CODE` 透出为 MCP 工具(例如 `browser_click`, `browser_run_js`),目前未暴露。
- 增加更丰富的写操作:选择下拉、多步点击、上传文件等。
- 更好的错误与状态回传:目前填充返回简单 success/message可扩展为详细目标描述。

View File

@@ -1,50 +0,0 @@
# Qwen Chrome Bridge Native Host (MCP-ready)
Bridge service + browser MCP server for the Qwen Chrome Extension. Provides an HTTP bridge on `127.0.0.1:18765` and the `chrome-browser` MCP tools (`read_page`, `capture_screenshot`, `get_network_logs`, `get_console_logs`, `fill_form`, `input_text`). The MCP entry auto-starts the bridge host; no manual `node host.js` needed.
## Requirements
- Node.js 18+ recommended (14+ minimum per engines)
- Qwen Chrome Extension installed/loaded
## Install & add to Qwen as MCP
From repo root:
```bash
cd packages/chrome-extension/native-host
npm install -g . # or: npm pack && npm install -g qwen-cli-bridge-host-*.tgz
# 按照 Qwen CLI 文档(基于 settings.json 的 mcpServers
# stdio 传输,命令为本包的可执行文件
qwen mcp add --transport stdio chrome-browser "chrome-browser-mcp"
# 验证
qwen mcp list
```
- `chrome-browser-mcp` (别名 `browser-bridge-mcp`) 是本包提供的可执行文件stdio MCP。被加载时会健康检查并拉起 `host.js`host 提供 `/api``/events` 给扩展访问。
## 后台常驻(可选)
如果希望在添加 MCP 后立即有 18765 端口、无需等待 Qwen 首次加载 MCP可直接将 host 跑成后台:
```bash
cd packages/chrome-extension/native-host
./scripts/run-daemon.sh # 启动 host.js监听 127.0.0.1:18765日志写 ~/.qwen/chrome-bridge/qwen-bridge-host.log
# 停止kill $(cat ~/.qwen/chrome-bridge/qwen-bridge-host.pid)
```
## Manual run (optional)
- Run only the HTTP bridge host: `npm run start` (listens on `127.0.0.1:18765`).
- Run only the MCP server (will spawn host if needed): `npm run mcp`.
## Logs
Host log: `~/.qwen/chrome-bridge/qwen-bridge-host.log` (fallback `/tmp/qwen-bridge-host.log`).
## Notes
- The bridge assumes the Chrome extension is loaded and allowed to fetch `http://127.0.0.1:18765/*`.
- If you change the port, set `BRIDGE_BASE`/`BRIDGE_URL` env vars before launching `chrome-browser-mcp`.

View File

@@ -1,7 +0,0 @@
#!/usr/local/bin/node
/**
* Native host entry point
* Delegates to the single source of truth in src/host.js
*/
require('./src/host.js');

View File

@@ -1,37 +0,0 @@
{
"name": "qwen-cli-bridge-host",
"version": "1.0.0",
"description": "Bridge service + MCP server for the Qwen Chrome Extension (HTTP bridge, browser MCP tools)",
"main": "host.js",
"bin": {
"browser-bridge-mcp": "src/browser-mcp-server.js",
"chrome-browser-mcp": "src/browser-mcp-server.js",
"qwen-bridge-host": "host.js"
},
"scripts": {
"start": "node host.js",
"mcp": "node src/browser-mcp-server.js",
"test": "node host.js --test"
},
"keywords": [
"chrome-extension",
"native-messaging",
"qwen",
"cli",
"bridge",
"mcp"
],
"author": "",
"license": "MIT",
"files": [
"host.js",
"src/",
"scripts/",
"README.md"
],
"engines": {
"node": ">=14.0.0"
},
"dependencies": {},
"devDependencies": {}
}

View File

@@ -1,2 +0,0 @@
@echo off
node "%~dp0..\\host.js" %*

View File

@@ -1,99 +0,0 @@
@echo off
setlocal enabledelayedexpansion
REM Qwen CLI Chrome Extension - Native Host Installation Script for Windows
REM This script installs the Native Messaging host for the Chrome extension
echo ========================================
echo Qwen CLI Chrome Extension - Native Host Installer
echo ========================================
echo.
REM Set variables
set HOST_NAME=com.qwen.cli.bridge
set SCRIPT_DIR=%~dp0
set HOST_SCRIPT=%SCRIPT_DIR%host.bat
set HOST_JS=%SCRIPT_DIR%..\host.js
REM Check if Node.js is installed
where node >nul 2>nul
if %ERRORLEVEL% NEQ 0 (
echo Error: Node.js is not installed
echo Please install Node.js from https://nodejs.org/
pause
exit /b 1
)
REM Check if qwen CLI is installed
where qwen >nul 2>nul
if %ERRORLEVEL% NEQ 0 (
echo Warning: qwen CLI is not installed
echo Please install qwen CLI to use all features
echo Installation will continue...
echo.
)
REM Check if host files exist
if not exist "%HOST_SCRIPT%" (
echo Error: host.bat not found in %SCRIPT_DIR%
pause
exit /b 1
)
if not exist "%HOST_JS%" (
echo Error: host.js not found at %HOST_JS%
pause
exit /b 1
)
REM Get extension ID
set /p EXTENSION_ID="Enter your Chrome extension ID (found in chrome://extensions): "
if "%EXTENSION_ID%"=="" (
echo Error: Extension ID is required
pause
exit /b 1
)
REM Create manifest
set MANIFEST_FILE=%SCRIPT_DIR%manifest-windows.json
echo Creating manifest: %MANIFEST_FILE%
(
echo {
echo "name": "%HOST_NAME%",
echo "description": "Native messaging host for Qwen CLI Chrome Extension",
echo "path": "%HOST_SCRIPT:\=\\%",
echo "type": "stdio",
echo "allowed_origins": [
echo "chrome-extension://%EXTENSION_ID%/"
echo ]
echo }
) > "%MANIFEST_FILE%"
REM Add registry entry for Chrome
echo.
echo Adding registry entry for Chrome...
reg add "HKCU\Software\Google\Chrome\NativeMessagingHosts\%HOST_NAME%" /ve /t REG_SZ /d "%MANIFEST_FILE%" /f
if %ERRORLEVEL% EQU 0 (
echo.
echo ✅ Installation complete!
echo.
echo Next steps:
echo 1. Load the Chrome extension in chrome://extensions
echo 2. Enable 'Developer mode'
echo 3. Click 'Load unpacked' and select: %SCRIPT_DIR%..\dist\extension (run "npm run build" first)
echo 4. Copy the extension ID and re-run this script if needed
echo 5. Click the extension icon and connect to Qwen CLI
echo.
echo Host manifest: %MANIFEST_FILE%
echo Log file location: %%USERPROFILE%%\.qwen\chrome-bridge\qwen-bridge-host.log (fallback: %%TEMP%%\qwen-bridge-host.log)
) else (
echo.
echo ❌ Failed to add registry entry
echo Please run this script as Administrator
)
echo.
pause

View File

@@ -1,22 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Simple daemon runner for qwen-bridge-host (HTTP 127.0.0.1:18765)
# Usage: ./scripts/run-daemon.sh
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_DIR="${HOME}/.qwen/chrome-bridge"
LOG_FILE="${LOG_DIR}/qwen-bridge-host.log"
mkdir -p "$LOG_DIR"
NODE_BIN="${NODE_BIN:-node}"
HOST_ENTRY="${HOST_ENTRY:-$ROOT_DIR/host.js}"
echo "Starting qwen-bridge-host via $NODE_BIN $HOST_ENTRY"
echo "Logs: $LOG_FILE"
QWEN_BRIDGE_NO_STDIO_EXIT=1 QWEN_BRIDGE_NO_STDIO_STAYALIVE=1 nohup "$NODE_BIN" "$HOST_ENTRY" >> "$LOG_FILE" 2>&1 &
echo $! > "$LOG_DIR/qwen-bridge-host.pid"
echo "Started with PID $(cat "$LOG_DIR/qwen-bridge-host.pid")"

View File

@@ -1,321 +0,0 @@
#!/bin/bash
# Qwen CLI Chrome Extension - 智能 Native Host 安装器
# 自动检测 Chrome 插件并配置 Native Host
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
HOST_NAME="com.qwen.cli.bridge"
HOST_SCRIPT="$SCRIPT_DIR/../host.js"
EXTENSION_ID_FILE="$ROOT_DIR/.extension-id"
echo -e "${CYAN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ ║${NC}"
echo -e "${CYAN}║ 🔧 Qwen CLI Chrome Extension - Native Host 安装器 ║${NC}"
echo -e "${CYAN}║ ║${NC}"
echo -e "${CYAN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
# 检测操作系统
if [[ "$OSTYPE" == "darwin"* ]]; then
OS="macOS"
MANIFEST_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
EXTENSIONS_DIR="$HOME/Library/Application Support/Google/Chrome/Default/Extensions"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
OS="Linux"
MANIFEST_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
EXTENSIONS_DIR="$HOME/.config/google-chrome/Default/Extensions"
else
echo -e "${RED}✗ 不支持的操作系统${NC}"
exit 1
fi
echo -e "${BLUE}检测到系统:${NC} $OS"
echo ""
# 检查 Node.js
echo -e "${BLUE}检查依赖...${NC}"
if ! command -v node &> /dev/null; then
echo -e "${RED}✗ Node.js 未安装${NC}"
echo -e " 请访问 https://nodejs.org 安装 Node.js"
exit 1
fi
echo -e "${GREEN}${NC} Node.js $(node --version)"
# 尝试自动检测扩展 ID
echo -e "\n${BLUE}查找已安装的 Qwen CLI Chrome Extension 扩展...${NC}"
EXTENSION_ID=""
AUTO_DETECTED=false
# 方法1: 从 Chrome 扩展目录查找
if [[ -d "$EXTENSIONS_DIR" ]]; then
for ext_id in "$EXTENSIONS_DIR"/*; do
if [[ -d "$ext_id" ]]; then
ext_id_name=$(basename "$ext_id")
# 检查最新版本目录
for version_dir in "$ext_id"/*; do
if [[ -f "$version_dir/manifest.json" ]]; then
# 检查是否是我们的扩展
if grep -q "Qwen CLI Chrome Extension" "$version_dir/manifest.json" 2>/dev/null; then
EXTENSION_ID="$ext_id_name"
AUTO_DETECTED=true
echo -e "${GREEN}${NC} 自动检测到扩展 ID: ${CYAN}$EXTENSION_ID${NC}"
break 2
fi
fi
done
fi
done
fi
# 方法2: 检查之前保存的 ID统一使用根目录的 .extension-id兼容旧路径
if [[ -z "$EXTENSION_ID" ]]; then
if [[ -f "$EXTENSION_ID_FILE" ]]; then
EXTENSION_ID=$(cat "$EXTENSION_ID_FILE")
echo -e "${GREEN}${NC} 使用保存的扩展 ID: ${CYAN}$EXTENSION_ID${NC}"
AUTO_DETECTED=true
else
for legacy in "$SCRIPT_DIR/../.extension-id" "$SCRIPT_DIR/../../scripts/.extension-id"; do
if [[ -z "$EXTENSION_ID" && -f "$legacy" ]]; then
EXTENSION_ID=$(cat "$legacy")
echo "$EXTENSION_ID" > "$EXTENSION_ID_FILE"
echo -e "${GREEN}${NC} 已从旧路径迁移扩展 ID: ${CYAN}$EXTENSION_ID${NC}"
AUTO_DETECTED=true
break
fi
done
fi
fi
# 如果自动检测失败,提供选项
if [[ -z "$EXTENSION_ID" ]]; then
echo -e "${YELLOW}⚠️ 未能自动检测到扩展${NC}"
echo ""
echo -e "请选择:"
echo -e " ${CYAN}1)${NC} 我已经安装了扩展(输入扩展 ID"
echo -e " ${CYAN}2)${NC} 我还没有安装扩展(通用配置)"
echo -e " ${CYAN}3)${NC} 打开 Chrome 扩展页面查看"
echo ""
read -p "选择 (1/2/3): " CHOICE
case $CHOICE in
1)
echo ""
echo -e "${YELLOW}请输入扩展 ID:${NC}"
echo -e "${CYAN}提示: 在 chrome://extensions 页面找到 Qwen CLI Chrome ExtensionID 在扩展卡片上${NC}"
read -p "> " EXTENSION_ID
if [[ -n "$EXTENSION_ID" ]]; then
# 保存 ID 供以后使用
echo "$EXTENSION_ID" > "$EXTENSION_ID_FILE"
echo -e "${GREEN}${NC} 扩展 ID 已保存"
fi
;;
2)
echo -e "\n${CYAN}将使用通用配置(允许所有开发扩展)${NC}"
EXTENSION_ID="*"
;;
3)
echo -e "\n${CYAN}正在打开 Chrome 扩展页面...${NC}"
open "chrome://extensions" 2>/dev/null || xdg-open "chrome://extensions" 2>/dev/null || echo "请手动打开 chrome://extensions"
echo ""
echo -e "${YELLOW}找到 Qwen CLI Chrome Extension 扩展后,输入其 ID:${NC}"
read -p "> " EXTENSION_ID
if [[ -n "$EXTENSION_ID" && "$EXTENSION_ID" != "*" ]]; then
echo "$EXTENSION_ID" > "$EXTENSION_ID_FILE"
fi
;;
*)
echo -e "${RED}无效的选择${NC}"
exit 1
;;
esac
fi
# 创建 Native Host 目录
echo -e "\n${BLUE}配置 Native Host...${NC}"
mkdir -p "$MANIFEST_DIR"
# 创建 manifest 文件
MANIFEST_FILE="$MANIFEST_DIR/$HOST_NAME.json"
if [[ "$EXTENSION_ID" == "*" ]]; then
# 通用配置
cat > "$MANIFEST_FILE" << EOF
{
"name": "$HOST_NAME",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$HOST_SCRIPT",
"type": "stdio",
"allowed_origins": [
"chrome-extension://*/"
]
}
EOF
echo -e "${GREEN}${NC} Native Host 已配置(通用模式)"
else
# 特定扩展 ID 配置
cat > "$MANIFEST_FILE" << EOF
{
"name": "$HOST_NAME",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$HOST_SCRIPT",
"type": "stdio",
"allowed_origins": [
"chrome-extension://$EXTENSION_ID/",
"chrome-extension://*/"
]
}
EOF
echo -e "${GREEN}${NC} Native Host 已配置(扩展 ID: $EXTENSION_ID"
fi
# 验证配置
echo -e "\n${BLUE}验证配置...${NC}"
# 检查 host.js 是否存在
if [[ ! -f "$HOST_SCRIPT" ]]; then
echo -e "${RED}✗ host.js 文件不存在${NC}"
exit 1
fi
# 确保 host.js 可执行
chmod +x "$HOST_SCRIPT"
echo -e "${GREEN}${NC} host.js 已设置为可执行"
# 检查 manifest 文件
if [[ -f "$MANIFEST_FILE" ]]; then
echo -e "${GREEN}${NC} Manifest 文件已创建: $MANIFEST_FILE"
else
echo -e "${RED}✗ Manifest 文件创建失败${NC}"
exit 1
fi
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ ✅ Native Host 安装成功! ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
# 显示下一步
if [[ "$AUTO_DETECTED" == true ]]; then
echo -e "${CYAN}检测到扩展已安装,你可以直接使用了!${NC}"
echo ""
echo -e "使用方法:"
echo -e " 1. 点击 Chrome 工具栏的扩展图标"
echo -e " 2. 点击 'Connect to Qwen CLI'"
echo -e " 3. 开始使用各项功能"
else
echo -e "${YELLOW}下一步:${NC}"
echo -e " 1. 在 Chrome 中打开 ${CYAN}chrome://extensions/${NC}"
echo -e " 2. 开启${CYAN}「开发者模式」${NC}(右上角)"
echo -e " 3. 点击${CYAN}「加载已解压的扩展程序」${NC}"
echo -e " 4. 选择目录: ${CYAN}$SCRIPT_DIR/../extension${NC}"
echo -e " 5. 安装完成后,重新运行此脚本以更新配置"
fi
echo ""
echo -e "${CYAN}提示:${NC}"
echo -e " • 如需重新配置,随时可以重新运行此脚本"
echo -e " • 日志文件位置: \$HOME/.qwen/chrome-bridge/qwen-bridge-host.log若主目录不可写则回落 /tmp/qwen-bridge-host.log"
echo -e " • 如遇问题,请查看: $SCRIPT_DIR/../docs/debugging.md"
echo ""
# 询问是否测试连接
if [[ "$AUTO_DETECTED" == true ]]; then
echo -e "${CYAN}是否测试 Native Host 连接?(y/n)${NC}"
read -p "> " TEST_CONNECTION
if [[ "$TEST_CONNECTION" == "y" ]] || [[ "$TEST_CONNECTION" == "Y" ]]; then
echo -e "\n${BLUE}测试连接...${NC}"
# 创建测试脚本
cat > /tmp/test-native-host.js << 'EOF'
const chrome = {
runtime: {
connectNative: () => {
console.log("Chrome API not available in Node.js environment");
console.log("请在 Chrome 扩展中测试连接");
}
}
};
// 直接测试 host.js
const { spawn } = require('child_process');
const path = require('path');
const hostPath = process.argv[2];
if (!hostPath) {
console.error("Missing host path");
process.exit(1);
}
console.log("Testing host at:", hostPath);
const host = spawn('node', [hostPath], {
stdio: ['pipe', 'pipe', 'pipe']
});
// 发送测试消息
const testMessage = JSON.stringify({ type: 'handshake', version: '1.0.0' });
const length = Buffer.allocUnsafe(4);
length.writeUInt32LE(Buffer.byteLength(testMessage), 0);
host.stdin.write(length);
host.stdin.write(testMessage);
// 读取响应
let responseBuffer = Buffer.alloc(0);
let messageLength = null;
host.stdout.on('data', (data) => {
responseBuffer = Buffer.concat([responseBuffer, data]);
if (messageLength === null && responseBuffer.length >= 4) {
messageLength = responseBuffer.readUInt32LE(0);
responseBuffer = responseBuffer.slice(4);
}
if (messageLength !== null && responseBuffer.length >= messageLength) {
const message = JSON.parse(responseBuffer.slice(0, messageLength).toString());
console.log("Response received:", message);
if (message.type === 'handshake_response') {
console.log("✅ Native Host 响应正常");
}
host.kill();
process.exit(0);
}
});
host.on('error', (error) => {
console.error("❌ Host error:", error.message);
process.exit(1);
});
setTimeout(() => {
console.error("❌ 测试超时");
host.kill();
process.exit(1);
}, 5000);
EOF
node /tmp/test-native-host.js "$HOST_SCRIPT"
rm /tmp/test-native-host.js
fi
fi
echo -e "${GREEN}安装完成!${NC}"

View File

@@ -1,151 +0,0 @@
#!/bin/bash
# Qwen CLI Chrome Extension - Native Host Configuration Updater
# 用于在更换电脑或浏览器后更新Native Host配置
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
HOST_NAME="com.qwen.cli.bridge"
HOST_SCRIPT="$SCRIPT_DIR/../host.js"
EXTENSION_ID_FILE="$ROOT_DIR/.extension-id"
echo "==============================================="
echo "Qwen CLI Chrome Extension - Native Host Configuration Updater"
echo "==============================================="
echo ""
# Detect OS
if [[ "$OSTYPE" == "darwin"* ]]; then
OS="macOS"
MANIFEST_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
OS="Linux"
MANIFEST_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
else
echo "Error: Unsupported operating system"
exit 1
fi
echo "Detected OS: $OS"
echo ""
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "Error: Node.js is not installed"
echo "Please install Node.js from https://nodejs.org/"
exit 1
fi
echo "✓ Node.js $(node --version) is installed"
echo ""
# Create Native Host directory
echo "Creating Native Host directory..."
mkdir -p "$MANIFEST_DIR"
echo "✓ Directory created: $MANIFEST_DIR"
echo ""
# Check if host.js exists
if [[ ! -f "$HOST_SCRIPT" ]]; then
echo "Error: host.js not found at $HOST_SCRIPT"
exit 1
fi
# Make host.js executable
chmod +x "$HOST_SCRIPT"
echo "✓ Made host.js executable"
echo ""
# Get extension ID
echo "How would you like to configure the extension?"
echo "1) Use specific extension ID (recommended for production)"
echo "2) Use generic configuration (allows any development extension)"
echo ""
read -p "Choose option (1/2): " CONFIG_OPTION
MANIFEST_FILE="$MANIFEST_DIR/$HOST_NAME.json"
if [[ "$CONFIG_OPTION" == "1" ]]; then
echo ""
echo "Please enter your Chrome extension ID:"
echo "Tip: Find it in chrome://extensions page for Qwen CLI Chrome Extension"
read -p "Extension ID: " EXTENSION_ID
if [[ -z "$EXTENSION_ID" ]]; then
echo "Error: Extension ID is required"
exit 1
fi
# Save extension ID for future use
echo "$EXTENSION_ID" > "$EXTENSION_ID_FILE"
# Create manifest with specific extension ID
cat > "$MANIFEST_FILE" << EOF
{
"name": "$HOST_NAME",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$HOST_SCRIPT",
"type": "stdio",
"allowed_origins": [
"chrome-extension://$EXTENSION_ID/",
"chrome-extension://*/"
]
}
EOF
echo ""
echo "✓ Native Host configured for extension ID: $EXTENSION_ID"
elif [[ "$CONFIG_OPTION" == "2" ]]; then
# Create manifest with generic configuration
cat > "$MANIFEST_FILE" << EOF
{
"name": "$HOST_NAME",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$HOST_SCRIPT",
"type": "stdio",
"allowed_origins": [
"chrome-extension://*/"
]
}
EOF
echo ""
echo "✓ Native Host configured with generic settings (allows any development extension)"
else
echo "Invalid option"
exit 1
fi
echo ""
echo "✓ Manifest file created: $MANIFEST_FILE"
echo ""
# Verify configuration
echo "Verifying configuration..."
if [[ -f "$MANIFEST_FILE" ]]; then
echo "✓ Configuration verified successfully"
echo ""
echo "Configuration details:"
cat "$MANIFEST_FILE"
echo ""
else
echo "✗ Configuration verification failed"
exit 1
fi
echo "==============================================="
echo "✅ Native Host configuration updated successfully!"
echo "==============================================="
echo ""
echo "Next steps:"
echo "1. Restart Chrome if it's running"
echo "2. Navigate to chrome://extensions"
echo "3. Reload the Qwen CLI Chrome Extension extension"
echo "4. Click the extension icon and connect to Qwen CLI"
echo ""
echo "Note: Run this script whenever you:"
echo " • Switch to a new computer"
echo " • Change browsers"
echo " • Reinstall Chrome"
echo " • Get a new extension ID"
echo ""

View File

@@ -1,530 +0,0 @@
#!/usr/bin/env node
/* global require, process, Buffer, __dirname, setTimeout, console */
/**
* Browser MCP Server
* Provides browser tools (read_page, capture_screenshot, etc.) to Qwen CLI
* Communicates with Native Host via HTTP to get browser data
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const http = require('http');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const path = require('path');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { spawn } = require('child_process');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { TOOLS } = require('./shared/tools');
const BRIDGE_BASE = process.env.BRIDGE_BASE || 'http://127.0.0.1:18765';
const BRIDGE_URL = process.env.BRIDGE_URL || `${BRIDGE_BASE}/api`;
// MCP Protocol version
const PROTOCOL_VERSION = '2024-11-05';
let bridgeReadyPromise = null;
let hostProcess = null;
async function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function checkBridgeHealth() {
return new Promise((resolve, reject) => {
const req = http.request(
`${BRIDGE_BASE}/healthz`,
{ method: 'GET' },
(res) => {
// any 2xx considered healthy
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(true);
} else {
reject(new Error(`Health check status ${res.statusCode}`));
}
},
);
req.on('error', (err) => reject(err));
req.end();
});
}
async function ensureBridgeReady() {
if (bridgeReadyPromise) return bridgeReadyPromise;
bridgeReadyPromise = (async () => {
// If health OK, done
try {
await checkBridgeHealth();
return;
} catch {
// continue to spawn
}
// Spawn host.js so extension can talk to it
const hostPath = path.join(__dirname, '..', 'host.js');
hostProcess = spawn(process.execPath || 'node', [hostPath], {
stdio: 'inherit',
env: { ...process.env },
});
// Wait for health up to ~5s
for (let i = 0; i < 10; i++) {
await wait(500);
try {
await checkBridgeHealth();
return;
} catch {
// retry
}
}
throw new Error('Bridge health check failed after spawning host.js');
})();
return bridgeReadyPromise;
}
process.on('exit', () => {
if (hostProcess) {
try {
hostProcess.kill('SIGTERM');
} catch {
// ignore
}
}
});
// Send request to Native Host HTTP bridge with simple retry
async function callBridge(method, params = {}) {
await ensureBridgeReady();
const data = JSON.stringify({ method, params });
const attempt = () =>
new Promise((resolve, reject) => {
const req = http.request(
BRIDGE_URL,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
},
},
(res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => {
try {
const result = JSON.parse(body || '{}');
if (result.success) {
resolve(result.data);
} else {
reject(new Error(result.error || 'Unknown error'));
}
} catch (err) {
reject(new Error(`Failed to parse response: ${err.message}`));
}
});
},
);
req.on('error', (err) => {
reject(
new Error(
`Bridge connection failed: ${err.message}. Ensure host.js is running and the extension is loaded.`,
),
);
});
req.write(data);
req.end();
});
// retry twice with small delay
let lastErr;
for (let i = 0; i < 3; i++) {
try {
return await attempt();
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 500));
}
}
throw lastErr;
}
// Handle MCP tool calls
async function handleToolCall(name, args) {
switch (name) {
case 'browser_read_page': {
const data = await callBridge('read_page');
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
url: data.url,
title: data.title,
content: data.content?.text || data.content?.markdown || '',
linksCount: data.links?.length || 0,
imagesCount: data.images?.length || 0,
},
null,
2,
),
},
],
};
}
case 'browser_capture_screenshot': {
const data = await callBridge('capture_screenshot');
return {
content: [
{
type: 'image',
data: data.dataUrl?.replace(/^data:image\/png;base64,/, '') || '',
mimeType: 'image/png',
},
],
};
}
case 'browser_get_network_logs': {
const data = await callBridge('get_network_logs');
const logs = data.logs || [];
if (!logs.length) {
return {
content: [
{
type: 'text',
text: 'No network entries captured yet. Try reloading the page or triggering a request, then run again.',
},
],
};
}
// Aggregate by requestId to include method/url/status/headers/bodies
const byRequest = new Map();
for (const log of logs) {
const reqId = log.params?.requestId;
if (!reqId) continue;
const entry = byRequest.get(reqId) || { requestId: reqId };
switch (log.method) {
case 'Network.requestWillBeSent': {
entry.method = log.params?.request?.method;
entry.url =
log.params?.request?.url || log.params?.documentURL || entry.url;
entry.requestHeaders = log.params?.request?.headers;
entry.requestBody = log.params?.request?.postData;
entry.timestamp = log.timestamp;
break;
}
case 'Network.responseReceived': {
entry.status = log.params?.response?.status;
entry.statusText = log.params?.response?.statusText;
entry.responseHeaders = log.params?.response?.headers;
entry.timestamp = log.timestamp;
break;
}
case 'Network.responseBody': {
entry.responseBody = log.params?.body;
entry.responseBodyBase64 = log.params?.base64Encoded;
if (log.params?.error) entry.responseBodyError = log.params.error;
entry.timestamp = log.timestamp;
break;
}
case 'Network.loadingFailed': {
entry.error = log.params?.errorText || log.params?.error;
entry.timestamp = log.timestamp;
break;
}
default:
break;
}
byRequest.set(reqId, entry);
}
// Take the most recent 20 requests
const items = Array.from(byRequest.values()).slice(-20);
const text = `Network requests (last ${items.length}):\n${JSON.stringify(
items,
null,
2,
)}`;
return {
content: [
{
type: 'text',
text,
},
],
};
}
case 'browser_get_console_logs': {
const data = await callBridge('get_console_logs');
const logs = data.logs || [];
const formatted = logs
.slice(-50)
.map((log) => `[${log.type}] ${log.message}`)
.join('\n');
return {
content: [
{
type: 'text',
text: `Console logs (last ${Math.min(logs.length, 50)} entries):\n${formatted || '(no logs captured)'}`,
},
],
};
}
case 'browser_fill_form': {
const data = await callBridge('fill_form', args);
const results = data.results || [];
return {
content: [
{
type: 'text',
text: `Fill results:\n${JSON.stringify(results, null, 2)}`,
},
],
};
}
case 'browser_fill_form_auto': {
const data = await callBridge('fill_form_auto', args);
const results = data.results || [];
return {
content: [
{
type: 'text',
text: `Auto fill results:\n${JSON.stringify(results, null, 2)}`,
},
],
};
}
case 'browser_input_text': {
if (!args.selector || args.text === undefined) {
throw new Error('selector and text are required');
}
const data = await callBridge('input_text', args);
const success = data?.success !== false;
const message =
data?.error ||
data?.message ||
(success ? 'Filled successfully' : 'Failed to fill input');
return {
content: [
{
type: 'text',
text: `Input result: ${message}`,
},
],
isError: !success,
};
}
case 'browser_click': {
if (!args.selector) throw new Error('selector is required');
const data = await callBridge('click_element', args);
const success = data?.success !== false;
const message =
data?.error || (success ? 'Click success' : 'Click failed');
return {
content: [
{
type: 'text',
text: message,
},
],
isError: !success,
};
}
case 'browser_click_text': {
if (!args.text) throw new Error('text is required');
const data = await callBridge('click_text', args);
const success = data?.success !== false;
const message =
data?.error || (success ? 'Click success' : 'Click failed');
return {
content: [
{
type: 'text',
text: message,
},
],
isError: !success,
};
}
case 'browser_run_js': {
if (!args.code) throw new Error('code is required');
const data = await callBridge('run_js', { code: args.code });
const success = data?.success !== false;
const message = data?.error || JSON.stringify(data?.result ?? data);
return {
content: [
{
type: 'text',
text: success ? `Result: ${message}` : `Error: ${message}`,
},
],
isError: !success,
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// JSON-RPC framing over stdio (Content-Length)
let inputBuffer = Buffer.alloc(0);
function writeMessage(obj) {
const json = Buffer.from(JSON.stringify(obj), 'utf8');
const header = Buffer.from(`Content-Length: ${json.length}\r\n\r\n`, 'utf8');
process.stdout.write(header);
process.stdout.write(json);
}
function sendResponse(id, result) {
writeMessage({ jsonrpc: '2.0', id, result });
}
function sendError(id, code, message) {
writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
}
// Handle incoming JSON-RPC messages
async function handleMessage(message) {
const { id, method, params } = message;
try {
switch (method) {
case 'initialize':
sendResponse(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: {
tools: {},
},
serverInfo: {
name: 'chrome-browser',
version: '1.0.0',
},
});
break;
case 'tool': {
// Return functionDeclarations compatible with Qwen's mcpToTool expectation
const functionDeclarations = TOOLS.map((t) => ({
name: t.name,
description: t.description,
parametersJsonSchema: t.inputSchema || {
type: 'object',
properties: {},
},
}));
sendResponse(id, { functionDeclarations });
break;
}
case 'notifications/initialized':
// No response needed for notifications
break;
case 'tools/list':
sendResponse(id, { tools: TOOLS });
break;
case 'tools/call':
try {
const result = await handleToolCall(
params.name,
params.arguments || {},
);
sendResponse(id, result);
} catch (err) {
sendResponse(id, {
content: [
{
type: 'text',
text: `Error: ${err.message}`,
},
],
isError: true,
});
}
break;
case 'ping':
sendResponse(id, {});
break;
default:
if (id !== undefined) {
sendError(id, -32601, `Method not found: ${method}`);
}
}
} catch (err) {
if (id !== undefined) {
sendError(id, -32603, err.message);
}
}
}
// Main: Read JSON-RPC messages from stdin (Content-Length framed)
process.stdin.on('data', (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, chunk]);
while (true) {
let headerEnd = inputBuffer.indexOf('\r\n\r\n');
let sepLen = 4;
if (headerEnd === -1) {
headerEnd = inputBuffer.indexOf('\n\n');
sepLen = 2;
}
if (headerEnd === -1) return; // wait for full header
const headerStr = inputBuffer.slice(0, headerEnd).toString('utf8');
const match = headerStr.match(/Content-Length:\s*(\d+)/i);
if (!match) {
// drop until next header
inputBuffer = inputBuffer.slice(headerEnd + sepLen);
continue;
}
const length = parseInt(match[1], 10);
const totalLen = headerEnd + sepLen + length;
if (inputBuffer.length < totalLen) return; // wait for full body
const body = inputBuffer.slice(headerEnd + sepLen, totalLen);
inputBuffer = inputBuffer.slice(totalLen);
try {
const message = JSON.parse(body.toString('utf8'));
// Debug to stderr (not stdout): show basic method flow
try {
console.error(
'[MCP <-]',
message.method || 'response',
message.id ?? '',
);
} catch {
/* ignore */
}
handleMessage(message);
} catch (e) {
try {
console.error('[MCP] JSON parse error:', e.message);
} catch {
/* ignore */
}
// ignore parse errors
}
}
});
// Handle errors
process.on('uncaughtException', (err) => {
console.error('Uncaught exception:', err);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,172 +0,0 @@
/* global module */
// Centralized browser tool definitions for MCP exposure
// Keep this list in sync with extension-side INTERNAL_MCP_TOOLS.
const TOOLS = [
{
name: 'browser_read_page',
description:
'Read the content of the current browser page. Returns URL, title, text content, links, and images.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'browser_capture_screenshot',
description:
'Capture a screenshot of the current browser tab. Returns a base64-encoded PNG image.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'browser_get_network_logs',
description:
'Get network request logs from the current browser tab. Useful for debugging API calls.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'browser_get_console_logs',
description:
'Get console logs (log, error, warn, info) from the current browser tab.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
},
{
name: 'browser_fill_form',
description:
'Fill inputs/textareas/contenteditable elements on the current page using selectors or labels.',
inputSchema: {
type: 'object',
properties: {
entries: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
selector: { type: 'string' },
label: { type: 'string' },
text: { type: 'string' },
mode: {
type: 'string',
enum: ['replace', 'append'],
default: 'replace',
},
focus: { type: 'boolean' },
simulateEvents: { type: 'boolean' },
},
required: ['text'],
},
},
},
required: ['entries'],
},
},
{
name: 'browser_input_text',
description:
'Fill a single input/textarea/contentEditable element using a CSS selector.',
inputSchema: {
type: 'object',
properties: {
selector: { type: 'string' },
text: { type: 'string' },
clear: {
type: 'boolean',
description: 'Clear existing text before filling (default true)',
},
},
required: ['selector', 'text'],
},
},
{
name: 'browser_click',
description: 'Click an element on the current page using a CSS selector.',
inputSchema: {
type: 'object',
properties: {
selector: { type: 'string' },
},
required: ['selector'],
},
},
{
name: 'browser_click_text',
description: 'Click an element (button/link) by matching its visible text.',
inputSchema: {
type: 'object',
properties: {
text: {
type: 'string',
description: 'Visible text to match (case-insensitive substring)',
},
},
required: ['text'],
},
},
{
name: 'browser_run_js',
description:
'Execute a JavaScript snippet in the page context (use with care).',
inputSchema: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'JavaScript expression or block to execute',
},
},
required: ['code'],
},
},
{
name: 'browser_fill_form_auto',
description:
'Automatically fill form fields by matching keys to visible labels/placeholder/name. Provide pairs of key/value.',
inputSchema: {
type: 'object',
properties: {
fields: {
type: 'array',
minItems: 1,
items: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Label/placeholder/name text to match',
},
value: { type: 'string', description: 'Text to fill' },
mode: {
type: 'string',
enum: ['replace', 'append'],
default: 'replace',
},
simulateEvents: { type: 'boolean' },
focus: { type: 'boolean' },
},
required: ['key', 'value'],
},
},
},
required: ['fields'],
},
},
];
module.exports = {
TOOLS,
};

View File

@@ -1,63 +0,0 @@
{
"name": "@qwen-code/chrome-bridge",
"version": "1.0.0",
"description": "Chrome extension bridge for Qwen CLI - enables AI-powered browser interactions",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/QwenLM/qwen-code.git",
"directory": "packages/chrome-extension"
},
"keywords": [
"chrome-extension",
"qwen",
"cli",
"bridge",
"native-messaging",
"mcp",
"ai"
],
"author": "Qwen Team",
"license": "Apache-2.0",
"type": "module",
"files": [
"public/",
"native-host/",
"README.md"
],
"scripts": {
"dev": "EXTENSION_OUT_DIR=dist/extension node scripts/dev-watch.js",
"debug:mac": "./scripts/debug.sh",
"sync:extension": "node scripts/sync-extension.js",
"build:ui": "node config/esbuild.config.js",
"build:ui:watch": "node config/esbuild.config.js --watch",
"build": "EXTENSION_OUT_DIR=dist/extension npm run clean && EXTENSION_OUT_DIR=dist/extension node scripts/sync-extension.js && EXTENSION_OUT_DIR=dist/extension node config/esbuild.config.js --production",
"install:extension": "./scripts/first-install.sh",
"install:host": "cd native-host && ./scripts/smart-install.sh",
"install:all": "./scripts/first-install.sh",
"update:host": "cd native-host && ./scripts/update-host-config.sh",
"dev:chrome": "open -a 'Google Chrome' --args --load-extension=$PWD/dist/extension --auto-open-devtools-for-tabs",
"package": "npm run build && cd dist && zip -r ../chrome-extension.zip extension/",
"clean": "./scripts/clean.sh",
"logs": "tail -f $HOME/.qwen/chrome-bridge/qwen-bridge-host.log"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"markdown-it": "^14.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/chrome": "^0.1.32",
"@types/markdown-it": "^14.1.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"autoprefixer": "^10.4.22",
"esbuild": "^0.25.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "^5.8.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 920 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -1,55 +0,0 @@
{
"manifest_version": 3,
"name": "Qwen CLI Chrome Extension",
"version": "1.0.0",
"description": "Bridge between Chrome browser and Qwen CLI for enhanced AI interactions",
"permissions": [
"activeTab",
"tabs",
"storage",
"debugger",
"webNavigation",
"scripting",
"cookies",
"webRequest",
"sidePanel"
],
"host_permissions": ["<all_urls>", "http://127.0.0.1:18765/*"],
"externally_connectable": {
"ids": ["aohjeidlpcjalobgghfkkehjbdhacjlo"],
"matches": ["https://*/*"]
},
"background": {
"service_worker": "background/service-worker.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content-script.js"],
"run_at": "document_idle"
}
],
"action": {
"default_icon": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
},
"side_panel": {
"default_path": "sidepanel/sidepanel.html"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
}
}

View File

@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Qwen Code</title>
<style>
/* Base reset and full-height container */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #1e1e1e;
color: #e0e0e0;
}
/* Loading state */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #333;
border-top-color: #615fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
color: #888;
font-size: 14px;
}
</style>
</head>
<body>
<div id="root">
<div class="loading-container">
<div class="loading-spinner"></div>
<div class="loading-text">Loading Qwen Code...</div>
</div>
</div>
<script>
// Inject extension URI for resource loading
try {
const extensionUri = chrome.runtime.getURL('');
document.body.setAttribute('data-extension-uri', extensionUri);
window.__EXTENSION_URI__ = extensionUri;
} catch (e) {
console.warn('Failed to inject extension URI:', e);
}
</script>
<script src="dist/sidepanel-app.js"></script>
</body>
</html>

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# Build script for Chrome extension package
echo "Building Chrome Qwen Bridge..."
# Ensure we're in the project root directory (where both scripts/ and extension/ are)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR/.."
# Build latest assets into dist/extension
npm run build
# Create a zip file for Chrome Web Store / unpacked install
echo "Creating extension package..."
cd dist
zip -r ../chrome-extension.zip extension/
cd ..
echo "✅ Build complete!"
echo " Extension package: chrome-extension.zip"
echo " Extension files: dist/extension/"

View File

@@ -1,24 +0,0 @@
#!/bin/bash
# Clean up build artifacts and temporary files for Chrome Extension
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
echo "Cleaning up Chrome Extension build artifacts..."
# Remove any dist directories and zips
rm -rf dist/
rm -f chrome-extension.zip
# Remove log files
rm -f "$HOME/.qwen/chrome-bridge/qwen-bridge-host.log"
rm -f /tmp/qwen-bridge-host.log
rm -f /tmp/qwen-server.log
# Remove saved extension ID (new unified path + legacy paths)
rm -f "$ROOT_DIR/.extension-id"
rm -f "$SCRIPT_DIR/.extension-id"
rm -f "$SCRIPT_DIR/../native-host/.extension-id"
echo "Cleanup complete!"

View File

@@ -1,170 +0,0 @@
#!/bin/bash
# Qwen CLI Chrome Extension - macOS 一键调试脚本
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# 获取脚本目录
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
EXTENSION_ID_FILE="$ROOT_DIR/.extension-id"
# 兼容旧路径的 .extension-id如存在则迁移到统一位置
if [[ ! -f "$EXTENSION_ID_FILE" ]]; then
for legacy in "$SCRIPT_DIR/.extension-id" "$SCRIPT_DIR/../native-host/.extension-id"; do
if [[ -f "$legacy" ]]; then
cp "$legacy" "$EXTENSION_ID_FILE"
break
fi
done
fi
# 检查是否首次安装
if [[ ! -f "$EXTENSION_ID_FILE" ]]; then
echo -e "${YELLOW}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${YELLOW}║ ║${NC}"
echo -e "${YELLOW}║ ⚠️ 检测到首次运行,需要先安装插件 ║${NC}"
echo -e "${YELLOW}║ ║${NC}"
echo -e "${YELLOW}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${CYAN}即将启动首次安装向导...${NC}"
sleep 2
exec "$SCRIPT_DIR/first-install.sh"
exit 0
fi
# 清屏显示标题
clear
echo -e "${CYAN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ ║${NC}"
echo -e "${CYAN}║ 🚀 Qwen CLI Chrome Extension - macOS 调试环境 ║${NC}"
echo -e "${CYAN}║ ║${NC}"
echo -e "${CYAN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
# 第一步:检查环境
echo -e "${BLUE}[1/6]${NC} 检查开发环境..."
# 检查 Node.js
if ! command -v node &> /dev/null; then
echo -e "${RED}${NC} Node.js 未安装,请先安装 Node.js"
echo " 访问 https://nodejs.org 下载安装"
exit 1
fi
echo -e "${GREEN}${NC} Node.js $(node --version)"
# 检查 Chrome
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
if [[ ! -f "$CHROME_PATH" ]]; then
echo -e "${RED}${NC} Chrome 未找到"
exit 1
fi
echo -e "${GREEN}${NC} Chrome 已安装"
EXT_DIR="$SCRIPT_DIR/../dist/extension"
# 第二步:配置 Native Host
echo -e "\n${BLUE}[2/6]${NC} 配置 Native Host..."
MANIFEST_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
mkdir -p "$MANIFEST_DIR"
cat > "$MANIFEST_DIR/com.qwen.cli.bridge.json" << EOF
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$SCRIPT_DIR/../native-host/src/host.js",
"type": "stdio",
"allowed_origins": ["chrome-extension://*/"]
}
EOF
echo -e "${GREEN}${NC} Native Host 已配置"
# 第三步:检查 Qwen CLI
echo -e "\n${BLUE}[3/6]${NC} 检查 Qwen CLI..."
QWEN_AVAILABLE=false
if command -v qwen &> /dev/null; then
QWEN_AVAILABLE=true
QWEN_VERSION=$(qwen --version 2>/dev/null || echo "已安装")
echo -e "${GREEN}${NC} Qwen CLI ${QWEN_VERSION}"
echo -e "${CYAN}${NC} 使用 ACP 模式与 Chrome 插件通信"
else
echo -e "${YELLOW}!${NC} Qwen CLI 未安装(插件基础功能仍可使用)"
echo -e " 安装方法: npm install -g @anthropic-ai/qwen-code"
fi
# 第四步:构建扩展
echo -e "\n${BLUE}[4/6]${NC} 构建扩展..."
(
cd "$SCRIPT_DIR/.."
EXTENSION_OUT_DIR=dist/extension npm run build >/tmp/qwen-bridge-build.log 2>&1
)
if [[ ! -d "$EXT_DIR" ]]; then
echo -e "${RED}${NC} 构建失败,查看 /tmp/qwen-bridge-build.log"
exit 1
fi
echo -e "${GREEN}${NC} 构建完成,输出目录: ${EXT_DIR}"
# 第五步:启动测试页面
# 第五步:启动 Chrome
echo -e "\n${BLUE}[5/5]${NC} 启动 Chrome 并加载插件..."
"$CHROME_PATH" \
--load-extension="$EXT_DIR" \
--auto-open-devtools-for-tabs \
--no-first-run \
--no-default-browser-check \
"about:blank" &
CHROME_PID=$!
echo -e "${GREEN}${NC} Chrome 已启动"
# 显示最终状态
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ ✅ 调试环境启动成功! ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${CYAN}📍 服务状态:${NC}"
echo -e " • Chrome: 运行中"
echo -e " • 插件: 已加载到工具栏"
if [ "$QWEN_AVAILABLE" = true ]; then
echo -e " • Qwen CLI: 可用 (ACP 模式)"
fi
echo ""
echo -e "${CYAN}🔍 调试位置:${NC}"
echo -e " • 插件日志: Chrome DevTools Console"
echo -e " • 后台脚本: chrome://extensions → Service Worker"
echo -e " • Native Host: $HOME/.qwen/chrome-bridge/qwen-bridge-host.log (fallback: /tmp/qwen-bridge-host.log)"
echo ""
echo -e "${YELLOW}按 Ctrl+C 停止所有服务${NC}"
echo ""
# 清理函数
cleanup() {
echo -e "\n${YELLOW}正在停止服务...${NC}"
echo -e "${GREEN}${NC} 已停止服务"
exit 0
}
# 捕获中断信号
trap cleanup INT TERM
# 保持运行
while true; do
sleep 1
done

View File

@@ -1,77 +0,0 @@
#!/usr/bin/env node
/**
* 开发模式:同步资源 + esbuild watch 到 dist/extension可通过 EXTENSION_OUT_DIR 覆盖)。
*/
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const outDir = process.env.EXTENSION_OUT_DIR || 'dist/extension';
function startProcess(command, args, options = {}) {
const child = spawn(command, args, {
cwd: projectRoot,
stdio: 'inherit',
env: { ...process.env, EXTENSION_OUT_DIR: outDir },
...options,
});
child.on('exit', (code, signal) => {
if (signal === 'SIGINT') {
return;
}
if (code !== 0) {
console.error(`${command} ${args.join(' ')} exited with code ${code}`);
process.exit(code || 1);
}
});
return child;
}
async function main() {
// 先做一次完整同步,保证 dist/extension 准备好静态资源和脚本
await new Promise((resolve, reject) => {
const syncOnce = startProcess('node', [
'scripts/sync-extension.js',
`--target=${outDir}`,
]);
syncOnce.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error('Initial sync failed'));
});
});
// 并行开启 watch静态/脚本同步 + sidepanel esbuild
const watchers = [
startProcess('node', [
'scripts/sync-extension.js',
'--watch',
`--target=${outDir}`,
]),
startProcess('node', ['config/esbuild.config.js', '--watch']),
];
// 优雅退出
const shutdown = () => {
watchers.forEach((proc) => {
if (!proc.killed) {
proc.kill('SIGINT');
}
});
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,531 +0,0 @@
#!/usr/bin/env node
/**
* 开发环境一键启动脚本
* 自动完成所有配置和启动步骤
*/
import { spawn, exec } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
// const readline = require('readline'); // Commenting out unused import
// 颜色输出
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
red: '\x1b[31m',
cyan: '\x1b[36m',
};
function log(message, color = '') {
console.log(`${color}${message}${colors.reset}`);
}
function logStep(step, message) {
log(`\n[${step}] ${message}`, colors.bright + colors.blue);
}
function logSuccess(message) {
log(`${message}`, colors.green);
}
function logWarning(message) {
log(`⚠️ ${message}`, colors.yellow);
}
function logError(message) {
log(`${message}`, colors.red);
}
function logInfo(message) {
log(` ${message}`, colors.cyan);
}
// 检查命令是否存在
function commandExists(command) {
return new Promise((resolve) => {
exec(`command -v ${command}`, (error) => {
resolve(!error);
});
});
}
// 获取 Chrome 路径
function getChromePath() {
const platform = process.platform;
const chromePaths = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
],
win32: [
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
process.env.LOCALAPPDATA + '\\Google\\Chrome\\Application\\chrome.exe',
],
linux: [
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
],
};
const paths = chromePaths[platform] || [];
for (const chromePath of paths) {
if (fs.existsSync(chromePath)) {
return chromePath;
}
}
return null;
}
// 安装 Native Host
async function installNativeHost(extensionPath) {
logStep(2, 'Installing Native Host...');
const hostPath = path.join(extensionPath, 'native-host');
const scriptPath = path.join(hostPath, 'host.js');
if (!fs.existsSync(scriptPath)) {
logError('Native host script not found!');
return false;
}
const platform = process.platform;
const hostName = 'com.qwen.cli.bridge';
let manifestDir;
if (platform === 'darwin') {
manifestDir = path.join(
os.homedir(),
'Library/Application Support/Google/Chrome/NativeMessagingHosts',
);
} else if (platform === 'linux') {
manifestDir = path.join(
os.homedir(),
'.config/google-chrome/NativeMessagingHosts',
);
} else if (platform === 'win32') {
// Windows 需要写注册表
logWarning(
'Windows requires registry modification. Please run install.bat manually.',
);
return true;
} else {
logError('Unsupported platform');
return false;
}
// 创建目录
if (!fs.existsSync(manifestDir)) {
fs.mkdirSync(manifestDir, { recursive: true });
}
// 创建 manifest 文件
const manifest = {
name: hostName,
description: 'Native messaging host for Qwen CLI Chrome Extension',
path: scriptPath,
type: 'stdio',
allowed_origins: [
'chrome-extension://jniepomhbdkeifkadbfolbcihcmfpfjo/', // 开发用 ID
'chrome-extension://*/', // 允许任何扩展(仅开发环境)
],
};
const manifestPath = path.join(manifestDir, `${hostName}.json`);
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
logSuccess(`Native Host installed at: ${manifestPath}`);
return true;
}
// 检查 Qwen CLI
async function checkQwenCli() {
logStep(3, 'Checking Qwen CLI...');
const qwenExists = await commandExists('qwen');
if (qwenExists) {
logSuccess('Qwen CLI is installed');
// 获取版本
return new Promise((resolve) => {
exec('qwen --version', (error, stdout) => {
if (!error && stdout) {
logInfo(`Version: ${stdout.trim()}`);
}
resolve(true);
});
});
} else {
logWarning('Qwen CLI is not installed');
logInfo(
'You can still use the extension, but some features will be limited',
);
return false;
}
}
// 启动 Qwen CLI 服务器
function startQwenServer(port = 8080) {
logStep(4, 'Starting Qwen CLI Server...');
return new Promise((resolve) => {
// 检查端口是否被占用
exec(`lsof -i:${port} || netstat -an | grep ${port}`, (_error, stdout) => {
if (stdout && stdout.length > 0) {
logWarning(`Port ${port} is already in use`);
logInfo('Qwen server might already be running');
resolve(null);
return;
}
// 启动服务器
const qwenProcess = spawn('qwen', ['server', '--port', String(port)], {
detached: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
qwenProcess.stdout.on('data', (data) => {
const output = data.toString();
if (output.includes('Server started') || output.includes('listening')) {
logSuccess(`Qwen server started on port ${port}`);
resolve(qwenProcess);
}
});
qwenProcess.stderr.on('data', (data) => {
logError(`Qwen server error: ${data}`);
});
qwenProcess.on('error', (error) => {
logError(`Failed to start Qwen server: ${error.message}`);
resolve(null);
});
// 超时处理
setTimeout(() => {
logWarning('Qwen server start timeout, continuing anyway...');
resolve(qwenProcess);
}, 5000);
});
});
}
// 启动 Chrome 开发模式
function startChrome(extensionPath, chromePath) {
logStep(5, 'Starting Chrome with extension...');
const args = [
`--load-extension=${extensionPath}`,
'--auto-open-devtools-for-tabs', // 自动打开 DevTools
'--disable-extensions-except=' + extensionPath,
'--no-first-run',
'--no-default-browser-check',
'--disable-default-apps',
'--disable-popup-blocking',
'--disable-translate',
'--disable-sync',
'--no-pings',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-device-discovery-notifications',
];
// 开发模式特定参数
if (process.env.DEBUG === 'true') {
args.push('--enable-logging=stderr');
args.push('--v=1');
}
// 添加测试页面
args.push('http://localhost:3000'); // 或其他测试页面
const chromeProcess = spawn(chromePath, args, {
detached: false,
stdio: 'inherit',
});
chromeProcess.on('error', (error) => {
logError(`Failed to start Chrome: ${error.message}`);
});
logSuccess('Chrome started with extension loaded');
logInfo('Extension should be visible in the toolbar');
return chromeProcess;
}
// 创建测试服务器
async function createTestServer(port = 3000) {
logStep(6, 'Starting test server...');
const { default: http } = await import('http');
const testHtml = `
<!DOCTYPE html>
<html>
<head>
<title>Qwen CLI Chrome Extension Test Page</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
h1 {
color: #333;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.test-content {
margin: 20px 0;
}
.test-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 5px;
}
.test-button:hover {
opacity: 0.9;
}
#console-output {
background: #f5f5f5;
padding: 10px;
border-radius: 5px;
font-family: monospace;
min-height: 100px;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>🚀 Qwen CLI Chrome Extension Test Page</h1>
<div class="test-content">
<h2>Test Content</h2>
<p>This is a test page for the Qwen CLI Chrome Extension.</p>
<p>Click the extension icon in your toolbar to start testing!</p>
<h3>Sample Data</h3>
<ul>
<li>Item 1: Lorem ipsum dolor sit amet</li>
<li>Item 2: Consectetur adipiscing elit</li>
<li>Item 3: Sed do eiusmod tempor incididunt</li>
</ul>
<h3>Test Actions</h3>
<button class="test-button" onclick="testLog()">Test Console Log</button>
<button class="test-button" onclick="testError()">Test Console Error</button>
<button class="test-button" onclick="testNetwork()">Test Network Request</button>
<h3>Console Output</h3>
<div id="console-output"></div>
</div>
<div class="test-content">
<h2>Test Form</h2>
<form>
<input type="text" placeholder="Test input" style="padding: 5px; margin: 5px;">
<textarea placeholder="Test textarea" style="padding: 5px; margin: 5px;"></textarea>
<select style="padding: 5px; margin: 5px;">
<option>Option 1</option>
<option>Option 2</option>
</select>
</form>
</div>
<div class="test-content">
<h2>Images</h2>
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iIzY2N2VlYSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmaWxsPSJ3aGl0ZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPjIwMHgxMDA8L3RleHQ+PC9zdmc+" alt="Test Image">
</div>
</div>
<script>
function addOutput(message, type = 'log') {
const output = document.getElementById('console-output');
const time = new Date().toLocaleTimeString();
const color = type === 'error' ? 'red' : type === 'warn' ? 'orange' : 'black';
output.innerHTML += \`<div style="color: \${color}">[\${time}] \${message}</div>\`;
console[type](message);
}
function testLog() {
addOutput('This is a test log message', 'log');
}
function testError() {
addOutput('This is a test error message', 'error');
}
async function testNetwork() {
addOutput('Making network request...', 'log');
try {
const response = await fetch('https://api.github.com/users/github');
const data = await response.json();
addOutput('Network request successful: ' + JSON.stringify(data).substring(0, 100) + '...', 'log');
} catch (error) {
addOutput('Network request failed: ' + error.message, 'error');
}
}
// 自动记录一些日志
console.log('Test page loaded');
console.info('Extension test environment ready');
</script>
</body>
</html>
`;
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(testHtml);
});
server.listen(port, () => {
logSuccess(`Test server running at http://localhost:${port}`);
});
return server;
}
// 主函数
async function main() {
console.clear();
log(
`
╔════════════════════════════════════════════════════════════════╗
║ ║
║ 🚀 Qwen CLI Chrome Extension - Development Environment Setup ║
║ ║
╚════════════════════════════════════════════════════════════════╝
`,
colors.bright + colors.cyan,
);
const extensionPath = path.join(
__dirname,
process.env.EXTENSION_OUT_DIR || 'dist/extension',
);
if (!fs.existsSync(extensionPath)) {
logWarning(
`Extension output not found at ${extensionPath}. Run "npm run build" first or set EXTENSION_OUT_DIR.`,
);
}
// Step 1: 检查 Chrome
logStep(1, 'Checking Chrome installation...');
const chromePath = getChromePath();
if (!chromePath) {
logError('Chrome not found! Please install Google Chrome.');
process.exit(1);
}
logSuccess(`Chrome found at: ${chromePath}`);
// Step 2: 安装 Native Host
const nativeHostInstalled = await installNativeHost(__dirname);
if (!nativeHostInstalled && process.platform === 'win32') {
logWarning(
'Please run install.bat as Administrator to complete Native Host setup',
);
}
// Step 3: 检查 Qwen CLI
const qwenInstalled = await checkQwenCli();
// Step 4: 启动 Qwen 服务器(如果已安装)
let qwenProcess = null;
if (qwenInstalled) {
qwenProcess = await startQwenServer(8080);
}
// Step 5: 启动测试服务器
const testServer = createTestServer(3000);
// Step 6: 启动 Chrome
await new Promise((resolve) => setTimeout(resolve, 1000)); // 等待服务器启动
const chromeProcess = startChrome(extensionPath, chromePath);
// 设置清理处理
const cleanup = () => {
log('\n\nShutting down...', colors.yellow);
if (qwenProcess) {
qwenProcess.kill();
logInfo('Qwen server stopped');
}
if (testServer) {
testServer.close();
logInfo('Test server stopped');
}
if (chromeProcess) {
chromeProcess.kill();
logInfo('Chrome stopped');
}
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// 显示使用说明
log(
`
╔════════════════════════════════════════════════════════════════╗
║ ✅ Setup Complete! ║
╠════════════════════════════════════════════════════════════════╣
║ ║
║ 📍 Chrome is running with the extension loaded ║
║ 📍 Test page: http://localhost:3000 ║
${qwenInstalled ? '📍 Qwen server: http://localhost:8080 ' : '📍 Qwen CLI not installed (limited functionality) '}
║ ║
║ 📝 How to debug: ║
║ 1. Click the extension icon in Chrome toolbar ║
║ 2. Open Chrome DevTools (F12) to see console logs ║
║ 3. Check background page: chrome://extensions → Details ║
║ 4. Native Host logs: $HOME/.qwen/chrome-bridge/qwen-bridge-host.log ║
║ ║
║ 🛑 Press Ctrl+C to stop all services ║
║ ║
╚════════════════════════════════════════════════════════════════╝
`,
colors.bright + colors.green,
);
// 保持进程运行
await new Promise(() => {});
}
// 运行
main().catch((error) => {
logError(`Fatal error: ${error.message}`);
process.exit(1);
});

View File

@@ -1,122 +0,0 @@
#!/bin/bash
# Qwen CLI Chrome Extension - 首次安装脚本
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
EXTENSION_ID_FILE="$ROOT_DIR/.extension-id"
clear
echo -e "${CYAN}╔════════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ ║${NC}"
echo -e "${CYAN}║ 🎯 Qwen CLI Chrome Extension - 首次安装向导 ║${NC}"
echo -e "${CYAN}║ ║${NC}"
echo -e "${CYAN}╚════════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${YELLOW}这是首次安装,需要手动加载插件到 Chrome。${NC}"
echo ""
# 步骤 1: 配置 Native Host
echo -e "${BLUE}步骤 1:${NC} 配置 Native Host..."
MANIFEST_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
mkdir -p "$MANIFEST_DIR"
# 先创建一个临时的 manifest允许所有扩展
cat > "$MANIFEST_DIR/com.qwen.cli.bridge.json" << EOF
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$SCRIPT_DIR/../native-host/src/host.js",
"type": "stdio",
"allowed_origins": ["chrome-extension://*/"]
}
EOF
echo -e "${GREEN}${NC} Native Host 已配置"
# 步骤 2: 打开 Chrome 扩展页面
echo -e "\n${BLUE}步骤 2:${NC} 打开 Chrome 扩展管理页面..."
open -a "Google Chrome" "chrome://extensions"
sleep 2
echo -e "${GREEN}${NC} 已打开扩展管理页面"
# 步骤 3: 指导用户安装
echo ""
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW}请按照以下步骤手动安装插件:${NC}"
echo ""
echo -e " 1⃣ 在 Chrome 扩展页面,${GREEN}开启「开发者模式」${NC}(右上角开关)"
echo ""
echo -e " 2⃣ 点击 ${GREEN}「加载已解压的扩展程序」${NC} 按钮"
echo ""
echo -e " 3⃣ 选择以下目录:"
echo -e " ${BLUE}$SCRIPT_DIR/../extension${NC}"
echo ""
echo -e " 4${YELLOW}重要:${NC} 记下显示的扩展 ID类似 ${CYAN}abcdefghijklmnopqrstuvwx${NC}"
echo ""
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
echo ""
# 等待用户输入扩展 ID
echo -e "${YELLOW}请输入扩展 ID安装后显示的 ID${NC}"
read -p "> " EXTENSION_ID
if [[ -z "$EXTENSION_ID" ]]; then
echo -e "${RED}✗ 未输入扩展 ID${NC}"
echo -e "${YELLOW}你可以稍后手动更新 Native Host 配置${NC}"
else
# 更新 manifest 文件,添加具体的扩展 ID
cat > "$MANIFEST_DIR/com.qwen.cli.bridge.json" << EOF
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$SCRIPT_DIR/../native-host/src/host.js",
"type": "stdio",
"allowed_origins": [
"chrome-extension://$EXTENSION_ID/",
"chrome-extension://*/"
]
}
EOF
# 保存扩展 ID 供后续使用
echo "$EXTENSION_ID" > "$EXTENSION_ID_FILE"
echo -e "${GREEN}${NC} Native Host 已更新,支持扩展 ID: $EXTENSION_ID"
fi
echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} ✅ 首次安装完成! ${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "现在你可以:"
echo ""
echo -e " 1. 运行 ${CYAN}npm run dev${NC} 启动调试环境"
echo -e " 2. 点击 Chrome 工具栏的插件图标开始使用"
echo ""
echo -e "${YELLOW}提示:${NC}"
echo -e " • 如果看不到插件图标,点击拼图图标并固定插件"
echo -e " • 首次连接可能需要刷新页面"
echo ""
# 询问是否立即启动
echo -e "${CYAN}是否立即启动调试环境?(y/n)${NC}"
read -p "> " START_NOW
if [[ "$START_NOW" == "y" ]] || [[ "$START_NOW" == "Y" ]]; then
echo -e "\n${GREEN}正在启动调试环境...${NC}\n"
exec "$SCRIPT_DIR/debug.sh"
fi

View File

@@ -1,24 +0,0 @@
#!/bin/bash
echo "🔧 配置 Native Host 使用特定扩展 ID..."
EXTENSION_ID="cimaabkejokbhjkdnajgfniiolfjgbhd"
CONFIG_FILE="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.qwen.cli.bridge.json"
RUN_SCRIPT="$PWD/native-host/run.sh"
# 创建配置(使用特定扩展 ID
cat > "$CONFIG_FILE" <<EOF
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$RUN_SCRIPT",
"type": "stdio",
"allowed_origins": [
"chrome-extension://$EXTENSION_ID/"
]
}
EOF
echo "✅ 配置已更新(仅允许扩展 ID: $EXTENSION_ID"
echo ""
cat "$CONFIG_FILE"

View File

@@ -1,307 +0,0 @@
#!/bin/bash
# 快速启动脚本 - 适用于 macOS/Linux
# 一键启动所有服务进行调试
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 打印带颜色的消息
print_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[✓]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[!]${NC} $1"
}
print_error() {
echo -e "${RED}[✗]${NC} $1"
}
# 获取脚本所在目录
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
EXTENSION_DIR="${EXTENSION_OUT_DIR:-"$SCRIPT_DIR/dist/extension"}"
NATIVE_HOST_DIR="$SCRIPT_DIR/native-host"
# 清屏并显示标题
clear
echo "======================================"
echo " Qwen CLI Chrome Extension - Quick Start"
echo "======================================"
echo ""
# 1. 检查 Chrome 是否安装
print_info "Checking Chrome installation..."
if [[ "$OSTYPE" == "darwin"* ]]; then
CHROME_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
if [[ ! -f "$CHROME_PATH" ]]; then
CHROME_PATH="/Applications/Chromium.app/Contents/MacOS/Chromium"
fi
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
CHROME_PATH=$(which google-chrome || which chromium-browser || which chromium || echo "")
fi
if [[ -z "$CHROME_PATH" ]] || [[ ! -f "$CHROME_PATH" ]]; then
print_error "Chrome not found! Please install Google Chrome first."
exit 1
fi
print_success "Chrome found: $CHROME_PATH"
# 2. 快速安装 Native Host (如果需要)
print_info "Setting up Native Host..."
if [[ "$OSTYPE" == "darwin"* ]]; then
MANIFEST_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
MANIFEST_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
fi
mkdir -p "$MANIFEST_DIR"
# 创建 manifest
cat > "$MANIFEST_DIR/com.qwen.cli.bridge.json" << EOF
{
"name": "com.qwen.cli.bridge",
"description": "Native messaging host for Qwen CLI Chrome Extension",
"path": "$NATIVE_HOST_DIR/host.js",
"type": "stdio",
"allowed_origins": [
"chrome-extension://*/",
"chrome-extension://jniepomhbdkeifkadbfolbcihcmfpfjo/"
]
}
EOF
print_success "Native Host configured"
# 3. 检查 Qwen CLI
print_info "Checking Qwen CLI..."
if command -v qwen &> /dev/null; then
print_success "Qwen CLI is installed"
QWEN_VERSION=$(qwen --version 2>/dev/null || echo "unknown")
print_info "Version: $QWEN_VERSION"
# 尝试启动 Qwen server
print_info "Starting Qwen server on port 8080..."
# 检查端口是否被占用
if lsof -i:8080 &> /dev/null; then
print_warning "Port 8080 is already in use, skipping Qwen server start"
else
# 在后台启动 Qwen server
nohup qwen server --port 8080 > /tmp/qwen-server.log 2>&1 &
QWEN_PID=$!
sleep 2
if kill -0 $QWEN_PID 2>/dev/null; then
print_success "Qwen server started (PID: $QWEN_PID)"
echo $QWEN_PID > /tmp/qwen-server.pid
else
print_warning "Failed to start Qwen server, continuing anyway..."
fi
fi
else
print_warning "Qwen CLI not installed - some features will be limited"
fi
# 4. 启动简单的测试服务器
print_info "Starting test server..."
# 创建简单的 Python HTTP 服务器
cat > /tmp/test-server.py << 'EOF'
#!/usr/bin/env python3
import http.server
import socketserver
PORT = 3000
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>Qwen CLI Chrome Extension Test</title>
<style>
body {
font-family: Arial;
padding: 40px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.container {
background: white;
color: #333;
padding: 30px;
border-radius: 10px;
max-width: 800px;
margin: 0 auto;
}
h1 { color: #667eea; }
button {
background: #667eea;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
margin: 5px;
cursor: pointer;
}
button:hover { opacity: 0.9; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 Qwen CLI Chrome Extension Test Page</h1>
<p>Extension debugging environment is ready!</p>
<h2>Quick Tests</h2>
<button onclick="console.log('Test log message')">Test Console Log</button>
<button onclick="console.error('Test error message')">Test Console Error</button>
<button onclick="fetch('/api/test').catch(e => console.error(e))">Test Network Request</button>
<h2>Instructions</h2>
<ol>
<li>Click the extension icon in Chrome toolbar</li>
<li>Click "Connect to Qwen CLI"</li>
<li>Try the various features</li>
<li>Open DevTools (F12) to see console output</li>
</ol>
<h2>Sample Content</h2>
<p>This is sample text content that can be extracted by the extension.</p>
<ul>
<li>Item 1: Lorem ipsum dolor sit amet</li>
<li>Item 2: Consectetur adipiscing elit</li>
<li>Item 3: Sed do eiusmod tempor</li>
</ul>
</div>
<script>
console.log('Test page loaded successfully');
console.info('Ready for debugging');
</script>
</body>
</html>
"""
class MyHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(html_content.encode())
with socketserver.TCPServer(("", PORT), MyHandler) as httpd:
print(f"Test server running at http://localhost:{PORT}")
httpd.serve_forever()
EOF
python3 /tmp/test-server.py > /tmp/test-server.log 2>&1 &
TEST_SERVER_PID=$!
echo $TEST_SERVER_PID > /tmp/test-server.pid
sleep 1
print_success "Test server started at http://localhost:3000"
# 5. 启动 Chrome
print_info "Starting Chrome with extension..."
# Ensure extension is built
if [[ ! -d "$EXTENSION_DIR" ]]; then
echo "Extension output not found at $EXTENSION_DIR"
echo "Please run: EXTENSION_OUT_DIR=dist/extension npm run build"
exit 1
fi
# Chrome 参数
CHROME_ARGS=(
"--load-extension=$EXTENSION_DIR"
"--auto-open-devtools-for-tabs"
"--no-first-run"
"--no-default-browser-check"
"--disable-default-apps"
"http://localhost:3000"
)
# 启动 Chrome
"$CHROME_PATH" "${CHROME_ARGS[@]}" &
CHROME_PID=$!
print_success "Chrome started with extension loaded"
# 6. 显示状态和清理指令
echo ""
echo "======================================"
echo " ✅ All Services Running"
echo "======================================"
echo ""
echo "📌 Chrome: Running (PID: $CHROME_PID)"
echo "📌 Test Page: http://localhost:3000"
if [[ -n "${QWEN_PID:-}" ]]; then
echo "📌 Qwen Server: http://localhost:8080 (PID: $QWEN_PID)"
fi
echo "📌 Extension: Loaded in Chrome toolbar"
echo ""
echo "📝 Debug Locations:"
echo " • Extension Logs: Chrome DevTools Console"
echo " • Background Page: chrome://extensions → Service Worker"
echo " • Native Host Log: \$HOME/.qwen/chrome-bridge/qwen-bridge-host.log (fallback: /tmp/qwen-bridge-host.log)"
if [[ -n "${QWEN_PID:-}" ]]; then
echo " • Qwen Server Log: /tmp/qwen-server.log"
fi
echo ""
echo "🛑 To stop all services, run: $SCRIPT_DIR/stop.sh"
echo " Or press Ctrl+C to stop this script"
echo ""
# 创建停止脚本
cat > "$SCRIPT_DIR/stop.sh" << 'STOP_SCRIPT'
#!/bin/bash
echo "Stopping services..."
# 停止 Qwen server
if [[ -f /tmp/qwen-server.pid ]]; then
PID=$(cat /tmp/qwen-server.pid)
if kill -0 $PID 2>/dev/null; then
kill $PID
echo "✓ Qwen server stopped"
fi
rm /tmp/qwen-server.pid
fi
# 停止测试服务器
if [[ -f /tmp/test-server.pid ]]; then
PID=$(cat /tmp/test-server.pid)
if kill -0 $PID 2>/dev/null; then
kill $PID
echo "✓ Test server stopped"
fi
rm /tmp/test-server.pid
fi
echo "✓ All services stopped"
STOP_SCRIPT
chmod +x "$SCRIPT_DIR/stop.sh"
# 等待用户中断
trap 'echo "Stopping services..."; $SCRIPT_DIR/stop.sh; exit 0' INT TERM
# 保持脚本运行
while true; do
sleep 1
done

View File

@@ -1,124 +0,0 @@
#!/usr/bin/env node
/**
* 将源代码同步到目标扩展目录(默认 dist/extension 或通过 EXTENSION_OUT_DIR/--target 指定)。
* - 复制 public 下的静态资源(排除 sidepanel/dist 旧构建)
* - 用 src/ 下的 background、content 覆盖对应目录
* 支持 --watch 监听变更(不清空输出,便于与 esbuild --watch 共存)。
*/
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
import { watch } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..');
const args = process.argv.slice(2);
const isWatch = args.includes('--watch');
const targetArg = args.find((arg) => arg.startsWith('--target='));
const targetDir = path.resolve(
projectRoot,
targetArg
? targetArg.split('=')[1]
: process.env.EXTENSION_OUT_DIR || 'extension',
);
const staticSrcDir = path.join(projectRoot, 'public');
const copyPairs = [
['src/background', 'background'],
['src/content', 'content'],
];
async function copyStatic(clean = false) {
if (clean) {
await fs.rm(targetDir, { recursive: true, force: true });
}
await fs.mkdir(targetDir, { recursive: true });
await fs.cp(staticSrcDir, targetDir, {
recursive: true,
filter: (src) => {
// 跳过旧的 sidepanel/dist 构建产物,交由 esbuild 重新生成
return !src.includes(`${path.sep}sidepanel${path.sep}dist${path.sep}`);
},
});
console.log(
`Static assets synced -> ${path.relative(projectRoot, targetDir)}`,
);
}
async function copySources() {
for (const [src, destRelative] of copyPairs) {
const srcPath = path.join(projectRoot, src);
const destPath = path.join(targetDir, destRelative);
await fs.mkdir(destPath, { recursive: true });
await fs.cp(srcPath, destPath, { recursive: true });
console.log(`Synced ${src} -> ${path.relative(projectRoot, destPath)}`);
}
}
async function syncAll({ clean } = { clean: false }) {
await copyStatic(clean);
await copySources();
}
function startWatchers() {
const watchTargets = [
path.join(projectRoot, 'public'),
path.join(projectRoot, 'src', 'background'),
path.join(projectRoot, 'src', 'content'),
];
let syncing = false;
let pending = false;
const triggerSync = (reason = 'change') => {
if (syncing) {
pending = true;
return;
}
syncing = true;
syncAll({ clean: false })
.then(() => console.log(`[watch] synced after ${reason}`))
.catch((err) => console.error('Sync error:', err))
.finally(() => {
syncing = false;
if (pending) {
pending = false;
triggerSync('pending change');
}
});
};
watchTargets.forEach((dir) => {
watch(dir, { recursive: true }, (_, filename) => {
if (
filename &&
filename.includes(`${path.sep}sidepanel${path.sep}dist${path.sep}`)
) {
// 让 esbuild 管理 sidepanel/dist 输出
return;
}
triggerSync(`${path.relative(projectRoot, dir)}/${filename || ''}`);
});
});
console.log(
`Watching extension sources -> ${path.relative(projectRoot, targetDir)} (sidepanel/dist excluded)`,
);
}
async function main() {
await syncAll({ clean: !isWatch });
if (isWatch) {
startWatchers();
}
}
main().catch((err) => {
console.error('Failed to sync extension assets:', err);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,877 +0,0 @@
/* global window, document, console, chrome, setTimeout, NodeFilter, Node, URL, MouseEvent, InputEvent, Event, module */
/**
* Content Script for Qwen CLI Chrome Extension
* Extracts data from web pages and communicates with background script
*/
if (window.__QWEN_BRIDGE_CONTENT_SCRIPT_LOADED__) {
console.debug('Qwen Bridge content script already loaded, skipping.');
} else {
window.__QWEN_BRIDGE_CONTENT_SCRIPT_LOADED__ = true;
// Data extraction functions
function extractPageData() {
const data = {
// Basic page info
url: window.location.href,
title: document.title,
domain: window.location.hostname,
path: window.location.pathname,
timestamp: new Date().toISOString(),
// Meta information
meta: {},
// Page content
content: {
text: '',
html: '',
markdown: '',
},
// Structured data
links: [],
images: [],
forms: [],
// Console logs
consoleLogs: [],
// Performance metrics
performance: {},
};
// Extract meta tags
document.querySelectorAll('meta').forEach((meta) => {
const name = meta.getAttribute('name') || meta.getAttribute('property');
const content = meta.getAttribute('content');
if (name && content) {
data.meta[name] = content;
}
});
// Extract main content (try to find article or main element first)
const mainContent =
document.querySelector('article, main, [role="main"]') || document.body;
data.content.text = extractTextContent(mainContent);
data.content.html = mainContent.innerHTML;
data.content.markdown = htmlToMarkdown(mainContent);
// Extract all links
document.querySelectorAll('a[href]').forEach((link) => {
data.links.push({
text: link.textContent.trim(),
href: link.href,
target: link.target,
isExternal: isExternalLink(link.href),
});
});
// Extract all images
document.querySelectorAll('img').forEach((img) => {
data.images.push({
src: img.src,
alt: img.alt,
title: img.title,
width: img.naturalWidth,
height: img.naturalHeight,
});
});
// Extract form data (structure only, no sensitive data)
document.querySelectorAll('form').forEach((form) => {
const formData = {
action: form.action,
method: form.method,
fields: [],
};
form.querySelectorAll('input, textarea, select').forEach((field) => {
formData.fields.push({
type: field.type || field.tagName.toLowerCase(),
name: field.name,
id: field.id,
placeholder: field.placeholder,
required: field.required,
});
});
data.forms.push(formData);
});
// Get performance metrics
if (window.performance && window.performance.timing) {
const perf = window.performance.timing;
data.performance = {
loadTime: perf.loadEventEnd - perf.navigationStart,
domReady: perf.domContentLoadedEventEnd - perf.navigationStart,
firstPaint: getFirstPaintTime(),
};
}
return data;
}
// Extract clean text content
function extractTextContent(element) {
// Clone the element to avoid modifying the original
const clone = element.cloneNode(true);
// Remove script and style elements
clone
.querySelectorAll('script, style, noscript')
.forEach((el) => el.remove());
// Get text content and clean it up
let text = clone.textContent || '';
// Remove excessive whitespace
text = text.replace(/\s+/g, ' ').trim();
// Limit length to prevent excessive data
const maxLength = 50000; // 50KB limit
if (text.length > maxLength) {
text = text.substring(0, maxLength) + '...';
}
return text;
}
// Simple HTML to Markdown converter
function htmlToMarkdown(element) {
const clone = element.cloneNode(true);
// Remove script and style elements
clone
.querySelectorAll('script, style, noscript')
.forEach((el) => el.remove());
let markdown = '';
const walker = document.createTreeWalker(
clone,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
null,
false,
);
let node;
let listStack = [];
while ((node = walker.nextNode())) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text) {
markdown += text + ' ';
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
switch (node.tagName.toLowerCase()) {
case 'h1':
markdown += '\n# ' + node.textContent.trim() + '\n';
break;
case 'h2':
markdown += '\n## ' + node.textContent.trim() + '\n';
break;
case 'h3':
markdown += '\n### ' + node.textContent.trim() + '\n';
break;
case 'h4':
markdown += '\n#### ' + node.textContent.trim() + '\n';
break;
case 'h5':
markdown += '\n##### ' + node.textContent.trim() + '\n';
break;
case 'h6':
markdown += '\n###### ' + node.textContent.trim() + '\n';
break;
case 'p':
markdown += '\n' + node.textContent.trim() + '\n';
break;
case 'br':
markdown += '\n';
break;
case 'a': {
const href = node.getAttribute('href');
const text = node.textContent.trim();
if (href) {
markdown += `[${text}](${href}) `;
}
break;
}
case 'img': {
const src = node.getAttribute('src');
const alt = node.getAttribute('alt') || '';
if (src) {
markdown += `![${alt}](${src}) `;
}
break;
}
case 'ul':
case 'ol':
markdown += '\n';
listStack.push(node.tagName.toLowerCase());
break;
case 'li': {
const listType = listStack[listStack.length - 1];
const prefix = listType === 'ol' ? '1. ' : '- ';
markdown += prefix + node.textContent.trim() + '\n';
break;
}
case 'code':
markdown += '`' + node.textContent + '`';
break;
case 'pre':
markdown += '\n```\n' + node.textContent + '\n```\n';
break;
case 'blockquote':
markdown += '\n> ' + node.textContent.trim() + '\n';
break;
case 'strong':
case 'b':
markdown += '**' + node.textContent + '**';
break;
case 'em':
case 'i':
markdown += '*' + node.textContent + '*';
break;
}
}
}
// Limit markdown length
const maxLength = 30000;
if (markdown.length > maxLength) {
markdown = markdown.substring(0, maxLength) + '...';
}
return markdown.trim();
}
// Check if link is external
function isExternalLink(url) {
try {
const link = new URL(url);
return link.hostname !== window.location.hostname;
} catch {
return false;
}
}
// Get first paint time
function getFirstPaintTime() {
if (window.performance && window.performance.getEntriesByType) {
const paintEntries = window.performance.getEntriesByType('paint');
const firstPaint = paintEntries.find(
(entry) => entry.name === 'first-paint',
);
return firstPaint ? firstPaint.startTime : null;
}
return null;
}
// Console log interceptor
const consoleLogs = [];
const originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
};
// Intercept console methods
['log', 'error', 'warn', 'info'].forEach((method) => {
console[method] = function (...args) {
// Store the log
consoleLogs.push({
type: method,
message: args
.map((arg) => {
try {
if (typeof arg === 'object') {
return JSON.stringify(arg);
}
return String(arg);
} catch {
return String(arg);
}
})
.join(' '),
timestamp: new Date().toISOString(),
stack: new Error().stack,
});
// Keep only last 100 logs to prevent memory issues
if (consoleLogs.length > 100) {
consoleLogs.shift();
}
// Call original console method
originalConsole[method].apply(console, args);
};
});
// Get selected text
function getSelectedText() {
return window.getSelection().toString();
}
// Highlight element on page
function highlightElement(selector) {
try {
const element = document.querySelector(selector);
if (element) {
// Store original style
const originalStyle = element.style.cssText;
// Apply highlight
element.style.cssText += `
outline: 3px solid #FF6B6B !important;
background-color: rgba(255, 107, 107, 0.1) !important;
transition: all 0.3s ease !important;
`;
// Remove highlight after 3 seconds
setTimeout(() => {
element.style.cssText = originalStyle;
}, 3000);
return true;
}
return false;
} catch (error) {
console.error('Failed to highlight element:', error);
return false;
}
}
// Simulate a click on a selector
function clickElement(selector) {
if (!selector) {
return { success: false, error: 'No selector provided' };
}
const element = document.querySelector(selector);
if (!element) {
return {
success: false,
error: `Element not found for selector: ${selector}`,
};
}
try {
if (typeof element.scrollIntoView === 'function') {
element.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
const evtOptions = { bubbles: true, cancelable: true, composed: true };
['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((type) => {
try {
const evt = new MouseEvent(type, evtOptions);
element.dispatchEvent(evt);
} catch {
// ignore individual dispatch failures
}
});
return { success: true };
} catch (error) {
console.error('Failed to click element:', error);
return { success: false, error: error?.message || String(error) };
}
}
// Click element by visible text
function clickElementByText(text) {
if (!text) {
return { success: false, error: 'No text provided' };
}
const norm = text.toLowerCase().trim();
const candidates = Array.from(
document.querySelectorAll(
'button, a, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
),
);
for (const el of candidates) {
const txt = (el.textContent || '').toLowerCase().trim();
if (txt && txt.includes(norm)) {
try {
if (typeof el.scrollIntoView === 'function') {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
const evtOptions = {
bubbles: true,
cancelable: true,
composed: true,
};
['pointerdown', 'mousedown', 'mouseup', 'click'].forEach((type) => {
try {
const evt = new MouseEvent(type, evtOptions);
el.dispatchEvent(evt);
} catch {
/* ignore */
}
});
return { success: true };
} catch (error) {
return { success: false, error: error?.message || String(error) };
}
}
}
return { success: false, error: `No element found with text: ${text}` };
}
// Fill text into an input/textarea/contentEditable element
function fillInput(selector, text, options = {}) {
if (!selector) {
return { success: false, error: 'No selector provided' };
}
const element = document.querySelector(selector);
if (!element) {
return {
success: false,
error: `Element not found for selector: ${selector}`,
};
}
const clearExisting = options.clear !== false; // default: clear before typing
try {
// Scroll into view and focus
if (typeof element.scrollIntoView === 'function') {
element.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
if (typeof element.focus === 'function') {
element.focus({ preventScroll: true });
}
// Determine how to set text based on element type
const tag = element.tagName?.toLowerCase();
const isInput =
tag === 'input' || tag === 'textarea' || element.isContentEditable;
if (!isInput) {
return {
success: false,
error: 'Target is not an input, textarea, or contentEditable element',
};
}
// Helper to dispatch events so frameworks pick up the change
const dispatch = (name) => {
const evt =
name === 'input' && typeof InputEvent !== 'undefined'
? new InputEvent(name, {
bubbles: true,
cancelable: true,
composed: true,
})
: new Event(name, {
bubbles: true,
cancelable: true,
composed: true,
});
element.dispatchEvent(evt);
};
if (tag === 'input' || tag === 'textarea') {
if (clearExisting) element.value = '';
element.value = text ?? '';
} else if (element.isContentEditable) {
if (clearExisting) element.innerText = '';
element.innerText = text ?? '';
}
dispatch('input');
dispatch('change');
return {
success: true,
appliedText: text ?? '',
};
} catch (error) {
console.error('Failed to fill input:', error);
return { success: false, error: error.message };
}
}
// Execute custom JavaScript in page context
function executeInPageContext(code) {
try {
const script = document.createElement('script');
script.textContent = `
(function() {
try {
const result = ${code};
window.postMessage({
type: 'QWEN_BRIDGE_RESULT',
success: true,
result: result
}, '*');
} catch (error) {
window.postMessage({
type: 'QWEN_BRIDGE_RESULT',
success: false,
error: error.message
}, '*');
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
return new Promise((resolve, reject) => {
const listener = (event) => {
if (event.data && event.data.type === 'QWEN_BRIDGE_RESULT') {
window.removeEventListener('message', listener);
if (event.data.success) {
resolve(event.data.result);
} else {
reject(new Error(event.data.error));
}
}
};
window.addEventListener('message', listener);
// Timeout after 5 seconds
setTimeout(() => {
window.removeEventListener('message', listener);
reject(new Error('Execution timeout'));
}, 5000);
});
} catch (error) {
return Promise.reject(error);
}
}
const TEXT_INPUT_TYPES = new Set([
'text',
'email',
'search',
'tel',
'url',
'number',
'password',
]);
function isWritableElement(el) {
if (!el || typeof el !== 'object') return false;
const tag = el.tagName?.toLowerCase();
if (tag === 'textarea') return true;
if (tag === 'input') {
const type = (el.type || '').toLowerCase();
return TEXT_INPUT_TYPES.has(type) || type === '' || type === 'date';
}
if (el.isContentEditable) return true;
return false;
}
function setElementText(
el,
text,
{ mode = 'replace', simulateEvents = true, focus = false } = {},
) {
if (!isWritableElement(el)) {
throw new Error('Element is not writable');
}
const applyValue = () => {
if (el.isContentEditable) {
el.innerText =
mode === 'append' ? `${el.innerText || ''}${text}` : text;
} else if (
el.tagName?.toLowerCase() === 'textarea' ||
el.tagName?.toLowerCase() === 'input'
) {
el.value = mode === 'append' ? `${el.value || ''}${text}` : text;
}
};
if (focus) {
try {
el.focus();
} catch {
// ignore focus failures
}
}
applyValue();
if (simulateEvents) {
const events = ['input', 'change'];
events.forEach((type) => {
try {
const evt = new Event(type, { bubbles: true });
el.dispatchEvent(evt);
} catch {
// ignore dispatch failures
}
});
try {
el.blur();
} catch {
// ignore blur failures
}
}
}
function normalizeText(str) {
return String(str || '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function findElementByLabel(labelText) {
const labelNorm = normalizeText(labelText);
if (!labelNorm) return null;
// <label> text -> for/id
const labels = Array.from(document.querySelectorAll('label')).filter(
(lbl) => normalizeText(lbl.textContent).includes(labelNorm),
);
for (const lbl of labels) {
const forId = lbl.getAttribute('for');
if (forId) {
const target = document.getElementById(forId);
if (target && isWritableElement(target)) return target;
}
const input = lbl.querySelector(
'input, textarea, [contenteditable="true"]',
);
if (input && isWritableElement(input)) return input;
}
// aria-label / placeholder / name fallback
const candidates = Array.from(
document.querySelectorAll('input, textarea, [contenteditable="true"]'),
);
for (const el of candidates) {
const aria = normalizeText(el.getAttribute?.('aria-label'));
const placeholder = normalizeText(el.getAttribute?.('placeholder'));
const name = normalizeText(el.getAttribute?.('name'));
if (
(aria && aria.includes(labelNorm)) ||
(placeholder && placeholder.includes(labelNorm)) ||
(name && name.includes(labelNorm))
) {
if (isWritableElement(el)) return el;
}
}
return null;
}
function describeElement(el) {
if (!el) return 'unknown';
const tag = (el.tagName || '').toLowerCase();
const id = el.id ? `#${el.id}` : '';
const name = el.name ? `[name="${el.name}"]` : '';
const aria = el.getAttribute?.('aria-label')
? `[aria-label="${el.getAttribute('aria-label')}"]`
: '';
const placeholder = el.getAttribute?.('placeholder')
? `[placeholder="${el.getAttribute('placeholder')}"]`
: '';
return `${tag}${id}${name}${aria}${placeholder}` || tag || 'element';
}
function fillInputs(entries) {
if (!Array.isArray(entries)) {
throw new Error('entries must be an array');
}
const results = [];
entries.forEach((entry, idx) => {
const {
selector,
label,
text,
mode = 'replace',
simulateEvents = true,
focus = false,
} = entry || {};
const result = {
index: idx,
selector: selector || null,
label: label || null,
success: false,
message: '',
target: null,
};
try {
if (text === undefined || text === null) {
throw new Error('text is required');
}
const textValue = String(text);
let target = null;
if (selector) {
target = document.querySelector(selector);
}
if (!target && label) {
target = findElementByLabel(label);
}
if (!target) {
throw new Error('Target element not found');
}
if (!isWritableElement(target)) {
throw new Error('Target element is not writable');
}
setElementText(target, textValue, { mode, simulateEvents, focus });
result.success = true;
result.target = describeElement(target);
result.message = 'Filled successfully';
} catch (err) {
result.success = false;
result.message = err?.message || String(err);
}
results.push(result);
});
return results;
}
// Message listener for communication with background script
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log('Content script received message:', request);
switch (request.type) {
case 'EXTRACT_DATA': {
// Extract and send page data
const pageData = extractPageData();
pageData.consoleLogs = consoleLogs;
sendResponse({
success: true,
data: pageData,
});
break;
}
case 'GET_CONSOLE_LOGS':
// Get captured console logs
sendResponse({
success: true,
data: consoleLogs.slice(), // Return a copy
});
break;
case 'GET_SELECTED_TEXT':
// Get currently selected text
sendResponse({
success: true,
data: getSelectedText(),
});
break;
case 'FILL_INPUTS': {
try {
const results = fillInputs(request.entries || []);
sendResponse({
success: true,
data: { results },
});
} catch (error) {
sendResponse({
success: false,
error: error?.message || String(error),
});
}
break;
}
case 'HIGHLIGHT_ELEMENT': {
// Highlight an element on the page
const highlighted = highlightElement(request.selector);
sendResponse({
success: highlighted,
});
break;
}
case 'CLICK_ELEMENT': {
const result = clickElement(request.selector);
sendResponse(result);
break;
}
case 'CLICK_TEXT': {
const result = clickElementByText(request.text);
sendResponse(result);
break;
}
case 'FILL_INPUT': {
const result = fillInput(request.selector, request.text, {
clear: request.clear,
});
sendResponse(result);
break;
}
case 'EXECUTE_CODE':
// Execute JavaScript in page context
executeInPageContext(request.code)
.then((result) => {
sendResponse({
success: true,
data: result,
});
})
.catch((error) => {
sendResponse({
success: false,
error: error.message,
});
});
return true; // Will respond asynchronously
case 'SCROLL_TO':
// Scroll to specific position
window.scrollTo({
top: request.y || 0,
left: request.x || 0,
behavior: request.smooth ? 'smooth' : 'auto',
});
sendResponse({ success: true });
break;
case 'QWEN_EVENT':
// Handle events from Qwen CLI
console.log('Qwen event received:', request.event);
// Could trigger UI updates or other actions based on event
break;
default:
sendResponse({
success: false,
error: 'Unknown request type',
});
}
});
// Notify background script that content script is loaded
chrome.runtime
.sendMessage({
type: 'CONTENT_SCRIPT_LOADED',
url: window.location.href,
})
.catch(() => {
// Ignore errors if background script is not ready
});
// Export for testing
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
extractPageData,
extractTextContent,
htmlToMarkdown,
getSelectedText,
highlightElement,
fillInput,
};
}
}

View File

@@ -1,845 +0,0 @@
/**
* Chrome Extension Side Panel App
* Simplified version adapted from vscode-ide-companion
*/
import type React from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useVSCode } from './hooks/useVSCode.js';
import { InputForm } from './components/layout/InputForm.js';
import { EmptyState } from './components/layout/EmptyState.js';
import {
UserMessage,
AssistantMessage,
WaitingMessage,
} from './components/messages/index.js';
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
import type {
PermissionOption,
ToolCall,
} from './components/PermissionDrawer/PermissionRequest.js';
interface Message {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
interface McpTool {
name: string;
description: string;
// Add other properties as needed based on the actual structure
}
interface InternalTool {
name: string;
description: string;
// Add other properties as needed based on the actual structure
}
export const App: React.FC = () => {
const vscode = useVSCode();
// State
const [messages, setMessages] = useState<Message[]>([]);
const [inputText, setInputText] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const [loadingMessage, setLoadingMessage] = useState<string | null>(null);
const [streamingContent, setStreamingContent] = useState('');
// Debug: cache slash-commands (available_commands_update) & MCP tools list
const [mcpTools, setMcpTools] = useState<McpTool[]>([]);
const [internalTools, setInternalTools] = useState<InternalTool[]>([]);
const [showToolsPanel, setShowToolsPanel] = useState(false);
const [authUri, setAuthUri] = useState<string | null>(null);
const [isComposing, setIsComposing] = useState(false);
const [permissionRequest, setPermissionRequest] = useState<{
requestId: number;
options: PermissionOption[];
toolCall: ToolCall;
} | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputFieldRef = useRef<HTMLDivElement>(null);
const autoConnectAttemptedRef = useRef(false);
// Auto-scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
// Listen for messages from background script
useEffect(() => {
const handleMessage = (message: { type: string; data?: unknown }) => {
console.log('[App] Received message:', message);
switch (message.type) {
case 'STATUS_UPDATE': {
const statusData = message.data as { status: string } | undefined;
if (statusData && 'status' in statusData) {
setIsConnected(statusData.status !== 'disconnected');
} else {
setIsConnected(false); // default to disconnected if status data is missing
}
break;
}
case 'hostInfo': {
const messageAny = message as { data?: unknown };
console.log('[HostInfo]', messageAny.data);
break;
}
case 'hostLog': {
const logMessage = message as { data?: { line?: string } };
const line = logMessage.data?.line;
if (line) console.log('[HostLog]', line);
break;
}
case 'authUpdate': {
const authMessage = message as { data?: { authUri?: string } };
const uri = authMessage.data?.authUri;
if (uri) setAuthUri(uri);
break;
}
case 'mcpTools': {
const toolMessage: { data?: { tools?: McpTool[] } } = message as {
data?: { tools?: McpTool[] };
};
const tools = toolMessage.data?.tools || [];
setMcpTools(tools);
console.log('[App] MCP tools:', tools);
break;
}
case 'internalMcpTools': {
const internalToolMessage: { data?: { tools?: InternalTool[] } } =
message as { data?: { tools?: InternalTool[] } };
const tools = internalToolMessage.data?.tools || [];
setInternalTools(tools);
console.log('[App] Internal MCP tools:', tools);
break;
}
case 'toolProgress': {
const payload =
(
message as {
data?: {
name?: string;
stage?: string;
ok?: boolean;
error?: string;
};
}
).data || {};
const name = payload.name || '';
const stage = payload.stage || '';
const ok = payload.ok;
const pretty = (n: string) => {
switch (n) {
case 'read_page':
return 'Read Page';
case 'capture_screenshot':
return 'Capture Screenshot';
case 'get_network_logs':
return 'Get Network Logs';
case 'get_console_logs':
return 'Get Console Logs';
default:
return n;
}
};
if (stage === 'start') {
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Running tool: ${pretty(name)}`,
timestamp: Date.now(),
},
]);
} else if (stage === 'end') {
const endText =
ok === false
? `Tool failed: ${pretty(name)}${payload.error ? `${payload.error}` : ''}`
: `Tool finished: ${pretty(name)}`;
setMessages((prev) => [
...prev,
{ role: 'assistant', content: endText, timestamp: Date.now() },
]);
}
break;
}
case 'streamStart': {
setIsStreaming(true);
setIsWaitingForResponse(false);
setStreamingContent('');
break;
}
case 'streamChunk': {
const chunkMessage = message as { data: { chunk: string } };
setStreamingContent(
(prev) => prev + (chunkMessage.data?.chunk || ''),
);
break;
}
case 'streamEnd': {
setStreamingContent((current) => {
if (current) {
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: current,
timestamp: Date.now(),
},
]);
}
return '';
});
setIsStreaming(false);
setIsWaitingForResponse(false);
break;
}
case 'message': {
const msgData = (message as { data: Message }).data;
if (msgData) {
setMessages((prev) => [
...prev,
{
role: msgData.role,
content: msgData.content,
timestamp: msgData.timestamp || Date.now(),
},
]);
}
break;
}
case 'error': {
setIsStreaming(false);
setIsWaitingForResponse(false);
setLoadingMessage(null);
break;
}
case 'permissionRequest': {
// Handle permission request from Qwen CLI
console.log('[App] Permission request:', message);
const permData = (
message as {
data: {
requestId: number;
options: PermissionOption[];
toolCall: ToolCall;
};
}
).data;
if (permData) {
setPermissionRequest({
requestId: permData.requestId,
options: permData.options,
toolCall: permData.toolCall,
});
}
break;
}
default:
// Handle unknown message types
console.log('[App] Unknown message type:', message.type);
break;
}
};
// Add Chrome message listener
if (typeof chrome !== 'undefined' && chrome.runtime) {
chrome.runtime.onMessage.addListener(handleMessage);
return () => {
chrome.runtime.onMessage.removeListener(handleMessage);
};
}
}, [streamingContent]);
// Check connection status on mount
useEffect(() => {
const checkStatus = async () => {
const response = (await vscode.postMessage({ type: 'GET_STATUS' })) as {
connected?: boolean;
status?: string;
mcpTools?: McpTool[];
internalTools?: InternalTool[];
} | null;
if (response) {
setIsConnected(response.connected || false);
if (Array.isArray(response.mcpTools)) {
setMcpTools(response.mcpTools);
}
if (Array.isArray(response.internalTools)) {
setInternalTools(response.internalTools);
}
}
};
checkStatus();
}, [vscode]);
// Auto-connect once on mount/when disconnected (defined after handleConnect to avoid TDZ)
// Handle submit
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const text = inputText.trim();
if (!text || isStreaming || isWaitingForResponse) return;
// Add user message
setMessages((prev) => [
...prev,
{
role: 'user',
content: text,
timestamp: Date.now(),
},
]);
// Clear input
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
// Send to background
setIsWaitingForResponse(true);
setLoadingMessage('Thinking...');
await vscode.postMessage({
type: 'sendMessage',
data: { text },
});
},
[inputText, isStreaming, isWaitingForResponse, vscode],
);
// Handle cancel
const handleCancel = useCallback(async () => {
await vscode.postMessage({ type: 'cancelStreaming', data: {} });
setIsStreaming(false);
setIsWaitingForResponse(false);
setLoadingMessage(null);
}, [vscode]);
// Handle connect
const handleConnect = useCallback(async () => {
setLoadingMessage('Connecting...');
const response = (await vscode.postMessage({ type: 'CONNECT' })) as {
success?: boolean;
status?: string;
} | null;
if (response?.success) {
setIsConnected(true);
setLoadingMessage(null);
} else {
setLoadingMessage('Connection failed');
setTimeout(() => setLoadingMessage(null), 3000);
}
}, [vscode]);
// Auto-connect once on mount/when disconnected
useEffect(() => {
if (!isConnected && !autoConnectAttemptedRef.current) {
autoConnectAttemptedRef.current = true;
(async () => {
try {
await handleConnect();
} catch (err) {
console.error('[AutoConnect] failed', err);
autoConnectAttemptedRef.current = false;
setLoadingMessage(null);
}
})();
}
}, [isConnected, handleConnect]);
// Read current page and ask Qwen to analyze (bypasses MCP; uses content-script extractor)
/* const handleReadPage = useCallback(async () => {
try {
setIsWaitingForResponse(true);
setLoadingMessage('Reading page...');
const extract = (await vscode.postMessage({
type: 'EXTRACT_PAGE_DATA',
})) as any;
if (!extract || !extract.success) {
setIsWaitingForResponse(false);
setLoadingMessage(null);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Read Page failed: ${extract?.error || 'unknown error'}`,
timestamp: Date.now(),
},
]);
return;
}
await vscode.postMessage({
type: 'SEND_TO_QWEN',
action: 'analyze_page',
data: extract.data,
});
// streamStart will arrive from service worker; keep waiting state until it starts streaming
} catch (err: any) {
setIsWaitingForResponse(false);
setLoadingMessage(null);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Read Page error: ${err?.message || String(err)}`,
timestamp: Date.now(),
},
]);
}
}, [vscode]); */
// Get network logs and send to Qwen to analyze (bypasses MCP; uses debugger API)
/* const handleGetNetworkLogs = useCallback(async () => {
try {
setIsWaitingForResponse(true);
setLoadingMessage('Collecting network logs...');
const resp = (await vscode.postMessage({
type: 'GET_NETWORK_LOGS',
})) as any;
if (!resp || !resp.success) {
setIsWaitingForResponse(false);
setLoadingMessage(null);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Get Network Logs failed: ${resp?.error || 'unknown error'}`,
timestamp: Date.now(),
},
]);
return;
}
const logs = resp.data || resp.logs || [];
const summary = Array.isArray(logs) ? logs.slice(-50) : [];
const text =
`Network logs (last ${summary.length} entries):\n` +
JSON.stringify(
summary.map((l: any) => ({
method: l.method,
url: l.params?.request?.url || l.params?.documentURL,
status: l.params?.response?.status,
timestamp: l.timestamp,
})),
null,
2,
);
// Show a short message to user
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: 'Running tool: Get Network Logs…',
timestamp: Date.now(),
},
]);
// Ask Qwen to analyze
await vscode.postMessage({
type: 'SEND_TO_QWEN',
action: 'ai_analyze',
data: {
pageData: { content: { text } },
prompt:
'Please analyze these network logs, list failed or slow requests and possible causes.',
},
});
} catch (err: any) {
setIsWaitingForResponse(false);
setLoadingMessage(null);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Get Network Logs error: ${err?.message || String(err)}`,
timestamp: Date.now(),
},
]);
}
}, [vscode]); */
// Handle permission response
const handlePermissionResponse = useCallback(
(optionId: string) => {
if (!permissionRequest) return;
console.log(
'[App] Sending permission response:',
optionId,
'for requestId:',
permissionRequest.requestId,
);
vscode.postMessage({
type: 'permissionResponse',
data: {
requestId: permissionRequest.requestId,
optionId,
},
});
setPermissionRequest(null);
},
[vscode, permissionRequest],
);
// Get console logs and send to Qwen to analyze (bypasses MCP; uses content-script capture)
/* const handleGetConsoleLogs = useCallback(async () => {
try {
setIsWaitingForResponse(true);
setLoadingMessage('Collecting console logs...');
const resp = (await vscode.postMessage({
type: 'GET_CONSOLE_LOGS',
})) as any;
if (!resp || !resp.success) {
setIsWaitingForResponse(false);
setLoadingMessage(null);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Get Console Logs failed: ${resp?.error || 'unknown error'}`,
timestamp: Date.now(),
},
]);
return;
}
const logs = resp.data || [];
const formatted = logs
.slice(-50)
.map((l: any) => `[${l.type}] ${l.message}`)
.join('\n');
const text = `Console logs (last ${Math.min(logs.length, 50)} entries):
${formatted || '(no logs captured)'}`;
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: 'Running tool: Get Console Logs…',
timestamp: Date.now(),
},
]);
await vscode.postMessage({
type: 'SEND_TO_QWEN',
action: 'ai_analyze',
data: {
pageData: { content: { text } },
prompt:
'Please analyze these console logs and summarize errors/warnings.',
},
});
} catch (err: any) {
setIsWaitingForResponse(false);
setLoadingMessage(null);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: `Get Console Logs error: ${err?.message || String(err)}`,
timestamp: Date.now(),
},
]);
}
}, [vscode]); */
const hasContent = messages.length > 0 || isStreaming || streamingContent;
return (
<div className="chat-container relative flex flex-col h-screen bg-[#1e1e1e] text-white">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-700">
<h1 className="text-sm font-medium">Qwen Code</h1>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-500'}`}
/>
<span className="text-xs text-gray-400">
{isConnected ? `Connected` : 'Disconnected'}
</span>
{/* {isConnected && (
<button
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
onClick={handleReadPage}
title="Read current page"
>
Read Page
</button>
)}
{isConnected && (
<button
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
onClick={handleGetNetworkLogs}
title="Get network logs"
>
Network Logs
</button>
)}
{isConnected && (
<button
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
onClick={handleGetConsoleLogs}
title="Get console logs"
>
Console Logs
</button>
)}
{isConnected && mcpTools.length + internalTools.length > 0 && (
<button
className="text-xs px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
onClick={() => setShowToolsPanel((v) => !v)}
title="Show available tools"
>
Tools
</button>
)} */}
</div>
</div>
{/* Messages */}
<div
ref={messagesContainerRef}
className="flex-1 overflow-y-auto p-4 pb-36 space-y-4"
>
{!hasContent ? (
<EmptyState
isAuthenticated={isConnected}
loadingMessage={!isConnected ? 'Click Connect to start' : undefined}
/>
) : (
<>
{messages.map((msg, index) =>
msg.role === 'user' ? (
<UserMessage
key={index}
content={msg.content}
timestamp={msg.timestamp}
onFileClick={() => {
// No action required
}}
/>
) : (
<AssistantMessage
key={index}
content={msg.content}
timestamp={msg.timestamp}
onFileClick={() => {
// No action required
}}
/>
),
)}
{/* Streaming message */}
{isStreaming && streamingContent && (
<AssistantMessage
content={streamingContent}
timestamp={Date.now()}
onFileClick={() => {}}
/>
)}
{/* Waiting indicator */}
{isWaitingForResponse && loadingMessage && (
<WaitingMessage loadingMessage={loadingMessage} />
)}
{/* If streaming started but no chunks yet, show thinking indicator */}
{isStreaming && !streamingContent && (
<WaitingMessage
loadingMessage={loadingMessage || 'Thinking...'}
/>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input */}
{isConnected ? (
<InputForm
inputText={inputText}
inputFieldRef={inputFieldRef as React.RefObject<HTMLDivElement>}
isStreaming={isStreaming}
isWaitingForResponse={isWaitingForResponse}
isComposing={isComposing}
editMode="default"
thinkingEnabled={false}
activeFileName={null}
activeSelection={null}
skipAutoActiveContext={true}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {
// No special key handling required
}}
onSubmit={handleSubmit}
onCancel={handleCancel}
onToggleEditMode={() => {
// No edit mode toggle required
}}
onToggleThinking={() => {
// No thinking mode toggle required
}}
onFocusActiveEditor={() => {
// No editor focus required
}}
onToggleSkipAutoActiveContext={() => {
// No context toggle required
}}
onShowCommandMenu={() => {
// No command menu required
}}
onAttachContext={() => {
// No context attachment required
}}
completionIsOpen={false}
completionItems={[]}
onCompletionSelect={() => {
// No completion selection required
}}
onCompletionClose={() => {
// No completion closing required
}}
/>
) : (
<div className="p-4 border-t border-gray-700">
<button
onClick={handleConnect}
className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 rounded text-white text-sm font-medium transition-colors"
>
Connect to Qwen CLI
</button>
</div>
)}
{/* Permission Request Drawer */}
{permissionRequest && (
<PermissionDrawer
isOpen={!!permissionRequest}
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
onClose={() => setPermissionRequest(null)}
/>
)}
{/* Auth Required banner */}
{authUri && (
<div className="absolute left-3 right-3 top-10 z-50 bg-[#2a2d2e] border border-yellow-600 text-yellow-200 rounded p-2 text-[12px] flex items-center justify-between gap-2">
<div>Authentication required. Click to sign in.</div>
<div className="flex items-center gap-2">
<button
className="px-2 py-0.5 rounded bg-yellow-700 hover:bg-yellow-600 text-white"
onClick={() => {
try {
chrome.tabs.create({ url: authUri });
} catch {
// Ignore errors when opening tab
}
}}
>
Open Link
</button>
<button
className="px-2 py-0.5 rounded bg-gray-700 hover:bg-gray-600"
onClick={() => setAuthUri(null)}
>
Dismiss
</button>
</div>
</div>
)}
{/* Debug: Tools panel */}
{showToolsPanel && mcpTools.length + internalTools.length > 0 && (
<div className="absolute right-3 top-10 z-50 max-w-[80%] w-[360px] max-h-[50vh] overflow-auto bg-[#2a2d2e] text-[13px] text-gray-200 border border-gray-700 rounded shadow-lg p-2">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold">
Available Tools ({mcpTools.length + internalTools.length})
</div>
<button
className="text-gray-400 hover:text-gray-200"
onClick={() => setShowToolsPanel(false)}
>
×
</button>
</div>
<div className="text-[11px] text-gray-400 mb-1">
Internal (chrome-browser)
</div>
<ul className="space-y-1 mb-2">
{internalTools.map(
(
t: InternalTool & {
tool?: { name?: string; description?: string };
},
i: number,
) => {
const name = (t && (t.name || t.tool?.name)) || String(t);
const desc =
(t && (t.description || t.tool?.description)) || '';
return (
<li
key={`internal-${i}`}
className="px-2 py-1 rounded hover:bg-[#3a3d3e]"
>
<div className="font-mono text-xs text-[#a6e22e] break-all">
{name}
</div>
{desc && (
<div className="text-[11px] text-gray-400 break-words">
{desc}
</div>
)}
</li>
);
},
)}
</ul>
<div className="text-[11px] text-gray-400 mb-1">Discovered (MCP)</div>
<ul className="space-y-1">
{mcpTools.map(
(
t: McpTool & { tool?: { name?: string; description?: string } },
i: number,
) => {
const name = (t && (t.name || t.tool?.name)) || String(t);
const desc =
(t && (t.description || t.tool?.description)) || '';
return (
<li
key={`discovered-${i}`}
className="px-2 py-1 rounded hover:bg-[#3a3d3e]"
>
<div className="font-mono text-xs text-[#a6e22e] break-all">
{name}
</div>
{desc && (
<div className="text-[11px] text-gray-400 break-words">
{desc}
</div>
)}
</li>
);
},
)}
</ul>
</div>
)}
</div>
);
};

View File

@@ -1,312 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState, useRef } from 'react';
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
interface PermissionDrawerProps {
isOpen: boolean;
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
onClose?: () => void;
}
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
isOpen,
options,
toolCall,
onResponse,
onClose,
}) => {
const [focusedIndex, setFocusedIndex] = useState(0);
const [customMessage, setCustomMessage] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
// Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting
const customInputRef = useRef<HTMLInputElement>(null);
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
// Prefer file name from locations, fall back to content[].path if present
const getAffectedFileName = (): string => {
const fromLocations = toolCall.locations?.[0]?.path;
if (fromLocations) {
return fromLocations.split('/').pop() || fromLocations;
}
// Some tool calls (e.g. write/edit with diff content) only include path in content
const fromContent = Array.isArray(toolCall.content)
? (
toolCall.content.find(
(c: unknown) =>
typeof c === 'object' &&
c !== null &&
'path' in (c as Record<string, unknown>),
) as { path?: unknown } | undefined
)?.path
: undefined;
if (typeof fromContent === 'string' && fromContent.length > 0) {
return fromContent.split('/').pop() || fromContent;
}
return 'file';
};
// Get the title for the permission request
const getTitle = () => {
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
const fileName = getAffectedFileName();
return (
<>
Make this edit to{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
?
</>
);
}
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
return 'Allow this bash command?';
}
if (toolCall.kind === 'read') {
const fileName = getAffectedFileName();
return (
<>
Allow read from{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
?
</>
);
}
return toolCall.title || 'Permission Required';
};
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) {
return;
}
// Number keys 1-9 for quick select
const numMatch = e.key.match(/^[1-9]$/);
if (
numMatch &&
!customInputRef.current?.contains(document.activeElement)
) {
const index = parseInt(e.key, 10) - 1;
if (index < options.length) {
e.preventDefault();
onResponse(options[index].optionId);
}
return;
}
// Arrow keys for navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const totalItems = options.length + 1; // +1 for custom input
if (e.key === 'ArrowDown') {
setFocusedIndex((prev) => (prev + 1) % totalItems);
} else {
setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems);
}
}
// Enter to select
if (
e.key === 'Enter' &&
!customInputRef.current?.contains(document.activeElement)
) {
e.preventDefault();
if (focusedIndex < options.length) {
onResponse(options[focusedIndex].optionId);
}
}
// Escape to cancel permission and close (align with CLI behavior)
if (e.key === 'Escape') {
e.preventDefault();
const rejectOptionId =
options.find((o) => o.kind.includes('reject'))?.optionId ||
options.find((o) => o.optionId === 'cancel')?.optionId ||
'cancel';
onResponse(rejectOptionId);
if (onClose) {
onClose();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, options, onResponse, onClose, focusedIndex]);
// Focus container when opened
useEffect(() => {
if (isOpen && containerRef.current) {
containerRef.current.focus();
}
}, [isOpen]);
// Reset focus to the first option when the drawer opens or the options change
useEffect(() => {
if (isOpen) {
setFocusedIndex(0);
}
}, [isOpen, options.length]);
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
{/* Main container */}
<div
ref={containerRef}
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
style={{
backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)',
}}
tabIndex={0}
data-focused-index={focusedIndex}
>
{/* Background layer */}
<div
className="p-2 absolute inset-0 rounded-large"
style={{ backgroundColor: 'var(--app-input-background)' }}
/>
{/* Title + Description (from toolCall.title) */}
<div className="relative z-[1] text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
{getTitle()}
</div>
{(toolCall.kind === 'edit' ||
toolCall.kind === 'write' ||
toolCall.kind === 'read' ||
toolCall.kind === 'execute' ||
toolCall.kind === 'bash') &&
toolCall.title && (
<div
/* 13px, normal font weight; normal whitespace wrapping + long word breaking; maximum 3 lines with overflow ellipsis */
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
style={{
fontSize: '.9em',
color: 'var(--app-secondary-foreground)',
marginBottom: '6px',
}}
title={toolCall.title}
>
{toolCall.title}
</div>
)}
</div>
{/* Options */}
<div className="relative z-[1] flex flex-col gap-1 pb-1">
{options.map((option, index) => {
const isFocused = focusedIndex === index;
return (
<button
key={option.optionId}
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-button-background)] ${
isFocused
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
}`}
onClick={() => onResponse(option.optionId)}
onMouseEnter={() => setFocusedIndex(index)}
>
{/* Number badge */}
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold opacity-60">
{index + 1}
</span>
{/* Option text */}
<span className="font-semibold">{option.name}</span>
</button>
);
})}
{/* Custom message input (extracted component) */}
{(() => {
const isFocused = focusedIndex === options.length;
const rejectOptionId = options.find((o) =>
o.kind.includes('reject'),
)?.optionId;
return (
<CustomMessageInputRow
isFocused={isFocused}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
onFocusRow={() => setFocusedIndex(options.length)}
onSubmitReject={() => {
if (rejectOptionId) {
onResponse(rejectOptionId);
}
}}
inputRef={customInputRef}
/>
);
})()}
</div>
</div>
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
</div>
);
};
/**
* CustomMessageInputRow: Reusable custom input row component (without hooks)
*/
interface CustomMessageInputRowProps {
isFocused: boolean;
customMessage: string;
setCustomMessage: (val: string) => void;
onFocusRow: () => void; // Set focus when mouse enters or input box is focused
onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option)
inputRef: React.RefObject<HTMLInputElement | null>;
}
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
isFocused,
customMessage,
setCustomMessage,
onFocusRow,
onSubmitReject,
inputRef,
}) => (
<div
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
}`}
onMouseEnter={onFocusRow}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef as React.LegacyRef<HTMLInputElement> | undefined}
type="text"
placeholder="Tell Qwen what to do instead"
spellCheck={false}
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
style={{ color: 'var(--app-input-foreground)' }}
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
onFocus={onFocusRow}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.preventDefault();
onSubmitReject();
}
}}
/>
</div>
);

View File

@@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface PermissionOption {
name: string;
kind: string;
optionId: string;
}
export interface ToolCall {
title?: string;
kind?: string;
toolCallId?: string;
rawInput?: {
command?: string;
description?: string;
[key: string]: unknown;
};
content?: Array<{
type: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
status?: string;
}
export interface PermissionRequestProps {
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
}

View File

@@ -1,215 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Edit mode related icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Edit pencil icon (16x16)
* Used for "Ask before edits" mode
*/
export const EditPencilIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Auto/fast-forward icon (16x16)
* Used for "Edit automatically" mode
*/
export const AutoEditIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M2.53 3.956A1 1 0 0 0 1 4.804v6.392a1 1 0 0 0 1.53.848l5.113-3.196c.16-.1.279-.233.357-.383v2.73a1 1 0 0 0 1.53.849l5.113-3.196a1 1 0 0 0 0-1.696L9.53 3.956A1 1 0 0 0 8 4.804v2.731a.992.992 0 0 0-.357-.383L2.53 3.956Z" />
</svg>
);
/**
* Plan mode/bars icon (16x16)
* Used for "Plan mode"
*/
export const PlanModeIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M4.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1ZM10.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1Z" />
</svg>
);
/**
* Code brackets icon (20x20)
* Used for active file indicator
*/
export const CodeBracketsIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06ZM11.377 2.011a.75.75 0 0 1 .612.867l-2.5 14.5a.75.75 0 0 1-1.478-.255l2.5-14.5a.75.75 0 0 1 .866-.612Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Hide context (eye slash) icon (20x20)
* Used to indicate the active selection will NOT be auto-loaded into context
*/
export const HideContextIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l14.5 14.5a.75.75 0 1 0 1.06-1.06l-1.745-1.745a10.029 10.029 0 0 0 3.3-4.38 1.651 1.651 0 0 0 0-1.185A10.004 10.004 0 0 0 9.999 3a9.956 9.956 0 0 0-4.744 1.194L3.28 2.22ZM7.752 6.69l1.092 1.092a2.5 2.5 0 0 1 3.374 3.373l1.091 1.092a4 4 0 0 0-5.557-5.557Z"
clipRule="evenodd"
/>
<path d="m10.748 13.93 2.523 2.523a9.987 9.987 0 0 1-3.27.547c-4.258 0-7.894-2.66-9.337-6.41a1.651 1.651 0 0 1 0-1.186A10.007 10.007 0 0 1 2.839 6.02L6.07 9.252a4 4 0 0 0 4.678 4.678Z" />
</svg>
);
/**
* Slash command icon (20x20)
* Used for command menu button
*/
export const SlashCommandIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Link/attachment icon (20x20)
* Used for attach context button
*/
export const LinkIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Open diff icon (16x16)
* Used for opening diff in VS Code
*/
export const OpenDiffIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M13.5 7l-4-4v3h-6v2h6v3l4-4z" />
</svg>
);

View File

@@ -1,103 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* File and document related icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* File document icon (16x16)
* Used for file completion menu
*/
export const FileIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M9 2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7l-5-5zm3 7V3.5L10.5 2H10v3a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1zM6 3h3v2H6V3z" />
</svg>
);
export const FileListIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M5 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Z" />
</svg>
);
/**
* Save document icon (16x16)
* Used for save session button
*/
export const SaveDocumentIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M2.66663 2.66663H10.6666L13.3333 5.33329V13.3333H2.66663V2.66663Z" />
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
</svg>
);
/**
* Folder icon (16x16)
* Useful for directory entries in completion lists
*/
export const FolderIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M1.5 3A1.5 1.5 0 0 1 3 1.5h3.086a1.5 1.5 0 0 1 1.06.44L8.5 3H13A1.5 1.5 0 0 1 14.5 4.5v7A1.5 1.5 0 0 1 13 13H3A1.5 1.5 0 0 1 1.5 11.5v-8Z" />
</svg>
);

View File

@@ -1,212 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Navigation and action icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Chevron down icon (20x20)
* Used for dropdown arrows
*/
export const ChevronDownIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Plus icon (20x20)
* Used for new session button
*/
export const PlusIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
</svg>
);
/**
* Small plus icon (16x16)
* Used for default attachment type
*/
export const PlusSmallIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 2a.5.5 0 0 1 .5.5V5h2.5a.5.5 0 0 1 0 1H8.5v2.5a.5.5 0 0 1-1 0V6H5a.5.5 0 0 1 0-1h2.5V2.5A.5.5 0 0 1 8 2Z" />
</svg>
);
/**
* Arrow up icon (20x20)
* Used for send message button
*/
export const ArrowUpIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Close X icon (14x14)
* Used for close buttons in banners and dialogs
*/
export const CloseIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
export const CloseSmallIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708Z" />
</svg>
);
/**
* Search/magnifying glass icon (20x20)
* Used for search input
*/
export const SearchIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z"
clipRule="evenodd"
/>
</svg>
);
/**
* Refresh/reload icon (16x16)
* Used for refresh session list
*/
export const RefreshIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M13.3333 8C13.3333 10.9455 10.9455 13.3333 8 13.3333C5.05451 13.3333 2.66663 10.9455 2.66663 8C2.66663 5.05451 5.05451 2.66663 8 2.66663" />
<path d="M10.6666 8L13.3333 8M13.3333 8L13.3333 5.33333M13.3333 8L10.6666 10.6667" />
</svg>
);

View File

@@ -1,79 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Special UI icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
interface ThinkingIconProps extends IconProps {
/**
* Whether thinking is enabled (affects styling)
*/
enabled?: boolean;
}
export const ThinkingIcon: React.FC<ThinkingIconProps> = ({
size = 16,
className,
enabled = false,
style,
...props
}) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-hidden="true"
{...props}
>
<path
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
strokeWidth="0.27"
style={{
stroke: enabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
fill: enabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
...style,
}}
/>
</svg>
);
export const TerminalIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"
clipRule="evenodd"
/>
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z" />
<path
fillRule="evenodd"
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
clipRule="evenodd"
/>
</svg>
);

View File

@@ -1,188 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Status and state related icons
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Plan completed icon (14x14)
* Used for completed plan items
*/
export const PlanCompletedIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.2" />
<path
d="M4 7.5L6 9.5L10 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
/**
* Plan in progress icon (14x14)
* Used for in-progress plan items
*/
export const PlanInProgressIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<circle
cx="7"
cy="7"
r="5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
/>
</svg>
);
/**
* Plan pending icon (14x14)
* Used for pending plan items
*/
export const PlanPendingIcon: React.FC<IconProps> = ({
size = 14,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
fill="none"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<circle
cx="7"
cy="7"
r="5.5"
fill="none"
stroke="currentColor"
strokeWidth="1"
/>
</svg>
);
/**
* Warning triangle icon (20x20)
* Used for warning messages
*/
export const WarningTriangleIcon: React.FC<IconProps> = ({
size = 20,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
clipRule="evenodd"
/>
</svg>
);
/**
* User profile icon (16x16)
* Used for login command
*/
export const UserIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
</svg>
);
export const SymbolIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M8 1a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 7.293V1.5A.5.5 0 0 1 8 1Z" />
</svg>
);
export const SelectionIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
</svg>
);

View File

@@ -1,33 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Stop icon for canceling operations
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Stop/square icon (16x16)
* Used for stop/cancel operations
*/
export const StopIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<rect x="4" y="4" width="8" height="8" rx="1" />
</svg>
);

View File

@@ -1,49 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export type { IconProps } from './types.js';
export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js';
// Navigation icons
export {
ChevronDownIcon,
PlusIcon,
PlusSmallIcon,
ArrowUpIcon,
CloseIcon,
CloseSmallIcon,
SearchIcon,
RefreshIcon,
} from './NavigationIcons.js';
// Edit mode icons
export {
EditPencilIcon,
AutoEditIcon,
PlanModeIcon,
CodeBracketsIcon,
HideContextIcon,
SlashCommandIcon,
LinkIcon,
OpenDiffIcon,
} from './EditIcons.js';
// Status icons
export {
PlanCompletedIcon,
PlanInProgressIcon,
PlanPendingIcon,
WarningTriangleIcon,
UserIcon,
SymbolIcon,
SelectionIcon,
} from './StatusIcons.js';
// Special icons
export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js';
// Stop icon
export { StopIcon } from './StopIcon.js';

View File

@@ -1,22 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Common icon props interface
*/
import type React from 'react';
export interface IconProps extends React.SVGProps<SVGSVGElement> {
/**
* Icon size (width and height)
* @default 16
*/
size?: number;
/**
* Additional CSS classes
*/
className?: string;
}

View File

@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
interface ChatHeaderProps {
currentSessionTitle: string;
onLoadSessions: () => void;
onNewSession: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
currentSessionTitle,
onLoadSessions,
onNewSession,
}) => (
<div
className="chat-header flex items-center select-none w-full border-b border-[var(--app-primary-border-color)] bg-[var(--app-header-background)] py-1.5 px-2.5"
style={{ borderBottom: '1px solid var(--app-primary-border-color)' }}
>
<button
className="flex items-center gap-1.5 py-0.5 px-2 bg-transparent border-none rounded cursor-pointer outline-none min-w-0 max-w-[300px] overflow-hidden text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
onClick={onLoadSessions}
title="Past conversations"
>
<span className="whitespace-nowrap overflow-hidden text-ellipsis min-w-0 font-medium">
{currentSessionTitle}
</span>
<ChevronDownIcon className="w-4 h-4 flex-shrink-0" />
</button>
<div className="flex-1 min-w-1"></div>
<button
className="flex items-center justify-center p-1 bg-transparent border-none rounded cursor-pointer outline-none hover:bg-[var(--app-ghost-button-hover-background)]"
onClick={onNewSession}
title="New Session"
style={{ padding: '4px' }}
>
<PlusIcon className="w-4 h-4" />
</button>
</div>
);

View File

@@ -1,171 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
interface CompletionMenuProps {
items: CompletionItem[];
onSelect: (item: CompletionItem) => void;
onClose: () => void;
title?: string;
selectedIndex?: number;
}
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
items,
onSelect,
onClose,
title,
selectedIndex = 0,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState(selectedIndex);
// Mount state to drive a simple Tailwind transition (replaces CSS keyframes)
const [mounted, setMounted] = useState(false);
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
useEffect(() => setMounted(true), []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
setSelected((prev) => Math.min(prev + 1, items.length - 1));
break;
case 'ArrowUp':
event.preventDefault();
setSelected((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
event.preventDefault();
if (items[selected]) {
onSelect(items[selected]);
}
break;
case 'Escape':
event.preventDefault();
onClose();
break;
default:
break;
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [items, selected, onSelect, onClose]);
useEffect(() => {
const selectedEl = containerRef.current?.querySelector(
`[data-index="${selected}"]`,
);
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' });
}
}, [selected]);
if (!items.length) {
return null;
}
return (
<div
ref={containerRef}
role="menu"
className={[
'completion-menu',
// Positioning and container styling
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
'rounded-large border bg-[var(--app-menu-background)]',
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',
// Mount animation (fade + slight slide up) via keyframes
mounted ? 'animate-completion-menu-enter' : '',
].join(' ')}
>
{/* Optional top spacer for visual separation from the input */}
<div className="h-1" />
<div
className={[
// Semantic
'completion-menu-list',
// Scroll area
'flex max-h-[300px] flex-col overflow-y-auto',
// Spacing driven by theme vars
'p-[var(--app-list-padding)] pb-2 gap-[var(--app-list-gap)]',
].join(' ')}
>
{title && (
<div className="completion-menu-section-label px-3 py-1 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em]">
{title}
</div>
)}
{items.map((item, index) => {
const isActive = index === selected;
return (
<div
key={item.id}
data-index={index}
role="menuitem"
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(index)}
className={[
// Semantic
'completion-menu-item',
// Hit area
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
'p-[var(--app-list-item-padding)]',
// Active background
isActive ? 'bg-[var(--app-list-active-background)]' : '',
].join(' ')}
>
<div className="completion-menu-item-row flex items-center justify-between gap-2">
{item.icon && (
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
{item.icon}
</span>
)}
<span
className={[
'completion-menu-item-label flex-1 truncate',
isActive
? 'text-[var(--app-list-active-foreground)]'
: 'text-[var(--app-primary-foreground)]',
].join(' ')}
>
{item.label}
</span>
{item.description && (
<span
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
title={item.description}
>
{item.description}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,67 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface EmptyStateProps {
isAuthenticated?: boolean;
loadingMessage?: string;
}
// Helper function to get extension asset URL
function getExtensionAssetUrl(assetPath: string): string {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getURL) {
return chrome.runtime.getURL(assetPath);
}
// Fallback during development or if chrome API is not available
return assetPath;
}
export const EmptyState: React.FC<EmptyStateProps> = ({
isAuthenticated = false,
loadingMessage,
}) => {
const iconSrc = getExtensionAssetUrl('icons/icon-source.png');
const description = loadingMessage
? 'Preparing Qwen Code Chrome Extension...'
: isAuthenticated
? 'What would you like to do? Ask about this codebase or we can start writing code.'
: 'Welcome! Please log in to start using Qwen Code.';
return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
<div className="flex flex-col items-center gap-8 w-full">
{/* Qwen Logo */}
<div className="flex flex-col items-center gap-6">
<img
src={iconSrc}
alt="Qwen Logo"
className="w-[60px] h-[60px] object-contain"
onError={(e) => {
// Fallback to a div with text if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const parent = target.parentElement;
if (parent) {
const fallback = document.createElement('div');
fallback.className =
'w-[60px] h-[60px] flex items-center justify-center text-2xl font-bold';
fallback.textContent = 'Q';
parent.appendChild(fallback);
}
}}
/>
<div className="text-center">
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
{description}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,148 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* FileLink component - Clickable file path links
* Supports clicking to open files and jump to specified line and column numbers
*/
import type React from 'react';
import { useVSCode } from '../../hooks/useVSCode.js';
// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes
/**
* Props for FileLink
*/
interface FileLinkProps {
/** File path */
path: string;
/** Optional line number (starting from 1) */
line?: number | null;
/** Optional column number (starting from 1) */
column?: number | null;
/** Whether to show full path, default false (show filename only) */
showFullPath?: boolean;
/** Optional custom class name */
className?: string;
/** Whether to disable click behavior (use when parent element handles clicks) */
disableClick?: boolean;
}
/**
* Extract filename from full path
* @param path File path
* @returns Filename
*/
function getFileName(path: string): string {
const segments = path.split(/[/\\]/);
return segments[segments.length - 1] || path;
}
/**
* FileLink component - Clickable file link
*
* Features:
* - Click to open file
* - Support line and column number navigation
* - Hover to show full path
* - Optional display mode (full path vs filename only)
*
* @example
* ```tsx
* <FileLink path="/src/App.tsx" line={42} />
* <FileLink path="/src/components/Button.tsx" line={10} column={5} showFullPath={true} />
* ```
*/
export const FileLink: React.FC<FileLinkProps> = ({
path,
line,
column,
showFullPath = false,
className = '',
disableClick = false,
}) => {
const vscode = useVSCode();
/**
* Handle click event - Send message to VSCode to open file
*/
const handleClick = (e: React.MouseEvent) => {
// Always prevent default behavior (prevent <a> tag # navigation)
e.preventDefault();
if (disableClick) {
// If click is disabled, return directly without stopping propagation
// This allows parent elements to handle click events
return;
}
// If click is enabled, stop event propagation
e.stopPropagation();
// Build full path including line and column numbers
let fullPath = path;
if (line !== null && line !== undefined) {
fullPath += `:${line}`;
if (column !== null && column !== undefined) {
fullPath += `:${column}`;
}
}
console.log('[FileLink] Opening file:', fullPath);
vscode.postMessage({
type: 'openFile',
data: { path: fullPath },
});
};
// Build display text
const displayPath = showFullPath ? path : getFileName(path);
// Build hover tooltip (always show full path)
const fullDisplayText =
line !== null && line !== undefined
? column !== null && column !== undefined
? `${path}:${line}:${column}`
: `${path}:${line}`
: path;
return (
<a
href="#"
className={[
'file-link',
// Layout + interaction
// Use items-center + leading-none to vertically center within surrounding rows
'inline-flex items-center leading-none',
disableClick
? 'pointer-events-none cursor-[inherit] hover:no-underline'
: 'cursor-pointer',
// Typography + color: match theme body text and fixed size
'text-[11px] no-underline hover:underline',
'text-[var(--app-primary-foreground)]',
// Transitions
'transition-colors duration-100 ease-in-out',
// Focus ring (keyboard nav)
'focus:outline focus:outline-1 focus:outline-[var(--vscode-focusBorder)] focus:outline-offset-2 focus:rounded-[2px]',
// Active state
'active:opacity-80',
className,
].join(' ')}
onClick={handleClick}
title={fullDisplayText}
role="button"
aria-label={`Open file: ${fullDisplayText}`}
// Inherit font family from context so it matches theme body text.
>
<span className="file-link-path">{displayPath}</span>
{line !== null && line !== undefined && (
<span className="file-link-location opacity-70 text-[0.9em] font-normal dark:opacity-60">
:{line}
{column !== null && column !== undefined && <>:{column}</>}
</span>
)}
</a>
);
};

View File

@@ -1,298 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import {
EditPencilIcon,
AutoEditIcon,
PlanModeIcon,
CodeBracketsIcon,
HideContextIcon,
// ThinkingIcon, // Temporarily disabled
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
StopIcon,
} from '../icons/index.js';
import { CompletionMenu } from '../layout/CompletionMenu.js';
import type { CompletionItem } from '../../../types/completionItemTypes.js';
import { getApprovalModeInfoFromString } from '../../types/acpTypes.js';
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
interface InputFormProps {
inputText: string;
// Note: RefObject<T> carries nullability in its `current` property, so the
// generic should be `HTMLDivElement` (not `HTMLDivElement | null`).
inputFieldRef: React.RefObject<HTMLDivElement>;
isStreaming: boolean;
isWaitingForResponse: boolean;
isComposing: boolean;
editMode: ApprovalModeValue;
thinkingEnabled: boolean;
activeFileName: string | null;
activeSelection: { startLine: number; endLine: number } | null;
// Whether to auto-load the active editor selection/path into context
skipAutoActiveContext: boolean;
onInputChange: (text: string) => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
onToggleEditMode: () => void;
onToggleThinking: () => void;
onFocusActiveEditor: () => void;
onToggleSkipAutoActiveContext: () => void;
onShowCommandMenu: () => void;
onAttachContext: () => void;
completionIsOpen: boolean;
completionItems?: CompletionItem[];
onCompletionSelect?: (item: CompletionItem) => void;
onCompletionClose?: () => void;
}
// Get edit mode display info using helper function
const getEditModeInfo = (editMode: ApprovalModeValue) => {
const info = getApprovalModeInfoFromString(editMode);
// Map icon types to actual icons
let icon = null;
switch (info.iconType) {
case 'edit':
icon = <EditPencilIcon />;
break;
case 'auto':
icon = <AutoEditIcon />;
break;
case 'plan':
icon = <PlanModeIcon />;
break;
case 'yolo':
icon = <AutoEditIcon />;
break;
default:
icon = null;
break;
}
return {
text: info.label,
title: info.title,
icon,
};
};
export const InputForm: React.FC<InputFormProps> = ({
inputText,
inputFieldRef,
isStreaming,
isWaitingForResponse,
isComposing,
editMode,
// thinkingEnabled, // Temporarily disabled
activeFileName,
activeSelection,
skipAutoActiveContext,
onInputChange,
onCompositionStart,
onCompositionEnd,
onKeyDown,
onSubmit,
onCancel,
onToggleEditMode,
// onToggleThinking, // Temporarily disabled
onToggleSkipAutoActiveContext,
onShowCommandMenu,
onAttachContext,
completionIsOpen,
completionItems,
onCompletionSelect,
onCompletionClose,
}) => {
const editModeInfo = getEditModeInfo(editMode);
const composerDisabled = isStreaming || isWaitingForResponse;
const handleKeyDown = (e: React.KeyboardEvent) => {
// ESC should cancel the current interaction (stop generation)
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
return;
}
// If composing (Chinese IME input), don't process Enter key
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
// If CompletionMenu is open, let it handle Enter key
if (completionIsOpen) {
return;
}
e.preventDefault();
onSubmit(e);
}
onKeyDown(e);
};
// Selection label like "6 lines selected"; no line numbers
const selectedLinesCount = activeSelection
? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1)
: 0;
const selectedLinesText =
selectedLinesCount > 0
? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected`
: '';
return (
<div className="p-1 px-4 pb-4 absolute bottom-0 left-0 right-0 bg-gradient-to-b from-transparent to-[var(--app-primary-background)]">
<div className="block">
<form className="composer-form" onSubmit={onSubmit}>
{/* Inner background layer */}
<div className="composer-overlay" />
{/* Banner area */}
<div className="input-banner" />
<div className="relative flex z-[1]">
{completionIsOpen &&
completionItems &&
completionItems.length > 0 &&
onCompletionSelect &&
onCompletionClose && (
<CompletionMenu
items={completionItems}
onSelect={onCompletionSelect}
onClose={onCompletionClose}
title={undefined}
/>
)}
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="composer-input"
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask Qwen Code …"
// Use a data flag so CSS can show placeholder even if the browser
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
data-empty={
inputText.replace(/\u200B/g, '').trim().length === 0
? 'true'
: 'false'
}
onInput={(e) => {
const target = e.target as HTMLDivElement;
// Filter out zero-width space that we use to maintain height
const text = target.textContent?.replace(/\u200B/g, '') || '';
onInputChange(text);
}}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={handleKeyDown}
suppressContentEditableWarning
/>
</div>
<div className="composer-actions">
{/* Edit mode button */}
<button
type="button"
className="btn-text-compact btn-text-compact--primary"
title={editModeInfo.title}
onClick={onToggleEditMode}
>
{editModeInfo.icon}
{/* Let the label truncate with ellipsis; hide on very small screens */}
<span className="hidden sm:inline">{editModeInfo.text}</span>
</button>
{/* Active file indicator */}
{activeFileName && (
<button
type="button"
className="btn-text-compact btn-text-compact--primary"
title={(() => {
if (skipAutoActiveContext) {
return selectedLinesText
? `Active selection will NOT be auto-loaded into context: ${selectedLinesText}`
: `Active file will NOT be auto-loaded into context: ${activeFileName}`;
}
return selectedLinesText
? `Showing Qwen Code your current selection: ${selectedLinesText}`
: `Showing Qwen Code your current file: ${activeFileName}`;
})()}
onClick={onToggleSkipAutoActiveContext}
>
{skipAutoActiveContext ? (
<HideContextIcon />
) : (
<CodeBracketsIcon />
)}
{/* Truncate file path/selection; hide label on very small screens */}
<span className="hidden sm:inline">
{selectedLinesText || activeFileName}
</span>
</button>
)}
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* @yiliang114. closed temporarily */}
{/* Thinking button */}
{/* <button
type="button"
className={`btn-icon-compact ${thinkingEnabled ? 'btn-icon-compact--active' : ''}`}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<ThinkingIcon enabled={thinkingEnabled} />
</button> */}
{/* Command button */}
<button
type="button"
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
title="Show command menu (/)"
onClick={onShowCommandMenu}
>
<SlashCommandIcon />
</button>
{/* Attach button */}
<button
type="button"
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
title="Attach context (Cmd/Ctrl + /)"
onClick={onAttachContext}
>
<LinkIcon />
</button>
{/* Send/Stop button */}
{isStreaming || isWaitingForResponse ? (
<button
type="button"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
onClick={onCancel}
title="Stop generation"
>
<StopIcon />
</button>
) : (
<button
type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={composerDisabled || !inputText.trim()}
>
<ArrowUpIcon />
</button>
)}
</div>
</form>
</div>
</div>
);
};

View File

@@ -1,49 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { generateIconUrl } from '../../utils/resourceUrl.js';
interface OnboardingPageProps {
onLogin: () => void;
}
export const Onboarding: React.FC<OnboardingPageProps> = ({ onLogin }) => {
const iconUri = generateIconUrl('icon.png');
return (
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
<div className="flex flex-col items-center gap-6">
{/* Application icon container */}
<div className="relative">
<img
src={iconUri}
alt="Qwen Code Logo"
className="w-[80px] h-[80px] object-contain"
/>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-app-primary-foreground mb-2">
Welcome to Qwen Code
</h1>
<p className="text-app-secondary-foreground max-w-sm">
Unlock the power of AI to understand, navigate, and transform your
codebase faster than ever before.
</p>
</div>
<button
onClick={onLogin}
className="w-full px-4 py-3 bg-[#4f46e5] text-white font-medium rounded-lg shadow-sm hover:bg-[#4338ca] transition-colors duration-200"
>
Get Started with Qwen Code
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,156 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import {
getTimeAgo,
groupSessionsByDate,
} from '../../utils/sessionGrouping.js';
import { SearchIcon } from '../icons/index.js';
interface SessionSelectorProps {
visible: boolean;
sessions: Array<Record<string, unknown>>;
currentSessionId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onSelectSession: (sessionId: string) => void;
onClose: () => void;
hasMore?: boolean;
isLoading?: boolean;
onLoadMore?: () => void;
}
/**
* Session selector component
* Display session list and support search and selection
*/
export const SessionSelector: React.FC<SessionSelectorProps> = ({
visible,
sessions,
currentSessionId,
searchQuery,
onSearchChange,
onSelectSession,
onClose,
hasMore = false,
isLoading = false,
onLoadMore,
}) => {
if (!visible) {
return null;
}
const hasNoSessions = sessions.length === 0;
return (
<>
<div
className="session-selector-backdrop fixed top-0 left-0 right-0 bottom-0 z-[999] bg-transparent"
onClick={onClose}
/>
<div
className="session-dropdown fixed bg-[var(--app-menu-background)] rounded-[var(--corner-radius-small)] w-[min(400px,calc(100vw-32px))] max-h-[min(500px,50vh)] flex flex-col shadow-[0_4px_16px_rgba(0,0,0,0.1)] z-[1000] outline-none text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)]"
tabIndex={-1}
style={{
top: '30px',
left: '10px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Search Box */}
<div className="session-search p-2 flex items-center gap-2">
<SearchIcon className="session-search-icon w-4 h-4 opacity-50 flex-shrink-0 text-[var(--app-primary-foreground)]" />
<input
type="text"
className="session-search-input flex-1 bg-transparent border-none outline-none text-[var(--app-menu-foreground)] text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] p-0 placeholder:text-[var(--app-input-placeholder-foreground)] placeholder:opacity-60"
placeholder="Search sessions…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Session List with Grouping */}
<div
className="session-list-content overflow-y-auto flex-1 select-none p-2"
onScroll={(e) => {
const el = e.currentTarget;
const distanceToBottom =
el.scrollHeight - (el.scrollTop + el.clientHeight);
if (distanceToBottom < 48 && hasMore && !isLoading) {
onLoadMore?.();
}
}}
>
{hasNoSessions ? (
<div
className="p-5 text-center text-[var(--app-secondary-foreground)]"
style={{
padding: '20px',
textAlign: 'center',
color: 'var(--app-secondary-foreground)',
}}
>
{searchQuery ? 'No matching sessions' : 'No sessions available'}
</div>
) : (
groupSessionsByDate(sessions).map((group) => (
<React.Fragment key={group.label}>
<div className="session-group-label p-1 px-2 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em] font-medium [&:not(:first-child)]:mt-2">
{group.label}
</div>
<div className="session-group flex flex-col gap-[2px]">
{group.sessions.map((session) => {
const sessionId =
(session.id as string) ||
(session.sessionId as string) ||
'';
const title =
(session.title as string) ||
(session.name as string) ||
'Untitled';
const lastUpdated =
(session.lastUpdated as string) ||
(session.startTime as string) ||
'';
const isActive = sessionId === currentSessionId;
return (
<button
key={sessionId}
className={`session-item flex items-center justify-between py-1.5 px-2 bg-transparent border-none rounded-md cursor-pointer text-left w-full text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] text-[var(--app-primary-foreground)] transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${
isActive
? 'active bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-[600]'
: ''
}`}
onClick={() => {
onSelectSession(sessionId);
onClose();
}}
>
<span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
{title}
</span>
<span className="session-item-time opacity-60 text-[0.9em] flex-shrink-0 ml-3">
{getTimeAgo(lastUpdated)}
</span>
</button>
);
})}
</div>
</React.Fragment>
))
)}
{hasMore && (
<div className="p-2 text-center opacity-60 text-[0.9em]">
{isLoading ? 'Loading…' : ''}
</div>
)}
</div>
</div>
</>
);
};

View File

@@ -1,52 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* AssistantMessage Component Styles
* Pseudo-elements (::before) for bullet points and (::after) for timeline connectors
*/
/* Bullet point indicator using ::before pseudo-element */
.assistant-message-container.assistant-message-default::before,
.assistant-message-container.assistant-message-success::before,
.assistant-message-container.assistant-message-error::before,
.assistant-message-container.assistant-message-warning::before,
.assistant-message-container.assistant-message-loading::before {
content: '\25cf';
position: absolute;
left: 8px;
padding-top: 2px;
font-size: 10px;
z-index: 1;
}
/* Default state - secondary foreground color */
.assistant-message-container.assistant-message-default::before {
color: var(--app-secondary-foreground);
}
/* Success state - green bullet (maps to .ge) */
.assistant-message-container.assistant-message-success::before {
color: #74c991;
}
/* Error state - red bullet (maps to .be) */
.assistant-message-container.assistant-message-error::before {
color: #c74e39;
}
/* Warning state - yellow/orange bullet (maps to .ue) */
.assistant-message-container.assistant-message-warning::before {
color: #e1c08d;
}
/* Loading state - static bullet (maps to .he) */
.assistant-message-container.assistant-message-loading::before {
color: var(--app-secondary-foreground);
background-color: var(--app-secondary-background);
}
.assistant-message-container.assistant-message-loading::after {
display: none;
}

View File

@@ -1,87 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { MessageContent } from '../MessageContent.js';
import './AssistantMessage.css';
interface AssistantMessageProps {
content: string;
timestamp: number;
onFileClick?: (path: string) => void;
status?: 'default' | 'success' | 'error' | 'warning' | 'loading';
// When true, render without the left status bullet (no ::before dot)
hideStatusIcon?: boolean;
}
/**
* AssistantMessage component - renders AI responses with Qwen Code styling
* Supports different states: default, success, error, warning, loading
*/
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
content,
timestamp: _timestamp,
onFileClick,
status = 'default',
hideStatusIcon = false,
}) => {
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
if (!content || content.trim().length === 0) {
return null;
}
// Map status to CSS class (only for ::before pseudo-element)
const getStatusClass = () => {
if (hideStatusIcon) {
return '';
}
switch (status) {
case 'success':
return 'assistant-message-success';
case 'error':
return 'assistant-message-error';
case 'warning':
return 'assistant-message-warning';
case 'loading':
return 'assistant-message-loading';
default:
return 'assistant-message-default';
}
};
return (
<div
className={`qwen-message message-item assistant-message-container ${getStatusClass()}`}
style={{
width: '100%',
alignItems: 'flex-start',
paddingLeft: '30px',
userSelect: 'text',
position: 'relative',
// paddingTop: '8px',
// paddingBottom: '8px',
}}
>
<span style={{ width: '100%' }}>
<div
style={{
margin: 0,
width: '100%',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
}}
>
<MessageContent
content={content}
onFileClick={onFileClick}
enableFileLinks={false}
/>
</div>
</span>
</div>
);
};

View File

@@ -1,223 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Styles for MarkdownRenderer component
*/
.markdown-content {
/* Base styles for markdown content */
line-height: 1.6;
color: var(--app-primary-foreground);
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
}
.markdown-content h1 {
font-size: 1.75em;
border-bottom: 1px solid var(--app-primary-border-color);
padding-bottom: 0.3em;
}
.markdown-content h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--app-primary-border-color);
padding-bottom: 0.3em;
}
.markdown-content h3 {
font-size: 1.25em;
}
.markdown-content h4 {
font-size: 1.1em;
}
.markdown-content h5,
.markdown-content h6 {
font-size: 1em;
}
.markdown-content p {
margin-top: 0;
/* margin-bottom: 1em; */
}
.markdown-content ul,
.markdown-content ol {
margin-top: 1em;
margin-bottom: 1em;
padding-left: 2em;
}
/* Ensure list markers are visible even with global CSS resets */
.markdown-content ul {
list-style-type: disc;
list-style-position: outside;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: outside;
}
/* Nested list styles */
.markdown-content ul ul {
list-style-type: circle;
}
.markdown-content ul ul ul {
list-style-type: square;
}
.markdown-content ol ol {
list-style-type: lower-alpha;
}
.markdown-content ol ol ol {
list-style-type: lower-roman;
}
/* Style the marker explicitly so themes don't hide it */
.markdown-content li::marker {
color: var(--app-secondary-foreground);
}
.markdown-content li {
margin-bottom: 0.25em;
}
.markdown-content li > p {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.markdown-content blockquote {
margin: 0 0 1em;
padding: 0 1em;
border-left: 0.25em solid var(--app-primary-border-color);
color: var(--app-secondary-foreground);
}
.markdown-content a {
color: var(--app-link-foreground, #007acc);
text-decoration: none;
}
.markdown-content a:hover {
color: var(--app-link-active-foreground, #005a9e);
text-decoration: underline;
}
.markdown-content code {
font-family: var(
--app-monospace-font-family,
'SF Mono',
Monaco,
'Cascadia Code',
'Roboto Mono',
Consolas,
'Courier New',
monospace
);
font-size: 0.9em;
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
border: 1px solid var(--app-primary-border-color);
border-radius: var(--corner-radius-small, 4px);
padding: 0.2em 0.4em;
white-space: pre-wrap; /* Support automatic line wrapping */
word-break: break-word; /* Break words when necessary */
}
.markdown-content pre {
margin: 1em 0;
padding: 1em;
overflow-x: auto;
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
border: 1px solid var(--app-primary-border-color);
border-radius: var(--corner-radius-small, 4px);
font-family: var(
--app-monospace-font-family,
'SF Mono',
Monaco,
'Cascadia Code',
'Roboto Mono',
Consolas,
'Courier New',
monospace
);
font-size: 0.9em;
line-height: 1.5;
}
.markdown-content pre code {
background: none;
border: none;
padding: 0;
white-space: pre-wrap; /* Support automatic line wrapping */
word-break: break-word; /* Break words when necessary */
}
.markdown-content .file-path-link {
background: transparent;
border: none;
padding: 0;
font-family: var(
--app-monospace-font-family,
'SF Mono',
Monaco,
'Cascadia Code',
'Roboto Mono',
Consolas,
'Courier New',
monospace
);
font-size: 0.95em;
color: var(--app-link-foreground, #007acc);
text-decoration: underline;
cursor: pointer;
transition: color 0.1s ease;
}
.markdown-content .file-path-link:hover {
color: var(--app-link-active-foreground, #005a9e);
}
.markdown-content hr {
border: none;
border-top: 1px solid var(--app-primary-border-color);
margin: 1.5em 0;
}
.markdown-content img {
max-width: 100%;
height: auto;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin: 1em 0;
}
.markdown-content th,
.markdown-content td {
padding: 0.5em 1em;
border: 1px solid var(--app-primary-border-color);
text-align: left;
}
.markdown-content th {
background-color: var(--app-secondary-background);
font-weight: 600;
}

View File

@@ -1,392 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths
*/
import type React from 'react';
import MarkdownIt from 'markdown-it';
import type { Options as MarkdownItOptions } from 'markdown-it';
import './MarkdownRenderer.css';
interface MarkdownRendererProps {
content: string;
onFileClick?: (filePath: string) => void;
/** When false, do not convert file paths into clickable links. Default: true */
enableFileLinks?: boolean;
}
/**
* Regular expressions for parsing content
*/
// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts
const FILE_PATH_REGEX =
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7
const FILE_PATH_WITH_LINES_REGEX =
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
/**
* MarkdownRenderer component - renders markdown content with enhanced features
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onFileClick,
enableFileLinks = true,
}) => {
/**
* Initialize markdown-it with plugins
*/
const getMarkdownInstance = (): MarkdownIt => {
// Create markdown-it instance with options
const md = new MarkdownIt({
html: false, // Disable HTML for security
xhtmlOut: false,
breaks: true,
linkify: true,
typographer: true,
} as MarkdownItOptions);
return md;
};
/**
* Render markdown content to HTML
*/
const renderMarkdown = (): string => {
try {
const md = getMarkdownInstance();
// Process the markdown content
let html = md.render(content);
// Post-process to add file path click handlers unless disabled
if (enableFileLinks) {
html = processFilePaths(html);
}
return html;
} catch (error) {
console.error('Error rendering markdown:', error);
// Fallback to plain text if markdown rendering fails
return escapeHtml(content);
}
};
/**
* Escape HTML characters for security
*/
const escapeHtml = (unsafe: string): string =>
unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* Process file paths in HTML to make them clickable
*/
const processFilePaths = (html: string): string => {
// If DOM is not available, bail out to avoid breaking SSR
if (typeof document === 'undefined') {
return html;
}
// Build non-global variants to avoid .test() statefulness
const FILE_PATH_NO_G = new RegExp(
FILE_PATH_REGEX.source,
FILE_PATH_REGEX.flags.replace('g', ''),
);
const FILE_PATH_WITH_LINES_NO_G = new RegExp(
FILE_PATH_WITH_LINES_REGEX.source,
FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''),
);
// Match a bare file name like README.md (no leading slash)
const BARE_FILE_REGEX =
/[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i;
// Parse HTML into a DOM tree so we don't replace inside attributes
const container = document.createElement('div');
container.innerHTML = html;
const union = new RegExp(
`${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`,
'gi',
);
// Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line)
const normalizePathAndLine = (
raw: string,
): { displayText: string; dataPath: string } => {
const displayText = raw;
let base = raw;
// Extract hash fragment like #12, #L12 or #12-34 and keep only the first number
const hashIndex = raw.indexOf('#');
if (hashIndex >= 0) {
const frag = raw.slice(hashIndex + 1);
// Accept L12, 12 or 12-34
const m = frag.match(/^L?(\d+)(?:-\d+)?$/i);
if (m) {
const line = parseInt(m[1], 10);
base = raw.slice(0, hashIndex);
return { displayText, dataPath: `${base}:${line}` };
}
}
return { displayText, dataPath: base };
};
const makeLink = (text: string) => {
const link = document.createElement('a');
// Pass base path (with optional :line) to the handler; keep the full text as label
const { dataPath } = normalizePathAndLine(text);
link.className = 'file-path-link';
link.textContent = text;
link.setAttribute('href', '#');
link.setAttribute('title', `Open ${text}`);
// Carry file path via data attribute; click handled by event delegation
link.setAttribute('data-file-path', dataPath);
return link;
};
const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => {
const href = a.getAttribute('href') || '';
const text = (a.textContent || '').trim();
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
// but DO NOT treat filenames/paths as code refs.
const isCodeReference = (str: string): boolean => {
if (BARE_FILE_REGEX.test(str)) {
return false; // looks like a filename
}
if (/[/\\]/.test(str)) {
return false; // contains a path separator
}
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
return codeRefPattern.test(str);
};
// If linkify turned a bare filename (e.g. README.md) into http://<filename>, convert it back
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
if (httpMatch) {
try {
const url = new URL(href);
const host = url.hostname || '';
const pathname = url.pathname || '';
const noPath = pathname === '' || pathname === '/';
// Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md)
if (
noPath &&
BARE_FILE_REGEX.test(text) &&
host.toLowerCase() === text.toLowerCase()
) {
const { dataPath } = normalizePathAndLine(text);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text}`);
a.setAttribute('data-file-path', dataPath);
return;
}
// Case 2: host itself looks like a filename (rare but happens), use it
if (noPath && BARE_FILE_REGEX.test(host)) {
const { dataPath } = normalizePathAndLine(host);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || host}`);
a.setAttribute('data-file-path', dataPath);
return;
}
} catch {
// fall through; unparseable URL
}
}
// Ignore other external protocols
if (/^(https?|mailto|ftp|data):/i.test(href)) {
return;
}
const candidate = href || text;
// Skip if it looks like a code reference
if (isCodeReference(candidate)) {
return;
}
if (
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
FILE_PATH_NO_G.test(candidate)
) {
const { dataPath } = normalizePathAndLine(candidate);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || href}`);
a.setAttribute('data-file-path', dataPath);
return;
}
// Bare file name or relative path (e.g. README.md or docs/README.md)
if (BARE_FILE_REGEX.test(candidate)) {
const { dataPath } = normalizePathAndLine(candidate);
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || href}`);
a.setAttribute('data-file-path', dataPath);
}
};
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
// but DO NOT treat filenames/paths as code refs.
const isCodeReference = (str: string): boolean => {
if (BARE_FILE_REGEX.test(str)) {
return false; // looks like a filename
}
if (/[/\\]/.test(str)) {
return false; // contains a path separator
}
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
return codeRefPattern.test(str);
};
const walk = (node: Node) => {
// Do not transform inside existing anchors
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName.toLowerCase() === 'a') {
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
return; // Don't descend into <a>
}
// Avoid transforming inside code/pre blocks
const tag = el.tagName.toLowerCase();
if (tag === 'code' || tag === 'pre') {
return;
}
}
for (let child = node.firstChild; child; ) {
const next = child.nextSibling; // child may be replaced
if (child.nodeType === Node.TEXT_NODE) {
const text = child.nodeValue || '';
union.lastIndex = 0;
const hasMatch = union.test(text);
union.lastIndex = 0;
if (hasMatch) {
const frag = document.createDocumentFragment();
let lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = union.exec(text))) {
const matchText = m[0];
const idx = m.index;
// Skip if it looks like a code reference
if (isCodeReference(matchText)) {
// Just add the text as-is without creating a link
if (idx > lastIndex) {
frag.appendChild(
document.createTextNode(text.slice(lastIndex, idx)),
);
}
frag.appendChild(document.createTextNode(matchText));
lastIndex = idx + matchText.length;
continue;
}
if (idx > lastIndex) {
frag.appendChild(
document.createTextNode(text.slice(lastIndex, idx)),
);
}
frag.appendChild(makeLink(matchText));
lastIndex = idx + matchText.length;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
node.replaceChild(frag, child);
}
} else if (child.nodeType === Node.ELEMENT_NODE) {
walk(child);
}
child = next;
}
};
walk(container);
return container.innerHTML;
};
// Event delegation: intercept clicks on generated file-path links
const handleContainerClick = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
// If file links disabled, do nothing
if (!enableFileLinks) {
return;
}
const target = e.target as HTMLElement | null;
if (!target) {
return;
}
// Find nearest anchor with our marker class
const anchor = (target.closest &&
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
if (anchor) {
const filePath = anchor.getAttribute('data-file-path');
if (!filePath) {
return;
}
e.preventDefault();
e.stopPropagation();
onFileClick?.(filePath);
return;
}
// Fallback: intercept "http://README.md" style links that slipped through
const anyAnchor = (target.closest &&
target.closest('a')) as HTMLAnchorElement | null;
if (!anyAnchor) {
return;
}
const href = anyAnchor.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
return;
}
try {
const url = new URL(href);
const host = url.hostname || '';
const path = url.pathname || '';
const noPath = path === '' || path === '/';
// Basic bare filename heuristic on the host part (e.g. README.md)
if (noPath && /\.[a-z0-9]+$/i.test(host)) {
// Prefer the readable text content if it looks like a file
const text = (anyAnchor.textContent || '').trim();
const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host;
e.preventDefault();
e.stopPropagation();
onFileClick?.(candidate);
}
} catch {
// ignore
}
};
return (
<div
className="markdown-content"
onClick={handleContainerClick}
dangerouslySetInnerHTML={{ __html: renderMarkdown() }}
style={{
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
}}
/>
);
};

View File

@@ -1,26 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js';
interface MessageContentProps {
content: string;
onFileClick?: (filePath: string) => void;
enableFileLinks?: boolean;
}
export const MessageContent: React.FC<MessageContentProps> = ({
content,
onFileClick,
enableFileLinks,
}) => (
<MarkdownRenderer
content={content}
onFileClick={onFileClick}
enableFileLinks={enableFileLinks}
/>
);

View File

@@ -1,41 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { MessageContent } from './MessageContent.js';
interface ThinkingMessageProps {
content: string;
timestamp: number;
onFileClick?: (path: string) => void;
}
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
content,
timestamp: _timestamp,
onFileClick,
}) => (
<div className="qwen-message thinking-message flex gap-0 items-start text-left py-2 flex-col relative opacity-80 italic pl-6 animate-[fadeIn_0.2s_ease-in]">
<div
className="inline-block my-1 relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
style={{
backgroundColor:
'var(--app-list-hover-background, rgba(100, 100, 255, 0.1))',
border: '1px solid rgba(100, 100, 255, 0.3)',
borderRadius: 'var(--corner-radius-medium)',
padding: 'var(--app-spacing-medium)',
color: 'var(--app-primary-foreground)',
}}
>
<span className="inline-flex items-center gap-1 mr-2">
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0s]"></span>
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.2s]"></span>
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.4s]"></span>
</span>
<MessageContent content={content} onFileClick={onFileClick} />
</div>
</div>
);

View File

@@ -1,98 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { MessageContent } from './MessageContent.js';
interface FileContext {
fileName: string;
filePath: string;
startLine?: number;
endLine?: number;
}
interface UserMessageProps {
content: string;
timestamp: number;
onFileClick?: (path: string) => void;
fileContext?: FileContext;
}
export const UserMessage: React.FC<UserMessageProps> = ({
content,
timestamp: _timestamp,
onFileClick,
fileContext,
}) => {
// Generate display text for file context
const getFileContextDisplay = () => {
if (!fileContext) {
return null;
}
const { fileName, startLine, endLine } = fileContext;
if (startLine && endLine) {
return startLine === endLine
? `${fileName}#${startLine}`
: `${fileName}#${startLine}-${endLine}`;
}
return fileName;
};
const fileContextDisplay = getFileContextDisplay();
return (
<div
className="qwen-message user-message-container flex gap-0 my-1 items-start text-left flex-col relative"
style={{ position: 'relative' }}
>
<div
className="inline-block relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
style={{
border: '1px solid var(--app-input-border)',
borderRadius: 'var(--corner-radius-medium)',
backgroundColor: 'var(--app-input-background)',
padding: '4px 6px',
color: 'var(--app-primary-foreground)',
}}
>
{/* For user messages, do NOT convert filenames to clickable links */}
<MessageContent
content={content}
onFileClick={onFileClick}
enableFileLinks={false}
/>
</div>
{/* File context indicator */}
{fileContextDisplay && (
<div className="mt-1">
<div
role="button"
tabIndex={0}
className="mr inline-flex items-center py-0 pr-2 gap-1 rounded-sm cursor-pointer relative opacity-50"
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
fileContext && onFileClick?.(fileContext.filePath);
}
}}
>
<div
className="gr"
title={fileContextDisplay}
style={{
fontSize: '12px',
color: 'var(--app-secondary-foreground)',
}}
>
{fileContextDisplay}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,22 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface InterruptedMessageProps {
text?: string;
}
// A lightweight status line similar to WaitingMessage but without the left status icon.
export const InterruptedMessage: React.FC<InterruptedMessageProps> = ({
text = 'Interrupted',
}) => (
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
<div className="interrupted-item w-full relative">
<span className="opacity-70 italic">{text}</span>
</div>
</div>
);

View File

@@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@import url('../Assistant/AssistantMessage.css');
/* Subtle shimmering highlight across the loading text */
@keyframes waitingMessageShimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.loading-text-shimmer {
/* Use the theme foreground as the base color, with a moving light band */
background-image: linear-gradient(
90deg,
var(--app-secondary-foreground) 0%,
var(--app-secondary-foreground) 40%,
rgba(255, 255, 255, 0.95) 50%,
var(--app-secondary-foreground) 60%,
var(--app-secondary-foreground) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* text color comes from the gradient */
animation: waitingMessageShimmer 1.6s linear infinite;
}
.interrupted-item::after {
display: none;
}

View File

@@ -1,77 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import './WaitingMessage.css';
import { WITTY_LOADING_PHRASES } from '../../../constants/loadingMessages.js';
interface WaitingMessageProps {
loadingMessage: string;
}
// Rotate message every few seconds while waiting
const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request
export const WaitingMessage: React.FC<WaitingMessageProps> = ({
loadingMessage,
}) => {
// Build a phrase list that starts with the provided message (if any), then witty fallbacks
const phrases = useMemo(() => {
const set = new Set<string>();
const list: string[] = [];
if (loadingMessage && loadingMessage.trim()) {
list.push(loadingMessage);
set.add(loadingMessage);
}
for (const p of WITTY_LOADING_PHRASES) {
if (!set.has(p)) {
list.push(p);
}
}
return list;
}, [loadingMessage]);
const [index, setIndex] = useState(0);
// Reset to the first phrase whenever the incoming message changes
useEffect(() => {
setIndex(0);
}, [phrases]);
// Periodically rotate to a different phrase
useEffect(() => {
if (phrases.length <= 1) {
return;
}
const id = setInterval(() => {
setIndex((prev) => {
// pick a different random index to avoid immediate repeats
let next = Math.floor(Math.random() * phrases.length);
if (phrases.length > 1) {
let guard = 0;
while (next === prev && guard < 5) {
next = Math.floor(Math.random() * phrases.length);
guard++;
}
}
return next;
});
}, ROTATE_INTERVAL_MS);
return () => clearInterval(id);
}, [phrases]);
return (
<div className="waiting-message-outer flex gap-0 items-start text-left py-2 flex-col opacity-85">
{/* Use the same left status icon (pseudo-element) style as assistant-message-container */}
<div className="assistant-message-container assistant-message-loading waiting-message-inner w-full items-start pl-[30px] relative">
<span className="waiting-message-text opacity-70 italic loading-text-shimmer">
{phrases[index]}
</span>
</div>
</div>
);
};

View File

@@ -1,11 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { UserMessage } from './UserMessage.js';
export { AssistantMessage } from './Assistant/AssistantMessage.js';
export { ThinkingMessage } from './ThinkingMessage.js';
export { WaitingMessage } from './Waiting/WaitingMessage.js';
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';

View File

@@ -1,102 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call styles - Enhanced styling with semantic class names
*/
/* Root container for execute tool call output */
.bash-toolcall-card {
border: 0.5px solid var(--app-input-border);
border-radius: 5px;
background: var(--app-tool-background);
margin: 8px 0;
max-width: 100%;
font-size: 1em;
align-items: start;
}
/* Content wrapper inside the card */
.bash-toolcall-content {
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px;
}
/* Individual input/output row */
.bash-toolcall-row {
display: grid;
grid-template-columns: max-content 1fr;
border-top: 0.5px solid var(--app-input-border);
padding: 4px;
}
/* First row has no top border */
.bash-toolcall-row:first-child {
border-top: none;
}
/* Row label (IN/OUT/ERROR) */
.bash-toolcall-label {
grid-column: 1;
color: var(--app-secondary-foreground);
text-align: left;
opacity: 50%;
padding: 4px 8px 4px 4px;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Row content area */
.bash-toolcall-row-content {
grid-column: 2;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 4px;
}
/* Truncated content styling */
.bash-toolcall-row-content:not(.bash-toolcall-full) {
max-height: 60px;
mask-image: linear-gradient(
to bottom,
var(--app-primary-background) 40px,
transparent 60px
);
overflow: hidden;
}
/* Preformatted content */
.bash-toolcall-pre {
margin-block: 0;
overflow: hidden;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Code content */
.bash-toolcall-code {
margin: 0;
padding: 0;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Output content with subtle styling */
.bash-toolcall-output-subtle {
background-color: var(--app-code-background);
white-space: pre;
overflow-x: auto;
max-width: 100%;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
/* Error content styling */
.bash-toolcall-error-content {
color: #c74e39;
}

View File

@@ -1,180 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call component - specialized for command execution operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../shared/utils.js';
import { useVSCode } from '../../../../hooks/useVSCode.js';
import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js';
import './Bash.css';
/**
* Specialized component for Execute/Bash tool calls
* Shows: Bash bullet + description + IN/OUT card
*/
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, content, rawInput, toolCallId } = toolCall;
const commandText = safeTitle(title);
const vscode = useVSCode();
// Group content by type
const { textOutputs, errors } = groupContent(content);
// Extract command from rawInput if available
let inputCommand = commandText;
if (rawInput && typeof rawInput === 'object') {
const inputObj = rawInput as { command?: string };
inputCommand = inputObj.command || commandText;
} else if (typeof rawInput === 'string') {
inputCommand = rawInput;
}
// Handle click on IN section
const handleInClick = () => {
createAndOpenTempFile(
vscode.postMessage,
inputCommand,
'bash-input',
'.sh',
);
};
// Handle click on OUT section
const handleOutClick = () => {
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt');
}
};
// Map tool status to container status for proper bullet coloring
const containerStatus:
| 'success'
| 'error'
| 'warning'
| 'loading'
| 'default' =
errors.length > 0
? 'error'
: toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
// Error case
if (errors.length > 0) {
return (
<ToolCallContainer
label="Bash"
status={containerStatus}
toolCallId={toolCallId}
>
{/* Branch connector summary */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
{/* Error card - semantic DOM + Tailwind styles */}
<div className="bash-toolcall-card">
<div className="bash-toolcall-content">
{/* IN row */}
<div
className="bash-toolcall-row"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="bash-toolcall-label">IN</div>
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre>
</div>
</div>
{/* ERROR row */}
<div className="bash-toolcall-row">
<div className="bash-toolcall-label">Error</div>
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre bash-toolcall-error-content">
{errors.join('\n')}
</pre>
</div>
</div>
</div>
</div>
</ToolCallContainer>
);
}
// Success with output
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
const truncatedOutput =
output.length > 500 ? output.substring(0, 500) + '...' : output;
return (
<ToolCallContainer
label="Bash"
status={containerStatus}
toolCallId={toolCallId}
>
{/* Branch connector summary */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
{/* Output card - semantic DOM + Tailwind styles */}
<div className="bash-toolcall-card">
<div className="bash-toolcall-content">
{/* IN row */}
<div
className="bash-toolcall-row"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="bash-toolcall-label">IN</div>
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre>
</div>
</div>
{/* OUT row */}
<div
className="bash-toolcall-row"
onClick={handleOutClick}
style={{ cursor: 'pointer' }}
>
<div className="bash-toolcall-label">OUT</div>
<div className="bash-toolcall-row-content">
<div className="bash-toolcall-output-subtle">
<pre className="bash-toolcall-pre">{truncatedOutput}</pre>
</div>
</div>
</div>
</div>
</div>
</ToolCallContainer>
);
}
// Success without output: show command with branch connector
return (
<ToolCallContainer
label="Bash"
status={containerStatus}
toolCallId={toolCallId}
>
<div
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
</ToolCallContainer>
);
};

View File

@@ -1,196 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Edit tool call component - specialized for file editing operations
*/
import { useMemo } from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import {
groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
import { FileLink } from '../../../layout/FileLink.js';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label,
status = 'success',
children,
toolCallId: _toolCallId,
labelSuffix,
className: _className,
}) => (
<div
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
>
<div className="EditToolCall toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-baseline gap-1.5 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
</span>
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
{labelSuffix}
</span>
</div>
{children && (
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
)}
</div>
</div>
);
/**
* Calculate diff summary (added/removed lines)
*/
const getDiffSummary = (
oldText: string | null | undefined,
newText: string | undefined,
): string => {
const oldLines = oldText ? oldText.split('\n').length : 0;
const newLines = newText ? newText.split('\n').length : 0;
const diff = newLines - oldLines;
if (diff > 0) {
return `+${diff} lines`;
} else if (diff < 0) {
return `${diff} lines`;
} else {
return 'Modified';
}
};
/**
* Specialized component for Edit tool calls
* Optimized for displaying file editing operations with diffs
*/
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { content, locations, toolCallId } = toolCall;
// Group content by type; memoize to avoid new array identities on every render
const { errors, diffs } = useMemo(() => groupContent(content), [content]);
// Failed case: show explicit failed message and render inline diffs
if (toolCall.status === 'failed') {
const firstDiff = diffs[0];
const path = firstDiff?.path || locations?.[0]?.path || '';
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<div
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
>
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-[13px] leading-none font-bold text-[var(--app-primary-foreground)]">
Edit
</span>
{path && (
<FileLink
path={path}
showFullPath={false}
className="font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
)}
</div>
</div>
{/* Failed state text (replace summary) */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-center">
<span className="flex-shrink-0 w-full">edit failed</span>
</div>
</div>
</div>
);
}
// Error case: show error
if (errors.length > 0) {
const path = diffs[0]?.path || locations?.[0]?.path || '';
return (
<ToolCallContainer
label={'Edit'}
status="error"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono hover:underline"
/>
) : undefined
}
>
{errors.join('\n')}
</ToolCallContainer>
);
}
// Success case with diff: show minimal inline preview; clicking the title opens VS Code diff
if (diffs.length > 0) {
const firstDiff = diffs[0];
const path = firstDiff.path || (locations && locations[0]?.path) || '';
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<div
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
>
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-baseline gap-1.5 min-w-0">
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
<span className="text-[13px] leading-none font-bold text-[var(--app-primary-foreground)]">
Edit
</span>
{path && (
<FileLink
path={path}
showFullPath={false}
className="font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
)}
</div>
</div>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-baseline">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{summary}</span>
</div>
</div>
</div>
);
}
// Success case without diff: show file in compact format
if (locations && locations.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<ToolCallContainer
label={`Edit`}
status={containerStatus}
toolCallId={toolCallId}
labelSuffix={
<FileLink
path={locations[0].path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-center">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<FileLink
path={locations[0].path}
line={locations[0].line}
showFullPath={true}
/>
</div>
</ToolCallContainer>
);
}
// No output, don't show anything
return null;
};

View File

@@ -1,102 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call styles - Enhanced styling with semantic class names
*/
/* Root container for execute tool call output */
.execute-toolcall-card {
border: 0.5px solid var(--app-input-border);
border-radius: 5px;
background: var(--app-tool-background);
margin: 8px 0;
max-width: 100%;
font-size: 1em;
align-items: start;
}
/* Content wrapper inside the card */
.execute-toolcall-content {
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px;
}
/* Individual input/output row */
.execute-toolcall-row {
display: grid;
grid-template-columns: max-content 1fr;
border-top: 0.5px solid var(--app-input-border);
padding: 4px;
}
/* First row has no top border */
.execute-toolcall-row:first-child {
border-top: none;
}
/* Row label (IN/OUT/ERROR) */
.execute-toolcall-label {
grid-column: 1;
color: var(--app-secondary-foreground);
text-align: left;
opacity: 50%;
padding: 4px 8px 4px 4px;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Row content area */
.execute-toolcall-row-content {
grid-column: 2;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 4px;
}
/* Truncated content styling */
.execute-toolcall-row-content:not(.execute-toolcall-full) {
max-height: 60px;
mask-image: linear-gradient(
to bottom,
var(--app-primary-background) 40px,
transparent 60px
);
overflow: hidden;
}
/* Preformatted content */
.execute-toolcall-pre {
margin-block: 0;
overflow: hidden;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Code content */
.execute-toolcall-code {
margin: 0;
padding: 0;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Output content with subtle styling */
.execute-toolcall-output-subtle {
background-color: var(--app-code-background);
white-space: pre;
overflow-x: auto;
max-width: 100%;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
/* Error content styling */
.execute-toolcall-error-content {
color: #c74e39;
}

View File

@@ -1,173 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call component - specialized for command execution operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { safeTitle, groupContent } from '../shared/utils.js';
import './Execute.css';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label,
status = 'success',
children,
toolCallId: _toolCallId,
labelSuffix,
className: _className,
}) => (
<div
className={`ExecuteToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
>
<div className="toolcall-content-wrapper flex flex-col gap-0 min-w-0 max-w-full">
<div className="flex items-baseline gap-1.5 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
</span>
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
{labelSuffix}
</span>
</div>
{children && (
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
)}
</div>
</div>
);
/**
* Specialized component for Execute tool calls
* Shows: Execute bullet + description + IN/OUT card
*/
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, content, rawInput, toolCallId } = toolCall;
const commandText = safeTitle(
(rawInput as Record<string, unknown>)?.description || title,
);
// Group content by type
const { textOutputs, errors } = groupContent(content);
// Extract command from rawInput if available
let inputCommand = commandText;
if (rawInput && typeof rawInput === 'object') {
const inputObj = rawInput as Record<string, unknown>;
inputCommand = (inputObj.command as string | undefined) || commandText;
} else if (typeof rawInput === 'string') {
inputCommand = rawInput;
}
// Map tool status to container status for proper bullet coloring
const containerStatus:
| 'success'
| 'error'
| 'warning'
| 'loading'
| 'default' =
errors.length > 0 || toolCall.status === 'failed'
? 'error'
: toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
// Error case
if (errors.length > 0) {
return (
<ToolCallContainer
label="Execute"
status={containerStatus}
toolCallId={toolCallId}
className="execute-default-toolcall"
>
{/* Branch connector summary */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
{/* Error card - semantic DOM + Tailwind styles */}
<div className="execute-toolcall-card">
<div className="execute-toolcall-content">
{/* IN row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre>
</div>
</div>
{/* ERROR row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">Error</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre execute-toolcall-error-content">
{errors.join('\n')}
</pre>
</div>
</div>
</div>
</div>
</ToolCallContainer>
);
}
// Success with output
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
const truncatedOutput =
output.length > 500 ? output.substring(0, 500) + '...' : output;
return (
<ToolCallContainer
label="Execute"
status={containerStatus}
toolCallId={toolCallId}
>
{/* Branch connector summary */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
{/* Output card - semantic DOM + Tailwind styles */}
<div className="execute-toolcall-card">
<div className="execute-toolcall-content">
{/* IN row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre>
</div>
</div>
{/* OUT row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">OUT</div>
<div className="execute-toolcall-row-content">
<div className="execute-toolcall-output-subtle">
<pre className="execute-toolcall-pre">{truncatedOutput}</pre>
</div>
</div>
</div>
</div>
</div>
</ToolCallContainer>
);
}
// Success without output: show command with branch connector
return (
<ToolCallContainer
label="Execute"
status={containerStatus}
toolCallId={toolCallId}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
</ToolCallContainer>
);
};

View File

@@ -1,119 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Generic tool call component - handles all tool call types as fallback
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallContainer,
ToolCallCard,
ToolCallRow,
LocationsList,
} from './shared/LayoutComponents.js';
import { safeTitle, groupContent } from './shared/utils.js';
/**
* Generic tool call component that can display any tool call type
* Used as fallback for unknown tool call kinds
* Minimal display: show description and outcome
*/
export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { kind, title, content, locations, toolCallId } = toolCall;
const operationText = safeTitle(title);
// Group content by type
const { textOutputs, errors } = groupContent(content);
// Error case: show operation + error in card layout
if (errors.length > 0) {
return (
<ToolCallCard icon="🔧">
<ToolCallRow label={kind}>
<div>{operationText}</div>
</ToolCallRow>
<ToolCallRow label="Error">
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div>
</ToolCallRow>
</ToolCallCard>
);
}
// Success with output: use card for long output, compact for short
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
const isLong = output.length > 150;
if (isLong) {
const truncatedOutput =
output.length > 300 ? output.substring(0, 300) + '...' : output;
return (
<ToolCallCard icon="🔧">
<ToolCallRow label={kind}>
<div>{operationText}</div>
</ToolCallRow>
<ToolCallRow label="Output">
<div className="whitespace-pre-wrap font-mono text-[13px] opacity-90">
{truncatedOutput}
</div>
</ToolCallRow>
</ToolCallCard>
);
}
// Short output - compact format
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
return (
<ToolCallContainer
label={kind}
status={statusFlag}
toolCallId={toolCallId}
>
{operationText || output}
</ToolCallContainer>
);
}
// Success with files: show operation + file list in compact format
if (locations && locations.length > 0) {
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
return (
<ToolCallContainer
label={kind}
status={statusFlag}
toolCallId={toolCallId}
>
<LocationsList locations={locations} />
</ToolCallContainer>
);
}
// No output - show just the operation
if (operationText) {
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
return (
<ToolCallContainer
label={kind}
status={statusFlag}
toolCallId={toolCallId}
>
{operationText}
</ToolCallContainer>
);
}
return null;
};

View File

@@ -1,177 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Read tool call component - specialized for file reading operations
*/
import type React from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import {
groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
import { FileLink } from '../../../layout/FileLink.js';
import { useVSCode } from '../../../../hooks/useVSCode.js';
import { handleOpenDiff } from '../../../../utils/diffUtils.js';
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
label,
status = 'success',
children,
toolCallId: _toolCallId,
labelSuffix,
className: _className,
}) => (
<div
className={`ReadToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
>
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-baseline gap-1.5 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
</span>
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
{labelSuffix}
</span>
</div>
{children && (
<div className="text-[var(--app-secondary-foreground)] py-1">
{children}
</div>
)}
</div>
</div>
);
/**
* Specialized component for Read tool calls
* Optimized for displaying file reading operations
* Shows: Read filename (no content preview)
*/
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { content, locations, toolCallId } = toolCall;
const vscode = useVSCode();
// Group content by type; memoize to avoid new array identities on every render
const { errors, diffs } = useMemo(() => groupContent(content), [content]);
// Post a message to the extension host to open a VS Code diff tab
const handleOpenDiffInternal = useCallback(
(
path: string | undefined,
oldText: string | null | undefined,
newText: string | undefined,
) => {
handleOpenDiff(vscode, path, oldText, newText);
},
[vscode],
);
// Auto-open diff when a read call returns diff content.
// Only trigger once per toolCallId so we don't spam as in-progress updates stream in.
useEffect(() => {
if (diffs.length > 0) {
const firstDiff = diffs[0];
const path = firstDiff.path || (locations && locations[0]?.path) || '';
if (
path &&
firstDiff.oldText !== undefined &&
firstDiff.newText !== undefined
) {
const timer = setTimeout(() => {
handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText);
}, 100);
return () => timer && clearTimeout(timer);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolCallId]);
// Compute container status based on toolCall.status (pending/in_progress -> loading)
const containerStatus:
| 'success'
| 'error'
| 'warning'
| 'loading'
| 'default' = mapToolStatusToContainerStatus(toolCall.status);
// Error case: show error
if (errors.length > 0) {
const path = locations?.[0]?.path || '';
return (
<ToolCallContainer
label={'Read'}
className="read-tool-call-error"
status="error"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{errors.join('\n')}
</ToolCallContainer>
);
}
// Success case with diff: keep UI compact; VS Code diff is auto-opened above
if (diffs.length > 0) {
const path = diffs[0]?.path || locations?.[0]?.path || '';
return (
<ToolCallContainer
label={'Read'}
className="read-tool-call-success"
status={containerStatus}
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{null}
</ToolCallContainer>
);
}
// Success case: show which file was read with filename in label
if (locations && locations.length > 0) {
const path = locations[0].path;
return (
<ToolCallContainer
label={'Read'}
className="read-tool-call-success"
status={containerStatus}
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{null}
</ToolCallContainer>
);
}
// No file info, don't show
return null;
};

View File

@@ -1,227 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Search tool call component - specialized for search operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { FileLink } from '../../../layout/FileLink.js';
import {
safeTitle,
groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
/**
* Specialized component for Search tool calls
* Optimized for displaying search operations and results
* Shows query + result count or file list
*/
const InlineContainer: React.FC<{
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
labelSuffix?: string;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
}> = ({ status, labelSuffix, children, isFirst, isLast }) => {
const beforeStatusClass = `toolcall-container toolcall-status-${status}`;
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
const lineCropBottom = isLast
? 'bottom-auto h-[calc(100%-24px)]'
: 'bottom-0';
return (
<div
className={
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
beforeStatusClass
}
>
{/* timeline vertical line */}
<div
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
aria-hidden
/>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
Search
</span>
{labelSuffix ? (
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
{labelSuffix}
</span>
) : null}
</div>
{children ? (
<div className="mt-1 text-[var(--app-secondary-foreground)]">
{children}
</div>
) : null}
</div>
</div>
);
};
// Local card layout for multi-result or error display
const SearchCard: React.FC<{
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
children: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
}> = ({ status, children, isFirst, isLast }) => {
const beforeStatusClass =
status === 'success'
? 'before:text-qwen-success'
: status === 'error'
? 'before:text-qwen-error'
: status === 'warning'
? 'before:text-qwen-warning'
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
const lineCropBottom = isLast
? 'bottom-auto h-[calc(100%-24px)]'
: 'bottom-0';
return (
<div
className={
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
beforeStatusClass
}
>
{/* timeline vertical line */}
<div
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
aria-hidden
/>
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium">
<div className="flex flex-col gap-3 min-w-0">{children}</div>
</div>
</div>
);
};
const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({
label,
children,
}) => (
<div className="grid grid-cols-[80px_1fr] gap-medium min-w-0">
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
{label}
</div>
<div className="text-[var(--app-primary-foreground)] min-w-0 break-words">
{children}
</div>
</div>
);
const LocationsListLocal: React.FC<{
locations: Array<{ path: string; line?: number | null }>;
}> = ({ locations }) => (
<div className="flex flex-col gap-1 max-w-full">
{locations.map((loc, idx) => (
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
))}
</div>
);
export const SearchToolCall: React.FC<BaseToolCallProps> = ({
toolCall,
isFirst,
isLast,
}) => {
const { title, content, locations } = toolCall;
const queryText = safeTitle(title);
// Group content by type
const { errors, textOutputs } = groupContent(content);
// Error case: show search query + error in card layout
if (errors.length > 0) {
return (
<SearchCard status="error" isFirst={isFirst} isLast={isLast}>
<SearchRow label="Search">
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label="Error">
<div className="text-qwen-error font-medium">{errors.join('\n')}</div>
</SearchRow>
</SearchCard>
);
}
// Success case with results: show search query + file list
if (locations && locations.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
// If multiple results, use card layout; otherwise use compact format
if (locations.length > 1) {
return (
<SearchCard status={containerStatus} isFirst={isFirst} isLast={isLast}>
<SearchRow label="Search">
<div className="font-mono">{queryText}</div>
</SearchRow>
<SearchRow label={`Found (${locations.length})`}>
<LocationsListLocal locations={locations} />
</SearchRow>
</SearchCard>
);
}
// Single result - compact format
return (
<InlineContainer
status={containerStatus}
labelSuffix={`(${queryText})`}
isFirst={isFirst}
isLast={isLast}
>
<span className="mx-2 opacity-50"></span>
<LocationsListLocal locations={locations} />
</InlineContainer>
);
}
// Show content text if available (e.g., "Listed 4 item(s).")
if (textOutputs.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<InlineContainer
status={containerStatus}
labelSuffix={queryText ? `(${queryText})` : undefined}
isFirst={isFirst}
isLast={isLast}
>
<div className="flex flex-col">
{textOutputs.map((text, index) => (
<div
key={index}
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
>
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{text}</span>
</div>
))}
</div>
</InlineContainer>
);
}
// No results - show query only
if (queryText) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return (
<InlineContainer
status={containerStatus}
isFirst={isFirst}
isLast={isLast}
>
<span className="font-mono">{queryText}</span>
</InlineContainer>
);
}
return null;
};

View File

@@ -1,72 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Think tool call component - specialized for thinking/reasoning operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import {
ToolCallContainer,
ToolCallCard,
ToolCallRow,
} from '../shared/LayoutComponents.js';
import { groupContent } from '../shared/utils.js';
/**
* Specialized component for Think tool calls
* Optimized for displaying AI reasoning and thought processes
* Minimal display: just show the thoughts (no context)
*/
export const ThinkToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { content } = toolCall;
// Group content by type
const { textOutputs, errors } = groupContent(content);
// Error case (rare for thinking)
if (errors.length > 0) {
return (
<ToolCallContainer label="Thinking" status="error">
{errors.join('\n')}
</ToolCallContainer>
);
}
// Show thoughts - use card for long content, compact for short
if (textOutputs.length > 0) {
const thoughts = textOutputs.join('\n\n');
const isLong = thoughts.length > 200;
if (isLong) {
const truncatedThoughts =
thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts;
return (
<ToolCallCard icon="💭">
<ToolCallRow label="Thinking">
<div className="italic opacity-90 leading-relaxed">
{truncatedThoughts}
</div>
</ToolCallRow>
</ToolCallCard>
);
}
// Short thoughts - compact format
const status =
toolCall.status === 'pending' || toolCall.status === 'in_progress'
? 'loading'
: 'default';
return (
<ToolCallContainer label="Thinking" status={status}>
<span className="italic opacity-90">{thoughts}</span>
</ToolCallContainer>
);
}
// Empty thoughts
return null;
};

View File

@@ -1,29 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Main ToolCall component - uses factory pattern to route to specialized components
*
* This file serves as the public API for tool call rendering.
* It re-exports the router and types from the toolcalls module.
*/
import type React from 'react';
import { ToolCallRouter } from './index.js';
// Re-export types from the toolcalls module for backward compatibility
export type {
ToolCallData,
BaseToolCallProps as ToolCallProps,
} from './shared/types.js';
// Re-export the content type for external use
export type { ToolCallContent } from './shared/types.js';
export const ToolCall: React.FC<{
toolCall: import('./shared/types.js').ToolCallData;
isFirst?: boolean;
isLast?: boolean;
}> = ({ toolCall, isFirst, isLast }) => (
<ToolCallRouter toolCall={toolCall} isFirst={isFirst} isLast={isLast} />
);

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