diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json
index 6c5be6727..1ed40e136 100644
--- a/packages/vscode-ide-companion/package.json
+++ b/packages/vscode-ide-companion/package.json
@@ -152,6 +152,7 @@
"vitest": "^3.2.4"
},
"dependencies": {
+ "@qwen-code/webui": "workspace:*",
"semver": "^7.7.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"cors": "^2.8.5",
diff --git a/packages/webui/example/ExampleComponent.tsx b/packages/webui/example/ExampleComponent.tsx
new file mode 100644
index 000000000..c29800049
--- /dev/null
+++ b/packages/webui/example/ExampleComponent.tsx
@@ -0,0 +1,78 @@
+// Example of how to use shared UI components
+// This would typically be integrated into existing components
+
+import React, { useState } from 'react';
+import {
+ Button,
+ Input,
+ Message,
+ PermissionDrawer,
+ Tooltip,
+} from '@qwen-code/webui';
+
+const ExampleComponent: React.FC = () => {
+ const [inputValue, setInputValue] = useState('');
+ const [showPermissionDrawer, setShowPermissionDrawer] = useState(false);
+
+ const handleConfirmPermission = () => {
+ console.log('Permissions confirmed');
+ setShowPermissionDrawer(false);
+ };
+
+ return (
+
+
Shared Components Demo
+
+ {/* Example of using shared Button component */}
+
+
+
+
+ {/* Example of using shared Input component */}
+
+
+
+
+ {/* Example of using shared Message component */}
+
+
+
+
+ {/* Example of using shared Tooltip component */}
+
+
+
+
+
+
+ {/* Example of using shared PermissionDrawer component */}
+
setShowPermissionDrawer(false)}
+ onConfirm={handleConfirmPermission}
+ permissions={[
+ 'Access browser history',
+ 'Read current page',
+ 'Capture screenshots',
+ ]}
+ />
+
+ );
+};
+
+export default ExampleComponent;
diff --git a/packages/webui/package.json b/packages/webui/package.json
new file mode 100644
index 000000000..757a6d812
--- /dev/null
+++ b/packages/webui/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@qwen-code/webui",
+ "version": "0.1.0",
+ "description": "Shared UI components for Qwen Code packages",
+ "main": "dist/index.js",
+ "module": "dist/index.esm.js",
+ "types": "dist/index.d.ts",
+ "type": "module",
+ "scripts": {
+ "build": "tsc && rollup -c",
+ "dev": "tsc --watch",
+ "lint": "eslint src --ext .ts,.tsx",
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
+ "typecheck": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.0",
+ "@types/react-dom": "^18.0.0",
+ "typescript": "^5.0.0",
+ "@rollup/plugin-typescript": "^11.0.0",
+ "rollup": "^4.0.0",
+ "rollup-plugin-dts": "^6.0.0"
+ },
+ "keywords": [
+ "qwen",
+ "ui",
+ "components",
+ "shared"
+ ],
+ "author": "Qwen Team",
+ "license": "MIT"
+}
diff --git a/packages/webui/rollup.config.js b/packages/webui/rollup.config.js
new file mode 100644
index 000000000..c6340858a
--- /dev/null
+++ b/packages/webui/rollup.config.js
@@ -0,0 +1,47 @@
+import typescript from '@rollup/plugin-typescript';
+import { dts } from 'rollup-plugin-dts';
+import pkg from './package.json' with { type: 'json' };
+
+const name = pkg.name;
+
+export default [
+ // Browser-friendly version
+ {
+ input: 'src/index.ts',
+ output: {
+ name,
+ file: 'dist/index.min.js',
+ format: 'iife',
+ globals: {
+ react: 'React',
+ 'react-dom': 'ReactDOM',
+ },
+ },
+ external: ['react', 'react-dom'],
+ plugins: [
+ typescript({
+ tsconfig: './tsconfig.json',
+ }),
+ ],
+ },
+ // ES module version
+ {
+ input: 'src/index.ts',
+ output: [
+ { file: 'dist/index.esm.js', format: 'es' },
+ { file: 'dist/index.cjs.js', format: 'cjs' },
+ ],
+ external: ['react', 'react-dom'],
+ plugins: [
+ typescript({
+ tsconfig: './tsconfig.json',
+ }),
+ ],
+ },
+ // Type declarations
+ {
+ input: 'dist/dts/src/index.d.ts',
+ output: [{ file: 'dist/index.d.ts', format: 'es' }],
+ plugins: [dts()],
+ },
+];
diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx
new file mode 100644
index 000000000..2805915cb
--- /dev/null
+++ b/packages/webui/src/components/PermissionDrawer.tsx
@@ -0,0 +1,93 @@
+import type React from 'react';
+import { useState, useEffect } from 'react';
+
+interface PermissionDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ permissions: string[];
+}
+
+const PermissionDrawer: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ permissions,
+}) => {
+ const [checkedPermissions, setCheckedPermissions] = useState(
+ Array(permissions.length).fill(false),
+ );
+
+ useEffect(() => {
+ if (!isOpen) {
+ setCheckedPermissions(Array(permissions.length).fill(false));
+ }
+ }, [isOpen, permissions]);
+
+ const handleTogglePermission = (index: number) => {
+ const newChecked = [...checkedPermissions];
+ newChecked[index] = !newChecked[index];
+ setCheckedPermissions(newChecked);
+ };
+
+ const handleConfirm = () => {
+ onConfirm();
+ onClose();
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
Permissions Required
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PermissionDrawer;
diff --git a/packages/webui/src/components/icons/CloseIcon.tsx b/packages/webui/src/components/icons/CloseIcon.tsx
new file mode 100644
index 000000000..ac559d299
--- /dev/null
+++ b/packages/webui/src/components/icons/CloseIcon.tsx
@@ -0,0 +1,30 @@
+import type React from 'react';
+
+interface CloseIconProps {
+ size?: number;
+ color?: string;
+ className?: string;
+}
+
+const CloseIcon: React.FC = ({
+ size = 24,
+ color = 'currentColor',
+ className = '',
+}) => (
+
+ );
+
+export default CloseIcon;
diff --git a/packages/webui/src/components/icons/Icon.tsx b/packages/webui/src/components/icons/Icon.tsx
new file mode 100644
index 000000000..2f874d594
--- /dev/null
+++ b/packages/webui/src/components/icons/Icon.tsx
@@ -0,0 +1,38 @@
+import type React from 'react';
+
+interface IconProps {
+ name: string;
+ size?: number;
+ color?: string;
+ className?: string;
+}
+
+const Icon: React.FC = ({
+ name,
+ size = 24,
+ color = 'currentColor',
+ className = '',
+}) =>
+ // This is a placeholder - in a real implementation you might use an icon library
+ (
+
+ )
+;
+
+export default Icon;
diff --git a/packages/webui/src/components/icons/SendIcon.tsx b/packages/webui/src/components/icons/SendIcon.tsx
new file mode 100644
index 000000000..d2561e033
--- /dev/null
+++ b/packages/webui/src/components/icons/SendIcon.tsx
@@ -0,0 +1,30 @@
+import type React from 'react';
+
+interface SendIconProps {
+ size?: number;
+ color?: string;
+ className?: string;
+}
+
+const SendIcon: React.FC = ({
+ size = 24,
+ color = 'currentColor',
+ className = '',
+}) => (
+
+ );
+
+export default SendIcon;
diff --git a/packages/webui/src/components/layout/Container.tsx b/packages/webui/src/components/layout/Container.tsx
new file mode 100644
index 000000000..436d4c72e
--- /dev/null
+++ b/packages/webui/src/components/layout/Container.tsx
@@ -0,0 +1,12 @@
+import type React from 'react';
+
+interface ContainerProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+const Container: React.FC = ({ children, className = '' }) => (
+ {children}
+ );
+
+export default Container;
diff --git a/packages/webui/src/components/layout/Footer.tsx b/packages/webui/src/components/layout/Footer.tsx
new file mode 100644
index 000000000..557a083b1
--- /dev/null
+++ b/packages/webui/src/components/layout/Footer.tsx
@@ -0,0 +1,5 @@
+import type React from 'react';
+
+const Footer: React.FC = () => ;
+
+export default Footer;
diff --git a/packages/webui/src/components/layout/Header.tsx b/packages/webui/src/components/layout/Header.tsx
new file mode 100644
index 000000000..2886c0dfb
--- /dev/null
+++ b/packages/webui/src/components/layout/Header.tsx
@@ -0,0 +1,5 @@
+import type React from 'react';
+
+const Header: React.FC = () => Header Component Placeholder;
+
+export default Header;
diff --git a/packages/webui/src/components/layout/Main.tsx b/packages/webui/src/components/layout/Main.tsx
new file mode 100644
index 000000000..118f67430
--- /dev/null
+++ b/packages/webui/src/components/layout/Main.tsx
@@ -0,0 +1,5 @@
+import type React from 'react';
+
+const Main: React.FC = () => Main Component Placeholder;
+
+export default Main;
diff --git a/packages/webui/src/components/layout/Sidebar.tsx b/packages/webui/src/components/layout/Sidebar.tsx
new file mode 100644
index 000000000..eb3746c20
--- /dev/null
+++ b/packages/webui/src/components/layout/Sidebar.tsx
@@ -0,0 +1,5 @@
+import type React from 'react';
+
+const Sidebar: React.FC = () => ;
+
+export default Sidebar;
diff --git a/packages/webui/src/components/messages/Message.tsx b/packages/webui/src/components/messages/Message.tsx
new file mode 100644
index 000000000..e09208528
--- /dev/null
+++ b/packages/webui/src/components/messages/Message.tsx
@@ -0,0 +1,39 @@
+import type React from 'react';
+
+interface MessageProps {
+ id: string;
+ content: string;
+ sender: 'user' | 'system' | 'assistant';
+ timestamp?: Date;
+ className?: string;
+}
+
+const Message: React.FC = ({
+ content,
+ sender,
+ timestamp,
+ className = '',
+}) => {
+ const alignment = sender === 'user' ? 'justify-end' : 'justify-start';
+ const bgColor = sender === 'user' ? 'bg-blue-500' : 'bg-gray-200';
+
+ return (
+
+
+ {content}
+ {timestamp && (
+
+ {timestamp.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ )}
+
+
+ );
+};
+
+export default Message;
diff --git a/packages/webui/src/components/messages/MessageInput.tsx b/packages/webui/src/components/messages/MessageInput.tsx
new file mode 100644
index 000000000..bd4637155
--- /dev/null
+++ b/packages/webui/src/components/messages/MessageInput.tsx
@@ -0,0 +1,5 @@
+import type React from 'react';
+
+const MessageInput: React.FC = () => MessageInput Component Placeholder
;
+
+export default MessageInput;
diff --git a/packages/webui/src/components/messages/MessageList.tsx b/packages/webui/src/components/messages/MessageList.tsx
new file mode 100644
index 000000000..5544865a0
--- /dev/null
+++ b/packages/webui/src/components/messages/MessageList.tsx
@@ -0,0 +1,5 @@
+import type React from 'react';
+
+const MessageList: React.FC = () => MessageList Component Placeholder
;
+
+export default MessageList;
diff --git a/packages/webui/src/components/ui/Button.tsx b/packages/webui/src/components/ui/Button.tsx
new file mode 100644
index 000000000..1ee0d3b07
--- /dev/null
+++ b/packages/webui/src/components/ui/Button.tsx
@@ -0,0 +1,49 @@
+import type React from 'react';
+
+interface ButtonProps {
+ children: React.ReactNode;
+ onClick?: () => void;
+ variant?: 'primary' | 'secondary' | 'danger';
+ size?: 'sm' | 'md' | 'lg';
+ disabled?: boolean;
+ className?: string;
+}
+
+const Button: React.FC = ({
+ children,
+ onClick,
+ variant = 'primary',
+ size = 'md',
+ disabled = false,
+ className = '',
+}) => {
+ const baseClasses =
+ 'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
+
+ const variantClasses = {
+ primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
+ secondary:
+ 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500',
+ danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
+ };
+
+ const sizeClasses = {
+ sm: 'px-2 py-1 text-sm',
+ md: 'px-4 py-2',
+ lg: 'px-6 py-3 text-lg',
+ };
+
+ const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : '';
+
+ return (
+
+ );
+};
+
+export default Button;
diff --git a/packages/webui/src/components/ui/Input.tsx b/packages/webui/src/components/ui/Input.tsx
new file mode 100644
index 000000000..8fd350c67
--- /dev/null
+++ b/packages/webui/src/components/ui/Input.tsx
@@ -0,0 +1,25 @@
+import type React from 'react';
+
+interface InputProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ className?: string;
+}
+
+const Input: React.FC = ({
+ value,
+ onChange,
+ placeholder,
+ className = '',
+}) => (
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ className={`border rounded px-3 py-2 ${className}`}
+ />
+ );
+
+export default Input;
diff --git a/packages/webui/src/components/ui/Tooltip.tsx b/packages/webui/src/components/ui/Tooltip.tsx
new file mode 100644
index 000000000..6b9206af9
--- /dev/null
+++ b/packages/webui/src/components/ui/Tooltip.tsx
@@ -0,0 +1,93 @@
+import React, { useState } from 'react';
+
+interface ChildProps {
+ onMouseEnter?: () => void;
+ onMouseLeave?: () => void;
+ onFocus?: () => void;
+ onBlur?: () => void;
+ tabIndex?: number;
+}
+
+interface TooltipProps {
+ children: React.ReactElement;
+ content: string;
+ position?: 'top' | 'right' | 'bottom' | 'left';
+}
+
+const Tooltip: React.FC = ({
+ children,
+ content,
+ position = 'top',
+}) => {
+ const [isVisible, setIsVisible] = useState(false);
+
+ const positionClasses = {
+ top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
+ right: 'top-1/2 left-full transform -translate-y-1/2 ml-2',
+ bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
+ left: 'top-1/2 right-full transform -translate-y-1/2 mr-2',
+ };
+
+ const arrowPositionClasses = {
+ top: 'top-full left-1/2 transform -translate-x-1/2 -mt-1',
+ right: 'top-1/2 left-0 transform -translate-y-1/2 -ml-1',
+ bottom: 'top-0 left-1/2 transform -translate-x-1/2 -mb-1',
+ left: 'top-1/2 right-0 transform -translate-y-1/2 -mr-1',
+ };
+
+ const tooltipClass = `absolute ${positionClasses[position]} bg-gray-800 text-white text-xs rounded py-1 px-2 pointer-events-none z-10`;
+ const arrowClass = `absolute w-2 h-2 bg-gray-800 transform rotate-45 ${arrowPositionClasses[position]}`;
+
+ return (
+
+
setIsVisible(true)}
+ onMouseLeave={() => setIsVisible(false)}
+ onFocus={() => setIsVisible(true)}
+ onBlur={() => setIsVisible(false)}
+ tabIndex={0}
+ >
+ {React.cloneElement(children, {
+ onMouseEnter: () => {
+ setIsVisible(true);
+ const typedChildren = children as React.ReactElement;
+ if (typeof typedChildren.props.onMouseEnter === 'function') {
+ typedChildren.props.onMouseEnter();
+ }
+ },
+ onMouseLeave: () => {
+ setIsVisible(false);
+ const typedChildren = children as React.ReactElement;
+ if (typeof typedChildren.props.onMouseLeave === 'function') {
+ typedChildren.props.onMouseLeave();
+ }
+ },
+ onFocus: () => {
+ setIsVisible(true);
+ const typedChildren = children as React.ReactElement;
+ if (typeof typedChildren.props.onFocus === 'function') {
+ typedChildren.props.onFocus();
+ }
+ },
+ onBlur: () => {
+ setIsVisible(false);
+ const typedChildren = children as React.ReactElement;
+ if (typeof typedChildren.props.onBlur === 'function') {
+ typedChildren.props.onBlur();
+ }
+ },
+ tabIndex:
+ (children as React.ReactElement).props.tabIndex || 0,
+ })}
+
+ {isVisible && (
+
+ )}
+
+ );
+};
+
+export default Tooltip;
diff --git a/packages/webui/src/hooks/useLocalStorage.ts b/packages/webui/src/hooks/useLocalStorage.ts
new file mode 100644
index 000000000..7fb675411
--- /dev/null
+++ b/packages/webui/src/hooks/useLocalStorage.ts
@@ -0,0 +1,27 @@
+import { useState } from 'react';
+
+export const useLocalStorage = (key: string, initialValue: T) => {
+ // Get value from localStorage or use initial value
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (_error) {
+ return initialValue;
+ }
+ });
+
+ // Update localStorage when state changes
+ const setValue = (value: T | ((val: T) => T)) => {
+ try {
+ const valueToStore =
+ value instanceof Function ? value(storedValue) : value;
+ setStoredValue(valueToStore);
+ window.localStorage.setItem(key, JSON.stringify(valueToStore));
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ return [storedValue, setValue] as const;
+};
diff --git a/packages/webui/src/hooks/useTheme.ts b/packages/webui/src/hooks/useTheme.ts
new file mode 100644
index 000000000..c4ca0a77a
--- /dev/null
+++ b/packages/webui/src/hooks/useTheme.ts
@@ -0,0 +1,29 @@
+import { useState, useEffect } from 'react';
+
+export const useTheme = () => {
+ const [theme, setTheme] = useState<'light' | 'dark' | 'auto'>('auto');
+
+ useEffect(() => {
+ const savedTheme = localStorage.getItem('theme') as
+ | 'light'
+ | 'dark'
+ | 'auto'
+ | null;
+ if (savedTheme) {
+ setTheme(savedTheme);
+ } else {
+ const prefersDark = window.matchMedia(
+ '(prefers-color-scheme: dark)',
+ ).matches;
+ setTheme(prefersDark ? 'dark' : 'light');
+ }
+ }, []);
+
+ const toggleTheme = () => {
+ const newTheme = theme === 'light' ? 'dark' : 'light';
+ setTheme(newTheme);
+ localStorage.setItem('theme', newTheme);
+ };
+
+ return { theme, toggleTheme };
+};
diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts
new file mode 100644
index 000000000..6f4066229
--- /dev/null
+++ b/packages/webui/src/index.ts
@@ -0,0 +1,35 @@
+// Shared UI Components Export
+// Export all shared components from this package
+
+// Layout components
+export { default as Container } from './components/layout/Container';
+export { default as Header } from './components/layout/Header';
+export { default as Sidebar } from './components/layout/Sidebar';
+export { default as Main } from './components/layout/Main';
+export { default as Footer } from './components/layout/Footer';
+
+// Message components
+export { default as Message } from './components/messages/Message';
+export { default as MessageInput } from './components/messages/MessageInput';
+export { default as MessageList } from './components/messages/MessageList';
+
+// UI Elements
+export { default as Button } from './components/ui/Button';
+export { default as Input } from './components/ui/Input';
+export { default as Tooltip } from './components/ui/Tooltip';
+
+// Permission components
+export { default as PermissionDrawer } from './components/PermissionDrawer';
+
+// Icons
+export { default as Icon } from './components/icons/Icon';
+export { default as CloseIcon } from './components/icons/CloseIcon';
+export { default as SendIcon } from './components/icons/SendIcon';
+
+// Hooks
+export { useTheme } from './hooks/useTheme';
+export { useLocalStorage } from './hooks/useLocalStorage';
+
+// Types
+export type { Theme } from './types/theme';
+export type { MessageProps } from './types/messages';
diff --git a/packages/webui/src/types/messages.ts b/packages/webui/src/types/messages.ts
new file mode 100644
index 000000000..9556b6176
--- /dev/null
+++ b/packages/webui/src/types/messages.ts
@@ -0,0 +1,7 @@
+export interface MessageProps {
+ id: string;
+ content: string;
+ sender: 'user' | 'system' | 'assistant';
+ timestamp?: Date;
+ className?: string;
+}
diff --git a/packages/webui/src/types/theme.ts b/packages/webui/src/types/theme.ts
new file mode 100644
index 000000000..3418c16f4
--- /dev/null
+++ b/packages/webui/src/types/theme.ts
@@ -0,0 +1 @@
+export type Theme = 'light' | 'dark' | 'auto';
diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json
new file mode 100644
index 000000000..58209e086
--- /dev/null
+++ b/packages/webui/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "declaration": true,
+ "declarationDir": "./dist",
+ "emitDeclarationOnly": true
+ },
+ "include": ["src"],
+ "exclude": ["node_modules", "dist"]
+}