style(vscode-ide-companion): use tailwind to refactor some ui components

This commit is contained in:
yiliang114
2025-11-30 23:06:37 +08:00
parent 1acc24bc17
commit 1b37d729cb
27 changed files with 260 additions and 992 deletions

View File

@@ -24,7 +24,7 @@ import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
import { InProgressToolCall } from './components/InProgressToolCall.js';
import { EmptyState } from './components/EmptyState.js';
import type { PlanEntry } from './components/PlanDisplay.js';
import { type CompletionItem } from './components/CompletionMenu.js';
import { type CompletionItem } from './components/CompletionTypes.js';
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
import { InfoBanner } from './components/InfoBanner.js';
import { ChatHeader } from './components/layouts/ChatHeader.js';
@@ -210,11 +210,22 @@ export const App: React.FC = () => {
// If user is near bottom, or if we just appended a new item, scroll to bottom
if (nearBottom() || newMsg || newInProg || newDone) {
// Try scrollIntoView first
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
endEl.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
block: 'end',
});
// Fallback: directly set scrollTop if scrollIntoView doesn't work
setTimeout(() => {
if (container && endEl) {
const shouldScroll = nearBottom() || newMsg || newInProg || newDone;
if (shouldScroll) {
container.scrollTop = container.scrollHeight;
}
}
}, 50);
}
}, [
messageHandling.messages,
@@ -222,6 +233,8 @@ export const App: React.FC = () => {
completedToolCalls,
messageHandling.isWaitingForResponse,
messageHandling.loadingMessage,
messageHandling.isStreaming,
planEntries,
]);
// Handle permission response

View File

@@ -1,97 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Context attachment types
* Based on vscode-copilot-chat implementation
*/
export interface ContextAttachment {
id: string;
type: 'file' | 'symbol' | 'selection' | 'variable';
name: string;
value: string | { uri: string; range?: { start: number; end: number } };
icon?: string;
}
/**
* Manages context attachments for the chat
* Similar to ChatContextAttachments in vscode-copilot-chat
*/
export class ContextAttachmentManager {
private attachments: Map<string, ContextAttachment> = new Map();
private listeners: Array<(attachments: ContextAttachment[]) => void> = [];
/**
* Add a context attachment
*/
addAttachment(attachment: ContextAttachment): void {
this.attachments.set(attachment.id, attachment);
this.notifyListeners();
}
/**
* Remove a context attachment
*/
removeAttachment(id: string): void {
this.attachments.delete(id);
this.notifyListeners();
}
/**
* Get all attachments
*/
getAttachments(): ContextAttachment[] {
return Array.from(this.attachments.values());
}
/**
* Check if an attachment exists
*/
hasAttachment(id: string): boolean {
return this.attachments.has(id);
}
/**
* Clear all attachments
*/
clearAttachments(): void {
this.attachments.clear();
this.notifyListeners();
}
/**
* Subscribe to attachment changes
*/
subscribe(listener: (attachments: ContextAttachment[]) => void): () => void {
this.listeners.push(listener);
return () => {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
};
}
/**
* Notify all listeners of changes
*/
private notifyListeners(): void {
const attachments = this.getAttachments();
this.listeners.forEach((listener) => listener(attachments));
}
/**
* Get context for message sending
*/
getContextForMessage(): Array<Record<string, unknown>> {
return this.getAttachments().map((att) => ({
id: att.id,
type: att.type,
name: att.name,
value: att.value,
}));
}
}

View File

@@ -1,115 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* Claude Code-like dropdown anchored to input container */
.hi {
display: flex;
flex-direction: column;
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 8px;
background: var(--app-menu-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-large);
overflow: hidden;
animation: So .15s ease-out;
max-height: 50vh;
z-index: 1000;
}
/* Optional top spacer to create visual separation from input */
.hi > .spacer-4px { height: 4px; }
.xi {
max-height: 300px;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: var(--app-list-padding);
gap: var(--app-list-gap);
padding-bottom: 8px;
}
.fi { /* divider */
height: 1px;
background: var(--app-input-border);
margin: 4px 0;
}
.vi { /* section label */
padding: 4px 12px;
color: var(--app-primary-foreground);
opacity: .5;
font-size: .9em;
}
.wi { /* item */
padding: var(--app-list-item-padding);
margin: 0 4px;
cursor: pointer;
border-radius: var(--app-list-border-radius);
}
.ki { /* item content */
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.Ii { /* leading icon */
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--vscode-symbolIcon-fileForeground, #cccccc);
}
.Lo { /* primary text */
color: var(--app-primary-foreground);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.Mo { /* secondary text (path/description) */
color: var(--app-secondary-foreground);
opacity: .7;
font-size: .9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
.jo { /* active/selected */
background: var(--app-list-active-background);
color: var(--app-list-active-foreground);
}
.jo .Lo { color: var(--app-list-active-foreground); }
.yi { /* trailing icon placeholder */
width: 16px;
height: 16px;
opacity: .5;
margin-left: auto;
}
@keyframes So {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
/* Container around the input to anchor the dropdown */
.Bo {
position: relative;
display: flex;
}

View File

@@ -1,123 +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 './ClaudeCompletionMenu.css';
import type { CompletionItem } from './CompletionMenu.js';
interface ClaudeCompletionMenuProps {
items: CompletionItem[];
onSelect: (item: CompletionItem) => void;
onClose: () => void;
title?: string;
selectedIndex?: number;
}
/**
* Claude Code-like anchored dropdown rendered above the input field.
* Keyboard: Up/Down to move, Enter to select, Esc to close.
*/
export const ClaudeCompletionMenu: React.FC<ClaudeCompletionMenuProps> = ({
items,
onSelect,
onClose,
title,
selectedIndex = 0,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState(selectedIndex);
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
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} className="hi" role="menu">
<div className="spacer-4px" />
<div className="xi">
{title && <div className="vi">{title}</div>}
{items.map((item, index) => {
const selectedCls = index === selected ? 'jo' : '';
return (
<div
key={item.id}
data-index={index}
className={`wi ${selectedCls}`}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(index)}
role="menuitem"
>
<div className="ki">
{item.icon && <span className="Ii">{item.icon}</span>}
<span className="Lo">{item.label}</span>
{item.description && (
<span className="Mo" title={item.description}>
{item.description}
</span>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,107 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
.completion-menu {
position: fixed;
background: var(--vscode-quickInput-background, #252526);
border: 1px solid var(--vscode-quickInput-border, #454545);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
min-width: 300px;
max-width: 500px;
max-height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
transform: translateY(-100%);
margin-top: -8px;
}
.completion-menu-items {
overflow-y: auto;
overflow-x: hidden;
}
.completion-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.1s ease;
border-bottom: 1px solid var(--vscode-quickInput-separator, transparent);
}
.completion-menu-item:last-child {
border-bottom: none;
}
.completion-menu-item:hover,
.completion-menu-item.selected {
background-color: var(--vscode-list-hoverBackground, #2a2d2e);
}
.completion-item-icon {
flex-shrink: 0;
margin-right: 10px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-symbolIcon-fileForeground, #cccccc);
}
.completion-item-icon svg {
width: 16px;
height: 16px;
}
.completion-item-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: row;
align-items: baseline;
gap: 8px;
}
.completion-item-label {
font-size: 13px;
color: var(--vscode-quickInput-foreground, #cccccc);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.completion-item-description {
font-size: 11px;
color: var(--vscode-descriptionForeground, #8c8c8c);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
/* Scrollbar styling */
.completion-menu-items::-webkit-scrollbar {
width: 10px;
}
.completion-menu-items::-webkit-scrollbar-track {
background: transparent;
}
.completion-menu-items::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-background, #424242);
border-radius: 5px;
}
.completion-menu-items::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-hoverBackground, #4f4f4f);
}

View File

@@ -1,140 +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 './CompletionMenu.css';
export interface CompletionItem {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info';
// Value inserted into the input when selected (e.g., filename or command)
value?: string;
// Optional full path for files (used to build @filename -> full path mapping)
path?: string;
}
interface CompletionMenuProps {
items: CompletionItem[];
position: { top: number; left: number };
onSelect: (item: CompletionItem) => void;
onClose: () => void;
selectedIndex?: number;
}
/**
* Completion menu for @ and / triggers
* Based on vscode-copilot-chat's AttachContextAction
*/
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
items,
position,
onSelect,
onClose,
selectedIndex = 0,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const [selected, setSelected] = useState(selectedIndex);
useEffect(() => {
setSelected(selectedIndex);
}, [selectedIndex]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.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]);
// Scroll selected item into view
useEffect(() => {
const selectedElement = menuRef.current?.querySelector(
`[data-index="${selected}"]`,
);
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest' });
}
}, [selected]);
const isEmpty = items.length === 0;
return (
<div
ref={menuRef}
className="completion-menu"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
}}
>
<div className="completion-menu-items">
{isEmpty ? (
<div className="completion-menu-empty">Type to search files</div>
) : (
items.map((item, index) => (
<div
key={item.id}
data-index={index}
className={`completion-menu-item ${index === selected ? 'selected' : ''}`}
onClick={() => onSelect(item)}
onMouseEnter={() => setSelected(index)}
>
{item.icon && (
<div className="completion-item-icon">{item.icon}</div>
)}
<div className="completion-item-content">
<div className="completion-item-label">{item.label}</div>
{item.description && (
<div className="completion-item-description">
{item.description}
</div>
)}
</div>
</div>
))
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,20 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
// Shared type for completion items used by the input completion system
export interface CompletionItem {
id: string;
label: string;
description?: string;
icon?: React.ReactNode;
type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info';
// Value inserted into the input when selected (e.g., filename or command)
value?: string;
// Optional full path for files (used to build @filename -> full path mapping)
path?: string;
}

View File

@@ -1,79 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
.context-pills-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
background: var(--vscode-input-background, #3c3c3c);
border-bottom: 1px solid var(--vscode-input-border, #454545);
}
.context-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 6px;
background: var(--vscode-badge-background, #4d4d4d);
color: var(--vscode-badge-foreground, #ffffff);
border-radius: 12px;
font-size: 12px;
max-width: 200px;
transition: background-color 0.1s ease;
}
.context-pill:hover {
background: var(--vscode-badge-background, #5a5a5a);
}
.context-pill-icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.context-pill-icon svg {
width: 14px;
height: 14px;
}
.context-pill-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.context-pill-remove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.1s ease;
flex-shrink: 0;
}
.context-pill-remove:hover {
opacity: 1;
}
.context-pill-remove svg {
width: 12px;
height: 12px;
}

View File

@@ -1,65 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import type { ContextAttachment } from '../ContextAttachmentManager.js';
import {
FileListIcon,
PlusSmallIcon,
SymbolIcon,
SelectionIcon,
CloseSmallIcon,
} from './icons/index.js';
import './ContextPills.css';
interface ContextPillsProps {
attachments: ContextAttachment[];
onRemove: (id: string) => void;
}
/**
* Display attached context as pills/chips
* Similar to ChatContextAttachments UI in vscode-copilot-chat
*/
export const ContextPills: React.FC<ContextPillsProps> = ({
attachments,
onRemove,
}) => {
if (attachments.length === 0) {
return null;
}
const getIcon = (type: string) => {
switch (type) {
case 'file':
return <FileListIcon />;
case 'symbol':
return <SymbolIcon />;
case 'selection':
return <SelectionIcon />;
default:
return <PlusSmallIcon />;
}
};
return (
<div className="context-pills-container">
{attachments.map((attachment) => (
<div key={attachment.id} className="context-pill">
<div className="context-pill-icon">{getIcon(attachment.type)}</div>
<div className="context-pill-label">{attachment.name}</div>
<button
className="context-pill-remove"
onClick={() => onRemove(attachment.id)}
aria-label="Remove attachment"
>
<CloseSmallIcon />
</button>
</div>
))}
</div>
);
};

View File

@@ -8,7 +8,7 @@
import React from 'react';
import type { ToolCallData } from './toolcalls/shared/types.js';
import { FileLink } from './shared/FileLink.js';
import { FileLink } from './ui/FileLink.js';
import { useVSCode } from '../hooks/useVSCode.js';
interface InProgressToolCallProps {

View File

@@ -15,8 +15,8 @@ import {
LinkIcon,
ArrowUpIcon,
} from './icons/index.js';
import { ClaudeCompletionMenu } from './ClaudeCompletionMenu.js';
import type { CompletionItem } from './CompletionMenu.js';
import { ClaudeCompletionMenu } from './ui/ClaudeCompletionMenu.js';
import type { CompletionItem } from './CompletionTypes.js';
type EditMode = 'ask' | 'auto' | 'plan';
@@ -143,7 +143,7 @@ export const InputForm: React.FC<InputFormProps> = ({
<div className="input-banner" />
{/* Input wrapper (Claude-style anchor container) */}
<div className="relative flex z-[1] Bo">
<div className="relative flex z-[1]">
{/* Claude-style anchored dropdown */}
{completionIsOpen &&
completionItems &&

View File

@@ -1,104 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { WarningTriangleIcon, CloseIcon } from './icons/index.js';
interface NotLoggedInMessageProps {
/**
* The message to display
*/
message: string;
/**
* Callback when the login button is clicked
*/
onLoginClick: () => void;
/**
* Callback when the message is dismissed (optional)
*/
onDismiss?: () => void;
}
export const NotLoggedInMessage: React.FC<NotLoggedInMessageProps> = ({
message,
onLoginClick,
onDismiss,
}) => (
<div
className="flex items-start gap-3 p-4 my-4 rounded-lg"
style={{
backgroundColor: 'var(--app-warning-background)',
borderLeft: '3px solid var(--app-warning-border)',
}}
>
{/* Warning Icon */}
<WarningTriangleIcon
className="flex-shrink-0 w-5 h-5 mt-0.5"
style={{ color: 'var(--app-warning-foreground)' }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className="m-0 mb-3 text-sm leading-relaxed"
style={{ color: 'var(--app-primary-foreground)' }}
>
{message}
</p>
{/* Login Button */}
<button
className="px-4 py-2 text-sm font-medium rounded transition-colors duration-200"
style={{
backgroundColor: 'var(--app-qwen-orange)',
color: 'white',
border: 'none',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1';
}}
onClick={() => {
if (onDismiss) {
onDismiss();
}
onLoginClick();
}}
>
Login Now
</button>
</div>
{/* Optional Close Button */}
{onDismiss && (
<button
className="flex-shrink-0 flex items-center justify-center cursor-pointer rounded"
style={{
background: 'none',
border: 'none',
padding: '6px',
color: 'var(--app-secondary-foreground)',
borderRadius: '4px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
'var(--app-ghost-button-hover-background)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Dismiss"
onClick={onDismiss}
>
<CloseIcon className="w-[10px] h-[10px]" />
</button>
)}
</div>
);

View File

@@ -76,7 +76,7 @@
}
.timeline-dot.green .dot-inner {
background-color: var(--app-qwen-green, #6BCF7F);
background-color: var(--app-qwen-green, #6bcf7f);
}
.timeline-dot.purple .dot-inner {
@@ -147,7 +147,12 @@
border: 1px solid var(--app-transparent-inner-border);
border-radius: 6px;
padding: 12px;
font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace);
font-family: var(
--vscode-editor-font-family,
'Monaco',
'Courier New',
monospace
);
font-size: 12px;
color: var(--app-secondary-foreground);
max-height: 300px;
@@ -170,11 +175,6 @@
padding: 10px 14px;
}
/* 助手消息样式 */
.timeline-item.assistant-message .timeline-body {
/* 保持默认样式,不加背景 */
}
/* 思考消息样式 */
.timeline-item.thinking .timeline-body {
color: var(--app-secondary-foreground);

View File

@@ -32,10 +32,6 @@ export const FileIcon: React.FC<IconProps> = ({
</svg>
);
/**
* File list icon (16x16)
* Used for file type indicator in context pills
*/
export const FileListIcon: React.FC<IconProps> = ({
size = 16,
className,

View File

@@ -137,10 +137,6 @@ export const CloseIcon: React.FC<IconProps> = ({
</svg>
);
/**
* Close X icon for context pills (16x16)
* Used to remove attachments
*/
export const CloseSmallIcon: React.FC<IconProps> = ({
size = 16,
className,

View File

@@ -149,10 +149,6 @@ export const UserIcon: React.FC<IconProps> = ({
</svg>
);
/**
* Symbol arrow icon (16x16)
* Used for symbol type in context pills
*/
export const SymbolIcon: React.FC<IconProps> = ({
size = 16,
className,
@@ -172,10 +168,6 @@ export const SymbolIcon: React.FC<IconProps> = ({
</svg>
);
/**
* Selection/text lines icon (16x16)
* Used for selection type in context pills
*/
export const SelectionIcon: React.FC<IconProps> = ({
size = 16,
className,

View File

@@ -1,120 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* FileLink 组件样式
*/
/**
* 文件链接基础样式
*/
.file-link {
/* 使用 VSCode 主题的链接颜色 */
color: var(--vscode-textLink-foreground);
cursor: pointer;
text-decoration: none;
/* 使用编辑器字体保持一致性 */
font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace);
font-size: inherit;
/* 行内显示 */
display: inline-flex;
align-items: baseline;
gap: 0;
/* 平滑过渡效果 */
transition: color 0.1s ease-in-out, text-decoration 0.1s ease-in-out;
}
/**
* 悬停状态
*/
.file-link:hover {
/* 悬停时显示下划线 */
text-decoration: underline;
/* 使用激活状态的链接颜色 */
color: var(--vscode-textLink-activeForeground);
}
/**
* 聚焦状态(键盘导航)
*/
.file-link:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
border-radius: 2px;
}
/**
* 激活状态(点击时)
*/
.file-link:active {
opacity: 0.8;
}
/**
* 文件路径部分
*/
.file-link-path {
font-weight: 500;
/* 继承父元素的颜色 */
color: inherit;
}
/**
* 位置信息部分(行号和列号)
*/
.file-link-location {
opacity: 0.7;
font-size: 0.9em;
/* 继承父元素的颜色 */
color: inherit;
font-weight: normal;
}
/**
* 在深色主题下增强可读性
*/
@media (prefers-color-scheme: dark) {
.file-link-location {
opacity: 0.6;
}
}
/**
* 高对比度模式支持
*/
@media (prefers-contrast: high) {
.file-link {
text-decoration: underline;
font-weight: 600;
}
.file-link-location {
opacity: 1;
}
}
/**
* 禁用点击时的样式(当父元素处理点击时)
*/
.file-link-disabled {
cursor: inherit;
pointer-events: none;
}
.file-link-disabled:hover {
text-decoration: none;
color: inherit;
}
/**
* 在代码块中的样式调整
*/
.code-block .file-link {
/* 在代码块中保持等宽字体 */
font-family: var(--vscode-editor-font-family, 'Menlo', 'Monaco', 'Courier New', monospace);
}

View File

@@ -12,7 +12,7 @@ import { ToolCallContainer } from './shared/LayoutComponents.js';
import { DiffDisplay } from './shared/DiffDisplay.js';
import { groupContent } from './shared/utils.js';
import { useVSCode } from '../../hooks/useVSCode.js';
import { FileLink } from '../shared/FileLink.js';
import { FileLink } from '../ui/FileLink.js';
/**
* Calculate diff summary (added/removed lines)

View File

@@ -10,7 +10,7 @@ import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js';
import { FileLink } from '../shared/FileLink.js';
import { FileLink } from '../ui/FileLink.js';
/**
* Specialized component for Read tool calls

View File

@@ -10,7 +10,7 @@ import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js';
import { FileLink } from '../shared/FileLink.js';
import { FileLink } from '../ui/FileLink.js';
/**
* Specialized component for Write tool calls

View File

@@ -8,7 +8,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { FileLink } from '../../shared/FileLink.js';
import { FileLink } from '../../ui/FileLink.js';
import {
calculateDiffStats,
formatDiffStatsDetailed,

View File

@@ -8,7 +8,7 @@
*/
import type React from 'react';
import { FileLink } from '../../shared/FileLink.js';
import { FileLink } from '../../ui/FileLink.js';
/**
* Props for ToolCallContainer - Claude Code style layout

View File

@@ -0,0 +1,176 @@
/**
* @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 '../CompletionTypes.js';
interface ClaudeCompletionMenuProps {
items: CompletionItem[];
onSelect: (item: CompletionItem) => void;
onClose: () => void;
title?: string;
selectedIndex?: number;
}
/**
* Claude Code-like anchored dropdown rendered above the input field.
* Keyboard: Up/Down to move, Enter to select, Esc to close.
*/
export const ClaudeCompletionMenu: React.FC<ClaudeCompletionMenuProps> = ({
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={[
// Semantic class name for readability (no CSS attached)
'completion-menu',
// Positioning and container styling (Tailwind)
'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

@@ -9,7 +9,7 @@
import type React from 'react';
import { useVSCode } from '../../hooks/useVSCode.js';
import './FileLink.css';
// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes
/**
* Props for FileLink
@@ -111,15 +111,34 @@ export const FileLink: React.FC<FileLinkProps> = ({
return (
<a
href="#"
className={`file-link ${disableClick ? 'file-link-disabled' : ''} ${className}`}
className={[
// Keep a semantic handle for scoped overrides (e.g. DiffDisplay.css)
'file-link',
// Layout + interaction
'inline-flex items-baseline',
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>
<span className="file-link-path font-medium">{displayPath}</span>
{line !== null && line !== undefined && (
<span className="file-link-location">
<span className="file-link-location opacity-70 text-[0.9em] font-normal dark:opacity-60">
:{line}
{column !== null && column !== undefined && <>:{column}</>}
</span>

View File

@@ -6,7 +6,7 @@
import type { RefObject } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import type { CompletionItem } from '../components/CompletionMenu.js';
import type { CompletionItem } from '../components/CompletionTypes.js';
interface CompletionTriggerState {
isOpen: boolean;

View File

@@ -9,11 +9,8 @@
/* Import component styles */
@import '../components/EmptyState.css';
@import '../components/CompletionMenu.css';
@import '../components/ContextPills.css';
@import '../components/PlanDisplay.css';
@import '../components/Timeline.css';
@import '../components/shared/FileLink.css';
@import '../components/toolcalls/shared/DiffDisplay.css';
@import '../components/messages/AssistantMessage.css';
@@ -63,4 +60,3 @@
--app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00);
--app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00);
}

View File

@@ -23,6 +23,16 @@ export default {
],
theme: {
extend: {
keyframes: {
// ClaudeCompletionMenu mount animation: fade in + slight upward slide
'completion-menu-enter': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'completion-menu-enter': 'completion-menu-enter 150ms ease-out both',
},
colors: {
qwen: {
orange: '#615fff',