feat(vscode-ide-companion): 增强工具调用与输入表单组件功能

- 新增 InProgressToolCall 组件用于展示进行中的工具调用状态
- 重构 InputForm 为独立组件,提升代码可维护性
- 改进 tool_call_update 处理逻辑,支持创建缺失的初始工具调用
- 添加思考块(thought chunk)日志以便调试 AI 思维过程
- 更新样式以支持新的进行中工具调用卡片显示
- 在权限请求时自动创建对应的工具调用记录
```
This commit is contained in:
yiliang114
2025-11-23 22:28:11 +08:00
parent 826516581b
commit 4dfbdcddca
10 changed files with 1045 additions and 1273 deletions

View File

@@ -62,11 +62,21 @@ export class QwenSessionUpdateHandler {
case 'agent_thought_chunk': case 'agent_thought_chunk':
// 处理思考块 - 使用特殊回调 // 处理思考块 - 使用特殊回调
console.log(
'[SessionUpdateHandler] 🧠 THOUGHT CHUNK:',
update.content?.text,
);
if (update.content?.text) { if (update.content?.text) {
if (this.callbacks.onThoughtChunk) { if (this.callbacks.onThoughtChunk) {
console.log(
'[SessionUpdateHandler] 🧠 Calling onThoughtChunk callback',
);
this.callbacks.onThoughtChunk(update.content.text); this.callbacks.onThoughtChunk(update.content.text);
} else if (this.callbacks.onStreamChunk) { } else if (this.callbacks.onStreamChunk) {
// 回退到常规流处理 // 回退到常规流处理
console.log(
'[SessionUpdateHandler] 🧠 Falling back to onStreamChunk',
);
this.callbacks.onStreamChunk(update.content.text); this.callbacks.onStreamChunk(update.content.text);
} }
} }

View File

@@ -233,241 +233,6 @@ button {
cursor: not-allowed; cursor: not-allowed;
} }
/* ===========================
Claude Code Style Input Form (.Me > .u)
=========================== */
/* Outer container (.Me) */
.input-form-container {
background-color: var(--app-primary-background);
padding: 4px 16px 16px;
}
/* Inner wrapper */
.input-form-wrapper {
display: block;
}
/* Input Form Container - matches Claude Code style */
.input-form {
background: var(--app-input-secondary-background, var(--app-input-background));
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-large);
color: var(--app-input-foreground);
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
/* Inner background layer - creates depth effect */
.input-form-background {
background: var(--app-input-background);
position: absolute;
border-radius: var(--corner-radius-large);
inset: 0;
z-index: 0;
}
.input-form:focus-within {
border-color: var(--app-qwen-orange);
box-shadow: 0 1px 2px color-mix(in srgb,var(--app-qwen-orange),transparent 80%);
}
/* Banner area - for warnings/messages */
.input-banner {
/* Empty for now, can be used for warnings/banners */
}
/* Input wrapper - contains the contenteditable field */
.input-wrapper {
position: relative;
display: flex;
z-index: 1;
}
/* Contenteditable input field - matches Claude Code */
.input-field-editable {
padding: 10px 14px;
outline: none;
font-family: inherit;
line-height: 1.5;
overflow-y: auto;
position: relative;
flex: 1;
align-self: stretch;
user-select: text;
min-height: 1.5em;
max-height: 200px;
background-color: transparent;
color: var(--app-input-foreground);
border: none;
border-radius: 0;
font-size: var(--vscode-chat-font-size, 13px);
overflow-x: hidden;
word-wrap: break-word;
white-space: pre-wrap;
}
.input-field-editable:focus {
outline: none;
}
.input-field-editable:empty:before {
content: attr(data-placeholder);
color: var(--app-input-placeholder-foreground);
pointer-events: none;
position: absolute;
}
.input-field-editable:disabled,
.input-field-editable[contenteditable='false'] {
color: #999;
cursor: not-allowed;
}
/* Actions row - matches Claude Code */
.input-actions {
display: flex;
align-items: center;
padding: 5px;
color: var(--app-secondary-foreground);
gap: 6px;
min-width: 0;
border-top: 0.5px solid var(--app-input-border);
z-index: 1;
}
/* Edit mode button (.l) */
.action-button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
height: 32px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--corner-radius-small);
color: var(--app-primary-foreground);
cursor: pointer;
font-size: 12px;
// font-weight: 500;
transition: background-color 0.15s;
white-space: nowrap;
}
.action-button:hover {
background-color: var(--app-ghost-button-hover-background);
}
.action-button svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Divider (.ii) */
.action-divider {
width: 1px;
height: 24px;
background-color: var(--app-transparent-inner-border);
margin: 0 2px;
flex-shrink: 0;
}
/* Icon buttons (.H) */
.action-icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: var(--corner-radius-small);
color: var(--app-secondary-foreground);
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.action-icon-button:hover {
background-color: var(--app-ghost-button-hover-background);
color: var(--app-primary-foreground);
}
.action-icon-button.active {
background-color: var(--app-qwen-clay-button-orange);
color: var(--app-qwen-ivory);
}
.action-icon-button.active svg {
stroke: var(--app-qwen-ivory);
fill: var(--app-qwen-ivory);
}
.action-icon-button svg {
width: 16px;
height: 16px;
}
/* Spacer to push file indicator to the right */
.input-actions-spacer {
flex: 1;
min-width: 0;
}
/* Active file indicator - shows current file selection */
.active-file-indicator {
// Inherits all styles from .action-button
// Only add specific overrides here if needed
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
/* Hide file indicator on very small screens */
@media screen and (max-width: 330px) {
.active-file-indicator {
display: none;
}
}
/* Send button (.r) */
.send-button-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: var(--app-qwen-clay-button-orange);
border: 1px solid transparent;
border-radius: var(--corner-radius-small);
color: var(--app-qwen-ivory);
cursor: pointer;
transition: background-color 0.15s, filter 0.15s;
margin-left: auto;
flex-shrink: 0;
}
.send-button-icon:hover:not(:disabled) {
filter: brightness(1.1);
}
.send-button-icon:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.send-button-icon svg {
width: 20px;
height: 20px;
}
/* =========================== /* ===========================
Tool Call Card Styles (Grid Layout) Tool Call Card Styles (Grid Layout)
=========================== */ =========================== */
@@ -549,6 +314,92 @@ button {
background: #f44336; background: #f44336;
} }
/* ===========================
In-Progress Tool Call Styles (Claude Code style)
=========================== */
.in-progress-tool-call {
display: flex;
flex-direction: column;
gap: var(--app-spacing-small);
padding: var(--app-spacing-medium);
margin: var(--app-spacing-small) 0;
background: var(--app-input-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-small);
animation: fadeIn 0.2s ease-in;
}
.in-progress-tool-call-header {
display: flex;
align-items: center;
gap: var(--app-spacing-medium);
}
.in-progress-tool-call-kind {
font-weight: 600;
font-size: 13px;
color: var(--app-primary-foreground);
}
.in-progress-tool-call-status {
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--app-secondary-foreground);
position: relative;
padding-left: 14px;
}
.in-progress-tool-call-status::before {
content: '';
position: absolute;
left: 0;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
}
.in-progress-tool-call-status.pending::before {
background: #ffc107;
}
.in-progress-tool-call-status.in_progress::before {
background: #2196f3;
animation: pulse 1.5s ease-in-out infinite;
}
.in-progress-tool-call-status.completed::before {
background: #4caf50;
}
.in-progress-tool-call-status.failed::before {
background: #f44336;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.in-progress-tool-call-title {
font-size: 12px;
color: var(--app-secondary-foreground);
font-family: var(--app-monospace-font-family);
padding-left: 2px;
}
.in-progress-tool-call-locations {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 2px;
}
.code-block { .code-block {
font-family: var(--app-monospace-font-family); font-family: var(--app-monospace-font-family);
font-size: var(--app-monospace-font-size); font-size: var(--app-monospace-font-size);

View File

@@ -14,6 +14,7 @@ import {
import { PermissionDrawer } from './components/PermissionDrawer.js'; import { PermissionDrawer } from './components/PermissionDrawer.js';
import { ToolCall, type ToolCallData } from './components/ToolCall.js'; import { ToolCall, type ToolCallData } from './components/ToolCall.js';
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js'; import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
import { InProgressToolCall } from './components/InProgressToolCall.js';
import { EmptyState } from './components/EmptyState.js'; import { EmptyState } from './components/EmptyState.js';
import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js'; import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js';
import { import {
@@ -31,6 +32,7 @@ import {
StreamingMessage, StreamingMessage,
WaitingMessage, WaitingMessage,
} from './components/messages/index.js'; } from './components/messages/index.js';
import { InputForm } from './components/InputForm.js';
interface ToolCallUpdate { interface ToolCallUpdate {
type: 'tool_call' | 'tool_call_update'; type: 'tool_call' | 'tool_call_update';
@@ -612,8 +614,8 @@ export const App: React.FC = () => {
content, content,
locations: update.locations, locations: update.locations,
}); });
} else if (update.type === 'tool_call_update' && existing) { } else if (update.type === 'tool_call_update') {
// Update existing tool call // Update existing tool call, or create if it doesn't exist
const updatedContent = update.content const updatedContent = update.content
? update.content.map((item) => ({ ? update.content.map((item) => ({
type: item.type as 'content' | 'diff', type: item.type as 'content' | 'diff',
@@ -624,14 +626,28 @@ export const App: React.FC = () => {
})) }))
: undefined; : undefined;
newMap.set(update.toolCallId, { if (existing) {
...existing, // Update existing tool call
...(update.kind && { kind: update.kind }), newMap.set(update.toolCallId, {
...(update.title && { title: safeTitle(update.title) }), ...existing,
...(update.status && { status: update.status }), ...(update.kind && { kind: update.kind }),
...(updatedContent && { content: updatedContent }), ...(update.title && { title: safeTitle(update.title) }),
...(update.locations && { locations: update.locations }), ...(update.status && { status: update.status }),
}); ...(updatedContent && { content: updatedContent }),
...(update.locations && { locations: update.locations }),
});
} else {
// Create new tool call if it doesn't exist (missed the initial tool_call message)
newMap.set(update.toolCallId, {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title: safeTitle(update.title),
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
content: updatedContent,
locations: update.locations,
});
}
} }
return newMap; return newMap;
@@ -717,12 +733,14 @@ export const App: React.FC = () => {
case 'thoughtChunk': { case 'thoughtChunk': {
const chunkData = message.data; const chunkData = message.data;
console.log('[App] 🧠 THOUGHT CHUNK RECEIVED:', chunkData);
// Handle thought chunks for AI thinking display // Handle thought chunks for AI thinking display
const thinkingMessage: TextMessage = { const thinkingMessage: TextMessage = {
role: 'thinking', role: 'thinking',
content: chunkData.content || chunkData.chunk || '', content: chunkData.content || chunkData.chunk || '',
timestamp: Date.now(), timestamp: Date.now(),
}; };
console.log('[App] 🧠 Adding thinking message:', thinkingMessage);
setMessages((prev) => [...prev, thinkingMessage]); setMessages((prev) => [...prev, thinkingMessage]);
break; break;
} }
@@ -760,10 +778,58 @@ export const App: React.FC = () => {
// console.log('[App] Set notLoggedInMessage to:', (message.data as { message: string })?.message); // console.log('[App] Set notLoggedInMessage to:', (message.data as { message: string })?.message);
// break; // break;
case 'permissionRequest': case 'permissionRequest': {
// Show permission dialog // Show permission dialog
handlePermissionRequest(message.data); handlePermissionRequest(message.data);
// Also create a tool call entry for the permission request
// This ensures that if it's rejected, we can show it properly
const permToolCall = message.data?.toolCall as {
toolCallId?: string;
kind?: string;
title?: string;
status?: string;
content?: unknown[];
locations?: Array<{ path: string; line?: number | null }>;
};
if (permToolCall?.toolCallId) {
// Infer kind from title if not provided
let kind = permToolCall.kind || 'execute';
if (permToolCall.title) {
const title = permToolCall.title.toLowerCase();
if (title.includes('touch') || title.includes('echo')) {
kind = 'execute';
} else if (title.includes('read') || title.includes('cat')) {
kind = 'read';
} else if (title.includes('write') || title.includes('edit')) {
kind = 'edit';
}
}
handleToolCallUpdate({
type: 'tool_call',
toolCallId: permToolCall.toolCallId,
kind,
title: permToolCall.title,
status: permToolCall.status || 'pending',
content: permToolCall.content as Array<{
type: 'content' | 'diff';
content?: {
type: string;
text?: string;
[key: string]: unknown;
};
path?: string;
oldText?: string | null;
newText?: string;
[key: string]: unknown;
}>,
locations: permToolCall.locations,
});
}
break; break;
}
case 'plan': case 'plan':
// Update plan entries // Update plan entries
@@ -972,67 +1038,6 @@ export const App: React.FC = () => {
setThinkingEnabled((prev) => !prev); setThinkingEnabled((prev) => !prev);
}; };
// Get edit mode display info
const getEditModeInfo = () => {
switch (editMode) {
case 'ask':
return {
text: 'Ask before edits',
title: 'Qwen will ask before each edit. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
),
};
case 'auto':
return {
text: 'Edit automatically',
title: 'Qwen will edit files automatically. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<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"></path>
</svg>
),
};
case 'plan':
return {
text: 'Plan mode',
title: 'Qwen will plan before executing. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<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"></path>
</svg>
),
};
default:
return {
text: 'Unknown mode',
title: 'Unknown edit mode',
icon: null,
};
}
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1417,9 +1422,28 @@ export const App: React.FC = () => {
); );
})} })}
{/* Tool Calls - only show those with actual output */} {/* In-Progress Tool Calls - show only pending/in_progress */}
{Array.from(toolCalls.values()) {Array.from(toolCalls.values())
.filter((toolCall) => hasToolCallOutput(toolCall)) .filter(
(toolCall) =>
toolCall.status === 'pending' ||
toolCall.status === 'in_progress',
)
.map((toolCall) => (
<InProgressToolCall
key={toolCall.toolCallId}
toolCall={toolCall}
/>
))}
{/* Completed Tool Calls - only show those with actual output */}
{Array.from(toolCalls.values())
.filter(
(toolCall) =>
(toolCall.status === 'completed' ||
toolCall.status === 'failed') &&
hasToolCallOutput(toolCall),
)
.map((toolCall) => ( .map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} /> <ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))} ))}
@@ -1477,223 +1501,65 @@ export const App: React.FC = () => {
}} }}
/> />
<div className="input-form-container"> <InputForm
<div className="input-form-wrapper"> inputText={inputText}
{/* Context Pills - Removed: now using inline @mentions in input */} inputFieldRef={inputFieldRef}
isStreaming={isStreaming}
isComposing={isComposing}
editMode={editMode}
thinkingEnabled={thinkingEnabled}
activeFileName={activeFileName}
activeSelection={activeSelection}
onInputChange={setInputText}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmit}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={() => {
vscode.postMessage({
type: 'focusActiveEditor',
data: {},
});
}}
onShowCommandMenu={async () => {
if (inputFieldRef.current) {
inputFieldRef.current.focus();
<form className="input-form" onSubmit={handleSubmit}> const selection = window.getSelection();
<div className="input-form-background"></div> let position = { top: 0, left: 0 };
<div className="input-banner"></div>
<div className="input-wrapper">
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="input-field-editable"
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask Qwen Code …"
onInput={(e) => {
const target = e.target as HTMLDivElement;
setInputText(target.textContent || '');
}}
onCompositionStart={() => {
setIsComposing(true);
}}
onCompositionEnd={() => {
setIsComposing(false);
}}
onKeyDown={(e) => {
// 如果正在进行中文输入法输入(拼音输入),不处理回车键
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
// 如果 CompletionMenu 打开,让它处理 Enter 键(选中文件)
if (completion.isOpen) {
return;
}
e.preventDefault();
handleSubmit(e);
}
}}
suppressContentEditableWarning
/>
</div>
<div className="input-actions">
<button
type="button"
className="action-button edit-mode-button"
title={getEditModeInfo().title}
onClick={handleToggleEditMode}
>
{getEditModeInfo().icon}
<span>{getEditModeInfo().text}</span>
</button>
{activeFileName && (
<button
type="button"
className="action-button active-file-indicator"
title={`Showing Qwen Code your current file selection: ${activeFileName}${activeSelection ? `#${activeSelection.startLine}-${activeSelection.endLine}` : ''}`}
onClick={() => {
// Request to focus/reveal the active file
vscode.postMessage({
type: 'focusActiveEditor',
data: {},
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
>
<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"
></path>
</svg>
<span>
{activeFileName}
{activeSelection &&
` #${activeSelection.startLine}${activeSelection.startLine !== activeSelection.endLine ? `-${activeSelection.endLine}` : ''}`}
</span>
</button>
)}
<div className="action-divider"></div>
{/* Spacer 将右侧按钮推到右边 */}
<div className="input-actions-spacer"></div>
<button
type="button"
className={`action-icon-button thinking-button ${thinkingEnabled ? 'active' : ''}`}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={handleToggleThinking}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<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: 'var(--app-secondary-foreground)',
fill: 'var(--app-secondary-foreground)',
}}
></path>
</svg>
</button>
<button
type="button"
className="action-icon-button command-button"
title="Show command menu (/)"
onClick={async () => {
if (inputFieldRef.current) {
// Focus the input first to ensure cursor is in the right place
inputFieldRef.current.focus();
// Get cursor position for menu placement if (selection && selection.rangeCount > 0) {
const selection = window.getSelection(); try {
let position = { top: 0, left: 0 }; const range = selection.getRangeAt(0);
const rangeRect = range.getBoundingClientRect();
if (rangeRect.top > 0 && rangeRect.left > 0) {
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} catch (error) {
console.error('[App] Error getting cursor position:', error);
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} else {
const inputRect = inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
// Try to get precise cursor position await completion.openCompletion('/', '', position);
if (selection && selection.rangeCount > 0) { }
try { }}
const range = selection.getRangeAt(0); onAttachContext={handleAttachContextClick}
const rangeRect = range.getBoundingClientRect(); completionIsOpen={completion.isOpen}
if (rangeRect.top > 0 && rangeRect.left > 0) { />
position = {
top: rangeRect.top,
left: rangeRect.left,
};
} else {
// Fallback to input element position
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = {
top: inputRect.top,
left: inputRect.left,
};
}
} catch (error) {
console.error(
'[App] Error getting cursor position:',
error,
);
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
} else {
// No selection, use input element position
const inputRect =
inputFieldRef.current.getBoundingClientRect();
position = { top: inputRect.top, left: inputRect.left };
}
// Open completion menu with / commands
await completion.openCompletion('/', '', position);
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
</button>
<button
type="button"
className="action-icon-button attach-button"
title="Attach context (Cmd/Ctrl + /)"
onClick={handleAttachContextClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
</button>
<button
type="submit"
className="send-button-icon"
disabled={isStreaming || !inputText.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
</button>
</div>
</form>
</div>
</div>
{/* Save Session Dialog */} {/* Save Session Dialog */}
<SaveSessionDialog <SaveSessionDialog

View File

@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* In-progress tool call component - displays active tool calls with Claude Code style
*/
import type React from 'react';
import type { ToolCallData } from './toolcalls/shared/types.js';
import { FileLink } from './shared/FileLink.js';
interface InProgressToolCallProps {
toolCall: ToolCallData;
}
/**
* Format the kind name to a readable label
*/
const formatKind = (kind: string): string => {
const kindMap: Record<string, string> = {
read: 'Read',
write: 'Write',
edit: 'Edit',
execute: 'Execute',
bash: 'Execute',
command: 'Execute',
search: 'Search',
grep: 'Search',
glob: 'Search',
find: 'Search',
think: 'Think',
thinking: 'Think',
fetch: 'Fetch',
delete: 'Delete',
move: 'Move',
};
return kindMap[kind.toLowerCase()] || 'Tool Call';
};
/**
* Get status display text
*/
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
pending: 'Pending',
in_progress: 'In Progress',
completed: 'Completed',
failed: 'Failed',
};
return statusMap[status] || status;
};
/**
* Component to display in-progress tool calls with Claude Code styling
* Shows kind, status, and file locations
*/
export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
toolCall,
}) => {
const { kind, status, title, locations } = toolCall;
// Format the kind label
const kindLabel = formatKind(kind);
// Get status text
const statusText = getStatusText(status || 'in_progress');
return (
<div className="in-progress-tool-call">
<div className="in-progress-tool-call-header">
<span className="in-progress-tool-call-kind">{kindLabel}</span>
<span
className={`in-progress-tool-call-status ${status || 'in_progress'}`}
>
{statusText}
</span>
</div>
{title && title !== kindLabel && (
<div className="in-progress-tool-call-title">{title}</div>
)}
{locations && locations.length > 0 && (
<div className="in-progress-tool-call-locations">
{locations.map((loc, idx) => (
<FileLink
key={idx}
path={loc.path}
line={loc.line}
showFullPath={false}
/>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,354 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
type EditMode = 'ask' | 'auto' | 'plan';
interface InputFormProps {
inputText: string;
inputFieldRef: React.RefObject<HTMLDivElement | null>;
isStreaming: boolean;
isComposing: boolean;
editMode: EditMode;
thinkingEnabled: boolean;
activeFileName: string | null;
activeSelection: { startLine: number; endLine: number } | null;
onInputChange: (text: string) => void;
onCompositionStart: () => void;
onCompositionEnd: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onSubmit: (e: React.FormEvent) => void;
onToggleEditMode: () => void;
onToggleThinking: () => void;
onFocusActiveEditor: () => void;
onShowCommandMenu: () => void;
onAttachContext: () => void;
completionIsOpen: boolean;
}
// Get edit mode display info
const getEditModeInfo = (editMode: EditMode) => {
switch (editMode) {
case 'ask':
return {
text: 'Ask before edits',
title: 'Qwen will ask before each edit. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
),
};
case 'auto':
return {
text: 'Edit automatically',
title: 'Qwen will edit files automatically. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<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"></path>
</svg>
),
};
case 'plan':
return {
text: 'Plan mode',
title: 'Qwen will plan before executing. Click to switch modes.',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<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"></path>
</svg>
),
};
default:
return {
text: 'Unknown mode',
title: 'Unknown edit mode',
icon: null,
};
}
};
export const InputForm: React.FC<InputFormProps> = ({
inputText,
inputFieldRef,
isStreaming,
isComposing,
editMode,
thinkingEnabled,
activeFileName,
activeSelection,
onInputChange,
onCompositionStart,
onCompositionEnd,
onKeyDown,
onSubmit,
onToggleEditMode,
onToggleThinking,
onFocusActiveEditor,
onShowCommandMenu,
onAttachContext,
completionIsOpen,
}) => {
const editModeInfo = getEditModeInfo(editMode);
const handleKeyDown = (e: React.KeyboardEvent) => {
// 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);
};
return (
<div
className="p-1 px-4 pb-4"
style={{ backgroundColor: 'var(--app-primary-background)' }}
>
<div className="block">
<form
className="relative flex flex-col rounded-large border shadow-sm transition-all duration-200 focus-within:shadow-md"
style={{
backgroundColor:
'var(--app-input-secondary-background, var(--app-input-background))',
borderColor: 'var(--app-input-border)',
color: 'var(--app-input-foreground)',
}}
onSubmit={onSubmit}
>
{/* Inner background layer */}
<div
className="absolute inset-0 rounded-large z-0"
style={{ backgroundColor: 'var(--app-input-background)' }}
/>
{/* Banner area */}
<div className="input-banner" />
{/* Input wrapper */}
<div className="relative flex z-[1]">
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="flex-1 self-stretch p-2.5 px-3.5 outline-none font-inherit leading-relaxed overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-none rounded-none overflow-x-hidden break-words whitespace-pre-wrap empty:before:content-[attr(data-placeholder)] empty:before:absolute empty:before:pointer-events-none disabled:text-gray-400 disabled:cursor-not-allowed"
style={{
color: 'var(--app-input-foreground)',
fontSize: 'var(--vscode-chat-font-size, 13px)',
}}
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask Qwen Code …"
onInput={(e) => {
const target = e.target as HTMLDivElement;
onInputChange(target.textContent || '');
}}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
onKeyDown={handleKeyDown}
suppressContentEditableWarning
/>
</div>
{/* Actions row */}
<div
className="flex items-center p-1.5 gap-1.5 min-w-0 z-[1]"
style={{
color: 'var(--app-secondary-foreground)',
borderTop: '0.5px solid var(--app-input-border)',
}}
>
{/* Edit mode button */}
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs whitespace-nowrap transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0"
style={{ color: 'var(--app-primary-foreground)' }}
title={editModeInfo.title}
onClick={onToggleEditMode}
>
{editModeInfo.icon}
<span>{editModeInfo.text}</span>
</button>
{/* Active file indicator */}
{activeFileName && (
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 h-8 bg-transparent border border-transparent rounded-small cursor-pointer text-xs whitespace-nowrap transition-colors duration-150 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 [&>svg]:flex-shrink-0 max-w-[200px] overflow-hidden text-ellipsis flex-shrink min-w-0"
style={{ color: 'var(--app-primary-foreground)' }}
title={`Showing Qwen Code your current file selection: ${activeFileName}${activeSelection ? `#${activeSelection.startLine}-${activeSelection.endLine}` : ''}`}
onClick={onFocusActiveEditor}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
>
<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"
></path>
</svg>
<span>
{activeFileName}
{activeSelection &&
` #${activeSelection.startLine}${activeSelection.startLine !== activeSelection.endLine ? `-${activeSelection.endLine}` : ''}`}
</span>
</button>
)}
{/* Divider */}
<div
className="w-px h-6 mx-0.5 flex-shrink-0"
style={{
backgroundColor: 'var(--app-transparent-inner-border)',
}}
/>
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* Thinking button */}
<button
type="button"
className={`flex items-center justify-center w-8 h-8 p-0 bg-transparent border border-transparent rounded-small cursor-pointer transition-all duration-150 flex-shrink-0 hover:bg-[var(--app-ghost-button-hover-background)] [&>svg]:w-4 [&>svg]:h-4 ${
thinkingEnabled
? 'bg-qwen-clay-orange text-qwen-ivory [&>svg]:stroke-qwen-ivory [&>svg]:fill-qwen-ivory'
: ''
}`}
style={{
color: thinkingEnabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
backgroundColor: thinkingEnabled
? 'var(--app-qwen-clay-button-orange)'
: undefined,
}}
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
onClick={onToggleThinking}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<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: thinkingEnabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
fill: thinkingEnabled
? 'var(--app-qwen-ivory)'
: 'var(--app-secondary-foreground)',
}}
></path>
</svg>
</button>
{/* Command button */}
<button
type="button"
className="flex items-center justify-center w-8 h-8 p-0 bg-transparent border border-transparent rounded-small cursor-pointer transition-all duration-150 flex-shrink-0 hover:bg-[var(--app-ghost-button-hover-background)] hover:text-[var(--app-primary-foreground)] [&>svg]:w-4 [&>svg]:h-4"
style={{ color: 'var(--app-secondary-foreground)' }}
title="Show command menu (/)"
onClick={onShowCommandMenu}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
</button>
{/* Attach button */}
<button
type="button"
className="flex items-center justify-center w-8 h-8 p-0 bg-transparent border border-transparent rounded-small cursor-pointer transition-all duration-150 flex-shrink-0 hover:bg-[var(--app-ghost-button-hover-background)] hover:text-[var(--app-primary-foreground)] [&>svg]:w-4 [&>svg]:h-4"
style={{ color: 'var(--app-secondary-foreground)' }}
title="Attach context (Cmd/Ctrl + /)"
onClick={onAttachContext}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
</button>
{/* Send button */}
<button
type="submit"
className="flex items-center justify-center w-8 h-8 p-0 border border-transparent rounded-small cursor-pointer transition-all duration-150 ml-auto flex-shrink-0 hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed [&>svg]:w-5 [&>svg]:h-5"
style={{
backgroundColor: 'var(--app-qwen-clay-button-orange)',
color: 'var(--app-qwen-ivory)',
}}
disabled={isStreaming || !inputText.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<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"
></path>
</svg>
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -1,560 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Permission Drawer - Bottom sheet style for permission requests
*/
/* Backdrop overlay */
.permission-drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--app-modal-background);
z-index: 998;
animation: fadeIn 0.2s ease-in-out;
}
/* Drawer container - bottom sheet style */
.permission-drawer {
display: flex;
flex-direction: column;
padding: 8px;
background-color: var(--app-input-secondary-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-large);
max-height: 70vh;
outline: 0;
position: relative;
margin-bottom: 6px;
z-index: 999;
animation: slideUpFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Background layer */
.permission-drawer-background {
background-color: var(--app-input-background);
border-radius: var(--corner-radius-large);
position: absolute;
inset: 0;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUpFromBottom {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* Drawer header */
.permission-drawer-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 28px 28px 24px;
border-bottom: 1px solid var(--app-primary-border-color);
background-color: var(--app-header-background);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
flex-shrink: 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}
.permission-drawer-title {
font-weight: 700;
color: var(--app-primary-foreground);
margin-bottom: 4px;
}
.permission-drawer-close {
width: 36px;
height: 36px;
padding: 0;
background: var(--app-secondary-background);
border: 1px solid var(--app-transparent-inner-border);
border-radius: 8px;
color: var(--app-secondary-foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.permission-drawer-close:hover {
background-color: var(--app-ghost-button-hover-background);
color: var(--app-primary-foreground);
transform: scale(1.05);
border-color: var(--app-primary-border-color);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.permission-drawer-close:active {
transform: scale(0.98);
}
/* Drawer content */
.permission-drawer-content {
font-size: 1.1em;
color: var(--app-primary-foreground);
display: flex;
flex-direction: column;
min-height: 0;
z-index: 1;
flex: 1;
overflow-y: auto;
padding: 0;
min-height: 0;
}
/* Override permission card styles when in drawer */
.permission-drawer-content .permission-request-card {
border: none;
margin: 0;
background: transparent;
box-shadow: none;
}
.permission-drawer-content .permission-card-body {
padding: 0;
}
/* Add a subtle border at the top of the card when in drawer */
.permission-drawer-content .permission-request-card::before {
content: '';
display: block;
height: 1px;
background: var(--app-primary-border-color);
margin-bottom: 24px;
opacity: 0.5;
}
/* Scrollbar for drawer content */
.permission-drawer-content::-webkit-scrollbar {
width: 8px;
}
.permission-drawer-content::-webkit-scrollbar-track {
background: transparent;
}
.permission-drawer-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.permission-drawer-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Add a drag handle indicator at the top */
.permission-drawer-header::before {
content: '';
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 5px;
background-color: var(--app-secondary-foreground);
opacity: 0.2;
border-radius: 3px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.permission-drawer {
max-height: 90vh;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.permission-drawer-header {
padding: 24px 24px 20px;
border-top-left-radius: 16px;
border-top-right-radius: 16px;
}
.permission-drawer-header::before {
top: 10px;
width: 40px;
height: 4px;
}
.permission-drawer-content {
padding: 24px;
}
.permission-drawer-content::after {
height: 24px;
}
}
/* ===========================================
Permission Request Card Styles
=========================================== */
.permission-request-card {
background: var(--app-primary-background);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--app-primary-border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.permission-card-body {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Permission Header */
.permission-header {
display: flex;
align-items: flex-start;
gap: 16px;
padding-bottom: 16px;
}
.permission-icon-wrapper {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--app-secondary-background);
border-radius: 8px;
border: 1px solid var(--app-transparent-inner-border);
}
.permission-icon {
font-size: 24px;
line-height: 1;
}
.permission-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.permission-title {
font-size: 16px;
font-weight: 600;
color: var(--app-primary-foreground);
line-height: 1.4;
word-break: break-word;
}
.permission-subtitle {
font-size: 13px;
color: var(--app-secondary-foreground);
opacity: 0.8;
}
/* Command Section - Bash style */
.permission-command-section {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--app-secondary-background);
border: 1px solid var(--app-transparent-inner-border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}
.permission-command-header {
display: flex;
align-items: center;
padding: 12px 16px;
background: var(--app-secondary-background);
border-bottom: 1px solid var(--app-transparent-inner-border);
}
.permission-command-status {
display: flex;
align-items: center;
gap: 8px;
}
.permission-command-dot {
color: var(--app-qwen-orange, #ff8c00);
font-size: 10px;
line-height: 1;
}
.permission-command-label {
font-size: 12px;
font-weight: 600;
color: var(--app-secondary-foreground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.permission-command-content {
display: flex;
flex-direction: column;
gap: 0;
}
.permission-command-input-section {
display: grid;
grid-template-columns: 40px 1fr;
align-items: flex-start;
padding: 12px 0;
background: var(--app-primary-background);
}
.permission-command-io-label {
font-size: 12px;
font-weight: 600;
color: var(--app-secondary-foreground);
opacity: 0.7;
text-align: right;
padding-right: 16px;
padding-top: 4px;
}
.permission-command-code {
font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace);
font-size: 13px;
line-height: 1.6;
color: var(--app-primary-foreground);
background: transparent;
border: none;
padding: 0 16px 0 0;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
}
.permission-command-description {
font-size: 13px;
color: var(--app-secondary-foreground);
opacity: 0.8;
padding: 12px 16px;
border-top: 1px solid var(--app-transparent-inner-border);
background: var(--app-secondary-background);
}
/* Locations Section */
.permission-locations-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.permission-locations-label {
font-size: 13px;
font-weight: 500;
color: var(--app-secondary-foreground);
opacity: 0.9;
}
.permission-location-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--app-secondary-background);
border-radius: 6px;
font-size: 13px;
border: 1px solid var(--app-transparent-inner-border);
transition: all 0.15s ease;
}
.permission-location-item:hover {
background: var(--app-ghost-button-hover-background);
border-color: var(--app-primary-border-color);
}
.permission-location-icon {
font-size: 16px;
flex-shrink: 0;
color: var(--app-qwen-orange, #ff8c00);
}
.permission-location-path {
flex: 1;
color: var(--app-primary-foreground);
font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.permission-location-line {
color: var(--app-secondary-foreground);
opacity: 0.7;
font-family: var(--vscode-editor-font-family, 'Monaco', 'Courier New', monospace);
font-weight: 500;
}
/* Options Section */
.permission-options-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.permission-options-label {
font-size: 14px;
font-weight: 500;
color: var(--app-secondary-foreground);
opacity: 0.9;
}
.permission-options-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.permission-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--app-secondary-background);
border: 1px solid var(--app-transparent-inner-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.permission-option:hover {
background: var(--app-ghost-button-hover-background);
border-color: var(--app-primary-border-color);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.permission-option.selected {
background: var(--app-qwen-clay-button-orange);
border-color: var(--app-qwen-orange, #ff8c00);
box-shadow: 0 2px 6px rgba(255, 140, 0, 0.2);
}
.permission-radio {
width: 18px;
height: 18px;
margin: 0;
cursor: pointer;
accent-color: var(--app-qwen-orange, #ff8c00);
}
.permission-option-content {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--app-primary-foreground);
font-weight: 500;
}
.permission-option-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--app-qwen-orange, #ff8c00);
color: var(--app-qwen-ivory, #fff);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.permission-always-badge {
font-size: 16px;
flex-shrink: 0;
color: #ffd700;
}
.permission-no-options {
padding: 16px;
text-align: center;
color: var(--app-secondary-foreground);
opacity: 0.6;
font-size: 13px;
}
/* Actions */
.permission-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 16px;
}
.permission-confirm-button {
padding: 10px 20px;
background: var(--app-qwen-orange, #ff8c00);
color: var(--app-qwen-ivory, #fff);
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(255, 140, 0, 0.3);
}
.permission-confirm-button:hover:not(:disabled) {
background: #e67e00;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(255, 140, 0, 0.4);
}
.permission-confirm-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Success Message */
.permission-success {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--app-qwen-green, #6BCF7F);
color: var(--app-qwen-ivory, #fff);
border-radius: 8px;
font-size: 14px;
box-shadow: 0 2px 8px rgba(107, 207, 127, 0.3);
animation: fadeIn 0.3s ease-in-out;
}
.permission-success-icon {
font-size: 20px;
flex-shrink: 0;
}
.permission-success-text {
flex: 1;
font-weight: 500;
}

View File

@@ -1,121 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* PermissionDrawer component using Tailwind CSS
*/
import type React from 'react';
import { useEffect } from 'react';
import {
PermissionRequest,
type PermissionOption,
type ToolCall,
} from './PermissionRequest.js';
import { buttonClasses, commonClasses } from '../../lib/tailwindUtils.js';
interface PermissionDrawerProps {
isOpen: boolean;
options: PermissionOption[];
toolCall: ToolCall;
onResponse: (optionId: string) => void;
onClose?: () => void;
}
/**
* Permission drawer component - displays permission requests in a bottom sheet
* Uses Tailwind CSS for styling
* @param isOpen - Whether the drawer is open
* @param options - Permission options to display
* @param toolCall - Tool call information
* @param onResponse - Callback when user responds
* @param onClose - Optional callback when drawer closes
*/
export const PermissionDrawerTailwind: React.FC<PermissionDrawerProps> = ({
isOpen,
options,
toolCall,
onResponse,
onClose,
}) => {
// Close drawer on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) {
return;
}
// Close on Escape
if (e.key === 'Escape' && onClose) {
onClose();
return;
}
// Quick select with number keys (1-9)
const numMatch = e.key.match(/^[1-9]$/);
if (numMatch) {
const index = parseInt(e.key, 10) - 1;
if (index < options.length) {
e.preventDefault();
onResponse(options[index].optionId);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, options, onResponse]);
if (!isOpen) {
return null;
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-75 z-[998] animate-fadeIn"
onClick={onClose}
/>
{/* Drawer */}
<div className="flex flex-col p-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-large max-h-[70vh] outline-0 relative mb-1.5 z-[999] animate-slideUpFromBottom">
<div className="bg-white dark:bg-gray-900 rounded-large absolute inset-0"></div>
<div className="relative flex items-center justify-between p-7 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 rounded-t-2xl shadow-sm flex-shrink-0">
<h3 className="font-bold text-gray-900 dark:text-white mb-1">Permission Required</h3>
{onClose && (
<button
className={buttonClasses('icon')}
onClick={onClose}
aria-label="Close drawer"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 2L14 14M2 14L14 2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div>
<div className="text-lg text-gray-900 dark:text-white flex flex-col min-h-0 z-10 flex-1 overflow-y-auto p-0 min-h-0">
<PermissionRequest
options={options}
toolCall={toolCall}
onResponse={onResponse}
/>
</div>
</div>
</>
);
};

View File

@@ -5,13 +5,8 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useEffect } from 'react'; import { useEffect, useState, useRef } from 'react';
import { import type { PermissionOption, ToolCall } from './PermissionRequest.js';
PermissionRequest,
type PermissionOption,
type ToolCall,
} from './PermissionRequest.js';
import './PermissionDrawer.css';
interface PermissionDrawerProps { interface PermissionDrawerProps {
isOpen: boolean; isOpen: boolean;
@@ -22,12 +17,7 @@ interface PermissionDrawerProps {
} }
/** /**
* Permission drawer component - displays permission requests in a bottom sheet * Permission drawer component - Claude Code style bottom sheet
* @param isOpen - Whether the drawer is open
* @param options - Permission options to display
* @param toolCall - Tool call information
* @param onResponse - Callback when user responds
* @param onClose - Optional callback when drawer closes
*/ */
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
isOpen, isOpen,
@@ -36,80 +26,229 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
onResponse, onResponse,
onClose, onClose,
}) => { }) => {
// Close drawer on Escape key const [focusedIndex, setFocusedIndex] = useState(0);
const [customMessage, setCustomMessage] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const customInputRef = useRef<HTMLDivElement>(null);
// Get the title for the permission request
const getTitle = () => {
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
const fileName =
toolCall.locations?.[0]?.path?.split('/').pop() || 'file';
return (
<>
Allow write to{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
?
</>
);
}
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
return 'Allow command execution?';
}
if (toolCall.kind === 'read') {
const fileName =
toolCall.locations?.[0]?.path?.split('/').pop() || 'file';
return (
<>
Allow read from{' '}
<span className="font-mono text-[var(--app-primary-foreground)]">
{fileName}
</span>
?
</>
);
}
return toolCall.title || 'Permission Required';
};
// Handle keyboard navigation
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) { if (!isOpen) return;
return;
}
// Close on Escape // Number keys 1-9 for quick select
if (e.key === 'Escape' && onClose) {
onClose();
return;
}
// Quick select with number keys (1-9)
const numMatch = e.key.match(/^[1-9]$/); const numMatch = e.key.match(/^[1-9]$/);
if (numMatch) { if (
numMatch &&
!customInputRef.current?.contains(document.activeElement)
) {
const index = parseInt(e.key, 10) - 1; const index = parseInt(e.key, 10) - 1;
if (index < options.length) { if (index < options.length) {
e.preventDefault(); e.preventDefault();
onResponse(options[index].optionId); 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 close
if (e.key === 'Escape' && onClose) {
onClose();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, options, onResponse]); }, [isOpen, options, onResponse, onClose, focusedIndex]);
if (!isOpen) { // Focus container when opened
return null; useEffect(() => {
} if (isOpen && containerRef.current) {
containerRef.current.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return ( return (
<> <div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
{/* Backdrop */} {/* Main container */}
<div className="permission-drawer-backdrop" onClick={onClose} /> <div
ref={containerRef}
className="relative flex flex-col rounded-large border p-2 outline-none animate-[slideUp_0.2s_ease-out]"
style={{
backgroundColor: 'var(--app-input-secondary-background)',
borderColor: 'var(--app-input-border)',
}}
tabIndex={0}
data-focused-index={focusedIndex}
>
{/* Background layer */}
<div
className="absolute inset-0 rounded-large"
style={{ backgroundColor: 'var(--app-input-background)' }}
/>
{/* Drawer */} {/* Title */}
<div className="permission-drawer"> <div className="relative z-[1] px-3 py-3">
<div className="permission-drawer-background"></div> <div
<div className="permission-drawer-header"> className="text-sm font-medium"
<h3 className="permission-drawer-title">Permission Required</h3> style={{ color: 'var(--app-secondary-foreground)' }}
{onClose && ( >
<button {getTitle()}
className="permission-drawer-close" </div>
onClick={onClose}
aria-label="Close drawer"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 2L14 14M2 14L14 2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
)}
</div> </div>
<div className="permission-drawer-content"> {/* Options */}
<PermissionRequest <div className="relative z-[1] flex flex-col gap-1 px-1 pb-1">
options={options} {options.map((option, index) => {
toolCall={toolCall} const isAlways = option.kind.includes('always');
onResponse={onResponse} const isFocused = focusedIndex === index;
/>
return (
<button
key={option.optionId}
className={`flex items-center gap-2 px-3 py-2 text-left rounded-small border transition-colors duration-150 ${
isFocused
? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] border-transparent'
: 'hover:bg-[var(--app-list-hover-background)] border-transparent'
}`}
style={{
color: isFocused
? 'var(--app-list-active-foreground)'
: 'var(--app-primary-foreground)',
}}
onClick={() => onResponse(option.optionId)}
onMouseEnter={() => setFocusedIndex(index)}
>
{/* Number badge */}
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${
isFocused
? 'bg-white/20 text-inherit'
: 'bg-[var(--app-list-hover-background)] text-[var(--app-secondary-foreground)]'
}`}
>
{index + 1}
</span>
{/* Option text */}
<span className="text-sm">{option.name}</span>
{/* Always badge */}
{isAlways && <span className="text-sm"></span>}
</button>
);
})}
{/* Custom message input */}
<div
className={`rounded-small border transition-colors duration-150 ${
focusedIndex === options.length
? 'bg-[var(--app-list-hover-background)] border-transparent'
: 'border-transparent'
}`}
onMouseEnter={() => setFocusedIndex(options.length)}
>
<div
className="px-3 py-2 text-sm"
style={{ color: 'var(--app-secondary-foreground)' }}
>
Tell Qwen what to do instead
</div>
<div
ref={customInputRef}
contentEditable="plaintext-only"
spellCheck={false}
className="px-3 pb-2 text-sm outline-none bg-transparent min-h-[1.5em]"
style={{ color: 'var(--app-input-foreground)' }}
onInput={(e) => {
const target = e.target as HTMLDivElement;
setCustomMessage(target.textContent || '');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.preventDefault();
const rejectOption = options.find((o) =>
o.kind.includes('reject'),
);
if (rejectOption) {
onResponse(rejectOption.optionId);
}
}
}}
/>
</div>
</div> </div>
</div> </div>
</>
<style>{`
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
`}</style>
</div>
); );
}; };

View File

@@ -109,18 +109,118 @@ export function useCompletionTrigger(
const handleInput = async () => { const handleInput = async () => {
const text = inputElement.textContent || ''; const text = inputElement.textContent || '';
const selection = window.getSelection(); const selection = window.getSelection();
console.log(
'[useCompletionTrigger] handleInput - text:',
JSON.stringify(text),
'length:',
text.length,
);
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
console.log('[useCompletionTrigger] No selection or rangeCount === 0');
return; return;
} }
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const cursorPosition = range.startOffset; console.log(
'[useCompletionTrigger] range.startContainer:',
range.startContainer,
'startOffset:',
range.startOffset,
);
console.log(
'[useCompletionTrigger] startContainer === inputElement:',
range.startContainer === inputElement,
);
console.log(
'[useCompletionTrigger] startContainer.nodeType:',
range.startContainer.nodeType,
'TEXT_NODE:',
Node.TEXT_NODE,
);
// Get cursor position more reliably
// For contentEditable, we need to calculate the actual text offset
let cursorPosition = text.length; // Default to end of text
if (range.startContainer === inputElement) {
// Cursor is directly in the container (e.g., empty or at boundary)
// Use childNodes to determine position
const childIndex = range.startOffset;
let offset = 0;
for (
let i = 0;
i < childIndex && i < inputElement.childNodes.length;
i++
) {
offset += inputElement.childNodes[i].textContent?.length || 0;
}
cursorPosition = offset || text.length;
console.log(
'[useCompletionTrigger] Container mode - childIndex:',
childIndex,
'offset:',
offset,
'cursorPosition:',
cursorPosition,
);
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
// Cursor is in a text node - calculate offset from start of input
const walker = document.createTreeWalker(
inputElement,
NodeFilter.SHOW_TEXT,
null,
);
let offset = 0;
let found = false;
let node: Node | null = walker.nextNode();
while (node) {
if (node === range.startContainer) {
offset += range.startOffset;
found = true;
break;
}
offset += node.textContent?.length || 0;
node = walker.nextNode();
}
// If we found the node, use the calculated offset; otherwise use text length
cursorPosition = found ? offset : text.length;
console.log(
'[useCompletionTrigger] Text node mode - found:',
found,
'offset:',
offset,
'cursorPosition:',
cursorPosition,
);
}
// Find trigger character before cursor // Find trigger character before cursor
const textBeforeCursor = text.substring(0, cursorPosition); // Use text length if cursorPosition is 0 but we have text (edge case for first character)
const effectiveCursorPosition =
cursorPosition === 0 && text.length > 0 ? text.length : cursorPosition;
console.log(
'[useCompletionTrigger] cursorPosition:',
cursorPosition,
'effectiveCursorPosition:',
effectiveCursorPosition,
);
const textBeforeCursor = text.substring(0, effectiveCursorPosition);
const lastAtMatch = textBeforeCursor.lastIndexOf('@'); const lastAtMatch = textBeforeCursor.lastIndexOf('@');
const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); const lastSlashMatch = textBeforeCursor.lastIndexOf('/');
console.log(
'[useCompletionTrigger] textBeforeCursor:',
JSON.stringify(textBeforeCursor),
'lastAtMatch:',
lastAtMatch,
'lastSlashMatch:',
lastSlashMatch,
);
// Check if we're in a trigger context // Check if we're in a trigger context
let triggerPos = -1; let triggerPos = -1;
let triggerChar: '@' | '/' | null = null; let triggerChar: '@' | '/' | null = null;
@@ -133,19 +233,46 @@ export function useCompletionTrigger(
triggerChar = '/'; triggerChar = '/';
} }
console.log(
'[useCompletionTrigger] triggerPos:',
triggerPos,
'triggerChar:',
triggerChar,
);
// Check if trigger is at word boundary (start of line or after space) // Check if trigger is at word boundary (start of line or after space)
if (triggerPos >= 0 && triggerChar) { if (triggerPos >= 0 && triggerChar) {
const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' ';
const isValidTrigger = const isValidTrigger =
charBefore === ' ' || charBefore === '\n' || triggerPos === 0; charBefore === ' ' || charBefore === '\n' || triggerPos === 0;
console.log(
'[useCompletionTrigger] charBefore:',
JSON.stringify(charBefore),
'isValidTrigger:',
isValidTrigger,
);
if (isValidTrigger) { if (isValidTrigger) {
const query = text.substring(triggerPos + 1, cursorPosition); const query = text.substring(triggerPos + 1, effectiveCursorPosition);
console.log(
'[useCompletionTrigger] query:',
JSON.stringify(query),
'hasSpace:',
query.includes(' '),
'hasNewline:',
query.includes('\n'),
);
// Only show if query doesn't contain spaces (still typing the reference) // Only show if query doesn't contain spaces (still typing the reference)
if (!query.includes(' ') && !query.includes('\n')) { if (!query.includes(' ') && !query.includes('\n')) {
// Get precise cursor position for menu // Get precise cursor position for menu
const cursorPos = getCursorPosition(); const cursorPos = getCursorPosition();
console.log(
'[useCompletionTrigger] Opening completion - cursorPos:',
cursorPos,
);
if (cursorPos) { if (cursorPos) {
await openCompletion(triggerChar, query, cursorPos); await openCompletion(triggerChar, query, cursorPos);
return; return;
@@ -155,6 +282,10 @@ export function useCompletionTrigger(
} }
// Close if no valid trigger // Close if no valid trigger
console.log(
'[useCompletionTrigger] No valid trigger, state.isOpen:',
state.isOpen,
);
if (state.isOpen) { if (state.isOpen) {
closeCompletion(); closeCompletion();
} }

View File

@@ -8,6 +8,8 @@ module.exports = {
'./src/webview/components/messages/**/*.{js,jsx,ts,tsx}', './src/webview/components/messages/**/*.{js,jsx,ts,tsx}',
'./src/webview/components/MessageContent.tsx', './src/webview/components/MessageContent.tsx',
'./src/webview/components/InfoBanner.tsx', './src/webview/components/InfoBanner.tsx',
'./src/webview/components/InputForm.tsx',
'./src/webview/components/PermissionDrawer.tsx',
// 当需要在更多组件中使用Tailwind时可以逐步添加路径 // 当需要在更多组件中使用Tailwind时可以逐步添加路径
// "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}", // "./src/webview/components/NewComponent/**/*.{js,jsx,ts,tsx}",
// "./src/webview/pages/**/*.{js,jsx,ts,tsx}", // "./src/webview/pages/**/*.{js,jsx,ts,tsx}",