From 223fb1bfab41f0dbd80cb3d7f8fb8ffb2a198af1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 4 Jan 2026 17:12:48 +0800 Subject: [PATCH 001/113] feat(vscode-ide-companion): unify tool call display names and merge Execute/Bash components - Rename tool call labels to be consistent with CLI counterparts - Merge Execute and Bash tool call components into unified Shell component - Add proper display name mapping for various tool kinds - Create ShellToolCall component to handle both execute and bash variants - Update documentation with tool display name unification details This change makes the VSCode extension tool display names consistent with the CLI interface. --- .../messages/toolcalls/Bash/Bash.css | 108 -------- .../messages/toolcalls/Bash/Bash.tsx | 178 ------------- .../messages/toolcalls/Execute/Execute.css | 103 -------- .../messages/toolcalls/GenericToolCall.tsx | 26 +- .../messages/toolcalls/Read/ReadToolCall.tsx | 22 +- .../toolcalls/Search/SearchToolCall.tsx | 28 ++- .../toolcalls/Shell/ShellToolCall.css | 191 ++++++++++++++ .../Execute.tsx => Shell/ShellToolCall.tsx} | 171 ++++++++----- .../toolcalls/Think/ThinkToolCall.tsx | 6 +- .../UpdatedPlan/UpdatedPlanToolCall.tsx | 4 +- .../toolcalls/Write/WriteToolCall.tsx | 6 +- .../components/messages/toolcalls/index.tsx | 7 +- tool_display_names_documentation.md | 234 ++++++++++++++++++ 13 files changed, 600 insertions(+), 484 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css create mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css rename packages/vscode-ide-companion/src/webview/components/messages/toolcalls/{Execute/Execute.tsx => Shell/ShellToolCall.tsx} (52%) create mode 100644 tool_display_names_documentation.md diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css deleted file mode 100644 index aff1fc5ec..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Bash tool call styles - Enhanced styling with semantic class names - */ - -/* Root container for bash tool call output */ -.bash-toolcall-card { - border: 0.5px solid var(--app-input-border); - border-radius: 5px; - background: var(--app-tool-background); - margin: 8px 0; - max-width: 100%; - font-size: 1em; - align-items: start; -} - -/* Content wrapper inside the card */ -.bash-toolcall-content { - display: flex; - flex-direction: column; - gap: 3px; - padding: 4px; -} - -/* Individual input/output row */ -.bash-toolcall-row { - display: grid; - grid-template-columns: max-content 1fr; - border-top: 0.5px solid var(--app-input-border); - padding: 4px; -} - -/* First row has no top border */ -.bash-toolcall-row:first-child { - border-top: none; -} - -/* Row label (IN/OUT/ERROR) */ -.bash-toolcall-label { - grid-column: 1; - color: var(--app-secondary-foreground); - text-align: left; - opacity: 50%; - padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Row content area */ -.bash-toolcall-row-content { - grid-column: 2; - white-space: pre-wrap; - word-break: break-word; - margin: 0; - padding: 4px; -} - -/* Truncated content styling */ -.bash-toolcall-row-content:not(.bash-toolcall-full) { - max-height: 60px; - mask-image: linear-gradient( - to bottom, - var(--app-primary-background) 40px, - transparent 60px - ); - overflow: hidden; -} - -/* Preformatted content */ -.bash-toolcall-pre { - margin-block: 0; - overflow: hidden; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Code content */ -.bash-toolcall-code { - margin: 0; - padding: 0; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Output content with subtle styling */ -.bash-toolcall-output-subtle { - background-color: var(--app-code-background); - white-space: pre; - overflow-x: auto; - max-width: 100%; - min-width: 0; - width: 100%; - box-sizing: border-box; -} - -/* Error content styling */ -.bash-toolcall-error-content { - color: #c74e39; -} - -/* Row with copy button */ -.bash-toolcall-row-with-copy { - position: relative; - grid-template-columns: max-content 1fr max-content; -} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx deleted file mode 100644 index 99f683a33..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Execute tool call component - specialized for command execution operations - */ - -import type React from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; -import { ToolCallContainer } from '../shared/LayoutComponents.js'; -import { safeTitle, groupContent } from '../../../../utils/utils.js'; -import { useVSCode } from '../../../../hooks/useVSCode.js'; -import { createAndOpenTempFile } from '../../../../utils/diffUtils.js'; -import { CopyButton } from '../shared/copyUtils.js'; -import './Bash.css'; - -/** - * Specialized component for Execute/Bash tool calls - * Shows: Bash bullet + description + IN/OUT card - */ -export const ExecuteToolCall: React.FC = ({ toolCall }) => { - const { title, content, rawInput, toolCallId } = toolCall; - const commandText = safeTitle(title); - const vscode = useVSCode(); - - // Group content by type - const { textOutputs, errors } = groupContent(content); - - // Extract command from rawInput if available - let inputCommand = commandText; - if (rawInput && typeof rawInput === 'object') { - const inputObj = rawInput as { command?: string }; - inputCommand = inputObj.command || commandText; - } else if (typeof rawInput === 'string') { - inputCommand = rawInput; - } - - // Handle click on IN section - const handleInClick = () => { - createAndOpenTempFile(vscode, inputCommand, `bash-input-${toolCallId}`); - }; - - // Handle click on OUT section - const handleOutClick = () => { - if (textOutputs.length > 0) { - const output = textOutputs.join('\n'); - createAndOpenTempFile(vscode, output, `bash-output-${toolCallId}`); - } - }; - - // Map tool status to container status for proper bullet coloring - const containerStatus: - | 'success' - | 'error' - | 'warning' - | 'loading' - | 'default' = - errors.length > 0 - ? 'error' - : toolCall.status === 'in_progress' || toolCall.status === 'pending' - ? 'loading' - : 'success'; - - // Error case - if (errors.length > 0) { - return ( - - {/* Branch connector summary */} -
- - {commandText} -
- {/* Error card - semantic DOM + Tailwind styles */} -
-
- {/* IN row */} -
-
IN
-
-
{inputCommand}
-
- -
- - {/* ERROR row */} -
-
Error
-
-
-                  {errors.join('\n')}
-                
-
-
-
-
-
- ); - } - - // Success with output - if (textOutputs.length > 0) { - const output = textOutputs.join('\n'); - const truncatedOutput = - output.length > 500 ? output.substring(0, 500) + '...' : output; - - return ( - - {/* Branch connector summary */} -
- - {commandText} -
- {/* Output card - semantic DOM + Tailwind styles */} -
-
- {/* IN row */} -
-
IN
-
-
{inputCommand}
-
- -
- - {/* OUT row */} -
-
OUT
-
-
-
{truncatedOutput}
-
-
-
-
-
-
- ); - } - - // Success without output: show command with branch connector - return ( - -
- - {commandText} -
-
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css deleted file mode 100644 index cfd4c8b64..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.css +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Execute tool call styles - Enhanced styling with semantic class names - */ - -/* Root container for execute tool call output */ -.execute-toolcall-card { - border: 0.5px solid var(--app-input-border); - border-radius: 5px; - background: var(--app-tool-background); - margin: 8px 0; - max-width: 100%; - font-size: 1em; - align-items: start; -} - -/* Content wrapper inside the card */ -.execute-toolcall-content { - display: flex; - flex-direction: column; - gap: 3px; - padding: 4px; -} - -/* Individual input/output row */ -.execute-toolcall-row { - display: grid; - grid-template-columns: max-content 1fr; - border-top: 0.5px solid var(--app-input-border); - padding: 4px; -} - -/* First row has no top border */ -.execute-toolcall-row:first-child { - border-top: none; -} - -/* Row label (IN/OUT/ERROR) */ -.execute-toolcall-label { - grid-column: 1; - color: var(--app-secondary-foreground); - text-align: left; - opacity: 50%; - padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Row content area */ -.execute-toolcall-row-content { - grid-column: 2; - white-space: pre-wrap; - word-break: break-word; - margin: 0; - padding: 4px; -} - -/* Truncated content styling */ -.execute-toolcall-row-content:not(.execute-toolcall-full) { - max-height: 60px; - mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px); - overflow: hidden; -} - -/* Preformatted content */ -.execute-toolcall-pre { - margin-block: 0; - overflow: hidden; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Code content */ -.execute-toolcall-code { - margin: 0; - padding: 0; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Output content with subtle styling */ -.execute-toolcall-output-subtle { - white-space: pre; - overflow-x: auto; - max-width: 100%; - min-width: 0; - width: 100%; - box-sizing: border-box; -} - -/* Error content styling */ -.execute-toolcall-error-content { - color: #c74e39; -} - -/* Row with copy button */ -.execute-toolcall-row-with-copy { - position: relative; - grid-template-columns: max-content 1fr max-content; -} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx index 28c2ff2b3..72cdeac9d 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx @@ -25,6 +25,22 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { const { kind, title, content, locations, toolCallId } = toolCall; const operationText = safeTitle(title); + // Map tool call kind to appropriate display name + const getDisplayLabel = (): string => { + const normalizedKind = kind.toLowerCase(); + if (normalizedKind === 'task') { + return 'Task'; + } else if (normalizedKind === 'web_fetch') { + return 'WebFetch'; + } else if (normalizedKind === 'web_search') { + return 'WebSearch'; + } else if (normalizedKind === 'exit_plan_mode') { + return 'ExitPlanMode'; + } else { + return kind; // fallback to original kind if not mapped + } + }; + // Group content by type const { textOutputs, errors } = groupContent(content); @@ -32,7 +48,7 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { if (errors.length > 0) { return ( - +
{operationText}
@@ -53,7 +69,7 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { return ( - +
{operationText}
@@ -72,7 +88,7 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { : 'success'; return ( @@ -89,7 +105,7 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { : 'success'; return ( @@ -106,7 +122,7 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { : 'success'; return ( diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx index 20b0ce785..de6147765 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx @@ -53,9 +53,23 @@ export const ToolCallContainer: React.FC = ({ * Shows: Read filename (no content preview) */ export const ReadToolCall: React.FC = ({ toolCall }) => { - const { content, locations, toolCallId } = toolCall; + const { kind, content, locations, toolCallId } = toolCall; const vscode = useVSCode(); + // Map tool call kind to appropriate display name + const getDisplayLabel = (): string => { + const normalizedKind = kind.toLowerCase(); + if (normalizedKind === 'read_many_files') { + return 'ReadManyFiles'; + } else if (normalizedKind === 'list_directory' || normalizedKind === 'ls') { + return 'ListFiles'; + } else if (normalizedKind === 'skill') { + return 'Skill'; + } else { + return 'ReadFile'; // default for read_file tools + } + }; + // Group content by type; memoize to avoid new array identities on every render const { errors, diffs } = useMemo(() => groupContent(content), [content]); @@ -105,7 +119,7 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { const path = locations?.[0]?.path || ''; return ( = ({ toolCall }) => { const path = diffs[0]?.path || locations?.[0]?.path || ''; return ( = ({ toolCall }) => { const path = locations[0].path; return ( = ({ status, labelSuffix, children, isFirst, isLast }) => { + displayLabel: string; +}> = ({ status, labelSuffix, children, isFirst, isLast, displayLabel }) => { const beforeStatusClass = `toolcall-container toolcall-status-${status}`; const lineCropTop = isFirst ? 'top-[24px]' : 'top-0'; const lineCropBottom = isLast @@ -48,7 +49,7 @@ const InlineContainer: React.FC<{
- Search + {displayLabel} {labelSuffix ? ( @@ -134,9 +135,23 @@ export const SearchToolCall: React.FC = ({ isFirst, isLast, }) => { - const { title, content, locations } = toolCall; + const { kind, title, content, locations } = toolCall; const queryText = safeTitle(title); + // Map tool call kind to appropriate display name + const getDisplayLabel = (): string => { + const normalizedKind = kind.toLowerCase(); + if (normalizedKind === 'grep' || normalizedKind === 'grep_search') { + return 'Grep'; + } else if (normalizedKind === 'glob') { + return 'Glob'; + } else if (normalizedKind === 'web_search') { + return 'WebSearch'; + } else { + return 'Search'; // fallback for other search-like tools + } + }; + // Group content by type const { errors, textOutputs } = groupContent(content); @@ -144,7 +159,7 @@ export const SearchToolCall: React.FC = ({ if (errors.length > 0) { return ( - +
{queryText}
@@ -161,7 +176,7 @@ export const SearchToolCall: React.FC = ({ if (locations.length > 1) { return ( - +
{queryText}
@@ -177,6 +192,7 @@ export const SearchToolCall: React.FC = ({ labelSuffix={`(${queryText})`} isFirst={isFirst} isLast={isLast} + displayLabel={getDisplayLabel()} > @@ -193,6 +209,7 @@ export const SearchToolCall: React.FC = ({ labelSuffix={queryText ? `(${queryText})` : undefined} isFirst={isFirst} isLast={isLast} + displayLabel={getDisplayLabel()} >
{textOutputs.map((text: string, index: number) => ( @@ -217,6 +234,7 @@ export const SearchToolCall: React.FC = ({ status={containerStatus} isFirst={isFirst} isLast={isLast} + displayLabel={getDisplayLabel()} > {queryText} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css new file mode 100644 index 000000000..363062ab0 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shell tool call styles - shared styles for bash/execute variants + */ + +/** + * Bash variant styles (class prefix: bash-*) + */ +.bash-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +.bash-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +.bash-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +.bash-toolcall-row:first-child { + border-top: none; +} + +.bash-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +.bash-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +.bash-toolcall-row-content:not(.bash-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +.bash-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +.bash-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +.bash-toolcall-output-subtle { + background-color: var(--app-code-background); + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +.bash-toolcall-error-content { + color: #c74e39; +} + +.bash-toolcall-row-with-copy { + position: relative; + grid-template-columns: max-content 1fr max-content; +} + +/** + * Execute variant styles (class prefix: execute-*) + */ +.execute-toolcall-card { + border: 0.5px solid var(--app-input-border); + border-radius: 5px; + background: var(--app-tool-background); + margin: 8px 0; + max-width: 100%; + font-size: 1em; + align-items: start; +} + +.execute-toolcall-content { + display: flex; + flex-direction: column; + gap: 3px; + padding: 4px; +} + +.execute-toolcall-row { + display: grid; + grid-template-columns: max-content 1fr; + border-top: 0.5px solid var(--app-input-border); + padding: 4px; +} + +.execute-toolcall-row:first-child { + border-top: none; +} + +.execute-toolcall-label { + grid-column: 1; + color: var(--app-secondary-foreground); + text-align: left; + opacity: 50%; + padding: 4px 8px 4px 4px; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +.execute-toolcall-row-content { + grid-column: 2; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 4px; +} + +.execute-toolcall-row-content:not(.execute-toolcall-full) { + max-height: 60px; + mask-image: linear-gradient( + to bottom, + var(--app-primary-background) 40px, + transparent 60px + ); + overflow: hidden; +} + +.execute-toolcall-pre { + margin-block: 0; + overflow: hidden; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +.execute-toolcall-code { + margin: 0; + padding: 0; + font-family: var(--app-monospace-font-family); + font-size: 0.85em; +} + +.execute-toolcall-output-subtle { + white-space: pre; + overflow-x: auto; + max-width: 100%; + min-width: 0; + width: 100%; + box-sizing: border-box; +} + +.execute-toolcall-error-content { + color: #c74e39; +} + +.execute-toolcall-row-with-copy { + position: relative; + grid-template-columns: max-content 1fr max-content; +} + diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx similarity index 52% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx rename to packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx index 2b02b0c86..f66e8a2c3 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Execute/Execute.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx @@ -3,19 +3,23 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * Execute tool call component - specialized for command execution operations + * Shared Shell tool call component for Execute/Bash/Command */ import type React from 'react'; import type { BaseToolCallProps } from '../shared/types.js'; -import { safeTitle, groupContent } from '../../../../utils/utils.js'; -import './Execute.css'; import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; +import { ToolCallContainer as SharedToolCallContainer } from '../shared/LayoutComponents.js'; +import { safeTitle, groupContent } from '../../../../utils/utils.js'; import { useVSCode } from '../../../../hooks/useVSCode.js'; import { createAndOpenTempFile } from '../../../../utils/diffUtils.js'; import { CopyButton } from '../shared/copyUtils.js'; -export const ToolCallContainer: React.FC = ({ +import './ShellToolCall.css'; + +type ShellVariant = 'execute' | 'bash'; + +const ExecuteToolCallContainer: React.FC = ({ label, status = 'success', children, @@ -42,39 +46,72 @@ export const ToolCallContainer: React.FC = ({
); +const getCommandText = ( + variant: ShellVariant, + title: unknown, + rawInput?: unknown, +): string => { + if (variant === 'execute' && rawInput && typeof rawInput === 'object') { + const description = (rawInput as Record).description; + const describedTitle = safeTitle(description); + if (describedTitle) { + return describedTitle; + } + } + return safeTitle(title); +}; + +const getInputCommand = ( + commandText: string, + rawInput?: string | object, +): string => { + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as Record; + return (inputObj.command as string | undefined) || commandText; + } + if (typeof rawInput === 'string') { + return rawInput; + } + return commandText; +}; + /** - * Specialized component for Execute tool calls - * Shows: Execute bullet + description + IN/OUT card + * Shared component for Execute/Bash tool calls + * Shows: Shell bullet + description + IN/OUT card */ -export const ExecuteToolCall: React.FC = ({ toolCall }) => { +const ShellToolCallImpl: React.FC< + BaseToolCallProps & { variant: ShellVariant } +> = ({ toolCall, variant }) => { const { title, content, rawInput, toolCallId } = toolCall; - const commandText = safeTitle( - (rawInput as Record)?.description || title, - ); + const classPrefix = variant; + const commandText = getCommandText(variant, title, rawInput); + const inputCommand = getInputCommand(commandText, rawInput); const vscode = useVSCode(); + const Container = + variant === 'execute' ? ExecuteToolCallContainer : SharedToolCallContainer; + // Group content by type const { textOutputs, errors } = groupContent(content); - // Extract command from rawInput if available - let inputCommand = commandText; - if (rawInput && typeof rawInput === 'object') { - const inputObj = rawInput as Record; - inputCommand = (inputObj.command as string | undefined) || commandText; - } else if (typeof rawInput === 'string') { - inputCommand = rawInput; - } - // Handle click on IN section const handleInClick = () => { - createAndOpenTempFile(vscode, inputCommand, `execute-input-${toolCallId}`); + createAndOpenTempFile( + vscode, + inputCommand, + `${classPrefix}-input-${toolCallId}`, + ); }; // Handle click on OUT section const handleOutClick = () => { if (textOutputs.length > 0) { const output = textOutputs.join('\n'); - createAndOpenTempFile(vscode, output, `execute-output-${toolCallId}`); + createAndOpenTempFile( + vscode, + output, + `${classPrefix}-output-${toolCallId}`, + ); } }; @@ -85,7 +122,7 @@ export const ExecuteToolCall: React.FC = ({ toolCall }) => { | 'warning' | 'loading' | 'default' = - errors.length > 0 || toolCall.status === 'failed' + errors.length > 0 || (variant === 'execute' && toolCall.status === 'failed') ? 'error' : toolCall.status === 'in_progress' || toolCall.status === 'pending' ? 'loading' @@ -94,45 +131,42 @@ export const ExecuteToolCall: React.FC = ({ toolCall }) => { // Error case if (errors.length > 0) { return ( - + {/* Branch connector summary */}
{commandText}
- {/* Error card - semantic DOM + Tailwind styles */} -
-
- {/* IN row */} + +
+
-
IN
-
-
{inputCommand}
+
IN
+
+
+                  {inputCommand}
+                
- {/* ERROR row */} -
-
Error
-
-
+            
+
Error
+
+
                   {errors.join('\n')}
                 
- + ); } @@ -143,58 +177,52 @@ export const ExecuteToolCall: React.FC = ({ toolCall }) => { output.length > 500 ? output.substring(0, 500) + '...' : output; return ( - + {/* Branch connector summary */}
{commandText}
- {/* Output card - semantic DOM + Tailwind styles */} -
-
- {/* IN row */} + +
+
-
IN
-
-
{inputCommand}
+
IN
+
+
+                  {inputCommand}
+                
- {/* OUT row */}
-
OUT
-
-
-
{truncatedOutput}
+
OUT
+
+
+
+                    {truncatedOutput}
+                  
- + ); } // Success without output: show command with branch connector return ( - +
= ({ toolCall }) => { {commandText}
-
+ ); }; + +export const ShellToolCall: React.FC = (props) => { + const normalizedKind = props.toolCall.kind.toLowerCase(); + const variant: ShellVariant = + normalizedKind === 'execute' ? 'execute' : 'bash'; + return ; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx index 2f73c4951..5b456b67c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx @@ -29,7 +29,7 @@ export const ThinkToolCall: React.FC = ({ toolCall }) => { // Error case (rare for thinking) if (errors.length > 0) { return ( - + {errors.join('\n')} ); @@ -46,7 +46,7 @@ export const ThinkToolCall: React.FC = ({ toolCall }) => { return ( - +
{truncatedThoughts}
@@ -61,7 +61,7 @@ export const ThinkToolCall: React.FC = ({ toolCall }) => { ? 'loading' : 'default'; return ( - + {thoughts} ); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx index 3b014150d..a0d01a39e 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx @@ -112,7 +112,7 @@ export const UpdatedPlanToolCall: React.FC = ({ // Error-first display if (errors.length > 0) { return ( - + {errors.join('\n')} ); @@ -120,7 +120,7 @@ export const UpdatedPlanToolCall: React.FC = ({ const entries = parsePlanEntries(textOutputs); - const label = safeTitle(toolCall.title) || 'Updated Plan'; + const label = safeTitle(toolCall.title) || 'TodoWrite'; return ( = ({ toolCall }) => { return ( = ({ toolCall }) => { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); return ( = ({ toolCall }) => { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); return ( diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index 05bf2ba76..1ecc15afe 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -13,8 +13,7 @@ import { GenericToolCall } from './GenericToolCall.js'; import { ReadToolCall } from './Read/ReadToolCall.js'; import { WriteToolCall } from './Write/WriteToolCall.js'; import { EditToolCall } from './Edit/EditToolCall.js'; -import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js'; -import { ExecuteToolCall } from './Execute/Execute.js'; +import { ShellToolCall } from './Shell/ShellToolCall.js'; import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js'; import { SearchToolCall } from './Search/SearchToolCall.js'; import { ThinkToolCall } from './Think/ThinkToolCall.js'; @@ -39,11 +38,9 @@ export const getToolCallComponent = ( return EditToolCall; case 'execute': - return ExecuteToolCall; - case 'bash': case 'command': - return BashExecuteToolCall; + return ShellToolCall; case 'updated_plan': case 'updatedplan': diff --git a/tool_display_names_documentation.md b/tool_display_names_documentation.md new file mode 100644 index 000000000..3917ac122 --- /dev/null +++ b/tool_display_names_documentation.md @@ -0,0 +1,234 @@ +# CLI and VSCode Extension Tool Display Name Unification Issue + +## What would you like to be added? + +This issue proposes unifying the tool display names between the CLI and VSCode Extension interfaces. Currently, the same tools show different names in different interfaces (e.g., `Shell Command`/`Shell` in CLI vs `Execute`/`Bash` in VSCode), creating confusion for users who switch between the two interfaces. + +The proposal is to standardize the display names to use the core tool names (as defined in ToolDisplayNames constants) across both interfaces, making the user experience consistent regardless of which interface they use. + +## Why is this needed? + +1. **User Experience Consistency**: Users often switch between CLI and VSCode Extension, and seeing different names for the same tools creates confusion and cognitive overhead. + +2. **Reduced Learning Curve**: Having consistent naming across interfaces means users only need to learn one set of tool names rather than interface-specific names. + +3. **Professional Standards**: Consistent UI/UX design is a professional standard that improves user satisfaction and reduces support requests. + +4. **Developer Productivity**: Consistent tool naming helps developers focus on their work rather than navigating interface differences. + +## Overview + +Currently, Qwen Code uses different display names for the same tools in the CLI and VSCode Extension, causing user experience inconsistency. This issue aims to unify the tool display names in both interfaces to provide a more consistent user experience. + +## Current Tool Display Comparison + +### 1. Shell Tool + +- **Internal Name**: `run_shell_command` +- **CLI Display**: `Shell Command` or `Shell` +- **VSCode Display**: `Execute` or `Bash` + +| CLI | VSCode Extension | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Shows as "Shell Command" when using shellCommandProcessor, "Shell" for LLM tool calls | Shows as "Execute" for execute kind, "Bash" for bash/command kind | + +### 2. Edit Tool + +- **Internal Name**: `edit` +- **CLI Display**: `Edit` +- **VSCode Display**: `Edit` + +| CLI | VSCode Extension | +| --------------- | -------------------------------------------- | +| Shows as "Edit" | Shows as "Edit" (via EditToolCall component) | + +### 3. Write File Tool + +- **Internal Name**: `write_file` +- **CLI Display**: `WriteFile` +- **VSCode Display**: `Write` + +| CLI | VSCode Extension | +| -------------------- | ---------------------------------------------- | +| Shows as "WriteFile" | Shows as "Write" (via WriteToolCall component) | + +### 4. Read File Tool + +- **Internal Name**: `read_file` +- **CLI Display**: `ReadFile` +- **VSCode Display**: `Read` + +| CLI | VSCode Extension | +| ------------------- | -------------------------------------------- | +| Shows as "ReadFile" | Shows as "Read" (via ReadToolCall component) | + +### 5. Read Many Files Tool + +- **Internal Name**: `read_many_files` +- **CLI Display**: `ReadManyFiles` +- **VSCode Display**: `Read` + +| CLI | VSCode Extension | +| ------------------------ | -------------------------------------------- | +| Shows as "ReadManyFiles" | Shows as "Read" (via ReadToolCall component) | + +### 6. Grep Search Tool + +- **Internal Name**: `grep_search` +- **CLI Display**: `Grep` +- **VSCode Display**: `Search` + +| CLI | VSCode Extension | +| --------------- | ------------------------------------------------ | +| Shows as "Grep" | Shows as "Search" (via SearchToolCall component) | + +### 7. Glob Tool + +- **Internal Name**: `glob` +- **CLI Display**: `Glob` +- **VSCode Display**: `Search` + +| CLI | VSCode Extension | +| --------------- | ------------------------------------------------ | +| Shows as "Glob" | Shows as "Search" (via SearchToolCall component) | + +### 8. List Directory Tool + +- **Internal Name**: `list_directory` +- **CLI Display**: `ListFiles` +- **VSCode Display**: `Read` + +| CLI | VSCode Extension | +| -------------------- | -------------------------------------------- | +| Shows as "ListFiles" | Shows as "Read" (via ReadToolCall component) | + +### 9. Todo Write Tool + +- **Internal Name**: `todo_write` +- **CLI Display**: `TodoWrite` +- **VSCode Display**: `Updated Plan` + +| CLI | VSCode Extension | +| -------------------- | ----------------------------------------------------------- | +| Shows as "TodoWrite" | Shows as "Updated Plan" (via UpdatedPlanToolCall component) | + +### 10. Memory Tool + +- **Internal Name**: `memory` +- **CLI Display**: `SaveMemory` +- **VSCode Display**: `Think` + +| CLI | VSCode Extension | +| --------------------- | ------------------------------------------------- | +| Shows as "SaveMemory" | Shows as "Thinking" (via ThinkToolCall component) | + +### 11. Task Tool + +- **Internal Name**: `task` +- **CLI Display**: `Task` +- **VSCode Display**: `Other` (via GenericToolCall) + +| CLI | VSCode Extension | +| --------------- | ------------------------------------------------ | +| Shows as "Task" | Shows as generic component (via GenericToolCall) | + +### 12. Skill Tool + +- **Internal Name**: `skill` +- **CLI Display**: `Skill` +- **VSCode Display**: `Read` + +| CLI | VSCode Extension | +| ---------------- | -------------------------------------------- | +| Shows as "Skill" | Shows as "Read" (via ReadToolCall component) | + +### 13. Exit Plan Mode Tool + +- **Internal Name**: `exit_plan_mode` +- **CLI Display**: `ExitPlanMode` +- **VSCode Display**: `Think` + +| CLI | VSCode Extension | +| ----------------------- | ------------------------------------------------- | +| Shows as "ExitPlanMode" | Shows as "Thinking" (via ThinkToolCall component) | + +### 14. Web Fetch Tool + +- **Internal Name**: `web_fetch` +- **CLI Display**: `WebFetch` +- **VSCode Display**: `Fetch` + +| CLI | VSCode Extension | +| ------------------- | ------------------------------------------------ | +| Shows as "WebFetch" | Shows as "Fetch" (via GenericToolCall component) | + +### 15. Web Search Tool + +- **Internal Name**: `web_search` +- **CLI Display**: `WebSearch` +- **VSCode Display**: `Search` + +| CLI | VSCode Extension | +| -------------------- | ------------------------------------------------ | +| Shows as "WebSearch" | Shows as "Search" (via SearchToolCall component) | + +## Unification Recommendations + +Recommend unifying display names to the core tool display names (i.e., names from ToolDisplayNames): + +1. **Shell tool**: Unify to `Shell` (from `Shell Command`/`Execute`/`Bash`) +2. **Todo Write tool**: Unify to `TodoWrite` (from `Updated Plan`) +3. **Memory/Exit Plan Mode tools**: Unify to `SaveMemory`/`ExitPlanMode` (from `Thinking`) +4. **Web Search tool**: Unify to `WebSearch` (from `Search`) +5. **Grep Search tool**: Unify to `Grep` (from `Search`) +6. **Glob tool**: Unify to `Glob` (from `Search`) +7. **List Directory tool**: Unify to `ListFiles` (from `Read`) +8. **Read File/Read Many Files tools**: Unify to `ReadFile`/`ReadManyFiles` (from `Read`) +9. **Write File tool**: Unify to `WriteFile` (from `Write`) +10. **Skill tool**: Unify to `Skill` (from `Read`) + +## Technical Implementation Approach + +The unification needs to focus on the VSCode IDE Companion side, as the CLI display names are already consistent with the core tool display names. The VSCode extension uses the `kind` property from ACP protocol to determine which component to display, and these components hardcode their labels. + +### Key Files to Modify: + +1. `packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx` + - Contains the `getToolCallComponent` function that maps `kind` to components + +2. Individual component files in `packages/vscode-ide-companion/src/webview/components/messages/toolcalls/`: + - `Execute/Execute.tsx` - Shows "Execute" label + - `Bash/Bash.tsx` - Shows "Bash" label + - `UpdatedPlan/UpdatedPlanToolCall.tsx` - Shows "Updated Plan" label + - `Think/ThinkToolCall.tsx` - Shows "Thinking" label + - `Search/SearchToolCall.tsx` - Shows "Search" label + - `Read/ReadToolCall.tsx` - Shows "Read" label + - `Write/WriteToolCall.tsx` - Shows "Write" label + - `Edit/EditToolCall.tsx` - Shows "Edit" label + - `GenericToolCall.tsx` - Fallback component + +### Proposed Implementation: + +1. **Option A - Modify component labels**: Change the hardcoded labels in each component to use the tool's display name from ToolDisplayNames +2. **Option B - Modify kind mapping**: Update the kind mapping to use more consistent values that align with core tool names +3. **Option C - Hybrid approach**: Use Option A for immediate consistency, then consider Option B for long-term improvement + +### Recommended Approach: Option A (Component label modification) + +This approach maintains the existing architecture while achieving the desired display name consistency: + +1. Modify each tool call component to use the display name from the tool call's `title` property when available +2. Fallback to the tool's internal name or a consistent mapping from ToolDisplayNames +3. Maintain all existing functionality while standardizing display names + +## Implementation Steps + +1. **Research and mapping**: Create a complete mapping of all tools and their desired display names based on ToolDisplayNames constants +2. **Modify VSCode components**: Update component files to use consistent display names +3. **Test functionality**: Ensure all tool functionality remains intact after display name changes +4. **Update documentation**: Document the new unified display behavior +5. **Quality assurance**: Test in both CLI and VSCode Extension to ensure consistency + +## Expected Outcome + +The unified display names will provide a more consistent user experience, preventing confusion when users switch between CLI and VSCode Extension as the same tools will have identical names in both interfaces. From a67a8d027734b932cd3b2ded14632b28c4196ac6 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 5 Jan 2026 01:42:05 +0800 Subject: [PATCH 002/113] wip(cli): support lsp --- .vscode/settings.json | 7 +- cclsp-integration-plan.md | 147 +++ package-lock.json | 8 + package.json | 1 + packages/cli/LSP_DEBUGGING_GUIDE.md | 107 ++ packages/cli/src/config/config.test.ts | 82 ++ packages/cli/src/config/config.ts | 82 +- packages/cli/src/config/lspSettingsSchema.ts | 38 + packages/cli/src/config/settings.ts | 52 +- packages/cli/src/config/settingsSchema.ts | 41 + packages/cli/src/gemini.tsx | 2 + .../src/services/lsp/LspConnectionFactory.ts | 358 ++++++ .../src/services/lsp/NativeLspService.test.ts | 126 ++ .../cli/src/services/lsp/NativeLspService.ts | 1075 +++++++++++++++++ packages/core/src/config/config.ts | 49 + packages/core/src/index.ts | 3 + packages/core/src/lsp/types.ts | 54 + .../core/src/tools/lsp-find-references.ts | 309 +++++ .../core/src/tools/lsp-go-to-definition.ts | 309 +++++ .../core/src/tools/lsp-workspace-symbol.ts | 180 +++ packages/core/src/tools/tool-names.ts | 8 + 21 files changed, 3035 insertions(+), 3 deletions(-) create mode 100644 cclsp-integration-plan.md create mode 100644 packages/cli/LSP_DEBUGGING_GUIDE.md create mode 100644 packages/cli/src/config/lspSettingsSchema.ts create mode 100644 packages/cli/src/services/lsp/LspConnectionFactory.ts create mode 100644 packages/cli/src/services/lsp/NativeLspService.test.ts create mode 100644 packages/cli/src/services/lsp/NativeLspService.ts create mode 100644 packages/core/src/lsp/types.ts create mode 100644 packages/core/src/tools/lsp-find-references.ts create mode 100644 packages/core/src/tools/lsp-go-to-definition.ts create mode 100644 packages/core/src/tools/lsp-workspace-symbol.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ea2735760..8331c3876 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,10 @@ "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "vitest.disableWorkspaceWarning": true + "vitest.disableWorkspaceWarning": true, + "lsp": { + "enabled": true, + "allowed": ["typescript-language-server"], + "excluded": ["gopls"] + } } diff --git a/cclsp-integration-plan.md b/cclsp-integration-plan.md new file mode 100644 index 000000000..7105653a7 --- /dev/null +++ b/cclsp-integration-plan.md @@ -0,0 +1,147 @@ +# Qwen Code CLI LSP 集成实现方案分析 + +## 1. 项目概述 + +本方案旨在将 LSP(Language Server Protocol)能力原生集成到 Qwen Code CLI 中,使 AI 代理能够利用代码导航、定义查找、引用查找等功能。LSP 将作为与 MCP 并行的一级扩展机制实现。 + +## 2. 技术方案对比 + +### 2.1 Piebald-AI/claude-code-lsps 方案 +- **架构**: 客户端直接与每个 LSP 通信,通过 `.lsp.json` 配置文件声明服务器命令/参数、stdio 传输和文件扩展名路由 +- **用户配置**: 低摩擦,只需放置 `.lsp.json` 配置并确保 LSP 二进制文件已安装 +- **安全**: LSP 子进程以用户权限运行,无内置信任门控 +- **功能覆盖**: 可以暴露完整的 LSP 表面(hover、诊断、代码操作、重命名等) + +### 2.2 原生 LSP 客户端方案(推荐方案) +- **架构**: Qwen Code CLI 直接作为 LSP 客户端,与语言服务器建立 JSON-RPC 连接 +- **用户配置**: 支持内置预设 + 用户自定义 `.lsp.json` 配置 +- **安全**: 与 MCP 共享相同的安全控制(信任工作区、允许/拒绝列表、确认提示) +- **功能覆盖**: 暴露完整的 LSP 功能(流式诊断、代码操作、重命名、语义标记等) + +### 2.3 cclsp + MCP 方案(备选) +- **架构**: 通过 MCP 协议调用 cclsp 作为 LSP 桥接 +- **用户配置**: 需要 MCP 配置 +- **安全**: 通过 MCP 安全控制 +- **功能覆盖**: 依赖于 cclsp 映射的 MCP 工具 + +## 3. 原生 LSP 集成详细计划 + +### 3.1 方案选择 +- **推荐方案**: 原生 LSP 客户端作为主要路径,因为它提供完整 LSP 功能、更低延迟和更好的用户体验 +- **兼容层**: 保留 cclsp+MCP 作为现有 MCP 工作流的兼容桥接 +- **并行架构**: LSP 和 MCP 作为独立的扩展机制共存,共享安全策略 + +### 3.2 实现步骤 + +#### 3.2.1 创建原生 LSP 服务 +在 `packages/cli/src/services/lsp/` 目录下创建 `NativeLspService` 类,处理: +- 工作区语言检测 +- 自动发现和启动语言服务器 +- 与现有文档/编辑模型同步 +- LSP 能力直接暴露给代理 + +#### 3.2.2 配置支持 +- 支持内置预设配置(常见语言服务器) +- 支持用户自定义 `.lsp.json` 配置文件 +- 与 MCP 配置共存,共享信任控制 + +#### 3.2.3 集成启动流程 +- 在 `packages/cli/src/config/config.ts` 中的 `loadCliConfig` 函数内集成 +- 确保 LSP 服务与 MCP 服务共享相同的安全控制机制 +- 处理沙箱预检和主运行的重复调用问题 + +#### 3.2.4 功能标志配置 +- 在 `packages/cli/src/config/settingsSchema.ts` 中添加新的设置项 +- 提供全局开关(如 `lsp.enabled=false`)允许用户禁用 LSP 功能 +- 尊重 `mcp.allowed`/`mcp.excluded` 和文件夹信任设置 + +#### 3.2.5 安全控制 +- 与 MCP 共享相同的安全控制机制 +- 在信任工作区中自动启用,在非信任工作区中提示用户 +- 实现路径允许列表和进程启动确认 + +#### 3.2.6 错误处理与用户通知 +- 检测缺失的语言服务器并提供安装命令 +- 通过现有 MCP 状态 UI 显示错误信息 +- 实现重试/退避机制,检测沙箱环境并抑制自动启动 + +### 3.3 需要确认的不确定项 + +1. **启动集成点**:在 `loadCliConfig` 中集成原生 LSP 服务,需确保与 MCP 服务的协调 + +2. **配置优先级**:如果用户已有 cclsp MCP 配置,应保持并存还是优先使用原生 LSP + +3. **功能开关设计**:开关应该是全局级别的,LSP 和 MCP 可独立启用/禁用 + +4. **共享安全模型**:如何在代码中复用 MCP 的信任/安全控制逻辑 + +5. **语言服务器管理**:如何管理 LSP 服务器生命周期并与文档编辑模型同步 + +6. **依赖检测机制**:检测 LSP 服务器可用性,失败时提供降级选项 + +7. **测试策略**:需要测试 LSP 与 MCP 的并行运行,以及共享安全控制 + +### 3.4 安全考虑 + +- 与 MCP 共享相同的安全控制模型 +- 仅在受信任工作区中启用自动 LSP 功能 +- 提供用户确认机制用于启动新的 LSP 服务器 +- 防止路径劫持,使用安全的路径解析 + +### 3.5 高级 LSP 功能支持 + +- **完整 LSP 功能**: 支持流式诊断、代码操作、重命名、语义高亮、工作区编辑等 +- **兼容 Claude 配置**: 支持导入 Claude Code 风格的 `.lsp.json` 配置 +- **性能优化**: 优化 LSP 服务器启动时间和内存使用 + +### 3.6 用户体验 + +- 提供安装提示而非自动安装 +- 在统一的状态界面显示 LSP 和 MCP 服务器状态 +- 提供独立开关让用户控制 LSP 和 MCP 功能 +- 为只读/沙箱环境提供安全的配置处理和清晰的错误消息 + +## 4. 实施总结 + +### 4.1 已完成的工作 +1. **NativeLspService 类**:创建了核心服务类,包含语言检测、配置合并、LSP 连接管理等功能 +2. **LSP 连接工厂**:实现了基于 stdio 的 LSP 连接创建和管理 +3. **语言检测机制**:实现了基于文件扩展名和项目配置文件的语言自动检测 +4. **配置系统**:实现了内置预设、用户配置和 Claude 兼容配置的合并 +5. **安全控制**:实现了与 MCP 共享的安全控制机制,包括信任检查、用户确认、路径安全验证 +6. **CLI 集成**:在 `loadCliConfig` 函数中集成了 LSP 服务初始化点 + +### 4.2 关键组件 + +#### 4.2.1 LspConnectionFactory +- 使用 `vscode-jsonrpc` 和 `vscode-languageserver-protocol` 实现 LSP 连接 +- 支持 stdio 传输方式,可以扩展支持 TCP 传输 +- 提供连接创建、初始化和关闭的完整生命周期管理 + +#### 4.2.2 NativeLspService +- **语言检测**:扫描项目文件和配置文件来识别编程语言 +- **配置合并**:按优先级合并内置预设、用户配置和兼容层配置 +- **LSP 服务器管理**:启动、停止和状态管理 +- **安全控制**:与 MCP 共享的信任和确认机制 + +#### 4.2.3 配置架构 +- **内置预设**:为常见语言提供默认 LSP 服务器配置 +- **用户配置**:支持 `.lsp.json` 文件格式 +- **Claude 兼容**:可导入 Claude Code 的 LSP 配置 + +### 4.3 依赖管理 +- 使用 `vscode-languageserver-protocol` 进行 LSP 协议通信 +- 使用 `vscode-jsonrpc` 进行 JSON-RPC 消息传递 +- 使用 `vscode-languageserver-textdocument` 管理文档版本 + +### 4.4 安全特性 +- 工作区信任检查 +- 用户确认机制(对于非信任工作区) +- 命令存在性验证 +- 路径安全性检查 + +## 5. 总结 + +原生 LSP 客户端是当前最符合 Qwen Code 架构的选择,它提供了完整的 LSP 功能、更低的延迟和更好的用户体验。LSP 作为与 MCP 并行的一级扩展机制,将与 MCP 共享安全控制策略,但提供更丰富的代码智能功能。cclsp+MCP 可作为兼容层保留,以支持现有的 MCP 工作流。 + +该实现方案将使 Qwen Code CLI 具备完整的 LSP 功能,包括代码跳转、引用查找、自动补全、代码诊断等,为 AI 代理提供更丰富的代码理解能力。 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 330b90e08..5f9c347ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "globals": "^16.0.0", "husky": "^9.1.7", "json": "^11.0.0", + "json-schema": "^0.4.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", @@ -10807,6 +10808,13 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", diff --git a/package.json b/package.json index c239067ff..fd60b2a1c 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "globals": "^16.0.0", "husky": "^9.1.7", "json": "^11.0.0", + "json-schema": "^0.4.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", "mnemonist": "^0.40.3", diff --git a/packages/cli/LSP_DEBUGGING_GUIDE.md b/packages/cli/LSP_DEBUGGING_GUIDE.md new file mode 100644 index 000000000..7833e8b87 --- /dev/null +++ b/packages/cli/LSP_DEBUGGING_GUIDE.md @@ -0,0 +1,107 @@ +# LSP 调试指南 + +本指南介绍如何调试 packages/cli 中的 LSP (Language Server Protocol) 功能。 + +## 1. 启用调试模式 + +CLI 支持调试模式,可以提供额外的日志信息: + +```bash +# 使用 debug 标志运行 +qwen --debug [你的命令] + +# 或设置环境变量 +DEBUG=true qwen [你的命令] +DEBUG_MODE=true qwen [你的命令] +``` + +## 2. LSP 配置选项 + +LSP 功能通过设置系统配置,包含以下选项: + +- `lsp.enabled`: 启用/禁用原生 LSP 客户端(默认为 `false`) +- `lsp.allowed`: 允许的 LSP 服务器名称白名单 +- `lsp.excluded`: 排除的 LSP 服务器名称黑名单 + +在 settings.json 中的示例配置: +```json +{ + "lsp": { + "enabled": true, + "allowed": ["typescript-language-server", "pylsp"], + "excluded": ["gopls"] + } +} +``` + +## 3. NativeLspService 调试功能 + +`NativeLspService` 类包含几个调试功能: + +### 3.1 控制台日志 +服务向控制台输出状态消息: +- `LSP 服务器 ${name} 启动成功` - 服务器成功启动 +- `LSP 服务器 ${name} 启动失败` - 服务器启动失败 +- `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现 + +### 3.2 错误处理 +服务具有全面的错误处理和详细的错误消息 + +### 3.3 状态跟踪 +您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态 + +## 4. 调试命令 + +```bash +# 启用调试运行 +qwen --debug --prompt "调试 LSP 功能" + +# 检查在您的项目中检测到哪些 LSP 服务器 +# 系统会自动检测语言和相应的 LSP 服务器 +``` + +## 5. 手动 LSP 服务器配置 + +您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器: + +```json +{ + "python": { + "command": "pylsp", + "args": [], + "transport": "stdio", + "trustRequired": true + } +} +``` + +## 6. LSP 问题排查 + +### 6.1 检查 LSP 服务器是否已安装 +- 对于 TypeScript/JavaScript: `typescript-language-server` +- 对于 Python: `pylsp` +- 对于 Go: `gopls` + +### 6.2 验证工作区信任 +- LSP 服务器可能需要受信任的工作区才能启动 +- 检查 `security.folderTrust.enabled` 设置 + +### 6.3 查看日志 +- 查找以 `LSP 服务器` 开头的控制台消息 +- 检查命令存在性和路径安全性问题 + +## 7. LSP 服务启动流程 + +LSP 服务的启动遵循以下流程: + +1. **发现和准备**: `discoverAndPrepare()` 方法检测工作区中的编程语言 +2. **创建服务器句柄**: 根据检测到的语言创建对应的服务器句柄 +3. **启动服务器**: `start()` 方法启动所有服务器句柄 +4. **状态管理**: 服务器状态在 `NOT_STARTED`, `IN_PROGRESS`, `READY`, `FAILED` 之间转换 + +## 8. 调试技巧 + +- 使用 `--debug` 标志查看详细的启动过程 +- 检查工作区是否受信任(影响 LSP 服务器启动) +- 确认 LSP 服务器命令在系统 PATH 中可用 +- 使用 `getStatus()` 方法监控服务器运行状态 \ No newline at end of file diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0b95f7857..59ccd5509 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,6 +21,23 @@ import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +const mockDiscoverAndPrepare = vi.fn(); +const mockStartLsp = vi.fn(); +const mockDefinitions = vi.fn().mockResolvedValue([]); +const mockReferences = vi.fn().mockResolvedValue([]); +const mockWorkspaceSymbols = vi.fn().mockResolvedValue([]); +const nativeLspServiceMock = vi.fn().mockImplementation(() => ({ + discoverAndPrepare: mockDiscoverAndPrepare, + start: mockStartLsp, + definitions: mockDefinitions, + references: mockReferences, + workspaceSymbols: mockWorkspaceSymbols, +})); + +vi.mock('../services/lsp/NativeLspService.js', () => ({ + NativeLspService: nativeLspServiceMock, +})); + vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi .fn() @@ -518,6 +535,16 @@ describe('loadCliConfig', () => { beforeEach(() => { vi.resetAllMocks(); + mockDiscoverAndPrepare.mockReset(); + mockStartLsp.mockReset(); + mockWorkspaceSymbols.mockReset(); + mockWorkspaceSymbols.mockResolvedValue([]); + nativeLspServiceMock.mockReset(); + nativeLspServiceMock.mockImplementation(() => ({ + discoverAndPrepare: mockDiscoverAndPrepare, + start: mockStartLsp, + workspaceSymbols: mockWorkspaceSymbols, + })); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); }); @@ -587,6 +614,61 @@ describe('loadCliConfig', () => { expect(config.getShowMemoryUsage()).toBe(false); }); + it('should initialize native LSP service when enabled', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { + lsp: { + enabled: true, + allowed: ['typescript-language-server'], + excluded: ['pylsp'], + }, + }; + + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + argv, + ); + + expect(config.isLspEnabled()).toBe(true); + expect(config.getLspAllowed()).toEqual(['typescript-language-server']); + expect(config.getLspExcluded()).toEqual(['pylsp']); + expect(nativeLspServiceMock).toHaveBeenCalledTimes(1); + expect(mockDiscoverAndPrepare).toHaveBeenCalledTimes(1); + expect(mockStartLsp).toHaveBeenCalledTimes(1); + + const options = nativeLspServiceMock.mock.calls[0][5]; + expect(options?.allowedServers).toEqual(['typescript-language-server']); + expect(options?.excludedServers).toEqual(['pylsp']); + }); + + it('should skip native LSP startup when startLsp option is false', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const settings: Settings = { lsp: { enabled: true } }; + + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + argv, + undefined, + { startLsp: false }, + ); + + expect(config.isLspEnabled()).toBe(true); + expect(nativeLspServiceMock).not.toHaveBeenCalled(); + expect(mockDiscoverAndPrepare).not.toHaveBeenCalled(); + }); + it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments({} as Settings); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7cd7d685a..0715725e6 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -23,9 +23,11 @@ import { InputFormat, OutputFormat, SessionService, + ideContextStore, type ResumedSessionData, type FileFilteringOptions, type MCPServerConfig, + type LspClient, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import type { Settings } from './settings.js'; @@ -42,6 +44,7 @@ import { annotateActiveExtensions } from './extension.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; +import { NativeLspService } from '../services/lsp/NativeLspService.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import type { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; @@ -147,6 +150,44 @@ export interface CliArgs { channel: string | undefined; } +export interface LoadCliConfigOptions { + /** + * Whether to start the native LSP service during config load. + * Disable when doing preflight runs (e.g., sandbox preparation). + */ + startLsp?: boolean; +} + +class NativeLspClient implements LspClient { + constructor(private readonly service: NativeLspService) {} + + workspaceSymbols(query: string, limit?: number) { + return this.service.workspaceSymbols(query, limit); + } + + definitions( + location: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.definitions(location, serverName, limit); + } + + references( + location: Parameters[0], + serverName?: string, + includeDeclaration?: boolean, + limit?: number, + ) { + return this.service.references( + location, + serverName, + includeDeclaration, + limit, + ); + } +} + function normalizeOutputFormat( format: string | OutputFormat | undefined, ): OutputFormat | undefined { @@ -655,6 +696,7 @@ export async function loadCliConfig( extensionEnablementManager: ExtensionEnablementManager, argv: CliArgs, cwd: string = process.cwd(), + options: LoadCliConfigOptions = {}, ): Promise { const debugMode = isDebugMode(argv); @@ -731,6 +773,12 @@ export async function loadCliConfig( ); let mcpServers = mergeMcpServers(settings, activeExtensions); + + // LSP configuration derived from settings; defaults to disabled for safety. + const lspEnabled = settings.lsp?.enabled ?? false; + const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed; + const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded; + let lspClient: LspClient | undefined; const question = argv.promptInteractive || argv.prompt || ''; const inputFormat: InputFormat = (argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT; @@ -934,7 +982,7 @@ export async function loadCliConfig( } } - return new Config({ + const config = new Config({ sessionId, sessionData, embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL, @@ -1037,7 +1085,39 @@ export async function loadCliConfig( // always be true and the settings file can never disable recording. chatRecording: argv.chatRecording ?? settings.general?.chatRecording ?? true, + lsp: { + enabled: lspEnabled, + allowed: lspAllowed, + excluded: lspExcluded, + }, }); + + const shouldStartLsp = options.startLsp ?? true; + if (shouldStartLsp && lspEnabled) { + try { + const lspService = new NativeLspService( + config, + config.getWorkspaceContext(), + appEvents, + fileService, + ideContextStore, + { + allowedServers: lspAllowed, + excludedServers: lspExcluded, + requireTrustedWorkspace: folderTrust, + }, + ); + + await lspService.discoverAndPrepare(); + await lspService.start(); + lspClient = new NativeLspClient(lspService); + config.setLspClient(lspClient); + } catch (err) { + logger.warn('Failed to initialize native LSP service:', err); + } + } + + return config; } function allowedMcpServers( diff --git a/packages/cli/src/config/lspSettingsSchema.ts b/packages/cli/src/config/lspSettingsSchema.ts new file mode 100644 index 000000000..c8d3f1b33 --- /dev/null +++ b/packages/cli/src/config/lspSettingsSchema.ts @@ -0,0 +1,38 @@ +import type { JSONSchema7 } from 'json-schema'; + +export const lspSettingsSchema: JSONSchema7 = { + type: 'object', + properties: { + 'lsp.enabled': { + type: 'boolean', + default: true, + description: '启用 LSP 语言服务器协议支持' + }, + 'lsp.allowed': { + type: 'array', + items: { + type: 'string' + }, + default: [], + description: '允许运行的 LSP 服务器列表' + }, + 'lsp.excluded': { + type: 'array', + items: { + type: 'string' + }, + default: [], + description: '禁止运行的 LSP 服务器列表' + }, + 'lsp.autoDetect': { + type: 'boolean', + default: true, + description: '自动检测项目语言并启动相应 LSP 服务器' + }, + 'lsp.serverTimeout': { + type: 'number', + default: 10000, + description: 'LSP 服务器启动超时时间(毫秒)' + } + } +}; \ No newline at end of file diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index ae29074b2..1f49fadd4 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -160,6 +160,34 @@ export function getSystemDefaultsPath(): string { ); } +function getVsCodeSettingsPath(workspaceDir: string): string { + return path.join(workspaceDir, '.vscode', 'settings.json'); +} + +function loadVsCodeSettings(workspaceDir: string): Settings { + const vscodeSettingsPath = getVsCodeSettingsPath(workspaceDir); + try { + if (fs.existsSync(vscodeSettingsPath)) { + const content = fs.readFileSync(vscodeSettingsPath, 'utf-8'); + const rawSettings: unknown = JSON.parse(stripJsonComments(content)); + + if ( + typeof rawSettings !== 'object' || + rawSettings === null || + Array.isArray(rawSettings) + ) { + console.error(`VS Code settings file is not a valid JSON object: ${vscodeSettingsPath}`); + return {}; + } + + return rawSettings as Settings; + } + } catch (error: unknown) { + console.error(`Error loading VS Code settings from ${vscodeSettingsPath}:`, getErrorMessage(error)); + } + return {}; +} + export type { DnsResolutionOrder } from './settingsSchema.js'; export enum SettingScope { @@ -632,6 +660,9 @@ export function loadSettings( workspaceDir, ).getWorkspaceSettingsPath(); + // Load VS Code settings as an additional source of configuration + const vscodeSettings = loadVsCodeSettings(workspaceDir); + const loadAndMigrate = ( filePath: string, scope: SettingScope, @@ -736,6 +767,14 @@ export function loadSettings( userSettings = resolveEnvVarsInObject(userResult.settings); workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings); + // Merge VS Code settings into workspace settings (VS Code settings take precedence) + workspaceSettings = customDeepMerge( + getMergeStrategyForPath, + {}, + workspaceSettings, + vscodeSettings, + ) as Settings; + // Support legacy theme names if (userSettings.ui?.theme === 'VS') { userSettings.ui.theme = DefaultLight.name; @@ -749,11 +788,13 @@ export function loadSettings( } // For the initial trust check, we can only use user and system settings. + // We also include VS Code settings as they may contain trust-related settings const initialTrustCheckSettings = customDeepMerge( getMergeStrategyForPath, {}, systemSettings, userSettings, + vscodeSettings, // Include VS Code settings ); const isTrusted = isWorkspaceTrusted(initialTrustCheckSettings as Settings).isTrusted ?? true; @@ -767,9 +808,18 @@ export function loadSettings( isTrusted, ); + // Add VS Code settings to the temp merged settings for environment loading + // Since loadEnvironment depends on settings, we need to consider VS Code settings as well + const tempMergedSettingsWithVsCode = customDeepMerge( + getMergeStrategyForPath, + {}, + tempMergedSettings, + vscodeSettings, + ) as Settings; + // loadEnviroment depends on settings so we have to create a temp version of // the settings to avoid a cycle - loadEnvironment(tempMergedSettings); + loadEnvironment(tempMergedSettingsWithVsCode); // Create LoadedSettings first diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2fe467ba9..c392caf1f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1007,6 +1007,47 @@ const SETTINGS_SCHEMA = { }, }, }, + lsp: { + type: 'object', + label: 'LSP', + category: 'LSP', + requiresRestart: true, + default: {}, + description: 'Settings for the native Language Server Protocol integration.', + showInDialog: false, + properties: { + enabled: { + type: 'boolean', + label: 'Enable LSP', + category: 'LSP', + requiresRestart: true, + default: false, + description: + 'Enable the native LSP client to connect to language servers discovered in the workspace.', + showInDialog: false, + }, + allowed: { + type: 'array', + label: 'Allow LSP Servers', + category: 'LSP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Optional allowlist of LSP server names. If set, only matching servers will start.', + showInDialog: false, + }, + excluded: { + type: 'array', + label: 'Exclude LSP Servers', + category: 'LSP', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Optional blocklist of LSP server names that should not start.', + showInDialog: false, + }, + }, + }, useSmartEdit: { type: 'boolean', label: 'Use Smart Edit', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index b05f12453..0aeb285a0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -248,6 +248,8 @@ export async function main() { [], new ExtensionEnablementManager(ExtensionStorage.getUserExtensionsDir()), argv, + undefined, + { startLsp: false }, ); if ( diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts new file mode 100644 index 000000000..e18262ed6 --- /dev/null +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -0,0 +1,358 @@ +import * as cp from 'node:child_process'; +import * as net from 'node:net'; + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; + timer: NodeJS.Timeout; +} + +class JsonRpcConnection { + private buffer = ''; + private nextId = 1; + private disposed = false; + private pendingRequests = new Map(); + private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = []; + private requestHandlers: Array<(request: JsonRpcMessage) => Promise> = []; + + constructor( + private readonly writer: (data: string) => void, + private readonly disposer?: () => void, + ) {} + + listen(readable: NodeJS.ReadableStream): void { + readable.on('data', (chunk: Buffer) => this.handleData(chunk)); + readable.on('error', (error) => + this.disposePending( + error instanceof Error ? error : new Error(String(error)), + ), + ); + } + + send(message: JsonRpcMessage): void { + this.writeMessage(message); + } + + onNotification(handler: (notification: JsonRpcMessage) => void): void { + this.notificationHandlers.push(handler); + } + + onRequest(handler: (request: JsonRpcMessage) => Promise): void { + this.requestHandlers.push(handler); + } + + async initialize(params: unknown): Promise { + return this.sendRequest('initialize', params); + } + + async shutdown(): Promise { + try { + await this.sendRequest('shutdown', {}); + } catch (_error) { + // Ignore shutdown errors – the server may already be gone. + } finally { + this.end(); + } + } + + request(method: string, params: unknown): Promise { + return this.sendRequest(method, params); + } + + end(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.disposePending(); + this.disposer?.(); + } + + private sendRequest(method: string, params: unknown): Promise { + if (this.disposed) { + return Promise.resolve(undefined); + } + + const id = this.nextId++; + const payload: JsonRpcMessage = { + jsonrpc: '2.0', + id, + method, + params, + }; + + const requestPromise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`LSP request timeout: ${method}`)); + }, 15000); + + this.pendingRequests.set(id, { resolve, reject, timer }); + }); + + this.writeMessage(payload); + return requestPromise; + } + + private async handleServerRequest(message: JsonRpcMessage): Promise { + const handler = this.requestHandlers[this.requestHandlers.length - 1]; + if (!handler) { + this.writeMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: `Method not supported: ${message.method}`, + }, + }); + return; + } + + try { + const result = await handler(message); + this.writeMessage({ + jsonrpc: '2.0', + id: message.id, + result: result ?? null, + }); + } catch (error) { + this.writeMessage({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: (error as Error).message ?? 'Internal error', + }, + }); + } + } + + private handleData(chunk: Buffer): void { + if (this.disposed) { + return; + } + + this.buffer += chunk.toString('utf8'); + + while (true) { + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + break; + } + + const header = this.buffer.slice(0, headerEnd); + const lengthMatch = /Content-Length:\s*(\d+)/i.exec(header); + if (!lengthMatch) { + this.buffer = this.buffer.slice(headerEnd + 4); + continue; + } + + const contentLength = Number(lengthMatch[1]); + const messageStart = headerEnd + 4; + const messageEnd = messageStart + contentLength; + + if (this.buffer.length < messageEnd) { + break; + } + + const body = this.buffer.slice(messageStart, messageEnd); + this.buffer = this.buffer.slice(messageEnd); + + try { + const message = JSON.parse(body); + this.routeMessage(message); + } catch { + // ignore malformed messages + } + } + } + + private routeMessage(message: JsonRpcMessage): void { + if (typeof message?.id !== 'undefined' && !message.method) { + const pending = this.pendingRequests.get(message.id); + if (!pending) { + return; + } + clearTimeout(pending.timer); + this.pendingRequests.delete(message.id); + if (message.error) { + pending.reject( + new Error(message.error.message || 'LSP request failed'), + ); + } else { + pending.resolve(message.result); + } + return; + } + + if (message?.method && typeof message.id !== 'undefined') { + void this.handleServerRequest(message); + return; + } + + if (message?.method) { + for (const handler of this.notificationHandlers) { + try { + handler(message); + } catch { + // ignore handler errors + } + } + } + } + + private writeMessage(message: JsonRpcMessage): void { + if (this.disposed) { + return; + } + const json = JSON.stringify(message); + const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`; + this.writer(header + json); + } + + private disposePending(error?: Error): void { + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timer); + pending.reject(error ?? new Error('LSP connection closed')); + } + this.pendingRequests.clear(); + } +} + +interface LspConnection { + connection: JsonRpcConnection; + process?: cp.ChildProcess; + socket?: net.Socket; +} + +interface JsonRpcMessage { + jsonrpc: string; + id?: number | string; + method?: string; + params?: any; + result?: any; + error?: { + code: number; + message: string; + data?: any; + }; +} + +export class LspConnectionFactory { + /** + * 创建基于 stdio 的 LSP 连接 + */ + static async createStdioConnection( + command: string, + args: string[], + options?: cp.SpawnOptions, + ): Promise { + return new Promise((resolve, reject) => { + const spawnOptions: cp.SpawnOptions = { + stdio: 'pipe', + ...options, + }; + const processInstance = cp.spawn(command, args, spawnOptions); + + const timeoutId = setTimeout(() => { + reject(new Error('LSP server spawn timeout')); + if (!processInstance.killed) { + processInstance.kill(); + } + }, 10000); + + processInstance.once('error', (error) => { + clearTimeout(timeoutId); + reject(new Error(`Failed to spawn LSP server: ${error.message}`)); + }); + + processInstance.once('spawn', () => { + clearTimeout(timeoutId); + + if (!processInstance.stdout || !processInstance.stdin) { + reject(new Error('LSP server stdio not available')); + return; + } + + const connection = new JsonRpcConnection( + (payload) => processInstance.stdin?.write(payload), + () => processInstance.stdin?.end(), + ); + + connection.listen(processInstance.stdout); + processInstance.once('exit', () => connection.end()); + processInstance.once('close', () => connection.end()); + + resolve({ + connection, + process: processInstance, + }); + }); + }); + } + + /** + * 创建基于 TCP 的 LSP 连接 + */ + static async createTcpConnection( + host: string, + port: number, + ): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + + const timeoutId = setTimeout(() => { + reject(new Error('LSP server connection timeout')); + socket.destroy(); + }, 10000); + + const onError = (error: Error) => { + clearTimeout(timeoutId); + reject(new Error(`Failed to connect to LSP server: ${error.message}`)); + }; + + socket.once('error', onError); + + socket.on('connect', () => { + clearTimeout(timeoutId); + socket.off('error', onError); + + const connection = new JsonRpcConnection( + (payload) => socket.write(payload), + () => socket.destroy(), + ); + connection.listen(socket); + socket.once('close', () => connection.end()); + socket.once('error', () => connection.end()); + + resolve({ + connection, + socket, + }); + }); + }); + } + + /** + * 关闭 LSP 连接 + */ + static async closeConnection(lspConnection: LspConnection): Promise { + if (lspConnection.connection) { + try { + await lspConnection.connection.shutdown(); + } catch (e) { + console.warn('LSP shutdown failed:', e); + } finally { + lspConnection.connection.end(); + } + } + + if (lspConnection.process && !lspConnection.process.killed) { + lspConnection.process.kill(); + } + + if (lspConnection.socket && !lspConnection.socket.destroyed) { + lspConnection.socket.destroy(); + } + } +} diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts new file mode 100644 index 000000000..1fadd620a --- /dev/null +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { NativeLspService } from './NativeLspService.js'; +import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; +import { WorkspaceContext } from '@qwen-code/qwen-code-core'; +import { EventEmitter } from 'events'; +import { FileDiscoveryService } from '@qwen-code/qwen-code-core'; +import { IdeContextStore } from '@qwen-code/qwen-code-core'; + +// 模拟依赖项 +class MockConfig { + rootPath = '/test/workspace'; + + isTrustedFolder(): boolean { + return true; + } + + get(key: string) { + return undefined; + } + + getProjectRoot(): string { + return this.rootPath; + } +} + +class MockWorkspaceContext { + rootPath = '/test/workspace'; + + async fileExists(path: string): Promise { + return path.endsWith('.json') || path.includes('package.json'); + } + + async readFile(path: string): Promise { + if (path.includes('.lsp.json')) { + return JSON.stringify({ + 'typescript': { + 'command': 'typescript-language-server', + 'args': ['--stdio'], + 'transport': 'stdio' + } + }); + } + return '{}'; + } + + resolvePath(path: string): string { + return this.rootPath + '/' + path; + } + + isPathWithinWorkspace(path: string): boolean { + return true; + } + + getDirectories(): string[] { + return [this.rootPath]; + } +} + +class MockFileDiscoveryService { + async discoverFiles(root: string, options: any): Promise { + // 模拟发现一些文件 + return [ + '/test/workspace/src/index.ts', + '/test/workspace/src/utils.ts', + '/test/workspace/server.py', + '/test/workspace/main.go' + ]; + } + + shouldIgnoreFile(): boolean { + return false; + } +} + +class MockIdeContextStore { + // 模拟 IDE 上下文存储 +} + +describe('NativeLspService', () => { + let lspService: NativeLspService; + let mockConfig: MockConfig; + let mockWorkspace: MockWorkspaceContext; + let mockFileDiscovery: MockFileDiscoveryService; + let mockIdeStore: MockIdeContextStore; + let eventEmitter: EventEmitter; + + beforeEach(() => { + mockConfig = new MockConfig(); + mockWorkspace = new MockWorkspaceContext(); + mockFileDiscovery = new MockFileDiscoveryService(); + mockIdeStore = new MockIdeContextStore(); + eventEmitter = new EventEmitter(); + + lspService = new NativeLspService( + mockConfig as any, + mockWorkspace as any, + eventEmitter, + mockFileDiscovery as any, + mockIdeStore as any + ); + }); + + test('should initialize correctly', () => { + expect(lspService).toBeDefined(); + }); + + test('should detect languages from workspace files', async () => { + // 这个测试需要修改,因为我们无法直接访问私有方法 + await lspService.discoverAndPrepare(); + const status = lspService.getStatus(); + + // 检查服务是否已准备就绪 + expect(status).toBeDefined(); + }); + + test('should merge built-in presets with user configs', async () => { + await lspService.discoverAndPrepare(); + + const status = lspService.getStatus(); + // 检查服务是否已准备就绪 + expect(status).toBeDefined(); + }); +}); + +// 注意:实际的单元测试需要适当的测试框架配置 +// 这里只是一个结构示例 diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts new file mode 100644 index 000000000..aca87e3e6 --- /dev/null +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -0,0 +1,1075 @@ +import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; +import type { WorkspaceContext } from '@qwen-code/qwen-code-core'; +import type { EventEmitter } from 'events'; +import type { FileDiscoveryService } from '@qwen-code/qwen-code-core'; +import type { IdeContextStore } from '@qwen-code/qwen-code-core'; +import { LspConnectionFactory } from './LspConnectionFactory.js'; +import type { + LspLocation, + LspDefinition, + LspReference, + LspSymbolInformation, +} from '@qwen-code/qwen-code-core'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import { globSync } from 'glob'; + +// 定义 LSP 初始化选项的类型 +interface LspInitializationOptions { + [key: string]: any; +} + +// 定义 LSP 服务器配置类型 +interface LspServerConfig { + name: string; + languages: string[]; + command: string; + args: string[]; + transport: 'stdio' | 'tcp'; + initializationOptions?: LspInitializationOptions; + rootUri: string; + trustRequired?: boolean; +} + +// 定义 LSP 连接接口 +interface LspConnectionInterface { + listen: (readable: NodeJS.ReadableStream) => void; + send: (message: any) => void; + onNotification: (handler: (notification: any) => void) => void; + onRequest: (handler: (request: any) => Promise) => void; + request: (method: string, params: any) => Promise; + initialize: (params: any) => Promise; + shutdown: () => Promise; + end: () => void; +} + +// 定义 LSP 服务器状态 +type LspServerStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'READY' | 'FAILED'; + +// 定义 LSP 服务器句柄 +interface LspServerHandle { + config: LspServerConfig; + status: LspServerStatus; + connection?: LspConnectionInterface; + process?: ChildProcess; + error?: Error; + warmedUp?: boolean; +} + +interface NativeLspServiceOptions { + allowedServers?: string[]; + excludedServers?: string[]; + requireTrustedWorkspace?: boolean; + workspaceRoot?: string; +} + +export class NativeLspService { + private serverHandles: Map = new Map(); + private config: CoreConfig; + private workspaceContext: WorkspaceContext; + private fileDiscoveryService: FileDiscoveryService; + private allowedServers?: string[]; + private excludedServers?: string[]; + private requireTrustedWorkspace: boolean; + private workspaceRoot: string; + + constructor( + config: CoreConfig, + workspaceContext: WorkspaceContext, + _eventEmitter: EventEmitter, // 未使用,用下划线前缀 + fileDiscoveryService: FileDiscoveryService, + _ideContextStore: IdeContextStore, // 未使用,用下划线前缀 + options: NativeLspServiceOptions = {}, + ) { + this.config = config; + this.workspaceContext = workspaceContext; + this.fileDiscoveryService = fileDiscoveryService; + this.allowedServers = options.allowedServers?.filter(Boolean); + this.excludedServers = options.excludedServers?.filter(Boolean); + this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; + this.workspaceRoot = + options.workspaceRoot ?? (config as any).getProjectRoot(); + } + + /** + * 发现并准备 LSP 服务器 + */ + async discoverAndPrepare(): Promise { + const workspaceTrusted = this.config.isTrustedFolder(); + this.serverHandles.clear(); + + // 检查工作区是否受信任 + if (this.requireTrustedWorkspace && !workspaceTrusted) { + console.log('工作区不受信任,跳过 LSP 服务器发现'); + return; + } + + // 检测工作区中的语言 + const detectedLanguages = await this.detectLanguages(); + + // 合并配置:内置预设 + 用户 .lsp.json + 可选 cclsp 兼容转换 + const serverConfigs = await this.mergeConfigs(detectedLanguages); + + // 创建服务器句柄 + for (const config of serverConfigs) { + this.serverHandles.set(config.name, { + config, + status: 'NOT_STARTED', + }); + } + } + + /** + * 启动所有 LSP 服务器 + */ + async start(): Promise { + for (const [name, handle] of this.serverHandles) { + await this.startServer(name, handle); + } + } + + /** + * 停止所有 LSP 服务器 + */ + async stop(): Promise { + for (const [name, handle] of this.serverHandles) { + await this.stopServer(name, handle); + } + this.serverHandles.clear(); + } + + /** + * 获取 LSP 服务器状态 + */ + getStatus(): Map { + const statusMap = new Map(); + for (const [name, handle] of this.serverHandles) { + statusMap.set(name, handle.status); + } + return statusMap; + } + + /** + * Workspace symbol search across all ready LSP servers. + */ + async workspaceSymbols( + query: string, + limit = 50, + ): Promise { + const results: LspSymbolInformation[] = []; + + for (const [serverName, handle] of this.serverHandles) { + if (handle.status !== 'READY' || !handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + let response = await handle.connection.request('workspace/symbol', { + query, + }); + if ( + this.isTypescriptServer(handle) && + this.isNoProjectErrorResponse(response) + ) { + await this.warmupTypescriptServer(handle, true); + response = await handle.connection.request('workspace/symbol', { + query, + }); + } + if (!Array.isArray(response)) { + continue; + } + for (const item of response) { + const symbol = this.normalizeSymbolResult(item, serverName); + if (symbol) { + results.push(symbol); + } + if (results.length >= limit) { + return results.slice(0, limit); + } + } + } catch (error) { + console.warn(`LSP workspace/symbol failed for ${serverName}:`, error); + } + } + + return results.slice(0, limit); + } + + /** + * 跳转到定义 + */ + async definitions( + location: LspLocation, + serverName?: string, + limit = 50, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/definition', + { + textDocument: { uri: location.uri }, + position: location.range.start, + }, + ); + const candidates = Array.isArray(response) + ? response + : response + ? [response] + : []; + const definitions: LspDefinition[] = []; + for (const def of candidates) { + const normalized = this.normalizeLocationResult(def, name); + if (normalized) { + definitions.push(normalized); + if (definitions.length >= limit) { + return definitions.slice(0, limit); + } + } + } + if (definitions.length > 0) { + return definitions.slice(0, limit); + } + } catch (error) { + console.warn(`LSP textDocument/definition failed for ${name}:`, error); + } + } + + return []; + } + + /** + * 查找引用 + */ + async references( + location: LspLocation, + serverName?: string, + includeDeclaration = false, + limit = 200, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/references', + { + textDocument: { uri: location.uri }, + position: location.range.start, + context: { includeDeclaration }, + }, + ); + if (!Array.isArray(response)) { + continue; + } + const refs: LspReference[] = []; + for (const ref of response) { + const normalized = this.normalizeLocationResult(ref, name); + if (normalized) { + refs.push(normalized); + } + if (refs.length >= limit) { + return refs.slice(0, limit); + } + } + if (refs.length > 0) { + return refs.slice(0, limit); + } + } catch (error) { + console.warn(`LSP textDocument/references failed for ${name}:`, error); + } + } + + return []; + } + + /** + * 检测工作区中的编程语言 + */ + private async detectLanguages(): Promise { + const patterns = ['**/*.{js,ts,jsx,tsx,py,go,rs,java,cpp,php,rb,cs}']; + const excludePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + ]; + + const files = new Set(); + const searchRoots = this.workspaceContext.getDirectories(); + + for (const root of searchRoots) { + for (const pattern of patterns) { + try { + const matches = globSync(pattern, { + cwd: root, + ignore: excludePatterns, + absolute: true, + nodir: true, + }); + + for (const match of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(match)) { + continue; + } + files.add(match); + } + } catch (_error) { + // Ignore glob errors for missing/invalid directories + } + } + } + + // 统计不同语言的文件数量 + const languageCounts = new Map(); + for (const file of files) { + const ext = path.extname(file).slice(1).toLowerCase(); + if (ext) { + const lang = this.mapExtensionToLanguage(ext); + if (lang) { + languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1); + } + } + } + + // 也可以通过特定的配置文件来检测语言 + const rootMarkers = await this.detectRootMarkers(); + for (const marker of rootMarkers) { + const lang = this.mapMarkerToLanguage(marker); + if (lang) { + // 使用安全的数字操作避免 NaN + const currentCount = languageCounts.get(lang) || 0; + languageCounts.set(lang, currentCount + 100); // 给配置文件更高的权重 + } + } + + // 返回检测到的语言,按数量排序 + return Array.from(languageCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([lang]) => lang); + } + + /** + * 检测根目录标记文件 + */ + private async detectRootMarkers(): Promise { + const markers = new Set(); + const commonMarkers = [ + 'package.json', + 'tsconfig.json', + 'pyproject.toml', + 'go.mod', + 'Cargo.toml', + 'pom.xml', + 'build.gradle', + 'composer.json', + 'Gemfile', + 'mix.exs', + 'deno.json', + ]; + + for (const root of this.workspaceContext.getDirectories()) { + for (const marker of commonMarkers) { + try { + const fullPath = path.join(root, marker); + if (fs.existsSync(fullPath)) { + markers.add(marker); + } + } catch (_error) { + // ignore missing files + } + } + } + + return Array.from(markers); + } + + /** + * 将文件扩展名映射到编程语言 + */ + private mapExtensionToLanguage(ext: string): string | null { + const extToLang: { [key: string]: string } = { + js: 'javascript', + ts: 'typescript', + jsx: 'javascriptreact', + tsx: 'typescriptreact', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + cpp: 'cpp', + c: 'c', + php: 'php', + rb: 'ruby', + cs: 'csharp', + vue: 'vue', + svelte: 'svelte', + html: 'html', + css: 'css', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + }; + + return extToLang[ext] || null; + } + + /** + * 将根目录标记映射到编程语言 + */ + private mapMarkerToLanguage(marker: string): string | null { + const markerToLang: { [key: string]: string } = { + 'package.json': 'javascript', + 'tsconfig.json': 'typescript', + 'pyproject.toml': 'python', + 'go.mod': 'go', + 'Cargo.toml': 'rust', + 'pom.xml': 'java', + 'build.gradle': 'java', + 'composer.json': 'php', + Gemfile: 'ruby', + '*.sln': 'csharp', + 'mix.exs': 'elixir', + 'deno.json': 'deno', + }; + + return markerToLang[marker] || null; + } + + private normalizeLocationResult( + item: any, + serverName: string, + ): LspReference | null { + const uri = item?.uri ?? item?.targetUri ?? item?.target?.uri; + const range = + item?.range ?? + item?.targetSelectionRange ?? + item?.targetRange ?? + item?.target?.range; + + if (!uri || !range?.start || !range?.end) { + return null; + } + + return { + uri, + range: { + start: { + line: Number(range.start.line ?? 0), + character: Number(range.start.character ?? 0), + }, + end: { + line: Number(range.end.line ?? 0), + character: Number(range.end.character ?? 0), + }, + }, + serverName, + }; + } + + private normalizeSymbolResult( + item: any, + serverName: string, + ): LspSymbolInformation | null { + const location = item?.location ?? item?.target ?? item; + const range = + location?.range ?? location?.targetRange ?? item?.range ?? undefined; + + if (!location?.uri || !range?.start || !range?.end) { + return null; + } + + return { + name: item?.name ?? item?.label ?? 'symbol', + kind: item?.kind ? String(item.kind) : undefined, + containerName: item?.containerName ?? item?.container, + location: { + uri: location.uri, + range: { + start: { + line: Number(range.start.line ?? 0), + character: Number(range.start.character ?? 0), + }, + end: { + line: Number(range.end.line ?? 0), + character: Number(range.end.character ?? 0), + }, + }, + }, + serverName, + }; + } + + /** + * 合并配置:内置预设 + 用户配置 + 兼容层 + */ + private async mergeConfigs( + detectedLanguages: string[], + ): Promise { + // 内置预设配置 + const presets = this.getBuiltInPresets(detectedLanguages); + + // 用户 .lsp.json 配置(如果存在) + const userConfigs = await this.loadUserConfigs(); + + // 合并配置,用户配置优先级更高 + const mergedConfigs = [...presets]; + + for (const userConfig of userConfigs) { + // 查找是否有同名的预设配置,如果有则替换 + const existingIndex = mergedConfigs.findIndex( + (c) => c.name === userConfig.name, + ); + if (existingIndex !== -1) { + mergedConfigs[existingIndex] = userConfig; + } else { + mergedConfigs.push(userConfig); + } + } + + return mergedConfigs; + } + + /** + * 获取内置预设配置 + */ + private getBuiltInPresets(detectedLanguages: string[]): LspServerConfig[] { + const presets: LspServerConfig[] = []; + + // 将目录路径转换为文件 URI 格式 + const rootUri = pathToFileURL(this.workspaceRoot).toString(); + + // 根据检测到的语言生成对应的 LSP 服务器配置 + if ( + detectedLanguages.includes('typescript') || + detectedLanguages.includes('javascript') + ) { + presets.push({ + name: 'typescript-language-server', + languages: [ + 'typescript', + 'javascript', + 'typescriptreact', + 'javascriptreact', + ], + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + initializationOptions: {}, + rootUri: rootUri, + trustRequired: true, + }); + } + + if (detectedLanguages.includes('python')) { + presets.push({ + name: 'pylsp', + languages: ['python'], + command: 'pylsp', + args: [], + transport: 'stdio', + initializationOptions: {}, + rootUri: rootUri, + trustRequired: true, + }); + } + + if (detectedLanguages.includes('go')) { + presets.push({ + name: 'gopls', + languages: ['go'], + command: 'gopls', + args: [], + transport: 'stdio', + initializationOptions: {}, + rootUri: rootUri, + trustRequired: true, + }); + } + + // 可以根据需要添加更多语言的预设配置 + + return presets; + } + + /** + * 加载用户 .lsp.json 配置 + */ + private async loadUserConfigs(): Promise { + const configs: LspServerConfig[] = []; + + try { + const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); + if (fs.existsSync(lspConfigPath)) { + const configContent = fs.readFileSync(lspConfigPath, 'utf-8'); + const userConfig = JSON.parse(configContent); + + // 验证并转换用户配置为内部格式 + if (userConfig && typeof userConfig === 'object') { + for (const [langId, serverSpec] of Object.entries(userConfig) as [ + string, + any, + ]) { + // 转换为文件 URI 格式 + const rootUri = pathToFileURL(this.workspaceRoot).toString(); + + // 验证 command 不为 undefined + if (!serverSpec.command) { + console.warn(`LSP 配置错误: ${langId} 缺少 command 属性`); + continue; + } + + const serverConfig: LspServerConfig = { + name: serverSpec.command, + languages: [langId], + command: serverSpec.command, + args: serverSpec.args || [], + transport: serverSpec.transport || 'stdio', + initializationOptions: serverSpec.initializationOptions, + rootUri: rootUri, + trustRequired: serverSpec.trustRequired ?? true, + }; + + configs.push(serverConfig); + } + } + } + } catch (e) { + console.warn('加载用户 .lsp.json 配置失败:', e); + } + + return configs; + } + + /** + * 启动单个 LSP 服务器 + */ + private async startServer( + name: string, + handle: LspServerHandle, + ): Promise { + if (this.excludedServers?.includes(name)) { + console.log(`LSP 服务器 ${name} 在排除列表中,跳过启动`); + handle.status = 'FAILED'; + return; + } + + if (this.allowedServers && !this.allowedServers.includes(name)) { + console.log(`LSP 服务器 ${name} 不在允许列表中,跳过启动`); + handle.status = 'FAILED'; + return; + } + + const workspaceTrusted = this.config.isTrustedFolder(); + if ( + (this.requireTrustedWorkspace || handle.config.trustRequired) && + !workspaceTrusted + ) { + console.log(`LSP 服务器 ${name} 需要受信任的工作区,跳过启动`); + handle.status = 'FAILED'; + return; + } + + // 请求用户确认 + const consent = await this.requestUserConsent( + name, + handle.config, + workspaceTrusted, + ); + if (!consent) { + console.log(`用户拒绝启动 LSP 服务器 ${name}`); + handle.status = 'FAILED'; + return; + } + + // 检查命令是否存在 + if (!(await this.commandExists(handle.config.command))) { + console.warn(`LSP 服务器 ${name} 的命令不存在: ${handle.config.command}`); + handle.status = 'FAILED'; + return; + } + + // 检查路径安全性 + if (!this.isPathSafe(handle.config.command, (this.config as any).cwd)) { + console.warn( + `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } + + try { + handle.status = 'IN_PROGRESS'; + + // 创建 LSP 连接 + const connection = await this.createLspConnection(handle.config); + handle.connection = connection.connection; + handle.process = connection.process; + + // 初始化 LSP 服务器 + await this.initializeLspServer(connection, handle.config); + + handle.status = 'READY'; + console.log(`LSP 服务器 ${name} 启动成功`); + } catch (error) { + handle.status = 'FAILED'; + handle.error = error as Error; + console.error(`LSP 服务器 ${name} 启动失败:`, error); + } + } + + /** + * 停止单个 LSP 服务器 + */ + private async stopServer( + name: string, + handle: LspServerHandle, + ): Promise { + if (handle.connection) { + try { + await handle.connection.shutdown(); + handle.connection.end(); + } catch (error) { + console.error(`关闭 LSP 服务器 ${name} 时出错:`, error); + } + } else if (handle.process && !handle.process.killed) { + handle.process.kill(); + } + handle.connection = undefined; + handle.process = undefined; + handle.status = 'NOT_STARTED'; + } + + /** + * 创建 LSP 连接 + */ + private async createLspConnection(config: LspServerConfig): Promise<{ + connection: LspConnectionInterface; + process: ChildProcess; + shutdown: () => Promise; + exit: () => void; + initialize: (params: any) => Promise; + }> { + if (config.transport === 'stdio') { + // 修复:使用 cwd 作为 cwd 而不是 rootUri + const lspConnection = await LspConnectionFactory.createStdioConnection( + config.command, + config.args, + { cwd: this.workspaceRoot }, + ); + + return { + connection: lspConnection.connection as LspConnectionInterface, + process: lspConnection.process as ChildProcess, + shutdown: async () => { + await lspConnection.connection.shutdown(); + }, + exit: () => { + if (lspConnection.process && !lspConnection.process.killed) { + (lspConnection.process as ChildProcess).kill(); + } + lspConnection.connection.end(); + }, + initialize: async (params: any) => { + return lspConnection.connection.initialize(params); + }, + }; + } else if (config.transport === 'tcp') { + // 如果需要 TCP 支持,可以扩展此部分 + throw new Error('TCP transport not yet implemented'); + } else { + throw new Error(`Unsupported transport: ${config.transport}`); + } + } + + /** + * 初始化 LSP 服务器 + */ + private async initializeLspServer( + connection: Awaited>, + config: LspServerConfig, + ): Promise { + const workspaceFolder = { + name: path.basename(this.workspaceRoot) || this.workspaceRoot, + uri: config.rootUri, + }; + + const initializeParams = { + processId: process.pid, + rootUri: config.rootUri, + rootPath: this.workspaceRoot, + workspaceFolders: [workspaceFolder], + capabilities: { + textDocument: { + completion: { dynamicRegistration: true }, + hover: { dynamicRegistration: true }, + definition: { dynamicRegistration: true }, + references: { dynamicRegistration: true }, + documentSymbol: { dynamicRegistration: true }, + codeAction: { dynamicRegistration: true }, + }, + workspace: { + workspaceFolders: { supported: true }, + }, + }, + initializationOptions: config.initializationOptions, + }; + + await connection.initialize(initializeParams); + + // Send initialized notification and workspace folders change to help servers (e.g. tsserver) + // create projects in the correct workspace. + connection.connection.send({ + jsonrpc: '2.0', + method: 'initialized', + params: {}, + }); + connection.connection.send({ + jsonrpc: '2.0', + method: 'workspace/didChangeWorkspaceFolders', + params: { + event: { + added: [workspaceFolder], + removed: [], + }, + }, + }); + + // Warm up TypeScript server by opening a workspace file so it can create a project. + if (config.name.includes('typescript')) { + try { + const tsFile = this.findFirstTypescriptFile(); + if (tsFile) { + const uri = pathToFileURL(tsFile).toString(); + const languageId = tsFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript'; + const text = fs.readFileSync(tsFile, 'utf-8'); + connection.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + } + } catch (error) { + console.warn('TypeScript LSP warm-up failed:', error); + } + } + } + + /** + * 检查命令是否存在 + */ + private async commandExists(command: string): Promise { + // 实现命令存在性检查 + return new Promise((resolve) => { + let settled = false; + const child = spawn(command, ['--version'], { + stdio: ['ignore', 'ignore', 'ignore'], + cwd: this.workspaceRoot, + }); + + child.on('error', () => { + settled = true; + resolve(false); + }); + + child.on('exit', (code) => { + if (settled) { + return; + } + // 如果命令存在,通常会返回 0 或其他非错误码 + // 有些命令的 --version 选项可能返回非 0,但不会抛出错误 + resolve(code !== 127); // 127 通常表示命令不存在 + }); + + // 设置超时,避免长时间等待 + setTimeout(() => { + settled = true; + child.kill(); + resolve(false); + }, 2000); + }); + } + + /** + * 检查路径安全性 + */ + private isPathSafe(command: string, workspacePath: string): boolean { + // 检查命令是否在工作区路径内,或者是否在系统 PATH 中 + // 允许全局安装的命令(如在 PATH 中的命令) + // 只阻止显式指定工作区外绝对路径的情况 + if (path.isAbsolute(command)) { + // 如果是绝对路径,检查是否在工作区路径内 + const resolvedPath = path.resolve(command); + const resolvedWorkspacePath = path.resolve(workspacePath); + return ( + resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || + resolvedPath === resolvedWorkspacePath + ); + } + // 相对路径和命令名(在 PATH 中查找)认为是安全的 + // 但需要确保相对路径不指向工作区外 + const resolvedPath = path.resolve(workspacePath, command); + const resolvedWorkspacePath = path.resolve(workspacePath); + return ( + resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || + resolvedPath === resolvedWorkspacePath + ); + } + + /** + * 请求用户确认启动 LSP 服务器 + */ + private async requestUserConsent( + serverName: string, + serverConfig: LspServerConfig, + workspaceTrusted: boolean, + ): Promise { + if (workspaceTrusted) { + return true; // 在受信任工作区中自动允许 + } + + if (this.requireTrustedWorkspace || serverConfig.trustRequired) { + console.log( + `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command})`, + ); + return false; + } + + console.log( + `未受信任的工作区,LSP 服务器 ${serverName} 标记为 trustRequired=false,将谨慎尝试启动`, + ); + return true; + } + + /** + * Find a representative TypeScript/JavaScript file to warm up tsserver. + */ + private findFirstTypescriptFile(): string | undefined { + const patterns = ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx']; + const excludePatterns = [ + '**/node_modules/**', + '**/.git/**', + '**/dist/**', + '**/build/**', + ]; + + for (const root of this.workspaceContext.getDirectories()) { + for (const pattern of patterns) { + try { + const matches = globSync(pattern, { + cwd: root, + ignore: excludePatterns, + absolute: true, + nodir: true, + }); + for (const file of matches) { + if (this.fileDiscoveryService.shouldIgnoreFile(file)) { + continue; + } + return file; + } + } catch (_error) { + // ignore glob errors + } + } + } + + return undefined; + } + + private isTypescriptServer(handle: LspServerHandle): boolean { + return handle.config.name.includes('typescript'); + } + + private isNoProjectErrorResponse(response: any): boolean { + if (!response) { + return false; + } + const message = + typeof response === 'string' + ? response + : typeof response?.message === 'string' + ? response.message + : ''; + return message.includes('No Project'); + } + + /** + * Ensure tsserver has at least one file open so navto/navtree requests succeed. + */ + private async warmupTypescriptServer( + handle: LspServerHandle, + force = false, + ): Promise { + if (!handle.connection || !this.isTypescriptServer(handle)) { + return; + } + if (handle.warmedUp && !force) { + return; + } + const tsFile = this.findFirstTypescriptFile(); + if (!tsFile) { + return; + } + handle.warmedUp = true; + const uri = pathToFileURL(tsFile).toString(); + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : tsFile.endsWith('.jsx') + ? 'javascriptreact' + : tsFile.endsWith('.js') + ? 'javascript' + : 'typescript'; + try { + const text = fs.readFileSync(tsFile, 'utf-8'); + handle.connection.send({ + jsonrpc: '2.0', + method: 'textDocument/didOpen', + params: { + textDocument: { + uri, + languageId, + version: 1, + text, + }, + }, + }); + // Give tsserver a moment to build the project. + await new Promise((resolve) => setTimeout(resolve, 150)); + } catch (error) { + console.warn('TypeScript server warm-up failed:', error); + } + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..33231de94 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -61,6 +61,10 @@ import { ToolRegistry } from '../tools/tool-registry.js'; import { WebFetchTool } from '../tools/web-fetch.js'; import { WebSearchTool } from '../tools/web-search/index.js'; import { WriteFileTool } from '../tools/write-file.js'; +import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js'; +import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js'; +import { LspFindReferencesTool } from '../tools/lsp-find-references.js'; +import type { LspClient } from '../lsp/types.js'; // Other modules import { ideContextStore } from '../ide/ideContext.js'; @@ -281,6 +285,12 @@ export interface ConfigParameters { toolCallCommand?: string; mcpServerCommand?: string; mcpServers?: Record; + lsp?: { + enabled?: boolean; + allowed?: string[]; + excluded?: string[]; + }; + lspClient?: LspClient; userMemory?: string; geminiMdFileCount?: number; approvalMode?: ApprovalMode; @@ -413,6 +423,10 @@ export class Config { private readonly toolCallCommand: string | undefined; private readonly mcpServerCommand: string | undefined; private mcpServers: Record | undefined; + private readonly lspEnabled: boolean; + private readonly lspAllowed?: string[]; + private readonly lspExcluded?: string[]; + private lspClient?: LspClient; private sessionSubagents: SubagentConfig[]; private userMemory: string; private sdkMode: boolean; @@ -521,6 +535,10 @@ export class Config { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.lspEnabled = params.lsp?.enabled ?? false; + this.lspAllowed = params.lsp?.allowed?.filter(Boolean); + this.lspExcluded = params.lsp?.excluded?.filter(Boolean); + this.lspClient = params.lspClient; this.sessionSubagents = params.sessionSubagents ?? []; this.sdkMode = params.sdkMode ?? false; this.userMemory = params.userMemory ?? ''; @@ -896,6 +914,32 @@ export class Config { this.mcpServers = { ...this.mcpServers, ...servers }; } + isLspEnabled(): boolean { + return this.lspEnabled; + } + + getLspAllowed(): string[] | undefined { + return this.lspAllowed; + } + + getLspExcluded(): string[] | undefined { + return this.lspExcluded; + } + + getLspClient(): LspClient | undefined { + return this.lspClient; + } + + /** + * Allows wiring an LSP client after Config construction but before initialize(). + */ + setLspClient(client: LspClient | undefined): void { + if (this.initialized) { + throw new Error('Cannot set LSP client after initialization'); + } + this.lspClient = client; + } + getSessionSubagents(): SubagentConfig[] { return this.sessionSubagents; } @@ -1403,6 +1447,11 @@ export class Config { if (this.getWebSearchConfig()) { registerCoreTool(WebSearchTool, this); } + if (this.isLspEnabled() && this.getLspClient()) { + registerCoreTool(LspGoToDefinitionTool, this); + registerCoreTool(LspFindReferencesTool, this); + registerCoreTool(LspWorkspaceSymbolTool, this); + } await registry.discoverAllTools(); console.debug('ToolRegistry created', registry.getAllToolNames()); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 56680403b..2ec73e236 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -85,6 +85,7 @@ export * from './skills/index.js'; // Export prompt logic export * from './prompts/mcp-prompts.js'; +export * from './lsp/types.js'; // Export specific tool logic export * from './tools/read-file.js'; @@ -99,6 +100,8 @@ export * from './tools/memoryTool.js'; export * from './tools/shell.js'; export * from './tools/web-search/index.js'; export * from './tools/read-many-files.js'; +export * from './tools/lsp-go-to-definition.js'; +export * from './tools/lsp-find-references.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-client-manager.js'; export * from './tools/mcp-tool.js'; diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts new file mode 100644 index 000000000..2a412d660 --- /dev/null +++ b/packages/core/src/lsp/types.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface LspPosition { + line: number; + character: number; +} + +export interface LspRange { + start: LspPosition; + end: LspPosition; +} + +export interface LspLocation { + uri: string; + range: LspRange; +} + +export interface LspLocationWithServer extends LspLocation { + serverName?: string; +} + +export interface LspSymbolInformation { + name: string; + kind?: string; + location: LspLocation; + containerName?: string; + serverName?: string; +} + +export interface LspReference extends LspLocationWithServer {} + +export interface LspDefinition extends LspLocationWithServer {} + +export interface LspClient { + workspaceSymbols( + query: string, + limit?: number, + ): Promise; + definitions( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise; + references( + location: LspLocation, + serverName?: string, + includeDeclaration?: boolean, + limit?: number, + ): Promise; +} diff --git a/packages/core/src/tools/lsp-find-references.ts b/packages/core/src/tools/lsp-find-references.ts new file mode 100644 index 000000000..078586e49 --- /dev/null +++ b/packages/core/src/tools/lsp-find-references.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import type { + LspClient, + LspLocation, + LspReference, +} from '../lsp/types.js'; + +export interface LspFindReferencesParams { + /** + * Symbol name to resolve if a file/position is not provided. + */ + symbol?: string; + /** + * File path (absolute or workspace-relative). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + file?: string; + /** + * File URI (e.g., file:///path/to/file). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + uri?: string; + /** + * 1-based line number when targeting a specific file location. + */ + line?: number; + /** + * 1-based character/column number when targeting a specific file location. + */ + character?: number; + /** + * Whether to include the declaration in results (default: false). + */ + includeDeclaration?: boolean; + /** + * Optional server name override. + */ + serverName?: string; + /** + * Optional maximum number of results. + */ + limit?: number; +} + +type ResolvedTarget = + | { + location: LspLocation; + description: string; + serverName?: string; + fromSymbol: boolean; + } + | { error: string }; + +class LspFindReferencesInvocation extends BaseToolInvocation< + LspFindReferencesParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: LspFindReferencesParams, + ) { + super(params); + } + + getDescription(): string { + if (this.params.symbol) { + return `LSP find-references(查引用) for symbol "${this.params.symbol}"`; + } + if (this.params.file && this.params.line !== undefined) { + return `LSP find-references(查引用) at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`; + } + if (this.params.uri && this.params.line !== undefined) { + return `LSP find-references(查引用) at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`; + } + return 'LSP find-references(查引用)'; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = + 'LSP find-references is unavailable (LSP disabled or not initialized).'; + return { llmContent: message, returnDisplay: message }; + } + + const target = await this.resolveTarget(client); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 50; + let references: LspReference[] = []; + try { + references = await client.references( + target.location, + target.serverName, + this.params.includeDeclaration ?? false, + limit, + ); + } catch (error) { + const message = `LSP find-references failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!references.length) { + const message = `No references found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = references.slice(0, limit).map((reference, index) => { + return `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`; + }); + + const heading = `References for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async resolveTarget( + client: Pick, + ): Promise { + const workspaceRoot = this.config.getProjectRoot(); + const lineProvided = typeof this.params.line === 'number'; + const character = this.params.character ?? 1; + + if ((this.params.file || this.params.uri) && lineProvided) { + const uri = this.resolveUri(workspaceRoot); + if (!uri) { + return { + error: + 'A valid file path or URI is required when specifying a line/character.', + }; + } + const position = { + line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)), + character: Math.max(0, Math.floor(character - 1)), + }; + const location: LspLocation = { + uri, + range: { start: position, end: position }, + }; + const description = this.formatLocation( + { ...location, serverName: this.params.serverName }, + workspaceRoot, + ); + return { + location, + description, + serverName: this.params.serverName, + fromSymbol: false, + }; + } + + if (this.params.symbol) { + try { + const symbols = await client.workspaceSymbols(this.params.symbol, 5); + if (!symbols.length) { + return { + error: `No symbols found for query "${this.params.symbol}".`, + }; + } + const top = symbols[0]; + return { + location: top.location, + description: `symbol "${this.params.symbol}"`, + serverName: this.params.serverName ?? top.serverName, + fromSymbol: true, + }; + } catch (error) { + return { + error: `Workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`, + }; + } + } + + return { + error: + 'Provide a symbol name or a file plus line (and optional character) to use find-references.', + }; + } + + private resolveUri(workspaceRoot: string): string | null { + if (this.params.uri) { + if ( + this.params.uri.startsWith('file://') || + this.params.uri.includes('://') + ) { + return this.params.uri; + } + const absoluteUriPath = path.isAbsolute(this.params.uri) + ? this.params.uri + : path.resolve(workspaceRoot, this.params.uri); + return pathToFileURL(absoluteUriPath).toString(); + } + + if (this.params.file) { + const absolutePath = path.isAbsolute(this.params.file) + ? this.params.file + : path.resolve(workspaceRoot, this.params.file); + return pathToFileURL(absolutePath).toString(); + } + + return null; + } + + private formatLocation( + location: LspReference | (LspLocation & { serverName?: string }), + workspaceRoot: string, + ): string { + const start = location.range.start; + let filePath = location.uri; + + if (filePath.startsWith('file://')) { + filePath = fileURLToPath(filePath); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + + const serverSuffix = + location.serverName && location.serverName !== '' + ? ` [${location.serverName}]` + : ''; + + return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`; + } +} + +export class LspFindReferencesTool extends BaseDeclarativeTool< + LspFindReferencesParams, + ToolResult +> { + static readonly Name = ToolNames.LSP_FIND_REFERENCES; + + constructor(private readonly config: Config) { + super( + LspFindReferencesTool.Name, + ToolDisplayNames.LSP_FIND_REFERENCES, + 'Use LSP find-references for a symbol or a specific file location(查引用,优先于 grep 搜索)。', + Kind.Other, + { + type: 'object', + properties: { + symbol: { + type: 'string', + description: + 'Symbol name to resolve when a file/position is not provided.', + }, + file: { + type: 'string', + description: + 'File path (absolute or workspace-relative). Requires `line`.', + }, + uri: { + type: 'string', + description: + 'File URI (file:///...). Requires `line` when provided.', + }, + line: { + type: 'number', + description: '1-based line number for the target location.', + }, + character: { + type: 'number', + description: + '1-based character/column number for the target location.', + }, + includeDeclaration: { + type: 'boolean', + description: + 'Include the declaration itself when looking up references.', + }, + serverName: { + type: 'string', + description: 'Optional LSP server name to target.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + }, + false, + false, + ); + } + + protected createInvocation( + params: LspFindReferencesParams, + ): ToolInvocation { + return new LspFindReferencesInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/lsp-go-to-definition.ts b/packages/core/src/tools/lsp-go-to-definition.ts new file mode 100644 index 000000000..cfbc92d32 --- /dev/null +++ b/packages/core/src/tools/lsp-go-to-definition.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import type { + LspClient, + LspDefinition, + LspLocation, +} from '../lsp/types.js'; + +export interface LspGoToDefinitionParams { + /** + * Symbol name to resolve if a file/position is not provided. + */ + symbol?: string; + /** + * File path (absolute or workspace-relative). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + file?: string; + /** + * File URI (e.g., file:///path/to/file). + * Use together with `line` (1-based) and optional `character` (1-based). + */ + uri?: string; + /** + * 1-based line number when targeting a specific file location. + */ + line?: number; + /** + * 1-based character/column number when targeting a specific file location. + */ + character?: number; + /** + * Optional server name override. + */ + serverName?: string; + /** + * Optional maximum number of results. + */ + limit?: number; +} + +type ResolvedTarget = + | { + location: LspLocation; + description: string; + serverName?: string; + fromSymbol: boolean; + } + | { error: string }; + +class LspGoToDefinitionInvocation extends BaseToolInvocation< + LspGoToDefinitionParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: LspGoToDefinitionParams, + ) { + super(params); + } + + getDescription(): string { + if (this.params.symbol) { + return `LSP go-to-definition(跳转定义) for symbol "${this.params.symbol}"`; + } + if (this.params.file && this.params.line !== undefined) { + return `LSP go-to-definition(跳转定义) at ${this.params.file}:${this.params.line}:${this.params.character ?? 1}`; + } + if (this.params.uri && this.params.line !== undefined) { + return `LSP go-to-definition(跳转定义) at ${this.params.uri}:${this.params.line}:${this.params.character ?? 1}`; + } + return 'LSP go-to-definition(跳转定义)'; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = + 'LSP go-to-definition is unavailable (LSP disabled or not initialized).'; + return { llmContent: message, returnDisplay: message }; + } + + const target = await this.resolveTarget(client); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let definitions: LspDefinition[] = []; + try { + definitions = await client.definitions( + target.location, + target.serverName, + limit, + ); + } catch (error) { + const message = `LSP go-to-definition failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + // Fallback to the resolved symbol location if the server does not return definitions. + if (!definitions.length && target.fromSymbol) { + definitions = [ + { + ...target.location, + serverName: target.serverName, + }, + ]; + } + + if (!definitions.length) { + const message = `No definitions found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = definitions.slice(0, limit).map((definition, index) => { + return `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`; + }); + + const heading = `Definitions for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async resolveTarget( + client: Pick, + ): Promise { + const workspaceRoot = this.config.getProjectRoot(); + const lineProvided = typeof this.params.line === 'number'; + const character = this.params.character ?? 1; + + if ((this.params.file || this.params.uri) && lineProvided) { + const uri = this.resolveUri(workspaceRoot); + if (!uri) { + return { + error: + 'A valid file path or URI is required when specifying a line/character.', + }; + } + const position = { + line: Math.max(0, Math.floor((this.params.line ?? 1) - 1)), + character: Math.max(0, Math.floor(character - 1)), + }; + const location: LspLocation = { + uri, + range: { start: position, end: position }, + }; + const description = this.formatLocation( + { ...location, serverName: this.params.serverName }, + workspaceRoot, + ); + return { + location, + description, + serverName: this.params.serverName, + fromSymbol: false, + }; + } + + if (this.params.symbol) { + try { + const symbols = await client.workspaceSymbols(this.params.symbol, 5); + if (!symbols.length) { + return { + error: `No symbols found for query "${this.params.symbol}".`, + }; + } + const top = symbols[0]; + return { + location: top.location, + description: `symbol "${this.params.symbol}"`, + serverName: this.params.serverName ?? top.serverName, + fromSymbol: true, + }; + } catch (error) { + return { + error: `Workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`, + }; + } + } + + return { + error: + 'Provide a symbol name or a file plus line (and optional character) to use go-to-definition.', + }; + } + + private resolveUri(workspaceRoot: string): string | null { + if (this.params.uri) { + if ( + this.params.uri.startsWith('file://') || + this.params.uri.includes('://') + ) { + return this.params.uri; + } + const absoluteUriPath = path.isAbsolute(this.params.uri) + ? this.params.uri + : path.resolve(workspaceRoot, this.params.uri); + return pathToFileURL(absoluteUriPath).toString(); + } + + if (this.params.file) { + const absolutePath = path.isAbsolute(this.params.file) + ? this.params.file + : path.resolve(workspaceRoot, this.params.file); + return pathToFileURL(absolutePath).toString(); + } + + return null; + } + + private formatLocation( + location: LspDefinition | (LspLocation & { serverName?: string }), + workspaceRoot: string, + ): string { + const start = location.range.start; + let filePath = location.uri; + + if (filePath.startsWith('file://')) { + filePath = fileURLToPath(filePath); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + + const serverSuffix = + location.serverName && location.serverName !== '' + ? ` [${location.serverName}]` + : ''; + + return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`; + } +} + +export class LspGoToDefinitionTool extends BaseDeclarativeTool< + LspGoToDefinitionParams, + ToolResult +> { + static readonly Name = ToolNames.LSP_GO_TO_DEFINITION; + + constructor(private readonly config: Config) { + super( + LspGoToDefinitionTool.Name, + ToolDisplayNames.LSP_GO_TO_DEFINITION, + 'Use LSP go-to-definition for a symbol or a specific file location(跳转定义,优先于 grep 搜索)。', + Kind.Other, + { + type: 'object', + properties: { + symbol: { + type: 'string', + description: + 'Symbol name to resolve when a file/position is not provided.', + }, + file: { + type: 'string', + description: + 'File path (absolute or workspace-relative). Requires `line`.', + }, + uri: { + type: 'string', + description: + 'File URI (file:///...). Requires `line` when provided.', + }, + line: { + type: 'number', + description: '1-based line number for the target location.', + }, + character: { + type: 'number', + description: + '1-based character/column number for the target location.', + }, + serverName: { + type: 'string', + description: 'Optional LSP server name to target.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + }, + false, + false, + ); + } + + protected createInvocation( + params: LspGoToDefinitionParams, + ): ToolInvocation { + return new LspGoToDefinitionInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/lsp-workspace-symbol.ts b/packages/core/src/tools/lsp-workspace-symbol.ts new file mode 100644 index 000000000..be016a02d --- /dev/null +++ b/packages/core/src/tools/lsp-workspace-symbol.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import type { LspSymbolInformation } from '../lsp/types.js'; + +export interface LspWorkspaceSymbolParams { + /** + * Query string to search symbols (e.g., function or class name). + */ + query: string; + /** + * Maximum number of results to return. + */ + limit?: number; +} + +class LspWorkspaceSymbolInvocation extends BaseToolInvocation< + LspWorkspaceSymbolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: LspWorkspaceSymbolParams, + ) { + super(params); + } + + getDescription(): string { + return `LSP workspace symbol search(按名称找定义/实现/引用) for "${this.params.query}"`; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = + 'LSP workspace symbol search is unavailable (LSP disabled or not initialized).'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 20; + let symbols: LspSymbolInformation[] = []; + try { + symbols = await client.workspaceSymbols(this.params.query, limit); + } catch (error) { + const message = `LSP workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!symbols.length) { + const message = `No symbols found for query "${this.params.query}".`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = symbols.slice(0, limit).map((symbol, index) => { + const location = this.formatLocation(symbol, workspaceRoot); + const serverSuffix = symbol.serverName + ? ` [${symbol.serverName}]` + : ''; + const kind = symbol.kind ? ` (${symbol.kind})` : ''; + const container = symbol.containerName + ? ` in ${symbol.containerName}` + : ''; + return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`; + }); + + const heading = `Found ${Math.min(symbols.length, limit)} of ${ + symbols.length + } symbols for query "${this.params.query}":`; + + let referenceSection = ''; + const topSymbol = symbols[0]; + if (topSymbol) { + try { + const referenceLimit = Math.min(20, Math.max(limit, 5)); + const references = await client.references( + topSymbol.location, + topSymbol.serverName, + false, + referenceLimit, + ); + if (references.length > 0) { + const refLines = references.map((ref, index) => { + const location = this.formatLocation( + { location: ref, name: '', kind: undefined }, + workspaceRoot, + ); + const serverSuffix = ref.serverName + ? ` [${ref.serverName}]` + : ''; + return `${index + 1}. ${location}${serverSuffix}`; + }); + referenceSection = [ + '', + `References for top match (${topSymbol.name}):`, + ...refLines, + ].join('\n'); + } + } catch (error) { + referenceSection = `\nReferences lookup failed: ${ + (error as Error)?.message || String(error) + }`; + } + } + + const llmParts = referenceSection + ? [heading, ...lines, referenceSection] + : [heading, ...lines]; + const displayParts = referenceSection + ? [...lines, referenceSection] + : [...lines]; + + return { + llmContent: llmParts.join('\n'), + returnDisplay: displayParts.join('\n'), + }; + } + + private formatLocation(symbol: LspSymbolInformation, workspaceRoot: string) { + const { uri, range } = symbol.location; + let filePath = uri; + if (uri.startsWith('file://')) { + filePath = fileURLToPath(uri); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + const line = (range.start.line ?? 0) + 1; + const character = (range.start.character ?? 0) + 1; + return `${filePath}:${line}:${character}`; + } +} + +export class LspWorkspaceSymbolTool extends BaseDeclarativeTool< + LspWorkspaceSymbolParams, + ToolResult +> { + static readonly Name = ToolNames.LSP_WORKSPACE_SYMBOL; + + constructor(private readonly config: Config) { + super( + LspWorkspaceSymbolTool.Name, + ToolDisplayNames.LSP_WORKSPACE_SYMBOL, + 'Search workspace symbols via LSP(查找定义/实现/引用,按名称定位符号,优先于 grep)。', + Kind.Other, + { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Symbol name query, e.g., function/class/variable name to search.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + required: ['query'], + }, + false, + false, + ); + } + + protected createInvocation( + params: LspWorkspaceSymbolParams, + ): ToolInvocation { + return new LspWorkspaceSymbolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 8cd1de541..1e0600b0a 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,9 @@ export const ToolNames = { WEB_FETCH: 'web_fetch', WEB_SEARCH: 'web_search', LS: 'list_directory', + LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol', + LSP_GO_TO_DEFINITION: 'lsp_go_to_definition', + LSP_FIND_REFERENCES: 'lsp_find_references', } as const; /** @@ -48,6 +51,9 @@ export const ToolDisplayNames = { WEB_FETCH: 'WebFetch', WEB_SEARCH: 'WebSearch', LS: 'ListFiles', + LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol', + LSP_GO_TO_DEFINITION: 'LspGoToDefinition', + LSP_FIND_REFERENCES: 'LspFindReferences', } as const; // Migration from old tool names to new tool names @@ -56,6 +62,8 @@ export const ToolDisplayNames = { export const ToolNamesMigration = { search_file_content: ToolNames.GREP, // Legacy name from grep tool replace: ToolNames.EDIT, // Legacy name from edit tool + go_to_definition: ToolNames.LSP_GO_TO_DEFINITION, + find_references: ToolNames.LSP_FIND_REFERENCES, } as const; // Migration from old tool display names to new tool display names From d1d215b82e9649a287af80d8cf595a9103d82932 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Mon, 5 Jan 2026 10:18:24 +0800 Subject: [PATCH 003/113] wip(cli): support lsp --- .../src/services/lsp/LspConnectionFactory.ts | 13 +-- .../src/services/lsp/NativeLspService.test.ts | 35 ++++---- .../cli/src/services/lsp/NativeLspService.ts | 87 +++++++++++-------- packages/core/src/lsp/types.ts | 4 +- 4 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index e18262ed6..00832adb5 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -12,8 +12,11 @@ class JsonRpcConnection { private nextId = 1; private disposed = false; private pendingRequests = new Map(); - private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = []; - private requestHandlers: Array<(request: JsonRpcMessage) => Promise> = []; + private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = + []; + private requestHandlers: Array< + (request: JsonRpcMessage) => Promise + > = []; constructor( private readonly writer: (data: string) => void, @@ -229,12 +232,12 @@ interface JsonRpcMessage { jsonrpc: string; id?: number | string; method?: string; - params?: any; - result?: any; + params?: unknown; + result?: unknown; error?: { code: number; message: string; - data?: any; + data?: unknown; }; } diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index 1fadd620a..521f4c2e0 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { NativeLspService } from './NativeLspService.js'; -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import { WorkspaceContext } from '@qwen-code/qwen-code-core'; +import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, +} from '@qwen-code/qwen-code-core'; import { EventEmitter } from 'events'; -import { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import { IdeContextStore } from '@qwen-code/qwen-code-core'; // 模拟依赖项 class MockConfig { @@ -33,11 +35,11 @@ class MockWorkspaceContext { async readFile(path: string): Promise { if (path.includes('.lsp.json')) { return JSON.stringify({ - 'typescript': { - 'command': 'typescript-language-server', - 'args': ['--stdio'], - 'transport': 'stdio' - } + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + }, }); } return '{}'; @@ -57,13 +59,16 @@ class MockWorkspaceContext { } class MockFileDiscoveryService { - async discoverFiles(root: string, options: any): Promise { + async discoverFiles( + root: string, + options: Record, + ): Promise { // 模拟发现一些文件 return [ '/test/workspace/src/index.ts', '/test/workspace/src/utils.ts', '/test/workspace/server.py', - '/test/workspace/main.go' + '/test/workspace/main.go', ]; } @@ -92,11 +97,11 @@ describe('NativeLspService', () => { eventEmitter = new EventEmitter(); lspService = new NativeLspService( - mockConfig as any, - mockWorkspace as any, + mockConfig as CoreConfig, + mockWorkspace as WorkspaceContext, eventEmitter, - mockFileDiscovery as any, - mockIdeStore as any + mockFileDiscovery as FileDiscoveryService, + mockIdeStore as IdeContextStore, ); }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index aca87e3e6..041decc35 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -1,15 +1,18 @@ -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import type { WorkspaceContext } from '@qwen-code/qwen-code-core'; -import type { EventEmitter } from 'events'; -import type { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import type { IdeContextStore } from '@qwen-code/qwen-code-core'; -import { LspConnectionFactory } from './LspConnectionFactory.js'; import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, LspLocation, LspDefinition, LspReference, LspSymbolInformation, } from '@qwen-code/qwen-code-core'; +import type { EventEmitter } from 'events'; +import { + LspConnectionFactory, + type JsonRpcMessage, +} from './LspConnectionFactory.js'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -18,7 +21,7 @@ import { globSync } from 'glob'; // 定义 LSP 初始化选项的类型 interface LspInitializationOptions { - [key: string]: any; + [key: string]: unknown; } // 定义 LSP 服务器配置类型 @@ -36,11 +39,11 @@ interface LspServerConfig { // 定义 LSP 连接接口 interface LspConnectionInterface { listen: (readable: NodeJS.ReadableStream) => void; - send: (message: any) => void; - onNotification: (handler: (notification: any) => void) => void; - onRequest: (handler: (request: any) => Promise) => void; - request: (method: string, params: any) => Promise; - initialize: (params: any) => Promise; + send: (message: JsonRpcMessage) => void; + onNotification: (handler: (notification: JsonRpcMessage) => void) => void; + onRequest: (handler: (request: JsonRpcMessage) => Promise) => void; + request: (method: string, params: unknown) => Promise; + initialize: (params: unknown) => Promise; shutdown: () => Promise; end: () => void; } @@ -90,7 +93,7 @@ export class NativeLspService { this.excludedServers = options.excludedServers?.filter(Boolean); this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; this.workspaceRoot = - options.workspaceRoot ?? (config as any).getProjectRoot(); + options.workspaceRoot ?? (config as CoreConfig).getProjectRoot(); } /** @@ -462,15 +465,19 @@ export class NativeLspService { } private normalizeLocationResult( - item: any, + item: unknown, serverName: string, ): LspReference | null { - const uri = item?.uri ?? item?.targetUri ?? item?.target?.uri; + const itemObj = item as Record; + const uri = + itemObj?.uri ?? + itemObj?.targetUri ?? + (itemObj?.target as Record)?.uri; const range = - item?.range ?? - item?.targetSelectionRange ?? - item?.targetRange ?? - item?.target?.range; + itemObj?.range ?? + itemObj?.targetSelectionRange ?? + itemObj?.targetRange ?? + (itemObj?.target as Record)?.range; if (!uri || !range?.start || !range?.end) { return null; @@ -493,12 +500,17 @@ export class NativeLspService { } private normalizeSymbolResult( - item: any, + item: unknown, serverName: string, ): LspSymbolInformation | null { - const location = item?.location ?? item?.target ?? item; + const itemObj = item as Record; + const location = itemObj?.location ?? itemObj?.target ?? itemObj; + const locationObj = location as Record; const range = - location?.range ?? location?.targetRange ?? item?.range ?? undefined; + locationObj?.range ?? + locationObj?.targetRange ?? + itemObj?.range ?? + undefined; if (!location?.uri || !range?.start || !range?.end) { return null; @@ -581,7 +593,7 @@ export class NativeLspService { args: ['--stdio'], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -594,7 +606,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -607,7 +619,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -633,7 +645,7 @@ export class NativeLspService { if (userConfig && typeof userConfig === 'object') { for (const [langId, serverSpec] of Object.entries(userConfig) as [ string, - any, + Record, ]) { // 转换为文件 URI 格式 const rootUri = pathToFileURL(this.workspaceRoot).toString(); @@ -651,7 +663,7 @@ export class NativeLspService { args: serverSpec.args || [], transport: serverSpec.transport || 'stdio', initializationOptions: serverSpec.initializationOptions, - rootUri: rootUri, + rootUri, trustRequired: serverSpec.trustRequired ?? true, }; @@ -715,7 +727,9 @@ export class NativeLspService { } // 检查路径安全性 - if (!this.isPathSafe(handle.config.command, (this.config as any).cwd)) { + if ( + !this.isPathSafe(handle.config.command, (this.config as CoreConfig).cwd) + ) { console.warn( `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, ); @@ -773,7 +787,7 @@ export class NativeLspService { process: ChildProcess; shutdown: () => Promise; exit: () => void; - initialize: (params: any) => Promise; + initialize: (params: unknown) => Promise; }> { if (config.transport === 'stdio') { // 修复:使用 cwd 作为 cwd 而不是 rootUri @@ -795,9 +809,8 @@ export class NativeLspService { } lspConnection.connection.end(); }, - initialize: async (params: any) => { - return lspConnection.connection.initialize(params); - }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), }; } else if (config.transport === 'tcp') { // 如果需要 TCP 支持,可以扩展此部分 @@ -866,7 +879,9 @@ export class NativeLspService { const tsFile = this.findFirstTypescriptFile(); if (tsFile) { const uri = pathToFileURL(tsFile).toString(); - const languageId = tsFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript'; + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : 'typescript'; const text = fs.readFileSync(tsFile, 'utf-8'); connection.connection.send({ jsonrpc: '2.0', @@ -1013,15 +1028,15 @@ export class NativeLspService { return handle.config.name.includes('typescript'); } - private isNoProjectErrorResponse(response: any): boolean { + private isNoProjectErrorResponse(response: unknown): boolean { if (!response) { return false; } const message = typeof response === 'string' ? response - : typeof response?.message === 'string' - ? response.message + : typeof (response as Record)?.message === 'string' + ? ((response as Record).message as string) : ''; return message.includes('No Project'); } diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 2a412d660..239962a77 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -31,9 +31,9 @@ export interface LspSymbolInformation { serverName?: string; } -export interface LspReference extends LspLocationWithServer {} +export type LspReference = LspLocationWithServer; -export interface LspDefinition extends LspLocationWithServer {} +export type LspDefinition = LspLocationWithServer; export interface LspClient { workspaceSymbols( From 5a907c3415d4c54e2fe8748ed896724e86aa73c4 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Jan 2026 15:21:33 +0800 Subject: [PATCH 004/113] wip(cli): support lsp --- .../src/services/lsp/LspConnectionFactory.ts | 15 ++- .../src/services/lsp/NativeLspService.test.ts | 43 +++--- .../cli/src/services/lsp/NativeLspService.ts | 124 +++++++++++------- packages/core/src/lsp/types.ts | 8 +- .../core/src/tools/lsp-find-references.ts | 15 +-- .../core/src/tools/lsp-go-to-definition.ts | 15 +-- 6 files changed, 122 insertions(+), 98 deletions(-) diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index e18262ed6..ccee42d06 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -11,9 +11,12 @@ class JsonRpcConnection { private buffer = ''; private nextId = 1; private disposed = false; - private pendingRequests = new Map(); - private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = []; - private requestHandlers: Array<(request: JsonRpcMessage) => Promise> = []; + private pendingRequests = new Map(); + private notificationHandlers: Array<(notification: JsonRpcMessage) => void> = + []; + private requestHandlers: Array< + (request: JsonRpcMessage) => Promise + > = []; constructor( private readonly writer: (data: string) => void, @@ -229,12 +232,12 @@ interface JsonRpcMessage { jsonrpc: string; id?: number | string; method?: string; - params?: any; - result?: any; + params?: unknown; + result?: unknown; error?: { code: number; message: string; - data?: any; + data?: unknown; }; } diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index 1fadd620a..c6479bfbb 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,10 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { NativeLspService } from './NativeLspService.js'; -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import { WorkspaceContext } from '@qwen-code/qwen-code-core'; import { EventEmitter } from 'events'; -import { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import { IdeContextStore } from '@qwen-code/qwen-code-core'; // 模拟依赖项 class MockConfig { @@ -14,7 +9,7 @@ class MockConfig { return true; } - get(key: string) { + get(_key: string) { return undefined; } @@ -26,28 +21,28 @@ class MockConfig { class MockWorkspaceContext { rootPath = '/test/workspace'; - async fileExists(path: string): Promise { - return path.endsWith('.json') || path.includes('package.json'); + async fileExists(_path: string): Promise { + return _path.endsWith('.json') || _path.includes('package.json'); } - async readFile(path: string): Promise { - if (path.includes('.lsp.json')) { + async readFile(_path: string): Promise { + if (_path.includes('.lsp.json')) { return JSON.stringify({ - 'typescript': { - 'command': 'typescript-language-server', - 'args': ['--stdio'], - 'transport': 'stdio' - } + typescript: { + command: 'typescript-language-server', + args: ['--stdio'], + transport: 'stdio', + }, }); } return '{}'; } - resolvePath(path: string): string { - return this.rootPath + '/' + path; + resolvePath(_path: string): string { + return this.rootPath + '/' + _path; } - isPathWithinWorkspace(path: string): boolean { + isPathWithinWorkspace(_path: string): boolean { return true; } @@ -57,13 +52,13 @@ class MockWorkspaceContext { } class MockFileDiscoveryService { - async discoverFiles(root: string, options: any): Promise { + async discoverFiles(_root: string, _options: unknown): Promise { // 模拟发现一些文件 return [ '/test/workspace/src/index.ts', '/test/workspace/src/utils.ts', '/test/workspace/server.py', - '/test/workspace/main.go' + '/test/workspace/main.go', ]; } @@ -92,11 +87,11 @@ describe('NativeLspService', () => { eventEmitter = new EventEmitter(); lspService = new NativeLspService( - mockConfig as any, - mockWorkspace as any, + mockConfig as MockConfig, + mockWorkspace as MockWorkspaceContext, eventEmitter, - mockFileDiscovery as any, - mockIdeStore as any + mockFileDiscovery as MockFileDiscoveryService, + mockIdeStore as MockIdeContextStore, ); }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index aca87e3e6..f15f2b2b5 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -1,15 +1,15 @@ -import type { Config as CoreConfig } from '@qwen-code/qwen-code-core'; -import type { WorkspaceContext } from '@qwen-code/qwen-code-core'; -import type { EventEmitter } from 'events'; -import type { FileDiscoveryService } from '@qwen-code/qwen-code-core'; -import type { IdeContextStore } from '@qwen-code/qwen-code-core'; -import { LspConnectionFactory } from './LspConnectionFactory.js'; import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, LspLocation, LspDefinition, LspReference, LspSymbolInformation, } from '@qwen-code/qwen-code-core'; +import type { EventEmitter } from 'events'; +import { LspConnectionFactory } from './LspConnectionFactory.js'; import * as path from 'path'; import { pathToFileURL } from 'url'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -18,7 +18,7 @@ import { globSync } from 'glob'; // 定义 LSP 初始化选项的类型 interface LspInitializationOptions { - [key: string]: any; + [key: string]: unknown; } // 定义 LSP 服务器配置类型 @@ -36,11 +36,11 @@ interface LspServerConfig { // 定义 LSP 连接接口 interface LspConnectionInterface { listen: (readable: NodeJS.ReadableStream) => void; - send: (message: any) => void; - onNotification: (handler: (notification: any) => void) => void; - onRequest: (handler: (request: any) => Promise) => void; - request: (method: string, params: any) => Promise; - initialize: (params: any) => Promise; + send: (message: unknown) => void; + onNotification: (handler: (notification: unknown) => void) => void; + onRequest: (handler: (request: unknown) => Promise) => void; + request: (method: string, params: unknown) => Promise; + initialize: (params: unknown) => Promise; shutdown: () => Promise; end: () => void; } @@ -90,7 +90,8 @@ export class NativeLspService { this.excludedServers = options.excludedServers?.filter(Boolean); this.requireTrustedWorkspace = options.requireTrustedWorkspace ?? true; this.workspaceRoot = - options.workspaceRoot ?? (config as any).getProjectRoot(); + options.workspaceRoot ?? + (config as { getProjectRoot: () => string }).getProjectRoot(); } /** @@ -462,30 +463,38 @@ export class NativeLspService { } private normalizeLocationResult( - item: any, + item: unknown, serverName: string, ): LspReference | null { - const uri = item?.uri ?? item?.targetUri ?? item?.target?.uri; + const itemObj = item as Record; + const uri = + itemObj?.uri ?? + itemObj?.targetUri ?? + (itemObj?.target as Record)?.uri; const range = - item?.range ?? - item?.targetSelectionRange ?? - item?.targetRange ?? - item?.target?.range; + itemObj?.range ?? + itemObj?.targetSelectionRange ?? + itemObj?.targetRange ?? + (itemObj?.target as Record)?.range; if (!uri || !range?.start || !range?.end) { return null; } + const rangeObj = range as Record; + const start = rangeObj.start as { line?: number; character?: number }; + const end = rangeObj.end as { line?: number; character?: number }; + return { - uri, + uri: uri as string, range: { start: { - line: Number(range.start.line ?? 0), - character: Number(range.start.character ?? 0), + line: Number(start?.line ?? 0), + character: Number(start?.character ?? 0), }, end: { - line: Number(range.end.line ?? 0), - character: Number(range.end.character ?? 0), + line: Number(end?.line ?? 0), + character: Number(end?.character ?? 0), }, }, serverName, @@ -493,31 +502,40 @@ export class NativeLspService { } private normalizeSymbolResult( - item: any, + item: unknown, serverName: string, ): LspSymbolInformation | null { - const location = item?.location ?? item?.target ?? item; + const itemObj = item as Record; + const location = itemObj?.location ?? itemObj?.target ?? item; + const locationObj = location as Record; const range = - location?.range ?? location?.targetRange ?? item?.range ?? undefined; + locationObj?.range ?? + locationObj?.targetRange ?? + itemObj?.range ?? + undefined; - if (!location?.uri || !range?.start || !range?.end) { + if (!locationObj?.uri || !range?.start || !range?.end) { return null; } + const rangeObj = range as Record; + const start = rangeObj.start as { line?: number; character?: number }; + const end = rangeObj.end as { line?: number; character?: number }; + return { - name: item?.name ?? item?.label ?? 'symbol', - kind: item?.kind ? String(item.kind) : undefined, - containerName: item?.containerName ?? item?.container, + name: (itemObj?.name ?? itemObj?.label ?? 'symbol') as string, + kind: itemObj?.kind ? String(itemObj.kind) : undefined, + containerName: itemObj?.containerName ?? itemObj?.container, location: { - uri: location.uri, + uri: locationObj.uri as string, range: { start: { - line: Number(range.start.line ?? 0), - character: Number(range.start.character ?? 0), + line: Number(start?.line ?? 0), + character: Number(start?.character ?? 0), }, end: { - line: Number(range.end.line ?? 0), - character: Number(range.end.character ?? 0), + line: Number(end?.line ?? 0), + character: Number(end?.character ?? 0), }, }, }, @@ -581,7 +599,7 @@ export class NativeLspService { args: ['--stdio'], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -594,7 +612,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -607,7 +625,7 @@ export class NativeLspService { args: [], transport: 'stdio', initializationOptions: {}, - rootUri: rootUri, + rootUri, trustRequired: true, }); } @@ -633,7 +651,7 @@ export class NativeLspService { if (userConfig && typeof userConfig === 'object') { for (const [langId, serverSpec] of Object.entries(userConfig) as [ string, - any, + Record, ]) { // 转换为文件 URI 格式 const rootUri = pathToFileURL(this.workspaceRoot).toString(); @@ -651,7 +669,7 @@ export class NativeLspService { args: serverSpec.args || [], transport: serverSpec.transport || 'stdio', initializationOptions: serverSpec.initializationOptions, - rootUri: rootUri, + rootUri, trustRequired: serverSpec.trustRequired ?? true, }; @@ -715,7 +733,12 @@ export class NativeLspService { } // 检查路径安全性 - if (!this.isPathSafe(handle.config.command, (this.config as any).cwd)) { + if ( + !this.isPathSafe( + handle.config.command, + (this.config as { cwd: string }).cwd, + ) + ) { console.warn( `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, ); @@ -773,7 +796,7 @@ export class NativeLspService { process: ChildProcess; shutdown: () => Promise; exit: () => void; - initialize: (params: any) => Promise; + initialize: (params: unknown) => Promise; }> { if (config.transport === 'stdio') { // 修复:使用 cwd 作为 cwd 而不是 rootUri @@ -795,9 +818,8 @@ export class NativeLspService { } lspConnection.connection.end(); }, - initialize: async (params: any) => { - return lspConnection.connection.initialize(params); - }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), }; } else if (config.transport === 'tcp') { // 如果需要 TCP 支持,可以扩展此部分 @@ -866,7 +888,9 @@ export class NativeLspService { const tsFile = this.findFirstTypescriptFile(); if (tsFile) { const uri = pathToFileURL(tsFile).toString(); - const languageId = tsFile.endsWith('.tsx') ? 'typescriptreact' : 'typescript'; + const languageId = tsFile.endsWith('.tsx') + ? 'typescriptreact' + : 'typescript'; const text = fs.readFileSync(tsFile, 'utf-8'); connection.connection.send({ jsonrpc: '2.0', @@ -1013,15 +1037,15 @@ export class NativeLspService { return handle.config.name.includes('typescript'); } - private isNoProjectErrorResponse(response: any): boolean { + private isNoProjectErrorResponse(response: unknown): boolean { if (!response) { return false; } const message = typeof response === 'string' ? response - : typeof response?.message === 'string' - ? response.message + : typeof (response as Record)?.message === 'string' + ? ((response as Record).message as string) : ''; return message.includes('No Project'); } diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 2a412d660..309ad43b9 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -31,9 +31,13 @@ export interface LspSymbolInformation { serverName?: string; } -export interface LspReference extends LspLocationWithServer {} +export interface LspReference extends LspLocationWithServer { + readonly serverName?: string; +} -export interface LspDefinition extends LspLocationWithServer {} +export interface LspDefinition extends LspLocationWithServer { + readonly serverName?: string; +} export interface LspClient { workspaceSymbols( diff --git a/packages/core/src/tools/lsp-find-references.ts b/packages/core/src/tools/lsp-find-references.ts index 078586e49..5f7127dba 100644 --- a/packages/core/src/tools/lsp-find-references.ts +++ b/packages/core/src/tools/lsp-find-references.ts @@ -10,11 +10,7 @@ import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import type { Config } from '../config/config.js'; -import type { - LspClient, - LspLocation, - LspReference, -} from '../lsp/types.js'; +import type { LspClient, LspLocation, LspReference } from '../lsp/types.js'; export interface LspFindReferencesParams { /** @@ -121,9 +117,12 @@ class LspFindReferencesInvocation extends BaseToolInvocation< } const workspaceRoot = this.config.getProjectRoot(); - const lines = references.slice(0, limit).map((reference, index) => { - return `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`; - }); + const lines = references + .slice(0, limit) + .map( + (reference, index) => + `${index + 1}. ${this.formatLocation(reference, workspaceRoot)}`, + ); const heading = `References for ${target.description}:`; return { diff --git a/packages/core/src/tools/lsp-go-to-definition.ts b/packages/core/src/tools/lsp-go-to-definition.ts index cfbc92d32..54e093545 100644 --- a/packages/core/src/tools/lsp-go-to-definition.ts +++ b/packages/core/src/tools/lsp-go-to-definition.ts @@ -10,11 +10,7 @@ import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolDisplayNames, ToolNames } from './tool-names.js'; import type { Config } from '../config/config.js'; -import type { - LspClient, - LspDefinition, - LspLocation, -} from '../lsp/types.js'; +import type { LspClient, LspDefinition, LspLocation } from '../lsp/types.js'; export interface LspGoToDefinitionParams { /** @@ -126,9 +122,12 @@ class LspGoToDefinitionInvocation extends BaseToolInvocation< } const workspaceRoot = this.config.getProjectRoot(); - const lines = definitions.slice(0, limit).map((definition, index) => { - return `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`; - }); + const lines = definitions + .slice(0, limit) + .map( + (definition, index) => + `${index + 1}. ${this.formatLocation(definition, workspaceRoot)}`, + ); const heading = `Definitions for ${target.description}:`; return { From c4e6c096dce3269337c40ddfa107b9e79534eac1 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 7 Jan 2026 19:59:19 +0800 Subject: [PATCH 005/113] feat(cli): improve LSP service implementation with type safety and iteration fixes - Fix iteration over Map and Set collections by using Array.from() to avoid potential modification during iteration issues - Add proper type casting for test mocks to ensure type safety - Add null checks and type guards for LSP reference and symbol processing - Improve type annotations for LSP server status and configuration objects - Update path validation to use workspace root instead of config.cwd These changes improve the robustness and type safety of the LSP service implementation. --- .../src/services/lsp/LspConnectionFactory.ts | 2 +- .../src/services/lsp/NativeLspService.test.ts | 8 +- .../cli/src/services/lsp/NativeLspService.ts | 136 ++++++++++-------- 3 files changed, 84 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index ccee42d06..9f2e4c9b8 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -214,7 +214,7 @@ class JsonRpcConnection { } private disposePending(error?: Error): void { - for (const [, pending] of this.pendingRequests) { + for (const [, pending] of Array.from(this.pendingRequests)) { clearTimeout(pending.timer); pending.reject(error ?? new Error('LSP connection closed')); } diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index c6479bfbb..acac65b98 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -87,11 +87,11 @@ describe('NativeLspService', () => { eventEmitter = new EventEmitter(); lspService = new NativeLspService( - mockConfig as MockConfig, - mockWorkspace as MockWorkspaceContext, + mockConfig as unknown as CoreConfig, + mockWorkspace as unknown as WorkspaceContext, eventEmitter, - mockFileDiscovery as MockFileDiscoveryService, - mockIdeStore as MockIdeContextStore, + mockFileDiscovery as unknown as FileDiscoveryService, + mockIdeStore as unknown as IdeContextStore, ); }); diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index f15f2b2b5..fe2da4498 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -117,7 +117,7 @@ export class NativeLspService { for (const config of serverConfigs) { this.serverHandles.set(config.name, { config, - status: 'NOT_STARTED', + status: 'NOT_STARTED' as LspServerStatus, }); } } @@ -126,7 +126,7 @@ export class NativeLspService { * 启动所有 LSP 服务器 */ async start(): Promise { - for (const [name, handle] of this.serverHandles) { + for (const [name, handle] of Array.from(this.serverHandles)) { await this.startServer(name, handle); } } @@ -135,7 +135,7 @@ export class NativeLspService { * 停止所有 LSP 服务器 */ async stop(): Promise { - for (const [name, handle] of this.serverHandles) { + for (const [name, handle] of Array.from(this.serverHandles)) { await this.stopServer(name, handle); } this.serverHandles.clear(); @@ -146,7 +146,7 @@ export class NativeLspService { */ getStatus(): Map { const statusMap = new Map(); - for (const [name, handle] of this.serverHandles) { + for (const [name, handle] of Array.from(this.serverHandles)) { statusMap.set(name, handle.status); } return statusMap; @@ -161,7 +161,7 @@ export class NativeLspService { ): Promise { const results: LspSymbolInformation[] = []; - for (const [serverName, handle] of this.serverHandles) { + for (const [serverName, handle] of Array.from(this.serverHandles)) { if (handle.status !== 'READY' || !handle.connection) { continue; } @@ -348,7 +348,7 @@ export class NativeLspService { // 统计不同语言的文件数量 const languageCounts = new Map(); - for (const file of files) { + for (const file of Array.from(files)) { const ext = path.extname(file).slice(1).toLowerCase(); if (ext) { const lang = this.mapExtensionToLanguage(ext); @@ -466,27 +466,33 @@ export class NativeLspService { item: unknown, serverName: string, ): LspReference | null { + if (!item || typeof item !== 'object') { + return null; + } + const itemObj = item as Record; - const uri = - itemObj?.uri ?? - itemObj?.targetUri ?? - (itemObj?.target as Record)?.uri; - const range = - itemObj?.range ?? - itemObj?.targetSelectionRange ?? - itemObj?.targetRange ?? - (itemObj?.target as Record)?.range; + const uri = (itemObj['uri'] ?? + itemObj['targetUri'] ?? + (itemObj['target'] as Record)?.['uri']) as + | string + | undefined; + + const range = (itemObj['range'] ?? + itemObj['targetSelectionRange'] ?? + itemObj['targetRange'] ?? + (itemObj['target'] as Record)?.['range']) as + | { start?: unknown; end?: unknown } + | undefined; if (!uri || !range?.start || !range?.end) { return null; } - const rangeObj = range as Record; - const start = rangeObj.start as { line?: number; character?: number }; - const end = rangeObj.end as { line?: number; character?: number }; + const start = range.start as { line?: number; character?: number }; + const end = range.end as { line?: number; character?: number }; return { - uri: uri as string, + uri, range: { start: { line: Number(start?.line ?? 0), @@ -505,29 +511,37 @@ export class NativeLspService { item: unknown, serverName: string, ): LspSymbolInformation | null { - const itemObj = item as Record; - const location = itemObj?.location ?? itemObj?.target ?? item; - const locationObj = location as Record; - const range = - locationObj?.range ?? - locationObj?.targetRange ?? - itemObj?.range ?? - undefined; - - if (!locationObj?.uri || !range?.start || !range?.end) { + if (!item || typeof item !== 'object') { return null; } - const rangeObj = range as Record; - const start = rangeObj.start as { line?: number; character?: number }; - const end = rangeObj.end as { line?: number; character?: number }; + const itemObj = item as Record; + const location = itemObj['location'] ?? itemObj['target'] ?? item; + if (!location || typeof location !== 'object') { + return null; + } + + const locationObj = location as Record; + const range = (locationObj['range'] ?? + locationObj['targetRange'] ?? + itemObj['range'] ?? + undefined) as { start?: unknown; end?: unknown } | undefined; + + if (!locationObj['uri'] || !range?.start || !range?.end) { + return null; + } + + const start = range.start as { line?: number; character?: number }; + const end = range.end as { line?: number; character?: number }; return { - name: (itemObj?.name ?? itemObj?.label ?? 'symbol') as string, - kind: itemObj?.kind ? String(itemObj.kind) : undefined, - containerName: itemObj?.containerName ?? itemObj?.container, + name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, + kind: itemObj['kind'] ? String(itemObj['kind']) : undefined, + containerName: (itemObj['containerName'] ?? itemObj['container']) as + | string + | undefined, location: { - uri: locationObj.uri as string, + uri: locationObj['uri'] as string, range: { start: { line: Number(start?.line ?? 0), @@ -649,28 +663,41 @@ export class NativeLspService { // 验证并转换用户配置为内部格式 if (userConfig && typeof userConfig === 'object') { - for (const [langId, serverSpec] of Object.entries(userConfig) as [ - string, - Record, - ]) { + for (const [langId, serverSpec] of Object.entries( + userConfig, + ) as Array<[string, Record]>) { // 转换为文件 URI 格式 const rootUri = pathToFileURL(this.workspaceRoot).toString(); - // 验证 command 不为 undefined - if (!serverSpec.command) { - console.warn(`LSP 配置错误: ${langId} 缺少 command 属性`); + // 驗證 command 不為 undefined + if (!(serverSpec as Record)['command']) { + console.warn(`LSP 配置錯誤: ${langId} 缺少 command 屬性`); continue; } const serverConfig: LspServerConfig = { - name: serverSpec.command, + name: (serverSpec as Record)[ + 'command' + ] as string, languages: [langId], - command: serverSpec.command, - args: serverSpec.args || [], - transport: serverSpec.transport || 'stdio', - initializationOptions: serverSpec.initializationOptions, + command: (serverSpec as Record)[ + 'command' + ] as string, + args: + ((serverSpec as Record)['args'] as string[]) || + [], + transport: + ((serverSpec as Record)['transport'] as + | 'stdio' + | 'tcp') || 'stdio', + initializationOptions: (serverSpec as Record)[ + 'initializationOptions' + ] as LspInitializationOptions, rootUri, - trustRequired: serverSpec.trustRequired ?? true, + trustRequired: + ((serverSpec as Record)[ + 'trustRequired' + ] as boolean) ?? true, }; configs.push(serverConfig); @@ -733,12 +760,7 @@ export class NativeLspService { } // 检查路径安全性 - if ( - !this.isPathSafe( - handle.config.command, - (this.config as { cwd: string }).cwd, - ) - ) { + if (!this.isPathSafe(handle.config.command, this.workspaceRoot)) { console.warn( `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, ); @@ -1044,8 +1066,8 @@ export class NativeLspService { const message = typeof response === 'string' ? response - : typeof (response as Record)?.message === 'string' - ? ((response as Record).message as string) + : typeof (response as Record)['message'] === 'string' + ? ((response as Record)['message'] as string) : ''; return message.includes('No Project'); } From ec0586b1355379e2e7cfd32841ed3ff835084f7b Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 14 Jan 2026 23:27:56 +0800 Subject: [PATCH 006/113] chore(webui): rename --- packages/vscode-ide-companion/package.json | 1 + packages/webui/example/ExampleComponent.tsx | 78 ++++++++++++++++ packages/webui/package.json | 36 +++++++ packages/webui/rollup.config.js | 47 ++++++++++ .../webui/src/components/PermissionDrawer.tsx | 93 +++++++++++++++++++ .../webui/src/components/icons/CloseIcon.tsx | 30 ++++++ packages/webui/src/components/icons/Icon.tsx | 38 ++++++++ .../webui/src/components/icons/SendIcon.tsx | 30 ++++++ .../webui/src/components/layout/Container.tsx | 12 +++ .../webui/src/components/layout/Footer.tsx | 5 + .../webui/src/components/layout/Header.tsx | 5 + packages/webui/src/components/layout/Main.tsx | 5 + .../webui/src/components/layout/Sidebar.tsx | 5 + .../webui/src/components/messages/Message.tsx | 39 ++++++++ .../src/components/messages/MessageInput.tsx | 5 + .../src/components/messages/MessageList.tsx | 5 + packages/webui/src/components/ui/Button.tsx | 49 ++++++++++ packages/webui/src/components/ui/Input.tsx | 25 +++++ packages/webui/src/components/ui/Tooltip.tsx | 93 +++++++++++++++++++ packages/webui/src/hooks/useLocalStorage.ts | 27 ++++++ packages/webui/src/hooks/useTheme.ts | 29 ++++++ packages/webui/src/index.ts | 35 +++++++ packages/webui/src/types/messages.ts | 7 ++ packages/webui/src/types/theme.ts | 1 + packages/webui/tsconfig.json | 24 +++++ 25 files changed, 724 insertions(+) create mode 100644 packages/webui/example/ExampleComponent.tsx create mode 100644 packages/webui/package.json create mode 100644 packages/webui/rollup.config.js create mode 100644 packages/webui/src/components/PermissionDrawer.tsx create mode 100644 packages/webui/src/components/icons/CloseIcon.tsx create mode 100644 packages/webui/src/components/icons/Icon.tsx create mode 100644 packages/webui/src/components/icons/SendIcon.tsx create mode 100644 packages/webui/src/components/layout/Container.tsx create mode 100644 packages/webui/src/components/layout/Footer.tsx create mode 100644 packages/webui/src/components/layout/Header.tsx create mode 100644 packages/webui/src/components/layout/Main.tsx create mode 100644 packages/webui/src/components/layout/Sidebar.tsx create mode 100644 packages/webui/src/components/messages/Message.tsx create mode 100644 packages/webui/src/components/messages/MessageInput.tsx create mode 100644 packages/webui/src/components/messages/MessageList.tsx create mode 100644 packages/webui/src/components/ui/Button.tsx create mode 100644 packages/webui/src/components/ui/Input.tsx create mode 100644 packages/webui/src/components/ui/Tooltip.tsx create mode 100644 packages/webui/src/hooks/useLocalStorage.ts create mode 100644 packages/webui/src/hooks/useTheme.ts create mode 100644 packages/webui/src/index.ts create mode 100644 packages/webui/src/types/messages.ts create mode 100644 packages/webui/src/types/theme.ts create mode 100644 packages/webui/tsconfig.json diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 6c5be6727..1ed40e136 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -152,6 +152,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "@qwen-code/webui": "workspace:*", "semver": "^7.7.2", "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", diff --git a/packages/webui/example/ExampleComponent.tsx b/packages/webui/example/ExampleComponent.tsx new file mode 100644 index 000000000..c29800049 --- /dev/null +++ b/packages/webui/example/ExampleComponent.tsx @@ -0,0 +1,78 @@ +// Example of how to use shared UI components +// This would typically be integrated into existing components + +import React, { useState } from 'react'; +import { + Button, + Input, + Message, + PermissionDrawer, + Tooltip, +} from '@qwen-code/webui'; + +const ExampleComponent: React.FC = () => { + const [inputValue, setInputValue] = useState(''); + const [showPermissionDrawer, setShowPermissionDrawer] = useState(false); + + const handleConfirmPermission = () => { + console.log('Permissions confirmed'); + setShowPermissionDrawer(false); + }; + + return ( +
+

Shared Components Demo

+ + {/* Example of using shared Button component */} +
+ +
+ + {/* Example of using shared Input component */} +
+ +
+ + {/* Example of using shared Message component */} +
+ +
+ + {/* Example of using shared Tooltip component */} +
+ + + +
+ + {/* Example of using shared PermissionDrawer component */} + setShowPermissionDrawer(false)} + onConfirm={handleConfirmPermission} + permissions={[ + 'Access browser history', + 'Read current page', + 'Capture screenshots', + ]} + /> +
+ ); +}; + +export default ExampleComponent; diff --git a/packages/webui/package.json b/packages/webui/package.json new file mode 100644 index 000000000..757a6d812 --- /dev/null +++ b/packages/webui/package.json @@ -0,0 +1,36 @@ +{ + "name": "@qwen-code/webui", + "version": "0.1.0", + "description": "Shared UI components for Qwen Code packages", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "type": "module", + "scripts": { + "build": "tsc && rollup -c", + "dev": "tsc --watch", + "lint": "eslint src --ext .ts,.tsx", + "lint:fix": "eslint src --ext .ts,.tsx --fix", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "typescript": "^5.0.0", + "@rollup/plugin-typescript": "^11.0.0", + "rollup": "^4.0.0", + "rollup-plugin-dts": "^6.0.0" + }, + "keywords": [ + "qwen", + "ui", + "components", + "shared" + ], + "author": "Qwen Team", + "license": "MIT" +} diff --git a/packages/webui/rollup.config.js b/packages/webui/rollup.config.js new file mode 100644 index 000000000..c6340858a --- /dev/null +++ b/packages/webui/rollup.config.js @@ -0,0 +1,47 @@ +import typescript from '@rollup/plugin-typescript'; +import { dts } from 'rollup-plugin-dts'; +import pkg from './package.json' with { type: 'json' }; + +const name = pkg.name; + +export default [ + // Browser-friendly version + { + input: 'src/index.ts', + output: { + name, + file: 'dist/index.min.js', + format: 'iife', + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + external: ['react', 'react-dom'], + plugins: [ + typescript({ + tsconfig: './tsconfig.json', + }), + ], + }, + // ES module version + { + input: 'src/index.ts', + output: [ + { file: 'dist/index.esm.js', format: 'es' }, + { file: 'dist/index.cjs.js', format: 'cjs' }, + ], + external: ['react', 'react-dom'], + plugins: [ + typescript({ + tsconfig: './tsconfig.json', + }), + ], + }, + // Type declarations + { + input: 'dist/dts/src/index.d.ts', + output: [{ file: 'dist/index.d.ts', format: 'es' }], + plugins: [dts()], + }, +]; diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx new file mode 100644 index 000000000..2805915cb --- /dev/null +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -0,0 +1,93 @@ +import type React from 'react'; +import { useState, useEffect } from 'react'; + +interface PermissionDrawerProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + permissions: string[]; +} + +const PermissionDrawer: React.FC = ({ + isOpen, + onClose, + onConfirm, + permissions, +}) => { + const [checkedPermissions, setCheckedPermissions] = useState( + Array(permissions.length).fill(false), + ); + + useEffect(() => { + if (!isOpen) { + setCheckedPermissions(Array(permissions.length).fill(false)); + } + }, [isOpen, permissions]); + + const handleTogglePermission = (index: number) => { + const newChecked = [...checkedPermissions]; + newChecked[index] = !newChecked[index]; + setCheckedPermissions(newChecked); + }; + + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+

Permissions Required

+ +
+ +
+
    + {permissions.map((permission, index) => ( +
  • + handleTogglePermission(index)} + className="mr-2 h-4 w-4" + /> + {permission} +
  • + ))} +
+
+ +
+ + +
+
+
+ ); +}; + +export default PermissionDrawer; diff --git a/packages/webui/src/components/icons/CloseIcon.tsx b/packages/webui/src/components/icons/CloseIcon.tsx new file mode 100644 index 000000000..ac559d299 --- /dev/null +++ b/packages/webui/src/components/icons/CloseIcon.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; + +interface CloseIconProps { + size?: number; + color?: string; + className?: string; +} + +const CloseIcon: React.FC = ({ + size = 24, + color = 'currentColor', + className = '', +}) => ( + + + + + ); + +export default CloseIcon; diff --git a/packages/webui/src/components/icons/Icon.tsx b/packages/webui/src/components/icons/Icon.tsx new file mode 100644 index 000000000..2f874d594 --- /dev/null +++ b/packages/webui/src/components/icons/Icon.tsx @@ -0,0 +1,38 @@ +import type React from 'react'; + +interface IconProps { + name: string; + size?: number; + color?: string; + className?: string; +} + +const Icon: React.FC = ({ + name, + size = 24, + color = 'currentColor', + className = '', +}) => + // This is a placeholder - in a real implementation you might use an icon library + ( + + + {name} + + + ) +; + +export default Icon; diff --git a/packages/webui/src/components/icons/SendIcon.tsx b/packages/webui/src/components/icons/SendIcon.tsx new file mode 100644 index 000000000..d2561e033 --- /dev/null +++ b/packages/webui/src/components/icons/SendIcon.tsx @@ -0,0 +1,30 @@ +import type React from 'react'; + +interface SendIconProps { + size?: number; + color?: string; + className?: string; +} + +const SendIcon: React.FC = ({ + size = 24, + color = 'currentColor', + className = '', +}) => ( + + + + + ); + +export default SendIcon; diff --git a/packages/webui/src/components/layout/Container.tsx b/packages/webui/src/components/layout/Container.tsx new file mode 100644 index 000000000..436d4c72e --- /dev/null +++ b/packages/webui/src/components/layout/Container.tsx @@ -0,0 +1,12 @@ +import type React from 'react'; + +interface ContainerProps { + children: React.ReactNode; + className?: string; +} + +const Container: React.FC = ({ children, className = '' }) => ( +
{children}
+ ); + +export default Container; diff --git a/packages/webui/src/components/layout/Footer.tsx b/packages/webui/src/components/layout/Footer.tsx new file mode 100644 index 000000000..557a083b1 --- /dev/null +++ b/packages/webui/src/components/layout/Footer.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Footer: React.FC = () =>
Footer Component Placeholder
; + +export default Footer; diff --git a/packages/webui/src/components/layout/Header.tsx b/packages/webui/src/components/layout/Header.tsx new file mode 100644 index 000000000..2886c0dfb --- /dev/null +++ b/packages/webui/src/components/layout/Header.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Header: React.FC = () =>
Header Component Placeholder
; + +export default Header; diff --git a/packages/webui/src/components/layout/Main.tsx b/packages/webui/src/components/layout/Main.tsx new file mode 100644 index 000000000..118f67430 --- /dev/null +++ b/packages/webui/src/components/layout/Main.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Main: React.FC = () =>
Main Component Placeholder
; + +export default Main; diff --git a/packages/webui/src/components/layout/Sidebar.tsx b/packages/webui/src/components/layout/Sidebar.tsx new file mode 100644 index 000000000..eb3746c20 --- /dev/null +++ b/packages/webui/src/components/layout/Sidebar.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const Sidebar: React.FC = () => ; + +export default Sidebar; diff --git a/packages/webui/src/components/messages/Message.tsx b/packages/webui/src/components/messages/Message.tsx new file mode 100644 index 000000000..e09208528 --- /dev/null +++ b/packages/webui/src/components/messages/Message.tsx @@ -0,0 +1,39 @@ +import type React from 'react'; + +interface MessageProps { + id: string; + content: string; + sender: 'user' | 'system' | 'assistant'; + timestamp?: Date; + className?: string; +} + +const Message: React.FC = ({ + content, + sender, + timestamp, + className = '', +}) => { + const alignment = sender === 'user' ? 'justify-end' : 'justify-start'; + const bgColor = sender === 'user' ? 'bg-blue-500' : 'bg-gray-200'; + + return ( +
+
+ {content} + {timestamp && ( +
+ {timestamp.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +
+ )} +
+
+ ); +}; + +export default Message; diff --git a/packages/webui/src/components/messages/MessageInput.tsx b/packages/webui/src/components/messages/MessageInput.tsx new file mode 100644 index 000000000..bd4637155 --- /dev/null +++ b/packages/webui/src/components/messages/MessageInput.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const MessageInput: React.FC = () =>
MessageInput Component Placeholder
; + +export default MessageInput; diff --git a/packages/webui/src/components/messages/MessageList.tsx b/packages/webui/src/components/messages/MessageList.tsx new file mode 100644 index 000000000..5544865a0 --- /dev/null +++ b/packages/webui/src/components/messages/MessageList.tsx @@ -0,0 +1,5 @@ +import type React from 'react'; + +const MessageList: React.FC = () =>
MessageList Component Placeholder
; + +export default MessageList; diff --git a/packages/webui/src/components/ui/Button.tsx b/packages/webui/src/components/ui/Button.tsx new file mode 100644 index 000000000..1ee0d3b07 --- /dev/null +++ b/packages/webui/src/components/ui/Button.tsx @@ -0,0 +1,49 @@ +import type React from 'react'; + +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + disabled?: boolean; + className?: string; +} + +const Button: React.FC = ({ + children, + onClick, + variant = 'primary', + size = 'md', + disabled = false, + className = '', +}) => { + const baseClasses = + 'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; + + const variantClasses = { + primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', + secondary: + 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + }; + + const sizeClasses = { + sm: 'px-2 py-1 text-sm', + md: 'px-4 py-2', + lg: 'px-6 py-3 text-lg', + }; + + const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : ''; + + return ( + + ); +}; + +export default Button; diff --git a/packages/webui/src/components/ui/Input.tsx b/packages/webui/src/components/ui/Input.tsx new file mode 100644 index 000000000..8fd350c67 --- /dev/null +++ b/packages/webui/src/components/ui/Input.tsx @@ -0,0 +1,25 @@ +import type React from 'react'; + +interface InputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +const Input: React.FC = ({ + value, + onChange, + placeholder, + className = '', +}) => ( + onChange(e.target.value)} + placeholder={placeholder} + className={`border rounded px-3 py-2 ${className}`} + /> + ); + +export default Input; diff --git a/packages/webui/src/components/ui/Tooltip.tsx b/packages/webui/src/components/ui/Tooltip.tsx new file mode 100644 index 000000000..6b9206af9 --- /dev/null +++ b/packages/webui/src/components/ui/Tooltip.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; + +interface ChildProps { + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onFocus?: () => void; + onBlur?: () => void; + tabIndex?: number; +} + +interface TooltipProps { + children: React.ReactElement; + content: string; + position?: 'top' | 'right' | 'bottom' | 'left'; +} + +const Tooltip: React.FC = ({ + children, + content, + position = 'top', +}) => { + const [isVisible, setIsVisible] = useState(false); + + const positionClasses = { + top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2', + right: 'top-1/2 left-full transform -translate-y-1/2 ml-2', + bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2', + left: 'top-1/2 right-full transform -translate-y-1/2 mr-2', + }; + + const arrowPositionClasses = { + top: 'top-full left-1/2 transform -translate-x-1/2 -mt-1', + right: 'top-1/2 left-0 transform -translate-y-1/2 -ml-1', + bottom: 'top-0 left-1/2 transform -translate-x-1/2 -mb-1', + left: 'top-1/2 right-0 transform -translate-y-1/2 -mr-1', + }; + + const tooltipClass = `absolute ${positionClasses[position]} bg-gray-800 text-white text-xs rounded py-1 px-2 pointer-events-none z-10`; + const arrowClass = `absolute w-2 h-2 bg-gray-800 transform rotate-45 ${arrowPositionClasses[position]}`; + + return ( +
+
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} + tabIndex={0} + > + {React.cloneElement(children, { + onMouseEnter: () => { + setIsVisible(true); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onMouseEnter === 'function') { + typedChildren.props.onMouseEnter(); + } + }, + onMouseLeave: () => { + setIsVisible(false); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onMouseLeave === 'function') { + typedChildren.props.onMouseLeave(); + } + }, + onFocus: () => { + setIsVisible(true); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onFocus === 'function') { + typedChildren.props.onFocus(); + } + }, + onBlur: () => { + setIsVisible(false); + const typedChildren = children as React.ReactElement; + if (typeof typedChildren.props.onBlur === 'function') { + typedChildren.props.onBlur(); + } + }, + tabIndex: + (children as React.ReactElement).props.tabIndex || 0, + })} +
+ {isVisible && ( +
+ {content} +
+
+ )} +
+ ); +}; + +export default Tooltip; diff --git a/packages/webui/src/hooks/useLocalStorage.ts b/packages/webui/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..7fb675411 --- /dev/null +++ b/packages/webui/src/hooks/useLocalStorage.ts @@ -0,0 +1,27 @@ +import { useState } from 'react'; + +export const useLocalStorage = (key: string, initialValue: T) => { + // Get value from localStorage or use initial value + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (_error) { + return initialValue; + } + }); + + // Update localStorage when state changes + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error(error); + } + }; + + return [storedValue, setValue] as const; +}; diff --git a/packages/webui/src/hooks/useTheme.ts b/packages/webui/src/hooks/useTheme.ts new file mode 100644 index 000000000..c4ca0a77a --- /dev/null +++ b/packages/webui/src/hooks/useTheme.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react'; + +export const useTheme = () => { + const [theme, setTheme] = useState<'light' | 'dark' | 'auto'>('auto'); + + useEffect(() => { + const savedTheme = localStorage.getItem('theme') as + | 'light' + | 'dark' + | 'auto' + | null; + if (savedTheme) { + setTheme(savedTheme); + } else { + const prefersDark = window.matchMedia( + '(prefers-color-scheme: dark)', + ).matches; + setTheme(prefersDark ? 'dark' : 'light'); + } + }, []); + + const toggleTheme = () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + return { theme, toggleTheme }; +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts new file mode 100644 index 000000000..6f4066229 --- /dev/null +++ b/packages/webui/src/index.ts @@ -0,0 +1,35 @@ +// Shared UI Components Export +// Export all shared components from this package + +// Layout components +export { default as Container } from './components/layout/Container'; +export { default as Header } from './components/layout/Header'; +export { default as Sidebar } from './components/layout/Sidebar'; +export { default as Main } from './components/layout/Main'; +export { default as Footer } from './components/layout/Footer'; + +// Message components +export { default as Message } from './components/messages/Message'; +export { default as MessageInput } from './components/messages/MessageInput'; +export { default as MessageList } from './components/messages/MessageList'; + +// UI Elements +export { default as Button } from './components/ui/Button'; +export { default as Input } from './components/ui/Input'; +export { default as Tooltip } from './components/ui/Tooltip'; + +// Permission components +export { default as PermissionDrawer } from './components/PermissionDrawer'; + +// Icons +export { default as Icon } from './components/icons/Icon'; +export { default as CloseIcon } from './components/icons/CloseIcon'; +export { default as SendIcon } from './components/icons/SendIcon'; + +// Hooks +export { useTheme } from './hooks/useTheme'; +export { useLocalStorage } from './hooks/useLocalStorage'; + +// Types +export type { Theme } from './types/theme'; +export type { MessageProps } from './types/messages'; diff --git a/packages/webui/src/types/messages.ts b/packages/webui/src/types/messages.ts new file mode 100644 index 000000000..9556b6176 --- /dev/null +++ b/packages/webui/src/types/messages.ts @@ -0,0 +1,7 @@ +export interface MessageProps { + id: string; + content: string; + sender: 'user' | 'system' | 'assistant'; + timestamp?: Date; + className?: string; +} diff --git a/packages/webui/src/types/theme.ts b/packages/webui/src/types/theme.ts new file mode 100644 index 000000000..3418c16f4 --- /dev/null +++ b/packages/webui/src/types/theme.ts @@ -0,0 +1 @@ +export type Theme = 'light' | 'dark' | 'auto'; diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json new file mode 100644 index 000000000..58209e086 --- /dev/null +++ b/packages/webui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "declaration": true, + "declarationDir": "./dist", + "emitDeclarationOnly": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From af76450deef6fe76da8895372879fb4e27d9ed64 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 15 Jan 2026 14:32:21 +0800 Subject: [PATCH 007/113] feat(webui): Infrastructure Setup (Prerequisites) --- .gitignore | 3 + eslint.config.js | 522 ++-- package-lock.json | 2664 ++++++++++++++++- packages/vscode-ide-companion/package.json | 2 +- packages/webui/.storybook/main.ts | 25 + packages/webui/.storybook/preview.css | 11 + packages/webui/.storybook/preview.ts | 15 + packages/webui/WEBUI_MIGRATION_PLAN_EN.md | 428 +++ packages/webui/WEBUI_MIGRATION_PLAN_ZH.md | 428 +++ packages/webui/package.json | 54 +- packages/webui/postcss.config.cjs | 13 + packages/webui/rollup.config.js | 47 - .../src/components/ui/Button.stories.tsx | 83 + .../src/components/ui/Tooltip.stories.tsx | 68 + .../webui/src/context/PlatformContext.tsx | 89 + packages/webui/src/index.ts | 19 + packages/webui/src/styles/variables.css | 52 + packages/webui/src/types/chat.ts | 28 + packages/webui/src/types/toolCall.ts | 48 + packages/webui/tailwind.config.cjs | 11 + packages/webui/tailwind.preset.cjs | 71 + packages/webui/tsconfig.json | 7 +- packages/webui/vite.config.ts | 53 + 23 files changed, 4367 insertions(+), 374 deletions(-) create mode 100644 packages/webui/.storybook/main.ts create mode 100644 packages/webui/.storybook/preview.css create mode 100644 packages/webui/.storybook/preview.ts create mode 100644 packages/webui/WEBUI_MIGRATION_PLAN_EN.md create mode 100644 packages/webui/WEBUI_MIGRATION_PLAN_ZH.md create mode 100644 packages/webui/postcss.config.cjs delete mode 100644 packages/webui/rollup.config.js create mode 100644 packages/webui/src/components/ui/Button.stories.tsx create mode 100644 packages/webui/src/components/ui/Tooltip.stories.tsx create mode 100644 packages/webui/src/context/PlatformContext.tsx create mode 100644 packages/webui/src/styles/variables.css create mode 100644 packages/webui/src/types/chat.ts create mode 100644 packages/webui/src/types/toolCall.ts create mode 100644 packages/webui/tailwind.config.cjs create mode 100644 packages/webui/tailwind.preset.cjs create mode 100644 packages/webui/vite.config.ts diff --git a/.gitignore b/.gitignore index 705216c80..8f088bc64 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ patch_output.log docs-site/.next # content is a symlink to ../docs docs-site/content + +*storybook.log +storybook-static diff --git a/eslint.config.js b/eslint.config.js index 78ff26c88..86fb9a4e3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,6 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; + /** * @license * Copyright 2025 Google LLC @@ -13,277 +16,260 @@ import importPlugin from 'eslint-plugin-import'; import vitest from '@vitest/eslint-plugin'; import globals from 'globals'; -export default tseslint.config( - { - // Global ignores - ignores: [ - 'node_modules/*', - 'packages/**/dist/**', - 'bundle/**', - 'package/bundle/**', - '.integration-tests/**', - 'packages/**/.integration-test/**', - 'dist/**', - 'docs-site/.next/**', - 'docs-site/out/**', +export default tseslint.config({ + // Global ignores + ignores: [ + 'node_modules/*', + 'packages/**/dist/**', + 'bundle/**', + 'package/bundle/**', + '.integration-tests/**', + 'packages/**/.integration-test/**', + 'dist/**', + 'docs-site/.next/**', + 'docs-site/out/**', + ], +}, eslint.configs.recommended, ...tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactPlugin.configs.flat.recommended, // Add this if you are using React 17+ +reactPlugin.configs.flat['jsx-runtime'], { + // Settings for eslint-plugin-react + settings: { + react: { + version: 'detect', + }, + }, +}, { + // Import specific config + files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + node: true, + }, + }, + rules: { + ...importPlugin.configs.recommended.rules, + ...importPlugin.configs.typescript.rules, + 'import/no-default-export': 'warn', + 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths + }, +}, { + // General overrides and rules for the project (TS/TSX files) + files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + node: true, + }, + }, + languageOptions: { + globals: { + ...globals.node, + ...globals.es2021, + }, + }, + rules: { + // We use TypeScript for React components; prop-types are unnecessary + 'react/prop-types': 'off', + // General Best Practice Rules (subset adapted for flat config) + '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], + 'arrow-body-style': ['error', 'as-needed'], + curly: ['error', 'multi-line'], + eqeqeq: ['error', 'always', { null: 'ignore' }], + '@typescript-eslint/consistent-type-assertions': [ + 'error', + { assertionStyle: 'as' }, + ], + '@typescript-eslint/explicit-member-accessibility': [ + 'error', + { accessibility: 'no-public' }, + ], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-inferrable-types': [ + 'error', + { ignoreParameters: true, ignoreProperties: true }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { disallowTypeAnnotations: false }, + ], + '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + 'import/no-internal-modules': [ + 'error', + { + allow: [ + 'react-dom/test-utils', + 'react-dom/client', + 'memfs/lib/volume.js', + 'yargs/**', + 'msw/node', + '**/generated/**', + './styles/tailwind.css', + './styles/App.css', + './styles/style.css' + ], + }, + ], + 'import/no-relative-packages': 'error', + 'no-cond-assign': 'error', + 'no-debugger': 'error', + 'no-duplicate-case': 'error', + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, + ], + 'no-unsafe-finally': 'error', + 'no-unused-expressions': 'off', // Disable base rule + '@typescript-eslint/no-unused-expressions': [ + // Enable TS version + 'error', + { allowShortCircuit: true, allowTernary: true }, + ], + 'no-var': 'error', + 'object-shorthand': 'error', + 'one-var': ['error', 'never'], + 'prefer-arrow-callback': 'error', + 'prefer-const': ['error', { destructuring: 'all' }], + radix: 'error', + 'default-case': 'error', + }, +}, { + files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], + plugins: { + vitest, + }, + rules: { + ...vitest.configs.recommended.rules, + 'vitest/expect-expect': 'off', + 'vitest/no-commented-out-tests': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, ], }, - eslint.configs.recommended, - ...tseslint.configs.recommended, - reactHooks.configs['recommended-latest'], - reactPlugin.configs.flat.recommended, - reactPlugin.configs.flat['jsx-runtime'], // Add this if you are using React 17+ - { - // Settings for eslint-plugin-react - settings: { - react: { - version: 'detect', +}, // extra settings for scripts that we run directly with node +{ + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, +}, { + files: ['packages/vscode-ide-companion/esbuild.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, +}, // extra settings for scripts that we run directly with node +{ + files: ['packages/vscode-ide-companion/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, +}, // extra settings for core package scripts +{ + files: ['packages/core/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, +}, // Prettier config must be last +prettierConfig, // extra settings for scripts that we run directly with node +{ + files: ['./integration-tests/**/*.{js,ts,tsx}'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, +}, // Settings for docs-site directory +{ + files: ['docs-site/**/*.{js,jsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, }, }, }, - { - // Import specific config - files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - node: true, - }, - }, - rules: { - ...importPlugin.configs.recommended.rules, - ...importPlugin.configs.typescript.rules, - 'import/no-default-export': 'warn', - 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths - }, + rules: { + // Allow relaxed rules for documentation site + '@typescript-eslint/no-unused-vars': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', }, - { - // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - node: true, - }, - }, - languageOptions: { - globals: { - ...globals.node, - ...globals.es2021, - }, - }, - rules: { - // We use TypeScript for React components; prop-types are unnecessary - 'react/prop-types': 'off', - // General Best Practice Rules (subset adapted for flat config) - '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], - 'arrow-body-style': ['error', 'as-needed'], - curly: ['error', 'multi-line'], - eqeqeq: ['error', 'always', { null: 'ignore' }], - '@typescript-eslint/consistent-type-assertions': [ - 'error', - { assertionStyle: 'as' }, - ], - '@typescript-eslint/explicit-member-accessibility': [ - 'error', - { accessibility: 'no-public' }, - ], - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-inferrable-types': [ - 'error', - { ignoreParameters: true, ignoreProperties: true }, - ], - '@typescript-eslint/consistent-type-imports': [ - 'error', - { disallowTypeAnnotations: false }, - ], - '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - 'import/no-internal-modules': [ - 'error', - { - allow: [ - 'react-dom/test-utils', - 'react-dom/client', - 'memfs/lib/volume.js', - 'yargs/**', - 'msw/node', - '**/generated/**', - './styles/tailwind.css', - './styles/App.css', - './styles/style.css' - ], - }, - ], - 'import/no-relative-packages': 'error', - 'no-cond-assign': 'error', - 'no-debugger': 'error', - 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], - 'no-unsafe-finally': 'error', - 'no-unused-expressions': 'off', // Disable base rule - '@typescript-eslint/no-unused-expressions': [ - // Enable TS version - 'error', - { allowShortCircuit: true, allowTernary: true }, - ], - 'no-var': 'error', - 'object-shorthand': 'error', - 'one-var': ['error', 'never'], - 'prefer-arrow-callback': 'error', - 'prefer-const': ['error', { destructuring: 'all' }], - radix: 'error', - 'default-case': 'error', - }, - }, - { - files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], - plugins: { - vitest, - }, - rules: { - ...vitest.configs.recommended.rules, - 'vitest/expect-expect': 'off', - 'vitest/no-commented-out-tests': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - }, - }, - // extra settings for scripts that we run directly with node - { - files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - }, - }, - { - files: ['packages/vscode-ide-companion/esbuild.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', - }, - }, - // extra settings for scripts that we run directly with node - { - files: ['packages/vscode-ide-companion/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', - }, - }, - // extra settings for core package scripts - { - files: ['packages/core/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', - }, - }, - // Prettier config must be last - prettierConfig, - // extra settings for scripts that we run directly with node - { - files: ['./integration-tests/**/*.{js,ts,tsx}'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - }, - }, - // Settings for docs-site directory - { - files: ['docs-site/**/*.{js,jsx}'], - languageOptions: { - globals: { - ...globals.browser, - ...globals.node, - }, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, - rules: { - // Allow relaxed rules for documentation site - '@typescript-eslint/no-unused-vars': 'off', - 'react/prop-types': 'off', - 'react/react-in-jsx-scope': 'off', - }, - }, -); +}, storybook.configs["flat/recommended"]); diff --git a/package-lock.json b/package-lock.json index a5459d69d..371fa7682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,13 @@ "node-pty": "^1.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz", @@ -375,12 +382,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -394,6 +401,183 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -405,22 +589,46 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -429,6 +637,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.27.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", @@ -438,15 +678,49 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -509,6 +783,27 @@ "node": ">=6" } }, + "node_modules/@chromatic-com/storybook": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.0.tgz", + "integrity": "sha512-8wUsqL8kg6R5ue8XNE7Jv/iD1SuE4+6EXMIGIuE+T2loBITEACLfC3V8W44NJviCLusZRMWbzICddz0nU0bFaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@neoconfetti/react": "^1.0.0", + "chromatic": "^13.3.4", + "filesize": "^10.0.12", + "jsonfile": "^6.1.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20.0.0", + "yarn": ">=1.22.18" + }, + "peerDependencies": { + "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1558,19 +1853,129 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.3.tgz", + "integrity": "sha512-9TGZuAX+liGkNKkwuo3FYJu7gHWT0vkBcf7GkOe7s7fmC19XwH/4u5u7sDIFrMooe558ORcmuBvBz7Ur5PlbHw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "glob": "^11.1.0", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=6.0.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1583,16 +1988,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1601,9 +1996,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1848,6 +2243,158 @@ "win32" ] }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@microsoft/api-extractor": { + "version": "7.43.0", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.43.0.tgz", + "integrity": "sha512-GFhTcJpB+MI6FhvXEI9b2K0snulNLWHqC/BbcJtyNYcKUiw7l3Lgis5ApsYncJ0leALX7/of4XfmXk+maT111w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor-model": "7.28.13", + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "~0.16.1", + "@rushstack/node-core-library": "4.0.2", + "@rushstack/rig-package": "0.5.2", + "@rushstack/terminal": "0.10.0", + "@rushstack/ts-command-line": "4.19.1", + "lodash": "~4.17.15", + "minimatch": "~3.0.3", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "source-map": "~0.6.1", + "typescript": "5.4.2" + }, + "bin": { + "api-extractor": "bin/api-extractor" + } + }, + "node_modules/@microsoft/api-extractor-model": { + "version": "7.28.13", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.28.13.tgz", + "integrity": "sha512-39v/JyldX4MS9uzHcdfmjjfS6cYGAoXV+io8B5a338pkHiSt+gy2eXQ0Q7cGFJ7quSa1VqqlMdlPrB6sLR/cAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "@microsoft/tsdoc-config": "~0.16.1", + "@rushstack/node-core-library": "4.0.2" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/typescript": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", + "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", + "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "ajv": "~6.12.6", + "jju": "~1.4.0", + "resolve": "~1.19.0" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.39.5", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.5.tgz", @@ -1866,6 +2413,13 @@ "node": ">=18" } }, + "node_modules/@neoconfetti/react": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@neoconfetti/react/-/react-1.0.0.tgz", + "integrity": "sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==", + "dev": true, + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2839,6 +3393,13 @@ "node": ">=12" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -2919,6 +3480,60 @@ "resolved": "packages/sdk-typescript", "link": true }, + "node_modules/@qwen-code/webui": { + "resolved": "packages/webui", + "link": true + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", @@ -3206,6 +3821,169 @@ "dev": true, "license": "MIT" }, + "node_modules/@rushstack/node-core-library": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-4.0.2.tgz", + "integrity": "sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "~7.0.1", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.5.4", + "z-schema": "~5.0.2" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/@rushstack/rig-package": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.2.tgz", + "integrity": "sha512-mUDecIJeH3yYGZs2a48k+pbhM6JYwWlgjs2Ca5f2n1G2/kgdgP9D/07oglEGf6mRyXEnazhEENeYTSNDRCwdqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "~1.22.1", + "strip-json-comments": "~3.1.1" + } + }, + "node_modules/@rushstack/terminal": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.10.0.tgz", + "integrity": "sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "4.0.2", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/terminal/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.19.1.tgz", + "integrity": "sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.10.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "node_modules/@rushstack/ts-command-line/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -3424,6 +4202,269 @@ "dev": true, "license": "MIT" }, + "node_modules/@storybook/addon-a11y": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.1.11.tgz", + "integrity": "sha512-3sr6HmcDgW1+TQAV9QtWBE3HlGyfFXVZY3RECTNLNH6fRC+rYQCItisvQIVxQpyftLSQ8EAMN9JQzs495MjWNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.11" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.1.11.tgz", + "integrity": "sha512-Jwm291Fhim2eVcZIVlkG1B2skb0ZI9oru6nqMbJxceQZlvZmcIa4oxvS1oaMTKw2DJnCv97gLm57P/YvRZ8eUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "10.1.11", + "@storybook/icons": "^2.0.0", + "@storybook/react-dom-shim": "10.1.11", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.11" + } + }, + "node_modules/@storybook/addon-onboarding": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-10.1.11.tgz", + "integrity": "sha512-DNJv0IDl5XBrY+PPgwnMXLyp3omPkMOS6xe8ejG3csT71B6+3VueL6m7Qivh6739SnAV0QBU5SQlpMA0gQUcSA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.11" + } + }, + "node_modules/@storybook/addon-vitest": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.1.11.tgz", + "integrity": "sha512-YbZzeKO3v+Xr97/malT4DZIATkVZT5EHNYx3xzEfPVuk19dDETAVYXO+tzcqCQHsgdKQHkmd56vv8nN3J3/kvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@vitest/browser": "^3.0.0 || ^4.0.0", + "@vitest/browser-playwright": "^4.0.0", + "@vitest/runner": "^3.0.0 || ^4.0.0", + "storybook": "^10.1.11", + "vitest": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/runner": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-vite": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.1.11.tgz", + "integrity": "sha512-MMD09Ap7FyzDfWG961pkIMv/w684XXe1bBEi+wCEpHxvrgAd3j3A9w/Rqp9Am2uRDPCEdi1QgSzS3SGW3aGThQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "10.1.11", + "@vitest/mocker": "3.2.4", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.1.11", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.1.11.tgz", + "integrity": "sha512-Ant0NhgqHKzQsseeVTSetZCuDHHs0W2HRkHt51Kg/sUl0T/sDtfVA+fWZT8nGzGZqYSFkxqYPWjauPmIhPtaRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^2.3.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "esbuild": "*", + "rollup": "*", + "storybook": "^10.1.11", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "esbuild": { + "optional": true + }, + "rollup": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", + "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@storybook/react": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.1.11.tgz", + "integrity": "sha512-rmMGmEwBaM2YpB8oDk2moM0MNjNMqtwyoPPZxjyruY9WVhYca8EDPGKEdRzUlb4qZJsTgLi7VU4eqg6LD/mL3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "10.1.11", + "react-docgen": "^8.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.1.11", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.1.11.tgz", + "integrity": "sha512-o8WPhRlZbORUWG9lAgDgJP0pi905VHJUFJr1Kp8980gHqtlemtnzjPxKy5vFwj6glNhAlK8SS8OOYzWP7hloTQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.1.11" + } + }, + "node_modules/@storybook/react-vite": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.1.11.tgz", + "integrity": "sha512-qh1BCD25nIoiDfqwha+qBkl7pcG4WuzM+c8tsE63YEm8AFIbNKg5K8lVUoclF+4CpFz7IwBpWe61YUTDfp+91w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.3", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "10.1.11", + "@storybook/react": "10.1.11", + "empathic": "^2.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^8.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.1.11", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/react-vite/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3475,6 +4516,47 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@textlint/ast-node-types": { "version": "15.2.2", "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz", @@ -3605,12 +4687,64 @@ "@types/readdir-glob": "*" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "license": "MIT" }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3686,6 +4820,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dotenv": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-6.1.1.tgz", @@ -3812,6 +4953,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3933,6 +5081,13 @@ "@types/node": "*" } }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -4357,6 +5512,63 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/browser": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", + "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.4", + "@vitest/utils": "3.2.4", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.4", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", @@ -4529,6 +5741,37 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, "node_modules/@vscode/vsce": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz", @@ -4850,6 +6093,110 @@ "dev": true, "license": "ISC" }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "dev": true, + "license": "MIT" + }, "node_modules/@xterm/headless": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz", @@ -5477,6 +6824,19 @@ "node": ">=12" } }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.3.tgz", @@ -5606,6 +6966,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -6236,6 +7606,30 @@ "node": ">=18" } }, + "node_modules/chromatic": { + "version": "13.3.5", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.5.tgz", + "integrity": "sha512-MzPhxpl838qJUo0A55osCF2ifwPbjcIPeElr1d4SHcjnHoIcg7l1syJDrAYK/a+PcCBrOGi06jPNpQAln5hWgw==", + "dev": true, + "license": "MIT", + "bin": { + "chroma": "dist/bin.js", + "chromatic": "dist/bin.js", + "chromatic-cli": "dist/bin.js" + }, + "peerDependencies": { + "@chromatic-com/cypress": "^0.*.* || ^1.0.0", + "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + }, + "peerDependenciesMeta": { + "@chromatic-com/cypress": { + "optional": true + }, + "@chromatic-com/playwright": { + "optional": true + } + } + }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -6583,6 +7977,13 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6653,6 +8054,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -6828,6 +8236,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6939,6 +8354,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -7417,6 +8839,16 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -8020,6 +9452,20 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-storybook": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.1.11.tgz", + "integrity": "sha512-mbq2r2kK5+AcLl0XDJ3to91JOgzCbHOqj+J3n+FRw6drk+M1boRqMShSoMMm0HdzXPLmlr7iur+qJ5ZuhH6ayQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.8.1" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^10.1.11" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -8550,6 +9996,16 @@ "node": ">=16.0.0" } }, + "node_modules/filesize": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", + "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 10.4.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8898,6 +10354,16 @@ "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", "license": "BSD-3-Clause" }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -9354,6 +10820,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/headers-polyfill": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", @@ -9625,6 +11101,16 @@ "module-details-from-path": "^1.0.3" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -10690,6 +12176,13 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -10759,6 +12252,19 @@ } } }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/json/-/json-11.0.0.tgz", @@ -10981,6 +12487,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ky": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/ky/-/ky-1.8.1.tgz", @@ -11315,6 +12828,14 @@ "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -11329,6 +12850,14 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -11723,6 +13252,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -11812,6 +13351,16 @@ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11883,6 +13432,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -12629,15 +14185,15 @@ } }, "node_modules/open": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", - "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "license": "MIT", "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", - "is-wsl": "^3.1.0" + "wsl-utils": "^0.1.0" }, "engines": { "node": ">=18" @@ -12925,6 +14481,13 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13101,6 +14664,54 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -13678,11 +15289,55 @@ } } }, + "node_modules/react-docgen": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.2.tgz", + "integrity": "sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, + "node_modules/react-docgen/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.26.0" @@ -13712,6 +15367,16 @@ "react": "^19.1.0" } }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -13888,6 +15553,60 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -14747,6 +16466,21 @@ "url": "https://github.com/steveukx/git-js?sponsor=1" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -14803,6 +16537,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -14910,6 +16654,42 @@ "node": ">= 0.4" } }, + "node_modules/storybook": { + "version": "10.1.11", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.11.tgz", + "integrity": "sha512-pKP5jXJYM4OjvNklGuHKO53wOCAwfx79KvZyOWHoi9zXUH5WVMFUe/ZfWyxXG/GTcj0maRgHGUjq/0I43r0dDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "open": "^10.2.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "use-sync-external-store": "^1.5.0", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -15167,6 +16947,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -15688,6 +17481,13 @@ "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==", "license": "MIT" }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -15845,6 +17645,16 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -15901,6 +17711,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -16244,6 +18064,35 @@ "node": ">= 0.8" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -16423,6 +18272,16 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -16463,6 +18322,16 @@ "spdx-expression-parse": "^3.0.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -16583,6 +18452,34 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-dts": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-3.9.1.tgz", + "integrity": "sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor": "7.43.0", + "@rollup/pluginutils": "^5.1.0", + "@vue/language-core": "^1.8.27", + "debug": "^4.3.4", + "kolorist": "^1.8.0", + "magic-string": "^0.30.8", + "vue-tsc": "^1.8.27" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "typescript": "*", + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -16697,6 +18594,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -16729,6 +18655,13 @@ "node": ">=12" } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -17047,6 +18980,21 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", @@ -17242,6 +19190,38 @@ "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", @@ -19389,6 +21369,39 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "packages/sdk-typescript/node_modules/@vitest/browser": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.6.1.tgz", + "integrity": "sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@vitest/utils": "1.6.1", + "magic-string": "^0.30.5", + "sirv": "^2.0.4" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "1.6.1", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, "packages/sdk-typescript/node_modules/@vitest/coverage-v8": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", @@ -20213,6 +22226,23 @@ "url": "https://opencollective.com/express" } }, + "packages/sdk-typescript/node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "packages/sdk-typescript/node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -21424,6 +23454,7 @@ "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", + "@qwen-code/webui": "*", "cors": "^2.8.5", "express": "^5.1.0", "markdown-it": "^14.1.0", @@ -21725,6 +23756,555 @@ "engines": { "node": ">= 0.6" } + }, + "packages/webui": { + "name": "@qwen-code/webui", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@chromatic-com/storybook": "^5.0.0", + "@storybook/addon-a11y": "^10.1.11", + "@storybook/addon-docs": "^10.1.11", + "@storybook/addon-onboarding": "^10.1.11", + "@storybook/addon-vitest": "^10.1.11", + "@storybook/react-vite": "^10.1.11", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^4.2.0", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "autoprefixer": "^10.4.0", + "eslint-plugin-storybook": "^10.1.11", + "playwright": "^1.57.0", + "postcss": "^8.4.0", + "storybook": "^10.1.11", + "tailwindcss": "^3.4.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vite-plugin-dts": "^3.7.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "packages/webui/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "packages/webui/node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "packages/webui/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "packages/webui/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "packages/webui/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "packages/webui/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } } } } diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 1ed40e136..cb0970fc1 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -152,7 +152,7 @@ "vitest": "^3.2.4" }, "dependencies": { - "@qwen-code/webui": "workspace:*", + "@qwen-code/webui": "*", "semver": "^7.7.2", "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", diff --git a/packages/webui/.storybook/main.ts b/packages/webui/.storybook/main.ts new file mode 100644 index 000000000..b76cf892f --- /dev/null +++ b/packages/webui/.storybook/main.ts @@ -0,0 +1,25 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +import { dirname } from 'path'; + +import { fileURLToPath } from 'url'; + +/** + * This function is used to resolve the absolute path of a package. + * It is needed in projects that use Yarn PnP or are set up within a monorepo. + */ +function getAbsolutePath(value: string): string { + return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`))); +} +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + getAbsolutePath('@chromatic-com/storybook'), + getAbsolutePath('@storybook/addon-vitest'), + getAbsolutePath('@storybook/addon-a11y'), + getAbsolutePath('@storybook/addon-docs'), + getAbsolutePath('@storybook/addon-onboarding'), + ], + framework: getAbsolutePath('@storybook/react-vite'), +}; +export default config; diff --git a/packages/webui/.storybook/preview.css b/packages/webui/.storybook/preview.css new file mode 100644 index 000000000..ae008a63c --- /dev/null +++ b/packages/webui/.storybook/preview.css @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import '../src/styles/variables.css'; diff --git a/packages/webui/.storybook/preview.ts b/packages/webui/.storybook/preview.ts new file mode 100644 index 000000000..13dd9ec87 --- /dev/null +++ b/packages/webui/.storybook/preview.ts @@ -0,0 +1,15 @@ +import type { Preview } from '@storybook/react-vite'; +import './preview.css'; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/packages/webui/WEBUI_MIGRATION_PLAN_EN.md b/packages/webui/WEBUI_MIGRATION_PLAN_EN.md new file mode 100644 index 000000000..9e60de1e4 --- /dev/null +++ b/packages/webui/WEBUI_MIGRATION_PLAN_EN.md @@ -0,0 +1,428 @@ +# WebUI Component Library Extraction Plan + +## 1. Background and Goals + +### 1.1 Background + +`packages/vscode-ide-companion` is a VSCode extension whose core content is a WebView page with UI components provided by React. As the product line expands, more scenarios require building products with Web UI: + +- **Chrome Browser Extension** - Sidebar chat interface +- **Web Chat Page** - Pure web application +- **Conversation Share Page** - Render conversations as static HTML + +For excellent software engineering architecture, we need to unify and reuse UI components across products. + +### 1.2 Goals + +1. Extract components from `vscode-ide-companion/src/webview/` into an independent `@qwen-code/webui` package +2. Establish a layered architecture: Pure UI components + Business UI components +3. Use Vite + Storybook for development and component showcase +4. Abstract platform capabilities through Platform Context for cross-platform reuse +5. Provide Tailwind CSS preset to ensure UI consistency across products + +--- + +## 2. Current State Analysis + +### 2.1 Current Code Structure + +`packages/vscode-ide-companion/src/webview/` contains 77 files: + +``` +webview/ +├── App.tsx # Main entry +├── components/ +│ ├── icons/ # 8 icon components +│ ├── layout/ # 8 layout components +│ │ ├── ChatHeader.tsx +│ │ ├── InputForm.tsx +│ │ ├── SessionSelector.tsx +│ │ ├── EmptyState.tsx +│ │ ├── Onboarding.tsx +│ │ └── ... +│ ├── messages/ # Message display components +│ │ ├── UserMessage.tsx +│ │ ├── Assistant/ +│ │ ├── MarkdownRenderer/ +│ │ ├── ThinkingMessage.tsx +│ │ ├── Waiting/ +│ │ └── toolcalls/ # 16 tool call components +│ ├── PermissionDrawer/ # Permission request drawer +│ └── Tooltip.tsx +├── hooks/ # Custom hooks +├── handlers/ # Message handlers +├── styles/ # CSS styles +└── utils/ # Utility functions +``` + +### 2.2 Key Dependency Analysis + +**Platform Coupling Points:** + +- `useVSCode` hook - Calls `acquireVsCodeApi()` for message communication +- `handlers/` - Handles VSCode message protocol +- Some type definitions come from `../types/` directory + +``` +┌─────────────────────────────────────────────────────────┐ +│ App.tsx (Entry) │ +├─────────────────────────────────────────────────────────┤ +│ hooks/ │ handlers/ │ components/ │ +│ ├─useVSCode ◄───┼──────────────────┼──────────────────┤ +│ ├─useSession │ ├─MessageRouter │ ├─icons/ │ +│ ├─useFileContext│ ├─AuthHandler │ ├─layout/ │ +│ └─... │ └─... │ ├─messages/ │ +│ │ │ └─PermDrawer/ │ +├─────────────────────────────────────────────────────────┤ +│ VSCode API (acquireVsCodeApi) │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Target Architecture + +### 3.1 Layered Architecture Design + +``` +┌─────────────────────────────────────────────────────────┐ +│ Layer 3: Platform Adapters │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │VSCode Adapter│ │Chrome Adapter│ │ Web Adapter │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +├─────────┼────────────────┼────────────────┼────────────┤ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Platform Context Provider │ │ +│ └─────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ Layer 2: Chat Components │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ MessageList│ │ ChatHeader │ │ InputForm │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ Layer 1: Primitives (Pure UI) │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ Button │ │ Input │ │ Icons │ │Tooltip │ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 Platform Context Design + +```typescript +// @qwen-code/webui/src/context/PlatformContext.ts +interface PlatformContext { + // Message communication + postMessage: (message: unknown) => void; + onMessage: (handler: (message: unknown) => void) => () => void; + + // File operations + openFile?: (path: string) => void; + attachFile?: () => void; + + // Authentication + login?: () => void; + + // Platform info + platform: 'vscode' | 'chrome' | 'web' | 'share'; +} +``` + +--- + +## 4. Technical Solution + +### 4.1 Build Configuration (Vite Library Mode) + +**Output formats:** + +- ESM (`dist/index.js`) - Primary format +- CJS (`dist/index.cjs`) - Compatibility +- TypeScript declarations (`dist/index.d.ts`) + +```javascript +// vite.config.ts +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + formats: ['es', 'cjs'], + fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, + }, + rollupOptions: { + external: ['react', 'react-dom'], + }, + }, +}); +``` + +### 4.2 Tailwind Preset Solution + +```javascript +// @qwen-code/webui/tailwind.preset.js +module.exports = { + theme: { + extend: { + colors: { + 'app-primary': 'var(--app-primary)', + 'app-background': 'var(--app-primary-background)', + 'app-foreground': 'var(--app-primary-foreground)', + }, + }, + }, +}; + +// Consumer's tailwind.config.js +module.exports = { + presets: [require('@qwen-code/webui/tailwind.preset')], + content: [ + './src/**/*.{ts,tsx}', + './node_modules/@qwen-code/webui/dist/**/*.js', + ], +}; +``` + +### 4.3 Storybook Configuration + +``` +packages/webui/ +├── .storybook/ +│ ├── main.ts # Storybook config +│ ├── preview.ts # Global decorators +│ └── manager.ts # UI config +└── src/ + └── stories/ # Story files +``` + +--- + +## 5. Component Migration Classification + +### 5.1 Batch 1: No-dependency Components (Ready to migrate) + +| Component | Source Path | Complexity | Notes | +| ------------------ | ------------------------ | ---------- | --------------------------- | +| Icons | `components/icons/` | Low | 8 icon components, pure SVG | +| Tooltip | `components/Tooltip.tsx` | Low | Pure UI | +| WaitingMessage | `messages/Waiting/` | Low | Loading state display | +| InterruptedMessage | `messages/Waiting/` | Low | Interrupted state display | + +### 5.2 Batch 2: Light-dependency Components (Need props abstraction) + +| Component | Source Path | Dependency | Refactoring | +| ---------------- | ------------------------------ | ----------- | ---------------- | +| UserMessage | `messages/UserMessage.tsx` | onFileClick | Props injection | +| AssistantMessage | `messages/Assistant/` | onFileClick | Props injection | +| ThinkingMessage | `messages/ThinkingMessage.tsx` | onFileClick | Props injection | +| MarkdownRenderer | `messages/MarkdownRenderer/` | None | Direct migration | +| EmptyState | `layout/EmptyState.tsx` | None | Direct migration | +| ChatHeader | `layout/ChatHeader.tsx` | callbacks | Props injection | + +### 5.3 Batch 3: Medium-dependency Components (Need Context) + +| Component | Source Path | Dependency | Refactoring | +| ------------------- | ---------------------------- | --------------------- | ----------------- | +| InputForm | `layout/InputForm.tsx` | Multiple callbacks | Context + Props | +| SessionSelector | `layout/SessionSelector.tsx` | session data | Props injection | +| CompletionMenu | `layout/CompletionMenu.tsx` | items data | Props injection | +| PermissionDrawer | `PermissionDrawer/` | callbacks | Context + Props | +| ToolCall components | `messages/toolcalls/` | Various tool displays | Modular migration | + +### 5.4 Batch 4: Heavy-dependency (Keep in platform package) + +| Component/Module | Notes | +| ---------------- | ------------------------------------------------- | +| App.tsx | Main entry, contains business orchestration logic | +| hooks/ | Most require platform adaptation | +| handlers/ | VSCode message handling | +| Onboarding | Authentication related, platform-specific | + +--- + +## 6. Incremental Migration Strategy + +### 6.1 Migration Principles + +1. **Bidirectional compatibility**: During migration, vscode-ide-companion can import from both webui and local +2. **One-by-one replacement**: For each migrated component, replace import path in VSCode extension and verify +3. **No breaking changes**: Ensure the extension builds and runs normally after each migration + +### 6.2 Migration Workflow + +``` +Developer ──► @qwen-code/webui ──► vscode-ide-companion + │ │ │ + │ 1. Copy component to webui │ + │ 2. Add Story for verification │ + │ 3. Export from index.ts │ + │ │ │ + │ └──────────────────────┤ + │ │ + │ 4. Update import path + │ 5. Delete original component + │ 6. Build and test +``` + +### 6.3 Example: Migrating Icons + +```typescript +// Before: vscode-ide-companion/src/webview/components/icons/index.ts +export { FileIcon } from './FileIcons.js'; + +// After: Update import +import { FileIcon } from '@qwen-code/webui'; +// or import { FileIcon } from '@qwen-code/webui/icons'; +``` + +--- + +## 7. Task Breakdown + +### Phase 0: Infrastructure Setup (Prerequisites) + +- [ ] **T0-1**: Vite build configuration +- [ ] **T0-2**: Storybook configuration +- [ ] **T0-3**: Tailwind preset creation +- [ ] **T0-4**: Platform Context definition +- [ ] **T0-5**: Shared types migration + +### Phase 1: Pure UI Components Migration + +- [ ] **T1-1**: Icons components migration (8 files) +- [ ] **T1-2**: Tooltip component migration +- [ ] **T1-3**: WaitingMessage / InterruptedMessage migration +- [ ] **T1-4**: Basic Button/Input components refinement + +### Phase 2: Message Components Migration + +- [ ] **T2-1**: MarkdownRenderer migration +- [ ] **T2-2**: UserMessage migration +- [ ] **T2-3**: AssistantMessage migration +- [ ] **T2-4**: ThinkingMessage migration + +### Phase 3: Layout Components Migration + +- [ ] **T3-1**: ChatHeader migration +- [ ] **T3-2**: EmptyState migration +- [ ] **T3-3**: InputForm migration (requires Context) +- [ ] **T3-4**: SessionSelector migration +- [ ] **T3-5**: CompletionMenu migration + +### Phase 4: Complex Components Migration + +- [ ] **T4-1**: PermissionDrawer migration +- [ ] **T4-2**: ToolCall series components migration (16 files) + +### Phase 5: Platform Adapters + +- [ ] **T5-1**: VSCode Adapter implementation +- [ ] **T5-2**: Chrome Extension Adapter +- [ ] **T5-3**: Web/Share Page Adapter + +--- + +## 8. Risks and Considerations + +### 8.1 Common Pitfalls + +1. **Tailwind Class Name Tree Shaking** + - Problem: Tailwind class names may be removed after library bundling + - Solution: Consumer's `content` config needs to include `node_modules/@qwen-code/webui` + +2. **CSS Variable Scope** + - Problem: Variables like `var(--app-primary)` need to be defined by consumers + - Solution: Provide default CSS variables file, or define fallbacks in Tailwind preset + +3. **React Version Compatibility** + - Current vscode-ide-companion uses React 19, webui's peerDependencies is React 18 + - Need to update peerDependencies to `"react": "^18.0.0 || ^19.0.0"` + +4. **ESM/CJS Compatibility** + - VSCode extensions may require CJS format + - Vite needs to be configured for dual format output + +### 8.2 Industry References + +- **Radix UI**: Pure Headless components, styles completely controlled by consumers +- **shadcn/ui**: Copy components into project, rather than importing as dependency +- **Ant Design**: Complete component library, customization through ConfigProvider + +### 8.3 Acceptance Criteria + +Each migration task completion requires: + +1. Component has corresponding Storybook Story +2. Import in vscode-ide-companion has been updated +3. Extension builds successfully (`npm run build:vscode`) +4. Extension functionality works (manual testing or existing tests pass) + +--- + +## 9. Time Estimation + +| Phase | Tasks | Estimated Days | Parallelizable | +| ------- | ----- | -------------- | -------------- | +| Phase 0 | 5 | 2-3 days | Partially | +| Phase 1 | 4 | 1-2 days | Fully | +| Phase 2 | 4 | 2-3 days | Fully | +| Phase 3 | 5 | 3-4 days | Partially | +| Phase 4 | 2 | 3-4 days | Yes | +| Phase 5 | 3 | 2-3 days | Yes | + +**Total**: Approximately 13-19 person-days (sequential execution), can be reduced to 1-2 weeks with parallel work + +--- + +## 10. Development and Debugging Workflow + +### 10.1 Component Development Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Development Workflow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Develop/Modify Component │ +│ └── Edit files in @qwen-code/webui/src/ │ +│ │ +│ 2. Debug with Storybook │ +│ └── npm run storybook (port 6006) │ +│ └── View component in isolation │ +│ └── Test different props/states │ +│ │ +│ 3. Build Library │ +│ └── npm run build │ +│ └── Outputs: dist/index.js, dist/index.cjs, dist/index.d.ts │ +│ │ +│ 4. Use in VSCode Extension │ +│ └── import { Component } from '@qwen-code/webui' │ +│ └── No UI code modifications in vscode-ide-companion │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 10.2 Debugging Commands + +```bash +# Start Storybook for component development +cd packages/webui +npm run storybook + +# Watch mode for library development +npm run dev + +# Build library for production +npm run build + +# Type checking +npm run typecheck +``` + +### 10.3 Key Principles + +1. **Single Source of Truth**: All UI components live in `@qwen-code/webui` +2. **Storybook First**: Debug and validate components in Storybook before integration +3. **No UI Code in Consumers**: `vscode-ide-companion` only imports and uses components +4. **Platform Abstraction**: Use `PlatformContext` for platform-specific behaviors diff --git a/packages/webui/WEBUI_MIGRATION_PLAN_ZH.md b/packages/webui/WEBUI_MIGRATION_PLAN_ZH.md new file mode 100644 index 000000000..1e46a3998 --- /dev/null +++ b/packages/webui/WEBUI_MIGRATION_PLAN_ZH.md @@ -0,0 +1,428 @@ +# WebUI 组件库抽离计划 + +## 一、背景与目标 + +### 1.1 背景 + +`packages/vscode-ide-companion` 是一个 VSCode 插件,其核心内容是一个 WebView 页面,大量 UI 部分由 React 组件提供。随着产品线扩展,越来越多的场景需要构建包含 Web UI 的产品: + +- **Chrome 浏览器扩展** - 侧边栏聊天界面 +- **Web 端聊天页面** - 纯 Web 应用 +- **对话分享页面** - 将对话渲染为静态 HTML + +对于优秀的软件工程架构,我们需要让 UI 做到统一且可复用。 + +### 1.2 目标 + +1. 将 `vscode-ide-companion/src/webview/` 中的组件抽离到独立的 `@qwen-code/webui` 包 +2. 建立分层架构:纯 UI 组件 + 业务 UI 组件 +3. 使用 Vite + Storybook 进行开发和组件展示 +4. 通过 Platform Context 抽象平台能力,实现跨平台复用 +5. 提供 Tailwind CSS 预设,保证多产品 UI 一致性 + +--- + +## 二、现状分析 + +### 2.1 当前代码结构 + +`packages/vscode-ide-companion/src/webview/` 包含 77 个文件: + +``` +webview/ +├── App.tsx # 主入口 +├── components/ +│ ├── icons/ # 8 个图标组件 +│ ├── layout/ # 8 个布局组件 +│ │ ├── ChatHeader.tsx +│ │ ├── InputForm.tsx +│ │ ├── SessionSelector.tsx +│ │ ├── EmptyState.tsx +│ │ ├── Onboarding.tsx +│ │ └── ... +│ ├── messages/ # 消息展示组件 +│ │ ├── UserMessage.tsx +│ │ ├── Assistant/ +│ │ ├── MarkdownRenderer/ +│ │ ├── ThinkingMessage.tsx +│ │ ├── Waiting/ +│ │ └── toolcalls/ # 16 个工具调用组件 +│ ├── PermissionDrawer/ # 权限请求抽屉 +│ └── Tooltip.tsx +├── hooks/ # 自定义 hooks +├── handlers/ # 消息处理器 +├── styles/ # CSS 样式 +└── utils/ # 工具函数 +``` + +### 2.2 关键依赖分析 + +**平台耦合点:** + +- `useVSCode` hook - 调用 `acquireVsCodeApi()` 进行消息通信 +- `handlers/` - 处理 VSCode 消息协议 +- 部分类型定义来自 `../types/` 目录 + +``` +┌─────────────────────────────────────────────────────────┐ +│ App.tsx (入口) │ +├─────────────────────────────────────────────────────────┤ +│ hooks/ │ handlers/ │ components/ │ +│ ├─useVSCode ◄───┼──────────────────┼──────────────────┤ +│ ├─useSession │ ├─MessageRouter │ ├─icons/ │ +│ ├─useFileContext│ ├─AuthHandler │ ├─layout/ │ +│ └─... │ └─... │ ├─messages/ │ +│ │ │ └─PermDrawer/ │ +├─────────────────────────────────────────────────────────┤ +│ VSCode API (acquireVsCodeApi) │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 三、目标架构 + +### 3.1 分层架构设计 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Layer 3: Platform Adapters │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │VSCode Adapter│ │Chrome Adapter│ │ Web Adapter │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +├─────────┼────────────────┼────────────────┼────────────┤ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Platform Context Provider │ │ +│ └─────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ Layer 2: Chat Components │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ MessageList│ │ ChatHeader │ │ InputForm │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ Layer 1: Primitives (纯 UI) │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ Button │ │ Input │ │ Icons │ │Tooltip │ │ +│ └────────┘ └────────┘ └────────┘ └────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 Platform Context 设计 + +```typescript +// @qwen-code/webui/src/context/PlatformContext.ts +interface PlatformContext { + // 消息通信 + postMessage: (message: unknown) => void; + onMessage: (handler: (message: unknown) => void) => () => void; + + // 文件操作 + openFile?: (path: string) => void; + attachFile?: () => void; + + // 认证 + login?: () => void; + + // 平台信息 + platform: 'vscode' | 'chrome' | 'web' | 'share'; +} +``` + +--- + +## 四、技术方案 + +### 4.1 构建配置(Vite Library Mode) + +**输出格式:** + +- ESM (`dist/index.js`) - 主要格式 +- CJS (`dist/index.cjs`) - 兼容性 +- TypeScript 声明 (`dist/index.d.ts`) + +```javascript +// vite.config.ts +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + formats: ['es', 'cjs'], + fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, + }, + rollupOptions: { + external: ['react', 'react-dom'], + }, + }, +}); +``` + +### 4.2 Tailwind 预设方案 + +```javascript +// @qwen-code/webui/tailwind.preset.js +module.exports = { + theme: { + extend: { + colors: { + 'app-primary': 'var(--app-primary)', + 'app-background': 'var(--app-primary-background)', + 'app-foreground': 'var(--app-primary-foreground)', + }, + }, + }, +}; + +// 消费方 tailwind.config.js +module.exports = { + presets: [require('@qwen-code/webui/tailwind.preset')], + content: [ + './src/**/*.{ts,tsx}', + './node_modules/@qwen-code/webui/dist/**/*.js', + ], +}; +``` + +### 4.3 Storybook 配置 + +``` +packages/webui/ +├── .storybook/ +│ ├── main.ts # Storybook 配置 +│ ├── preview.ts # 全局装饰器 +│ └── manager.ts # UI 配置 +└── src/ + └── stories/ # Story 文件 +``` + +--- + +## 五、组件迁移分类 + +### 5.1 第一批:无依赖组件(可立即迁移) + +| 组件 | 来源路径 | 复杂度 | 说明 | +| ------------------ | ------------------------ | ------ | -------------------- | +| Icons | `components/icons/` | 低 | 8 个图标组件,纯 SVG | +| Tooltip | `components/Tooltip.tsx` | 低 | 纯 UI | +| WaitingMessage | `messages/Waiting/` | 低 | 加载状态展示 | +| InterruptedMessage | `messages/Waiting/` | 低 | 中断状态展示 | + +### 5.2 第二批:轻度依赖组件(需要抽象 props) + +| 组件 | 来源路径 | 依赖 | 改造方式 | +| ---------------- | ------------------------------ | ----------- | --------------- | +| UserMessage | `messages/UserMessage.tsx` | onFileClick | 通过 props 注入 | +| AssistantMessage | `messages/Assistant/` | onFileClick | 通过 props 注入 | +| ThinkingMessage | `messages/ThinkingMessage.tsx` | onFileClick | 通过 props 注入 | +| MarkdownRenderer | `messages/MarkdownRenderer/` | 无 | 直接迁移 | +| EmptyState | `layout/EmptyState.tsx` | 无 | 直接迁移 | +| ChatHeader | `layout/ChatHeader.tsx` | callbacks | 通过 props 注入 | + +### 5.3 第三批:中度依赖组件(需要 Context) + +| 组件 | 来源路径 | 依赖 | 改造方式 | +| ---------------- | ---------------------------- | -------------- | --------------- | +| InputForm | `layout/InputForm.tsx` | 多个 callbacks | Context + Props | +| SessionSelector | `layout/SessionSelector.tsx` | session 数据 | Props 注入 | +| CompletionMenu | `layout/CompletionMenu.tsx` | items 数据 | Props 注入 | +| PermissionDrawer | `PermissionDrawer/` | 回调函数 | Context + Props | +| ToolCall 组件 | `messages/toolcalls/` | 多种工具展示 | 分模块迁移 | + +### 5.4 第四批:重度依赖(保留在平台包) + +| 组件/模块 | 说明 | +| ---------- | ------------------------ | +| App.tsx | 总入口,包含业务编排逻辑 | +| hooks/ | 大部分需要平台适配 | +| handlers/ | VSCode 消息处理 | +| Onboarding | 认证相关,平台特定 | + +--- + +## 六、渐进式迁移策略 + +### 6.1 迁移原则 + +1. **双向兼容**:迁移期间,vscode-ide-companion 可以同时从 webui 和本地导入 +2. **逐个替换**:每迁移一个组件,在 VSCode 插件中替换导入路径并验证 +3. **不破坏现有功能**:确保每次迁移后插件可正常构建和运行 + +### 6.2 迁移流程 + +``` +开发者 ──► @qwen-code/webui ──► vscode-ide-companion + │ │ │ + │ 1. 复制组件到 webui │ + │ 2. 添加 Story 验证 │ + │ 3. 从 index.ts 导出 │ + │ │ │ + │ └──────────────────────┤ + │ │ + │ 4. 更新 import 路径 + │ 5. 删除原组件文件 + │ 6. 构建测试验证 +``` + +### 6.3 示例:迁移 Icons + +```typescript +// Before: vscode-ide-companion/src/webview/components/icons/index.ts +export { FileIcon } from './FileIcons.js'; + +// After: 修改导入 +import { FileIcon } from '@qwen-code/webui'; +// 或 import { FileIcon } from '@qwen-code/webui/icons'; +``` + +--- + +## 七、任务拆分 + +### Phase 0: 基础设施搭建(前置任务) + +- [ ] **T0-1**: Vite 构建配置 +- [ ] **T0-2**: Storybook 配置 +- [ ] **T0-3**: Tailwind 预设创建 +- [ ] **T0-4**: Platform Context 定义 +- [ ] **T0-5**: 类型定义迁移(共享 types) + +### Phase 1: 纯 UI 组件迁移 + +- [ ] **T1-1**: Icons 组件迁移(8 个文件) +- [ ] **T1-2**: Tooltip 组件迁移 +- [ ] **T1-3**: WaitingMessage / InterruptedMessage 迁移 +- [ ] **T1-4**: 基础 Button/Input 组件完善 + +### Phase 2: 消息组件迁移 + +- [ ] **T2-1**: MarkdownRenderer 迁移 +- [ ] **T2-2**: UserMessage 迁移 +- [ ] **T2-3**: AssistantMessage 迁移 +- [ ] **T2-4**: ThinkingMessage 迁移 + +### Phase 3: 布局组件迁移 + +- [ ] **T3-1**: ChatHeader 迁移 +- [ ] **T3-2**: EmptyState 迁移 +- [ ] **T3-3**: InputForm 迁移(需要 Context) +- [ ] **T3-4**: SessionSelector 迁移 +- [ ] **T3-5**: CompletionMenu 迁移 + +### Phase 4: 复杂组件迁移 + +- [ ] **T4-1**: PermissionDrawer 迁移 +- [ ] **T4-2**: ToolCall 系列组件迁移(16 个文件) + +### Phase 5: 平台适配器 + +- [ ] **T5-1**: VSCode Adapter 实现 +- [ ] **T5-2**: Chrome Extension Adapter +- [ ] **T5-3**: Web/Share Page Adapter + +--- + +## 八、风险与注意事项 + +### 8.1 常见坑点 + +1. **Tailwind 类名 Tree Shaking** + - 问题:组件库打包后 Tailwind 类名可能被移除 + - 解决:消费方的 `content` 配置需要包含 `node_modules/@qwen-code/webui` + +2. **CSS 变量作用域** + - 问题:`var(--app-primary)` 等变量需要在消费方定义 + - 解决:提供默认 CSS 变量文件,或在 Tailwind 预设中定义 fallback + +3. **React 版本兼容** + - 当前 vscode-ide-companion 使用 React 19,webui 的 peerDependencies 是 React 18 + - 需要更新 peerDependencies 为 `"react": "^18.0.0 || ^19.0.0"` + +4. **ESM/CJS 兼容** + - VSCode 扩展可能需要 CJS 格式 + - Vite 需要配置双格式输出 + +### 8.2 业界参考 + +- **Radix UI**: 纯 Headless 组件,样式完全由消费方控制 +- **shadcn/ui**: 复制组件到项目中,而非作为依赖引入 +- **Ant Design**: 完整的组件库,通过 ConfigProvider 进行定制 + +### 8.3 验收标准 + +每个迁移任务完成后需要: + +1. 组件有对应的 Storybook Story +2. vscode-ide-companion 中的导入已更新 +3. 插件可正常构建 (`npm run build:vscode`) +4. 插件功能正常(手动测试或已有测试通过) + +--- + +## 九、预估时间 + +| 阶段 | 任务数 | 预估人天 | 可并行 | +| ------- | ------ | -------- | ---------- | +| Phase 0 | 5 | 2-3 天 | 部分可并行 | +| Phase 1 | 4 | 1-2 天 | 全部可并行 | +| Phase 2 | 4 | 2-3 天 | 全部可并行 | +| Phase 3 | 5 | 3-4 天 | 部分可并行 | +| Phase 4 | 2 | 3-4 天 | 可并行 | +| Phase 5 | 3 | 2-3 天 | 可并行 | + +**总计**:约 13-19 人天(单人顺序执行),如果多人并行可缩短至 1-2 周 + +--- + +## 十、开发与调试流程 + +### 10.1 组件开发流程 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 开发工作流程 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 开发/修改组件 │ +│ └── 在 @qwen-code/webui/src/ 中编辑文件 │ +│ │ +│ 2. 使用 Storybook 调试 │ +│ └── npm run storybook (端口 6006) │ +│ └── 独立查看组件 │ +│ └── 测试不同的 props/状态 │ +│ │ +│ 3. 构建组件库 │ +│ └── npm run build │ +│ └── 输出: dist/index.js, dist/index.cjs, dist/index.d.ts │ +│ │ +│ 4. 在 VSCode 插件中使用 │ +│ └── import { Component } from '@qwen-code/webui' │ +│ └── vscode-ide-companion 中不再修改 UI 代码 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 10.2 调试命令 + +```bash +# 启动 Storybook 进行组件开发 +cd packages/webui +npm run storybook + +# 监听模式进行库开发 +npm run dev + +# 构建生产版本 +npm run build + +# 类型检查 +npm run typecheck +``` + +### 10.3 核心原则 + +1. **单一数据源**: 所有 UI 组件都在 `@qwen-code/webui` 中 +2. **Storybook 优先**: 在集成前先在 Storybook 中调试和验证组件 +3. **消费方不修改 UI 代码**: `vscode-ide-companion` 只导入和使用组件 +4. **平台抽象**: 使用 `PlatformContext` 处理平台特定行为 diff --git a/packages/webui/package.json b/packages/webui/package.json index 757a6d812..ffd2b956d 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -2,28 +2,60 @@ "name": "@qwen-code/webui", "version": "0.1.0", "description": "Shared UI components for Qwen Code packages", - "main": "dist/index.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts", "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./tailwind.preset": "./tailwind.preset.cjs", + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist", + "tailwind.preset.cjs" + ], + "sideEffects": [ + "**/*.css" + ], "scripts": { - "build": "tsc && rollup -c", - "dev": "tsc --watch", + "dev": "vite build --watch", + "build": "vite build", + "typecheck": "tsc --noEmit", "lint": "eslint src --ext .ts,.tsx", "lint:fix": "eslint src --ext .ts,.tsx --fix", - "typecheck": "tsc --noEmit" + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", "typescript": "^5.0.0", - "@rollup/plugin-typescript": "^11.0.0", - "rollup": "^4.0.0", - "rollup-plugin-dts": "^6.0.0" + "vite": "^5.0.0", + "vite-plugin-dts": "^3.7.0", + "storybook": "^10.1.11", + "@storybook/react-vite": "^10.1.11", + "@chromatic-com/storybook": "^5.0.0", + "@storybook/addon-vitest": "^10.1.11", + "@storybook/addon-a11y": "^10.1.11", + "@storybook/addon-docs": "^10.1.11", + "@storybook/addon-onboarding": "^10.1.11", + "eslint-plugin-storybook": "^10.1.11", + "playwright": "^1.57.0", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4" }, "keywords": [ "qwen", diff --git a/packages/webui/postcss.config.cjs b/packages/webui/postcss.config.cjs new file mode 100644 index 000000000..29fd6d75d --- /dev/null +++ b/packages/webui/postcss.config.cjs @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/webui/rollup.config.js b/packages/webui/rollup.config.js deleted file mode 100644 index c6340858a..000000000 --- a/packages/webui/rollup.config.js +++ /dev/null @@ -1,47 +0,0 @@ -import typescript from '@rollup/plugin-typescript'; -import { dts } from 'rollup-plugin-dts'; -import pkg from './package.json' with { type: 'json' }; - -const name = pkg.name; - -export default [ - // Browser-friendly version - { - input: 'src/index.ts', - output: { - name, - file: 'dist/index.min.js', - format: 'iife', - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - }, - external: ['react', 'react-dom'], - plugins: [ - typescript({ - tsconfig: './tsconfig.json', - }), - ], - }, - // ES module version - { - input: 'src/index.ts', - output: [ - { file: 'dist/index.esm.js', format: 'es' }, - { file: 'dist/index.cjs.js', format: 'cjs' }, - ], - external: ['react', 'react-dom'], - plugins: [ - typescript({ - tsconfig: './tsconfig.json', - }), - ], - }, - // Type declarations - { - input: 'dist/dts/src/index.d.ts', - output: [{ file: 'dist/index.d.ts', format: 'es' }], - plugins: [dts()], - }, -]; diff --git a/packages/webui/src/components/ui/Button.stories.tsx b/packages/webui/src/components/ui/Button.stories.tsx new file mode 100644 index 000000000..68724cd84 --- /dev/null +++ b/packages/webui/src/components/ui/Button.stories.tsx @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import Button from './Button'; + +/** + * Button component for user interactions. + * Supports multiple variants and sizes. + */ +const meta: Meta = { + title: 'UI/Button', + component: Button, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['primary', 'secondary', 'danger'], + description: 'Visual style variant', + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Button size', + }, + disabled: { + control: 'boolean', + description: 'Disabled state', + }, + onClick: { action: 'clicked' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Primary: Story = { + args: { + children: 'Primary Button', + variant: 'primary', + }, +}; + +export const Secondary: Story = { + args: { + children: 'Secondary Button', + variant: 'secondary', + }, +}; + +export const Danger: Story = { + args: { + children: 'Danger Button', + variant: 'danger', + }, +}; + +export const Small: Story = { + args: { + children: 'Small Button', + size: 'sm', + }, +}; + +export const Large: Story = { + args: { + children: 'Large Button', + size: 'lg', + }, +}; + +export const Disabled: Story = { + args: { + children: 'Disabled Button', + disabled: true, + }, +}; diff --git a/packages/webui/src/components/ui/Tooltip.stories.tsx b/packages/webui/src/components/ui/Tooltip.stories.tsx new file mode 100644 index 000000000..6ba656ad5 --- /dev/null +++ b/packages/webui/src/components/ui/Tooltip.stories.tsx @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import Tooltip from './Tooltip'; +import Button from './Button'; + +/** + * Tooltip component for displaying contextual information on hover. + * Supports four positions: top, right, bottom, left. + */ +const meta: Meta = { + title: 'UI/Tooltip', + component: Tooltip, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + position: { + control: 'select', + options: ['top', 'right', 'bottom', 'left'], + description: 'Tooltip position relative to trigger', + }, + content: { + control: 'text', + description: 'Tooltip content text', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Top: Story = { + args: { + content: 'Tooltip on top', + position: 'top', + children: , + }, +}; + +export const Right: Story = { + args: { + content: 'Tooltip on right', + position: 'right', + children: , + }, +}; + +export const Bottom: Story = { + args: { + content: 'Tooltip on bottom', + position: 'bottom', + children: , + }, +}; + +export const Left: Story = { + args: { + content: 'Tooltip on left', + position: 'left', + children: , + }, +}; diff --git a/packages/webui/src/context/PlatformContext.tsx b/packages/webui/src/context/PlatformContext.tsx new file mode 100644 index 000000000..dd244c72b --- /dev/null +++ b/packages/webui/src/context/PlatformContext.tsx @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { createContext, useContext } from 'react'; + +/** + * Platform types supported by the webui library + */ +export type PlatformType = 'vscode' | 'chrome' | 'web' | 'share'; + +/** + * Platform context interface for cross-platform component reuse. + * Each platform adapter implements this interface. + */ +export interface PlatformContextValue { + /** Current platform identifier */ + platform: PlatformType; + + /** Send message to platform host */ + postMessage: (message: unknown) => void; + + /** Subscribe to messages from platform host */ + onMessage: (handler: (message: unknown) => void) => () => void; + + /** Open a file in the platform's editor (optional) */ + openFile?: (path: string) => void; + + /** Trigger file attachment dialog (optional) */ + attachFile?: () => void; + + /** Trigger platform login flow (optional) */ + login?: () => void; + + /** Copy text to clipboard */ + copyToClipboard?: (text: string) => Promise; + + /** Platform-specific feature flags */ + features?: { + canOpenFile?: boolean; + canAttachFile?: boolean; + canLogin?: boolean; + canCopy?: boolean; + }; +} + +/** + * Default noop implementation for platforms without message support + */ +const defaultContext: PlatformContextValue = { + platform: 'web', + postMessage: () => {}, + onMessage: () => () => {}, +}; + +/** + * Platform context for accessing platform-specific capabilities + */ +export const PlatformContext = + createContext(defaultContext); + +/** + * Hook to access platform context + */ +export function usePlatform(): PlatformContextValue { + return useContext(PlatformContext); +} + +/** + * Provider component props + */ +export interface PlatformProviderProps { + children: React.ReactNode; + value: PlatformContextValue; +} + +/** + * Platform context provider component + */ +export function PlatformProvider({ children, value }: PlatformProviderProps) { + return ( + + {children} + + ); +} diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 6f4066229..4be114226 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -1,6 +1,18 @@ // Shared UI Components Export // Export all shared components from this package +// Context +export { + PlatformContext, + PlatformProvider, + usePlatform, +} from './context/PlatformContext'; +export type { + PlatformContextValue, + PlatformProviderProps, + PlatformType, +} from './context/PlatformContext'; + // Layout components export { default as Container } from './components/layout/Container'; export { default as Header } from './components/layout/Header'; @@ -33,3 +45,10 @@ export { useLocalStorage } from './hooks/useLocalStorage'; // Types export type { Theme } from './types/theme'; export type { MessageProps } from './types/messages'; +export type { ChatMessage, MessageRole, PlanEntry } from './types/chat'; +export type { + ToolCallStatus, + ToolCallLocation, + ToolCallContentItem, + ToolCallUpdate, +} from './types/toolCall'; diff --git a/packages/webui/src/styles/variables.css b/packages/webui/src/styles/variables.css new file mode 100644 index 000000000..8ec989554 --- /dev/null +++ b/packages/webui/src/styles/variables.css @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Default CSS variables for @qwen-code/webui + * Consumers can override these variables to customize the theme. + */ +:root { + /* Primary colors */ + --app-primary: #3b82f6; + --app-primary-hover: #2563eb; + --app-primary-foreground: #ffffff; + + /* Background colors */ + --app-background: #ffffff; + --app-background-secondary: #f3f4f6; + --app-background-tertiary: #e5e7eb; + + /* Foreground/text colors */ + --app-foreground: #111827; + --app-foreground-secondary: #6b7280; + --app-foreground-muted: #9ca3af; + + /* Border colors */ + --app-border: #e5e7eb; + --app-border-focus: #3b82f6; + + /* Status colors */ + --app-success: #10b981; + --app-warning: #f59e0b; + --app-error: #ef4444; + --app-info: #3b82f6; + + /* Typography */ + --app-font-sans: system-ui, -apple-system, sans-serif; + --app-font-mono: ui-monospace, monospace; + + /* Border radius */ + --app-radius-sm: 0.25rem; + --app-radius-md: 0.375rem; + --app-radius-lg: 0.5rem; + + /* Spacing */ + --app-spacing-xs: 0.25rem; + --app-spacing-sm: 0.5rem; + --app-spacing-md: 1rem; + --app-spacing-lg: 1.5rem; + --app-spacing-xl: 2rem; +} diff --git a/packages/webui/src/types/chat.ts b/packages/webui/src/types/chat.ts new file mode 100644 index 000000000..11bd77fc2 --- /dev/null +++ b/packages/webui/src/types/chat.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Chat message role types + */ +export type MessageRole = 'user' | 'assistant' | 'system'; + +/** + * Basic chat message structure + */ +export interface ChatMessage { + role: MessageRole; + content: string; + timestamp: number; +} + +/** + * Plan entry for task tracking + */ +export interface PlanEntry { + content: string; + priority?: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; +} diff --git a/packages/webui/src/types/toolCall.ts b/packages/webui/src/types/toolCall.ts new file mode 100644 index 000000000..37762fb8a --- /dev/null +++ b/packages/webui/src/types/toolCall.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool call status + */ +export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** + * Tool call location reference + */ +export interface ToolCallLocation { + path: string; + line?: number | null; +} + +/** + * Tool call content item + */ +export interface ToolCallContentItem { + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; +} + +/** + * Tool call update data + */ +export interface ToolCallUpdate { + toolCallId: string; + kind?: string; + title?: string; + status?: ToolCallStatus; + rawInput?: unknown; + content?: ToolCallContentItem[]; + locations?: ToolCallLocation[]; + timestamp?: number; +} diff --git a/packages/webui/tailwind.config.cjs b/packages/webui/tailwind.config.cjs new file mode 100644 index 000000000..58599c59b --- /dev/null +++ b/packages/webui/tailwind.config.cjs @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +module.exports = { + presets: [require('./tailwind.preset.cjs')], + content: ['./src/**/*.{ts,tsx}'], +}; diff --git a/packages/webui/tailwind.preset.cjs b/packages/webui/tailwind.preset.cjs new file mode 100644 index 000000000..21aa7eca0 --- /dev/null +++ b/packages/webui/tailwind.preset.cjs @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @qwen-code/webui Tailwind CSS Preset + * + * This preset provides shared theme configuration for all Qwen Code products. + * Consumers should include this preset in their tailwind.config.js: + * + * @example + * module.exports = { + * presets: [require('@qwen-code/webui/tailwind.preset')], + * content: [ + * './src/**\/*.{ts,tsx}', + * './node_modules/@qwen-code/webui/dist/**\/*.js' + * ] + * } + */ + +/* eslint-env node */ +module.exports = { + theme: { + extend: { + colors: { + // Primary colors using CSS variables for runtime theming + 'app-primary': 'var(--app-primary, #3b82f6)', + 'app-primary-hover': 'var(--app-primary-hover, #2563eb)', + 'app-primary-foreground': 'var(--app-primary-foreground, #ffffff)', + + // Background colors + 'app-background': 'var(--app-background, #ffffff)', + 'app-background-secondary': 'var(--app-background-secondary, #f3f4f6)', + 'app-background-tertiary': 'var(--app-background-tertiary, #e5e7eb)', + + // Foreground/text colors + 'app-foreground': 'var(--app-foreground, #111827)', + 'app-foreground-secondary': 'var(--app-foreground-secondary, #6b7280)', + 'app-foreground-muted': 'var(--app-foreground-muted, #9ca3af)', + + // Border colors + 'app-border': 'var(--app-border, #e5e7eb)', + 'app-border-focus': 'var(--app-border-focus, #3b82f6)', + + // Status colors + 'app-success': 'var(--app-success, #10b981)', + 'app-warning': 'var(--app-warning, #f59e0b)', + 'app-error': 'var(--app-error, #ef4444)', + 'app-info': 'var(--app-info, #3b82f6)', + }, + fontFamily: { + sans: ['var(--app-font-sans, system-ui, sans-serif)'], + mono: ['var(--app-font-mono, ui-monospace, monospace)'], + }, + borderRadius: { + 'app-sm': 'var(--app-radius-sm, 0.25rem)', + 'app-md': 'var(--app-radius-md, 0.375rem)', + 'app-lg': 'var(--app-radius-lg, 0.5rem)', + }, + spacing: { + 'app-xs': 'var(--app-spacing-xs, 0.25rem)', + 'app-sm': 'var(--app-spacing-sm, 0.5rem)', + 'app-md': 'var(--app-spacing-md, 1rem)', + 'app-lg': 'var(--app-spacing-lg, 1.5rem)', + 'app-xl': 'var(--app-spacing-xl, 2rem)', + }, + }, + }, +}; diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json index 58209e086..3e0be85f6 100644 --- a/packages/webui/tsconfig.json +++ b/packages/webui/tsconfig.json @@ -14,11 +14,8 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "declaration": true, - "declarationDir": "./dist", - "emitDeclarationOnly": true + "noFallthroughCasesInSwitch": true }, "include": ["src"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.stories.tsx"] } diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts new file mode 100644 index 000000000..9edb5b28c --- /dev/null +++ b/packages/webui/vite.config.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import dts from 'vite-plugin-dts'; +import { resolve } from 'path'; + +/** + * Vite configuration for @qwen-code/webui library + * + * Build outputs: + * - ESM: dist/index.js (primary format) + * - CJS: dist/index.cjs (compatibility) + * - TypeScript declarations: dist/index.d.ts + * - CSS: dist/styles.css (optional styles) + */ +export default defineConfig({ + plugins: [ + react(), + dts({ + include: ['src'], + outDir: 'dist', + rollupTypes: true, + insertTypesEntry: true, + }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'QwenCodeWebUI', + formats: ['es', 'cjs'], + fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime'], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + 'react/jsx-runtime': 'jsxRuntime', + }, + assetFileNames: 'styles.[ext]', + }, + }, + sourcemap: true, + minify: false, + cssCodeSplit: false, + }, +}); From 71570540ccdfa03716d43ddc28a2471a8702b4b0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 15 Jan 2026 19:53:19 +0800 Subject: [PATCH 008/113] feat(webui): migrate icons, Tooltip, WaitingMessage from vscode-ide-companion - Move icon components (FileIcons, EditIcons, NavigationIcons, StatusIcons, SpecialIcons, StopIcon) from vscode-ide-companion to webui package - Migrate Tooltip component with CSS variable theming support - Migrate WaitingMessage and InterruptedMessage components - Enhance Button component with forwardRef, new variants (ghost, outline), loading state, and icon support - Enhance Input component with forwardRef, error state, label, and helper text - Update vscode-ide-companion to import components from @qwen-code/webui - Remove replaced local components from vscode-ide-companion - Add skipLibCheck to vscode-ide-companion tsconfig for type compatibility --- .../vscode-ide-companion/src/webview/App.tsx | 5 +- .../src/webview/components/Tooltip.tsx | 61 ----- .../webview/components/layout/ChatHeader.tsx | 2 +- .../components/layout/ContextIndicator.tsx | 2 +- .../webview/components/layout/InputForm.tsx | 2 +- .../components/layout/SessionSelector.tsx | 2 +- .../messages/Waiting/WaitingMessage.css | 38 ---- .../src/webview/components/messages/index.tsx | 3 +- packages/vscode-ide-companion/tsconfig.json | 3 +- packages/webui/.storybook/main.ts | 6 + packages/webui/.storybook/preview.ts | 6 + packages/webui/README.md | 210 ++++++++++++++++++ packages/webui/example/ExampleComponent.tsx | 6 + packages/webui/package.json | 5 + packages/webui/scripts/add-license-header.sh | 48 ++++ .../webui/src/components/PermissionDrawer.tsx | 6 + .../webui/src/components/icons/CloseIcon.tsx | 36 +-- .../src}/components/icons/EditIcons.tsx | 190 ++++++++++++++++ .../src}/components/icons/FileIcons.tsx | 0 packages/webui/src/components/icons/Icon.tsx | 45 ++-- .../src}/components/icons/NavigationIcons.tsx | 0 .../webui/src/components/icons/SendIcon.tsx | 36 +-- .../src}/components/icons/SpecialIcons.tsx | 0 .../src}/components/icons/StatusIcons.tsx | 0 .../src}/components/icons/StopIcon.tsx | 0 .../src}/components/icons/index.ts | 10 +- .../src}/components/icons/types.ts | 0 .../webui/src/components/layout/Container.tsx | 10 +- .../webui/src/components/layout/Footer.tsx | 6 + .../webui/src/components/layout/Header.tsx | 6 + packages/webui/src/components/layout/Main.tsx | 6 + .../webui/src/components/layout/Sidebar.tsx | 6 + .../webui/src/components/messages/Message.tsx | 6 + .../src/components/messages/MessageInput.tsx | 10 +- .../src/components/messages/MessageList.tsx | 10 +- .../messages/Waiting/InterruptedMessage.tsx | 0 .../messages/Waiting/WaitingMessage.tsx | 14 +- packages/webui/src/components/ui/Button.tsx | 172 ++++++++++---- packages/webui/src/components/ui/Input.tsx | 164 ++++++++++++-- packages/webui/src/components/ui/Tooltip.tsx | 140 +++++------- packages/webui/src/hooks/useLocalStorage.ts | 6 + packages/webui/src/hooks/useTheme.ts | 6 + packages/webui/src/index.ts | 61 ++++- packages/webui/src/types/messages.ts | 6 + packages/webui/src/types/theme.ts | 6 + 45 files changed, 1049 insertions(+), 308 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/components/Tooltip.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css create mode 100644 packages/webui/README.md create mode 100755 packages/webui/scripts/add-license-header.sh rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/EditIcons.tsx (52%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/FileIcons.tsx (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/NavigationIcons.tsx (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/SpecialIcons.tsx (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/StatusIcons.tsx (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/StopIcon.tsx (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/index.ts (87%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/icons/types.ts (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/Waiting/InterruptedMessage.tsx (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/Waiting/WaitingMessage.tsx (90%) diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 4286cd44e..fd0a51e8f 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -37,12 +37,11 @@ import { UserMessage, AssistantMessage, ThinkingMessage, - WaitingMessage, - InterruptedMessage, } from './components/messages/index.js'; +import { WaitingMessage, InterruptedMessage } from '@qwen-code/webui'; import { InputForm } from './components/layout/InputForm.js'; import { SessionSelector } from './components/layout/SessionSelector.js'; -import { FileIcon, UserIcon } from './components/icons/index.js'; +import { FileIcon, UserIcon } from '@qwen-code/webui'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/Tooltip.tsx b/packages/vscode-ide-companion/src/webview/components/Tooltip.tsx deleted file mode 100644 index 1ee10c000..000000000 --- a/packages/vscode-ide-companion/src/webview/components/Tooltip.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; - -interface TooltipProps { - children: React.ReactNode; - content: React.ReactNode; - position?: 'top' | 'bottom' | 'left' | 'right'; -} - -export const Tooltip: React.FC = ({ - children, - content, - position = 'top', -}) => ( -
-
- {children} -
- {content} -
-
-
-
-); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx index 82cc905fb..b6b37e2cb 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ChatHeader.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { ChevronDownIcon, PlusIcon } from '../icons/index.js'; +import { ChevronDownIcon, PlusIcon } from '@qwen-code/webui'; interface ChatHeaderProps { currentSessionTitle: string; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx index b7f476c36..ce3dddfa4 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { Tooltip } from '../Tooltip.js'; +import { Tooltip } from '@qwen-code/webui'; interface ContextUsage { percentLeft: number; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 2058b7c04..6fd6ef61d 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -16,7 +16,7 @@ import { LinkIcon, ArrowUpIcon, StopIcon, -} from '../icons/index.js'; +} from '@qwen-code/webui'; import { CompletionMenu } from '../layout/CompletionMenu.js'; import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx index 1b744c1d2..a751410bc 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx @@ -9,7 +9,7 @@ import { getTimeAgo, groupSessionsByDate, } from '../../utils/sessionGrouping.js'; -import { SearchIcon } from '../icons/index.js'; +import { SearchIcon } from '@qwen-code/webui'; interface SessionSelectorProps { visible: boolean; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css deleted file mode 100644 index 9a109a082..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.css +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -@import url('../Assistant/AssistantMessage.css'); - -/* Subtle shimmering highlight across the loading text */ -@keyframes waitingMessageShimmer { - 0% { - background-position: -200% 0; - } - 100% { - background-position: 200% 0; - } -} - -.loading-text-shimmer { - /* Use the theme foreground as the base color, with a moving light band */ - background-image: linear-gradient( - 90deg, - var(--app-secondary-foreground) 0%, - var(--app-secondary-foreground) 40%, - rgba(255, 255, 255, 0.95) 50%, - var(--app-secondary-foreground) 60%, - var(--app-secondary-foreground) 100% - ); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - color: transparent; /* text color comes from the gradient */ - animation: waitingMessageShimmer 1.6s linear infinite; -} - -.interrupted-item::after { - display: none; -} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx index 2ec06e87e..1cf19e26c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -7,5 +7,4 @@ export { UserMessage } from './UserMessage.js'; export { AssistantMessage } from './Assistant/AssistantMessage.js'; export { ThinkingMessage } from './ThinkingMessage.js'; -export { WaitingMessage } from './Waiting/WaitingMessage.js'; -export { InterruptedMessage } from './Waiting/InterruptedMessage.js'; +// WaitingMessage and InterruptedMessage are now imported from @qwen-code/webui diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json index 538ec461f..886ddb4b6 100644 --- a/packages/vscode-ide-companion/tsconfig.json +++ b/packages/vscode-ide-companion/tsconfig.json @@ -7,7 +7,8 @@ "jsx": "react-jsx", "jsxImportSource": "react", "sourceMap": true, - "strict": true /* enable all strict type-checking options */ + "strict": true, + "skipLibCheck": true /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ diff --git a/packages/webui/.storybook/main.ts b/packages/webui/.storybook/main.ts index b76cf892f..69c9da990 100644 --- a/packages/webui/.storybook/main.ts +++ b/packages/webui/.storybook/main.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type { StorybookConfig } from '@storybook/react-vite'; import { dirname } from 'path'; diff --git a/packages/webui/.storybook/preview.ts b/packages/webui/.storybook/preview.ts index 13dd9ec87..3cc995c1f 100644 --- a/packages/webui/.storybook/preview.ts +++ b/packages/webui/.storybook/preview.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type { Preview } from '@storybook/react-vite'; import './preview.css'; diff --git a/packages/webui/README.md b/packages/webui/README.md new file mode 100644 index 000000000..0558d6d4f --- /dev/null +++ b/packages/webui/README.md @@ -0,0 +1,210 @@ +# @anthropic/webui + +A shared React component library for Qwen Code applications, providing cross-platform UI components with consistent styling and behavior. + +## Features + +- **Cross-platform support**: Components work seamlessly across VS Code extension, web, and other platforms +- **Platform Context**: Abstraction layer for platform-specific capabilities +- **Tailwind CSS**: Shared styling preset for consistent design +- **TypeScript**: Full type definitions for all components +- **Storybook**: Interactive component documentation and development + +## Installation + +```bash +npm install @anthropic/webui +``` + +## Quick Start + +```tsx +import { Button, Input, Tooltip } from '@anthropic/webui'; +import { PlatformProvider } from '@anthropic/webui/context'; + +function App() { + return ( + + + + ); +} +``` + +## Components + +### UI Components + +#### Button + +```tsx +import { Button } from '@anthropic/webui'; + +; +``` + +**Props:** + +- `variant`: 'primary' | 'secondary' | 'danger' | 'ghost' | 'outline' +- `size`: 'sm' | 'md' | 'lg' +- `loading`: boolean +- `leftIcon`: ReactNode +- `rightIcon`: ReactNode +- `fullWidth`: boolean + +#### Input + +```tsx +import { Input } from '@anthropic/webui'; + +; +``` + +**Props:** + +- `size`: 'sm' | 'md' | 'lg' +- `error`: boolean +- `errorMessage`: string +- `label`: string +- `helperText`: string +- `leftElement`: ReactNode +- `rightElement`: ReactNode + +#### Tooltip + +```tsx +import { Tooltip } from '@anthropic/webui'; + + + Hover me +; +``` + +### Icons + +```tsx +import { FileIcon, FolderIcon, CheckIcon } from '@anthropic/webui/icons'; + +; +``` + +Available icon categories: + +- **FileIcons**: FileIcon, FolderIcon, SaveDocumentIcon +- **StatusIcons**: CheckIcon, ErrorIcon, WarningIcon, LoadingIcon +- **NavigationIcons**: ArrowLeftIcon, ArrowRightIcon, ChevronIcon +- **EditIcons**: EditIcon, DeleteIcon, CopyIcon +- **SpecialIcons**: SendIcon, StopIcon, CloseIcon + +### Layout Components + +- `Container`: Main layout wrapper +- `Header`: Application header +- `Footer`: Application footer +- `Sidebar`: Side navigation +- `Main`: Main content area + +### Message Components + +- `Message`: Chat message display +- `MessageList`: List of messages +- `MessageInput`: Message input field +- `WaitingMessage`: Loading/waiting state +- `InterruptedMessage`: Interrupted state display + +## Platform Context + +The Platform Context provides an abstraction layer for platform-specific capabilities: + +```tsx +import { PlatformProvider, usePlatform } from '@anthropic/webui/context'; + +const platformContext = { + postMessage: (message) => vscode.postMessage(message), + onMessage: (handler) => { + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, + openFile: (path) => { + /* platform-specific */ + }, + platform: 'vscode', +}; + +function App() { + return ( + + + + ); +} + +function Component() { + const { postMessage, platform } = usePlatform(); + // Use platform capabilities +} +``` + +## Tailwind Preset + +Use the shared Tailwind preset for consistent styling: + +```js +// tailwind.config.js +module.exports = { + presets: [require('@anthropic/webui/tailwind.preset.cjs')], + // your customizations +}; +``` + +## Development + +### Running Storybook + +```bash +cd packages/webui +npm run storybook +``` + +### Building + +```bash +npm run build +``` + +### Type Checking + +```bash +npm run typecheck +``` + +## Project Structure + +``` +packages/webui/ +├── src/ +│ ├── components/ +│ │ ├── icons/ # Icon components +│ │ ├── layout/ # Layout components +│ │ ├── messages/ # Message components +│ │ └── ui/ # UI primitives +│ ├── context/ # Platform context +│ ├── hooks/ # Custom hooks +│ └── types/ # Type definitions +├── .storybook/ # Storybook config +├── tailwind.preset.cjs # Shared Tailwind preset +└── vite.config.ts # Build configuration +``` + +## License + +Apache-2.0 diff --git a/packages/webui/example/ExampleComponent.tsx b/packages/webui/example/ExampleComponent.tsx index c29800049..c554f2b6a 100644 --- a/packages/webui/example/ExampleComponent.tsx +++ b/packages/webui/example/ExampleComponent.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + // Example of how to use shared UI components // This would typically be integrated into existing components diff --git a/packages/webui/package.json b/packages/webui/package.json index ffd2b956d..b2f5459ab 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -12,6 +12,11 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./icons": { + "types": "./dist/components/icons/index.d.ts", + "import": "./dist/components/icons/index.js", + "require": "./dist/components/icons/index.cjs" + }, "./tailwind.preset": "./tailwind.preset.cjs", "./styles.css": "./dist/styles.css" }, diff --git a/packages/webui/scripts/add-license-header.sh b/packages/webui/scripts/add-license-header.sh new file mode 100755 index 000000000..8fa424202 --- /dev/null +++ b/packages/webui/scripts/add-license-header.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Script to check and add license header to files in the packages/webui directory +# If a file doesn't have the required license header, it will be added at the top +# Excludes Markdown files and common build/dependency directories + +LICENSE_HEADER="/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */" + +# Directory to scan (relative to script location) +TARGET_DIR="$(dirname "$0")/../" + +# Find all JavaScript, TypeScript, CSS, HTML, and JSX/TSX files in the target directory, excluding Markdown files +# Also exclude common build/dependency directories +find "$TARGET_DIR" -type f \( -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" -o -name "*.cjs" -o -name "*.mjs" -o -name "*.css" -o -name "*.html" \) -not -name "*.md" \ + -not -path "*/node_modules/*" \ + -not -path "*/dist/*" \ + -not -path "*/build/*" \ + -not -path "*/coverage/*" \ + -not -path "*/.next/*" \ + -not -path "*/out/*" \ + -not -path "*/target/*" \ + -not -path "*/vendor/*" \ + -print0 | while IFS= read -r -d '' file; do + # Skip the script file itself + if [[ "$(basename "$file")" != "add-license-header.sh" ]]; then + # Check if the file starts with the license header + if ! head -n 5 "$file" | grep -Fq "@license"; then + echo "Adding license header to: $file" + + # Create a temporary file with the license header followed by the original content + temp_file=$(mktemp) + echo "$LICENSE_HEADER" > "$temp_file" + echo "" >> "$temp_file" # Add an empty line after the license header + cat "$file" >> "$temp_file" + + # Move the temporary file to replace the original file + mv "$temp_file" "$file" + else + echo "License header already present in: $file" + fi + fi +done + +echo "License header check and update completed." \ No newline at end of file diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx index 2805915cb..e90fcde11 100644 --- a/packages/webui/src/components/PermissionDrawer.tsx +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; import { useState, useEffect } from 'react'; diff --git a/packages/webui/src/components/icons/CloseIcon.tsx b/packages/webui/src/components/icons/CloseIcon.tsx index ac559d299..65d60b68a 100644 --- a/packages/webui/src/components/icons/CloseIcon.tsx +++ b/packages/webui/src/components/icons/CloseIcon.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; interface CloseIconProps { @@ -11,20 +17,20 @@ const CloseIcon: React.FC = ({ color = 'currentColor', className = '', }) => ( - - - - - ); + + + + +); export default CloseIcon; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx b/packages/webui/src/components/icons/EditIcons.tsx similarity index 52% rename from packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx rename to packages/webui/src/components/icons/EditIcons.tsx index f5e12b330..ddec39d4a 100644 --- a/packages/vscode-ide-companion/src/webview/components/icons/EditIcons.tsx +++ b/packages/webui/src/components/icons/EditIcons.tsx @@ -213,3 +213,193 @@ export const OpenDiffIcon: React.FC = ({ ); + +/** + * Undo edit icon (16x16) + * Used for undoing edits in diff views + */ +export const UndoIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Redo edit icon (16x16) + * Used for redoing edits in diff views + */ +export const RedoIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Replace all icon (16x16) + * Used for replacing all occurrences in search/replace + */ +export const ReplaceAllIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Copy icon (16x16) + * Used for copying content + */ +export const CopyIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Paste icon (16x16) + * Used for pasting content + */ +export const PasteIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); + +/** + * Select all icon (16x16) + * Used for selecting all content + */ +export const SelectAllIcon: React.FC = ({ + size = 16, + className, + ...props +}) => ( + +); diff --git a/packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx b/packages/webui/src/components/icons/FileIcons.tsx similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/icons/FileIcons.tsx rename to packages/webui/src/components/icons/FileIcons.tsx diff --git a/packages/webui/src/components/icons/Icon.tsx b/packages/webui/src/components/icons/Icon.tsx index 2f874d594..afa888cbb 100644 --- a/packages/webui/src/components/icons/Icon.tsx +++ b/packages/webui/src/components/icons/Icon.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; interface IconProps { @@ -12,27 +18,24 @@ const Icon: React.FC = ({ size = 24, color = 'currentColor', className = '', -}) => +}) => ( // This is a placeholder - in a real implementation you might use an icon library - ( - + - - {name} - - - ) -; - + {name} + + +); export default Icon; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx b/packages/webui/src/components/icons/NavigationIcons.tsx similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/icons/NavigationIcons.tsx rename to packages/webui/src/components/icons/NavigationIcons.tsx diff --git a/packages/webui/src/components/icons/SendIcon.tsx b/packages/webui/src/components/icons/SendIcon.tsx index d2561e033..f0c657ecc 100644 --- a/packages/webui/src/components/icons/SendIcon.tsx +++ b/packages/webui/src/components/icons/SendIcon.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; interface SendIconProps { @@ -11,20 +17,20 @@ const SendIcon: React.FC = ({ color = 'currentColor', className = '', }) => ( - - - - - ); + + + + +); export default SendIcon; diff --git a/packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx b/packages/webui/src/components/icons/SpecialIcons.tsx similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/icons/SpecialIcons.tsx rename to packages/webui/src/components/icons/SpecialIcons.tsx diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx b/packages/webui/src/components/icons/StatusIcons.tsx similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/icons/StatusIcons.tsx rename to packages/webui/src/components/icons/StatusIcons.tsx diff --git a/packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx b/packages/webui/src/components/icons/StopIcon.tsx similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/icons/StopIcon.tsx rename to packages/webui/src/components/icons/StopIcon.tsx diff --git a/packages/vscode-ide-companion/src/webview/components/icons/index.ts b/packages/webui/src/components/icons/index.ts similarity index 87% rename from packages/vscode-ide-companion/src/webview/components/icons/index.ts rename to packages/webui/src/components/icons/index.ts index ffecbbced..5b48a6125 100644 --- a/packages/vscode-ide-companion/src/webview/components/icons/index.ts +++ b/packages/webui/src/components/icons/index.ts @@ -5,7 +5,14 @@ */ export type { IconProps } from './types.js'; -export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js'; + +// File icons +export { + FileIcon, + FileListIcon, + SaveDocumentIcon, + FolderIcon, +} from './FileIcons.js'; // Navigation icons export { @@ -29,6 +36,7 @@ export { SlashCommandIcon, LinkIcon, OpenDiffIcon, + UndoIcon, } from './EditIcons.js'; // Status icons diff --git a/packages/vscode-ide-companion/src/webview/components/icons/types.ts b/packages/webui/src/components/icons/types.ts similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/icons/types.ts rename to packages/webui/src/components/icons/types.ts diff --git a/packages/webui/src/components/layout/Container.tsx b/packages/webui/src/components/layout/Container.tsx index 436d4c72e..37dc27bca 100644 --- a/packages/webui/src/components/layout/Container.tsx +++ b/packages/webui/src/components/layout/Container.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; interface ContainerProps { @@ -6,7 +12,7 @@ interface ContainerProps { } const Container: React.FC = ({ children, className = '' }) => ( -
{children}
- ); +
{children}
+); export default Container; diff --git a/packages/webui/src/components/layout/Footer.tsx b/packages/webui/src/components/layout/Footer.tsx index 557a083b1..6a4f162f5 100644 --- a/packages/webui/src/components/layout/Footer.tsx +++ b/packages/webui/src/components/layout/Footer.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; const Footer: React.FC = () =>
Footer Component Placeholder
; diff --git a/packages/webui/src/components/layout/Header.tsx b/packages/webui/src/components/layout/Header.tsx index 2886c0dfb..1f7fe373c 100644 --- a/packages/webui/src/components/layout/Header.tsx +++ b/packages/webui/src/components/layout/Header.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; const Header: React.FC = () =>
Header Component Placeholder
; diff --git a/packages/webui/src/components/layout/Main.tsx b/packages/webui/src/components/layout/Main.tsx index 118f67430..b046a1827 100644 --- a/packages/webui/src/components/layout/Main.tsx +++ b/packages/webui/src/components/layout/Main.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; const Main: React.FC = () =>
Main Component Placeholder
; diff --git a/packages/webui/src/components/layout/Sidebar.tsx b/packages/webui/src/components/layout/Sidebar.tsx index eb3746c20..d8835427a 100644 --- a/packages/webui/src/components/layout/Sidebar.tsx +++ b/packages/webui/src/components/layout/Sidebar.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; const Sidebar: React.FC = () => ; diff --git a/packages/webui/src/components/messages/Message.tsx b/packages/webui/src/components/messages/Message.tsx index e09208528..cce7b53ce 100644 --- a/packages/webui/src/components/messages/Message.tsx +++ b/packages/webui/src/components/messages/Message.tsx @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; interface MessageProps { diff --git a/packages/webui/src/components/messages/MessageInput.tsx b/packages/webui/src/components/messages/MessageInput.tsx index bd4637155..281fd5b4e 100644 --- a/packages/webui/src/components/messages/MessageInput.tsx +++ b/packages/webui/src/components/messages/MessageInput.tsx @@ -1,5 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; -const MessageInput: React.FC = () =>
MessageInput Component Placeholder
; +const MessageInput: React.FC = () => ( +
MessageInput Component Placeholder
+); export default MessageInput; diff --git a/packages/webui/src/components/messages/MessageList.tsx b/packages/webui/src/components/messages/MessageList.tsx index 5544865a0..5a322929c 100644 --- a/packages/webui/src/components/messages/MessageList.tsx +++ b/packages/webui/src/components/messages/MessageList.tsx @@ -1,5 +1,13 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import type React from 'react'; -const MessageList: React.FC = () =>
MessageList Component Placeholder
; +const MessageList: React.FC = () => ( +
MessageList Component Placeholder
+); export default MessageList; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx b/packages/webui/src/components/messages/Waiting/InterruptedMessage.tsx similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/messages/Waiting/InterruptedMessage.tsx rename to packages/webui/src/components/messages/Waiting/InterruptedMessage.tsx diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx b/packages/webui/src/components/messages/Waiting/WaitingMessage.tsx similarity index 90% rename from packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx rename to packages/webui/src/components/messages/Waiting/WaitingMessage.tsx index 68aceac8f..95b728bf0 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Waiting/WaitingMessage.tsx +++ b/packages/webui/src/components/messages/Waiting/WaitingMessage.tsx @@ -6,8 +6,6 @@ import type React from 'react'; import { useEffect, useMemo, useState } from 'react'; -import './WaitingMessage.css'; -import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js'; interface WaitingMessageProps { loadingMessage: string; @@ -16,6 +14,16 @@ interface WaitingMessageProps { // Rotate message every few seconds while waiting const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request +// Default witty loading phrases +const DEFAULT_LOADING_PHRASES = [ + 'Processing...', + 'Working on it...', + 'Just a moment...', + 'Loading...', + 'Hold tight...', + 'Almost there...', +]; + export const WaitingMessage: React.FC = ({ loadingMessage, }) => { @@ -27,7 +35,7 @@ export const WaitingMessage: React.FC = ({ list.push(loadingMessage); set.add(loadingMessage); } - for (const p of WITTY_LOADING_PHRASES) { + for (const p of DEFAULT_LOADING_PHRASES) { if (!set.has(p)) { list.push(p); } diff --git a/packages/webui/src/components/ui/Button.tsx b/packages/webui/src/components/ui/Button.tsx index 1ee0d3b07..b86a8dcef 100644 --- a/packages/webui/src/components/ui/Button.tsx +++ b/packages/webui/src/components/ui/Button.tsx @@ -1,49 +1,143 @@ -import type React from 'react'; +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ -interface ButtonProps { +import type React from 'react'; +import { forwardRef } from 'react'; + +/** + * Button variant types + */ +export type ButtonVariant = + | 'primary' + | 'secondary' + | 'danger' + | 'ghost' + | 'outline'; + +/** + * Button size types + */ +export type ButtonSize = 'sm' | 'md' | 'lg'; + +/** + * Button component props interface + */ +export interface ButtonProps + extends React.ButtonHTMLAttributes { + /** Button content */ children: React.ReactNode; - onClick?: () => void; - variant?: 'primary' | 'secondary' | 'danger'; - size?: 'sm' | 'md' | 'lg'; - disabled?: boolean; - className?: string; + /** Visual style variant */ + variant?: ButtonVariant; + /** Button size */ + size?: ButtonSize; + /** Loading state - shows spinner and disables button */ + loading?: boolean; + /** Icon to display before children */ + leftIcon?: React.ReactNode; + /** Icon to display after children */ + rightIcon?: React.ReactNode; + /** Full width button */ + fullWidth?: boolean; } -const Button: React.FC = ({ - children, - onClick, - variant = 'primary', - size = 'md', - disabled = false, - className = '', -}) => { - const baseClasses = - 'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; +/** + * Button component with multiple variants and sizes + * + * @example + * ```tsx + * + * ``` + */ +const Button = forwardRef( + ( + { + children, + variant = 'primary', + size = 'md', + disabled = false, + loading = false, + leftIcon, + rightIcon, + fullWidth = false, + className = '', + type = 'button', + ...props + }, + ref, + ) => { + const isDisabled = disabled || loading; - const variantClasses = { - primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', - secondary: - 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500', - danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', - }; + const baseClasses = + 'inline-flex items-center justify-center rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; - const sizeClasses = { - sm: 'px-2 py-1 text-sm', - md: 'px-4 py-2', - lg: 'px-6 py-3 text-lg', - }; + const variantClasses: Record = { + primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', + secondary: + 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500', + danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + ghost: + 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-400', + outline: + 'bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-400', + }; - const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : ''; + const sizeClasses: Record = { + sm: 'px-2 py-1 text-sm gap-1', + md: 'px-4 py-2 gap-2', + lg: 'px-6 py-3 text-lg gap-2', + }; - return ( - - ); -}; + const disabledClass = isDisabled + ? 'opacity-50 cursor-not-allowed pointer-events-none' + : ''; + const widthClass = fullWidth ? 'w-full' : ''; + + return ( + + ); + }, +); + +Button.displayName = 'Button'; export default Button; diff --git a/packages/webui/src/components/ui/Input.tsx b/packages/webui/src/components/ui/Input.tsx index 8fd350c67..8ac2c8395 100644 --- a/packages/webui/src/components/ui/Input.tsx +++ b/packages/webui/src/components/ui/Input.tsx @@ -1,25 +1,149 @@ -import type React from 'react'; +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ -interface InputProps { - value: string; - onChange: (value: string) => void; - placeholder?: string; - className?: string; +import type React from 'react'; +import { forwardRef } from 'react'; + +/** + * Input size types + */ +export type InputSize = 'sm' | 'md' | 'lg'; + +/** + * Input component props interface + */ +export interface InputProps + extends Omit, 'size'> { + /** Input size */ + size?: InputSize; + /** Error state */ + error?: boolean; + /** Error message to display */ + errorMessage?: string; + /** Label for the input */ + label?: string; + /** Helper text below input */ + helperText?: string; + /** Left icon/element */ + leftElement?: React.ReactNode; + /** Right icon/element */ + rightElement?: React.ReactNode; + /** Full width input */ + fullWidth?: boolean; } -const Input: React.FC = ({ - value, - onChange, - placeholder, - className = '', -}) => ( - onChange(e.target.value)} - placeholder={placeholder} - className={`border rounded px-3 py-2 ${className}`} - /> - ); +/** + * Input component with multiple sizes and states + * + * @example + * ```tsx + * + * ``` + */ +const Input = forwardRef( + ( + { + size = 'md', + error = false, + errorMessage, + label, + helperText, + leftElement, + rightElement, + fullWidth = false, + className = '', + id, + disabled, + ...props + }, + ref, + ) => { + const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`; + + const baseClasses = + 'border rounded transition-colors focus:outline-none focus:ring-2'; + + const sizeClasses: Record = { + sm: 'px-2 py-1 text-sm', + md: 'px-3 py-2', + lg: 'px-4 py-3 text-lg', + }; + + const stateClasses = error + ? 'border-red-500 focus:ring-red-500 focus:border-red-500' + : 'border-gray-300 focus:ring-blue-500 focus:border-blue-500'; + + const disabledClasses = disabled + ? 'bg-gray-100 cursor-not-allowed opacity-60' + : 'bg-white'; + + const widthClass = fullWidth ? 'w-full' : ''; + + const paddingClasses = [ + leftElement ? 'pl-10' : '', + rightElement ? 'pr-10' : '', + ].join(' '); + + return ( +
+ {label && ( + + )} +
+ {leftElement && ( +
+ {leftElement} +
+ )} + + {rightElement && ( +
+ {rightElement} +
+ )} +
+ {errorMessage && error && ( +

+ {errorMessage} +

+ )} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ); + }, +); + +Input.displayName = 'Input'; export default Input; diff --git a/packages/webui/src/components/ui/Tooltip.tsx b/packages/webui/src/components/ui/Tooltip.tsx index 6b9206af9..05846d637 100644 --- a/packages/webui/src/components/ui/Tooltip.tsx +++ b/packages/webui/src/components/ui/Tooltip.tsx @@ -1,93 +1,73 @@ -import React, { useState } from 'react'; +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ -interface ChildProps { - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onFocus?: () => void; - onBlur?: () => void; - tabIndex?: number; +import type React from 'react'; + +/** + * Tooltip component props + */ +export interface TooltipProps { + /** Content to wrap with tooltip */ + children: React.ReactNode; + /** Tooltip content (can be string or ReactNode) */ + content: React.ReactNode; + /** Tooltip position relative to children */ + position?: 'top' | 'bottom' | 'left' | 'right'; } -interface TooltipProps { - children: React.ReactElement; - content: string; - position?: 'top' | 'right' | 'bottom' | 'left'; -} - -const Tooltip: React.FC = ({ +/** + * Tooltip component using CSS group-hover for display + * Supports CSS variables for theming + */ +export const Tooltip: React.FC = ({ children, content, position = 'top', -}) => { - const [isVisible, setIsVisible] = useState(false); - - const positionClasses = { - top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2', - right: 'top-1/2 left-full transform -translate-y-1/2 ml-2', - bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2', - left: 'top-1/2 right-full transform -translate-y-1/2 mr-2', - }; - - const arrowPositionClasses = { - top: 'top-full left-1/2 transform -translate-x-1/2 -mt-1', - right: 'top-1/2 left-0 transform -translate-y-1/2 -ml-1', - bottom: 'top-0 left-1/2 transform -translate-x-1/2 -mb-1', - left: 'top-1/2 right-0 transform -translate-y-1/2 -mr-1', - }; - - const tooltipClass = `absolute ${positionClasses[position]} bg-gray-800 text-white text-xs rounded py-1 px-2 pointer-events-none z-10`; - const arrowClass = `absolute w-2 h-2 bg-gray-800 transform rotate-45 ${arrowPositionClasses[position]}`; - - return ( -
+}) => ( +
+
+ {children}
setIsVisible(true)} - onMouseLeave={() => setIsVisible(false)} - onFocus={() => setIsVisible(true)} - onBlur={() => setIsVisible(false)} - tabIndex={0} + className={` + absolute z-50 px-2 py-1 text-xs rounded-md shadow-lg + bg-[var(--app-primary-background,#1f2937)] border border-[var(--app-input-border,#374151)] + text-[var(--app-primary-foreground,#f9fafb)] whitespace-nowrap + opacity-0 group-hover:opacity-100 transition-opacity duration-150 + -translate-x-1/2 left-1/2 + ${ + position === 'top' + ? '-translate-y-1 bottom-full mb-1' + : position === 'bottom' + ? 'translate-y-1 top-full mt-1' + : position === 'left' + ? '-translate-x-full left-0 translate-y-[-50%] top-1/2' + : 'translate-x-0 right-0 translate-y-[-50%] top-1/2' + } + pointer-events-none + `} > - {React.cloneElement(children, { - onMouseEnter: () => { - setIsVisible(true); - const typedChildren = children as React.ReactElement; - if (typeof typedChildren.props.onMouseEnter === 'function') { - typedChildren.props.onMouseEnter(); + {content} +
{ - setIsVisible(false); - const typedChildren = children as React.ReactElement; - if (typeof typedChildren.props.onMouseLeave === 'function') { - typedChildren.props.onMouseLeave(); - } - }, - onFocus: () => { - setIsVisible(true); - const typedChildren = children as React.ReactElement; - if (typeof typedChildren.props.onFocus === 'function') { - typedChildren.props.onFocus(); - } - }, - onBlur: () => { - setIsVisible(false); - const typedChildren = children as React.ReactElement; - if (typeof typedChildren.props.onBlur === 'function') { - typedChildren.props.onBlur(); - } - }, - tabIndex: - (children as React.ReactElement).props.tabIndex || 0, - })} + `} + />
- {isVisible && ( -
- {content} -
-
- )}
- ); -}; +
+); export default Tooltip; diff --git a/packages/webui/src/hooks/useLocalStorage.ts b/packages/webui/src/hooks/useLocalStorage.ts index 7fb675411..b0bbdcad9 100644 --- a/packages/webui/src/hooks/useLocalStorage.ts +++ b/packages/webui/src/hooks/useLocalStorage.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import { useState } from 'react'; export const useLocalStorage = (key: string, initialValue: T) => { diff --git a/packages/webui/src/hooks/useTheme.ts b/packages/webui/src/hooks/useTheme.ts index c4ca0a77a..95fc8202e 100644 --- a/packages/webui/src/hooks/useTheme.ts +++ b/packages/webui/src/hooks/useTheme.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import { useState, useEffect } from 'react'; export const useTheme = () => { diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 4be114226..5bfaf89cd 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + // Shared UI Components Export // Export all shared components from this package @@ -24,11 +30,14 @@ export { default as Footer } from './components/layout/Footer'; export { default as Message } from './components/messages/Message'; export { default as MessageInput } from './components/messages/MessageInput'; export { default as MessageList } from './components/messages/MessageList'; +export { WaitingMessage } from './components/messages/Waiting/WaitingMessage'; +export { InterruptedMessage } from './components/messages/Waiting/InterruptedMessage'; // UI Elements export { default as Button } from './components/ui/Button'; export { default as Input } from './components/ui/Input'; -export { default as Tooltip } from './components/ui/Tooltip'; +export { Tooltip } from './components/ui/Tooltip'; +export type { TooltipProps } from './components/ui/Tooltip'; // Permission components export { default as PermissionDrawer } from './components/PermissionDrawer'; @@ -38,6 +47,56 @@ export { default as Icon } from './components/icons/Icon'; export { default as CloseIcon } from './components/icons/CloseIcon'; export { default as SendIcon } from './components/icons/SendIcon'; +// File Icons +export { + FileIcon, + FileListIcon, + SaveDocumentIcon, + FolderIcon, +} from './components/icons/FileIcons'; + +// Status Icons +export { + PlanCompletedIcon, + PlanInProgressIcon, + PlanPendingIcon, + WarningTriangleIcon, + UserIcon, + SymbolIcon, + SelectionIcon, +} from './components/icons/StatusIcons'; + +// Navigation Icons +export { + ChevronDownIcon, + PlusIcon, + PlusSmallIcon, + ArrowUpIcon, + CloseIcon as CloseXIcon, + CloseSmallIcon, + SearchIcon, + RefreshIcon, +} from './components/icons/NavigationIcons'; + +// Edit Icons +export { + EditPencilIcon, + AutoEditIcon, + PlanModeIcon, + CodeBracketsIcon, + HideContextIcon, + SlashCommandIcon, + LinkIcon, + OpenDiffIcon, + UndoIcon, +} from './components/icons/EditIcons'; + +// Special Icons +export { ThinkingIcon, TerminalIcon } from './components/icons/SpecialIcons'; + +// Action Icons +export { StopIcon } from './components/icons/StopIcon'; + // Hooks export { useTheme } from './hooks/useTheme'; export { useLocalStorage } from './hooks/useLocalStorage'; diff --git a/packages/webui/src/types/messages.ts b/packages/webui/src/types/messages.ts index 9556b6176..269eb1c2e 100644 --- a/packages/webui/src/types/messages.ts +++ b/packages/webui/src/types/messages.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + export interface MessageProps { id: string; content: string; diff --git a/packages/webui/src/types/theme.ts b/packages/webui/src/types/theme.ts index 3418c16f4..116199991 100644 --- a/packages/webui/src/types/theme.ts +++ b/packages/webui/src/types/theme.ts @@ -1 +1,7 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + export type Theme = 'light' | 'dark' | 'auto'; From a78cfc572bd0edc9e4176897f09310b2cebcce08 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 15 Jan 2026 21:33:45 +0800 Subject: [PATCH 009/113] feat(webui): migrate message components with optimizations - Migrate MarkdownRenderer, MessageContent, UserMessage, ThinkingMessage - Add useMemo/useCallback for performance optimization - Fix external link false positive using KNOWN_FILE_EXTENSIONS - Fix line number display logic (support line 0 and start-only) - Improve accessibility: use native button, add aria-hidden - Add React.memo to MessageContent - Add markdown-it dependency to webui package --- package-lock.json | 4 + .../vscode-ide-companion/src/webview/App.tsx | 10 +- .../messages/Assistant/AssistantMessage.tsx | 2 +- .../src/webview/components/messages/index.tsx | 4 +- packages/webui/package.json | 4 + .../MarkdownRenderer/MarkdownRenderer.css | 0 .../MarkdownRenderer/MarkdownRenderer.tsx | 258 ++++++++---------- .../messages/MarkdownRenderer/index.ts | 8 + .../components/messages/MessageContent.tsx | 9 +- .../components/messages/ThinkingMessage.tsx | 4 +- .../src}/components/messages/UserMessage.tsx | 37 +-- packages/webui/src/index.ts | 11 + 12 files changed, 172 insertions(+), 179 deletions(-) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/MarkdownRenderer/MarkdownRenderer.css (100%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/MarkdownRenderer/MarkdownRenderer.tsx (66%) create mode 100644 packages/webui/src/components/messages/MarkdownRenderer/index.ts rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/MessageContent.tsx (65%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/ThinkingMessage.tsx (93%) rename packages/{vscode-ide-companion/src/webview => webui/src}/components/messages/UserMessage.tsx (70%) diff --git a/package-lock.json b/package-lock.json index 371fa7682..3a7a4f8f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23761,6 +23761,9 @@ "name": "@qwen-code/webui", "version": "0.1.0", "license": "MIT", + "dependencies": { + "markdown-it": "^14.1.0" + }, "devDependencies": { "@chromatic-com/storybook": "^5.0.0", "@storybook/addon-a11y": "^10.1.11", @@ -23768,6 +23771,7 @@ "@storybook/addon-onboarding": "^10.1.11", "@storybook/addon-vitest": "^10.1.11", "@storybook/react-vite": "^10.1.11", + "@types/markdown-it": "^14.1.2", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^4.2.0", diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index fd0a51e8f..cf3d34337 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -33,15 +33,17 @@ import { Onboarding } from './components/layout/Onboarding.js'; import { type CompletionItem } from '../types/completionItemTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { ChatHeader } from './components/layout/ChatHeader.js'; +import { AssistantMessage } from './components/messages/index.js'; import { UserMessage, - AssistantMessage, ThinkingMessage, -} from './components/messages/index.js'; -import { WaitingMessage, InterruptedMessage } from '@qwen-code/webui'; + WaitingMessage, + InterruptedMessage, + FileIcon, + UserIcon, +} from '@qwen-code/webui'; import { InputForm } from './components/layout/InputForm.js'; import { SessionSelector } from './components/layout/SessionSelector.js'; -import { FileIcon, UserIcon } from '@qwen-code/webui'; import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx index 84712efaf..e31c7d793 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { MessageContent } from '../MessageContent.js'; +import { MessageContent } from '@qwen-code/webui'; import './AssistantMessage.css'; interface AssistantMessageProps { diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx index 1cf19e26c..18eda9a1b 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -4,7 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { UserMessage } from './UserMessage.js'; export { AssistantMessage } from './Assistant/AssistantMessage.js'; -export { ThinkingMessage } from './ThinkingMessage.js'; -// WaitingMessage and InterruptedMessage are now imported from @qwen-code/webui +// UserMessage, ThinkingMessage, WaitingMessage and InterruptedMessage are now imported from @qwen-code/webui diff --git a/packages/webui/package.json b/packages/webui/package.json index b2f5459ab..e293398c1 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -40,7 +40,11 @@ "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, + "dependencies": { + "markdown-it": "^14.1.0" + }, "devDependencies": { + "@types/markdown-it": "^14.1.2", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^4.2.0", diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.css similarity index 100% rename from packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.css rename to packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.css diff --git a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx similarity index 66% rename from packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx rename to packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx index 112464202..7413c4041 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/MarkdownRenderer/MarkdownRenderer.tsx +++ b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -7,11 +7,12 @@ */ import type React from 'react'; +import { useMemo, useCallback } from 'react'; import MarkdownIt from 'markdown-it'; import type { Options as MarkdownItOptions } from 'markdown-it'; import './MarkdownRenderer.css'; -interface MarkdownRendererProps { +export interface MarkdownRendererProps { content: string; onFileClick?: (filePath: string) => void; /** When false, do not convert file paths into clickable links. Default: true */ @@ -28,6 +29,33 @@ const FILE_PATH_REGEX = const FILE_PATH_WITH_LINES_REGEX = /(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi; +// Known file extensions for validation +const KNOWN_FILE_EXTENSIONS = + /\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)$/i; + +/** + * Escape HTML characters for security + */ +const escapeHtml = (unsafe: string): string => + unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +/** + * Create a cached MarkdownIt instance + */ +const createMarkdownInstance = (): MarkdownIt => + new MarkdownIt({ + html: false, // Disable HTML for security + xhtmlOut: false, + breaks: true, + linkify: true, + typographer: true, + } as MarkdownItOptions); + /** * MarkdownRenderer component - renders markdown content with enhanced features */ @@ -36,55 +64,8 @@ export const MarkdownRenderer: React.FC = ({ onFileClick, enableFileLinks = true, }) => { - /** - * Initialize markdown-it with plugins - */ - const getMarkdownInstance = (): MarkdownIt => { - // Create markdown-it instance with options - const md = new MarkdownIt({ - html: false, // Disable HTML for security - xhtmlOut: false, - breaks: true, - linkify: true, - typographer: true, - } as MarkdownItOptions); - - return md; - }; - - /** - * Render markdown content to HTML - */ - const renderMarkdown = (): string => { - try { - const md = getMarkdownInstance(); - - // Process the markdown content - let html = md.render(content); - - // Post-process to add file path click handlers unless disabled - if (enableFileLinks) { - html = processFilePaths(html); - } - - return html; - } catch (error) { - console.error('Error rendering markdown:', error); - // Fallback to plain text if markdown rendering fails - return escapeHtml(content); - } - }; - - /** - * Escape HTML characters for security - */ - const escapeHtml = (unsafe: string): string => - unsafe - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + // Cache MarkdownIt instance + const md = useMemo(() => createMarkdownInstance(), []); /** * Process file paths in HTML to make them clickable @@ -117,17 +98,15 @@ export const MarkdownRenderer: React.FC = ({ 'gi', ); - // Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line) + // Convert a "path#fragment" into VS Code friendly "path:line" const normalizePathAndLine = ( raw: string, ): { displayText: string; dataPath: string } => { const displayText = raw; let base = raw; - // Extract hash fragment like #12, #L12 or #12-34 and keep only the first number const hashIndex = raw.indexOf('#'); if (hashIndex >= 0) { const frag = raw.slice(hashIndex + 1); - // Accept L12, 12 or 12-34 const m = frag.match(/^L?(\d+)(?:-\d+)?$/i); if (m) { const line = parseInt(m[1], 10); @@ -140,35 +119,31 @@ export const MarkdownRenderer: React.FC = ({ const makeLink = (text: string) => { const link = document.createElement('a'); - // Pass base path (with optional :line) to the handler; keep the full text as label const { dataPath } = normalizePathAndLine(text); link.className = 'file-path-link'; link.textContent = text; link.setAttribute('href', '#'); link.setAttribute('title', `Open ${text}`); - // Carry file path via data attribute; click handled by event delegation link.setAttribute('data-file-path', dataPath); return link; }; + // Helper: identify dot-chained code refs (e.g. vscode.commands.register) + const isCodeReference = (str: string): boolean => { + if (BARE_FILE_REGEX.test(str)) { + return false; + } + if (/[/\\]/.test(str)) { + return false; + } + const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; + return codeRefPattern.test(str); + }; + const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => { const href = a.getAttribute('href') || ''; const text = (a.textContent || '').trim(); - // Helper: identify dot-chained code refs (e.g. vscode.commands.register) - // but DO NOT treat filenames/paths as code refs. - const isCodeReference = (str: string): boolean => { - if (BARE_FILE_REGEX.test(str)) { - return false; // looks like a filename - } - if (/[/\\]/.test(str)) { - return false; // contains a path separator - } - const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; - return codeRefPattern.test(str); - }; - - // If linkify turned a bare filename (e.g. README.md) into http://, convert it back const httpMatch = href.match(/^https?:\/\/(.+)$/i); if (httpMatch) { try { @@ -177,7 +152,6 @@ export const MarkdownRenderer: React.FC = ({ const pathname = url.pathname || ''; const noPath = pathname === '' || pathname === '/'; - // Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md) if ( noPath && BARE_FILE_REGEX.test(text) && @@ -191,7 +165,6 @@ export const MarkdownRenderer: React.FC = ({ return; } - // Case 2: host itself looks like a filename (rare but happens), use it if (noPath && BARE_FILE_REGEX.test(host)) { const { dataPath } = normalizePathAndLine(host); a.classList.add('file-path-link'); @@ -201,18 +174,16 @@ export const MarkdownRenderer: React.FC = ({ return; } } catch { - // fall through; unparseable URL + // fall through } } - // Ignore other external protocols if (/^(https?|mailto|ftp|data):/i.test(href)) { return; } const candidate = href || text; - // Skip if it looks like a code reference if (isCodeReference(candidate)) { return; } @@ -229,7 +200,6 @@ export const MarkdownRenderer: React.FC = ({ return; } - // Bare file name or relative path (e.g. README.md or docs/README.md) if (BARE_FILE_REGEX.test(candidate)) { const { dataPath } = normalizePathAndLine(candidate); a.classList.add('file-path-link'); @@ -239,28 +209,13 @@ export const MarkdownRenderer: React.FC = ({ } }; - // Helper: identify dot-chained code refs (e.g. vscode.commands.register) - // but DO NOT treat filenames/paths as code refs. - const isCodeReference = (str: string): boolean => { - if (BARE_FILE_REGEX.test(str)) { - return false; // looks like a filename - } - if (/[/\\]/.test(str)) { - return false; // contains a path separator - } - const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/; - return codeRefPattern.test(str); - }; - const walk = (node: Node) => { - // Do not transform inside existing anchors if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement; if (el.tagName.toLowerCase() === 'a') { upgradeAnchorIfFilePath(el as HTMLAnchorElement); - return; // Don't descend into + return; } - // Avoid transforming inside code/pre blocks const tag = el.tagName.toLowerCase(); if (tag === 'code' || tag === 'pre') { return; @@ -268,7 +223,7 @@ export const MarkdownRenderer: React.FC = ({ } for (let child = node.firstChild; child; ) { - const next = child.nextSibling; // child may be replaced + const next = child.nextSibling; if (child.nodeType === Node.TEXT_NODE) { const text = child.nodeValue || ''; union.lastIndex = 0; @@ -282,9 +237,7 @@ export const MarkdownRenderer: React.FC = ({ const matchText = m[0]; const idx = m.index; - // Skip if it looks like a code reference if (isCodeReference(matchText)) { - // Just add the text as-is without creating a link if (idx > lastIndex) { frag.appendChild( document.createTextNode(text.slice(lastIndex, idx)), @@ -319,69 +272,84 @@ export const MarkdownRenderer: React.FC = ({ return container.innerHTML; }; - // Event delegation: intercept clicks on generated file-path links - const handleContainerClick = ( - e: React.MouseEvent, - ) => { - // If file links disabled, do nothing - if (!enableFileLinks) { - return; - } - const target = e.target as HTMLElement | null; - if (!target) { - return; - } + /** + * Render markdown content to HTML (memoized) + */ + const renderedHtml = useMemo(() => { + try { + let html = md.render(content); - // Find nearest anchor with our marker class - const anchor = (target.closest && - target.closest('a.file-path-link')) as HTMLAnchorElement | null; - if (anchor) { - const filePath = anchor.getAttribute('data-file-path'); - if (!filePath) { + if (enableFileLinks) { + html = processFilePaths(html); + } + + return html; + } catch (error) { + console.error('Error rendering markdown:', error); + return escapeHtml(content); + } + }, [content, enableFileLinks, md]); + + // Event delegation: intercept clicks on generated file-path links + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + if (!enableFileLinks) { + return; + } + const target = e.target as HTMLElement | null; + if (!target) { return; } - e.preventDefault(); - e.stopPropagation(); - onFileClick?.(filePath); - return; - } - // Fallback: intercept "http://README.md" style links that slipped through - const anyAnchor = (target.closest && - target.closest('a')) as HTMLAnchorElement | null; - if (!anyAnchor) { - return; - } - - const href = anyAnchor.getAttribute('href') || ''; - if (!/^https?:\/\//i.test(href)) { - return; - } - try { - const url = new URL(href); - const host = url.hostname || ''; - const path = url.pathname || ''; - const noPath = path === '' || path === '/'; - - // Basic bare filename heuristic on the host part (e.g. README.md) - if (noPath && /\.[a-z0-9]+$/i.test(host)) { - // Prefer the readable text content if it looks like a file - const text = (anyAnchor.textContent || '').trim(); - const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host; + const anchor = (target.closest && + target.closest('a.file-path-link')) as HTMLAnchorElement | null; + if (anchor) { + const filePath = anchor.getAttribute('data-file-path'); + if (!filePath) { + return; + } e.preventDefault(); e.stopPropagation(); - onFileClick?.(candidate); + onFileClick?.(filePath); + return; } - } catch { - // ignore - } - }; + + const anyAnchor = (target.closest && + target.closest('a')) as HTMLAnchorElement | null; + if (!anyAnchor) { + return; + } + + const href = anyAnchor.getAttribute('href') || ''; + if (!/^https?:\/\//i.test(href)) { + return; + } + try { + const url = new URL(href); + const host = url.hostname || ''; + const path = url.pathname || ''; + const noPath = path === '' || path === '/'; + + // Only treat as file if host has a known file extension + if (noPath && KNOWN_FILE_EXTENSIONS.test(host)) { + const text = (anyAnchor.textContent || '').trim(); + const candidate = KNOWN_FILE_EXTENSIONS.test(text) ? text : host; + e.preventDefault(); + e.stopPropagation(); + onFileClick?.(candidate); + } + } catch { + // ignore + } + }, + [enableFileLinks, onFileClick], + ); return (
void; enableFileLinks?: boolean; } -export const MessageContent: React.FC = ({ +const MessageContentBase: React.FC = ({ content, onFileClick, enableFileLinks, @@ -24,3 +25,7 @@ export const MessageContent: React.FC = ({ enableFileLinks={enableFileLinks} /> ); + +MessageContentBase.displayName = 'MessageContent'; + +export const MessageContent = memo(MessageContentBase); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx b/packages/webui/src/components/messages/ThinkingMessage.tsx similarity index 93% rename from packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx rename to packages/webui/src/components/messages/ThinkingMessage.tsx index 1f92e1f4e..db91854f8 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/ThinkingMessage.tsx +++ b/packages/webui/src/components/messages/ThinkingMessage.tsx @@ -7,7 +7,7 @@ import type React from 'react'; import { MessageContent } from './MessageContent.js'; -interface ThinkingMessageProps { +export interface ThinkingMessageProps { content: string; timestamp: number; onFileClick?: (path: string) => void; @@ -30,7 +30,7 @@ export const ThinkingMessage: React.FC = ({ color: 'var(--app-primary-foreground)', }} > - +
- {/* File context indicator */} {fileContextDisplay && (
-
fileContext && onFileClick?.(fileContext.filePath)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - fileContext && onFileClick?.(fileContext.filePath); - } - }} + disabled={!onFileClick} > -
= ({ }} > {fileContextDisplay} -
-
+ +
)}
diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 5bfaf89cd..168574a6f 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -32,6 +32,17 @@ export { default as MessageInput } from './components/messages/MessageInput'; export { default as MessageList } from './components/messages/MessageList'; export { WaitingMessage } from './components/messages/Waiting/WaitingMessage'; export { InterruptedMessage } from './components/messages/Waiting/InterruptedMessage'; +export { MarkdownRenderer } from './components/messages/MarkdownRenderer/MarkdownRenderer'; +export type { MarkdownRendererProps } from './components/messages/MarkdownRenderer/MarkdownRenderer'; +export { MessageContent } from './components/messages/MessageContent'; +export type { MessageContentProps } from './components/messages/MessageContent'; +export { UserMessage } from './components/messages/UserMessage'; +export type { + UserMessageProps, + FileContext, +} from './components/messages/UserMessage'; +export { ThinkingMessage } from './components/messages/ThinkingMessage'; +export type { ThinkingMessageProps } from './components/messages/ThinkingMessage'; // UI Elements export { default as Button } from './components/ui/Button'; From 2cdfb1ffad0a29958fb1aa1c0eae314e17e93474 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 16 Jan 2026 00:37:49 +0800 Subject: [PATCH 010/113] feat(webui): migrate ToolCall shared components and FileLink - Add FileLink component with PlatformContext support - Migrate ToolCallContainer, ToolCallCard, ToolCallRow, StatusIndicator, CodeBlock, LocationsList - Add CopyButton and handleCopyToClipboard utilities - Export utility functions: groupContent, mapToolStatusToContainerStatus, formatValue, etc. - Add shared types: BaseToolCallProps, ToolCallData, ToolCallContent, etc. --- .../webui/src/components/layout/FileLink.tsx | 161 +++++++++++ .../webui/src/components/toolcalls/index.ts | 8 + .../toolcalls/shared/LayoutComponents.css | 205 ++++++++++++++ .../toolcalls/shared/LayoutComponents.tsx | 188 +++++++++++++ .../components/toolcalls/shared/copyUtils.tsx | 76 +++++ .../src/components/toolcalls/shared/index.ts | 41 +++ .../src/components/toolcalls/shared/types.ts | 82 ++++++ .../src/components/toolcalls/shared/utils.ts | 263 ++++++++++++++++++ packages/webui/src/index.ts | 45 ++- 9 files changed, 1063 insertions(+), 6 deletions(-) create mode 100644 packages/webui/src/components/layout/FileLink.tsx create mode 100644 packages/webui/src/components/toolcalls/index.ts create mode 100644 packages/webui/src/components/toolcalls/shared/LayoutComponents.css create mode 100644 packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx create mode 100644 packages/webui/src/components/toolcalls/shared/copyUtils.tsx create mode 100644 packages/webui/src/components/toolcalls/shared/index.ts create mode 100644 packages/webui/src/components/toolcalls/shared/types.ts create mode 100644 packages/webui/src/components/toolcalls/shared/utils.ts diff --git a/packages/webui/src/components/layout/FileLink.tsx b/packages/webui/src/components/layout/FileLink.tsx new file mode 100644 index 000000000..1f8a77a95 --- /dev/null +++ b/packages/webui/src/components/layout/FileLink.tsx @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * FileLink component - Clickable file path links + * Platform-agnostic version using PlatformContext + * Supports clicking to open files and jump to specified line and column numbers + */ + +import type React from 'react'; +import { usePlatform } from '../../context/PlatformContext.js'; + +/** + * Props for FileLink component + */ +export interface FileLinkProps { + /** File path */ + path: string; + /** Optional line number (starting from 1) */ + line?: number | null; + /** Optional column number (starting from 1) */ + column?: number | null; + /** Whether to show full path, default false (show filename only) */ + showFullPath?: boolean; + /** Optional custom class name */ + className?: string; + /** Whether to disable click behavior (use when parent element handles clicks) */ + disableClick?: boolean; +} + +/** + * Extract filename from full path + * @param path File path + * @returns Filename + */ +function getFileName(path: string): string { + const segments = path.split(/[/\\]/); + return segments[segments.length - 1] || path; +} + +/** + * Build full path string including line and column numbers + * @param path Base file path + * @param line Optional line number + * @param column Optional column number + * @returns Full path with line:column suffix if provided + */ +function buildFullPath( + path: string, + line?: number | null, + column?: number | null, +): string { + let fullPath = path; + if (line !== null && line !== undefined) { + fullPath += `:${line}`; + if (column !== null && column !== undefined) { + fullPath += `:${column}`; + } + } + return fullPath; +} + +/** + * FileLink component - Clickable file link + * + * Features: + * - Click to open file using platform-specific handler + * - Support line and column number navigation + * - Hover to show full path + * - Optional display mode (full path vs filename only) + * + * @example + * ```tsx + * + * + * ``` + */ +export const FileLink: React.FC = ({ + path, + line, + column, + showFullPath = false, + className = '', + disableClick = false, +}) => { + const platform = usePlatform(); + + /** + * Handle click event - Open file using platform-specific method + */ + const handleClick = (e: React.MouseEvent) => { + // Always prevent default behavior (prevent tag # navigation) + e.preventDefault(); + + if (disableClick) { + // If click is disabled, return directly without stopping propagation + // This allows parent elements to handle click events + return; + } + + // If click is enabled, stop event propagation + e.stopPropagation(); + + // Build full path including line and column numbers + const fullPath = buildFullPath(path, line, column); + + // Use platform-specific openFile if available, otherwise use postMessage + if (platform.openFile) { + platform.openFile(fullPath); + } else { + platform.postMessage({ + type: 'openFile', + data: { path: fullPath }, + }); + } + }; + + // Build display text + const displayPath = showFullPath ? path : getFileName(path); + + // Build hover tooltip (always show full path) + const fullDisplayText = buildFullPath(path, line, column); + + return ( + + {displayPath} + {line !== null && line !== undefined && ( + + :{line} + {column !== null && column !== undefined && <>:{column}} + + )} + + ); +}; diff --git a/packages/webui/src/components/toolcalls/index.ts b/packages/webui/src/components/toolcalls/index.ts new file mode 100644 index 000000000..db76591aa --- /dev/null +++ b/packages/webui/src/components/toolcalls/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Re-export shared toolcall components and types +export * from './shared/index.js'; diff --git a/packages/webui/src/components/toolcalls/shared/LayoutComponents.css b/packages/webui/src/components/toolcalls/shared/LayoutComponents.css new file mode 100644 index 000000000..e5b2cce9b --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/LayoutComponents.css @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * LayoutComponents.css - Tool call layout styles with timeline support + */ + +/* ToolCallContainer with timeline support */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; + user-select: text; + align-items: flex-start; +} + +/* Default timeline connector line */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* Status-specific styles using ::before pseudo-element for bullet points */ +.toolcall-container.toolcall-status-default::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + z-index: 1; +} + +.toolcall-container.toolcall-status-success::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #74c991; + z-index: 1; +} + +.toolcall-container.toolcall-status-error::before { + content: '\25cf'; + position: absolute; + left: 8px; + font-size: 10px; + color: #c74e39; + z-index: 1; +} + +.toolcall-container.toolcall-status-warning::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: #e1c08d; + z-index: 1; +} + +.toolcall-container.toolcall-status-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); + animation: toolcallPulse 1s linear infinite; + z-index: 1; +} + +/* Loading animation */ +@keyframes toolcallPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Content wrapper */ +.toolcall-content-wrapper { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +/* Legacy card styles */ +.toolcall-card { + grid-template-columns: auto 1fr; + gap: var(--spacing-medium); + background: var(--app-input-background); + border: 1px solid var(--app-input-border); + border-radius: var(--border-radius-medium); + padding: var(--spacing-large); + margin: var(--spacing-medium) 0; + align-items: start; + animation: fadeIn 0.2s ease-in; +} + +/* Legacy row styles */ +.toolcall-row { + grid-template-columns: 80px 1fr; + gap: var(--spacing-medium); + min-width: 0; +} + +.toolcall-row-label { + font-size: var(--font-size-xs); + color: var(--app-secondary-foreground); + font-weight: 500; + padding-top: 2px; +} + +.toolcall-row-content { + color: var(--app-primary-foreground); + min-width: 0; + word-break: break-word; +} + +/* Locations list */ +.toolcall-locations-list { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 100%; +} + +/* ToolCall header with loading indicator */ +.toolcall-header { + position: relative; +} + +.toolcall-header::before { + content: '\25cf'; + position: absolute; + left: -22px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + line-height: 1; + z-index: 1; + color: #e1c08d; + animation: toolcallHeaderPulse 1.5s ease-in-out infinite; +} + +/* Loading animation for toolcall header */ +@keyframes toolcallHeaderPulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* In-progress toolcall specific styles */ +.in-progress-toolcall .toolcall-content-wrapper { + display: flex; + flex-direction: column; + gap: 1; + min-width: 0; + max-width: 100%; +} + +.in-progress-toolcall .toolcall-header { + display: flex; + align-items: center; + gap: 2; + position: relative; + min-width: 0; +} + +.in-progress-toolcall .toolcall-content-text { + word-break: break-word; + white-space: pre-wrap; + width: 100%; +} diff --git a/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx b/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx new file mode 100644 index 000000000..1433d261b --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared layout components for tool call UI + * Platform-agnostic version using webui components + */ + +import type React from 'react'; +import { FileLink } from '../../layout/FileLink.js'; +import './LayoutComponents.css'; + +/** + * Props for ToolCallContainer + */ +export interface ToolCallContainerProps { + /** Operation label (e.g., "Read", "Write", "Search") */ + label: string; + /** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */ + status?: 'success' | 'error' | 'warning' | 'loading' | 'default'; + /** Main content to display */ + children: React.ReactNode; + /** Tool call ID for debugging */ + toolCallId?: string; + /** Optional trailing content rendered next to label (e.g., clickable filename) */ + labelSuffix?: React.ReactNode; + /** Optional custom class name */ + className?: string; +} + +/** + * ToolCallContainer - Main container for tool call displays + * Features timeline connector line and status bullet + */ +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId: _toolCallId, + labelSuffix, + className: _className, +}) => ( +
+
+
+ + {label} + + + {labelSuffix} + +
+ {children && ( +
+ {children} +
+ )} +
+
+); + +/** + * Props for ToolCallCard + */ +interface ToolCallCardProps { + icon: string; + children: React.ReactNode; +} + +/** + * ToolCallCard - Legacy card wrapper for complex layouts like diffs + */ +export const ToolCallCard: React.FC = ({ + icon: _icon, + children, +}) => ( +
+
{children}
+
+); + +/** + * Props for ToolCallRow + */ +interface ToolCallRowProps { + label: string; + children: React.ReactNode; +} + +/** + * ToolCallRow - A single row in the tool call grid (legacy - for complex layouts) + */ +export const ToolCallRow: React.FC = ({ + label, + children, +}) => ( +
+
+ {label} +
+
+ {children} +
+
+); + +/** + * Props for StatusIndicator + */ +interface StatusIndicatorProps { + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + text: string; +} + +/** + * Get status color class for StatusIndicator + */ +const getStatusColorClass = ( + status: 'pending' | 'in_progress' | 'completed' | 'failed', +): string => { + switch (status) { + case 'pending': + return 'bg-[#ffc107]'; + case 'in_progress': + return 'bg-[#2196f3]'; + case 'completed': + return 'bg-[#4caf50]'; + case 'failed': + return 'bg-[#f44336]'; + default: + return 'bg-gray-500'; + } +}; + +/** + * StatusIndicator - Status indicator with colored dot + */ +export const StatusIndicator: React.FC = ({ + status, + text, +}) => ( +
+ + {text} +
+); + +/** + * Props for CodeBlock + */ +interface CodeBlockProps { + children: string; +} + +/** + * CodeBlock - Code block for displaying formatted code or output + */ +export const CodeBlock: React.FC = ({ children }) => ( +
+    {children}
+  
+); + +/** + * Props for LocationsList + */ +interface LocationsListProps { + locations: Array<{ + path: string; + line?: number | null; + }>; +} + +/** + * LocationsList - List of file locations with clickable links + */ +export const LocationsList: React.FC = ({ locations }) => ( +
+ {locations.map((loc, idx) => ( + + ))} +
+); diff --git a/packages/webui/src/components/toolcalls/shared/copyUtils.tsx b/packages/webui/src/components/toolcalls/shared/copyUtils.tsx new file mode 100644 index 000000000..9ead4cee0 --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/copyUtils.tsx @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared copy utilities for toolcall components + */ + +import type React from 'react'; +import { useState } from 'react'; + +/** + * Handle copy to clipboard + * @param text Text to copy + * @param event Mouse event to stop propagation + */ +export const handleCopyToClipboard = async ( + text: string, + event: React.MouseEvent, +): Promise => { + event.stopPropagation(); // Prevent triggering the row click + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error('Failed to copy text:', err); + } +}; + +/** + * Copy button component props + */ +interface CopyButtonProps { + text: string; +} + +/** + * CopyButton - Shared copy button component with Tailwind styles + * Note: Parent element should have 'group' class for hover effect + */ +export const CopyButton: React.FC = ({ text }) => { + const [showTooltip, setShowTooltip] = useState(false); + + return ( + + ); +}; diff --git a/packages/webui/src/components/toolcalls/shared/index.ts b/packages/webui/src/components/toolcalls/shared/index.ts new file mode 100644 index 000000000..784778aea --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/index.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Layout components +export { + ToolCallContainer, + ToolCallCard, + ToolCallRow, + StatusIndicator, + CodeBlock, + LocationsList, +} from './LayoutComponents.js'; +export type { ToolCallContainerProps } from './LayoutComponents.js'; + +// Copy utilities +export { handleCopyToClipboard, CopyButton } from './copyUtils.js'; + +// Utility functions +export { + extractCommandOutput, + formatValue, + safeTitle, + shouldShowToolCall, + groupContent, + hasToolCallOutput, + mapToolStatusToContainerStatus, +} from './utils.js'; + +// Types +export type { + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolCallData, + BaseToolCallProps, + GroupedContent, + ContainerStatus, +} from './types.js'; diff --git a/packages/webui/src/components/toolcalls/shared/types.ts b/packages/webui/src/components/toolcalls/shared/types.ts new file mode 100644 index 000000000..59026460b --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/types.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared types for tool call components + */ + +/** + * Tool call content types + */ +export interface ToolCallContent { + type: 'content' | 'diff'; + // For content type + content?: { + type: string; + text?: string; + error?: unknown; + [key: string]: unknown; + }; + // For diff type + path?: string; + oldText?: string | null; + newText?: string; +} + +/** + * Tool call location type + */ +export interface ToolCallLocation { + path: string; + line?: number | null; +} + +/** + * Tool call status type + */ +export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +/** + * Base tool call data interface + */ +export interface ToolCallData { + toolCallId: string; + kind: string; + title: string | object; + status: ToolCallStatus; + rawInput?: string | object; + content?: ToolCallContent[]; + locations?: ToolCallLocation[]; + timestamp?: number; +} + +/** + * Base props for all tool call components + */ +export interface BaseToolCallProps { + toolCall: ToolCallData; + // Optional timeline flags for rendering connector line cropping + isFirst?: boolean; + isLast?: boolean; +} + +/** + * Grouped content structure for rendering + */ +export interface GroupedContent { + textOutputs: string[]; + errors: string[]; + diffs: ToolCallContent[]; + otherData: unknown[]; +} + +/** + * Container status type for styling + */ +export type ContainerStatus = + | 'success' + | 'error' + | 'warning' + | 'loading' + | 'default'; diff --git a/packages/webui/src/components/toolcalls/shared/utils.ts b/packages/webui/src/components/toolcalls/shared/utils.ts new file mode 100644 index 000000000..f56720663 --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/utils.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared utility functions for tool call components + * Platform-agnostic utilities that can be used across different platforms + */ + +import type { + ToolCallContent, + GroupedContent, + ToolCallData, + ToolCallStatus, + ContainerStatus, +} from './types.js'; + +/** + * Extract output from command execution result text + * Handles both JSON format and structured text format + * + * Example structured text: + * ``` + * Command: lsof -i :5173 + * Directory: (root) + * Output: COMMAND PID USER... + * Error: (none) + * Exit Code: 0 + * ``` + */ +export const extractCommandOutput = (text: string): string => { + // First try: Parse as JSON and extract output field + try { + const parsed = JSON.parse(text) as { output?: unknown; Output?: unknown }; + const output = parsed.output ?? parsed.Output; + if (output !== undefined && output !== null) { + return typeof output === 'string' + ? output + : JSON.stringify(output, null, 2); + } + } catch (_error) { + // Not JSON, continue with text parsing + } + + // Second try: Extract from structured text format + const outputMatch = text.match(/Output:[ \t]*(.+?)(?=\nError:|$)/i); + if (outputMatch && outputMatch[1]) { + const output = outputMatch[1].trim(); + if (output && output !== '(none)' && output.length > 0) { + return output; + } + } + + // Third try: Check if text starts with structured format + if (text.match(/^Command:/)) { + const lines = text.split('\n'); + const outputLines: string[] = []; + let inOutput = false; + + for (const line of lines) { + if ( + line.startsWith('Error:') || + line.startsWith('Exit Code:') || + line.startsWith('Signal:') || + line.startsWith('Background PIDs:') || + line.startsWith('Process Group PGID:') + ) { + break; + } + if (line.startsWith('Command:') || line.startsWith('Directory:')) { + continue; + } + if (line.startsWith('Output:')) { + inOutput = true; + const content = line.substring('Output:'.length).trim(); + if (content && content !== '(none)') { + outputLines.push(content); + } + continue; + } + if ( + inOutput || + (!line.startsWith('Command:') && !line.startsWith('Directory:')) + ) { + outputLines.push(line); + } + } + + if (outputLines.length > 0) { + const result = outputLines.join('\n').trim(); + if (result && result !== '(none)') { + return result; + } + } + } + + // Fallback: Return original text + return text; +}; + +/** + * Format any value to a string for display + */ +export const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return extractCommandOutput(value); + } + if (value instanceof Error) { + return value.message || value.toString(); + } + if (typeof value === 'object' && value !== null && 'message' in value) { + const errorObj = value as { message?: string; stack?: string }; + return errorObj.message || String(value); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch (_e) { + return String(value); + } + } + return String(value); +}; + +/** + * Safely convert title to string, handling object types + * Returns empty string if no meaningful title + */ +export const safeTitle = (title: unknown): string => { + if (typeof title === 'string' && title.trim()) { + return title; + } + if (title && typeof title === 'object') { + return JSON.stringify(title); + } + return ''; +}; + +/** + * Check if a tool call should be displayed + * Hides internal tool calls + */ +export const shouldShowToolCall = (kind: string): boolean => + !kind.includes('internal'); + +/** + * Group tool call content by type to avoid duplicate labels + */ +export const groupContent = (content?: ToolCallContent[]): GroupedContent => { + const textOutputs: string[] = []; + const errors: string[] = []; + const diffs: ToolCallContent[] = []; + const otherData: unknown[] = []; + + content?.forEach((item) => { + if (item.type === 'diff') { + diffs.push(item); + } else if (item.content) { + const contentObj = item.content; + + if (contentObj.type === 'error' || 'error' in contentObj) { + let errorMsg = ''; + + if (typeof contentObj.error === 'string') { + errorMsg = contentObj.error; + } else if ( + contentObj.error && + typeof contentObj.error === 'object' && + 'message' in contentObj.error + ) { + errorMsg = (contentObj.error as { message: string }).message; + } else if (contentObj.text) { + errorMsg = formatValue(contentObj.text); + } else if (contentObj.error) { + errorMsg = formatValue(contentObj.error); + } else { + errorMsg = 'An error occurred'; + } + + errors.push(errorMsg); + } else if (contentObj.text) { + textOutputs.push(formatValue(contentObj.text)); + } else { + otherData.push(contentObj); + } + } + }); + + return { textOutputs, errors, diffs, otherData }; +}; + +/** + * Check if a tool call has actual output to display + * Returns false for tool calls that completed successfully but have no visible output + */ +export const hasToolCallOutput = (toolCall: ToolCallData): boolean => { + if (toolCall.status === 'failed') { + return true; + } + + const kind = toolCall.kind.toLowerCase(); + if (kind === 'execute' || kind === 'bash' || kind === 'command') { + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + } + + if (toolCall.locations && toolCall.locations.length > 0) { + return true; + } + + if (toolCall.content && toolCall.content.length > 0) { + const grouped = groupContent(toolCall.content); + if ( + grouped.textOutputs.length > 0 || + grouped.errors.length > 0 || + grouped.diffs.length > 0 || + grouped.otherData.length > 0 + ) { + return true; + } + } + + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + + return false; +}; + +/** + * Map a tool call status to a ToolCallContainer status (bullet color) + * - pending/in_progress -> loading + * - completed -> success + * - failed -> error + * - default fallback + */ +export const mapToolStatusToContainerStatus = ( + status: ToolCallStatus, +): ContainerStatus => { + switch (status) { + case 'pending': + case 'in_progress': + return 'loading'; + case 'failed': + return 'error'; + case 'completed': + return 'success'; + default: + return 'default'; + } +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 168574a6f..0a34aa505 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -25,6 +25,8 @@ export { default as Header } from './components/layout/Header'; export { default as Sidebar } from './components/layout/Sidebar'; export { default as Main } from './components/layout/Main'; export { default as Footer } from './components/layout/Footer'; +export { FileLink } from './components/layout/FileLink'; +export type { FileLinkProps } from './components/layout/FileLink'; // Message components export { default as Message } from './components/messages/Message'; @@ -43,6 +45,11 @@ export type { } from './components/messages/UserMessage'; export { ThinkingMessage } from './components/messages/ThinkingMessage'; export type { ThinkingMessageProps } from './components/messages/ThinkingMessage'; +export { AssistantMessage } from './components/messages/Assistant/AssistantMessage'; +export type { + AssistantMessageProps, + AssistantMessageStatus, +} from './components/messages/Assistant/AssistantMessage'; // UI Elements export { default as Button } from './components/ui/Button'; @@ -53,6 +60,34 @@ export type { TooltipProps } from './components/ui/Tooltip'; // Permission components export { default as PermissionDrawer } from './components/PermissionDrawer'; +// ToolCall shared components +export { + ToolCallContainer, + ToolCallCard, + ToolCallRow, + StatusIndicator, + CodeBlock, + LocationsList, + handleCopyToClipboard, + CopyButton, + // Utility functions + extractCommandOutput, + formatValue, + safeTitle, + shouldShowToolCall, + groupContent, + hasToolCallOutput, + mapToolStatusToContainerStatus, +} from './components/toolcalls'; +export type { + ToolCallContainerProps, + ToolCallContent, + ToolCallData, + BaseToolCallProps, + GroupedContent, + ContainerStatus, +} from './components/toolcalls'; + // Icons export { default as Icon } from './components/icons/Icon'; export { default as CloseIcon } from './components/icons/CloseIcon'; @@ -116,9 +151,7 @@ export { useLocalStorage } from './hooks/useLocalStorage'; export type { Theme } from './types/theme'; export type { MessageProps } from './types/messages'; export type { ChatMessage, MessageRole, PlanEntry } from './types/chat'; -export type { - ToolCallStatus, - ToolCallLocation, - ToolCallContentItem, - ToolCallUpdate, -} from './types/toolCall'; +// ToolCallStatus and ToolCallLocation are now exported from './components/toolcalls' +export type { ToolCallContentItem, ToolCallUpdate } from './types/toolCall'; +// Re-export ToolCallStatus and ToolCallLocation for backward compatibility +export type { ToolCallStatus, ToolCallLocation } from './components/toolcalls'; From 4f0aed4d71534882626af71053f430b11d5332f7 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 16 Jan 2026 00:38:15 +0800 Subject: [PATCH 011/113] feat(webui): migrate AssistantMessage component - Move AssistantMessage.tsx and AssistantMessage.css from vscode-ide-companion - Export AssistantMessageProps and AssistantMessageStatus types --- .../messages/Assistant/AssistantMessage.css | 52 +++++++++++ .../messages/Assistant/AssistantMessage.tsx | 91 +++++++++++++++++++ .../components/messages/Assistant/index.ts | 11 +++ 3 files changed, 154 insertions(+) create mode 100644 packages/webui/src/components/messages/Assistant/AssistantMessage.css create mode 100644 packages/webui/src/components/messages/Assistant/AssistantMessage.tsx create mode 100644 packages/webui/src/components/messages/Assistant/index.ts diff --git a/packages/webui/src/components/messages/Assistant/AssistantMessage.css b/packages/webui/src/components/messages/Assistant/AssistantMessage.css new file mode 100644 index 000000000..676758166 --- /dev/null +++ b/packages/webui/src/components/messages/Assistant/AssistantMessage.css @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AssistantMessage Component Styles + * Pseudo-elements (::before) for bullet points and (::after) for timeline connectors + */ + +/* Bullet point indicator using ::before pseudo-element */ +.assistant-message-container.assistant-message-default::before, +.assistant-message-container.assistant-message-success::before, +.assistant-message-container.assistant-message-error::before, +.assistant-message-container.assistant-message-warning::before, +.assistant-message-container.assistant-message-loading::before { + content: '\25cf'; + position: absolute; + left: 8px; + padding-top: 2px; + font-size: 10px; + z-index: 1; +} + +/* Default state - secondary foreground color */ +.assistant-message-container.assistant-message-default::before { + color: var(--app-secondary-foreground); +} + +/* Success state - green bullet (maps to .ge) */ +.assistant-message-container.assistant-message-success::before { + color: #74c991; +} + +/* Error state - red bullet (maps to .be) */ +.assistant-message-container.assistant-message-error::before { + color: #c74e39; +} + +/* Warning state - yellow/orange bullet (maps to .ue) */ +.assistant-message-container.assistant-message-warning::before { + color: #e1c08d; +} + +/* Loading state - static bullet (maps to .he) */ +.assistant-message-container.assistant-message-loading::before { + color: var(--app-secondary-foreground); + background-color: var(--app-secondary-background); +} + +.assistant-message-container.assistant-message-loading::after { + display: none; +} diff --git a/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx b/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx new file mode 100644 index 000000000..e0c41970f --- /dev/null +++ b/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { MessageContent } from '../MessageContent.js'; +import './AssistantMessage.css'; + +export type AssistantMessageStatus = + | 'default' + | 'success' + | 'error' + | 'warning' + | 'loading'; + +export interface AssistantMessageProps { + content: string; + timestamp?: number; + onFileClick?: (path: string) => void; + status?: AssistantMessageStatus; + /** When true, render without the left status bullet (no ::before dot) */ + hideStatusIcon?: boolean; +} + +/** + * AssistantMessage component - renders AI responses with styling + * Supports different states: default, success, error, warning, loading + */ +export const AssistantMessage: React.FC = ({ + content, + timestamp: _timestamp, + onFileClick, + status = 'default', + hideStatusIcon = false, +}) => { + // Empty content not rendered directly + if (!content || content.trim().length === 0) { + return null; + } + + const getStatusClass = () => { + if (hideStatusIcon) { + return ''; + } + switch (status) { + case 'success': + return 'assistant-message-success'; + case 'error': + return 'assistant-message-error'; + case 'warning': + return 'assistant-message-warning'; + case 'loading': + return 'assistant-message-loading'; + default: + return 'assistant-message-default'; + } + }; + + return ( +
+ +
+ +
+
+
+ ); +}; diff --git a/packages/webui/src/components/messages/Assistant/index.ts b/packages/webui/src/components/messages/Assistant/index.ts new file mode 100644 index 000000000..9b8bb76da --- /dev/null +++ b/packages/webui/src/components/messages/Assistant/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { AssistantMessage } from './AssistantMessage.js'; +export type { + AssistantMessageProps, + AssistantMessageStatus, +} from './AssistantMessage.js'; From ef48ebc118cfc03e0bcf6be679d07ef50ab7d8b9 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 16 Jan 2026 00:38:42 +0800 Subject: [PATCH 012/113] refactor(vscode-ide-companion): use @qwen-code/webui for shared components - Add VSCodePlatformProvider to wrap App with PlatformContext - Update all ToolCall components to import from @qwen-code/webui - Replace local FileLink, LayoutComponents, copyUtils with re-exports - Replace local utils.ts with re-exports from webui - Remove local LayoutComponents.css (now bundled in webui) - Remove MarkdownRenderer.css import (now bundled in webui) - Delete local AssistantMessage component (moved to webui) --- .../src/commands/index.ts | 6 + .../vscode-ide-companion/src/webview/App.tsx | 2 +- .../webview/components/layout/FileLink.tsx | 143 +-------- .../messages/Assistant/AssistantMessage.css | 52 --- .../messages/Assistant/AssistantMessage.tsx | 87 ----- .../src/webview/components/messages/index.tsx | 8 +- .../messages/toolcalls/Edit/EditToolCall.tsx | 10 +- .../messages/toolcalls/GenericToolCall.tsx | 7 +- .../messages/toolcalls/Read/ReadToolCall.tsx | 10 +- .../toolcalls/Search/SearchToolCall.tsx | 6 +- .../toolcalls/Shell/ShellToolCall.tsx | 15 +- .../toolcalls/Think/ThinkToolCall.tsx | 6 +- .../UpdatedPlan/UpdatedPlanToolCall.tsx | 11 +- .../toolcalls/Write/WriteToolCall.tsx | 8 +- .../components/messages/toolcalls/index.tsx | 6 +- .../toolcalls/shared/LayoutComponents.css | 205 ------------ .../toolcalls/shared/LayoutComponents.tsx | 177 +---------- .../messages/toolcalls/shared/copyUtils.tsx | 68 +--- .../messages/toolcalls/shared/types.ts | 74 +---- .../context/VSCodePlatformProvider.tsx | 119 +++++++ .../src/webview/index.tsx | 7 +- .../src/webview/styles/styles.css | 2 +- .../src/webview/utils/utils.ts | 299 +----------------- 23 files changed, 225 insertions(+), 1103 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css create mode 100644 packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index e75e1bd10..5f487c6fb 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + import * as vscode from 'vscode'; import type { DiffManager } from '../diff-manager.js'; import type { WebViewProvider } from '../webview/WebViewProvider.js'; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index cf3d34337..4548f128d 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -33,8 +33,8 @@ import { Onboarding } from './components/layout/Onboarding.js'; import { type CompletionItem } from '../types/completionItemTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; import { ChatHeader } from './components/layout/ChatHeader.js'; -import { AssistantMessage } from './components/messages/index.js'; import { + AssistantMessage, UserMessage, ThinkingMessage, WaitingMessage, diff --git a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx index 356ffaf42..e46ba943c 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx @@ -4,145 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 * * FileLink component - Clickable file path links - * Supports clicking to open files and jump to specified line and column numbers + * Now re-exports from @qwen-code/webui for backward compatibility */ -import type React from 'react'; -import { useVSCode } from '../../hooks/useVSCode.js'; -// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes - -/** - * Props for FileLink - */ -interface FileLinkProps { - /** File path */ - path: string; - /** Optional line number (starting from 1) */ - line?: number | null; - /** Optional column number (starting from 1) */ - column?: number | null; - /** Whether to show full path, default false (show filename only) */ - showFullPath?: boolean; - /** Optional custom class name */ - className?: string; - /** Whether to disable click behavior (use when parent element handles clicks) */ - disableClick?: boolean; -} - -/** - * Extract filename from full path - * @param path File path - * @returns Filename - */ -function getFileName(path: string): string { - const segments = path.split(/[/\\]/); - return segments[segments.length - 1] || path; -} - -/** - * FileLink component - Clickable file link - * - * Features: - * - Click to open file - * - Support line and column number navigation - * - Hover to show full path - * - Optional display mode (full path vs filename only) - * - * @example - * ```tsx - * - * - * ``` - */ -export const FileLink: React.FC = ({ - path, - line, - column, - showFullPath = false, - className = '', - disableClick = false, -}) => { - const vscode = useVSCode(); - - /** - * Handle click event - Send message to VSCode to open file - */ - const handleClick = (e: React.MouseEvent) => { - // Always prevent default behavior (prevent tag # navigation) - e.preventDefault(); - - if (disableClick) { - // If click is disabled, return directly without stopping propagation - // This allows parent elements to handle click events - return; - } - - // If click is enabled, stop event propagation - e.stopPropagation(); - - // Build full path including line and column numbers - let fullPath = path; - if (line !== null && line !== undefined) { - fullPath += `:${line}`; - if (column !== null && column !== undefined) { - fullPath += `:${column}`; - } - } - - console.log('[FileLink] Opening file:', fullPath); - - vscode.postMessage({ - type: 'openFile', - data: { path: fullPath }, - }); - }; - - // Build display text - const displayPath = showFullPath ? path : getFileName(path); - - // Build hover tooltip (always show full path) - const fullDisplayText = - line !== null && line !== undefined - ? column !== null && column !== undefined - ? `${path}:${line}:${column}` - : `${path}:${line}` - : path; - - return ( - - {displayPath} - {line !== null && line !== undefined && ( - - :{line} - {column !== null && column !== undefined && <>:{column}} - - )} - - ); -}; +export { FileLink } from '@qwen-code/webui'; +export type { FileLinkProps } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css deleted file mode 100644 index 676758166..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.css +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * AssistantMessage Component Styles - * Pseudo-elements (::before) for bullet points and (::after) for timeline connectors - */ - -/* Bullet point indicator using ::before pseudo-element */ -.assistant-message-container.assistant-message-default::before, -.assistant-message-container.assistant-message-success::before, -.assistant-message-container.assistant-message-error::before, -.assistant-message-container.assistant-message-warning::before, -.assistant-message-container.assistant-message-loading::before { - content: '\25cf'; - position: absolute; - left: 8px; - padding-top: 2px; - font-size: 10px; - z-index: 1; -} - -/* Default state - secondary foreground color */ -.assistant-message-container.assistant-message-default::before { - color: var(--app-secondary-foreground); -} - -/* Success state - green bullet (maps to .ge) */ -.assistant-message-container.assistant-message-success::before { - color: #74c991; -} - -/* Error state - red bullet (maps to .be) */ -.assistant-message-container.assistant-message-error::before { - color: #c74e39; -} - -/* Warning state - yellow/orange bullet (maps to .ue) */ -.assistant-message-container.assistant-message-warning::before { - color: #e1c08d; -} - -/* Loading state - static bullet (maps to .he) */ -.assistant-message-container.assistant-message-loading::before { - color: var(--app-secondary-foreground); - background-color: var(--app-secondary-background); -} - -.assistant-message-container.assistant-message-loading::after { - display: none; -} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx b/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx deleted file mode 100644 index e31c7d793..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/Assistant/AssistantMessage.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { MessageContent } from '@qwen-code/webui'; -import './AssistantMessage.css'; - -interface AssistantMessageProps { - content: string; - timestamp: number; - onFileClick?: (path: string) => void; - status?: 'default' | 'success' | 'error' | 'warning' | 'loading'; - // When true, render without the left status bullet (no ::before dot) - hideStatusIcon?: boolean; -} - -/** - * AssistantMessage component - renders AI responses with Qwen Code styling - * Supports different states: default, success, error, warning, loading - */ -export const AssistantMessage: React.FC = ({ - content, - timestamp: _timestamp, - onFileClick, - status = 'default', - hideStatusIcon = false, -}) => { - // Empty content not rendered directly, avoid poor visual experience from only showing ::before dot - if (!content || content.trim().length === 0) { - return null; - } - - // Map status to CSS class (only for ::before pseudo-element) - const getStatusClass = () => { - if (hideStatusIcon) { - return ''; - } - switch (status) { - case 'success': - return 'assistant-message-success'; - case 'error': - return 'assistant-message-error'; - case 'warning': - return 'assistant-message-warning'; - case 'loading': - return 'assistant-message-loading'; - default: - return 'assistant-message-default'; - } - }; - - return ( -
- -
- -
-
-
- ); -}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx index 18eda9a1b..a6eb82f61 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx @@ -4,5 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { AssistantMessage } from './Assistant/AssistantMessage.js'; -// UserMessage, ThinkingMessage, WaitingMessage and InterruptedMessage are now imported from @qwen-code/webui +// All message components are now imported from @qwen-code/webui: +// - AssistantMessage +// - UserMessage +// - ThinkingMessage +// - WaitingMessage +// - InterruptedMessage diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx index 348a2d142..3d677cb1e 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx @@ -7,13 +7,15 @@ */ import { useMemo } from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; import { + FileLink, groupContent, mapToolStatusToContainerStatus, -} from '../../../../utils/utils.js'; -import { FileLink } from '../../../layout/FileLink.js'; -import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; +} from '@qwen-code/webui'; +import type { + BaseToolCallProps, + ToolCallContainerProps, +} from '@qwen-code/webui'; export const ToolCallContainer: React.FC = ({ label, diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx index 72cdeac9d..cedd344b8 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx @@ -7,14 +7,15 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from './shared/types.js'; import { ToolCallContainer, ToolCallCard, ToolCallRow, LocationsList, -} from './shared/LayoutComponents.js'; -import { safeTitle, groupContent } from '../../../utils/utils.js'; + safeTitle, + groupContent, +} from '@qwen-code/webui'; +import type { BaseToolCallProps } from '@qwen-code/webui'; /** * Generic tool call component that can display any tool call type diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx index de6147765..93a8e3626 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx @@ -8,15 +8,17 @@ import type React from 'react'; import { useCallback, useEffect, useMemo } from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; import { + FileLink, groupContent, mapToolStatusToContainerStatus, -} from '../../../../utils/utils.js'; -import { FileLink } from '../../../layout/FileLink.js'; +} from '@qwen-code/webui'; +import type { + BaseToolCallProps, + ToolCallContainerProps, +} from '@qwen-code/webui'; import { useVSCode } from '../../../../hooks/useVSCode.js'; import { handleOpenDiff } from '../../../../utils/diffUtils.js'; -import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; export const ToolCallContainer: React.FC = ({ label, diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx index 406124f33..30a492e0c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx @@ -7,13 +7,13 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; -import { FileLink } from '../../../layout/FileLink.js'; import { + FileLink, safeTitle, groupContent, mapToolStatusToContainerStatus, -} from '../../../../utils/utils.js'; +} from '@qwen-code/webui'; +import type { BaseToolCallProps } from '@qwen-code/webui'; /** * Specialized component for Search tool calls diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx index f66e8a2c3..fc595555b 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx @@ -7,13 +7,18 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; -import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; -import { ToolCallContainer as SharedToolCallContainer } from '../shared/LayoutComponents.js'; -import { safeTitle, groupContent } from '../../../../utils/utils.js'; +import { + ToolCallContainer as SharedToolCallContainer, + CopyButton, + safeTitle, + groupContent, +} from '@qwen-code/webui'; +import type { + BaseToolCallProps, + ToolCallContainerProps, +} from '@qwen-code/webui'; import { useVSCode } from '../../../../hooks/useVSCode.js'; import { createAndOpenTempFile } from '../../../../utils/diffUtils.js'; -import { CopyButton } from '../shared/copyUtils.js'; import './ShellToolCall.css'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx index 5b456b67c..b7801e057 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx @@ -7,13 +7,13 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; import { ToolCallContainer, ToolCallCard, ToolCallRow, -} from '../shared/LayoutComponents.js'; -import { groupContent } from '../../../../utils/utils.js'; + groupContent, +} from '@qwen-code/webui'; +import type { BaseToolCallProps } from '@qwen-code/webui'; /** * Specialized component for Think tool calls diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx index a0d01a39e..fec2831e9 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx @@ -7,9 +7,12 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; -import type { ToolCallContainerProps } from '../shared/LayoutComponents.js'; -import { groupContent, safeTitle } from '../../../../utils/utils.js'; +import { groupContent, safeTitle } from '@qwen-code/webui'; +import type { + BaseToolCallProps, + ToolCallContainerProps, + ToolCallStatus, +} from '@qwen-code/webui'; import { CheckboxDisplay } from './CheckboxDisplay.js'; import type { PlanEntry } from '../../../../../types/chatTypes.js'; @@ -45,7 +48,7 @@ export const ToolCallContainer: React.FC = ({ ); const mapToolStatusToBullet = ( - status: import('../shared/types.js').ToolCallStatus, + status: ToolCallStatus, ): 'success' | 'error' | 'warning' | 'loading' | 'default' => { switch (status) { case 'completed': diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx index 3c4a868b1..ead5b234d 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx @@ -7,13 +7,13 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from '../shared/types.js'; -import { ToolCallContainer } from '../shared/LayoutComponents.js'; import { + ToolCallContainer, + FileLink, groupContent, mapToolStatusToContainerStatus, -} from '../../../../utils/utils.js'; -import { FileLink } from '../../../layout/FileLink.js'; +} from '@qwen-code/webui'; +import type { BaseToolCallProps } from '@qwen-code/webui'; /** * Specialized component for Write tool calls diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index 1ecc15afe..e47eccd5f 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -7,8 +7,8 @@ */ import type React from 'react'; -import type { BaseToolCallProps } from './shared/types.js'; -import { shouldShowToolCall } from '../../../utils/utils.js'; +import { shouldShowToolCall } from '@qwen-code/webui'; +import type { BaseToolCallProps } from '@qwen-code/webui'; import { GenericToolCall } from './GenericToolCall.js'; import { ReadToolCall } from './Read/ReadToolCall.js'; import { WriteToolCall } from './Write/WriteToolCall.js'; @@ -84,4 +84,4 @@ export const ToolCallRouter: React.FC< }; // Re-export types for convenience -export type { BaseToolCallProps, ToolCallData } from './shared/types.js'; +export type { BaseToolCallProps, ToolCallData } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css deleted file mode 100644 index e5b2cce9b..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.css +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * LayoutComponents.css - Tool call layout styles with timeline support - */ - -/* ToolCallContainer with timeline support */ -.toolcall-container { - position: relative; - padding-left: 30px; - padding-top: 8px; - padding-bottom: 8px; - user-select: text; - align-items: flex-start; -} - -/* Default timeline connector line */ -.toolcall-container::after { - content: ''; - position: absolute; - left: 12px; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--app-primary-border-color); -} - -/* First item: connector starts from status point position */ -.toolcall-container:first-child::after { - top: 24px; -} - -/* Last item: connector shows only upper part */ -.toolcall-container:last-child::after { - height: calc(100% - 24px); - top: 0; - bottom: auto; -} - -/* Status-specific styles using ::before pseudo-element for bullet points */ -.toolcall-container.toolcall-status-default::before { - content: '\25cf'; - position: absolute; - left: 8px; - padding-top: 2px; - font-size: 10px; - color: var(--app-secondary-foreground); - z-index: 1; -} - -.toolcall-container.toolcall-status-success::before { - content: '\25cf'; - position: absolute; - left: 8px; - font-size: 10px; - color: #74c991; - z-index: 1; -} - -.toolcall-container.toolcall-status-error::before { - content: '\25cf'; - position: absolute; - left: 8px; - font-size: 10px; - color: #c74e39; - z-index: 1; -} - -.toolcall-container.toolcall-status-warning::before { - content: '\25cf'; - position: absolute; - left: 8px; - padding-top: 2px; - font-size: 10px; - color: #e1c08d; - z-index: 1; -} - -.toolcall-container.toolcall-status-loading::before { - content: '\25cf'; - position: absolute; - left: 8px; - padding-top: 2px; - font-size: 10px; - color: var(--app-secondary-foreground); - background-color: var(--app-secondary-background); - animation: toolcallPulse 1s linear infinite; - z-index: 1; -} - -/* Loading animation */ -@keyframes toolcallPulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -/* Content wrapper */ -.toolcall-content-wrapper { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - max-width: 100%; -} - -/* Legacy card styles */ -.toolcall-card { - grid-template-columns: auto 1fr; - gap: var(--spacing-medium); - background: var(--app-input-background); - border: 1px solid var(--app-input-border); - border-radius: var(--border-radius-medium); - padding: var(--spacing-large); - margin: var(--spacing-medium) 0; - align-items: start; - animation: fadeIn 0.2s ease-in; -} - -/* Legacy row styles */ -.toolcall-row { - grid-template-columns: 80px 1fr; - gap: var(--spacing-medium); - min-width: 0; -} - -.toolcall-row-label { - font-size: var(--font-size-xs); - color: var(--app-secondary-foreground); - font-weight: 500; - padding-top: 2px; -} - -.toolcall-row-content { - color: var(--app-primary-foreground); - min-width: 0; - word-break: break-word; -} - -/* Locations list */ -.toolcall-locations-list { - display: flex; - flex-direction: column; - gap: 4px; - max-width: 100%; -} - -/* ToolCall header with loading indicator */ -.toolcall-header { - position: relative; -} - -.toolcall-header::before { - content: '\25cf'; - position: absolute; - left: -22px; - top: 50%; - transform: translateY(-50%); - font-size: 10px; - line-height: 1; - z-index: 1; - color: #e1c08d; - animation: toolcallHeaderPulse 1.5s ease-in-out infinite; -} - -/* Loading animation for toolcall header */ -@keyframes toolcallHeaderPulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -/* In-progress toolcall specific styles */ -.in-progress-toolcall .toolcall-content-wrapper { - display: flex; - flex-direction: column; - gap: 1; - min-width: 0; - max-width: 100%; -} - -.in-progress-toolcall .toolcall-header { - display: flex; - align-items: center; - gap: 2; - position: relative; - min-width: 0; -} - -.in-progress-toolcall .toolcall-content-text { - word-break: break-word; - white-space: pre-wrap; - width: 100%; -} diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx index 89a0b14cc..48003815c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx @@ -4,171 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 * * Shared layout components for tool call UI + * Now re-exports from @qwen-code/webui for backward compatibility */ -import type React from 'react'; -import { FileLink } from '../../../layout/FileLink.js'; -import './LayoutComponents.css'; - -/** - * Props for ToolCallContainer - */ -export interface ToolCallContainerProps { - /** Operation label (e.g., "Read", "Write", "Search") */ - label: string; - /** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */ - status?: 'success' | 'error' | 'warning' | 'loading' | 'default'; - /** Main content to display */ - children: React.ReactNode; - /** Tool call ID for debugging */ - toolCallId?: string; - /** Optional trailing content rendered next to label (e.g., clickable filename) */ - labelSuffix?: React.ReactNode; - /** Optional custom class name */ - className?: string; -} - -export const ToolCallContainer: React.FC = ({ - label, - status = 'success', - children, - toolCallId: _toolCallId, - labelSuffix, - className: _className, -}) => ( -
-
-
- - {label} - - - {labelSuffix} - -
- {children && ( -
- {children} -
- )} -
-
-); - -interface ToolCallCardProps { - icon: string; - children: React.ReactNode; -} - -/** - * Legacy card wrapper - kept for backward compatibility with complex layouts like diffs - */ -export const ToolCallCard: React.FC = ({ - icon: _icon, - children, -}) => ( -
-
{children}
-
-); - -interface ToolCallRowProps { - label: string; - children: React.ReactNode; -} - -/** - * A single row in the tool call grid (legacy - for complex layouts) - */ -export const ToolCallRow: React.FC = ({ - label, - children, -}) => ( -
-
- {label} -
-
- {children} -
-
-); - -/** - * Props for StatusIndicator - */ -interface StatusIndicatorProps { - status: 'pending' | 'in_progress' | 'completed' | 'failed'; - text: string; -} - -/** - * Get status color class - */ -const getStatusColorClass = ( - status: 'pending' | 'in_progress' | 'completed' | 'failed', -): string => { - switch (status) { - case 'pending': - return 'bg-[#ffc107]'; - case 'in_progress': - return 'bg-[#2196f3]'; - case 'completed': - return 'bg-[#4caf50]'; - case 'failed': - return 'bg-[#f44336]'; - default: - return 'bg-gray-500'; - } -}; - -/** - * Status indicator with colored dot - */ -export const StatusIndicator: React.FC = ({ - status, - text, -}) => ( -
- - {text} -
-); - -interface CodeBlockProps { - children: string; -} - -/** - * Code block for displaying formatted code or output - */ -export const CodeBlock: React.FC = ({ children }) => ( -
-    {children}
-  
-); - -/** - * Props for LocationsList - */ -interface LocationsListProps { - locations: Array<{ - path: string; - line?: number | null; - }>; -} - -/** - * List of file locations with clickable links - */ -export const LocationsList: React.FC = ({ locations }) => ( -
- {locations.map((loc, idx) => ( - - ))} -
-); +// Re-export all layout components from webui +export { + ToolCallContainer, + ToolCallCard, + ToolCallRow, + StatusIndicator, + CodeBlock, + LocationsList, +} from '@qwen-code/webui'; +export type { ToolCallContainerProps } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx index 1a977cbbe..600a3ca6c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx @@ -4,71 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 * * Shared copy utilities for toolcall components + * Now re-exports from @qwen-code/webui for backward compatibility */ -import type React from 'react'; -import { useState } from 'react'; - -/** - * Handle copy to clipboard - */ -export const handleCopyToClipboard = async ( - text: string, - event: React.MouseEvent, -): Promise => { - event.stopPropagation(); // Prevent triggering the row click - try { - await navigator.clipboard.writeText(text); - } catch (err) { - console.error('Failed to copy text:', err); - } -}; - -/** - * Copy button component props - */ -interface CopyButtonProps { - text: string; -} - -/** - * Shared copy button component with Tailwind styles - * Note: Parent element should have 'group' class for hover effect - */ -export const CopyButton: React.FC = ({ text }) => { - const [showTooltip, setShowTooltip] = useState(false); - - return ( - - ); -}; +export { handleCopyToClipboard, CopyButton } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts index 0fccb1865..36b10b135 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts @@ -4,69 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 * * Shared types for tool call components + * Now re-exports from @qwen-code/webui for backward compatibility */ -/** - * Tool call content types - */ -export interface ToolCallContent { - type: 'content' | 'diff'; - // For content type - content?: { - type: string; - text?: string; - error?: unknown; - [key: string]: unknown; - }; - // For diff type - path?: string; - oldText?: string | null; - newText?: string; -} - -/** - * Tool call location type - */ -export interface ToolCallLocation { - path: string; - line?: number | null; -} - -/** - * Tool call status type - */ -export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; - -/** - * Base tool call data interface - */ -export interface ToolCallData { - toolCallId: string; - kind: string; - title: string | object; - status: ToolCallStatus; - rawInput?: string | object; - content?: ToolCallContent[]; - locations?: ToolCallLocation[]; - timestamp?: number; // Add a timestamp field for message sorting -} - -/** - * Base props for all tool call components - */ -export interface BaseToolCallProps { - toolCall: ToolCallData; - // Optional timeline flags for rendering connector line cropping - isFirst?: boolean; - isLast?: boolean; -} - -/** - * Grouped content structure for rendering - */ -export interface GroupedContent { - textOutputs: string[]; - errors: string[]; - diffs: ToolCallContent[]; - otherData: unknown[]; -} +export type { + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolCallData, + BaseToolCallProps, + GroupedContent, + ContainerStatus, +} from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx new file mode 100644 index 000000000..84da89b02 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * VSCode Platform Provider - Adapts VSCode API to PlatformContext + * This allows webui components to work with VSCode's messaging system + */ + +import type React from 'react'; +import { useMemo, useCallback, useEffect, useRef } from 'react'; +import { PlatformProvider } from '@qwen-code/webui'; +import type { PlatformContextValue } from '@qwen-code/webui'; +import { useVSCode } from '../hooks/useVSCode.js'; + +/** + * Props for VSCodePlatformProvider + */ +interface VSCodePlatformProviderProps { + children: React.ReactNode; +} + +/** + * VSCodePlatformProvider - Provides platform context for VSCode extension + * + * This component bridges the VSCode API with the platform-agnostic webui components. + * It wraps children with PlatformProvider and provides VSCode-specific implementations. + */ +export const VSCodePlatformProvider: React.FC = ({ + children, +}) => { + const vscode = useVSCode(); + const messageHandlersRef = useRef void>>(new Set()); + + // Set up message listener + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + messageHandlersRef.current.forEach((handler) => { + handler(event.data); + }); + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + // Open file handler + const openFile = useCallback( + (path: string) => { + vscode.postMessage({ + type: 'openFile', + data: { path }, + }); + }, + [vscode], + ); + + // Attach file handler + const attachFile = useCallback(() => { + vscode.postMessage({ + type: 'attachFile', + data: {}, + }); + }, [vscode]); + + // Login handler + const login = useCallback(() => { + vscode.postMessage({ + type: 'login', + data: {}, + }); + }, [vscode]); + + // Copy to clipboard handler + const copyToClipboard = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + }, []); + + // Subscribe to messages + const onMessage = useCallback((handler: (message: unknown) => void) => { + messageHandlersRef.current.add(handler); + return () => { + messageHandlersRef.current.delete(handler); + }; + }, []); + + // Build platform context value + const platformValue = useMemo( + () => ({ + platform: 'vscode', + postMessage: vscode.postMessage, + onMessage, + openFile, + attachFile, + login, + copyToClipboard, + features: { + canOpenFile: true, + canAttachFile: true, + canLogin: true, + canCopy: true, + }, + }), + [ + vscode.postMessage, + onMessage, + openFile, + attachFile, + login, + copyToClipboard, + ], + ); + + return {children}; +}; diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx index 547dc3fc4..aa2681bec 100644 --- a/packages/vscode-ide-companion/src/webview/index.tsx +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -6,6 +6,7 @@ import ReactDOM from 'react-dom/client'; import { App } from './App.js'; +import { VSCodePlatformProvider } from './context/VSCodePlatformProvider.js'; // eslint-disable-next-line import/no-internal-modules import './styles/tailwind.css'; @@ -17,5 +18,9 @@ import './styles/styles.css'; const container = document.getElementById('root'); if (container) { const root = ReactDOM.createRoot(container); - root.render(); + root.render( + + + , + ); } diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css index 956912cbc..ed99ccded 100644 --- a/packages/vscode-ide-companion/src/webview/styles/styles.css +++ b/packages/vscode-ide-companion/src/webview/styles/styles.css @@ -6,7 +6,7 @@ /* Import component styles */ @import './timeline.css'; -@import '../components/messages/MarkdownRenderer/MarkdownRenderer.css'; +/* MarkdownRenderer styles are now bundled with @qwen-code/webui */ /* =========================== CSS Variables diff --git a/packages/vscode-ide-companion/src/webview/utils/utils.ts b/packages/vscode-ide-companion/src/webview/utils/utils.ts index c98240e73..793f89f07 100644 --- a/packages/vscode-ide-companion/src/webview/utils/utils.ts +++ b/packages/vscode-ide-companion/src/webview/utils/utils.ts @@ -4,294 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 * * Shared utility functions for tool call components + * Now re-exports from @qwen-code/webui for backward compatibility */ -import type { +export { + extractCommandOutput, + formatValue, + safeTitle, + shouldShowToolCall, + groupContent, + hasToolCallOutput, + mapToolStatusToContainerStatus, +} from '@qwen-code/webui'; + +// Re-export types for backward compatibility +export type { ToolCallContent, GroupedContent, ToolCallData, ToolCallStatus, -} from '../components/messages/toolcalls/shared/types.js'; - -/** - * Extract output from command execution result text - * Handles both JSON format and structured text format - * - * Example structured text: - * ``` - * Command: lsof -i :5173 - * Directory: (root) - * Output: COMMAND PID USER... - * Error: (none) - * Exit Code: 0 - * ``` - */ -export const extractCommandOutput = (text: string): string => { - // First try: Parse as JSON and extract output field - try { - const parsed = JSON.parse(text) as { output?: unknown; Output?: unknown }; - const output = parsed.output ?? parsed.Output; - if (output !== undefined && output !== null) { - return typeof output === 'string' - ? output - : JSON.stringify(output, null, 2); - } - } catch (_error) { - // Not JSON, continue with text parsing - } - - // Second try: Extract from structured text format - // Look for "Output: " followed by content until "Error: " or end of string - // Only match if there's actual content after "Output:" (not just whitespace) - // Avoid treating the next line (e.g. "Error: ...") as output when the Output line is empty. - // Intentionally do not allow `\s*` here since it would consume newlines. - const outputMatch = text.match(/Output:[ \t]*(.+?)(?=\nError:|$)/i); - if (outputMatch && outputMatch[1]) { - const output = outputMatch[1].trim(); - // Only return if there's meaningful content (not just "(none)" or empty) - if (output && output !== '(none)' && output.length > 0) { - return output; - } - } - - // Third try: Check if text starts with structured format (Command:, Directory:, etc.) - // If so, try to extract everything between first line and "Error:" or "Exit Code:" - if (text.match(/^Command:/)) { - const lines = text.split('\n'); - const outputLines: string[] = []; - let inOutput = false; - - for (const line of lines) { - // Stop at metadata lines - if ( - line.startsWith('Error:') || - line.startsWith('Exit Code:') || - line.startsWith('Signal:') || - line.startsWith('Background PIDs:') || - line.startsWith('Process Group PGID:') - ) { - break; - } - // Skip header lines - if (line.startsWith('Command:') || line.startsWith('Directory:')) { - continue; - } - // Start collecting after "Output:" label - if (line.startsWith('Output:')) { - inOutput = true; - const content = line.substring('Output:'.length).trim(); - if (content && content !== '(none)') { - outputLines.push(content); - } - continue; - } - // Collect output lines - if ( - inOutput || - (!line.startsWith('Command:') && !line.startsWith('Directory:')) - ) { - outputLines.push(line); - } - } - - if (outputLines.length > 0) { - const result = outputLines.join('\n').trim(); - if (result && result !== '(none)') { - return result; - } - } - } - - // Fallback: Return original text - return text; -}; - -/** - * Format any value to a string for display - */ -export const formatValue = (value: unknown): string => { - if (value === null || value === undefined) { - return ''; - } - if (typeof value === 'string') { - // Extract command output from structured text - return extractCommandOutput(value); - } - // Handle Error objects specially - if (value instanceof Error) { - return value.message || value.toString(); - } - // Handle error-like objects with message property - if (typeof value === 'object' && value !== null && 'message' in value) { - const errorObj = value as { message?: string; stack?: string }; - return errorObj.message || String(value); - } - if (typeof value === 'object') { - try { - return JSON.stringify(value, null, 2); - } catch (_e) { - return String(value); - } - } - return String(value); -}; - -/** - * Safely convert title to string, handling object types - * Returns empty string if no meaningful title - */ -export const safeTitle = (title: unknown): string => { - if (typeof title === 'string' && title.trim()) { - return title; - } - if (title && typeof title === 'object') { - return JSON.stringify(title); - } - return ''; -}; - -/** - * Check if a tool call should be displayed - * Hides internal tool calls - */ -export const shouldShowToolCall = (kind: string): boolean => - !kind.includes('internal'); - -/** - * Check if a tool call has actual output to display - * Returns false for tool calls that completed successfully but have no visible output - */ -export const hasToolCallOutput = (toolCall: ToolCallData): boolean => { - // Always show failed tool calls (even without content) - if (toolCall.status === 'failed') { - return true; - } - - // Always show execute/bash/command tool calls (they show the command in title) - const kind = toolCall.kind.toLowerCase(); - if (kind === 'execute' || kind === 'bash' || kind === 'command') { - // But only if they have a title - if ( - toolCall.title && - typeof toolCall.title === 'string' && - toolCall.title.trim() - ) { - return true; - } - } - - // Show if there are locations (file paths) - if (toolCall.locations && toolCall.locations.length > 0) { - return true; - } - - // Show if there is content - if (toolCall.content && toolCall.content.length > 0) { - const grouped = groupContent(toolCall.content); - // Has any meaningful content? - if ( - grouped.textOutputs.length > 0 || - grouped.errors.length > 0 || - grouped.diffs.length > 0 || - grouped.otherData.length > 0 - ) { - return true; - } - } - - // Show if there's a meaningful title for generic tool calls - if ( - toolCall.title && - typeof toolCall.title === 'string' && - toolCall.title.trim() - ) { - return true; - } - - // No output, don't show - return false; -}; - -/** - * Group tool call content by type to avoid duplicate labels - */ -export const groupContent = (content?: ToolCallContent[]): GroupedContent => { - const textOutputs: string[] = []; - const errors: string[] = []; - const diffs: ToolCallContent[] = []; - const otherData: unknown[] = []; - - content?.forEach((item) => { - if (item.type === 'diff') { - diffs.push(item); - } else if (item.content) { - const contentObj = item.content; - - // Handle error content - if (contentObj.type === 'error' || 'error' in contentObj) { - // Try to extract meaningful error message - let errorMsg = ''; - - // Check if error is a string - if (typeof contentObj.error === 'string') { - errorMsg = contentObj.error; - } - // Check if error has a message property - else if ( - contentObj.error && - typeof contentObj.error === 'object' && - 'message' in contentObj.error - ) { - errorMsg = (contentObj.error as { message: string }).message; - } - // Try text field - else if (contentObj.text) { - errorMsg = formatValue(contentObj.text); - } - // Format the error object itself - else if (contentObj.error) { - errorMsg = formatValue(contentObj.error); - } - // Fallback - else { - errorMsg = 'An error occurred'; - } - - errors.push(errorMsg); - } - // Handle text content - else if (contentObj.text) { - textOutputs.push(formatValue(contentObj.text)); - } - // Handle other content - else { - otherData.push(contentObj); - } - } - }); - - return { textOutputs, errors, diffs, otherData }; -}; - -/** - * Map a tool call status to a ToolCallContainer status (bullet color) - * - pending/in_progress -> loading - * - completed -> success - * - failed -> error - * - default fallback - */ -export const mapToolStatusToContainerStatus = ( - status: ToolCallStatus, -): 'success' | 'error' | 'warning' | 'loading' | 'default' => { - switch (status) { - case 'pending': - case 'in_progress': - return 'loading'; - case 'failed': - return 'error'; - case 'completed': - return 'success'; - default: - return 'default'; - } -}; +} from '@qwen-code/webui'; From e81cdbbcb189d0abd0c816a5c35cff9659ca3499 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 16 Jan 2026 00:59:29 +0800 Subject: [PATCH 013/113] fix(webui): improve robustness and accessibility based on code review - Fix groupContent error detection: only treat as error when contentObj.error is truthy - Fix safeTitle: add try/catch for circular reference handling - Update CopyButton to use PlatformContext with navigator.clipboard fallback - Improve FileLink accessibility: use button element with proper keyboard support - Add aria-disabled and disabled attributes when file opening is unavailable --- .../webui/src/components/layout/FileLink.tsx | 74 +++++++++++++------ .../components/toolcalls/shared/copyUtils.tsx | 39 ++++++++-- .../src/components/toolcalls/shared/utils.ts | 16 +++- 3 files changed, 96 insertions(+), 33 deletions(-) diff --git a/packages/webui/src/components/layout/FileLink.tsx b/packages/webui/src/components/layout/FileLink.tsx index 1f8a77a95..9c8945dd5 100644 --- a/packages/webui/src/components/layout/FileLink.tsx +++ b/packages/webui/src/components/layout/FileLink.tsx @@ -69,6 +69,7 @@ function buildFullPath( * - Support line and column number navigation * - Hover to show full path * - Optional display mode (full path vs filename only) + * - Full keyboard accessibility (Enter and Space keys) * * @example * ```tsx @@ -86,22 +87,18 @@ export const FileLink: React.FC = ({ }) => { const platform = usePlatform(); - /** - * Handle click event - Open file using platform-specific method - */ - const handleClick = (e: React.MouseEvent) => { - // Always prevent default behavior (prevent tag # navigation) - e.preventDefault(); + // Check if file opening is available + const canOpenFile = platform.features?.canOpenFile !== false; + const isDisabled = disableClick || !canOpenFile; - if (disableClick) { - // If click is disabled, return directly without stopping propagation - // This allows parent elements to handle click events + /** + * Open file using platform-specific method + */ + const openFile = () => { + if (isDisabled) { return; } - // If click is enabled, stop event propagation - e.stopPropagation(); - // Build full path including line and column numbers const fullPath = buildFullPath(path, line, column); @@ -116,6 +113,32 @@ export const FileLink: React.FC = ({ } }; + /** + * Handle click event + */ + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (!isDisabled) { + e.stopPropagation(); + openFile(); + } + }; + + /** + * Handle keyboard event - Support Space key for button behavior + */ + const handleKeyDown = (e: React.KeyboardEvent) => { + if (isDisabled) { + return; + } + // Space key triggers button action (Enter is handled by default for buttons) + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + openFile(); + } + }; + // Build display text const displayPath = showFullPath ? path : getFileName(path); @@ -123,31 +146,36 @@ export const FileLink: React.FC = ({ const fullDisplayText = buildFullPath(path, line, column); return ( - {displayPath} {line !== null && line !== undefined && ( @@ -156,6 +184,6 @@ export const FileLink: React.FC = ({ {column !== null && column !== undefined && <>:{column}} )} - + ); }; diff --git a/packages/webui/src/components/toolcalls/shared/copyUtils.tsx b/packages/webui/src/components/toolcalls/shared/copyUtils.tsx index 9ead4cee0..e71169aa1 100644 --- a/packages/webui/src/components/toolcalls/shared/copyUtils.tsx +++ b/packages/webui/src/components/toolcalls/shared/copyUtils.tsx @@ -7,20 +7,28 @@ */ import type React from 'react'; -import { useState } from 'react'; +import { useState, useCallback } from 'react'; +import { usePlatform } from '../../../context/PlatformContext.js'; /** - * Handle copy to clipboard + * Handle copy to clipboard using platform-specific API with fallback * @param text Text to copy * @param event Mouse event to stop propagation + * @param platformCopy Optional platform-specific copy function */ export const handleCopyToClipboard = async ( text: string, event: React.MouseEvent, + platformCopy?: (text: string) => Promise, ): Promise => { event.stopPropagation(); // Prevent triggering the row click try { - await navigator.clipboard.writeText(text); + // Use platform-specific copy if available, otherwise fall back to navigator.clipboard + if (platformCopy) { + await platformCopy(text); + } else { + await navigator.clipboard.writeText(text); + } } catch (err) { console.error('Failed to copy text:', err); } @@ -35,21 +43,36 @@ interface CopyButtonProps { /** * CopyButton - Shared copy button component with Tailwind styles + * Uses PlatformContext for platform-specific clipboard access with fallback * Note: Parent element should have 'group' class for hover effect */ export const CopyButton: React.FC = ({ text }) => { const [showTooltip, setShowTooltip] = useState(false); + const platform = usePlatform(); + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + await handleCopyToClipboard(text, e, platform.copyToClipboard); + setShowTooltip(true); + setTimeout(() => setShowTooltip(false), 1000); + }, + [text, platform.copyToClipboard], + ); + + // Check if copy feature is available + const canCopy = platform.features?.canCopy !== false; + + if (!canCopy) { + return null; + } return ( - -
- - -
-); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 6fd6ef61d..3c04525c2 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -2,307 +2,54 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * InputForm adapter for VSCode - wraps webui InputForm with local type handling + * This allows local ApprovalModeValue to work with webui's EditModeInfo */ import type React from 'react'; -import { - EditPencilIcon, - AutoEditIcon, - PlanModeIcon, - CodeBracketsIcon, - HideContextIcon, - // ThinkingIcon, // Temporarily disabled - SlashCommandIcon, - LinkIcon, - ArrowUpIcon, - StopIcon, +import { InputForm as BaseInputForm, getEditModeIcon } from '@qwen-code/webui'; +import type { + InputFormProps as BaseInputFormProps, + EditModeInfo, } from '@qwen-code/webui'; -import { CompletionMenu } from '../layout/CompletionMenu.js'; -import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; -import { ContextIndicator } from './ContextIndicator.js'; -interface InputFormProps { - inputText: string; - // Note: RefObject carries nullability in its `current` property, so the - // generic should be `HTMLDivElement` (not `HTMLDivElement | null`). - inputFieldRef: React.RefObject; - isStreaming: boolean; - isWaitingForResponse: boolean; - isComposing: boolean; +// Re-export base types for convenience +export type { EditModeInfo, EditModeIconType } from '@qwen-code/webui'; +export { getEditModeIcon } from '@qwen-code/webui'; + +/** + * Extended props that accept ApprovalModeValue + */ +export interface InputFormProps + extends Omit { + /** Edit mode value (local type) */ editMode: ApprovalModeValue; - thinkingEnabled: boolean; - activeFileName: string | null; - activeSelection: { startLine: number; endLine: number } | null; - // Whether to auto-load the active editor selection/path into context - skipAutoActiveContext: boolean; - contextUsage: { - percentLeft: number; - usedTokens: number; - tokenLimit: number; - } | null; - onInputChange: (text: string) => void; - onCompositionStart: () => void; - onCompositionEnd: () => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onSubmit: (e: React.FormEvent) => void; - onCancel: () => void; - onToggleEditMode: () => void; - onToggleThinking: () => void; - onFocusActiveEditor: () => void; - onToggleSkipAutoActiveContext: () => void; - onShowCommandMenu: () => void; - onAttachContext: () => void; - completionIsOpen: boolean; - completionItems?: CompletionItem[]; - onCompletionSelect?: (item: CompletionItem) => void; - onCompletionClose?: () => void; } -// Get edit mode display info using helper function -const getEditModeInfo = (editMode: ApprovalModeValue) => { +/** + * Convert ApprovalModeValue to EditModeInfo + */ +const getEditModeInfo = (editMode: ApprovalModeValue): EditModeInfo => { const info = getApprovalModeInfoFromString(editMode); - // Map icon types to actual icons - let icon = null; - switch (info.iconType) { - case 'edit': - icon = ; - break; - case 'auto': - icon = ; - break; - case 'plan': - icon = ; - break; - case 'yolo': - icon = ; - break; - default: - icon = null; - break; - } - return { - text: info.label, + label: info.label, title: info.title, - icon, + icon: info.iconType ? getEditModeIcon(info.iconType) : null, }; }; -export const InputForm: React.FC = ({ - inputText, - inputFieldRef, - isStreaming, - isWaitingForResponse, - isComposing, - editMode, - // thinkingEnabled, // Temporarily disabled - activeFileName, - activeSelection, - skipAutoActiveContext, - contextUsage, - onInputChange, - onCompositionStart, - onCompositionEnd, - onKeyDown, - onSubmit, - onCancel, - onToggleEditMode, - // onToggleThinking, // Temporarily disabled - onToggleSkipAutoActiveContext, - onShowCommandMenu, - onAttachContext, - completionIsOpen, - completionItems, - onCompletionSelect, - onCompletionClose, -}) => { +/** + * InputForm with ApprovalModeValue support + * + * This is an adapter that accepts the local ApprovalModeValue type + * and converts it to webui's EditModeInfo format. + */ +export const InputForm: React.FC = ({ editMode, ...rest }) => { const editModeInfo = getEditModeInfo(editMode); - const composerDisabled = isStreaming || isWaitingForResponse; - const handleKeyDown = (e: React.KeyboardEvent) => { - // ESC should cancel the current interaction (stop generation) - if (e.key === 'Escape') { - e.preventDefault(); - onCancel(); - return; - } - // If composing (Chinese IME input), don't process Enter key - if (e.key === 'Enter' && !e.shiftKey && !isComposing) { - // If CompletionMenu is open, let it handle Enter key - if (completionIsOpen) { - return; - } - e.preventDefault(); - onSubmit(e); - } - onKeyDown(e); - }; - - // Selection label like "6 lines selected"; no line numbers - const selectedLinesCount = activeSelection - ? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1) - : 0; - const selectedLinesText = - selectedLinesCount > 0 - ? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected` - : ''; - - return ( -
-
-
- {/* Inner background layer */} -
- - {/* Banner area */} -
- -
- {completionIsOpen && - completionItems && - completionItems.length > 0 && - onCompletionSelect && - onCompletionClose && ( - - )} - -
into contentEditable (so :empty no longer matches) - data-empty={ - inputText.replace(/\u200B/g, '').trim().length === 0 - ? 'true' - : 'false' - } - onInput={(e) => { - const target = e.target as HTMLDivElement; - // Filter out zero-width space that we use to maintain height - const text = target.textContent?.replace(/\u200B/g, '') || ''; - onInputChange(text); - }} - onCompositionStart={onCompositionStart} - onCompositionEnd={onCompositionEnd} - onKeyDown={handleKeyDown} - suppressContentEditableWarning - /> -
- -
- {/* Edit mode button */} - - - {/* Active file indicator */} - {activeFileName && ( - - )} - - {/* Spacer */} -
- - {/* Context usage indicator */} - - - {/* @yiliang114. closed temporarily */} - {/* Thinking button */} - {/* */} - - {/* Command button */} - - - {/* Attach button */} - - - {/* Send/Stop button */} - {isStreaming || isWaitingForResponse ? ( - - ) : ( - - )} -
- -
-
- ); + return ; }; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index e47eccd5f..f557084b5 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -7,16 +7,20 @@ */ import type React from 'react'; -import { shouldShowToolCall } from '@qwen-code/webui'; +import { + shouldShowToolCall, + // Pure UI ToolCall components from webui + GenericToolCall, + ThinkToolCall, + EditToolCall, + WriteToolCall, + SearchToolCall, + UpdatedPlanToolCall, +} from '@qwen-code/webui'; import type { BaseToolCallProps } from '@qwen-code/webui'; -import { GenericToolCall } from './GenericToolCall.js'; +// VSCode-specific components (have platform dependencies) import { ReadToolCall } from './Read/ReadToolCall.js'; -import { WriteToolCall } from './Write/WriteToolCall.js'; -import { EditToolCall } from './Edit/EditToolCall.js'; import { ShellToolCall } from './Shell/ShellToolCall.js'; -import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js'; -import { SearchToolCall } from './Search/SearchToolCall.js'; -import { ThinkToolCall } from './Think/ThinkToolCall.js'; /** * Factory function that returns the appropriate tool call component based on kind diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx index 84da89b02..5a2ed8c6c 100644 --- a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx +++ b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx @@ -12,6 +12,7 @@ import { useMemo, useCallback, useEffect, useRef } from 'react'; import { PlatformProvider } from '@qwen-code/webui'; import type { PlatformContextValue } from '@qwen-code/webui'; import { useVSCode } from '../hooks/useVSCode.js'; +import { generateIconUrl } from '../utils/resourceUrl.js'; /** * Props for VSCodePlatformProvider @@ -80,6 +81,9 @@ export const VSCodePlatformProvider: React.FC = ({ } }, []); + // Get resource URL handler (for icons and other assets) + const getResourceUrl = useCallback((resourceName: string) => generateIconUrl(resourceName) || undefined, []); + // Subscribe to messages const onMessage = useCallback((handler: (message: unknown) => void) => { messageHandlersRef.current.add(handler); @@ -98,6 +102,7 @@ export const VSCodePlatformProvider: React.FC = ({ attachFile, login, copyToClipboard, + getResourceUrl, features: { canOpenFile: true, canAttachFile: true, @@ -112,6 +117,7 @@ export const VSCodePlatformProvider: React.FC = ({ attachFile, login, copyToClipboard, + getResourceUrl, ], ); diff --git a/packages/vscode-ide-companion/src/webview/index.tsx b/packages/vscode-ide-companion/src/webview/index.tsx index aa2681bec..b5e7b94c5 100644 --- a/packages/vscode-ide-companion/src/webview/index.tsx +++ b/packages/vscode-ide-companion/src/webview/index.tsx @@ -8,6 +8,9 @@ import ReactDOM from 'react-dom/client'; import { App } from './App.js'; import { VSCodePlatformProvider } from './context/VSCodePlatformProvider.js'; +// Import webui shared styles (CSS variables, component-specific styles) +import '@qwen-code/webui/styles.css'; + // eslint-disable-next-line import/no-internal-modules import './styles/tailwind.css'; // eslint-disable-next-line import/no-internal-modules diff --git a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts index e11f4bcef..19431a679 100644 --- a/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts +++ b/packages/vscode-ide-companion/src/webview/utils/sessionGrouping.ts @@ -2,98 +2,9 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 - */ - -export interface SessionGroup { - label: string; - sessions: Array>; -} - -/** - * Group sessions by date * - * @param sessions - Array of session objects - * @returns Array of grouped sessions + * Re-export session grouping utilities from webui for backward compatibility */ -export const groupSessionsByDate = ( - sessions: Array>, -): SessionGroup[] => { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const groups: { - [key: string]: Array>; - } = { - Today: [], - Yesterday: [], - 'This Week': [], - Older: [], - }; - - sessions.forEach((session) => { - const timestamp = - (session.lastUpdated as string) || (session.startTime as string) || ''; - if (!timestamp) { - groups['Older'].push(session); - return; - } - - const sessionDate = new Date(timestamp); - const sessionDay = new Date( - sessionDate.getFullYear(), - sessionDate.getMonth(), - sessionDate.getDate(), - ); - - if (sessionDay.getTime() === today.getTime()) { - groups['Today'].push(session); - } else if (sessionDay.getTime() === yesterday.getTime()) { - groups['Yesterday'].push(session); - } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { - groups['This Week'].push(session); - } else { - groups['Older'].push(session); - } - }); - - return Object.entries(groups) - .filter(([, sessions]) => sessions.length > 0) - .map(([label, sessions]) => ({ label, sessions })); -}; - -/** - * Time ago formatter - * - * @param timestamp - ISO timestamp string - * @returns Formatted time string - */ -export const getTimeAgo = (timestamp: string): string => { - if (!timestamp) { - return ''; - } - const now = new Date().getTime(); - const then = new Date(timestamp).getTime(); - const diffMs = now - then; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) { - return 'now'; - } - if (diffMins < 60) { - return `${diffMins}m`; - } - if (diffHours < 24) { - return `${diffHours}h`; - } - if (diffDays === 1) { - return 'Yesterday'; - } - if (diffDays < 7) { - return `${diffDays}d`; - } - return new Date(timestamp).toLocaleDateString(); -}; +export { groupSessionsByDate, getTimeAgo } from '@qwen-code/webui'; +export type { SessionGroup } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js index 956f785cf..b286d45d0 100644 --- a/packages/vscode-ide-companion/tailwind.config.js +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -5,9 +5,18 @@ */ /* eslint-env node */ +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + /** @type {import('tailwindcss').Config} */ export default { - content: ['./src/webview/**/**/*.{js,jsx,ts,tsx}'], + // Use webui preset for shared theme configuration + presets: [require('@qwen-code/webui/tailwind.preset')], + content: [ + './src/webview/**/**/*.{js,jsx,ts,tsx}', + // Include webui components to prevent Tailwind JIT from tree-shaking their classes + './node_modules/@qwen-code/webui/dist/**/*.js', + ], theme: { extend: { keyframes: { diff --git a/packages/webui/.storybook/preview.css b/packages/webui/.storybook/preview.css index ae008a63c..1514fc9fd 100644 --- a/packages/webui/.storybook/preview.css +++ b/packages/webui/.storybook/preview.css @@ -4,8 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +/* Import CSS variables BEFORE Tailwind so they're available */ +@import '../src/styles/variables.css'; + @tailwind base; @tailwind components; @tailwind utilities; -@import '../src/styles/variables.css'; +/* Ensure text color is inherited properly in Storybook */ +body { + color: var(--app-primary-foreground); +} diff --git a/packages/webui/.storybook/preview.ts b/packages/webui/.storybook/preview.ts index 3cc995c1f..47686b067 100644 --- a/packages/webui/.storybook/preview.ts +++ b/packages/webui/.storybook/preview.ts @@ -5,6 +5,7 @@ */ import type { Preview } from '@storybook/react-vite'; +import React from 'react'; import './preview.css'; const preview: Preview = { @@ -15,7 +16,29 @@ const preview: Preview = { date: /Date$/i, }, }, + backgrounds: { + default: 'dark', + values: [ + { name: 'dark', value: '#1e1e1e' }, + { name: 'light', value: '#ffffff' }, + ], + }, }, + decorators: [ + (Story) => + React.createElement( + 'div', + { + style: { + backgroundColor: 'var(--app-background)', + color: 'var(--app-primary-foreground)', + minHeight: '100px', + padding: '16px', + }, + }, + React.createElement(Story), + ), + ], }; export default preview; diff --git a/packages/webui/src/components/layout/ChatHeader.stories.tsx b/packages/webui/src/components/layout/ChatHeader.stories.tsx new file mode 100644 index 000000000..8b5b0f75c --- /dev/null +++ b/packages/webui/src/components/layout/ChatHeader.stories.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ChatHeader } from './ChatHeader.js'; + +/** + * ChatHeader component for displaying chat session information. + * Shows current session title with navigation controls. + */ +const meta: Meta = { + title: 'Layout/ChatHeader', + component: ChatHeader, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + argTypes: { + currentSessionTitle: { + control: 'text', + description: 'Current session title to display', + }, + onLoadSessions: { action: 'loadSessions' }, + onNewSession: { action: 'newSession' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + currentSessionTitle: 'My Chat Session', + }, +}; + +export const LongTitle: Story = { + args: { + currentSessionTitle: + 'This is a very long session title that should be truncated with ellipsis', + }, +}; + +export const ShortTitle: Story = { + args: { + currentSessionTitle: 'Chat', + }, +}; + +export const UntitledSession: Story = { + args: { + currentSessionTitle: 'Untitled Session', + }, +}; diff --git a/packages/webui/src/components/layout/ChatHeader.tsx b/packages/webui/src/components/layout/ChatHeader.tsx new file mode 100644 index 000000000..4ece03d78 --- /dev/null +++ b/packages/webui/src/components/layout/ChatHeader.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * ChatHeader component - Header for chat interface + * Displays current session title with navigation controls + */ + +import type React from 'react'; +import { ChevronDownIcon } from '../icons/NavigationIcons.js'; +import { PlusIcon } from '../icons/NavigationIcons.js'; + +/** + * Props for ChatHeader component + */ +export interface ChatHeaderProps { + /** Current session title to display */ + currentSessionTitle: string; + /** Callback when user clicks to load session list */ + onLoadSessions: () => void; + /** Callback when user clicks to create new session */ + onNewSession: () => void; +} + +/** + * ChatHeader component + * + * Features: + * - Displays current session title with dropdown indicator + * - Button to view past conversations + * - Button to create new session + * + * @example + * ```tsx + * console.log('Load sessions')} + * onNewSession={() => console.log('New session')} + * /> + * ``` + */ +export const ChatHeader: React.FC = ({ + currentSessionTitle, + onLoadSessions, + onNewSession, +}) => ( +
+ + +
+ + +
+); diff --git a/packages/webui/src/components/layout/CompletionMenu.stories.tsx b/packages/webui/src/components/layout/CompletionMenu.stories.tsx new file mode 100644 index 000000000..088864fb4 --- /dev/null +++ b/packages/webui/src/components/layout/CompletionMenu.stories.tsx @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CompletionMenu } from './CompletionMenu.js'; +import { FileIcon, FolderIcon } from '../icons/FileIcons.js'; + +/** + * CompletionMenu component displays an autocomplete dropdown menu. + * Supports keyboard navigation and mouse interaction. + */ +const meta: Meta = { + title: 'Layout/CompletionMenu', + component: CompletionMenu, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + title: { + control: 'text', + description: 'Optional section title', + }, + selectedIndex: { + control: 'number', + description: 'Initial selected index', + }, + onSelect: { action: 'selected' }, + onClose: { action: 'closed' }, + }, + decorators: [ + (Story) => ( +
+
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + items: [ + { id: '1', label: 'index.ts', type: 'file', icon: }, + { id: '2', label: 'components', type: 'folder', icon: }, + { id: '3', label: 'utils.ts', type: 'file', icon: }, + ], + }, +}; + +export const WithTitle: Story = { + args: { + title: 'Recent Files', + items: [ + { id: '1', label: 'App.tsx', type: 'file', icon: }, + { id: '2', label: 'Header.tsx', type: 'file', icon: }, + { id: '3', label: 'Footer.tsx', type: 'file', icon: }, + ], + }, +}; + +export const WithDescriptions: Story = { + args: { + title: 'Commands', + items: [ + { + id: '1', + label: '/help', + type: 'command', + description: 'Show help message', + }, + { + id: '2', + label: '/clear', + type: 'command', + description: 'Clear chat history', + }, + { + id: '3', + label: '/settings', + type: 'command', + description: 'Open settings', + }, + ], + }, +}; + +export const ManyItems: Story = { + args: { + title: 'All Files', + items: Array.from({ length: 20 }, (_, i) => ({ + id: String(i + 1), + label: `file-${i + 1}.ts`, + type: 'file' as const, + icon: , + })), + }, +}; + +export const SingleItem: Story = { + args: { + items: [ + { id: '1', label: 'only-option.ts', type: 'file', icon: }, + ], + }, +}; + +export const Empty: Story = { + args: { + items: [], + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx b/packages/webui/src/components/layout/CompletionMenu.tsx similarity index 79% rename from packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx rename to packages/webui/src/components/layout/CompletionMenu.tsx index f667b849a..698c623ca 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/CompletionMenu.tsx +++ b/packages/webui/src/components/layout/CompletionMenu.tsx @@ -2,20 +2,53 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * CompletionMenu component - Autocomplete dropdown menu + * Supports keyboard navigation and mouse interaction */ import type React from 'react'; import { useEffect, useRef, useState } from 'react'; -import type { CompletionItem } from '../../../types/completionItemTypes.js'; +import type { CompletionItem } from '../../types/completion.js'; -interface CompletionMenuProps { +/** + * Props for CompletionMenu component + */ +export interface CompletionMenuProps { + /** List of completion items to display */ items: CompletionItem[]; + /** Callback when an item is selected */ onSelect: (item: CompletionItem) => void; + /** Callback when menu should close */ onClose: () => void; + /** Optional section title */ title?: string; + /** Initial selected index */ selectedIndex?: number; } +/** + * CompletionMenu component + * + * Features: + * - Keyboard navigation (Arrow Up/Down, Enter, Escape) + * - Mouse hover selection + * - Click outside to close + * - Auto-scroll to selected item + * - Smooth enter animation + * + * @example + * ```tsx + * console.log('Selected:', item)} + * onClose={() => console.log('Closed')} + * /> + * ``` + */ export const CompletionMenu: React.FC = ({ items, onSelect, @@ -28,7 +61,13 @@ export const CompletionMenu: React.FC = ({ // Mount state to drive a simple Tailwind transition (replaces CSS keyframes) const [mounted, setMounted] = useState(false); - useEffect(() => setSelected(selectedIndex), [selectedIndex]); + useEffect(() => { + if (!items.length) { + return; + } + const nextIndex = Math.min(Math.max(selectedIndex, 0), items.length - 1); + setSelected(nextIndex); + }, [items.length, selectedIndex]); useEffect(() => setMounted(true), []); useEffect(() => { @@ -90,7 +129,8 @@ export const CompletionMenu: React.FC = ({ return (
= ({
onSelect(item)} onMouseEnter={() => setSelected(index)} className={[ diff --git a/packages/webui/src/components/layout/ContextIndicator.stories.tsx b/packages/webui/src/components/layout/ContextIndicator.stories.tsx new file mode 100644 index 000000000..97c1e2624 --- /dev/null +++ b/packages/webui/src/components/layout/ContextIndicator.stories.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ContextIndicator } from './ContextIndicator.js'; + +/** + * ContextIndicator component shows context usage as a circular progress indicator. + * Displays token usage information with tooltip on hover. + */ +const meta: Meta = { + title: 'Layout/ContextIndicator', + component: ContextIndicator, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + contextUsage: { + description: 'Context usage data, null to hide indicator', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + contextUsage: { + percentLeft: 75, + usedTokens: 25000, + tokenLimit: 100000, + }, + }, +}; + +export const HalfUsed: Story = { + args: { + contextUsage: { + percentLeft: 50, + usedTokens: 50000, + tokenLimit: 100000, + }, + }, +}; + +export const AlmostFull: Story = { + args: { + contextUsage: { + percentLeft: 10, + usedTokens: 90000, + tokenLimit: 100000, + }, + }, +}; + +export const Full: Story = { + args: { + contextUsage: { + percentLeft: 0, + usedTokens: 100000, + tokenLimit: 100000, + }, + }, +}; + +export const LowUsage: Story = { + args: { + contextUsage: { + percentLeft: 95, + usedTokens: 5000, + tokenLimit: 100000, + }, + }, +}; + +export const Hidden: Story = { + args: { + contextUsage: null, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx b/packages/webui/src/components/layout/ContextIndicator.tsx similarity index 56% rename from packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx rename to packages/webui/src/components/layout/ContextIndicator.tsx index ce3dddfa4..4af745abb 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/ContextIndicator.tsx +++ b/packages/webui/src/components/layout/ContextIndicator.tsx @@ -2,21 +2,65 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * ContextIndicator component - Shows context usage as a circular progress indicator + * Displays token usage information with tooltip */ import type React from 'react'; -import { Tooltip } from '@qwen-code/webui'; +import { Tooltip } from '../ui/Tooltip.js'; -interface ContextUsage { +/** + * Context usage information + */ +export interface ContextUsage { + /** Percentage of context remaining (0-100) */ percentLeft: number; + /** Number of tokens used */ usedTokens: number; + /** Maximum token limit */ tokenLimit: number; } -interface ContextIndicatorProps { +/** + * Props for ContextIndicator component + */ +export interface ContextIndicatorProps { + /** Context usage data, null to hide indicator */ contextUsage: ContextUsage | null; } +/** + * Format large numbers with 'k' suffix + * @param value Number to format + * @returns Formatted string (e.g., "1.5k" for 1500) + */ +const formatNumber = (value: number): string => { + if (value >= 1000) { + return `${(Math.round((value / 1000) * 10) / 10).toFixed(1)}k`; + } + return Math.round(value).toLocaleString(); +}; + +/** + * ContextIndicator component + * + * Features: + * - Circular progress indicator showing context usage + * - Tooltip with detailed usage information + * - Accessible with proper ARIA labels + * + * @example + * ```tsx + * + * ``` + */ export const ContextIndicator: React.FC = ({ contextUsage, }) => { @@ -26,19 +70,17 @@ export const ContextIndicator: React.FC = ({ // Calculate used percentage for the progress indicator // contextUsage.percentLeft is the percentage remaining, so 100 - percentLeft = percent used - const percentUsed = 100 - contextUsage.percentLeft; - const percentFormatted = Math.max(0, Math.min(100, Math.round(percentUsed))); + // Clamp percentUsed to valid range [0, 100] before SVG calculations + const percentUsed = Math.max( + 0, + Math.min(100, 100 - contextUsage.percentLeft), + ); + const percentFormatted = Math.round(percentUsed); const radius = 9; const circumference = 2 * Math.PI * radius; // To show the used portion, we need to offset the unused portion // If 20% is used, we want to show 20% filled, so offset the remaining 80% const dashOffset = ((100 - percentUsed) / 100) * circumference; - const formatNumber = (value: number) => { - if (value >= 1000) { - return `${(Math.round((value / 1000) * 10) / 10).toFixed(1)}k`; - } - return Math.round(value).toLocaleString(); - }; // Create tooltip content with proper formatting const tooltipContent = ( @@ -50,12 +92,11 @@ export const ContextIndicator: React.FC = ({
); + const ariaLabel = `${percentFormatted}% • ${formatNumber(contextUsage.usedTokens)} / ${formatNumber(contextUsage.tokenLimit)} context used`; + return ( - + + {/* Active file indicator */} + {activeFileName && ( + + )} + + {/* Spacer */} +
+ + {/* Context usage indicator */} + + + {/* Command button */} + + + {/* Attach button */} + + + {/* Send/Stop button */} + {isStreaming || isWaitingForResponse ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; diff --git a/packages/webui/src/components/layout/SessionSelector.stories.tsx b/packages/webui/src/components/layout/SessionSelector.stories.tsx new file mode 100644 index 000000000..a9e4b003e --- /dev/null +++ b/packages/webui/src/components/layout/SessionSelector.stories.tsx @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SessionSelector } from './SessionSelector.js'; + +/** + * SessionSelector component displays a session list dropdown. + * Shows sessions grouped by date with search and infinite scroll support. + */ +const meta: Meta = { + title: 'Layout/SessionSelector', + component: SessionSelector, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + argTypes: { + visible: { + control: 'boolean', + description: 'Whether the selector is visible', + }, + currentSessionId: { + control: 'text', + description: 'Currently selected session ID', + }, + searchQuery: { + control: 'text', + description: 'Current search query', + }, + hasMore: { + control: 'boolean', + description: 'Whether there are more sessions to load', + }, + isLoading: { + control: 'boolean', + description: 'Whether loading is in progress', + }, + onSearchChange: { action: 'searchChanged' }, + onSelectSession: { action: 'sessionSelected' }, + onClose: { action: 'closed' }, + onLoadMore: { action: 'loadMore' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +const now = new Date(); +const today = now.toISOString(); +const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); +const lastWeek = new Date( + now.getTime() - 5 * 24 * 60 * 60 * 1000, +).toISOString(); +const older = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + +const mockSessions = [ + { id: '1', title: 'Debugging React hooks', lastUpdated: today }, + { id: '2', title: 'API integration discussion', lastUpdated: today }, + { id: '3', title: 'Code review feedback', lastUpdated: yesterday }, + { id: '4', title: 'Project planning', lastUpdated: lastWeek }, + { id: '5', title: 'Feature brainstorming', lastUpdated: lastWeek }, + { id: '6', title: 'Old conversation', lastUpdated: older }, +]; + +export const Default: Story = { + args: { + visible: true, + sessions: mockSessions, + currentSessionId: '1', + searchQuery: '', + }, +}; + +export const WithSearch: Story = { + args: { + visible: true, + sessions: mockSessions.filter((s) => + s.title.toLowerCase().includes('debug'), + ), + currentSessionId: null, + searchQuery: 'debug', + }, +}; + +export const Empty: Story = { + args: { + visible: true, + sessions: [], + currentSessionId: null, + searchQuery: '', + }, +}; + +export const NoSearchResults: Story = { + args: { + visible: true, + sessions: [], + currentSessionId: null, + searchQuery: 'nonexistent', + }, +}; + +export const Loading: Story = { + args: { + visible: true, + sessions: mockSessions, + currentSessionId: '1', + searchQuery: '', + hasMore: true, + isLoading: true, + }, +}; + +export const Hidden: Story = { + args: { + visible: false, + sessions: mockSessions, + currentSessionId: '1', + searchQuery: '', + }, +}; + +export const ManySessions: Story = { + args: { + visible: true, + sessions: Array.from({ length: 30 }, (_, i) => ({ + id: String(i + 1), + title: `Session ${i + 1}`, + lastUpdated: new Date( + now.getTime() - i * 24 * 60 * 60 * 1000, + ).toISOString(), + })), + currentSessionId: '5', + searchQuery: '', + hasMore: true, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx b/packages/webui/src/components/layout/SessionSelector.tsx similarity index 79% rename from packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx rename to packages/webui/src/components/layout/SessionSelector.tsx index a751410bc..6e185af81 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/SessionSelector.tsx +++ b/packages/webui/src/components/layout/SessionSelector.tsx @@ -2,31 +2,67 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * SessionSelector component - Session list dropdown + * Displays sessions grouped by date with search and infinite scroll */ -import React from 'react'; +import type React from 'react'; +import { Fragment } from 'react'; import { getTimeAgo, groupSessionsByDate, } from '../../utils/sessionGrouping.js'; -import { SearchIcon } from '@qwen-code/webui'; +import { SearchIcon } from '../icons/NavigationIcons.js'; -interface SessionSelectorProps { +/** + * Props for SessionSelector component + */ +export interface SessionSelectorProps { + /** Whether the selector is visible */ visible: boolean; + /** List of session objects */ sessions: Array>; + /** Currently selected session ID */ currentSessionId: string | null; + /** Current search query */ searchQuery: string; + /** Callback when search query changes */ onSearchChange: (query: string) => void; + /** Callback when a session is selected */ onSelectSession: (sessionId: string) => void; + /** Callback when selector should close */ onClose: () => void; + /** Whether there are more sessions to load */ hasMore?: boolean; + /** Whether loading is in progress */ isLoading?: boolean; + /** Callback to load more sessions */ onLoadMore?: () => void; } /** - * Session selector component - * Display session list and support search and selection + * SessionSelector component + * + * Features: + * - Sessions grouped by date (Today, Yesterday, This Week, Older) + * - Search filtering + * - Infinite scroll to load more sessions + * - Click outside to close + * - Active session highlighting + * + * @example + * ```tsx + * setQuery(q)} + * onSelectSession={(id) => loadSession(id)} + * onClose={() => setVisible(false)} + * /> + * ``` */ export const SessionSelector: React.FC = ({ visible, @@ -68,6 +104,7 @@ export const SessionSelector: React.FC = ({ type="text" className="session-search-input flex-1 bg-transparent border-none outline-none text-[var(--app-menu-foreground)] text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] p-0 placeholder:text-[var(--app-input-placeholder-foreground)] placeholder:opacity-60" placeholder="Search sessions…" + aria-label="Search sessions" value={searchQuery} onChange={(e) => onSearchChange(e.target.value)} /> @@ -98,7 +135,7 @@ export const SessionSelector: React.FC = ({
) : ( groupSessionsByDate(sessions).map((group) => ( - +
{group.label}
@@ -121,6 +158,7 @@ export const SessionSelector: React.FC = ({ return (
- + )) )} {hasMore && ( diff --git a/packages/webui/src/components/messages/ThinkingMessage.stories.tsx b/packages/webui/src/components/messages/ThinkingMessage.stories.tsx new file mode 100644 index 000000000..246bcf5d0 --- /dev/null +++ b/packages/webui/src/components/messages/ThinkingMessage.stories.tsx @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ThinkingMessage } from './ThinkingMessage.js'; + +/** + * ThinkingMessage component displays AI's internal thought process. + * Shows with animated dots and distinctive styling. + */ +const meta: Meta = { + title: 'Messages/ThinkingMessage', + component: ThinkingMessage, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + content: { + control: 'text', + description: 'The thinking content to display', + }, + timestamp: { + control: 'number', + description: 'Message timestamp', + }, + onFileClick: { action: 'fileClicked' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'Let me analyze this code and think about the best approach...', + timestamp: Date.now(), + }, +}; + +export const ShortThought: Story = { + args: { + content: 'Checking dependencies...', + timestamp: Date.now(), + }, +}; + +export const LongThought: Story = { + args: { + content: `I need to consider several factors here: +1. The function structure and its dependencies +2. The type annotations and their implications +3. How this integrates with the rest of the codebase +4. Performance implications of the proposed changes + +Let me work through each of these systematically...`, + timestamp: Date.now(), + }, +}; + +export const WithFilePath: Story = { + args: { + content: + 'Looking at the code in `src/utils/helpers.ts` to understand the pattern...', + timestamp: Date.now(), + }, +}; + +export const CodeAnalysis: Story = { + args: { + content: + 'The current implementation uses a recursive approach. I should consider whether an iterative solution would be more efficient for large inputs.', + timestamp: Date.now(), + }, +}; diff --git a/packages/webui/src/components/messages/UserMessage.stories.tsx b/packages/webui/src/components/messages/UserMessage.stories.tsx new file mode 100644 index 000000000..9b1180af9 --- /dev/null +++ b/packages/webui/src/components/messages/UserMessage.stories.tsx @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { UserMessage } from './UserMessage.js'; + +/** + * UserMessage component displays messages from the user. + * Supports file context display with line numbers. + */ +const meta: Meta = { + title: 'Messages/UserMessage', + component: UserMessage, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + content: { + control: 'text', + description: 'The message content', + }, + timestamp: { + control: 'number', + description: 'Message timestamp', + }, + onFileClick: { action: 'fileClicked' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'How do I fix this bug?', + timestamp: Date.now(), + }, +}; + +export const LongMessage: Story = { + args: { + content: `I'm having trouble with a TypeScript error. The compiler says: +"Type 'string' is not assignable to type 'number'" + +Can you help me understand what's wrong and how to fix it?`, + timestamp: Date.now(), + }, +}; + +export const WithFileContext: Story = { + args: { + content: 'Can you explain what this function does?', + timestamp: Date.now(), + fileContext: { + fileName: 'helpers.ts', + filePath: 'src/utils/helpers.ts', + }, + }, +}; + +export const WithFileContextAndLines: Story = { + args: { + content: 'This code seems inefficient. How can I optimize it?', + timestamp: Date.now(), + fileContext: { + fileName: 'api.ts', + filePath: 'src/services/api.ts', + startLine: 45, + endLine: 78, + }, + }, +}; + +export const WithSingleLine: Story = { + args: { + content: 'What does this line do?', + timestamp: Date.now(), + fileContext: { + fileName: 'config.ts', + filePath: 'src/config.ts', + startLine: 12, + }, + }, +}; + +export const CodeQuestion: Story = { + args: { + content: `What's the difference between: +\`\`\`typescript +const foo = () => {} +\`\`\` +and +\`\`\`typescript +function foo() {} +\`\`\``, + timestamp: Date.now(), + }, +}; + +export const SimpleQuery: Story = { + args: { + content: 'Help', + timestamp: Date.now(), + }, +}; diff --git a/packages/webui/src/components/toolcalls/CheckboxDisplay.stories.tsx b/packages/webui/src/components/toolcalls/CheckboxDisplay.stories.tsx new file mode 100644 index 000000000..52fe59856 --- /dev/null +++ b/packages/webui/src/components/toolcalls/CheckboxDisplay.stories.tsx @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CheckboxDisplay } from './CheckboxDisplay.js'; + +/** + * CheckboxDisplay is a read-only checkbox for displaying plan entry status. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/CheckboxDisplay', + component: CheckboxDisplay, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Unchecked: Story = { + args: { + checked: false, + indeterminate: false, + }, +}; + +export const Checked: Story = { + args: { + checked: true, + indeterminate: false, + }, +}; + +export const Indeterminate: Story = { + args: { + checked: false, + indeterminate: true, + }, +}; + +export const AllStates: Story = { + render: () => ( +
+
+ +
Pending
+
+
+ +
In Progress
+
+
+ +
Completed
+
+
+ ), +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx b/packages/webui/src/components/toolcalls/CheckboxDisplay.tsx similarity index 76% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx rename to packages/webui/src/components/toolcalls/CheckboxDisplay.tsx index d17ed073c..555c33c32 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/CheckboxDisplay.tsx +++ b/packages/webui/src/components/toolcalls/CheckboxDisplay.tsx @@ -2,6 +2,8 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * Display-only checkbox component for plan entries */ import type React from 'react'; @@ -18,7 +20,7 @@ export interface CheckboxDisplayProps { /** * Display-only checkbox styled via Tailwind classes. * - Renders a custom-looking checkbox using appearance-none and pseudo-elements. - * - Supports indeterminate (middle) state using the DOM property and a data- attribute. + * - Supports indeterminate (middle) state using a data- attribute. * - Intended for read-only display (disabled by default). */ export const CheckboxDisplay: React.FC = ({ @@ -29,9 +31,6 @@ export const CheckboxDisplay: React.FC = ({ style, title, }) => { - // Render as a span (not ) so we can draw a checkmark with CSS. - // Pseudo-elements do not reliably render on in Chromium (VS Code webviews), - // which caused the missing icon. This version is font-free and uses borders. const showCheck = !!checked && !indeterminate; const showInProgress = !!indeterminate; @@ -55,11 +54,8 @@ export const CheckboxDisplay: React.FC = ({ aria-hidden className={[ 'absolute block', - // Place the check slightly to the left/top so rotated arms stay inside the 16x16 box 'left-[3px] top-[3px]', - // 10x6 shape works well for a 16x16 checkbox 'w-2.5 h-1.5', - // Draw the L-corner and rotate to form a check 'border-l-2 border-b-2', 'border-[#74c991]', '-rotate-45', @@ -72,7 +68,6 @@ export const CheckboxDisplay: React.FC = ({ className={[ 'absolute inline-block', 'left-1/2 top-[10px] -translate-x-1/2 -translate-y-1/2', - // Use a literal star; no icon font needed 'text-[16px] leading-none text-[#e1c08d] select-none', ].join(' ')} > diff --git a/packages/webui/src/components/toolcalls/EditToolCall.stories.tsx b/packages/webui/src/components/toolcalls/EditToolCall.stories.tsx new file mode 100644 index 000000000..6d5e16b35 --- /dev/null +++ b/packages/webui/src/components/toolcalls/EditToolCall.stories.tsx @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { EditToolCall } from './EditToolCall.js'; + +/** + * EditToolCall displays file editing operations with diff summaries. + */ +const meta: Meta = { + title: 'ToolCalls/EditToolCall', + component: EditToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const WithDiff: Story = { + args: { + toolCall: { + toolCallId: 'edit-1', + kind: 'edit', + title: 'Edit file', + status: 'completed', + content: [ + { + type: 'diff', + path: 'src/components/App.tsx', + oldText: 'const App = () => {\n return
Hello
;\n};', + newText: + 'const App = () => {\n return (\n
\n

Hello World

\n
\n );\n};', + }, + ], + }, + }, +}; + +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'edit-2', + kind: 'edit', + title: 'Edit file', + status: 'failed', + content: [ + { + type: 'content', + content: { type: 'error', error: 'File not found' }, + }, + ], + locations: [{ path: 'src/missing.ts' }], + }, + }, +}; + +export const WithLocation: Story = { + args: { + toolCall: { + toolCallId: 'edit-3', + kind: 'edit', + title: 'Edit file', + status: 'completed', + locations: [{ path: 'src/utils/helpers.ts', line: 42 }], + }, + }, +}; + +export const Failed: Story = { + args: { + toolCall: { + toolCallId: 'edit-4', + kind: 'edit', + title: 'Edit file', + status: 'failed', + content: [ + { + type: 'diff', + path: 'src/App.tsx', + oldText: 'old content', + newText: 'new content', + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx b/packages/webui/src/components/toolcalls/EditToolCall.tsx similarity index 93% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx rename to packages/webui/src/components/toolcalls/EditToolCall.tsx index 3d677cb1e..652c58166 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Edit/EditToolCall.tsx +++ b/packages/webui/src/components/toolcalls/EditToolCall.tsx @@ -8,16 +8,19 @@ import { useMemo } from 'react'; import { - FileLink, groupContent, mapToolStatusToContainerStatus, -} from '@qwen-code/webui'; +} from './shared/index.js'; import type { BaseToolCallProps, ToolCallContainerProps, -} from '@qwen-code/webui'; +} from './shared/index.js'; +import { FileLink } from '../layout/FileLink.js'; -export const ToolCallContainer: React.FC = ({ +/** + * Custom ToolCallContainer for EditToolCall with specific styling + */ +const EditToolCallContainer: React.FC = ({ label, status = 'success', children, @@ -111,7 +114,7 @@ export const EditToolCall: React.FC = ({ toolCall }) => { if (errors.length > 0) { const path = diffs[0]?.path || locations?.[0]?.path || ''; return ( - = ({ toolCall }) => { } > {errors.join('\n')} - + ); } - // Success case with diff: show minimal inline preview; clicking the title opens VS Code diff + // Success case with diff: show minimal inline preview if (diffs.length > 0) { const firstDiff = diffs[0]; const path = firstDiff.path || (locations && locations[0]?.path) || ''; @@ -143,7 +146,6 @@ export const EditToolCall: React.FC = ({ toolCall }) => {
- {/* Align the inline Edit label styling with shared toolcall label: larger + bold */} Edit @@ -169,7 +171,7 @@ export const EditToolCall: React.FC = ({ toolCall }) => { if (locations && locations.length > 0) { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); return ( - = ({ toolCall }) => { showFullPath={true} />
- + ); } diff --git a/packages/webui/src/components/toolcalls/GenericToolCall.stories.tsx b/packages/webui/src/components/toolcalls/GenericToolCall.stories.tsx new file mode 100644 index 000000000..2906855ae --- /dev/null +++ b/packages/webui/src/components/toolcalls/GenericToolCall.stories.tsx @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { GenericToolCall } from './GenericToolCall.js'; + +/** + * GenericToolCall is a fallback component for displaying any tool call type. + * Used when no specialized component exists for a particular tool kind. + */ +const meta: Meta = { + title: 'ToolCalls/GenericToolCall', + component: GenericToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const TaskSuccess: Story = { + args: { + toolCall: { + toolCallId: 'generic-1', + kind: 'task', + title: 'Running background task', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: 'Task completed successfully' }, + }, + ], + }, + }, +}; + +export const WebFetch: Story = { + args: { + toolCall: { + toolCallId: 'generic-2', + kind: 'web_fetch', + title: 'Fetching https://api.example.com/data', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: 'Retrieved 1.2KB of data' }, + }, + ], + }, + }, +}; + +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'generic-3', + kind: 'web_search', + title: 'Searching for "react hooks"', + status: 'failed', + content: [ + { + type: 'content', + content: { type: 'error', error: 'Network timeout' }, + }, + ], + }, + }, +}; + +export const Loading: Story = { + args: { + toolCall: { + toolCallId: 'generic-4', + kind: 'task', + title: 'Processing files...', + status: 'in_progress', + content: [], + }, + }, +}; + +export const WithLocations: Story = { + args: { + toolCall: { + toolCallId: 'generic-5', + kind: 'task', + title: 'Found matching files', + status: 'completed', + locations: [ + { path: 'src/App.tsx', line: 10 }, + { path: 'src/utils/helpers.ts', line: 25 }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx b/packages/webui/src/components/toolcalls/GenericToolCall.tsx similarity index 96% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx rename to packages/webui/src/components/toolcalls/GenericToolCall.tsx index cedd344b8..1c003f1e4 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/GenericToolCall.tsx +++ b/packages/webui/src/components/toolcalls/GenericToolCall.tsx @@ -14,8 +14,8 @@ import { LocationsList, safeTitle, groupContent, -} from '@qwen-code/webui'; -import type { BaseToolCallProps } from '@qwen-code/webui'; +} from './shared/index.js'; +import type { BaseToolCallProps } from './shared/index.js'; /** * Generic tool call component that can display any tool call type @@ -26,7 +26,9 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { const { kind, title, content, locations, toolCallId } = toolCall; const operationText = safeTitle(title); - // Map tool call kind to appropriate display name + /** + * Map tool call kind to appropriate display name + */ const getDisplayLabel = (): string => { const normalizedKind = kind.toLowerCase(); if (normalizedKind === 'task') { diff --git a/packages/webui/src/components/toolcalls/SearchToolCall.stories.tsx b/packages/webui/src/components/toolcalls/SearchToolCall.stories.tsx new file mode 100644 index 000000000..b66c58d0e --- /dev/null +++ b/packages/webui/src/components/toolcalls/SearchToolCall.stories.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SearchToolCall } from './SearchToolCall.js'; + +/** + * SearchToolCall displays search operations and results. + */ +const meta: Meta = { + title: 'ToolCalls/SearchToolCall', + component: SearchToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const GrepSingleResult: Story = { + args: { + toolCall: { + toolCallId: 'search-1', + kind: 'grep', + title: 'useState', + status: 'completed', + locations: [{ path: 'src/App.tsx', line: 5 }], + }, + }, +}; + +export const GrepMultipleResults: Story = { + args: { + toolCall: { + toolCallId: 'search-2', + kind: 'grep', + title: 'import React', + status: 'completed', + locations: [ + { path: 'src/App.tsx', line: 1 }, + { path: 'src/components/Header.tsx', line: 1 }, + { path: 'src/utils/hooks.ts', line: 3 }, + ], + }, + }, +}; + +export const GlobSearch: Story = { + args: { + toolCall: { + toolCallId: 'search-3', + kind: 'glob', + title: '**/*.tsx', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: 'Listed 4 item(s).' }, + }, + ], + }, + }, +}; + +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'search-4', + kind: 'grep', + title: 'invalid[regex', + status: 'failed', + content: [ + { + type: 'content', + content: { type: 'error', error: 'Invalid regex pattern' }, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx b/packages/webui/src/components/toolcalls/SearchToolCall.tsx similarity index 86% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx rename to packages/webui/src/components/toolcalls/SearchToolCall.tsx index 30a492e0c..f34d355ee 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Search/SearchToolCall.tsx +++ b/packages/webui/src/components/toolcalls/SearchToolCall.tsx @@ -8,17 +8,15 @@ import type React from 'react'; import { - FileLink, safeTitle, groupContent, mapToolStatusToContainerStatus, -} from '@qwen-code/webui'; -import type { BaseToolCallProps } from '@qwen-code/webui'; +} from './shared/index.js'; +import type { BaseToolCallProps } from './shared/index.js'; +import { FileLink } from '../layout/FileLink.js'; /** - * Specialized component for Search tool calls - * Optimized for displaying search operations and results - * Shows query + result count or file list + * Inline container for compact search results display */ const InlineContainer: React.FC<{ status: 'success' | 'error' | 'warning' | 'loading' | 'default'; @@ -67,7 +65,9 @@ const InlineContainer: React.FC<{ ); }; -// Local card layout for multi-result or error display +/** + * Card layout for multi-result or error display + */ const SearchCard: React.FC<{ status: 'success' | 'error' | 'warning' | 'loading' | 'default'; children: React.ReactNode; @@ -106,6 +106,9 @@ const SearchCard: React.FC<{ ); }; +/** + * Row component for search card layout + */ const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children, @@ -120,6 +123,9 @@ const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({
); +/** + * Local locations list component + */ const LocationsListLocal: React.FC<{ locations: Array<{ path: string; line?: number | null }>; }> = ({ locations }) => ( @@ -130,6 +136,26 @@ const LocationsListLocal: React.FC<{
); +/** + * Map tool call kind to appropriate display name + */ +const getDisplayLabel = (kind: string): string => { + const normalizedKind = kind.toLowerCase(); + if (normalizedKind === 'grep' || normalizedKind === 'grep_search') { + return 'Grep'; + } else if (normalizedKind === 'glob') { + return 'Glob'; + } else if (normalizedKind === 'web_search') { + return 'WebSearch'; + } else { + return 'Search'; + } +}; + +/** + * Specialized component for Search tool calls + * Optimized for displaying search operations and results + */ export const SearchToolCall: React.FC = ({ toolCall, isFirst, @@ -137,20 +163,7 @@ export const SearchToolCall: React.FC = ({ }) => { const { kind, title, content, locations } = toolCall; const queryText = safeTitle(title); - - // Map tool call kind to appropriate display name - const getDisplayLabel = (): string => { - const normalizedKind = kind.toLowerCase(); - if (normalizedKind === 'grep' || normalizedKind === 'grep_search') { - return 'Grep'; - } else if (normalizedKind === 'glob') { - return 'Glob'; - } else if (normalizedKind === 'web_search') { - return 'WebSearch'; - } else { - return 'Search'; // fallback for other search-like tools - } - }; + const displayLabel = getDisplayLabel(kind); // Group content by type const { errors, textOutputs } = groupContent(content); @@ -159,7 +172,7 @@ export const SearchToolCall: React.FC = ({ if (errors.length > 0) { return ( - +
{queryText}
@@ -172,11 +185,11 @@ export const SearchToolCall: React.FC = ({ // Success case with results: show search query + file list if (locations && locations.length > 0) { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); - // If multiple results, use card layout; otherwise use compact format + // Multiple results use card layout if (locations.length > 1) { return ( - +
{queryText}
@@ -192,7 +205,7 @@ export const SearchToolCall: React.FC = ({ labelSuffix={`(${queryText})`} isFirst={isFirst} isLast={isLast} - displayLabel={getDisplayLabel()} + displayLabel={displayLabel} > @@ -200,7 +213,7 @@ export const SearchToolCall: React.FC = ({ ); } - // Show content text if available (e.g., "Listed 4 item(s).") + // Show content text if available if (textOutputs.length > 0) { const containerStatus = mapToolStatusToContainerStatus(toolCall.status); return ( @@ -209,7 +222,7 @@ export const SearchToolCall: React.FC = ({ labelSuffix={queryText ? `(${queryText})` : undefined} isFirst={isFirst} isLast={isLast} - displayLabel={getDisplayLabel()} + displayLabel={displayLabel} >
{textOutputs.map((text: string, index: number) => ( @@ -234,7 +247,7 @@ export const SearchToolCall: React.FC = ({ status={containerStatus} isFirst={isFirst} isLast={isLast} - displayLabel={getDisplayLabel()} + displayLabel={displayLabel} > {queryText} diff --git a/packages/webui/src/components/toolcalls/ThinkToolCall.stories.tsx b/packages/webui/src/components/toolcalls/ThinkToolCall.stories.tsx new file mode 100644 index 000000000..968b40b9f --- /dev/null +++ b/packages/webui/src/components/toolcalls/ThinkToolCall.stories.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ThinkToolCall } from './ThinkToolCall.js'; + +/** + * ThinkToolCall displays AI reasoning and thought processes. + * Shows thoughts in compact or card format based on content length. + */ +const meta: Meta = { + title: 'ToolCalls/ThinkToolCall', + component: ThinkToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const ShortThought: Story = { + args: { + toolCall: { + toolCallId: 'think-1', + kind: 'think', + title: 'Thinking', + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'User wants to refactor the auth module.', + }, + }, + ], + }, + }, +}; + +export const LongThought: Story = { + args: { + toolCall: { + toolCallId: 'think-2', + kind: 'think', + title: 'Thinking', + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'The user is asking about implementing a new authentication system. I need to consider several factors: 1) The current codebase uses JWT tokens for authentication. 2) They want to add OAuth2 support. 3) The existing user model needs to be extended. 4) We should maintain backward compatibility with the current API. Let me analyze the best approach for this refactoring task.', + }, + }, + ], + }, + }, +}; + +export const Loading: Story = { + args: { + toolCall: { + toolCallId: 'think-3', + kind: 'think', + title: 'Thinking', + status: 'in_progress', + content: [ + { + type: 'content', + content: { type: 'text', text: 'Analyzing the codebase...' }, + }, + ], + }, + }, +}; + +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'think-4', + kind: 'think', + title: 'Thinking', + status: 'failed', + content: [ + { + type: 'content', + content: { type: 'error', error: 'Memory save failed' }, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx similarity index 95% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx rename to packages/webui/src/components/toolcalls/ThinkToolCall.tsx index b7801e057..1bcf393a7 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Think/ThinkToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx @@ -12,8 +12,8 @@ import { ToolCallCard, ToolCallRow, groupContent, -} from '@qwen-code/webui'; -import type { BaseToolCallProps } from '@qwen-code/webui'; +} from './shared/index.js'; +import type { BaseToolCallProps } from './shared/index.js'; /** * Specialized component for Think tool calls diff --git a/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.stories.tsx b/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.stories.tsx new file mode 100644 index 000000000..6436400ce --- /dev/null +++ b/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.stories.tsx @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; + +/** + * UpdatedPlanToolCall displays plan/todo list updates with checkboxes. + */ +const meta: Meta = { + title: 'ToolCalls/UpdatedPlanToolCall', + component: UpdatedPlanToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const MixedStatus: Story = { + args: { + toolCall: { + toolCallId: 'plan-1', + kind: 'todo_write', + title: 'TodoWrite', + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: '- [x] Setup project structure\n- [-] Implement authentication\n- [ ] Add unit tests\n- [ ] Deploy to production', + }, + }, + ], + }, + }, +}; + +export const AllCompleted: Story = { + args: { + toolCall: { + toolCallId: 'plan-2', + kind: 'todo_write', + title: 'TodoWrite', + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: '- [x] Create component\n- [x] Add styles\n- [x] Write tests', + }, + }, + ], + }, + }, +}; + +export const AllPending: Story = { + args: { + toolCall: { + toolCallId: 'plan-3', + kind: 'todo_write', + title: 'TodoWrite', + status: 'completed', + content: [ + { + type: 'content', + content: { + type: 'text', + text: '- [ ] Research API options\n- [ ] Design database schema\n- [ ] Implement endpoints', + }, + }, + ], + }, + }, +}; + +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'plan-4', + kind: 'todo_write', + title: 'TodoWrite', + status: 'failed', + content: [ + { + type: 'content', + content: { type: 'error', error: 'Failed to update plan' }, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx b/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx similarity index 87% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx rename to packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx index fec2831e9..e07694874 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/UpdatedPlan/UpdatedPlanToolCall.tsx +++ b/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx @@ -7,18 +7,20 @@ */ import type React from 'react'; -import { groupContent, safeTitle } from '@qwen-code/webui'; +import { groupContent, safeTitle } from './shared/index.js'; import type { BaseToolCallProps, ToolCallContainerProps, ToolCallStatus, -} from '@qwen-code/webui'; + PlanEntry, + PlanEntryStatus, +} from './shared/index.js'; import { CheckboxDisplay } from './CheckboxDisplay.js'; -import type { PlanEntry } from '../../../../../types/chatTypes.js'; -type EntryStatus = 'pending' | 'in_progress' | 'completed'; - -export const ToolCallContainer: React.FC = ({ +/** + * Custom container for UpdatedPlanToolCall with specific styling + */ +const PlanToolCallContainer: React.FC = ({ label, status = 'success', children, @@ -47,6 +49,9 @@ export const ToolCallContainer: React.FC = ({
); +/** + * Map tool status to bullet status + */ const mapToolStatusToBullet = ( status: ToolCallStatus, ): 'success' | 'error' | 'warning' | 'loading' | 'default' => { @@ -64,7 +69,9 @@ const mapToolStatusToBullet = ( } }; -// Parse plan entries with - [ ] / - [x] from text as much as possible +/** + * Parse plan entries with - [ ] / - [x] from text + */ const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { const text = textOutputs.join('\n'); const lines = text.split(/\r?\n/); @@ -77,7 +84,7 @@ const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { if (m) { const mark = m[1]; const title = m[2].trim(); - const status: EntryStatus = + const status: PlanEntryStatus = mark === 'x' || mark === 'X' ? 'completed' : mark === '-' || mark === '*' @@ -89,7 +96,7 @@ const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { } } - // If no match is found, fall back to treating non-empty lines as pending items + // Fallback: treat non-empty lines as pending items if (entries.length === 0) { for (const line of lines) { const title = line.trim(); @@ -115,18 +122,17 @@ export const UpdatedPlanToolCall: React.FC = ({ // Error-first display if (errors.length > 0) { return ( - + {errors.join('\n')} - + ); } const entries = parsePlanEntries(textOutputs); - const label = safeTitle(toolCall.title) || 'TodoWrite'; return ( - = ({ indeterminate={isIndeterminate} /> -
= ({ ); })} - + ); }; diff --git a/packages/webui/src/components/toolcalls/WriteToolCall.stories.tsx b/packages/webui/src/components/toolcalls/WriteToolCall.stories.tsx new file mode 100644 index 000000000..d58816882 --- /dev/null +++ b/packages/webui/src/components/toolcalls/WriteToolCall.stories.tsx @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { WriteToolCall } from './WriteToolCall.js'; + +/** + * WriteToolCall displays file writing operations with line counts. + */ +const meta: Meta = { + title: 'ToolCalls/WriteToolCall', + component: WriteToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { + args: { + toolCall: { + toolCallId: 'write-1', + kind: 'write', + title: 'Write file', + status: 'completed', + rawInput: { content: 'line1\nline2\nline3\nline4\nline5' }, + locations: [{ path: 'src/new-file.ts' }], + }, + }, +}; + +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'write-2', + kind: 'write', + title: 'Write file', + status: 'failed', + rawInput: { content: 'const x = 1;' }, + content: [ + { + type: 'content', + content: { type: 'error', error: 'Permission denied' }, + }, + ], + locations: [{ path: '/etc/config.ts' }], + }, + }, +}; + +export const Loading: Story = { + args: { + toolCall: { + toolCallId: 'write-3', + kind: 'write', + title: 'Write file', + status: 'in_progress', + rawInput: { content: 'writing...' }, + locations: [{ path: 'src/output.ts' }], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx b/packages/webui/src/components/toolcalls/WriteToolCall.tsx similarity index 94% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx rename to packages/webui/src/components/toolcalls/WriteToolCall.tsx index ead5b234d..a940f07af 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Write/WriteToolCall.tsx +++ b/packages/webui/src/components/toolcalls/WriteToolCall.tsx @@ -9,11 +9,11 @@ import type React from 'react'; import { ToolCallContainer, - FileLink, groupContent, mapToolStatusToContainerStatus, -} from '@qwen-code/webui'; -import type { BaseToolCallProps } from '@qwen-code/webui'; +} from './shared/index.js'; +import type { BaseToolCallProps } from './shared/index.js'; +import { FileLink } from '../layout/FileLink.js'; /** * Specialized component for Write tool calls @@ -25,9 +25,6 @@ export const WriteToolCall: React.FC = ({ toolCall }) => { // Group content by type const { errors, textOutputs } = groupContent(content); - // Extract filename from path - // const getFileName = (path: string): string => path.split('/').pop() || path; - // Extract content to write from rawInput let writeContent = ''; if (rawInput && typeof rawInput === 'object') { diff --git a/packages/webui/src/components/toolcalls/index.ts b/packages/webui/src/components/toolcalls/index.ts index db76591aa..bd121c9af 100644 --- a/packages/webui/src/components/toolcalls/index.ts +++ b/packages/webui/src/components/toolcalls/index.ts @@ -6,3 +6,13 @@ // Re-export shared toolcall components and types export * from './shared/index.js'; + +// Business ToolCall components +export { ThinkToolCall } from './ThinkToolCall.js'; +export { GenericToolCall } from './GenericToolCall.js'; +export { EditToolCall } from './EditToolCall.js'; +export { WriteToolCall } from './WriteToolCall.js'; +export { SearchToolCall } from './SearchToolCall.js'; +export { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; +export { CheckboxDisplay } from './CheckboxDisplay.js'; +export type { CheckboxDisplayProps } from './CheckboxDisplay.js'; diff --git a/packages/webui/src/components/toolcalls/shared/CodeBlock.stories.tsx b/packages/webui/src/components/toolcalls/shared/CodeBlock.stories.tsx new file mode 100644 index 000000000..a71173a06 --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/CodeBlock.stories.tsx @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CodeBlock } from './LayoutComponents.js'; + +/** + * CodeBlock displays formatted code or command output. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/CodeBlock', + component: CodeBlock, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const ShortCode: Story = { + args: { + children: 'const greeting = "Hello, World!";', + }, +}; + +export const MultilineCode: Story = { + args: { + children: `function fibonacci(n) { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +console.log(fibonacci(10));`, + }, +}; + +export const CommandOutput: Story = { + args: { + children: `$ npm run build +> @qwen-code/webui@0.1.0 build +> vite build + +vite v5.4.21 building for production... +✓ 131 modules transformed. +✓ built in 2.34s`, + }, +}; diff --git a/packages/webui/src/components/toolcalls/shared/CopyButton.stories.tsx b/packages/webui/src/components/toolcalls/shared/CopyButton.stories.tsx new file mode 100644 index 000000000..57164c2ad --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/CopyButton.stories.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj, Decorator } from '@storybook/react-vite'; +import { CopyButton } from './copyUtils.js'; +import { PlatformProvider } from '../../../context/PlatformContext.js'; + +/** + * CopyButton displays a copy icon that copies text to clipboard. + * Note: Parent element needs 'group' class for hover effect. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/CopyButton', + component: CopyButton, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [ + ((Story) => ( + { + await navigator.clipboard.writeText(text); + }, + features: { canCopy: true }, + }} + > +
+ +
+
+ )) as Decorator, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + text: 'Hello, World!', + }, +}; + +export const WithLongText: Story = { + args: { + text: 'This is a longer piece of text that will be copied to the clipboard when the button is clicked.', + }, +}; diff --git a/packages/webui/src/components/toolcalls/shared/LocationsList.stories.tsx b/packages/webui/src/components/toolcalls/shared/LocationsList.stories.tsx new file mode 100644 index 000000000..2ef6a0e26 --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/LocationsList.stories.tsx @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { LocationsList } from './LayoutComponents.js'; + +/** + * LocationsList displays a list of file locations with clickable links. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/LocationsList', + component: LocationsList, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const SingleFile: Story = { + args: { + locations: [{ path: 'src/App.tsx', line: 10 }], + }, +}; + +export const MultipleFiles: Story = { + args: { + locations: [ + { path: 'src/App.tsx', line: 10 }, + { path: 'src/components/Header.tsx', line: 25 }, + { path: 'src/utils/helpers.ts', line: 42 }, + ], + }, +}; + +export const WithoutLineNumbers: Story = { + args: { + locations: [{ path: 'package.json' }, { path: 'tsconfig.json' }], + }, +}; diff --git a/packages/webui/src/components/toolcalls/shared/StatusIndicator.stories.tsx b/packages/webui/src/components/toolcalls/shared/StatusIndicator.stories.tsx new file mode 100644 index 000000000..534e926d3 --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/StatusIndicator.stories.tsx @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { StatusIndicator } from './LayoutComponents.js'; + +/** + * StatusIndicator displays a colored dot with status text. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/StatusIndicator', + component: StatusIndicator, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Pending: Story = { + args: { + status: 'pending', + text: 'Waiting to start', + }, +}; + +export const InProgress: Story = { + args: { + status: 'in_progress', + text: 'Processing...', + }, +}; + +export const Completed: Story = { + args: { + status: 'completed', + text: 'Done', + }, +}; + +export const Failed: Story = { + args: { + status: 'failed', + text: 'Error occurred', + }, +}; diff --git a/packages/webui/src/components/toolcalls/shared/ToolCallCard.stories.tsx b/packages/webui/src/components/toolcalls/shared/ToolCallCard.stories.tsx new file mode 100644 index 000000000..203403fde --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/ToolCallCard.stories.tsx @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ToolCallCard, ToolCallRow } from './LayoutComponents.js'; + +/** + * ToolCallCard is a card-style container for displaying detailed tool call results. + * Used when there's more content to show than fits in a compact container. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/ToolCallCard', + component: ToolCallCard, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: '🔧', + children: ( + +
Processing data...
+
+ ), + }, +}; + +export const WithMultipleRows: Story = { + args: { + icon: '📝', + children: ( + <> + +
src/components/App.tsx
+
+ +
+15 lines, -3 lines
+
+ + ), + }, +}; + +export const WithError: Story = { + args: { + icon: '❌', + children: ( + <> + +
npm run build
+
+ +
Build failed with 3 errors
+
+ + ), + }, +}; + +export const ThinkingCard: Story = { + args: { + icon: '💭', + children: ( + +
+ The user wants to refactor the authentication module... +
+
+ ), + }, +}; diff --git a/packages/webui/src/components/toolcalls/shared/ToolCallContainer.stories.tsx b/packages/webui/src/components/toolcalls/shared/ToolCallContainer.stories.tsx new file mode 100644 index 000000000..f25b0e22d --- /dev/null +++ b/packages/webui/src/components/toolcalls/shared/ToolCallContainer.stories.tsx @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ToolCallContainer } from './LayoutComponents.js'; + +/** + * ToolCallContainer is the base container for displaying tool call results. + * It shows a status indicator bullet and supports various status states. + */ +const meta: Meta = { + title: 'ToolCalls/Shared/ToolCallContainer', + component: ToolCallContainer, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + status: { + control: 'select', + options: ['success', 'error', 'warning', 'loading', 'default'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { + args: { + label: 'Read', + status: 'success', + children: 'src/components/App.tsx', + }, +}; + +export const Error: Story = { + args: { + label: 'Edit', + status: 'error', + children: 'File not found: /path/to/file.ts', + }, +}; + +export const Warning: Story = { + args: { + label: 'Write', + status: 'warning', + children: 'File already exists, will be overwritten', + }, +}; + +export const Loading: Story = { + args: { + label: 'Bash', + status: 'loading', + children: 'Running command...', + }, +}; + +export const Default: Story = { + args: { + label: 'Task', + status: 'default', + children: 'Processing task', + }, +}; + +export const WithLabelSuffix: Story = { + args: { + label: 'Grep', + status: 'success', + labelSuffix: '(pattern: "useState")', + children: 'Found 5 matches', + }, +}; diff --git a/packages/webui/src/components/toolcalls/shared/index.ts b/packages/webui/src/components/toolcalls/shared/index.ts index 784778aea..7c5a37224 100644 --- a/packages/webui/src/components/toolcalls/shared/index.ts +++ b/packages/webui/src/components/toolcalls/shared/index.ts @@ -38,4 +38,6 @@ export type { BaseToolCallProps, GroupedContent, ContainerStatus, + PlanEntryStatus, + PlanEntry, } from './types.js'; diff --git a/packages/webui/src/components/toolcalls/shared/types.ts b/packages/webui/src/components/toolcalls/shared/types.ts index 59026460b..01c200226 100644 --- a/packages/webui/src/components/toolcalls/shared/types.ts +++ b/packages/webui/src/components/toolcalls/shared/types.ts @@ -80,3 +80,16 @@ export type ContainerStatus = | 'warning' | 'loading' | 'default'; + +/** + * Plan entry status type + */ +export type PlanEntryStatus = 'pending' | 'in_progress' | 'completed'; + +/** + * Plan entry interface for UpdatedPlanToolCall + */ +export interface PlanEntry { + content: string; + status: PlanEntryStatus; +} diff --git a/packages/webui/src/context/PlatformContext.tsx b/packages/webui/src/context/PlatformContext.tsx index dd244c72b..2680e1dbc 100644 --- a/packages/webui/src/context/PlatformContext.tsx +++ b/packages/webui/src/context/PlatformContext.tsx @@ -38,6 +38,9 @@ export interface PlatformContextValue { /** Copy text to clipboard */ copyToClipboard?: (text: string) => Promise; + /** Get resource URL for platform-specific assets (e.g., icons) */ + getResourceUrl?: (resourceName: string) => string | undefined; + /** Platform-specific feature flags */ features?: { canOpenFile?: boolean; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 0a34aa505..908c12c03 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -27,6 +27,25 @@ export { default as Main } from './components/layout/Main'; export { default as Footer } from './components/layout/Footer'; export { FileLink } from './components/layout/FileLink'; export type { FileLinkProps } from './components/layout/FileLink'; +export { ChatHeader } from './components/layout/ChatHeader'; +export type { ChatHeaderProps } from './components/layout/ChatHeader'; +export { ContextIndicator } from './components/layout/ContextIndicator'; +export type { + ContextIndicatorProps, + ContextUsage, +} from './components/layout/ContextIndicator'; +export { CompletionMenu } from './components/layout/CompletionMenu'; +export type { CompletionMenuProps } from './components/layout/CompletionMenu'; +export { SessionSelector } from './components/layout/SessionSelector'; +export type { SessionSelectorProps } from './components/layout/SessionSelector'; +export { EmptyState } from './components/layout/EmptyState'; +export type { EmptyStateProps } from './components/layout/EmptyState'; +export { InputForm, getEditModeIcon } from './components/layout/InputForm'; +export type { + InputFormProps, + EditModeInfo, + EditModeIconType, +} from './components/layout/InputForm'; // Message components export { default as Message } from './components/messages/Message'; @@ -78,6 +97,14 @@ export { groupContent, hasToolCallOutput, mapToolStatusToContainerStatus, + // Business ToolCall components + ThinkToolCall, + GenericToolCall, + EditToolCall, + WriteToolCall, + SearchToolCall, + UpdatedPlanToolCall, + CheckboxDisplay, } from './components/toolcalls'; export type { ToolCallContainerProps, @@ -86,6 +113,8 @@ export type { BaseToolCallProps, GroupedContent, ContainerStatus, + PlanEntryStatus, + CheckboxDisplayProps, } from './components/toolcalls'; // Icons @@ -155,3 +184,8 @@ export type { ChatMessage, MessageRole, PlanEntry } from './types/chat'; export type { ToolCallContentItem, ToolCallUpdate } from './types/toolCall'; // Re-export ToolCallStatus and ToolCallLocation for backward compatibility export type { ToolCallStatus, ToolCallLocation } from './components/toolcalls'; +export type { CompletionItem, CompletionItemType } from './types/completion'; + +// Utils +export { groupSessionsByDate, getTimeAgo } from './utils/sessionGrouping'; +export type { SessionGroup } from './utils/sessionGrouping'; diff --git a/packages/webui/src/styles/variables.css b/packages/webui/src/styles/variables.css index 8ec989554..c4ea0c012 100644 --- a/packages/webui/src/styles/variables.css +++ b/packages/webui/src/styles/variables.css @@ -6,47 +6,150 @@ /** * Default CSS variables for @qwen-code/webui - * Consumers can override these variables to customize the theme. + * These provide fallback values when running outside of VSCode. + * In VSCode, these will be overridden by VSCode's design tokens. */ :root { - /* Primary colors */ + /* =========================== + Primary Colors + =========================== */ --app-primary: #3b82f6; --app-primary-hover: #2563eb; - --app-primary-foreground: #ffffff; + --app-primary-foreground: #e4e4e7; + --app-secondary-foreground: #a1a1aa; - /* Background colors */ - --app-background: #ffffff; - --app-background-secondary: #f3f4f6; - --app-background-tertiary: #e5e7eb; + /* =========================== + Background Colors + =========================== */ + --app-background: #1e1e1e; + --app-primary-background: #1e1e1e; + --app-background-secondary: #252526; + --app-background-tertiary: #2d2d2d; - /* Foreground/text colors */ - --app-foreground: #111827; - --app-foreground-secondary: #6b7280; - --app-foreground-muted: #9ca3af; + /* =========================== + Foreground/Text Colors + =========================== */ + --app-foreground: #e4e4e7; + --app-foreground-secondary: #a1a1aa; + --app-foreground-muted: #71717a; - /* Border colors */ - --app-border: #e5e7eb; + /* =========================== + Border Colors + =========================== */ + --app-border: #3f3f46; --app-border-focus: #3b82f6; + --app-primary-border-color: #3f3f46; - /* Status colors */ + /* =========================== + Status Colors + =========================== */ --app-success: #10b981; --app-warning: #f59e0b; --app-error: #ef4444; --app-info: #3b82f6; - /* Typography */ + /* =========================== + Typography + =========================== */ --app-font-sans: system-ui, -apple-system, sans-serif; - --app-font-mono: ui-monospace, monospace; + --app-font-mono: ui-monospace, 'SF Mono', monospace; - /* Border radius */ + /* =========================== + Border Radius + =========================== */ --app-radius-sm: 0.25rem; --app-radius-md: 0.375rem; --app-radius-lg: 0.5rem; + --corner-radius-small: 6px; + --corner-radius-medium: 8px; - /* Spacing */ + /* =========================== + Spacing + =========================== */ --app-spacing-xs: 0.25rem; --app-spacing-sm: 0.5rem; --app-spacing-md: 1rem; + --app-spacing-medium: 8px; --app-spacing-lg: 1.5rem; --app-spacing-xl: 2rem; + + /* =========================== + Input Styles + =========================== */ + --app-input-background: #3c3c3c; + --app-input-border: #3f3f46; + --app-input-placeholder-foreground: #71717a; + + /* =========================== + Button Styles + =========================== */ + --app-ghost-button-hover-background: rgba(90, 93, 94, 0.31); + + /* =========================== + Header Styles + =========================== */ + --app-header-background: #252526; + + /* =========================== + List Styles + =========================== */ + --app-list-padding: 0px; + --app-list-item-padding: 4px 8px; + --app-list-border-color: transparent; + --app-list-border-radius: 4px; + --app-list-hover-background: rgba(90, 93, 94, 0.31); + --app-list-active-background: #094771; + --app-list-active-foreground: #ffffff; + --app-list-gap: 2px; + + /* =========================== + Menu Styles + =========================== */ + --app-menu-background: #252526; + --app-menu-border: #454545; + --app-menu-foreground: #cccccc; + --app-menu-selection-background: #094771; + --app-menu-selection-foreground: #ffffff; + + /* =========================== + Tool Call Styles + =========================== */ + --app-tool-background: #1e1e1e; + --app-code-background: #2d2d2d; + + /* =========================== + Warning/Error Styles + =========================== */ + --app-warning-background: rgba(255, 204, 0, 0.1); + --app-warning-border: #ffcc00; + --app-warning-foreground: #ffcc00; +} + +/* Light theme override (can be toggled via class or media query) */ +@media (prefers-color-scheme: light) { + :root.auto-theme { + --app-primary-foreground: #1f2937; + --app-secondary-foreground: #6b7280; + --app-background: #ffffff; + --app-primary-background: #ffffff; + --app-background-secondary: #f3f4f6; + --app-background-tertiary: #e5e7eb; + --app-foreground: #1f2937; + --app-foreground-secondary: #6b7280; + --app-foreground-muted: #9ca3af; + --app-border: #e5e7eb; + --app-primary-border-color: #e5e7eb; + --app-input-background: #ffffff; + --app-input-border: #d1d5db; + --app-input-placeholder-foreground: #9ca3af; + --app-ghost-button-hover-background: rgba(0, 0, 0, 0.05); + --app-header-background: #f9fafb; + --app-list-hover-background: rgba(0, 0, 0, 0.05); + --app-list-active-background: #3b82f6; + --app-menu-background: #ffffff; + --app-menu-border: #e5e7eb; + --app-menu-foreground: #1f2937; + --app-tool-background: #ffffff; + --app-code-background: #f3f4f6; + } } diff --git a/packages/webui/src/types/completion.ts b/packages/webui/src/types/completion.ts new file mode 100644 index 000000000..ebecd1b89 --- /dev/null +++ b/packages/webui/src/types/completion.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Completion item types for autocomplete menus + */ + +import type React from 'react'; + +/** + * Completion item type categories + */ +export type CompletionItemType = + | 'file' + | 'folder' + | 'symbol' + | 'command' + | 'variable' + | 'info'; + +/** + * Completion item for autocomplete menus + */ +export interface CompletionItem { + /** Unique identifier */ + id: string; + /** Display label */ + label: string; + /** Optional description shown below label */ + description?: string; + /** Optional icon to display */ + icon?: React.ReactNode; + /** Type of completion item */ + type: CompletionItemType; + /** 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; +} diff --git a/packages/webui/src/utils/sessionGrouping.ts b/packages/webui/src/utils/sessionGrouping.ts new file mode 100644 index 000000000..fa0b41143 --- /dev/null +++ b/packages/webui/src/utils/sessionGrouping.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Session grouping utilities + * Functions for organizing sessions by date and formatting time ago + */ + +/** + * Session group structure + */ +export interface SessionGroup { + /** Group label (e.g., "Today", "Yesterday") */ + label: string; + /** Sessions in this group */ + sessions: Array>; +} + +/** + * Group sessions by date + * + * Categories: + * - Today: Sessions from today + * - Yesterday: Sessions from yesterday + * - This Week: Sessions from the last 7 days (excluding today/yesterday) + * - Older: Sessions older than a week + * + * @param sessions - Array of session objects (must have lastUpdated or startTime) + * @returns Array of grouped sessions, only includes non-empty groups + * + * @example + * ```ts + * const grouped = groupSessionsByDate(sessions); + * // [{ label: 'Today', sessions: [...] }, { label: 'Older', sessions: [...] }] + * ``` + */ +export const groupSessionsByDate = ( + sessions: Array>, +): SessionGroup[] => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const groups: { + [key: string]: Array>; + } = { + Today: [], + Yesterday: [], + 'This Week': [], + Older: [], + }; + + sessions.forEach((session) => { + const timestamp = + (session.lastUpdated as string) || (session.startTime as string) || ''; + if (!timestamp) { + groups['Older'].push(session); + return; + } + + const sessionDate = new Date(timestamp); + const sessionDay = new Date( + sessionDate.getFullYear(), + sessionDate.getMonth(), + sessionDate.getDate(), + ); + + if (sessionDay.getTime() === today.getTime()) { + groups['Today'].push(session); + } else if (sessionDay.getTime() === yesterday.getTime()) { + groups['Yesterday'].push(session); + } else if (sessionDay.getTime() > today.getTime() - 7 * 86400000) { + groups['This Week'].push(session); + } else { + groups['Older'].push(session); + } + }); + + return Object.entries(groups) + .filter(([, sessions]) => sessions.length > 0) + .map(([label, sessions]) => ({ label, sessions })); +}; + +/** + * Format timestamp as relative time string + * + * @param timestamp - ISO timestamp string + * @returns Formatted relative time (e.g., "now", "5m", "2h", "Yesterday", "3d", or date) + * + * @example + * ```ts + * getTimeAgo(new Date().toISOString()) // "now" + * getTimeAgo(thirtyMinutesAgo.toISOString()) // "30m" + * getTimeAgo(twoHoursAgo.toISOString()) // "2h" + * ``` + */ +export const getTimeAgo = (timestamp: string): string => { + if (!timestamp) { + return ''; + } + const now = new Date().getTime(); + const then = new Date(timestamp).getTime(); + const diffMs = now - then; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) { + return 'now'; + } + if (diffMins < 60) { + return `${diffMins}m`; + } + if (diffHours < 24) { + return `${diffHours}h`; + } + if (diffDays === 1) { + return 'Yesterday'; + } + if (diffDays < 7) { + return `${diffDays}d`; + } + return new Date(timestamp).toLocaleDateString(); +}; From 07c3d487bbd02137b847303d477458e5a983568c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 16 Jan 2026 19:48:44 +0800 Subject: [PATCH 016/113] refactor(vscode-ide-companion/webui): migrate PermissionDrawer to shared webui package - Move PermissionDrawer component and types from vscode-ide-companion to webui - Add PermissionDrawer Storybook story for documentation - Export PermissionOption and PermissionToolCall types from webui - Update VSCodePlatformProvider with openDiff and openTempFile handlers - Remove duplicated components: PermissionDrawer, FileLink, types, copyUtils - Update ToolCall components to use usePlatform hook instead of useVSCode - Add animation keyframes to tailwind preset for slide-up effect --- .../vscode-ide-companion/src/webview/App.tsx | 7 +- .../PermissionDrawer/PermissionDrawer.tsx | 312 --------------- .../PermissionDrawer/PermissionRequest.tsx | 37 -- .../webview/components/layout/FileLink.tsx | 11 - .../src/webview/components/messages/index.tsx | 12 - .../messages/toolcalls/Read/ReadToolCall.tsx | 23 +- .../toolcalls/Shell/ShellToolCall.tsx | 31 +- .../messages/toolcalls/ToolCall.tsx | 10 +- .../toolcalls/shared/LayoutComponents.tsx | 19 - .../messages/toolcalls/shared/copyUtils.tsx | 10 - .../messages/toolcalls/shared/types.ts | 18 - .../context/VSCodePlatformProvider.tsx | 44 ++- .../src/webview/hooks/useWebViewMessages.ts | 5 +- .../components/PermissionDrawer.stories.tsx | 81 ++++ .../webui/src/components/PermissionDrawer.tsx | 364 ++++++++++++++---- .../webui/src/context/PlatformContext.tsx | 12 + packages/webui/src/index.ts | 8 + packages/webui/tailwind.preset.cjs | 19 + 18 files changed, 505 insertions(+), 518 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/index.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx delete mode 100644 packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts create mode 100644 packages/webui/src/components/PermissionDrawer.stories.tsx diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index f22d9bf97..6a825627f 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -19,13 +19,9 @@ import { useMessageHandling } from './hooks/message/useMessageHandling.js'; import { useToolCalls } from './hooks/useToolCalls.js'; import { useWebViewMessages } from './hooks/useWebViewMessages.js'; import { useMessageSubmit } from './hooks/useMessageSubmit.js'; -import type { - PermissionOption, - ToolCall as PermissionToolCall, -} from './components/PermissionDrawer/PermissionRequest.js'; +import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui'; import type { TextMessage } from './hooks/message/useMessageHandling.js'; import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; -import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js'; import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; import { hasToolCallOutput } from './utils/utils.js'; import { Onboarding } from './components/layout/Onboarding.js'; @@ -39,6 +35,7 @@ import { InterruptedMessage, FileIcon, UserIcon, + PermissionDrawer, // Layout components imported directly from webui EmptyState, ChatHeader, diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx deleted file mode 100644 index 00e5bccaa..000000000 --- a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionDrawer.tsx +++ /dev/null @@ -1,312 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useEffect, useState, useRef } from 'react'; -import type { PermissionOption, ToolCall } from './PermissionRequest.js'; - -interface PermissionDrawerProps { - isOpen: boolean; - options: PermissionOption[]; - toolCall: ToolCall; - onResponse: (optionId: string) => void; - onClose?: () => void; -} - -export const PermissionDrawer: React.FC = ({ - isOpen, - options, - toolCall, - onResponse, - onClose, -}) => { - const [focusedIndex, setFocusedIndex] = useState(0); - const [customMessage, setCustomMessage] = useState(''); - const containerRef = useRef(null); - // Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting - const customInputRef = useRef(null); - - console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall); - // Prefer file name from locations, fall back to content[].path if present - const getAffectedFileName = (): string => { - const fromLocations = toolCall.locations?.[0]?.path; - if (fromLocations) { - return fromLocations.split('/').pop() || fromLocations; - } - // Some tool calls (e.g. write/edit with diff content) only include path in content - const fromContent = Array.isArray(toolCall.content) - ? ( - toolCall.content.find( - (c: unknown) => - typeof c === 'object' && - c !== null && - 'path' in (c as Record), - ) as { path?: unknown } | undefined - )?.path - : undefined; - if (typeof fromContent === 'string' && fromContent.length > 0) { - return fromContent.split('/').pop() || fromContent; - } - return 'file'; - }; - - // Get the title for the permission request - const getTitle = () => { - if (toolCall.kind === 'edit' || toolCall.kind === 'write') { - const fileName = getAffectedFileName(); - return ( - <> - Make this edit to{' '} - - {fileName} - - ? - - ); - } - if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { - return 'Allow this bash command?'; - } - if (toolCall.kind === 'read') { - const fileName = getAffectedFileName(); - return ( - <> - Allow read from{' '} - - {fileName} - - ? - - ); - } - return toolCall.title || 'Permission Required'; - }; - - // Handle keyboard navigation - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!isOpen) { - return; - } - - // Number keys 1-9 for quick select - const numMatch = e.key.match(/^[1-9]$/); - if ( - numMatch && - !customInputRef.current?.contains(document.activeElement) - ) { - const index = parseInt(e.key, 10) - 1; - if (index < options.length) { - e.preventDefault(); - onResponse(options[index].optionId); - } - return; - } - - // Arrow keys for navigation - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - const totalItems = options.length + 1; // +1 for custom input - if (e.key === 'ArrowDown') { - setFocusedIndex((prev) => (prev + 1) % totalItems); - } else { - setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); - } - } - - // Enter to select - if ( - e.key === 'Enter' && - !customInputRef.current?.contains(document.activeElement) - ) { - e.preventDefault(); - if (focusedIndex < options.length) { - onResponse(options[focusedIndex].optionId); - } - } - - // Escape to cancel permission and close (align with CLI behavior) - if (e.key === 'Escape') { - e.preventDefault(); - const rejectOptionId = - options.find((o) => o.kind.includes('reject'))?.optionId || - options.find((o) => o.optionId === 'cancel')?.optionId || - 'cancel'; - onResponse(rejectOptionId); - if (onClose) { - onClose(); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, options, onResponse, onClose, focusedIndex]); - - // Focus container when opened - useEffect(() => { - if (isOpen && containerRef.current) { - containerRef.current.focus(); - } - }, [isOpen]); - - // Reset focus to the first option when the drawer opens or the options change - useEffect(() => { - if (isOpen) { - setFocusedIndex(0); - } - }, [isOpen, options.length]); - - if (!isOpen) { - return null; - } - - return ( -
- {/* Main container */} -
- {/* Background layer */} -
- - {/* Title + Description (from toolCall.title) */} -
-
- {getTitle()} -
- {(toolCall.kind === 'edit' || - toolCall.kind === 'write' || - toolCall.kind === 'read' || - toolCall.kind === 'execute' || - toolCall.kind === 'bash') && - toolCall.title && ( -
- {toolCall.title} -
- )} -
- - {/* Options */} -
- {options.map((option, index) => { - const isFocused = focusedIndex === index; - - return ( - - ); - })} - - {/* Custom message input (extracted component) */} - {(() => { - const isFocused = focusedIndex === options.length; - const rejectOptionId = options.find((o) => - o.kind.includes('reject'), - )?.optionId; - return ( - setFocusedIndex(options.length)} - onSubmitReject={() => { - if (rejectOptionId) { - onResponse(rejectOptionId); - } - }} - inputRef={customInputRef} - /> - ); - })()} -
-
- - {/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */} -
- ); -}; - -/** - * CustomMessageInputRow: Reusable custom input row component (without hooks) - */ -interface CustomMessageInputRowProps { - isFocused: boolean; - customMessage: string; - setCustomMessage: (val: string) => void; - onFocusRow: () => void; // Set focus when mouse enters or input box is focused - onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option) - inputRef: React.RefObject; -} - -const CustomMessageInputRow: React.FC = ({ - isFocused, - customMessage, - setCustomMessage, - onFocusRow, - onSubmitReject, - inputRef, -}) => ( -
inputRef.current?.focus()} - > - | undefined} - type="text" - placeholder="Tell Qwen what to do instead" - spellCheck={false} - className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70" - style={{ color: 'var(--app-input-foreground)' }} - value={customMessage} - onChange={(e) => setCustomMessage(e.target.value)} - onFocus={onFocusRow} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { - e.preventDefault(); - onSubmitReject(); - } - }} - /> -
-); diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx deleted file mode 100644 index a7b7356cb..000000000 --- a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer/PermissionRequest.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface PermissionOption { - name: string; - kind: string; - optionId: string; -} - -export interface ToolCall { - title?: string; - kind?: string; - toolCallId?: string; - rawInput?: { - command?: string; - description?: string; - [key: string]: unknown; - }; - content?: Array<{ - type: string; - [key: string]: unknown; - }>; - locations?: Array<{ - path: string; - line?: number | null; - }>; - status?: string; -} - -export interface PermissionRequestProps { - options: PermissionOption[]; - toolCall: ToolCall; - onResponse: (optionId: string) => void; -} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx b/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx deleted file mode 100644 index e46ba943c..000000000 --- a/packages/vscode-ide-companion/src/webview/components/layout/FileLink.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * FileLink component - Clickable file path links - * Now re-exports from @qwen-code/webui for backward compatibility - */ - -export { FileLink } from '@qwen-code/webui'; -export type { FileLinkProps } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/index.tsx deleted file mode 100644 index a6eb82f61..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -// All message components are now imported from @qwen-code/webui: -// - AssistantMessage -// - UserMessage -// - ThinkingMessage -// - WaitingMessage -// - InterruptedMessage diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx index 93a8e3626..c494e0b3c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx @@ -12,13 +12,12 @@ import { FileLink, groupContent, mapToolStatusToContainerStatus, + usePlatform, } from '@qwen-code/webui'; import type { BaseToolCallProps, ToolCallContainerProps, } from '@qwen-code/webui'; -import { useVSCode } from '../../../../hooks/useVSCode.js'; -import { handleOpenDiff } from '../../../../utils/diffUtils.js'; export const ToolCallContainer: React.FC = ({ label, @@ -56,7 +55,7 @@ export const ToolCallContainer: React.FC = ({ */ export const ReadToolCall: React.FC = ({ toolCall }) => { const { kind, content, locations, toolCallId } = toolCall; - const vscode = useVSCode(); + const platform = usePlatform(); // Map tool call kind to appropriate display name const getDisplayLabel = (): string => { @@ -82,9 +81,23 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { oldText: string | null | undefined, newText: string | undefined, ) => { - handleOpenDiff(vscode, path, oldText, newText); + if (!path) { + return; + } + if (platform.openDiff) { + platform.openDiff(path, oldText, newText); + return; + } + platform.postMessage({ + type: 'openDiff', + data: { + path, + oldText: oldText ?? '', + newText: newText ?? '', + }, + }); }, - [vscode], + [platform], ); // Auto-open diff when a read call returns diff content. diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx index fc595555b..a488d2603 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx @@ -12,13 +12,12 @@ import { CopyButton, safeTitle, groupContent, + usePlatform, } from '@qwen-code/webui'; import type { BaseToolCallProps, ToolCallContainerProps, } from '@qwen-code/webui'; -import { useVSCode } from '../../../../hooks/useVSCode.js'; -import { createAndOpenTempFile } from '../../../../utils/diffUtils.js'; import './ShellToolCall.css'; @@ -89,9 +88,23 @@ const ShellToolCallImpl: React.FC< > = ({ toolCall, variant }) => { const { title, content, rawInput, toolCallId } = toolCall; const classPrefix = variant; + const platform = usePlatform(); + + const openTempFile = (content: string, fileName: string) => { + if (platform.openTempFile) { + platform.openTempFile(content, fileName); + return; + } + platform.postMessage({ + type: 'createAndOpenTempFile', + data: { + content, + fileName, + }, + }); + }; const commandText = getCommandText(variant, title, rawInput); const inputCommand = getInputCommand(commandText, rawInput); - const vscode = useVSCode(); const Container = variant === 'execute' ? ExecuteToolCallContainer : SharedToolCallContainer; @@ -101,22 +114,14 @@ const ShellToolCallImpl: React.FC< // Handle click on IN section const handleInClick = () => { - createAndOpenTempFile( - vscode, - inputCommand, - `${classPrefix}-input-${toolCallId}`, - ); + openTempFile(inputCommand, `${classPrefix}-input-${toolCallId}`); }; // Handle click on OUT section const handleOutClick = () => { if (textOutputs.length > 0) { const output = textOutputs.join('\n'); - createAndOpenTempFile( - vscode, - output, - `${classPrefix}-output-${toolCallId}`, - ); + openTempFile(output, `${classPrefix}-output-${toolCallId}`); } }; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx index 6cda54a2f..2890cb51b 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx @@ -10,18 +10,18 @@ */ import type React from 'react'; +import type { ToolCallData } from '@qwen-code/webui'; import { ToolCallRouter } from './index.js'; -// Re-export types from the toolcalls module for backward compatibility +// Re-export types from webui for backward compatibility export type { ToolCallData, BaseToolCallProps as ToolCallProps, -} from './shared/types.js'; + ToolCallContent, +} from '@qwen-code/webui'; -// Re-export the content type for external use -export type { ToolCallContent } from './shared/types.js'; export const ToolCall: React.FC<{ - toolCall: import('./shared/types.js').ToolCallData; + toolCall: ToolCallData; isFirst?: boolean; isLast?: boolean; }> = ({ toolCall, isFirst, isLast }) => ( diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx deleted file mode 100644 index 48003815c..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/LayoutComponents.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Shared layout components for tool call UI - * Now re-exports from @qwen-code/webui for backward compatibility - */ - -// Re-export all layout components from webui -export { - ToolCallContainer, - ToolCallCard, - ToolCallRow, - StatusIndicator, - CodeBlock, - LocationsList, -} from '@qwen-code/webui'; -export type { ToolCallContainerProps } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx deleted file mode 100644 index 600a3ca6c..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/copyUtils.tsx +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Shared copy utilities for toolcall components - * Now re-exports from @qwen-code/webui for backward compatibility - */ - -export { handleCopyToClipboard, CopyButton } from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts deleted file mode 100644 index 36b10b135..000000000 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/shared/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Shared types for tool call components - * Now re-exports from @qwen-code/webui for backward compatibility - */ - -export type { - ToolCallContent, - ToolCallLocation, - ToolCallStatus, - ToolCallData, - BaseToolCallProps, - GroupedContent, - ContainerStatus, -} from '@qwen-code/webui'; diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx index 5a2ed8c6c..da128f381 100644 --- a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx +++ b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx @@ -56,6 +56,39 @@ export const VSCodePlatformProvider: React.FC = ({ [vscode], ); + // Open diff handler + const openDiff = useCallback( + ( + path: string, + oldText: string | null | undefined, + newText: string | undefined, + ) => { + vscode.postMessage({ + type: 'openDiff', + data: { + path, + oldText: oldText ?? '', + newText: newText ?? '', + }, + }); + }, + [vscode], + ); + + // Open temp file handler + const openTempFile = useCallback( + (content: string, fileName: string = 'temp') => { + vscode.postMessage({ + type: 'createAndOpenTempFile', + data: { + content, + fileName, + }, + }); + }, + [vscode], + ); + // Attach file handler const attachFile = useCallback(() => { vscode.postMessage({ @@ -82,7 +115,10 @@ export const VSCodePlatformProvider: React.FC = ({ }, []); // Get resource URL handler (for icons and other assets) - const getResourceUrl = useCallback((resourceName: string) => generateIconUrl(resourceName) || undefined, []); + const getResourceUrl = useCallback( + (resourceName: string) => generateIconUrl(resourceName) || undefined, + [], + ); // Subscribe to messages const onMessage = useCallback((handler: (message: unknown) => void) => { @@ -99,12 +135,16 @@ export const VSCodePlatformProvider: React.FC = ({ postMessage: vscode.postMessage, onMessage, openFile, + openDiff, + openTempFile, attachFile, login, copyToClipboard, getResourceUrl, features: { canOpenFile: true, + canOpenDiff: true, + canOpenTempFile: true, canAttachFile: true, canLogin: true, canCopy: true, @@ -114,6 +154,8 @@ export const VSCodePlatformProvider: React.FC = ({ vscode.postMessage, onMessage, openFile, + openDiff, + openTempFile, attachFile, login, copyToClipboard, diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 9995d095c..9d84e46f7 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -7,10 +7,7 @@ import { useEffect, useRef, useCallback } from 'react'; import { useVSCode } from './useVSCode.js'; import type { Conversation } from '../../services/conversationStore.js'; -import type { - PermissionOption, - ToolCall as PermissionToolCall, -} from '../components/PermissionDrawer/PermissionRequest.js'; +import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui'; import type { ToolCallUpdate, UsageStatsPayload, diff --git a/packages/webui/src/components/PermissionDrawer.stories.tsx b/packages/webui/src/components/PermissionDrawer.stories.tsx new file mode 100644 index 000000000..82cbd41b1 --- /dev/null +++ b/packages/webui/src/components/PermissionDrawer.stories.tsx @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import PermissionDrawer from './PermissionDrawer.js'; +import type { + PermissionOption, + PermissionToolCall, +} from './PermissionDrawer.js'; + +const options: PermissionOption[] = [ + { + name: 'Allow once', + kind: 'approve_once', + optionId: 'allow-once', + }, + { + name: 'Always allow', + kind: 'approve_always', + optionId: 'allow-always', + }, + { + name: 'Deny', + kind: 'reject', + optionId: 'deny', + }, +]; + +const toolCall: PermissionToolCall = { + kind: 'edit', + title: 'Edit src/components/PermissionDrawer.tsx', + locations: [ + { + path: 'src/components/PermissionDrawer.tsx', + line: 42, + }, + ], +}; + +const meta: Meta = { + title: 'Components/PermissionDrawer', + component: PermissionDrawer, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+ { + console.log('[PermissionDrawer story] response:', optionId); + setIsOpen(false); + }} + onClose={() => setIsOpen(false)} + /> +
+ ); + }, +}; diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx index e90fcde11..0461dfa52 100644 --- a/packages/webui/src/components/PermissionDrawer.tsx +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -5,95 +5,327 @@ */ import type React from 'react'; -import { useState, useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; -interface PermissionDrawerProps { +export interface PermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface PermissionToolCall { + title?: string; + kind?: string; + toolCallId?: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + status?: string; +} + +export interface PermissionDrawerProps { isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - permissions: string[]; + options: PermissionOption[]; + toolCall: PermissionToolCall; + onResponse: (optionId: string) => void; + onClose?: () => void; } const PermissionDrawer: React.FC = ({ isOpen, + options, + toolCall, + onResponse, onClose, - onConfirm, - permissions, }) => { - const [checkedPermissions, setCheckedPermissions] = useState( - Array(permissions.length).fill(false), - ); + const [focusedIndex, setFocusedIndex] = useState(0); + const [customMessage, setCustomMessage] = useState(''); + const containerRef = useRef(null); + const customInputRef = useRef(null); - useEffect(() => { - if (!isOpen) { - setCheckedPermissions(Array(permissions.length).fill(false)); + // Prefer file name from locations, fall back to content[].path if present + const getAffectedFileName = (): string => { + const fromLocations = toolCall.locations?.[0]?.path; + if (fromLocations) { + return fromLocations.split('/').pop() || fromLocations; } - }, [isOpen, permissions]); - - const handleTogglePermission = (index: number) => { - const newChecked = [...checkedPermissions]; - newChecked[index] = !newChecked[index]; - setCheckedPermissions(newChecked); + // Some tool calls (e.g. write/edit with diff content) only include path in content + const fromContent = Array.isArray(toolCall.content) + ? ( + toolCall.content.find( + (c: unknown) => + typeof c === 'object' && + c !== null && + 'path' in (c as Record), + ) as { path?: unknown } | undefined + )?.path + : undefined; + if (typeof fromContent === 'string' && fromContent.length > 0) { + return fromContent.split('/').pop() || fromContent; + } + return 'file'; }; - const handleConfirm = () => { - onConfirm(); - onClose(); + // Get the title for the permission request + const getTitle = () => { + if (toolCall.kind === 'edit' || toolCall.kind === 'write') { + const fileName = getAffectedFileName(); + return ( + <> + Make this edit to{' '} + + {fileName} + + ? + + ); + } + if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { + return 'Allow this bash command?'; + } + if (toolCall.kind === 'read') { + const fileName = getAffectedFileName(); + return ( + <> + Allow read from{' '} + + {fileName} + + ? + + ); + } + return toolCall.title || 'Permission Required'; }; - if (!isOpen) return null; + // Handle keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) { + return; + } + + // Number keys 1-9 for quick select + const numMatch = e.key.match(/^[1-9]$/); + if ( + numMatch && + !customInputRef.current?.contains(document.activeElement) + ) { + const index = parseInt(e.key, 10) - 1; + if (index < options.length) { + e.preventDefault(); + onResponse(options[index].optionId); + } + return; + } + + // Arrow keys for navigation + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const totalItems = options.length + 1; // +1 for custom input + if (e.key === 'ArrowDown') { + setFocusedIndex((prev) => (prev + 1) % totalItems); + } else { + setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems); + } + } + + // Enter to select + if ( + e.key === 'Enter' && + !customInputRef.current?.contains(document.activeElement) + ) { + e.preventDefault(); + if (focusedIndex < options.length) { + onResponse(options[focusedIndex].optionId); + } + } + + // Escape to cancel permission and close (align with CLI behavior) + if (e.key === 'Escape') { + e.preventDefault(); + const rejectOptionId = + options.find((o) => o.kind.includes('reject'))?.optionId || + options.find((o) => o.optionId === 'cancel')?.optionId || + 'cancel'; + onResponse(rejectOptionId); + if (onClose) { + onClose(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, options, onResponse, onClose, focusedIndex]); + + // Focus container when opened + useEffect(() => { + if (isOpen && containerRef.current) { + containerRef.current.focus(); + } + }, [isOpen]); + + // Reset focus to the first option when the drawer opens or the options change + useEffect(() => { + if (isOpen) { + setFocusedIndex(0); + } + }, [isOpen, options.length]); + + if (!isOpen) { + return null; + } return ( -
-
-
-

Permissions Required

- +
+ {/* Main container */} +
+ {/* Background layer */} +
+ + {/* Title + Description (from toolCall.title) */} +
+
+ {getTitle()} +
+ {(toolCall.kind === 'edit' || + toolCall.kind === 'write' || + toolCall.kind === 'read' || + toolCall.kind === 'execute' || + toolCall.kind === 'bash') && + toolCall.title && ( +
+ {toolCall.title} +
+ )}
-
-
    - {permissions.map((permission, index) => ( -
  • - handleTogglePermission(index)} - className="mr-2 h-4 w-4" - /> - {permission} -
  • - ))} -
-
+ {/* Options */} +
+ {options.map((option, index) => { + const isFocused = focusedIndex === index; -
- - + return ( + + ); + })} + + {(() => { + const isFocused = focusedIndex === options.length; + const rejectOptionId = options.find((o) => + o.kind.includes('reject'), + )?.optionId; + return ( + setFocusedIndex(options.length)} + onSubmitReject={() => { + if (rejectOptionId) { + onResponse(rejectOptionId); + } + }} + inputRef={customInputRef} + /> + ); + })()}
); }; +interface CustomMessageInputRowProps { + isFocused: boolean; + customMessage: string; + setCustomMessage: (val: string) => void; + onFocusRow: () => void; + onSubmitReject: () => void; + inputRef: React.RefObject; +} + +const CustomMessageInputRow: React.FC = ({ + isFocused, + customMessage, + setCustomMessage, + onFocusRow, + onSubmitReject, + inputRef, +}) => ( +
inputRef.current?.focus()} + > + | undefined} + type="text" + placeholder="Tell Qwen what to do instead" + spellCheck={false} + className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70" + style={{ color: 'var(--app-input-foreground)' }} + value={customMessage} + onChange={(e) => setCustomMessage(e.target.value)} + onFocus={onFocusRow} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + onSubmitReject(); + } + }} + /> +
+); + export default PermissionDrawer; diff --git a/packages/webui/src/context/PlatformContext.tsx b/packages/webui/src/context/PlatformContext.tsx index 2680e1dbc..9a48baca5 100644 --- a/packages/webui/src/context/PlatformContext.tsx +++ b/packages/webui/src/context/PlatformContext.tsx @@ -29,6 +29,16 @@ export interface PlatformContextValue { /** Open a file in the platform's editor (optional) */ openFile?: (path: string) => void; + /** Open a diff view for a file (optional) */ + openDiff?: ( + path: string, + oldText: string | null | undefined, + newText: string | undefined, + ) => void; + + /** Open a temporary file with given content (optional) */ + openTempFile?: (content: string, fileName?: string) => void; + /** Trigger file attachment dialog (optional) */ attachFile?: () => void; @@ -44,6 +54,8 @@ export interface PlatformContextValue { /** Platform-specific feature flags */ features?: { canOpenFile?: boolean; + canOpenDiff?: boolean; + canOpenTempFile?: boolean; canAttachFile?: boolean; canLogin?: boolean; canCopy?: boolean; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 908c12c03..be6d8d6ee 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +// eslint-disable-next-line import/no-internal-modules +import './styles/variables.css'; + // Shared UI Components Export // Export all shared components from this package @@ -78,6 +81,11 @@ export type { TooltipProps } from './components/ui/Tooltip'; // Permission components export { default as PermissionDrawer } from './components/PermissionDrawer'; +export type { + PermissionDrawerProps, + PermissionOption, + PermissionToolCall, +} from './components/PermissionDrawer'; // ToolCall shared components export { diff --git a/packages/webui/tailwind.preset.cjs b/packages/webui/tailwind.preset.cjs index 21aa7eca0..f09c1f5f8 100644 --- a/packages/webui/tailwind.preset.cjs +++ b/packages/webui/tailwind.preset.cjs @@ -66,6 +66,25 @@ module.exports = { 'app-lg': 'var(--app-spacing-lg, 1.5rem)', 'app-xl': 'var(--app-spacing-xl, 2rem)', }, + keyframes: { + 'completion-menu-enter': { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + 'pulse-slow': { + '0%, 100%': { opacity: '1' }, + '50%': { opacity: '0.5' }, + }, + 'slide-up': { + '0%': { transform: 'translateY(100%)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + animation: { + 'completion-menu-enter': 'completion-menu-enter 150ms ease-out both', + 'pulse-slow': 'pulse-slow 1.5s ease-in-out infinite', + 'slide-up': 'slide-up 200ms ease-out both', + }, }, }, }; From 9a47ad5e628eafc76f6bc58def337a1a1af03edd Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Fri, 16 Jan 2026 23:32:20 +0800 Subject: [PATCH 017/113] refactor(webui): migrate ReadToolCall, ShellToolCall and Onboarding to shared package - Move ReadToolCall and ShellToolCall from vscode-ide-companion to webui - Migrate Onboarding component with platform-agnostic props (iconUrl, appName, etc.) - Add Storybook stories for ReadToolCall, ShellToolCall, and Onboarding - Add shared styles: components.css (toolcall components), timeline.css - Update vscode-ide-companion to use webui components via imports - Remove duplicate InputForm type re-exports - Add WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md for platform adapter documentation - Update toolcalls/index.tsx to route read_many_files/list_directory/ls to ReadToolCall --- .../webview/components/layout/InputForm.tsx | 4 - .../webview/components/layout/Onboarding.tsx | 43 +--- .../components/messages/toolcalls/index.tsx | 11 +- .../webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md | 121 ++++++++++++ .../components/layout/Onboarding.stories.tsx | 69 +++++++ .../src/components/layout/Onboarding.tsx | 70 +++++++ .../toolcalls/ReadToolCall.stories.tsx | 155 +++++++++++++++ .../components/toolcalls}/ReadToolCall.tsx | 104 +++++----- .../components/toolcalls}/ShellToolCall.css | 13 +- .../toolcalls/ShellToolCall.stories.tsx | 183 ++++++++++++++++++ .../components/toolcalls}/ShellToolCall.tsx | 34 +++- .../webui/src/components/toolcalls/index.ts | 2 + packages/webui/src/index.ts | 8 + packages/webui/src/styles/components.css | 148 ++++++++++++++ packages/webui/src/styles/timeline.css | 142 ++++++++++++++ packages/webui/src/styles/variables.css | 25 ++- 16 files changed, 1029 insertions(+), 103 deletions(-) create mode 100644 packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md create mode 100644 packages/webui/src/components/layout/Onboarding.stories.tsx create mode 100644 packages/webui/src/components/layout/Onboarding.tsx create mode 100644 packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx rename packages/{vscode-ide-companion/src/webview/components/messages/toolcalls/Read => webui/src/components/toolcalls}/ReadToolCall.tsx (66%) rename packages/{vscode-ide-companion/src/webview/components/messages/toolcalls/Shell => webui/src/components/toolcalls}/ShellToolCall.css (91%) create mode 100644 packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx rename packages/{vscode-ide-companion/src/webview/components/messages/toolcalls/Shell => webui/src/components/toolcalls}/ShellToolCall.tsx (90%) create mode 100644 packages/webui/src/styles/components.css create mode 100644 packages/webui/src/styles/timeline.css diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 3c04525c2..2761ace87 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -16,10 +16,6 @@ import type { import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; -// Re-export base types for convenience -export type { EditModeInfo, EditModeIconType } from '@qwen-code/webui'; -export { getEditModeIcon } from '@qwen-code/webui'; - /** * Extended props that accept ApprovalModeValue */ diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index 2eddc4d39..79c8791f2 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -2,48 +2,25 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * VSCode-specific Onboarding adapter + * Uses webui Onboarding component with platform-specific icon URL */ +import type React from 'react'; +import { Onboarding as BaseOnboarding } from '@qwen-code/webui'; import { generateIconUrl } from '../../utils/resourceUrl.js'; interface OnboardingPageProps { onLogin: () => void; } +/** + * VSCode Onboarding wrapper + * Provides platform-specific icon URL to the webui Onboarding component + */ export const Onboarding: React.FC = ({ onLogin }) => { const iconUri = generateIconUrl('icon.png'); - return ( -
-
-
- {/* Application icon container */} -
- Qwen Code Logo -
- -
-

- Welcome to Qwen Code -

-

- Unlock the power of AI to understand, navigate, and transform your - codebase faster than ever before. -

-
- - -
-
-
- ); + return ; }; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index f557084b5..24741a567 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -4,23 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 * * Tool call component factory - routes to specialized components by kind + * All UI components are now imported from @qwen-code/webui */ import type React from 'react'; import { shouldShowToolCall, - // Pure UI ToolCall components from webui + // All ToolCall components from webui GenericToolCall, ThinkToolCall, EditToolCall, WriteToolCall, SearchToolCall, UpdatedPlanToolCall, + ShellToolCall, + ReadToolCall, } from '@qwen-code/webui'; import type { BaseToolCallProps } from '@qwen-code/webui'; -// VSCode-specific components (have platform dependencies) -import { ReadToolCall } from './Read/ReadToolCall.js'; -import { ShellToolCall } from './Shell/ShellToolCall.js'; /** * Factory function that returns the appropriate tool call component based on kind @@ -33,6 +33,9 @@ export const getToolCallComponent = ( // Route to specialized components switch (normalizedKind) { case 'read': + case 'read_many_files': + case 'list_directory': + case 'ls': return ReadToolCall; case 'write': diff --git a/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md b/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md new file mode 100644 index 000000000..3be71a22b --- /dev/null +++ b/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md @@ -0,0 +1,121 @@ +## WebUI 平台适配指引(Chrome / Web / Share) + +本指引用于后续扩展 `@qwen-code/webui` 到新的运行平台(例如 Chrome 扩展、纯 Web 页、分享页)。 +VSCode 的适配实现可参考: +`packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx` + +--- + +### 1. 核心目标 + +- 在 **不改 UI 组件** 的前提下复用 WebUI。 +- 用 `PlatformProvider` 注入平台能力(消息、文件、登录、剪贴板等)。 +- 针对缺失能力,提供**降级方案**或标记 `features`。 + +--- + +### 2. PlatformContext 要点(最小实现) + +必需字段: + +- `platform`: `'chrome' | 'web' | 'share'` +- `postMessage`: 发送消息到宿主 +- `onMessage`: 订阅宿主消息 + +可选能力(按平台支持): + +- `openFile` +- `openDiff` +- `openTempFile` +- `attachFile` +- `login` +- `copyToClipboard` +- `getResourceUrl` +- `features`(标记能力可用性) + +类型定义位置: +`packages/webui/src/context/PlatformContext.tsx` + +--- + +### 3. 适配步骤(建议流程) + +1. **搭建消息通道** + - Chrome 扩展:`chrome.runtime.sendMessage` + `chrome.runtime.onMessage` + - Web/Share:`window.postMessage` + `message` 事件,或自定义事件总线 + +2. **实现 PlatformProvider** + - 将平台 API 映射到 `PlatformContextValue` + - 缺失能力返回 `undefined`,并设置 `features` + +3. **应用入口接入** + - 在平台入口包裹 `` + - 确保所有 UI 组件处于 Provider 内 + +4. **样式与主题** + - 引入 `@qwen-code/webui/styles.css` + - 在平台侧定义 CSS 变量(可从 `packages/webui/src/styles/variables.css` 复制初始值) + +5. **构建与依赖** + - Tailwind 使用 `@qwen-code/webui/tailwind.preset` + - `content` 需要包含 `node_modules/@qwen-code/webui/dist/**/*.js` + +6. **功能验收** + - 消息收发正常(`postMessage`/`onMessage`) + - 点击文件/差异输出不报错(可降级) + - `@`/`/` 补全与输入框交互正常 + +--- + +### 4. 参考实现(Web 平台示例) + +```tsx +import type React from 'react'; +import { PlatformProvider } from '@qwen-code/webui'; +import type { PlatformContextValue } from '@qwen-code/webui'; + +const platformValue: PlatformContextValue = { + platform: 'web', + postMessage: (message) => { + window.postMessage(message, '*'); + }, + onMessage: (handler) => { + const listener = (event: MessageEvent) => handler(event.data); + window.addEventListener('message', listener); + return () => window.removeEventListener('message', listener); + }, + copyToClipboard: async (text) => navigator.clipboard.writeText(text), + features: { + canCopy: true, + }, +}; + +export const WebPlatformProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => {children}; +``` + +--- + +### 5. Chrome 扩展建议映射 + +- `postMessage` -> `chrome.runtime.sendMessage` +- `onMessage` -> `chrome.runtime.onMessage.addListener` +- `openFile`/`openDiff` -> 触发 background 脚本打开 tab / side panel +- `attachFile` -> `chrome.tabs` 或 `` + +--- + +### 6. Web/Share 场景的降级策略 + +- `openFile/openDiff`:用新窗口/模态框展示内容 +- `openTempFile`:生成 `Blob` 并打开或下载 +- `login`:跳转到登录 URL 或弹出登录窗口 + +--- + +### 7. 常见坑 + +- Tailwind 样式未生效:`content` 缺少 `@qwen-code/webui` +- 主题色失效:未加载 `styles.css` 或未设置 CSS 变量 +- `postMessage` 无响应:宿主侧未注册对应消息通道 diff --git a/packages/webui/src/components/layout/Onboarding.stories.tsx b/packages/webui/src/components/layout/Onboarding.stories.tsx new file mode 100644 index 000000000..475437379 --- /dev/null +++ b/packages/webui/src/components/layout/Onboarding.stories.tsx @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Onboarding } from './Onboarding.js'; + +/** + * Onboarding is the welcome screen shown to new users. + * It displays the app logo, welcome message, and a get started button. + */ +const meta: Meta = { + title: 'Layout/Onboarding', + component: Onboarding, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Default onboarding screen + */ +export const Default: Story = { + args: { + onGetStarted: () => console.log('Get started clicked'), + }, +}; + +/** + * With custom icon URL + */ +export const WithIcon: Story = { + args: { + iconUrl: 'https://via.placeholder.com/80', + onGetStarted: () => console.log('Get started clicked'), + }, +}; + +/** + * Custom app name and messages + */ +export const CustomBranding: Story = { + args: { + iconUrl: 'https://via.placeholder.com/80', + appName: 'My AI Assistant', + subtitle: + 'Your personal coding companion powered by advanced AI technology.', + buttonText: 'Start Coding Now', + onGetStarted: () => console.log('Get started clicked'), + }, +}; + +/** + * Minimal (no icon) + */ +export const NoIcon: Story = { + args: { + appName: 'Code Helper', + subtitle: 'Simple and powerful code assistance.', + buttonText: 'Begin', + onGetStarted: () => console.log('Get started clicked'), + }, +}; diff --git a/packages/webui/src/components/layout/Onboarding.tsx b/packages/webui/src/components/layout/Onboarding.tsx new file mode 100644 index 000000000..bdf1a2652 --- /dev/null +++ b/packages/webui/src/components/layout/Onboarding.tsx @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Onboarding component - Pure UI welcome screen + * Platform-specific logic (icon URL) passed via props + */ + +import type React from 'react'; + +export interface OnboardingProps { + /** URL of the application icon */ + iconUrl?: string; + /** Callback when user clicks the get started button */ + onGetStarted: () => void; + /** Application name (defaults to "Qwen Code") */ + appName?: string; + /** Welcome message subtitle */ + subtitle?: string; + /** Button text (defaults to "Get Started with Qwen Code") */ + buttonText?: string; +} + +/** + * Onboarding - Welcome screen for new users + * Pure presentational component + */ +export const Onboarding: React.FC = ({ + iconUrl, + onGetStarted, + appName = 'Qwen Code', + subtitle = 'Unlock the power of AI to understand, navigate, and transform your codebase faster than ever before.', + buttonText = 'Get Started with Qwen Code', +}) => ( +
+
+
+ {/* Application icon container */} + {iconUrl && ( +
+ {`${appName} +
+ )} + +
+

+ Welcome to {appName} +

+

+ {subtitle} +

+
+ + +
+
+
+); + +export default Onboarding; diff --git a/packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx new file mode 100644 index 000000000..ac87e1ce7 --- /dev/null +++ b/packages/webui/src/components/toolcalls/ReadToolCall.stories.tsx @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ReadToolCall } from './ReadToolCall.js'; + +/** + * ReadToolCall displays file reading operations. + * Shows the file name being read with appropriate status indicators. + */ +const meta: Meta = { + title: 'ToolCalls/ReadToolCall', + component: ReadToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Successfully read a file + */ +export const Success: Story = { + args: { + toolCall: { + toolCallId: 'read-1', + kind: 'read', + title: 'Read file', + status: 'completed', + locations: [{ path: 'src/components/Button.tsx', line: 1 }], + }, + }, +}; + +/** + * Reading file in progress (loading state) + */ +export const Loading: Story = { + args: { + toolCall: { + toolCallId: 'read-2', + kind: 'read', + title: 'Read file', + status: 'in_progress', + locations: [{ path: 'src/utils/helpers.ts' }], + }, + }, +}; + +/** + * Read file with error + */ +export const WithError: Story = { + args: { + toolCall: { + toolCallId: 'read-3', + kind: 'read', + title: 'Read file', + status: 'failed', + content: [ + { + type: 'content', + content: { + type: 'error', + error: 'File not found: src/missing-file.ts', + }, + }, + ], + locations: [{ path: 'src/missing-file.ts' }], + }, + }, +}; + +/** + * Read multiple files + */ +export const ReadManyFiles: Story = { + args: { + toolCall: { + toolCallId: 'read-4', + kind: 'read_many_files', + title: 'Read multiple files', + status: 'completed', + locations: [ + { path: 'src/index.ts' }, + { path: 'src/App.tsx' }, + { path: 'src/main.ts' }, + ], + }, + }, +}; + +/** + * List directory operation + */ +export const ListDirectory: Story = { + args: { + toolCall: { + toolCallId: 'read-5', + kind: 'list_directory', + title: 'List directory', + status: 'completed', + locations: [{ path: 'src/components' }], + }, + }, +}; + +/** + * Read with diff content + */ +export const WithDiff: Story = { + args: { + toolCall: { + toolCallId: 'read-6', + kind: 'read', + title: 'Read file with diff', + status: 'completed', + content: [ + { + type: 'diff', + path: 'src/config.ts', + oldText: 'const debug = false;', + newText: 'const debug = true;', + }, + ], + locations: [{ path: 'src/config.ts' }], + }, + }, +}; + +/** + * Long file path + */ +export const LongFilePath: Story = { + args: { + toolCall: { + toolCallId: 'read-7', + kind: 'read', + title: 'Read file', + status: 'completed', + locations: [ + { + path: 'packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ReadToolCall.tsx', + line: 42, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.tsx similarity index 66% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx rename to packages/webui/src/components/toolcalls/ReadToolCall.tsx index c494e0b3c..874dca50c 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Read/ReadToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ReadToolCall.tsx @@ -3,23 +3,27 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * Read tool call component - specialized for file reading operations + * Read tool call component - displays file reading operations + * Pure UI component - platform interactions via usePlatform hook */ import type React from 'react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { FileLink } from '../layout/FileLink.js'; import { - FileLink, groupContent, mapToolStatusToContainerStatus, - usePlatform, -} from '@qwen-code/webui'; +} from './shared/index.js'; +import { usePlatform } from '../../context/PlatformContext.js'; import type { BaseToolCallProps, ToolCallContainerProps, -} from '@qwen-code/webui'; +} from './shared/index.js'; -export const ToolCallContainer: React.FC = ({ +/** + * Simple container for Read tool calls + */ +const ReadToolCallContainer: React.FC = ({ label, status = 'success', children, @@ -49,15 +53,17 @@ export const ToolCallContainer: React.FC = ({ ); /** - * Specialized component for Read tool calls - * Optimized for displaying file reading operations + * ReadToolCall - displays file reading operations * Shows: Read filename (no content preview) */ export const ReadToolCall: React.FC = ({ toolCall }) => { const { kind, content, locations, toolCallId } = toolCall; const platform = usePlatform(); + const openedDiffsRef = useRef>(new Map()); - // Map tool call kind to appropriate display name + /** + * Map tool call kind to appropriate display name + */ const getDisplayLabel = (): string => { const normalizedKind = kind.toLowerCase(); if (normalizedKind === 'read_many_files') { @@ -67,15 +73,17 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { } else if (normalizedKind === 'skill') { return 'Skill'; } else { - return 'ReadFile'; // default for read_file tools + return 'ReadFile'; } }; // Group content by type; memoize to avoid new array identities on every render const { errors, diffs } = useMemo(() => groupContent(content), [content]); - // Post a message to the extension host to open a VS Code diff tab - const handleOpenDiffInternal = useCallback( + /** + * Open diff view (if platform supports it) + */ + const handleOpenDiff = useCallback( ( path: string | undefined, oldText: string | null | undefined, @@ -88,6 +96,7 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { platform.openDiff(path, oldText, newText); return; } + // Fallback: post message for platforms that handle it differently platform.postMessage({ type: 'openDiff', data: { @@ -100,40 +109,43 @@ export const ReadToolCall: React.FC = ({ toolCall }) => { [platform], ); - // Auto-open diff when a read call returns diff content. - // Only trigger once per toolCallId so we don't spam as in-progress updates stream in. + // Auto-open diff when a read call returns diff content (once per diff signature) useEffect(() => { - if (diffs.length > 0) { - const firstDiff = diffs[0]; - const path = firstDiff.path || (locations && locations[0]?.path) || ''; - - if ( - path && - firstDiff.oldText !== undefined && - firstDiff.newText !== undefined - ) { - const timer = setTimeout(() => { - handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText); - }, 100); - return () => timer && clearTimeout(timer); - } + if (diffs.length === 0) { + return; } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [toolCallId]); - // Compute container status based on toolCall.status (pending/in_progress -> loading) - const containerStatus: - | 'success' - | 'error' - | 'warning' - | 'loading' - | 'default' = mapToolStatusToContainerStatus(toolCall.status); + const firstDiff = diffs[0]; + const path = firstDiff.path || locations?.[0]?.path || ''; + if (!path) { + return; + } + + if (firstDiff.oldText === undefined || firstDiff.newText === undefined) { + return; + } + + const signature = `${path}:${firstDiff.oldText ?? ''}:${firstDiff.newText ?? ''}`; + const lastSignature = openedDiffsRef.current.get(toolCallId); + if (lastSignature === signature) { + return; + } + + openedDiffsRef.current.set(toolCallId, signature); + const timer = setTimeout(() => { + handleOpenDiff(path, firstDiff.oldText, firstDiff.newText); + }, 100); + return () => clearTimeout(timer); + }, [diffs, handleOpenDiff, locations, toolCallId]); + + // Compute container status based on toolCall.status + const containerStatus = mapToolStatusToContainerStatus(toolCall.status); // Error case: show error if (errors.length > 0) { const path = locations?.[0]?.path || ''; return ( - = ({ toolCall }) => { } > {errors.join('\n')} - + ); } - // Success case with diff: keep UI compact; VS Code diff is auto-opened above + // Success case with diff if (diffs.length > 0) { const path = diffs[0]?.path || locations?.[0]?.path || ''; return ( - = ({ toolCall }) => { } > {null} - + ); } - // Success case: show which file was read with filename in label + // Success case: show which file was read if (locations && locations.length > 0) { const path = locations[0].path; return ( - = ({ toolCall }) => { } > {null} - + ); } diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css b/packages/webui/src/components/toolcalls/ShellToolCall.css similarity index 91% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css rename to packages/webui/src/components/toolcalls/ShellToolCall.css index 363062ab0..bf4ec3c56 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.css +++ b/packages/webui/src/components/toolcalls/ShellToolCall.css @@ -43,7 +43,7 @@ text-align: left; opacity: 50%; padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -68,14 +68,14 @@ .bash-toolcall-pre { margin-block: 0; overflow: hidden; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } .bash-toolcall-code { margin: 0; padding: 0; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -135,7 +135,7 @@ text-align: left; opacity: 50%; padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -160,14 +160,14 @@ .execute-toolcall-pre { margin-block: 0; overflow: hidden; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } .execute-toolcall-code { margin: 0; padding: 0; - font-family: var(--app-monospace-font-family); + font-family: var(--app-font-mono); font-size: 0.85em; } @@ -188,4 +188,3 @@ position: relative; grid-template-columns: max-content 1fr max-content; } - diff --git a/packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx b/packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx new file mode 100644 index 000000000..1ff26c4e3 --- /dev/null +++ b/packages/webui/src/components/toolcalls/ShellToolCall.stories.tsx @@ -0,0 +1,183 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ShellToolCall } from './ShellToolCall.js'; + +/** + * ShellToolCall displays bash/execute command operations. + * Shows command input (IN) and output (OUT) in a card layout. + */ +const meta: Meta = { + title: 'ToolCalls/ShellToolCall', + component: ShellToolCall, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Bash command with successful output + */ +export const BashWithOutput: Story = { + args: { + toolCall: { + toolCallId: 'bash-1', + kind: 'bash', + title: 'ls -la', + status: 'completed', + rawInput: { command: 'ls -la' }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'total 24\ndrwxr-xr-x 5 user staff 160 Jan 16 10:00 .\ndrwxr-xr-x 10 user staff 320 Jan 16 09:00 ..\n-rw-r--r-- 1 user staff 1234 Jan 16 10:00 package.json\n-rw-r--r-- 1 user staff 567 Jan 16 10:00 tsconfig.json', + }, + }, + ], + }, + }, +}; + +/** + * Bash command without output (just ran successfully) + */ +export const BashNoOutput: Story = { + args: { + toolCall: { + toolCallId: 'bash-2', + kind: 'bash', + title: 'mkdir -p src/components', + status: 'completed', + rawInput: { command: 'mkdir -p src/components' }, + }, + }, +}; + +/** + * Bash command with error + */ +export const BashWithError: Story = { + args: { + toolCall: { + toolCallId: 'bash-3', + kind: 'bash', + title: 'rm -rf /protected', + status: 'failed', + rawInput: { command: 'rm -rf /protected' }, + content: [ + { + type: 'content', + content: { + type: 'error', + error: 'rm: /protected: Permission denied', + }, + }, + ], + }, + }, +}; + +/** + * Bash command in progress (loading state) + */ +export const BashLoading: Story = { + args: { + toolCall: { + toolCallId: 'bash-4', + kind: 'bash', + title: 'npm install', + status: 'in_progress', + rawInput: { command: 'npm install' }, + }, + }, +}; + +/** + * Execute variant with description + */ +export const ExecuteWithDescription: Story = { + args: { + toolCall: { + toolCallId: 'execute-1', + kind: 'execute', + title: 'Run unit tests', + status: 'completed', + rawInput: { + command: 'npm test', + description: 'Run unit tests', + }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: 'PASS src/utils.test.ts\n ✓ should format date correctly (5ms)\n ✓ should parse input (2ms)\n\nTest Suites: 1 passed, 1 total\nTests: 2 passed, 2 total', + }, + }, + ], + }, + }, +}; + +/** + * Execute variant with long output (truncated) + */ +export const ExecuteLongOutput: Story = { + args: { + toolCall: { + toolCallId: 'execute-2', + kind: 'execute', + title: 'Build project', + status: 'completed', + rawInput: { + command: 'npm run build', + description: 'Build project', + }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: Array(100) + .fill('Building module...') + .map((s, i) => `[${i + 1}/100] ${s}`) + .join('\n'), + }, + }, + ], + }, + }, +}; + +/** + * Command variant (alias for bash) + */ +export const CommandVariant: Story = { + args: { + toolCall: { + toolCallId: 'command-1', + kind: 'command', + title: 'git status', + status: 'completed', + rawInput: { command: 'git status' }, + content: [ + { + type: 'content', + content: { + type: 'text', + text: "On branch main\nYour branch is up to date with 'origin/main'.\n\nnothing to commit, working tree clean", + }, + }, + ], + }, + }, +}; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx b/packages/webui/src/components/toolcalls/ShellToolCall.tsx similarity index 90% rename from packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx rename to packages/webui/src/components/toolcalls/ShellToolCall.tsx index a488d2603..2ac4030cf 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Shell/ShellToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ShellToolCall.tsx @@ -3,26 +3,30 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * Shared Shell tool call component for Execute/Bash/Command + * Shell tool call component for Execute/Bash/Command + * Pure UI component - platform interactions via usePlatform hook */ import type React from 'react'; import { - ToolCallContainer as SharedToolCallContainer, + ToolCallContainer, CopyButton, safeTitle, groupContent, - usePlatform, -} from '@qwen-code/webui'; +} from './shared/index.js'; +import { usePlatform } from '../../context/PlatformContext.js'; import type { BaseToolCallProps, ToolCallContainerProps, -} from '@qwen-code/webui'; +} from './shared/index.js'; import './ShellToolCall.css'; type ShellVariant = 'execute' | 'bash'; +/** + * Custom container for Execute variant with different styling + */ const ExecuteToolCallContainer: React.FC = ({ label, status = 'success', @@ -50,6 +54,9 @@ const ExecuteToolCallContainer: React.FC = ({
); +/** + * Get command text from tool call data + */ const getCommandText = ( variant: ShellVariant, title: unknown, @@ -65,6 +72,9 @@ const getCommandText = ( return safeTitle(title); }; +/** + * Get input command from raw input + */ const getInputCommand = ( commandText: string, rawInput?: string | object, @@ -80,8 +90,7 @@ const getInputCommand = ( }; /** - * Shared component for Execute/Bash tool calls - * Shows: Shell bullet + description + IN/OUT card + * Shell tool call implementation */ const ShellToolCallImpl: React.FC< BaseToolCallProps & { variant: ShellVariant } @@ -90,11 +99,15 @@ const ShellToolCallImpl: React.FC< const classPrefix = variant; const platform = usePlatform(); + /** + * Open content in a temporary file (if platform supports it) + */ const openTempFile = (content: string, fileName: string) => { if (platform.openTempFile) { platform.openTempFile(content, fileName); return; } + // Fallback: post message for platforms that handle it differently platform.postMessage({ type: 'createAndOpenTempFile', data: { @@ -103,11 +116,12 @@ const ShellToolCallImpl: React.FC< }, }); }; + const commandText = getCommandText(variant, title, rawInput); const inputCommand = getInputCommand(commandText, rawInput); const Container = - variant === 'execute' ? ExecuteToolCallContainer : SharedToolCallContainer; + variant === 'execute' ? ExecuteToolCallContainer : ToolCallContainer; // Group content by type const { textOutputs, errors } = groupContent(content); @@ -245,6 +259,10 @@ const ShellToolCallImpl: React.FC< ); }; +/** + * ShellToolCall - displays bash/execute command tool calls + * Shows command input and output with IN/OUT cards + */ export const ShellToolCall: React.FC = (props) => { const normalizedKind = props.toolCall.kind.toLowerCase(); const variant: ShellVariant = diff --git a/packages/webui/src/components/toolcalls/index.ts b/packages/webui/src/components/toolcalls/index.ts index bd121c9af..81e03b0ed 100644 --- a/packages/webui/src/components/toolcalls/index.ts +++ b/packages/webui/src/components/toolcalls/index.ts @@ -14,5 +14,7 @@ export { EditToolCall } from './EditToolCall.js'; export { WriteToolCall } from './WriteToolCall.js'; export { SearchToolCall } from './SearchToolCall.js'; export { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; +export { ShellToolCall } from './ShellToolCall.js'; +export { ReadToolCall } from './ReadToolCall.js'; export { CheckboxDisplay } from './CheckboxDisplay.js'; export type { CheckboxDisplayProps } from './CheckboxDisplay.js'; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index be6d8d6ee..2054d9e1c 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -6,6 +6,10 @@ // eslint-disable-next-line import/no-internal-modules import './styles/variables.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/timeline.css'; +// eslint-disable-next-line import/no-internal-modules +import './styles/components.css'; // Shared UI Components Export // Export all shared components from this package @@ -49,6 +53,8 @@ export type { EditModeInfo, EditModeIconType, } from './components/layout/InputForm'; +export { Onboarding } from './components/layout/Onboarding'; +export type { OnboardingProps } from './components/layout/Onboarding'; // Message components export { default as Message } from './components/messages/Message'; @@ -112,6 +118,8 @@ export { WriteToolCall, SearchToolCall, UpdatedPlanToolCall, + ShellToolCall, + ReadToolCall, CheckboxDisplay, } from './components/toolcalls'; export type { diff --git a/packages/webui/src/styles/components.css b/packages/webui/src/styles/components.css new file mode 100644 index 000000000..787d6ddbc --- /dev/null +++ b/packages/webui/src/styles/components.css @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Common component styles for webui + */ + +/* =========================== + Animations + =========================== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +@keyframes typingPulse { + 0%, + 60%, + 100% { + transform: scale(0.7); + opacity: 0.6; + } + 30% { + transform: scale(1); + opacity: 1; + } +} + +/* =========================== + Code Block Styles + =========================== */ +.code-block { + font-family: var(--app-font-mono); + font-size: var(--app-monospace-font-size, 13px); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-sm, 4px); + padding: var(--app-spacing-medium, 8px); + overflow-x: auto; + margin: 4px 0 0 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +/* =========================== + Diff Display Styles + =========================== */ +.diff-display-container { + margin: 8px 0; + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-md, 6px); + overflow: hidden; +} + +.diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--app-input-secondary-background, var(--app-background-secondary)); + border-bottom: 1px solid var(--app-input-border); +} + +.diff-file-path { + font-family: var(--app-font-mono); + font-size: 13px; + color: var(--app-primary-foreground); +} + +.open-diff-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-sm, 4px); + color: var(--app-primary-foreground); + cursor: pointer; + font-size: 12px; + transition: background-color 0.15s; +} + +.open-diff-button:hover { + background: var(--app-ghost-button-hover-background); +} + +.open-diff-button svg { + width: 16px; + height: 16px; +} + +.diff-section { + margin: 0; +} + +.diff-label { + padding: 8px 12px; + background: var(--app-primary-background); + border-bottom: 1px solid var(--app-input-border); + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + text-transform: uppercase; +} + +.diff-section .code-block { + border: none; + border-radius: 0; + margin: 0; + max-height: none; + overflow-y: visible; +} + +.diff-section .code-content { + display: block; +} + +/* =========================== + Tool Call Card Styles + =========================== */ +.toolcall-card { + padding-left: 30px; +} + +/* Icon SVG styles */ +.icon-svg { + display: block; +} diff --git a/packages/webui/src/styles/timeline.css b/packages/webui/src/styles/timeline.css new file mode 100644 index 000000000..033e82d22 --- /dev/null +++ b/packages/webui/src/styles/timeline.css @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Unified timeline styles for tool calls and messages + */ + +/* ========================================== + ToolCallContainer timeline styles + ========================================== */ +.toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* ToolCallContainer timeline connector */ +.toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.toolcall-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + AssistantMessage timeline styles + ========================================== */ +.assistant-message-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +/* AssistantMessage timeline connector */ +.assistant-message-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +/* First item: connector starts from status point position */ +.assistant-message-container:first-child::after { + top: 24px; +} + +/* Last item: connector shows only upper part */ +.assistant-message-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +/* ========================================== + Custom timeline styles for qwen-message message-item elements + ========================================== */ + +/* Default connector style - creates full-height connectors for all AI message items */ +.qwen-message.message-item:not(.user-message-container)::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); + z-index: 0; +} + +/* Single-item AI sequence (both a start and an end): hide the connector entirely */ +.qwen-message.message-item:not(.user-message-container):is( + :first-child, + .user-message-container + + .qwen-message.message-item:not(.user-message-container), + .chat-messages + > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container) + ):is( + :has(+ .user-message-container), + :has(+ :not(.qwen-message.message-item)), + :last-child + )::after { + display: none; +} + +/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */ +.qwen-message.message-item:not(.user-message-container):first-child::after, +.user-message-container + .qwen-message.message-item:not(.user-message-container)::after, +/* If the previous sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, or card-style tool calls), also treat as a new group start */ +.chat-messages > :not(.qwen-message.message-item) + + .qwen-message.message-item:not(.user-message-container)::after { + top: 15px; +} + +/* Handle the end of each AI message sequence */ +/* When the next sibling is a user message */ +.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after, +/* Or when the next sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, card-style tool calls, etc.) */ +.qwen-message.message-item:not(.user-message-container):has(+ :not(.qwen-message.message-item))::after, +/* When it's truly the last child element of the parent container */ +.qwen-message.message-item:not(.user-message-container):last-child::after { + /* Note: When setting both top and bottom, the height is (container height - top - bottom). + * Here we expect "15px spacing at the bottom", so bottom should be 15px (not calc(100% - 15px)). */ + top: 0; + bottom: calc(100% - 15px); +} + +.user-message-container:first-child { + margin-top: 0; +} + +.message-item { + padding: 8px 0; + width: 100%; + align-items: flex-start; + padding-left: 30px; + user-select: text; + position: relative; + padding-top: 8px; + padding-bottom: 8px; +} diff --git a/packages/webui/src/styles/variables.css b/packages/webui/src/styles/variables.css index c4ea0c012..81e88966a 100644 --- a/packages/webui/src/styles/variables.css +++ b/packages/webui/src/styles/variables.css @@ -24,6 +24,7 @@ --app-background: #1e1e1e; --app-primary-background: #1e1e1e; --app-background-secondary: #252526; + --app-secondary-background: #252526; --app-background-tertiary: #2d2d2d; /* =========================== @@ -52,7 +53,24 @@ Typography =========================== */ --app-font-sans: system-ui, -apple-system, sans-serif; - --app-font-mono: ui-monospace, 'SF Mono', monospace; + --app-font-mono: var( + --app-monospace-font-family, + ui-monospace, + 'SF Mono', + monospace + ); + --app-monospace-font-size: 13px; + + /* =========================== + Link Styles + =========================== */ + --app-link-foreground: #007acc; + --app-link-active-foreground: #005a9e; + + /* =========================== + Brand Colors + =========================== */ + --app-qwen-ivory: #f5f5dc; /* =========================== Border Radius @@ -77,13 +95,18 @@ Input Styles =========================== */ --app-input-background: #3c3c3c; + --app-input-secondary-background: #2d2d2d; --app-input-border: #3f3f46; + --app-input-foreground: #e4e4e7; --app-input-placeholder-foreground: #71717a; /* =========================== Button Styles =========================== */ --app-ghost-button-hover-background: rgba(90, 93, 94, 0.31); + --app-button-background: #3c3c3c; + --app-button-foreground: #ffffff; + --app-transparent-inner-border: rgba(255, 255, 255, 0.1); /* =========================== Header Styles From ff43a278dc27a4302ae4f6c7a2484218ca9f8627 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 17 Jan 2026 10:53:32 +0800 Subject: [PATCH 018/113] feat(webui): add webview container and isolate styles for VSCode integration - Introduce WebviewContainer component for style isolation in VSCode webviews - Rename CSS variables from --app-* to --qwen-app-* to prevent conflicts - Add dedicated webview.css with isolated styles - Update exports to include webview.css in package - Modify all components to use new CSS variable names - Update VSCode IDE companion to use new webview container - Add style isolation to prevent conflicts with VSCode environment Co-authored-by: Qwen-Coder Fixes webview UI issues in VSCode IDE Companion by providing proper style encapsulation. --- packages/vscode-ide-companion/esbuild.js | 45 +++- .../src/webview/WebViewProvider.ts | 73 +++++ .../webview/components/layout/InputForm.tsx | 4 +- .../webview/components/layout/Onboarding.tsx | 4 +- .../messages/toolcalls/ToolCall.tsx | 4 +- .../components/messages/toolcalls/index.tsx | 6 +- .../context/VSCodePlatformProvider.tsx | 6 +- .../src/webview/hooks/useWebViewMessages.ts | 4 +- .../vscode-ide-companion/tailwind.config.js | 4 +- packages/webui/package.json | 3 +- .../webui/src/components/PermissionDrawer.tsx | 10 +- .../webui/src/components/WebviewContainer.tsx | 17 ++ .../webui/src/components/icons/CloseIcon.tsx | 4 +- .../webui/src/components/icons/EditIcons.tsx | 46 ++-- .../webui/src/components/icons/FileIcons.tsx | 14 +- packages/webui/src/components/icons/Icon.tsx | 4 +- .../src/components/icons/NavigationIcons.tsx | 22 +- .../webui/src/components/icons/SendIcon.tsx | 4 +- .../src/components/icons/SpecialIcons.tsx | 6 +- .../src/components/icons/StatusIcons.tsx | 20 +- .../webui/src/components/icons/StopIcon.tsx | 8 +- packages/webui/src/components/icons/types.ts | 4 +- .../src/components/layout/ChatHeader.tsx | 4 +- .../src/components/layout/CompletionMenu.tsx | 4 +- .../webui/src/components/layout/Container.tsx | 4 +- .../components/layout/ContextIndicator.tsx | 4 +- .../src/components/layout/EmptyState.tsx | 4 +- .../webui/src/components/layout/FileLink.tsx | 4 +- .../webui/src/components/layout/Footer.tsx | 4 +- .../webui/src/components/layout/Header.tsx | 4 +- .../components/layout/InputForm.stories.tsx | 4 +- .../webui/src/components/layout/InputForm.tsx | 4 +- packages/webui/src/components/layout/Main.tsx | 4 +- .../src/components/layout/Onboarding.tsx | 4 +- .../src/components/layout/SessionSelector.tsx | 4 +- .../webui/src/components/layout/Sidebar.tsx | 4 +- .../messages/Assistant/AssistantMessage.tsx | 4 +- .../MarkdownRenderer/MarkdownRenderer.tsx | 4 +- .../webui/src/components/messages/Message.tsx | 4 +- .../components/messages/MessageContent.tsx | 4 +- .../src/components/messages/MessageInput.tsx | 6 +- .../src/components/messages/MessageList.tsx | 6 +- .../components/messages/ThinkingMessage.tsx | 4 +- .../src/components/messages/UserMessage.tsx | 4 +- .../messages/Waiting/InterruptedMessage.tsx | 4 +- .../messages/Waiting/WaitingMessage.tsx | 6 +- .../components/toolcalls/CheckboxDisplay.tsx | 4 +- .../components/toolcalls/GenericToolCall.tsx | 4 +- .../src/components/toolcalls/ReadToolCall.tsx | 6 +- .../components/toolcalls/SearchToolCall.tsx | 12 +- .../components/toolcalls/ShellToolCall.tsx | 13 +- .../components/toolcalls/ThinkToolCall.tsx | 4 +- .../toolcalls/UpdatedPlanToolCall.tsx | 8 +- .../components/toolcalls/WriteToolCall.tsx | 4 +- .../toolcalls/shared/LayoutComponents.tsx | 20 +- .../components/toolcalls/shared/copyUtils.tsx | 4 +- packages/webui/src/components/ui/Button.tsx | 8 +- packages/webui/src/components/ui/Input.tsx | 6 +- packages/webui/src/components/ui/Tooltip.tsx | 4 +- .../webui/src/context/PlatformContext.tsx | 4 +- packages/webui/src/index.ts | 3 + packages/webui/src/styles/variables.css | 4 +- packages/webui/src/styles/webview.css | 254 ++++++++++++++++++ packages/webui/src/types/completion.ts | 4 +- 64 files changed, 564 insertions(+), 216 deletions(-) create mode 100644 packages/webui/src/components/WebviewContainer.tsx create mode 100644 packages/webui/src/styles/webview.css diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 032c3c138..567b6f363 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -5,10 +5,17 @@ */ import esbuild from 'esbuild'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; const production = process.argv.includes('--production'); const watch = process.argv.includes('--watch'); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..', '..'); +const rootRequire = createRequire(resolve(repoRoot, 'package.json')); + /** * @type {import('esbuild').Plugin} */ @@ -31,6 +38,42 @@ const esbuildProblemMatcherPlugin = { }, }; +/** + * Ensure a single React copy in the webview bundle by resolving from repo root. + * Prevents mixing React 18/19 element types when nested node_modules exist. + * @type {import('esbuild').Plugin} + */ +const resolveFromRoot = (moduleId) => { + try { + return rootRequire.resolve(moduleId); + } catch { + return null; + } +}; + +const reactDedupPlugin = { + name: 'react-dedup', + setup(build) { + const aliases = [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + ]; + + for (const alias of aliases) { + build.onResolve({ filter: new RegExp(`^${alias}$`) }, () => { + const resolved = resolveFromRoot(alias); + if (!resolved) { + return undefined; + } + return { path: resolved }; + }); + } + }, +}; + /** * @type {import('esbuild').Plugin} */ @@ -128,7 +171,7 @@ async function main() { platform: 'browser', outfile: 'dist/webview.js', logLevel: 'silent', - plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin], + plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin], jsx: 'automatic', // Use new JSX transform (React 17+) define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"', diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index 5aa92c0fb..5e112673a 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -29,6 +29,7 @@ export class WebViewProvider { private pendingPermissionResolve: ((optionId: string) => void) | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; + private authState: boolean | null = null; constructor( private context: vscode.ExtensionContext, @@ -416,6 +417,10 @@ export class WebViewProvider { if (message.type === 'openDiff' && this.isAutoMode()) { return; } + if (message.type === 'webviewReady') { + this.handleWebviewReady(); + return; + } // Allow webview to request updating the VS Code tab title if (message.type === 'updatePanelTitle') { const title = String( @@ -874,10 +879,72 @@ export class WebViewProvider { } } + /** + * Track authentication state based on outbound messages to the webview. + */ + private updateAuthStateFromMessage(message: unknown): void { + if (!message || typeof message !== 'object') { + return; + } + const msg = message as { + type?: string; + data?: { authenticated?: boolean | null }; + }; + + switch (msg.type) { + case 'authState': + if (typeof msg.data?.authenticated === 'boolean') { + this.authState = msg.data.authenticated; + } else { + this.authState = null; + } + break; + case 'agentConnected': + case 'loginSuccess': + this.authState = true; + break; + case 'agentConnectionError': + case 'loginError': + this.authState = false; + break; + default: + break; + } + } + + /** + * Sync important initialization state when the webview signals readiness. + */ + private handleWebviewReady(): void { + if (this.currentModeId) { + this.sendMessageToWebView({ + type: 'modeChanged', + data: { modeId: this.currentModeId }, + }); + } + + if (typeof this.authState === 'boolean') { + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: this.authState }, + }); + return; + } + + if (this.agentInitialized) { + const authenticated = Boolean(this.agentManager.currentSessionId); + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated }, + }); + } + } + /** * Send message to WebView */ private sendMessageToWebView(message: unknown): void { + this.updateAuthStateFromMessage(message); const panel = this.panelManager.getPanel(); panel?.webview.postMessage(message); } @@ -983,6 +1050,7 @@ export class WebViewProvider { resetAgentState(): void { console.log('[WebViewProvider] Resetting agent state'); this.agentInitialized = false; + this.authState = null; // Disconnect existing connection this.agentManager.disconnect(); } @@ -1017,6 +1085,10 @@ export class WebViewProvider { if (message.type === 'openDiff' && this.isAutoMode()) { return; } + if (message.type === 'webviewReady') { + this.handleWebviewReady(); + return; + } if (message.type === 'updatePanelTitle') { const title = String( (message.data as { title?: unknown } | undefined)?.title ?? '', @@ -1174,6 +1246,7 @@ export class WebViewProvider { console.log('[WebViewProvider] Restoring state:', state); this.messageHandler.setCurrentConversationId(state.conversationId); this.agentInitialized = state.agentInitialized; + this.authState = null; console.log( '[WebViewProvider] State restored. agentInitialized:', this.agentInitialized, diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index 2761ace87..5722c66d2 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -7,7 +7,7 @@ * This allows local ApprovalModeValue to work with webui's EditModeInfo */ -import type React from 'react'; +import type { FC } from 'react'; import { InputForm as BaseInputForm, getEditModeIcon } from '@qwen-code/webui'; import type { InputFormProps as BaseInputFormProps, @@ -44,7 +44,7 @@ const getEditModeInfo = (editMode: ApprovalModeValue): EditModeInfo => { * This is an adapter that accepts the local ApprovalModeValue type * and converts it to webui's EditModeInfo format. */ -export const InputForm: React.FC = ({ editMode, ...rest }) => { +export const InputForm: FC = ({ editMode, ...rest }) => { const editModeInfo = getEditModeInfo(editMode); return ; diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index 79c8791f2..b67893097 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -7,7 +7,7 @@ * Uses webui Onboarding component with platform-specific icon URL */ -import type React from 'react'; +import type { FC } from 'react'; import { Onboarding as BaseOnboarding } from '@qwen-code/webui'; import { generateIconUrl } from '../../utils/resourceUrl.js'; @@ -19,7 +19,7 @@ interface OnboardingPageProps { * VSCode Onboarding wrapper * Provides platform-specific icon URL to the webui Onboarding component */ -export const Onboarding: React.FC = ({ onLogin }) => { +export const Onboarding: FC = ({ onLogin }) => { const iconUri = generateIconUrl('icon.png'); return ; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx index 2890cb51b..ac1fbce11 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/ToolCall.tsx @@ -9,7 +9,7 @@ * It re-exports the router and types from the toolcalls module. */ -import type React from 'react'; +import type { FC } from 'react'; import type { ToolCallData } from '@qwen-code/webui'; import { ToolCallRouter } from './index.js'; @@ -20,7 +20,7 @@ export type { ToolCallContent, } from '@qwen-code/webui'; -export const ToolCall: React.FC<{ +export const ToolCall: FC<{ toolCall: ToolCallData; isFirst?: boolean; isLast?: boolean; diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index 24741a567..d20e07188 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -7,7 +7,7 @@ * All UI components are now imported from @qwen-code/webui */ -import type React from 'react'; +import type { FC } from 'react'; import { shouldShowToolCall, // All ToolCall components from webui @@ -25,9 +25,7 @@ import type { BaseToolCallProps } from '@qwen-code/webui'; /** * Factory function that returns the appropriate tool call component based on kind */ -export const getToolCallComponent = ( - kind: string, -): React.FC => { +export const getToolCallComponent = (kind: string): FC => { const normalizedKind = kind.toLowerCase(); // Route to specialized components diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx index da128f381..241a5fada 100644 --- a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx +++ b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx @@ -7,8 +7,8 @@ * This allows webui components to work with VSCode's messaging system */ -import type React from 'react'; import { useMemo, useCallback, useEffect, useRef } from 'react'; +import type { FC, ReactNode } from 'react'; import { PlatformProvider } from '@qwen-code/webui'; import type { PlatformContextValue } from '@qwen-code/webui'; import { useVSCode } from '../hooks/useVSCode.js'; @@ -18,7 +18,7 @@ import { generateIconUrl } from '../utils/resourceUrl.js'; * Props for VSCodePlatformProvider */ interface VSCodePlatformProviderProps { - children: React.ReactNode; + children: ReactNode; } /** @@ -27,7 +27,7 @@ interface VSCodePlatformProviderProps { * This component bridges the VSCode API with the platform-agnostic webui components. * It wraps children with PlatformProvider and provides VSCode-specific implementations. */ -export const VSCodePlatformProvider: React.FC = ({ +export const VSCodePlatformProvider: FC = ({ children, }) => { const vscode = useVSCode(); diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 9d84e46f7..ae46a9b4f 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -886,6 +886,8 @@ export const useWebViewMessages = ({ useEffect(() => { window.addEventListener('message', handleMessage); + // Notify extension that the webview is ready to receive initialization state. + vscode.postMessage({ type: 'webviewReady', data: {} }); return () => window.removeEventListener('message', handleMessage); - }, [handleMessage]); + }, [handleMessage, vscode]); }; diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js index b286d45d0..f220c40ac 100644 --- a/packages/vscode-ide-companion/tailwind.config.js +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -15,7 +15,9 @@ export default { content: [ './src/webview/**/**/*.{js,jsx,ts,tsx}', // Include webui components to prevent Tailwind JIT from tree-shaking their classes - './node_modules/@qwen-code/webui/dist/**/*.js', + // Use relative path for pnpm workspace - node_modules symlinks are in root + '../webui/src/**/*.{js,jsx,ts,tsx}', + '../webui/dist/**/*.js', ], theme: { extend: { diff --git a/packages/webui/package.json b/packages/webui/package.json index e293398c1..06fda4881 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -18,7 +18,8 @@ "require": "./dist/components/icons/index.cjs" }, "./tailwind.preset": "./tailwind.preset.cjs", - "./styles.css": "./dist/styles.css" + "./styles.css": "./dist/styles.css", + "./webview.css": "./dist/webview.css" }, "files": [ "dist", diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx index 0461dfa52..450522cbd 100644 --- a/packages/webui/src/components/PermissionDrawer.tsx +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; import { useEffect, useRef, useState } from 'react'; +import type { FC, RefObject } from 'react'; export interface PermissionOption { name: string; @@ -41,7 +41,7 @@ export interface PermissionDrawerProps { onClose?: () => void; } -const PermissionDrawer: React.FC = ({ +const PermissionDrawer: FC = ({ isOpen, options, toolCall, @@ -290,10 +290,10 @@ interface CustomMessageInputRowProps { setCustomMessage: (val: string) => void; onFocusRow: () => void; onSubmitReject: () => void; - inputRef: React.RefObject; + inputRef: RefObject; } -const CustomMessageInputRow: React.FC = ({ +const CustomMessageInputRow: FC = ({ isFocused, customMessage, setCustomMessage, @@ -309,7 +309,7 @@ const CustomMessageInputRow: React.FC = ({ onClick={() => inputRef.current?.focus()} > | undefined} + ref={inputRef as unknown as RefObject} type="text" placeholder="Tell Qwen what to do instead" spellCheck={false} diff --git a/packages/webui/src/components/WebviewContainer.tsx b/packages/webui/src/components/WebviewContainer.tsx new file mode 100644 index 000000000..b8317fdc1 --- /dev/null +++ b/packages/webui/src/components/WebviewContainer.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from 'react'; +import type React from 'react'; + +interface WebviewContainerProps extends PropsWithChildren { + className?: string; +} + +/** + * A container component that provides style isolation for VSCode webviews + * This component wraps content in a namespace to prevent style conflicts + */ +const WebviewContainer: React.FC = ({ + children, + className = '', +}) =>
{children}
; + +export default WebviewContainer; diff --git a/packages/webui/src/components/icons/CloseIcon.tsx b/packages/webui/src/components/icons/CloseIcon.tsx index 65d60b68a..d2324ad89 100644 --- a/packages/webui/src/components/icons/CloseIcon.tsx +++ b/packages/webui/src/components/icons/CloseIcon.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; interface CloseIconProps { size?: number; @@ -12,7 +12,7 @@ interface CloseIconProps { className?: string; } -const CloseIcon: React.FC = ({ +const CloseIcon: FC = ({ size = 24, color = 'currentColor', className = '', diff --git a/packages/webui/src/components/icons/EditIcons.tsx b/packages/webui/src/components/icons/EditIcons.tsx index ddec39d4a..511ba9bb7 100644 --- a/packages/webui/src/components/icons/EditIcons.tsx +++ b/packages/webui/src/components/icons/EditIcons.tsx @@ -6,14 +6,14 @@ * Edit mode related icons */ -import type React from 'react'; +import type { FC } from 'react'; import type { IconProps } from './types.js'; /** * Edit pencil icon (16x16) * Used for "Ask before edits" mode */ -export const EditPencilIcon: React.FC = ({ +export const EditPencilIcon: FC = ({ size = 16, className, ...props @@ -40,7 +40,7 @@ export const EditPencilIcon: React.FC = ({ * Auto/fast-forward icon (16x16) * Used for "Edit automatically" mode */ -export const AutoEditIcon: React.FC = ({ +export const AutoEditIcon: FC = ({ size = 16, className, ...props @@ -63,7 +63,7 @@ export const AutoEditIcon: React.FC = ({ * Plan mode/bars icon (16x16) * Used for "Plan mode" */ -export const PlanModeIcon: React.FC = ({ +export const PlanModeIcon: FC = ({ size = 16, className, ...props @@ -86,7 +86,7 @@ export const PlanModeIcon: React.FC = ({ * Code brackets icon (20x20) * Used for active file indicator */ -export const CodeBracketsIcon: React.FC = ({ +export const CodeBracketsIcon: FC = ({ size = 20, className, ...props @@ -113,7 +113,7 @@ export const CodeBracketsIcon: React.FC = ({ * Hide context (eye slash) icon (20x20) * Used to indicate the active selection will NOT be auto-loaded into context */ -export const HideContextIcon: React.FC = ({ +export const HideContextIcon: FC = ({ size = 20, className, ...props @@ -141,7 +141,7 @@ export const HideContextIcon: React.FC = ({ * Slash command icon (20x20) * Used for command menu button */ -export const SlashCommandIcon: React.FC = ({ +export const SlashCommandIcon: FC = ({ size = 20, className, ...props @@ -168,11 +168,7 @@ export const SlashCommandIcon: React.FC = ({ * Link/attachment icon (20x20) * Used for attach context button */ -export const LinkIcon: React.FC = ({ - size = 20, - className, - ...props -}) => ( +export const LinkIcon: FC = ({ size = 20, className, ...props }) => ( = ({ * Open diff icon (16x16) * Used for opening diff in VS Code */ -export const OpenDiffIcon: React.FC = ({ +export const OpenDiffIcon: FC = ({ size = 16, className, ...props @@ -218,11 +214,7 @@ export const OpenDiffIcon: React.FC = ({ * Undo edit icon (16x16) * Used for undoing edits in diff views */ -export const UndoIcon: React.FC = ({ - size = 16, - className, - ...props -}) => ( +export const UndoIcon: FC = ({ size = 16, className, ...props }) => ( = ({ * Redo edit icon (16x16) * Used for redoing edits in diff views */ -export const RedoIcon: React.FC = ({ - size = 16, - className, - ...props -}) => ( +export const RedoIcon: FC = ({ size = 16, className, ...props }) => ( = ({ * Replace all icon (16x16) * Used for replacing all occurrences in search/replace */ -export const ReplaceAllIcon: React.FC = ({ +export const ReplaceAllIcon: FC = ({ size = 16, className, ...props @@ -305,11 +293,7 @@ export const ReplaceAllIcon: React.FC = ({ * Copy icon (16x16) * Used for copying content */ -export const CopyIcon: React.FC = ({ - size = 16, - className, - ...props -}) => ( +export const CopyIcon: FC = ({ size = 16, className, ...props }) => ( = ({ * Paste icon (16x16) * Used for pasting content */ -export const PasteIcon: React.FC = ({ +export const PasteIcon: FC = ({ size = 16, className, ...props @@ -371,7 +355,7 @@ export const PasteIcon: React.FC = ({ * Select all icon (16x16) * Used for selecting all content */ -export const SelectAllIcon: React.FC = ({ +export const SelectAllIcon: FC = ({ size = 16, className, ...props diff --git a/packages/webui/src/components/icons/FileIcons.tsx b/packages/webui/src/components/icons/FileIcons.tsx index 38bf27f7a..bf00697a2 100644 --- a/packages/webui/src/components/icons/FileIcons.tsx +++ b/packages/webui/src/components/icons/FileIcons.tsx @@ -6,18 +6,14 @@ * File and document related icons */ -import type React from 'react'; +import type { FC } from 'react'; import type { IconProps } from './types.js'; /** * File document icon (16x16) * Used for file completion menu */ -export const FileIcon: React.FC = ({ - size = 16, - className, - ...props -}) => ( +export const FileIcon: FC = ({ size = 16, className, ...props }) => ( = ({ ); -export const FileListIcon: React.FC = ({ +export const FileListIcon: FC = ({ size = 16, className, ...props @@ -55,7 +51,7 @@ export const FileListIcon: React.FC = ({ * Save document icon (16x16) * Used for save session button */ -export const SaveDocumentIcon: React.FC = ({ +export const SaveDocumentIcon: FC = ({ size = 16, className, ...props @@ -83,7 +79,7 @@ export const SaveDocumentIcon: React.FC = ({ * Folder icon (16x16) * Useful for directory entries in completion lists */ -export const FolderIcon: React.FC = ({ +export const FolderIcon: FC = ({ size = 16, className, ...props diff --git a/packages/webui/src/components/icons/Icon.tsx b/packages/webui/src/components/icons/Icon.tsx index afa888cbb..d4ce3a8c4 100644 --- a/packages/webui/src/components/icons/Icon.tsx +++ b/packages/webui/src/components/icons/Icon.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; interface IconProps { name: string; @@ -13,7 +13,7 @@ interface IconProps { className?: string; } -const Icon: React.FC = ({ +const Icon: FC = ({ name, size = 24, color = 'currentColor', diff --git a/packages/webui/src/components/icons/NavigationIcons.tsx b/packages/webui/src/components/icons/NavigationIcons.tsx index 9a4e52fbd..541eb1ba5 100644 --- a/packages/webui/src/components/icons/NavigationIcons.tsx +++ b/packages/webui/src/components/icons/NavigationIcons.tsx @@ -6,14 +6,14 @@ * Navigation and action icons */ -import type React from 'react'; +import type { FC } from 'react'; import type { IconProps } from './types.js'; /** * Chevron down icon (20x20) * Used for dropdown arrows */ -export const ChevronDownIcon: React.FC = ({ +export const ChevronDownIcon: FC = ({ size = 20, className, ...props @@ -40,11 +40,7 @@ export const ChevronDownIcon: React.FC = ({ * Plus icon (20x20) * Used for new session button */ -export const PlusIcon: React.FC = ({ - size = 20, - className, - ...props -}) => ( +export const PlusIcon: FC = ({ size = 20, className, ...props }) => ( = ({ * Small plus icon (16x16) * Used for default attachment type */ -export const PlusSmallIcon: React.FC = ({ +export const PlusSmallIcon: FC = ({ size = 16, className, ...props @@ -86,7 +82,7 @@ export const PlusSmallIcon: React.FC = ({ * Arrow up icon (20x20) * Used for send message button */ -export const ArrowUpIcon: React.FC = ({ +export const ArrowUpIcon: FC = ({ size = 20, className, ...props @@ -113,7 +109,7 @@ export const ArrowUpIcon: React.FC = ({ * Close X icon (14x14) * Used for close buttons in banners and dialogs */ -export const CloseIcon: React.FC = ({ +export const CloseIcon: FC = ({ size = 14, className, ...props @@ -137,7 +133,7 @@ export const CloseIcon: React.FC = ({ ); -export const CloseSmallIcon: React.FC = ({ +export const CloseSmallIcon: FC = ({ size = 16, className, ...props @@ -160,7 +156,7 @@ export const CloseSmallIcon: React.FC = ({ * Search/magnifying glass icon (20x20) * Used for search input */ -export const SearchIcon: React.FC = ({ +export const SearchIcon: FC = ({ size = 20, className, ...props @@ -187,7 +183,7 @@ export const SearchIcon: React.FC = ({ * Refresh/reload icon (16x16) * Used for refresh session list */ -export const RefreshIcon: React.FC = ({ +export const RefreshIcon: FC = ({ size = 16, className, ...props diff --git a/packages/webui/src/components/icons/SendIcon.tsx b/packages/webui/src/components/icons/SendIcon.tsx index f0c657ecc..fa8a2a059 100644 --- a/packages/webui/src/components/icons/SendIcon.tsx +++ b/packages/webui/src/components/icons/SendIcon.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; interface SendIconProps { size?: number; @@ -12,7 +12,7 @@ interface SendIconProps { className?: string; } -const SendIcon: React.FC = ({ +const SendIcon: FC = ({ size = 24, color = 'currentColor', className = '', diff --git a/packages/webui/src/components/icons/SpecialIcons.tsx b/packages/webui/src/components/icons/SpecialIcons.tsx index 48c5db846..492ba926b 100644 --- a/packages/webui/src/components/icons/SpecialIcons.tsx +++ b/packages/webui/src/components/icons/SpecialIcons.tsx @@ -6,7 +6,7 @@ * Special UI icons */ -import type React from 'react'; +import type { FC } from 'react'; import type { IconProps } from './types.js'; interface ThinkingIconProps extends IconProps { @@ -16,7 +16,7 @@ interface ThinkingIconProps extends IconProps { enabled?: boolean; } -export const ThinkingIcon: React.FC = ({ +export const ThinkingIcon: FC = ({ size = 16, className, enabled = false, @@ -49,7 +49,7 @@ export const ThinkingIcon: React.FC = ({ ); -export const TerminalIcon: React.FC = ({ +export const TerminalIcon: FC = ({ size = 20, className, ...props diff --git a/packages/webui/src/components/icons/StatusIcons.tsx b/packages/webui/src/components/icons/StatusIcons.tsx index fdaa29434..231a62ed2 100644 --- a/packages/webui/src/components/icons/StatusIcons.tsx +++ b/packages/webui/src/components/icons/StatusIcons.tsx @@ -6,14 +6,14 @@ * Status and state related icons */ -import type React from 'react'; +import type { FC } from 'react'; import type { IconProps } from './types.js'; /** * Plan completed icon (14x14) * Used for completed plan items */ -export const PlanCompletedIcon: React.FC = ({ +export const PlanCompletedIcon: FC = ({ size = 14, className, ...props @@ -43,7 +43,7 @@ export const PlanCompletedIcon: React.FC = ({ * Plan in progress icon (14x14) * Used for in-progress plan items */ -export const PlanInProgressIcon: React.FC = ({ +export const PlanInProgressIcon: FC = ({ size = 14, className, ...props @@ -73,7 +73,7 @@ export const PlanInProgressIcon: React.FC = ({ * Plan pending icon (14x14) * Used for pending plan items */ -export const PlanPendingIcon: React.FC = ({ +export const PlanPendingIcon: FC = ({ size = 14, className, ...props @@ -103,7 +103,7 @@ export const PlanPendingIcon: React.FC = ({ * Warning triangle icon (20x20) * Used for warning messages */ -export const WarningTriangleIcon: React.FC = ({ +export const WarningTriangleIcon: FC = ({ size = 20, className, ...props @@ -130,11 +130,7 @@ export const WarningTriangleIcon: React.FC = ({ * User profile icon (16x16) * Used for login command */ -export const UserIcon: React.FC = ({ - size = 16, - className, - ...props -}) => ( +export const UserIcon: FC = ({ size = 16, className, ...props }) => ( = ({ ); -export const SymbolIcon: React.FC = ({ +export const SymbolIcon: FC = ({ size = 16, className, ...props @@ -168,7 +164,7 @@ export const SymbolIcon: React.FC = ({ ); -export const SelectionIcon: React.FC = ({ +export const SelectionIcon: FC = ({ size = 16, className, ...props diff --git a/packages/webui/src/components/icons/StopIcon.tsx b/packages/webui/src/components/icons/StopIcon.tsx index 40c232502..759349224 100644 --- a/packages/webui/src/components/icons/StopIcon.tsx +++ b/packages/webui/src/components/icons/StopIcon.tsx @@ -6,18 +6,14 @@ * Stop icon for canceling operations */ -import type React from 'react'; +import type { FC } from 'react'; import type { IconProps } from './types.js'; /** * Stop/square icon (16x16) * Used for stop/cancel operations */ -export const StopIcon: React.FC = ({ - size = 16, - className, - ...props -}) => ( +export const StopIcon: FC = ({ size = 16, className, ...props }) => ( { +export interface IconProps extends SVGProps { /** * Icon size (width and height) * @default 16 diff --git a/packages/webui/src/components/layout/ChatHeader.tsx b/packages/webui/src/components/layout/ChatHeader.tsx index 4ece03d78..bd4855596 100644 --- a/packages/webui/src/components/layout/ChatHeader.tsx +++ b/packages/webui/src/components/layout/ChatHeader.tsx @@ -7,7 +7,7 @@ * Displays current session title with navigation controls */ -import type React from 'react'; +import type { FC } from 'react'; import { ChevronDownIcon } from '../icons/NavigationIcons.js'; import { PlusIcon } from '../icons/NavigationIcons.js'; @@ -40,7 +40,7 @@ export interface ChatHeaderProps { * /> * ``` */ -export const ChatHeader: React.FC = ({ +export const ChatHeader: FC = ({ currentSessionTitle, onLoadSessions, onNewSession, diff --git a/packages/webui/src/components/layout/CompletionMenu.tsx b/packages/webui/src/components/layout/CompletionMenu.tsx index 698c623ca..809e24b73 100644 --- a/packages/webui/src/components/layout/CompletionMenu.tsx +++ b/packages/webui/src/components/layout/CompletionMenu.tsx @@ -7,7 +7,7 @@ * Supports keyboard navigation and mouse interaction */ -import type React from 'react'; +import type { FC } from 'react'; import { useEffect, useRef, useState } from 'react'; import type { CompletionItem } from '../../types/completion.js'; @@ -49,7 +49,7 @@ export interface CompletionMenuProps { * /> * ``` */ -export const CompletionMenu: React.FC = ({ +export const CompletionMenu: FC = ({ items, onSelect, onClose, diff --git a/packages/webui/src/components/layout/Container.tsx b/packages/webui/src/components/layout/Container.tsx index 37dc27bca..c86de299e 100644 --- a/packages/webui/src/components/layout/Container.tsx +++ b/packages/webui/src/components/layout/Container.tsx @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; interface ContainerProps { children: React.ReactNode; className?: string; } -const Container: React.FC = ({ children, className = '' }) => ( +const Container: FC = ({ children, className = '' }) => (
{children}
); diff --git a/packages/webui/src/components/layout/ContextIndicator.tsx b/packages/webui/src/components/layout/ContextIndicator.tsx index 4af745abb..ceac64d58 100644 --- a/packages/webui/src/components/layout/ContextIndicator.tsx +++ b/packages/webui/src/components/layout/ContextIndicator.tsx @@ -7,7 +7,7 @@ * Displays token usage information with tooltip */ -import type React from 'react'; +import type { FC } from 'react'; import { Tooltip } from '../ui/Tooltip.js'; /** @@ -61,7 +61,7 @@ const formatNumber = (value: number): string => { * /> * ``` */ -export const ContextIndicator: React.FC = ({ +export const ContextIndicator: FC = ({ contextUsage, }) => { if (!contextUsage) { diff --git a/packages/webui/src/components/layout/EmptyState.tsx b/packages/webui/src/components/layout/EmptyState.tsx index 4c84bcdc9..de0c11f39 100644 --- a/packages/webui/src/components/layout/EmptyState.tsx +++ b/packages/webui/src/components/layout/EmptyState.tsx @@ -7,7 +7,7 @@ * Shows logo and welcome message based on authentication state */ -import type React from 'react'; +import type { FC } from 'react'; import { usePlatform } from '../../context/PlatformContext.js'; /** @@ -41,7 +41,7 @@ export interface EmptyStateProps { * /> * ``` */ -export const EmptyState: React.FC = ({ +export const EmptyState: FC = ({ isAuthenticated = false, loadingMessage, logoUrl, diff --git a/packages/webui/src/components/layout/FileLink.tsx b/packages/webui/src/components/layout/FileLink.tsx index 9c8945dd5..07cd9c593 100644 --- a/packages/webui/src/components/layout/FileLink.tsx +++ b/packages/webui/src/components/layout/FileLink.tsx @@ -8,7 +8,7 @@ * Supports clicking to open files and jump to specified line and column numbers */ -import type React from 'react'; +import type { FC } from 'react'; import { usePlatform } from '../../context/PlatformContext.js'; /** @@ -77,7 +77,7 @@ function buildFullPath( * * ``` */ -export const FileLink: React.FC = ({ +export const FileLink: FC = ({ path, line, column, diff --git a/packages/webui/src/components/layout/Footer.tsx b/packages/webui/src/components/layout/Footer.tsx index 6a4f162f5..e12c7f1fd 100644 --- a/packages/webui/src/components/layout/Footer.tsx +++ b/packages/webui/src/components/layout/Footer.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; -const Footer: React.FC = () =>
Footer Component Placeholder
; +const Footer: FC = () =>
Footer Component Placeholder
; export default Footer; diff --git a/packages/webui/src/components/layout/Header.tsx b/packages/webui/src/components/layout/Header.tsx index 1f7fe373c..28861f57d 100644 --- a/packages/webui/src/components/layout/Header.tsx +++ b/packages/webui/src/components/layout/Header.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; -const Header: React.FC = () =>
Header Component Placeholder
; +const Header: FC = () =>
Header Component Placeholder
; export default Header; diff --git a/packages/webui/src/components/layout/InputForm.stories.tsx b/packages/webui/src/components/layout/InputForm.stories.tsx index e937e988b..db5796343 100644 --- a/packages/webui/src/components/layout/InputForm.stories.tsx +++ b/packages/webui/src/components/layout/InputForm.stories.tsx @@ -5,7 +5,7 @@ */ import type { Meta, StoryObj, StoryFn, Decorator } from '@storybook/react-vite'; -import type React from 'react'; +import type { FC } from 'react'; import { useRef } from 'react'; import { InputForm, getEditModeIcon } from './InputForm.js'; import type { InputFormProps } from './InputForm.js'; @@ -15,7 +15,7 @@ type InputFormStoryProps = Omit; /** * Wrapper component to provide inputFieldRef */ -const InputFormWrapper: React.FC = (props) => { +const InputFormWrapper: FC = (props) => { const inputFieldRef = useRef(null); return ; }; diff --git a/packages/webui/src/components/layout/InputForm.tsx b/packages/webui/src/components/layout/InputForm.tsx index eb7ab2f11..e77f57e24 100644 --- a/packages/webui/src/components/layout/InputForm.tsx +++ b/packages/webui/src/components/layout/InputForm.tsx @@ -7,7 +7,7 @@ * Platform-agnostic version with configurable edit modes */ -import type React from 'react'; +import type { FC } from 'react'; import type { ReactNode } from 'react'; import { EditPencilIcon, @@ -144,7 +144,7 @@ export interface InputFormProps { * /> * ``` */ -export const InputForm: React.FC = ({ +export const InputForm: FC = ({ inputText, inputFieldRef, isStreaming, diff --git a/packages/webui/src/components/layout/Main.tsx b/packages/webui/src/components/layout/Main.tsx index b046a1827..a39181aaf 100644 --- a/packages/webui/src/components/layout/Main.tsx +++ b/packages/webui/src/components/layout/Main.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; -const Main: React.FC = () =>
Main Component Placeholder
; +const Main: FC = () =>
Main Component Placeholder
; export default Main; diff --git a/packages/webui/src/components/layout/Onboarding.tsx b/packages/webui/src/components/layout/Onboarding.tsx index bdf1a2652..9a1deed59 100644 --- a/packages/webui/src/components/layout/Onboarding.tsx +++ b/packages/webui/src/components/layout/Onboarding.tsx @@ -7,7 +7,7 @@ * Platform-specific logic (icon URL) passed via props */ -import type React from 'react'; +import type { FC } from 'react'; export interface OnboardingProps { /** URL of the application icon */ @@ -26,7 +26,7 @@ export interface OnboardingProps { * Onboarding - Welcome screen for new users * Pure presentational component */ -export const Onboarding: React.FC = ({ +export const Onboarding: FC = ({ iconUrl, onGetStarted, appName = 'Qwen Code', diff --git a/packages/webui/src/components/layout/SessionSelector.tsx b/packages/webui/src/components/layout/SessionSelector.tsx index 6e185af81..7012770ff 100644 --- a/packages/webui/src/components/layout/SessionSelector.tsx +++ b/packages/webui/src/components/layout/SessionSelector.tsx @@ -7,7 +7,7 @@ * Displays sessions grouped by date with search and infinite scroll */ -import type React from 'react'; +import type { FC } from 'react'; import { Fragment } from 'react'; import { getTimeAgo, @@ -64,7 +64,7 @@ export interface SessionSelectorProps { * /> * ``` */ -export const SessionSelector: React.FC = ({ +export const SessionSelector: FC = ({ visible, sessions, currentSessionId, diff --git a/packages/webui/src/components/layout/Sidebar.tsx b/packages/webui/src/components/layout/Sidebar.tsx index d8835427a..606c2da02 100644 --- a/packages/webui/src/components/layout/Sidebar.tsx +++ b/packages/webui/src/components/layout/Sidebar.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; -const Sidebar: React.FC = () => ; +const Sidebar: FC = () => ; export default Sidebar; diff --git a/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx b/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx index e0c41970f..73ed296c4 100644 --- a/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx +++ b/packages/webui/src/components/messages/Assistant/AssistantMessage.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; import { MessageContent } from '../MessageContent.js'; import './AssistantMessage.css'; @@ -28,7 +28,7 @@ export interface AssistantMessageProps { * AssistantMessage component - renders AI responses with styling * Supports different states: default, success, error, warning, loading */ -export const AssistantMessage: React.FC = ({ +export const AssistantMessage: FC = ({ content, timestamp: _timestamp, onFileClick, diff --git a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx index 7413c4041..3d52a342b 100644 --- a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx +++ b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.tsx @@ -6,7 +6,7 @@ * MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths */ -import type React from 'react'; +import type { FC } from 'react'; import { useMemo, useCallback } from 'react'; import MarkdownIt from 'markdown-it'; import type { Options as MarkdownItOptions } from 'markdown-it'; @@ -59,7 +59,7 @@ const createMarkdownInstance = (): MarkdownIt => /** * MarkdownRenderer component - renders markdown content with enhanced features */ -export const MarkdownRenderer: React.FC = ({ +export const MarkdownRenderer: FC = ({ content, onFileClick, enableFileLinks = true, diff --git a/packages/webui/src/components/messages/Message.tsx b/packages/webui/src/components/messages/Message.tsx index cce7b53ce..876307242 100644 --- a/packages/webui/src/components/messages/Message.tsx +++ b/packages/webui/src/components/messages/Message.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; interface MessageProps { id: string; @@ -14,7 +14,7 @@ interface MessageProps { className?: string; } -const Message: React.FC = ({ +const Message: FC = ({ content, sender, timestamp, diff --git a/packages/webui/src/components/messages/MessageContent.tsx b/packages/webui/src/components/messages/MessageContent.tsx index bed103b48..cdc3bf34e 100644 --- a/packages/webui/src/components/messages/MessageContent.tsx +++ b/packages/webui/src/components/messages/MessageContent.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; import { memo } from 'react'; import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js'; @@ -14,7 +14,7 @@ export interface MessageContentProps { enableFileLinks?: boolean; } -const MessageContentBase: React.FC = ({ +const MessageContentBase: FC = ({ content, onFileClick, enableFileLinks, diff --git a/packages/webui/src/components/messages/MessageInput.tsx b/packages/webui/src/components/messages/MessageInput.tsx index 281fd5b4e..878c1a70f 100644 --- a/packages/webui/src/components/messages/MessageInput.tsx +++ b/packages/webui/src/components/messages/MessageInput.tsx @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; -const MessageInput: React.FC = () => ( -
MessageInput Component Placeholder
-); +const MessageInput: FC = () =>
MessageInput Component Placeholder
; export default MessageInput; diff --git a/packages/webui/src/components/messages/MessageList.tsx b/packages/webui/src/components/messages/MessageList.tsx index 5a322929c..218c843bc 100644 --- a/packages/webui/src/components/messages/MessageList.tsx +++ b/packages/webui/src/components/messages/MessageList.tsx @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; -const MessageList: React.FC = () => ( -
MessageList Component Placeholder
-); +const MessageList: FC = () =>
MessageList Component Placeholder
; export default MessageList; diff --git a/packages/webui/src/components/messages/ThinkingMessage.tsx b/packages/webui/src/components/messages/ThinkingMessage.tsx index db91854f8..8e2c5e8b1 100644 --- a/packages/webui/src/components/messages/ThinkingMessage.tsx +++ b/packages/webui/src/components/messages/ThinkingMessage.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; import { MessageContent } from './MessageContent.js'; export interface ThinkingMessageProps { @@ -13,7 +13,7 @@ export interface ThinkingMessageProps { onFileClick?: (path: string) => void; } -export const ThinkingMessage: React.FC = ({ +export const ThinkingMessage: FC = ({ content, timestamp: _timestamp, onFileClick, diff --git a/packages/webui/src/components/messages/UserMessage.tsx b/packages/webui/src/components/messages/UserMessage.tsx index 02b31739b..8f772167e 100644 --- a/packages/webui/src/components/messages/UserMessage.tsx +++ b/packages/webui/src/components/messages/UserMessage.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; import { MessageContent } from './MessageContent.js'; export interface FileContext { @@ -21,7 +21,7 @@ export interface UserMessageProps { fileContext?: FileContext; } -export const UserMessage: React.FC = ({ +export const UserMessage: FC = ({ content, timestamp: _timestamp, onFileClick, diff --git a/packages/webui/src/components/messages/Waiting/InterruptedMessage.tsx b/packages/webui/src/components/messages/Waiting/InterruptedMessage.tsx index 0c0e4c8d3..f077ff0bd 100644 --- a/packages/webui/src/components/messages/Waiting/InterruptedMessage.tsx +++ b/packages/webui/src/components/messages/Waiting/InterruptedMessage.tsx @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; interface InterruptedMessageProps { text?: string; } // A lightweight status line similar to WaitingMessage but without the left status icon. -export const InterruptedMessage: React.FC = ({ +export const InterruptedMessage: FC = ({ text = 'Interrupted', }) => (
diff --git a/packages/webui/src/components/messages/Waiting/WaitingMessage.tsx b/packages/webui/src/components/messages/Waiting/WaitingMessage.tsx index 95b728bf0..61d3448db 100644 --- a/packages/webui/src/components/messages/Waiting/WaitingMessage.tsx +++ b/packages/webui/src/components/messages/Waiting/WaitingMessage.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; import { useEffect, useMemo, useState } from 'react'; interface WaitingMessageProps { @@ -24,9 +24,7 @@ const DEFAULT_LOADING_PHRASES = [ 'Almost there...', ]; -export const WaitingMessage: React.FC = ({ - loadingMessage, -}) => { +export const WaitingMessage: FC = ({ loadingMessage }) => { // Build a phrase list that starts with the provided message (if any), then witty fallbacks const phrases = useMemo(() => { const set = new Set(); diff --git a/packages/webui/src/components/toolcalls/CheckboxDisplay.tsx b/packages/webui/src/components/toolcalls/CheckboxDisplay.tsx index 555c33c32..e08a02e6a 100644 --- a/packages/webui/src/components/toolcalls/CheckboxDisplay.tsx +++ b/packages/webui/src/components/toolcalls/CheckboxDisplay.tsx @@ -6,7 +6,7 @@ * Display-only checkbox component for plan entries */ -import type React from 'react'; +import type { FC } from 'react'; export interface CheckboxDisplayProps { checked?: boolean; @@ -23,7 +23,7 @@ export interface CheckboxDisplayProps { * - Supports indeterminate (middle) state using a data- attribute. * - Intended for read-only display (disabled by default). */ -export const CheckboxDisplay: React.FC = ({ +export const CheckboxDisplay: FC = ({ checked = false, indeterminate = false, disabled = true, diff --git a/packages/webui/src/components/toolcalls/GenericToolCall.tsx b/packages/webui/src/components/toolcalls/GenericToolCall.tsx index 1c003f1e4..0f62c0a0e 100644 --- a/packages/webui/src/components/toolcalls/GenericToolCall.tsx +++ b/packages/webui/src/components/toolcalls/GenericToolCall.tsx @@ -6,7 +6,7 @@ * Generic tool call component - handles all tool call types as fallback */ -import type React from 'react'; +import type { FC } from 'react'; import { ToolCallContainer, ToolCallCard, @@ -22,7 +22,7 @@ import type { BaseToolCallProps } from './shared/index.js'; * Used as fallback for unknown tool call kinds * Minimal display: show description and outcome */ -export const GenericToolCall: React.FC = ({ toolCall }) => { +export const GenericToolCall: FC = ({ toolCall }) => { const { kind, title, content, locations, toolCallId } = toolCall; const operationText = safeTitle(title); diff --git a/packages/webui/src/components/toolcalls/ReadToolCall.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.tsx index 874dca50c..a397a12ee 100644 --- a/packages/webui/src/components/toolcalls/ReadToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ReadToolCall.tsx @@ -7,7 +7,7 @@ * Pure UI component - platform interactions via usePlatform hook */ -import type React from 'react'; +import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { FileLink } from '../layout/FileLink.js'; import { @@ -23,7 +23,7 @@ import type { /** * Simple container for Read tool calls */ -const ReadToolCallContainer: React.FC = ({ +const ReadToolCallContainer: FC = ({ label, status = 'success', children, @@ -56,7 +56,7 @@ const ReadToolCallContainer: React.FC = ({ * ReadToolCall - displays file reading operations * Shows: Read filename (no content preview) */ -export const ReadToolCall: React.FC = ({ toolCall }) => { +export const ReadToolCall: FC = ({ toolCall }) => { const { kind, content, locations, toolCallId } = toolCall; const platform = usePlatform(); const openedDiffsRef = useRef>(new Map()); diff --git a/packages/webui/src/components/toolcalls/SearchToolCall.tsx b/packages/webui/src/components/toolcalls/SearchToolCall.tsx index f34d355ee..c1fdb4b15 100644 --- a/packages/webui/src/components/toolcalls/SearchToolCall.tsx +++ b/packages/webui/src/components/toolcalls/SearchToolCall.tsx @@ -6,7 +6,7 @@ * Search tool call component - specialized for search operations */ -import type React from 'react'; +import type { FC } from 'react'; import { safeTitle, groupContent, @@ -18,7 +18,7 @@ import { FileLink } from '../layout/FileLink.js'; /** * Inline container for compact search results display */ -const InlineContainer: React.FC<{ +const InlineContainer: FC<{ status: 'success' | 'error' | 'warning' | 'loading' | 'default'; labelSuffix?: string; children?: React.ReactNode; @@ -68,7 +68,7 @@ const InlineContainer: React.FC<{ /** * Card layout for multi-result or error display */ -const SearchCard: React.FC<{ +const SearchCard: FC<{ status: 'success' | 'error' | 'warning' | 'loading' | 'default'; children: React.ReactNode; isFirst?: boolean; @@ -109,7 +109,7 @@ const SearchCard: React.FC<{ /** * Row component for search card layout */ -const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ +const SearchRow: FC<{ label: string; children: React.ReactNode }> = ({ label, children, }) => ( @@ -126,7 +126,7 @@ const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({ /** * Local locations list component */ -const LocationsListLocal: React.FC<{ +const LocationsListLocal: FC<{ locations: Array<{ path: string; line?: number | null }>; }> = ({ locations }) => (
@@ -156,7 +156,7 @@ const getDisplayLabel = (kind: string): string => { * Specialized component for Search tool calls * Optimized for displaying search operations and results */ -export const SearchToolCall: React.FC = ({ +export const SearchToolCall: FC = ({ toolCall, isFirst, isLast, diff --git a/packages/webui/src/components/toolcalls/ShellToolCall.tsx b/packages/webui/src/components/toolcalls/ShellToolCall.tsx index 2ac4030cf..79e71b6bc 100644 --- a/packages/webui/src/components/toolcalls/ShellToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ShellToolCall.tsx @@ -7,7 +7,7 @@ * Pure UI component - platform interactions via usePlatform hook */ -import type React from 'react'; +import type { FC } from 'react'; import { ToolCallContainer, CopyButton, @@ -27,7 +27,7 @@ type ShellVariant = 'execute' | 'bash'; /** * Custom container for Execute variant with different styling */ -const ExecuteToolCallContainer: React.FC = ({ +const ExecuteToolCallContainer: FC = ({ label, status = 'success', children, @@ -92,9 +92,10 @@ const getInputCommand = ( /** * Shell tool call implementation */ -const ShellToolCallImpl: React.FC< - BaseToolCallProps & { variant: ShellVariant } -> = ({ toolCall, variant }) => { +const ShellToolCallImpl: FC = ({ + toolCall, + variant, +}) => { const { title, content, rawInput, toolCallId } = toolCall; const classPrefix = variant; const platform = usePlatform(); @@ -263,7 +264,7 @@ const ShellToolCallImpl: React.FC< * ShellToolCall - displays bash/execute command tool calls * Shows command input and output with IN/OUT cards */ -export const ShellToolCall: React.FC = (props) => { +export const ShellToolCall: FC = (props) => { const normalizedKind = props.toolCall.kind.toLowerCase(); const variant: ShellVariant = normalizedKind === 'execute' ? 'execute' : 'bash'; diff --git a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx index 1bcf393a7..2acce711b 100644 --- a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx @@ -6,7 +6,7 @@ * Think tool call component - specialized for thinking/reasoning operations */ -import type React from 'react'; +import type { FC } from 'react'; import { ToolCallContainer, ToolCallCard, @@ -20,7 +20,7 @@ import type { BaseToolCallProps } from './shared/index.js'; * Optimized for displaying AI reasoning and thought processes * Minimal display: just show the thoughts (no context) */ -export const ThinkToolCall: React.FC = ({ toolCall }) => { +export const ThinkToolCall: FC = ({ toolCall }) => { const { content } = toolCall; // Group content by type diff --git a/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx b/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx index e07694874..fcccfccfa 100644 --- a/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx +++ b/packages/webui/src/components/toolcalls/UpdatedPlanToolCall.tsx @@ -6,7 +6,7 @@ * UpdatedPlan tool call component - specialized for plan update operations */ -import type React from 'react'; +import type { FC } from 'react'; import { groupContent, safeTitle } from './shared/index.js'; import type { BaseToolCallProps, @@ -20,7 +20,7 @@ import { CheckboxDisplay } from './CheckboxDisplay.js'; /** * Custom container for UpdatedPlanToolCall with specific styling */ -const PlanToolCallContainer: React.FC = ({ +const PlanToolCallContainer: FC = ({ label, status = 'success', children, @@ -113,9 +113,7 @@ const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => { * Specialized component for UpdatedPlan tool calls * Optimized for displaying plan update operations */ -export const UpdatedPlanToolCall: React.FC = ({ - toolCall, -}) => { +export const UpdatedPlanToolCall: FC = ({ toolCall }) => { const { content, status } = toolCall; const { errors, textOutputs } = groupContent(content); diff --git a/packages/webui/src/components/toolcalls/WriteToolCall.tsx b/packages/webui/src/components/toolcalls/WriteToolCall.tsx index a940f07af..ec2c6d1f0 100644 --- a/packages/webui/src/components/toolcalls/WriteToolCall.tsx +++ b/packages/webui/src/components/toolcalls/WriteToolCall.tsx @@ -6,7 +6,7 @@ * Write tool call component - specialized for file writing operations */ -import type React from 'react'; +import type { FC } from 'react'; import { ToolCallContainer, groupContent, @@ -19,7 +19,7 @@ import { FileLink } from '../layout/FileLink.js'; * Specialized component for Write tool calls * Shows: Write filename + error message + content preview */ -export const WriteToolCall: React.FC = ({ toolCall }) => { +export const WriteToolCall: FC = ({ toolCall }) => { const { content, locations, rawInput, toolCallId } = toolCall; // Group content by type diff --git a/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx b/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx index 1433d261b..7e0b01deb 100644 --- a/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx +++ b/packages/webui/src/components/toolcalls/shared/LayoutComponents.tsx @@ -7,7 +7,7 @@ * Platform-agnostic version using webui components */ -import type React from 'react'; +import type { FC } from 'react'; import { FileLink } from '../../layout/FileLink.js'; import './LayoutComponents.css'; @@ -33,7 +33,7 @@ export interface ToolCallContainerProps { * ToolCallContainer - Main container for tool call displays * Features timeline connector line and status bullet */ -export const ToolCallContainer: React.FC = ({ +export const ToolCallContainer: FC = ({ label, status = 'success', children, @@ -73,7 +73,7 @@ interface ToolCallCardProps { /** * ToolCallCard - Legacy card wrapper for complex layouts like diffs */ -export const ToolCallCard: React.FC = ({ +export const ToolCallCard: FC = ({ icon: _icon, children, }) => ( @@ -93,10 +93,7 @@ interface ToolCallRowProps { /** * ToolCallRow - A single row in the tool call grid (legacy - for complex layouts) */ -export const ToolCallRow: React.FC = ({ - label, - children, -}) => ( +export const ToolCallRow: FC = ({ label, children }) => (
{label} @@ -138,10 +135,7 @@ const getStatusColorClass = ( /** * StatusIndicator - Status indicator with colored dot */ -export const StatusIndicator: React.FC = ({ - status, - text, -}) => ( +export const StatusIndicator: FC = ({ status, text }) => (
= ({ children }) => ( +export const CodeBlock: FC = ({ children }) => (
     {children}
   
@@ -179,7 +173,7 @@ interface LocationsListProps { /** * LocationsList - List of file locations with clickable links */ -export const LocationsList: React.FC = ({ locations }) => ( +export const LocationsList: FC = ({ locations }) => (
{locations.map((loc, idx) => ( diff --git a/packages/webui/src/components/toolcalls/shared/copyUtils.tsx b/packages/webui/src/components/toolcalls/shared/copyUtils.tsx index e71169aa1..3ea1cf5a7 100644 --- a/packages/webui/src/components/toolcalls/shared/copyUtils.tsx +++ b/packages/webui/src/components/toolcalls/shared/copyUtils.tsx @@ -6,7 +6,7 @@ * Shared copy utilities for toolcall components */ -import type React from 'react'; +import type { FC } from 'react'; import { useState, useCallback } from 'react'; import { usePlatform } from '../../../context/PlatformContext.js'; @@ -46,7 +46,7 @@ interface CopyButtonProps { * Uses PlatformContext for platform-specific clipboard access with fallback * Note: Parent element should have 'group' class for hover effect */ -export const CopyButton: React.FC = ({ text }) => { +export const CopyButton: FC = ({ text }) => { const [showTooltip, setShowTooltip] = useState(false); const platform = usePlatform(); diff --git a/packages/webui/src/components/ui/Button.tsx b/packages/webui/src/components/ui/Button.tsx index b86a8dcef..e577b1d80 100644 --- a/packages/webui/src/components/ui/Button.tsx +++ b/packages/webui/src/components/ui/Button.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { ReactNode } from 'react'; import { forwardRef } from 'react'; /** @@ -28,7 +28,7 @@ export type ButtonSize = 'sm' | 'md' | 'lg'; export interface ButtonProps extends React.ButtonHTMLAttributes { /** Button content */ - children: React.ReactNode; + children: ReactNode; /** Visual style variant */ variant?: ButtonVariant; /** Button size */ @@ -36,9 +36,9 @@ export interface ButtonProps /** Loading state - shows spinner and disables button */ loading?: boolean; /** Icon to display before children */ - leftIcon?: React.ReactNode; + leftIcon?: ReactNode; /** Icon to display after children */ - rightIcon?: React.ReactNode; + rightIcon?: ReactNode; /** Full width button */ fullWidth?: boolean; } diff --git a/packages/webui/src/components/ui/Input.tsx b/packages/webui/src/components/ui/Input.tsx index 8ac2c8395..57ba5d76a 100644 --- a/packages/webui/src/components/ui/Input.tsx +++ b/packages/webui/src/components/ui/Input.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { ReactNode } from 'react'; import { forwardRef } from 'react'; /** @@ -28,9 +28,9 @@ export interface InputProps /** Helper text below input */ helperText?: string; /** Left icon/element */ - leftElement?: React.ReactNode; + leftElement?: ReactNode; /** Right icon/element */ - rightElement?: React.ReactNode; + rightElement?: ReactNode; /** Full width input */ fullWidth?: boolean; } diff --git a/packages/webui/src/components/ui/Tooltip.tsx b/packages/webui/src/components/ui/Tooltip.tsx index 05846d637..38ffc4f87 100644 --- a/packages/webui/src/components/ui/Tooltip.tsx +++ b/packages/webui/src/components/ui/Tooltip.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; +import type { FC } from 'react'; /** * Tooltip component props @@ -22,7 +22,7 @@ export interface TooltipProps { * Tooltip component using CSS group-hover for display * Supports CSS variables for theming */ -export const Tooltip: React.FC = ({ +export const Tooltip: FC = ({ children, content, position = 'top', diff --git a/packages/webui/src/context/PlatformContext.tsx b/packages/webui/src/context/PlatformContext.tsx index 9a48baca5..793d58518 100644 --- a/packages/webui/src/context/PlatformContext.tsx +++ b/packages/webui/src/context/PlatformContext.tsx @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type React from 'react'; import { createContext, useContext } from 'react'; +import type { ReactNode } from 'react'; /** * Platform types supported by the webui library @@ -88,7 +88,7 @@ export function usePlatform(): PlatformContextValue { * Provider component props */ export interface PlatformProviderProps { - children: React.ReactNode; + children: ReactNode; value: PlatformContextValue; } diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 2054d9e1c..0ffa52c8e 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -205,3 +205,6 @@ export type { CompletionItem, CompletionItemType } from './types/completion'; // Utils export { groupSessionsByDate, getTimeAgo } from './utils/sessionGrouping'; export type { SessionGroup } from './utils/sessionGrouping'; + +// VSCode Webview utilities +export { default as WebviewContainer } from './components/WebviewContainer'; diff --git a/packages/webui/src/styles/variables.css b/packages/webui/src/styles/variables.css index 81e88966a..2c955c8c6 100644 --- a/packages/webui/src/styles/variables.css +++ b/packages/webui/src/styles/variables.css @@ -78,8 +78,8 @@ --app-radius-sm: 0.25rem; --app-radius-md: 0.375rem; --app-radius-lg: 0.5rem; - --corner-radius-small: 6px; - --corner-radius-medium: 8px; + --qwen-corner-radius-small: 6px; + --qwen-corner-radius-medium: 8px; /* =========================== Spacing diff --git a/packages/webui/src/styles/webview.css b/packages/webui/src/styles/webview.css new file mode 100644 index 000000000..aaddf7861 --- /dev/null +++ b/packages/webui/src/styles/webview.css @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Isolated styles for VSCode webview environments to prevent conflicts + */ + +/* Isolate all webui styles under a specific namespace to prevent global conflicts in webviews */ +.qwen-webui-container { + /* Apply all CSS variables in the isolated namespace */ + --app-primary: var(--app-primary, #3b82f6); + --app-primary-hover: var(--app-primary-hover, #2563eb); + --app-primary-foreground: var(--app-primary-foreground, #e4e4e7); + --app-secondary-foreground: var(--app-secondary-foreground, #a1a1aa); + + --app-background: var(--app-background, #1e1e1e); + --app-primary-background: var(--app-primary-background, #1e1e1e); + --app-background-secondary: var(--app-background-secondary, #252526); + --app-secondary-background: var(--app-secondary-background, #252526); + --app-background-tertiary: var(--app-background-tertiary, #2d2d2d); + + --app-foreground: var(--app-foreground, #e4e4e7); + --app-foreground-secondary: var(--app-foreground-secondary, #a1a1aa); + --app-foreground-muted: var(--app-foreground-muted, #71717a); + + --app-border: var(--app-border, #3f3f46); + --app-border-focus: var(--app-border-focus, #3b82f6); + --app-primary-border-color: var(--app-primary-border-color, #3f3f46); + + --app-success: var(--app-success, #10b981); + --app-warning: var(--app-warning, #f59e0b); + --app-error: var(--app-error, #ef4444); + --app-info: var(--app-info, #3b82f6); + + --app-font-sans: var(--app-font-sans, system-ui, -apple-system, sans-serif); + --app-font-mono: var(--app-font-mono, ui-monospace, 'SF Mono', monospace); + --app-monospace-font-size: var(--app-monospace-font-size, 13px); + + --app-link-foreground: var(--app-link-foreground, #007acc); + --app-link-active-foreground: var(--app-link-active-foreground, #005a9e); + + --app-qwen-ivory: var(--app-qwen-ivory, #f5f5dc); + + --app-radius-sm: var(--app-radius-sm, 0.25rem); + --app-radius-md: var(--app-radius-md, 0.375rem); + --app-radius-lg: var(--app-radius-lg, 0.5rem); + --qwen-corner-radius-small: var(--qwen-corner-radius-small, 6px); + --qwen-corner-radius-medium: var(--qwen-corner-radius-medium, 8px); + + --app-spacing-xs: var(--app-spacing-xs, 0.25rem); + --app-spacing-sm: var(--app-spacing-sm, 0.5rem); + --app-spacing-md: var(--app-spacing-md, 1rem); + --app-spacing-medium: var(--app-spacing-medium, 8px); + --app-spacing-lg: var(--app-spacing-lg, 1.5rem); + --app-spacing-xl: var(--app-spacing-xl, 2rem); + + --app-input-background: var(--app-input-background, #3c3c3c); + --app-input-secondary-background: var(--app-input-secondary-background, #2d2d2d); + --app-input-border: var(--app-input-border, #3f3f46); + --app-input-foreground: var(--app-input-foreground, #e4e4e7); + --app-input-placeholder-foreground: var(--app-input-placeholder-foreground, #71717a); + + --app-ghost-button-hover-background: var(--app-ghost-button-hover-background, rgba(90, 93, 94, 0.31)); + --app-button-background: var(--app-button-background, #3c3c3c); + --app-button-foreground: var(--app-button-foreground, #ffffff); + --app-transparent-inner-border: var(--app-transparent-inner-border, rgba(255, 255, 255, 0.1)); + + --app-header-background: var(--app-header-background, #252526); + + --app-list-padding: var(--app-list-padding, 0px); + --app-list-item-padding: var(--app-list-item-padding, 4px 8px); + --app-list-border-color: var(--app-list-border-color, transparent); + --app-list-border-radius: var(--app-list-border-radius, 4px); + --app-list-hover-background: var(--app-list-hover-background, rgba(90, 93, 94, 0.31)); + --app-list-active-background: var(--app-list-active-background, #094771); + --app-list-active-foreground: var(--app-list-active-foreground, #ffffff); + --app-list-gap: var(--app-list-gap, 2px); + + --app-menu-background: var(--app-menu-background, #252526); + --app-menu-border: var(--app-menu-border, #454545); + --app-menu-foreground: var(--app-menu-foreground, #cccccc); + --app-menu-selection-background: var(--app-menu-selection-background, #094771); + --app-menu-selection-foreground: var(--app-menu-selection-foreground, #ffffff); + + --app-tool-background: var(--app-tool-background, #1e1e1e); + --app-code-background: var(--app-code-background, #2d2d2d); + + --app-warning-background: var(--app-warning-background, rgba(255, 204, 0, 0.1)); + --app-warning-border: var(--app-warning-border, #ffcc00); + --app-warning-foreground: var(--app-warning-foreground, #ffcc00); +} + +/* Reset potential conflicts with VSCode webview styles */ +.qwen-webui-container *, +.qwen-webui-container *::before, +.qwen-webui-container *::after { + box-sizing: border-box; +} + +/* Prevent styles from bleeding out of the container */ +.qwen-webui-container { + all: inherit; + font: inherit; + line-height: inherit; + letter-spacing: inherit; + color: var(--app-foreground); + background: var(--app-background); + width: 100%; + height: 100%; +} + +/* Isolated component styles */ +.qwen-webui-container .code-block { + font-family: var(--app-font-mono); + font-size: var(--app-monospace-font-size, 13px); + background: var(--app-primary-background); + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-sm, 4px); + padding: var(--app-spacing-medium, 8px); + overflow-x: auto; + margin: 4px 0 0 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; + overflow-y: auto; +} + +.qwen-webui-container .diff-display-container { + margin: 8px 0; + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-md, 6px); + overflow: hidden; +} + +.qwen-webui-container .diff-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--app-input-secondary-background, var(--app-background-secondary)); + border-bottom: 1px solid var(--app-input-border); +} + +.qwen-webui-container .diff-file-path { + font-family: var(--app-font-mono); + font-size: 13px; + color: var(--app-primary-foreground); +} + +.qwen-webui-container .open-diff-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--app-input-border); + border-radius: var(--app-radius-sm, 4px); + color: var(--app-primary-foreground); + cursor: pointer; + font-size: 12px; + transition: background-color 0.15s; +} + +.qwen-webui-container .open-diff-button:hover { + background: var(--app-ghost-button-hover-background); +} + +.qwen-webui-container .diff-label { + padding: 8px 12px; + background: var(--app-primary-background); + border-bottom: 1px solid var(--app-input-border); + font-size: 11px; + font-weight: 600; + color: var(--app-secondary-foreground); + text-transform: uppercase; +} + +/* Timeline styles in isolation */ +.qwen-webui-container .toolcall-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +.qwen-webui-container .toolcall-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +.qwen-webui-container .toolcall-container:first-child::after { + top: 24px; +} + +.qwen-webui-container .toolcall-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +.qwen-webui-container .assistant-message-container { + position: relative; + padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; +} + +.qwen-webui-container .assistant-message-container::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); +} + +.qwen-webui-container .assistant-message-container:first-child::after { + top: 24px; +} + +.qwen-webui-container .assistant-message-container:last-child::after { + height: calc(100% - 24px); + top: 0; + bottom: auto; +} + +.qwen-webui-container .qwen-message.message-item:not(.user-message-container)::after { + content: ''; + position: absolute; + left: 12px; + top: 0; + bottom: 0; + width: 1px; + background-color: var(--app-primary-border-color); + z-index: 0; +} + +.qwen-webui-container .message-item { + padding: 8px 0; + width: 100%; + align-items: flex-start; + padding-left: 30px; + user-select: text; + position: relative; + padding-top: 8px; + padding-bottom: 8px; +} \ No newline at end of file diff --git a/packages/webui/src/types/completion.ts b/packages/webui/src/types/completion.ts index ebecd1b89..bef887625 100644 --- a/packages/webui/src/types/completion.ts +++ b/packages/webui/src/types/completion.ts @@ -6,7 +6,7 @@ * Completion item types for autocomplete menus */ -import type React from 'react'; +import type { ReactNode } from 'react'; /** * Completion item type categories @@ -30,7 +30,7 @@ export interface CompletionItem { /** Optional description shown below label */ description?: string; /** Optional icon to display */ - icon?: React.ReactNode; + icon?: ReactNode; /** Type of completion item */ type: CompletionItemType; /** Value inserted into the input when selected (e.g., filename or command) */ From 7de36190ff41938b7662e3f08a258889051fb7c0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sat, 17 Jan 2026 13:50:50 +0800 Subject: [PATCH 019/113] refactor(webui): Unified Tools Invoke Component Label Display and Style Variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除多余的工具调用类型分支,统一使用简洁的标签显示,并添加必要的CSS 变量以支持VSCode兼容性和界面一致性。 - 移除 read_many_files、list_directory、ls 等冗余工具类型分支 - 简化 ReadToolCall 组件中的标签逻辑,统一使用 "Read" 标签 - 为 ShellToolCall 添加基于变体的动态标签(Execute/Bash) - 在 CSS 变量中添加 xs 字体大小、圆角和间距变量 - 增加 VSCode 兼容性样式变量以改善集成体验 ``` --- .../components/messages/toolcalls/index.tsx | 3 --- .../src/components/toolcalls/ReadToolCall.tsx | 24 ++++--------------- .../components/toolcalls/ShellToolCall.tsx | 7 +++--- packages/webui/src/styles/variables.css | 15 ++++++++++++ 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index d20e07188..5f4769573 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -31,9 +31,6 @@ export const getToolCallComponent = (kind: string): FC => { // Route to specialized components switch (normalizedKind) { case 'read': - case 'read_many_files': - case 'list_directory': - case 'ls': return ReadToolCall; case 'write': diff --git a/packages/webui/src/components/toolcalls/ReadToolCall.tsx b/packages/webui/src/components/toolcalls/ReadToolCall.tsx index a397a12ee..a8fa508ae 100644 --- a/packages/webui/src/components/toolcalls/ReadToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ReadToolCall.tsx @@ -57,26 +57,10 @@ const ReadToolCallContainer: FC = ({ * Shows: Read filename (no content preview) */ export const ReadToolCall: FC = ({ toolCall }) => { - const { kind, content, locations, toolCallId } = toolCall; + const { content, locations, toolCallId } = toolCall; const platform = usePlatform(); const openedDiffsRef = useRef>(new Map()); - /** - * Map tool call kind to appropriate display name - */ - const getDisplayLabel = (): string => { - const normalizedKind = kind.toLowerCase(); - if (normalizedKind === 'read_many_files') { - return 'ReadManyFiles'; - } else if (normalizedKind === 'list_directory' || normalizedKind === 'ls') { - return 'ListFiles'; - } else if (normalizedKind === 'skill') { - return 'Skill'; - } else { - return 'ReadFile'; - } - }; - // Group content by type; memoize to avoid new array identities on every render const { errors, diffs } = useMemo(() => groupContent(content), [content]); @@ -146,7 +130,7 @@ export const ReadToolCall: FC = ({ toolCall }) => { const path = locations?.[0]?.path || ''; return ( = ({ toolCall }) => { const path = diffs[0]?.path || locations?.[0]?.path || ''; return ( = ({ toolCall }) => { const path = locations[0].path; return ( = ({ const Container = variant === 'execute' ? ExecuteToolCallContainer : ToolCallContainer; + const label = variant === 'execute' ? 'Execute' : 'Bash'; // Group content by type const { textOutputs, errors } = groupContent(content); @@ -156,7 +157,7 @@ const ShellToolCallImpl: FC = ({ // Error case if (errors.length > 0) { return ( - + {/* Branch connector summary */}
@@ -202,7 +203,7 @@ const ShellToolCallImpl: FC = ({ output.length > 500 ? output.substring(0, 500) + '...' : output; return ( - + {/* Branch connector summary */}
@@ -247,7 +248,7 @@ const ShellToolCallImpl: FC = ({ // Success without output: show command with branch connector return ( - +
Date: Sun, 18 Jan 2026 19:34:17 +0800 Subject: [PATCH 020/113] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80LSP=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=B9=B6=E6=89=A9=E5=B1=95=E6=93=8D=E4=BD=9C=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建统一的LSP工具,整合了之前的多个分散LSP工具 - 增加对更多LSP操作的支持,包括hover、documentSymbol、goToImplementation等 - 扩展LSP类型定义,支持Call Hierarchy等高级功能 - 更新配置和测试文件以适配新的LSP工具架构 - 保持向后兼容性,同时引入新工具名称映射 Co-authored-by: Qwen-Coder 此更改是LSP工具重构计划的一部分,旨在提供更统一和功能完备的LSP集成体验。 --- packages/cli/LSP_DEBUGGING_GUIDE.md | 39 +- packages/cli/src/config/config.test.ts | 59 +- packages/cli/src/config/config.ts | 63 + packages/cli/src/config/settingsSchema.ts | 11 + .../src/services/lsp/LspConnectionFactory.ts | 36 +- .../src/services/lsp/NativeLspService.test.ts | 6 + .../cli/src/services/lsp/NativeLspService.ts | 1456 +++++++++++++++-- packages/core/src/config/config.ts | 4 + packages/core/src/lsp/types.ts | 120 ++ packages/core/src/tools/lsp.test.ts | 1220 ++++++++++++++ packages/core/src/tools/lsp.ts | 960 +++++++++++ packages/core/src/tools/tool-names.ts | 4 + .../LSP_REFACTORING_PLAN.md | 255 +++ 13 files changed, 4092 insertions(+), 141 deletions(-) create mode 100644 packages/core/src/tools/lsp.test.ts create mode 100644 packages/core/src/tools/lsp.ts create mode 100644 packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md diff --git a/packages/cli/LSP_DEBUGGING_GUIDE.md b/packages/cli/LSP_DEBUGGING_GUIDE.md index 7833e8b87..75c018ecf 100644 --- a/packages/cli/LSP_DEBUGGING_GUIDE.md +++ b/packages/cli/LSP_DEBUGGING_GUIDE.md @@ -24,6 +24,7 @@ LSP 功能通过设置系统配置,包含以下选项: - `lsp.excluded`: 排除的 LSP 服务器名称黑名单 在 settings.json 中的示例配置: + ```json { "lsp": { @@ -34,20 +35,26 @@ LSP 功能通过设置系统配置,包含以下选项: } ``` +也可以在 `settings.json` 中配置 `lsp.languageServers`,格式与 `.lsp.json` 一致。 + ## 3. NativeLspService 调试功能 `NativeLspService` 类包含几个调试功能: ### 3.1 控制台日志 + 服务向控制台输出状态消息: + - `LSP 服务器 ${name} 启动成功` - 服务器成功启动 - `LSP 服务器 ${name} 启动失败` - 服务器启动失败 - `工作区不受信任,跳过 LSP 服务器发现` - 工作区不受信任,跳过发现 ### 3.2 错误处理 + 服务具有全面的错误处理和详细的错误消息 ### 3.3 状态跟踪 + 您可以通过 `getStatus()` 方法检查所有 LSP 服务器的状态 ## 4. 调试命令 @@ -62,7 +69,30 @@ qwen --debug --prompt "调试 LSP 功能" ## 5. 手动 LSP 服务器配置 -您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器: +您还可以在项目根目录使用 `.lsp.json` 文件手动配置 LSP 服务器。 +推荐使用新格式(以服务器名称为键),旧格式仍然兼容但会提示迁移: + +```json +{ + "languageServers": { + "pylsp": { + "command": "pylsp", + "args": [], + "languages": ["python"], + "transport": "stdio", + "settings": {}, + "workspaceFolder": null, + "startupTimeout": 10000, + "shutdownTimeout": 3000, + "restartOnCrash": true, + "maxRestarts": 3, + "trustRequired": true + } + } +} +``` + +旧格式示例: ```json { @@ -78,15 +108,18 @@ qwen --debug --prompt "调试 LSP 功能" ## 6. LSP 问题排查 ### 6.1 检查 LSP 服务器是否已安装 + - 对于 TypeScript/JavaScript: `typescript-language-server` -- 对于 Python: `pylsp` +- 对于 Python: `pylsp` - 对于 Go: `gopls` ### 6.2 验证工作区信任 + - LSP 服务器可能需要受信任的工作区才能启动 - 检查 `security.folderTrust.enabled` 设置 ### 6.3 查看日志 + - 查找以 `LSP 服务器` 开头的控制台消息 - 检查命令存在性和路径安全性问题 @@ -104,4 +137,4 @@ LSP 服务的启动遵循以下流程: - 使用 `--debug` 标志查看详细的启动过程 - 检查工作区是否受信任(影响 LSP 服务器启动) - 确认 LSP 服务器命令在系统 PATH 中可用 -- 使用 `getStatus()` 方法监控服务器运行状态 \ No newline at end of file +- 使用 `getStatus()` 方法监控服务器运行状态 diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index cce32b209..1aaa521b8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -20,22 +20,24 @@ import { ExtensionStorage, type Extension } from './extension.js'; import * as ServerConfig from '@qwen-code/qwen-code-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; +import { NativeLspService } from '../services/lsp/NativeLspService.js'; -const mockDiscoverAndPrepare = vi.fn(); -const mockStartLsp = vi.fn(); -const mockDefinitions = vi.fn().mockResolvedValue([]); -const mockReferences = vi.fn().mockResolvedValue([]); -const mockWorkspaceSymbols = vi.fn().mockResolvedValue([]); -const nativeLspServiceMock = vi.fn().mockImplementation(() => ({ - discoverAndPrepare: mockDiscoverAndPrepare, - start: mockStartLsp, - definitions: mockDefinitions, - references: mockReferences, - workspaceSymbols: mockWorkspaceSymbols, -})); +const createNativeLspServiceInstance = () => ({ + discoverAndPrepare: vi.fn(), + start: vi.fn(), + definitions: vi.fn().mockResolvedValue([]), + references: vi.fn().mockResolvedValue([]), + workspaceSymbols: vi.fn().mockResolvedValue([]), +}); vi.mock('../services/lsp/NativeLspService.js', () => ({ - NativeLspService: nativeLspServiceMock, + NativeLspService: vi.fn().mockImplementation(() => ({ + discoverAndPrepare: vi.fn(), + start: vi.fn(), + definitions: vi.fn().mockResolvedValue([]), + references: vi.fn().mockResolvedValue([]), + workspaceSymbols: vi.fn().mockResolvedValue([]), + })), })); vi.mock('./trustedFolders.js', () => ({ @@ -44,6 +46,17 @@ vi.mock('./trustedFolders.js', () => ({ .mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted })); +const nativeLspServiceMock = vi.mocked(NativeLspService); +const getLastLspInstance = () => { + const results = nativeLspServiceMock.mock.results; + if (results.length === 0) { + return undefined; + } + return results[results.length - 1]?.value as ReturnType< + typeof createNativeLspServiceInstance + >; +}; + vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); const pathMod = await import('node:path'); @@ -533,16 +546,10 @@ describe('loadCliConfig', () => { beforeEach(() => { vi.resetAllMocks(); - mockDiscoverAndPrepare.mockReset(); - mockStartLsp.mockReset(); - mockWorkspaceSymbols.mockReset(); - mockWorkspaceSymbols.mockResolvedValue([]); nativeLspServiceMock.mockReset(); - nativeLspServiceMock.mockImplementation(() => ({ - discoverAndPrepare: mockDiscoverAndPrepare, - start: mockStartLsp, - workspaceSymbols: mockWorkspaceSymbols, - })); + nativeLspServiceMock.mockImplementation(() => + createNativeLspServiceInstance(), + ); vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); }); @@ -637,8 +644,10 @@ describe('loadCliConfig', () => { expect(config.getLspAllowed()).toEqual(['typescript-language-server']); expect(config.getLspExcluded()).toEqual(['pylsp']); expect(nativeLspServiceMock).toHaveBeenCalledTimes(1); - expect(mockDiscoverAndPrepare).toHaveBeenCalledTimes(1); - expect(mockStartLsp).toHaveBeenCalledTimes(1); + const lspInstance = getLastLspInstance(); + expect(lspInstance).toBeDefined(); + expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1); + expect(lspInstance?.start).toHaveBeenCalledTimes(1); const options = nativeLspServiceMock.mock.calls[0][5]; expect(options?.allowedServers).toEqual(['typescript-language-server']); @@ -664,7 +673,7 @@ describe('loadCliConfig', () => { expect(config.isLspEnabled()).toBe(true); expect(nativeLspServiceMock).not.toHaveBeenCalled(); - expect(mockDiscoverAndPrepare).not.toHaveBeenCalled(); + expect(getLastLspInstance()).toBeUndefined(); }); it('should set showMemoryUsage to false by default from settings if CLI flag is not present', async () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4fdf08079..214e923c9 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -193,6 +193,67 @@ class NativeLspClient implements LspClient { limit, ); } + + /** + * Get hover information (documentation, type info) for a symbol. + */ + hover( + location: Parameters[0], + serverName?: string, + ) { + return this.service.hover(location, serverName); + } + + /** + * Get all symbols in a document. + */ + documentSymbols(uri: string, serverName?: string, limit?: number) { + return this.service.documentSymbols(uri, serverName, limit); + } + + /** + * Find implementations of an interface or abstract method. + */ + implementations( + location: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.implementations(location, serverName, limit); + } + + /** + * Prepare call hierarchy item at a position (functions/methods). + */ + prepareCallHierarchy( + location: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.prepareCallHierarchy(location, serverName, limit); + } + + /** + * Find all functions/methods that call the given function. + */ + incomingCalls( + item: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.incomingCalls(item, serverName, limit); + } + + /** + * Find all functions/methods called by the given function. + */ + outgoingCalls( + item: Parameters[0], + serverName?: string, + limit?: number, + ) { + return this.service.outgoingCalls(item, serverName, limit); + } } function normalizeOutputFormat( @@ -812,6 +873,7 @@ export async function loadCliConfig( const lspEnabled = settings.lsp?.enabled ?? false; const lspAllowed = settings.lsp?.allowed ?? settings.mcp?.allowed; const lspExcluded = settings.lsp?.excluded ?? settings.mcp?.excluded; + const lspLanguageServers = settings.lsp?.languageServers; let lspClient: LspClient | undefined; const question = argv.promptInteractive || argv.prompt || ''; const inputFormat: InputFormat = @@ -1149,6 +1211,7 @@ export async function loadCliConfig( allowedServers: lspAllowed, excludedServers: lspExcluded, requireTrustedWorkspace: folderTrust, + inlineServerConfigs: lspLanguageServers, }, ); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a70590fe8..585e574f2 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1083,6 +1083,17 @@ const SETTINGS_SCHEMA = { 'Optional blocklist of LSP server names that should not start.', showInDialog: false, }, + languageServers: { + type: 'object', + label: 'LSP Language Servers', + category: 'LSP', + requiresRestart: true, + default: {} as Record, + description: + 'Inline LSP server configuration (same format as .lsp.json).', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, }, }, useSmartEdit: { diff --git a/packages/cli/src/services/lsp/LspConnectionFactory.ts b/packages/cli/src/services/lsp/LspConnectionFactory.ts index 9f2e4c9b8..056afc91f 100644 --- a/packages/cli/src/services/lsp/LspConnectionFactory.ts +++ b/packages/cli/src/services/lsp/LspConnectionFactory.ts @@ -228,6 +228,12 @@ interface LspConnection { socket?: net.Socket; } +interface SocketConnectionOptions { + host?: string; + port?: number; + path?: string; +} + interface JsonRpcMessage { jsonrpc: string; id?: number | string; @@ -249,6 +255,7 @@ export class LspConnectionFactory { command: string, args: string[], options?: cp.SpawnOptions, + timeoutMs = 10000, ): Promise { return new Promise((resolve, reject) => { const spawnOptions: cp.SpawnOptions = { @@ -262,7 +269,7 @@ export class LspConnectionFactory { if (!processInstance.killed) { processInstance.kill(); } - }, 10000); + }, timeoutMs); processInstance.once('error', (error) => { clearTimeout(timeoutId); @@ -300,14 +307,37 @@ export class LspConnectionFactory { static async createTcpConnection( host: string, port: number, + timeoutMs = 10000, + ): Promise { + return LspConnectionFactory.createSocketConnection( + { host, port }, + timeoutMs, + ); + } + + /** + * 创建基于 socket 的 LSP 连接(支持 TCP 或 unix socket) + */ + static async createSocketConnection( + options: SocketConnectionOptions, + timeoutMs = 10000, ): Promise { return new Promise((resolve, reject) => { - const socket = net.createConnection({ host, port }); + const socketOptions = options.path + ? { path: options.path } + : { host: options.host ?? '127.0.0.1', port: options.port }; + + if (!('path' in socketOptions) && !socketOptions.port) { + reject(new Error('Socket transport requires port or path')); + return; + } + + const socket = net.createConnection(socketOptions); const timeoutId = setTimeout(() => { reject(new Error('LSP server connection timeout')); socket.destroy(); - }, 10000); + }, timeoutMs); const onError = (error: Error) => { clearTimeout(timeoutId); diff --git a/packages/cli/src/services/lsp/NativeLspService.test.ts b/packages/cli/src/services/lsp/NativeLspService.test.ts index acac65b98..5ee4eff29 100644 --- a/packages/cli/src/services/lsp/NativeLspService.test.ts +++ b/packages/cli/src/services/lsp/NativeLspService.test.ts @@ -1,5 +1,11 @@ import { NativeLspService } from './NativeLspService.js'; import { EventEmitter } from 'events'; +import type { + Config as CoreConfig, + WorkspaceContext, + FileDiscoveryService, + IdeContextStore, +} from '@qwen-code/qwen-code-core'; // 模拟依赖项 class MockConfig { diff --git a/packages/cli/src/services/lsp/NativeLspService.ts b/packages/cli/src/services/lsp/NativeLspService.ts index fe2da4498..77445a2f8 100644 --- a/packages/cli/src/services/lsp/NativeLspService.ts +++ b/packages/cli/src/services/lsp/NativeLspService.ts @@ -3,8 +3,13 @@ import type { WorkspaceContext, FileDiscoveryService, IdeContextStore, - LspLocation, + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, LspDefinition, + LspHoverResult, + LspLocation, + LspRange, LspReference, LspSymbolInformation, } from '@qwen-code/qwen-code-core'; @@ -21,16 +26,31 @@ interface LspInitializationOptions { [key: string]: unknown; } +interface LspSocketOptions { + host?: string; + port?: number; + path?: string; +} + // 定义 LSP 服务器配置类型 interface LspServerConfig { name: string; languages: string[]; - command: string; - args: string[]; - transport: 'stdio' | 'tcp'; + command?: string; + args?: string[]; + transport: 'stdio' | 'tcp' | 'socket'; + env?: Record; initializationOptions?: LspInitializationOptions; + settings?: Record; + extensionToLanguage?: Record; rootUri: string; + workspaceFolder?: string; + startupTimeout?: number; + shutdownTimeout?: number; + restartOnCrash?: boolean; + maxRestarts?: number; trustRequired?: boolean; + socket?: LspSocketOptions; } // 定义 LSP 连接接口 @@ -56,13 +76,52 @@ interface LspServerHandle { process?: ChildProcess; error?: Error; warmedUp?: boolean; + stopRequested?: boolean; + restartAttempts?: number; } +/** + * Symbol kind labels for converting numeric LSP SymbolKind to readable strings. + * Based on the LSP specification: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#symbolKind + */ +const SYMBOL_KIND_LABELS: Record = { + 1: 'File', + 2: 'Module', + 3: 'Namespace', + 4: 'Package', + 5: 'Class', + 6: 'Method', + 7: 'Property', + 8: 'Field', + 9: 'Constructor', + 10: 'Enum', + 11: 'Interface', + 12: 'Function', + 13: 'Variable', + 14: 'Constant', + 15: 'String', + 16: 'Number', + 17: 'Boolean', + 18: 'Array', + 19: 'Object', + 20: 'Key', + 21: 'Null', + 22: 'EnumMember', + 23: 'Struct', + 24: 'Event', + 25: 'Operator', + 26: 'TypeParameter', +}; + +const DEFAULT_LSP_STARTUP_TIMEOUT_MS = 10000; +const DEFAULT_LSP_MAX_RESTARTS = 3; + interface NativeLspServiceOptions { allowedServers?: string[]; excludedServers?: string[]; requireTrustedWorkspace?: boolean; workspaceRoot?: string; + inlineServerConfigs?: Record; } export class NativeLspService { @@ -74,6 +133,8 @@ export class NativeLspService { private excludedServers?: string[]; private requireTrustedWorkspace: boolean; private workspaceRoot: string; + private inlineServerConfigs?: Record; + private warnedLegacyConfig = false; constructor( config: CoreConfig, @@ -92,6 +153,7 @@ export class NativeLspService { this.workspaceRoot = options.workspaceRoot ?? (config as { getProjectRoot: () => string }).getProjectRoot(); + this.inlineServerConfigs = options.inlineServerConfigs; } /** @@ -108,10 +170,13 @@ export class NativeLspService { } // 检测工作区中的语言 - const detectedLanguages = await this.detectLanguages(); + const userConfigs = await this.loadUserConfigs(); + const extensionOverrides = + this.collectExtensionToLanguageOverrides(userConfigs); + const detectedLanguages = await this.detectLanguages(extensionOverrides); // 合并配置:内置预设 + 用户 .lsp.json + 可选 cclsp 兼容转换 - const serverConfigs = await this.mergeConfigs(detectedLanguages); + const serverConfigs = this.mergeConfigs(detectedLanguages, userConfigs); // 创建服务器句柄 for (const config of serverConfigs) { @@ -309,11 +374,338 @@ export class NativeLspService { return []; } + /** + * 获取悬停信息 + */ + async hover( + location: LspLocation, + serverName?: string, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request('textDocument/hover', { + textDocument: { uri: location.uri }, + position: location.range.start, + }); + const normalized = this.normalizeHoverResult(response, name); + if (normalized) { + return normalized; + } + } catch (error) { + console.warn(`LSP textDocument/hover failed for ${name}:`, error); + } + } + + return null; + } + + /** + * 获取文档符号 + */ + async documentSymbols( + uri: string, + serverName?: string, + limit = 200, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/documentSymbol', + { + textDocument: { uri }, + }, + ); + if (!Array.isArray(response)) { + continue; + } + const symbols: LspSymbolInformation[] = []; + for (const item of response) { + if (!item || typeof item !== 'object') { + continue; + } + const itemObj = item as Record; + if (this.isDocumentSymbol(itemObj)) { + this.collectDocumentSymbol(itemObj, uri, name, symbols, limit); + } else { + const normalized = this.normalizeSymbolResult(itemObj, name); + if (normalized) { + symbols.push(normalized); + } + } + if (symbols.length >= limit) { + return symbols.slice(0, limit); + } + } + if (symbols.length > 0) { + return symbols.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/documentSymbol failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 查找实现 + */ + async implementations( + location: LspLocation, + serverName?: string, + limit = 50, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/implementation', + { + textDocument: { uri: location.uri }, + position: location.range.start, + }, + ); + const candidates = Array.isArray(response) + ? response + : response + ? [response] + : []; + const implementations: LspDefinition[] = []; + for (const item of candidates) { + const normalized = this.normalizeLocationResult(item, name); + if (normalized) { + implementations.push(normalized); + if (implementations.length >= limit) { + return implementations.slice(0, limit); + } + } + } + if (implementations.length > 0) { + return implementations.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/implementation failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 准备调用层级 + */ + async prepareCallHierarchy( + location: LspLocation, + serverName?: string, + limit = 50, + ): Promise { + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!serverName || name === serverName), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'textDocument/prepareCallHierarchy', + { + textDocument: { uri: location.uri }, + position: location.range.start, + }, + ); + const candidates = Array.isArray(response) + ? response + : response + ? [response] + : []; + const items: LspCallHierarchyItem[] = []; + for (const item of candidates) { + const normalized = this.normalizeCallHierarchyItem(item, name); + if (normalized) { + items.push(normalized); + if (items.length >= limit) { + return items.slice(0, limit); + } + } + } + if (items.length > 0) { + return items.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP textDocument/prepareCallHierarchy failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 查找调用当前函数的调用者 + */ + async incomingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit = 50, + ): Promise { + const targetServer = serverName ?? item.serverName; + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!targetServer || name === targetServer), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'callHierarchy/incomingCalls', + { + item: this.toCallHierarchyItemParams(item), + }, + ); + if (!Array.isArray(response)) { + continue; + } + const calls: LspCallHierarchyIncomingCall[] = []; + for (const call of response) { + const normalized = this.normalizeIncomingCall(call, name); + if (normalized) { + calls.push(normalized); + if (calls.length >= limit) { + return calls.slice(0, limit); + } + } + } + if (calls.length > 0) { + return calls.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP callHierarchy/incomingCalls failed for ${name}:`, + error, + ); + } + } + + return []; + } + + /** + * 查找当前函数调用的目标 + */ + async outgoingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit = 50, + ): Promise { + const targetServer = serverName ?? item.serverName; + const handles = Array.from(this.serverHandles.entries()).filter( + ([name, handle]) => + handle.status === 'READY' && + handle.connection && + (!targetServer || name === targetServer), + ); + + for (const [name, handle] of handles) { + if (!handle.connection) { + continue; + } + try { + await this.warmupTypescriptServer(handle); + const response = await handle.connection.request( + 'callHierarchy/outgoingCalls', + { + item: this.toCallHierarchyItemParams(item), + }, + ); + if (!Array.isArray(response)) { + continue; + } + const calls: LspCallHierarchyOutgoingCall[] = []; + for (const call of response) { + const normalized = this.normalizeOutgoingCall(call, name); + if (normalized) { + calls.push(normalized); + if (calls.length >= limit) { + return calls.slice(0, limit); + } + } + } + if (calls.length > 0) { + return calls.slice(0, limit); + } + } catch (error) { + console.warn( + `LSP callHierarchy/outgoingCalls failed for ${name}:`, + error, + ); + } + } + + return []; + } + /** * 检测工作区中的编程语言 */ - private async detectLanguages(): Promise { - const patterns = ['**/*.{js,ts,jsx,tsx,py,go,rs,java,cpp,php,rb,cs}']; + private async detectLanguages( + extensionOverrides: Record = {}, + ): Promise { + const extensionMap = this.getExtensionToLanguageMap(extensionOverrides); + const extensions = Object.keys(extensionMap); + const patterns = + extensions.length > 0 ? [`**/*.{${extensions.join(',')}}`] : ['**/*']; const excludePatterns = [ '**/node_modules/**', '**/.git/**', @@ -351,7 +743,7 @@ export class NativeLspService { for (const file of Array.from(files)) { const ext = path.extname(file).slice(1).toLowerCase(); if (ext) { - const lang = this.mapExtensionToLanguage(ext); + const lang = this.mapExtensionToLanguage(ext, extensionMap); if (lang) { languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1); } @@ -413,8 +805,17 @@ export class NativeLspService { /** * 将文件扩展名映射到编程语言 */ - private mapExtensionToLanguage(ext: string): string | null { - const extToLang: { [key: string]: string } = { + private mapExtensionToLanguage( + ext: string, + extensionMap: Record, + ): string | null { + return extensionMap[ext] || null; + } + + private getExtensionToLanguageMap( + extensionOverrides: Record = {}, + ): Record { + const extToLang: Record = { js: 'javascript', ts: 'typescript', jsx: 'javascriptreact', @@ -437,7 +838,37 @@ export class NativeLspService { yml: 'yaml', }; - return extToLang[ext] || null; + for (const [key, value] of Object.entries(extensionOverrides)) { + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (!normalized) { + continue; + } + extToLang[normalized.toLowerCase()] = value; + } + + return extToLang; + } + + private collectExtensionToLanguageOverrides( + configs: LspServerConfig[], + ): Record { + const overrides: Record = {}; + for (const config of configs) { + if (!config.extensionToLanguage) { + continue; + } + for (const [key, value] of Object.entries(config.extensionToLanguage)) { + if (typeof value !== 'string') { + continue; + } + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (!normalized) { + continue; + } + overrides[normalized.toLowerCase()] = value; + } + } + return overrides; } /** @@ -536,7 +967,7 @@ export class NativeLspService { return { name: (itemObj['name'] ?? itemObj['label'] ?? 'symbol') as string, - kind: itemObj['kind'] ? String(itemObj['kind']) : undefined, + kind: this.normalizeSymbolKind(itemObj['kind']), containerName: (itemObj['containerName'] ?? itemObj['container']) as | string | undefined, @@ -557,18 +988,331 @@ export class NativeLspService { }; } + private normalizeRange(range: unknown): LspRange | null { + if (!range || typeof range !== 'object') { + return null; + } + + const rangeObj = range as Record; + const start = rangeObj['start']; + const end = rangeObj['end']; + + if ( + !start || + typeof start !== 'object' || + !end || + typeof end !== 'object' + ) { + return null; + } + + const startObj = start as Record; + const endObj = end as Record; + + return { + start: { + line: Number(startObj['line'] ?? 0), + character: Number(startObj['character'] ?? 0), + }, + end: { + line: Number(endObj['line'] ?? 0), + character: Number(endObj['character'] ?? 0), + }, + }; + } + + private normalizeRanges(ranges: unknown): LspRange[] { + if (!Array.isArray(ranges)) { + return []; + } + + const results: LspRange[] = []; + for (const range of ranges) { + const normalized = this.normalizeRange(range); + if (normalized) { + results.push(normalized); + } + } + + return results; + } + + private normalizeSymbolKind(kind: unknown): string | undefined { + if (typeof kind === 'number') { + return SYMBOL_KIND_LABELS[kind] ?? String(kind); + } + if (typeof kind === 'string') { + const trimmed = kind.trim(); + if (trimmed === '') { + return undefined; + } + const numeric = Number(trimmed); + if (Number.isFinite(numeric) && SYMBOL_KIND_LABELS[numeric]) { + return SYMBOL_KIND_LABELS[numeric]; + } + return trimmed; + } + return undefined; + } + + private normalizeHoverContents(contents: unknown): string { + if (!contents) { + return ''; + } + if (typeof contents === 'string') { + return contents; + } + if (Array.isArray(contents)) { + const parts = contents + .map((item) => this.normalizeHoverContents(item)) + .map((item) => item.trim()) + .filter((item) => item.length > 0); + return parts.join('\n'); + } + if (typeof contents === 'object') { + const contentsObj = contents as Record; + const value = contentsObj['value']; + if (typeof value === 'string') { + const language = contentsObj['language']; + if (typeof language === 'string' && language.trim() !== '') { + return `\`\`\`${language}\n${value}\n\`\`\``; + } + return value; + } + } + return ''; + } + + private normalizeHoverResult( + response: unknown, + serverName: string, + ): LspHoverResult | null { + if (!response) { + return null; + } + if (typeof response !== 'object') { + const contents = this.normalizeHoverContents(response); + if (!contents.trim()) { + return null; + } + return { + contents, + serverName, + }; + } + + const responseObj = response as Record; + const contents = this.normalizeHoverContents(responseObj['contents']); + if (!contents.trim()) { + return null; + } + + const range = this.normalizeRange(responseObj['range']); + return { + contents, + range: range ?? undefined, + serverName, + }; + } + + private normalizeCallHierarchyItem( + item: unknown, + serverName: string, + ): LspCallHierarchyItem | null { + if (!item || typeof item !== 'object') { + return null; + } + + const itemObj = item as Record; + const nameValue = itemObj['name'] ?? itemObj['label'] ?? 'symbol'; + const name = + typeof nameValue === 'string' ? nameValue : String(nameValue ?? ''); + const uri = itemObj['uri']; + + if (!name || typeof uri !== 'string') { + return null; + } + + const range = this.normalizeRange(itemObj['range']); + const selectionRange = + this.normalizeRange(itemObj['selectionRange']) ?? range; + + if (!range || !selectionRange) { + return null; + } + + const serverOverride = + typeof itemObj['serverName'] === 'string' + ? (itemObj['serverName'] as string) + : undefined; + + // Preserve raw numeric kind for server communication + // Priority: rawKind field > numeric kind > parsed numeric string + let rawKind: number | undefined; + if (typeof itemObj['rawKind'] === 'number') { + rawKind = itemObj['rawKind']; + } else if (typeof itemObj['kind'] === 'number') { + rawKind = itemObj['kind']; + } else if (typeof itemObj['kind'] === 'string') { + const parsed = Number(itemObj['kind']); + if (Number.isFinite(parsed)) { + rawKind = parsed; + } + } + + return { + name, + kind: this.normalizeSymbolKind(itemObj['kind']), + rawKind, + detail: + typeof itemObj['detail'] === 'string' + ? (itemObj['detail'] as string) + : undefined, + uri, + range, + selectionRange, + data: itemObj['data'], + serverName: serverOverride ?? serverName, + }; + } + + private normalizeIncomingCall( + item: unknown, + serverName: string, + ): LspCallHierarchyIncomingCall | null { + if (!item || typeof item !== 'object') { + return null; + } + const itemObj = item as Record; + const from = this.normalizeCallHierarchyItem(itemObj['from'], serverName); + if (!from) { + return null; + } + return { + from, + fromRanges: this.normalizeRanges(itemObj['fromRanges']), + }; + } + + private normalizeOutgoingCall( + item: unknown, + serverName: string, + ): LspCallHierarchyOutgoingCall | null { + if (!item || typeof item !== 'object') { + return null; + } + const itemObj = item as Record; + const to = this.normalizeCallHierarchyItem(itemObj['to'], serverName); + if (!to) { + return null; + } + return { + to, + fromRanges: this.normalizeRanges(itemObj['fromRanges']), + }; + } + + private toCallHierarchyItemParams( + item: LspCallHierarchyItem, + ): Record { + // Use rawKind (numeric) for server communication, fallback to parsing kind string + let numericKind: number | undefined = item.rawKind; + if (numericKind === undefined && item.kind !== undefined) { + const parsed = Number(item.kind); + if (Number.isFinite(parsed)) { + numericKind = parsed; + } + } + + return { + name: item.name, + kind: numericKind, + detail: item.detail, + uri: item.uri, + range: item.range, + selectionRange: item.selectionRange, + data: item.data, + }; + } + + private isDocumentSymbol(item: Record): boolean { + const range = item['range']; + const selectionRange = item['selectionRange']; + return ( + typeof range === 'object' && + range !== null && + typeof selectionRange === 'object' && + selectionRange !== null + ); + } + + private collectDocumentSymbol( + item: Record, + uri: string, + serverName: string, + results: LspSymbolInformation[], + limit: number, + containerName?: string, + ): void { + if (results.length >= limit) { + return; + } + + const nameValue = item['name'] ?? item['label'] ?? 'symbol'; + const name = typeof nameValue === 'string' ? nameValue : String(nameValue); + const selectionRange = + this.normalizeRange(item['selectionRange']) ?? + this.normalizeRange(item['range']); + + if (!selectionRange) { + return; + } + + results.push({ + name, + kind: this.normalizeSymbolKind(item['kind']), + containerName, + location: { + uri, + range: selectionRange, + }, + serverName, + }); + + if (results.length >= limit) { + return; + } + + const children = item['children']; + if (Array.isArray(children)) { + for (const child of children) { + if (results.length >= limit) { + break; + } + if (child && typeof child === 'object') { + this.collectDocumentSymbol( + child as Record, + uri, + serverName, + results, + limit, + name, + ); + } + } + } + } + /** * 合并配置:内置预设 + 用户配置 + 兼容层 */ - private async mergeConfigs( + private mergeConfigs( detectedLanguages: string[], - ): Promise { + userConfigs: LspServerConfig[], + ): LspServerConfig[] { // 内置预设配置 const presets = this.getBuiltInPresets(detectedLanguages); - // 用户 .lsp.json 配置(如果存在) - const userConfigs = await this.loadUserConfigs(); - // 合并配置,用户配置优先级更高 const mergedConfigs = [...presets]; @@ -614,6 +1358,7 @@ export class NativeLspService { transport: 'stdio', initializationOptions: {}, rootUri, + workspaceFolder: this.workspaceRoot, trustRequired: true, }); } @@ -627,6 +1372,7 @@ export class NativeLspService { transport: 'stdio', initializationOptions: {}, rootUri, + workspaceFolder: this.workspaceRoot, trustRequired: true, }); } @@ -640,6 +1386,7 @@ export class NativeLspService { transport: 'stdio', initializationOptions: {}, rootUri, + workspaceFolder: this.workspaceRoot, trustRequired: true, }); } @@ -654,63 +1401,331 @@ export class NativeLspService { */ private async loadUserConfigs(): Promise { const configs: LspServerConfig[] = []; + const sources: Array<{ origin: string; data: unknown }> = []; - try { - const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); - if (fs.existsSync(lspConfigPath)) { + if (this.inlineServerConfigs) { + sources.push({ + origin: 'settings.lsp.languageServers', + data: this.inlineServerConfigs, + }); + } + + const lspConfigPath = path.join(this.workspaceRoot, '.lsp.json'); + if (fs.existsSync(lspConfigPath)) { + try { const configContent = fs.readFileSync(lspConfigPath, 'utf-8'); - const userConfig = JSON.parse(configContent); - - // 验证并转换用户配置为内部格式 - if (userConfig && typeof userConfig === 'object') { - for (const [langId, serverSpec] of Object.entries( - userConfig, - ) as Array<[string, Record]>) { - // 转换为文件 URI 格式 - const rootUri = pathToFileURL(this.workspaceRoot).toString(); - - // 驗證 command 不為 undefined - if (!(serverSpec as Record)['command']) { - console.warn(`LSP 配置錯誤: ${langId} 缺少 command 屬性`); - continue; - } - - const serverConfig: LspServerConfig = { - name: (serverSpec as Record)[ - 'command' - ] as string, - languages: [langId], - command: (serverSpec as Record)[ - 'command' - ] as string, - args: - ((serverSpec as Record)['args'] as string[]) || - [], - transport: - ((serverSpec as Record)['transport'] as - | 'stdio' - | 'tcp') || 'stdio', - initializationOptions: (serverSpec as Record)[ - 'initializationOptions' - ] as LspInitializationOptions, - rootUri, - trustRequired: - ((serverSpec as Record)[ - 'trustRequired' - ] as boolean) ?? true, - }; - - configs.push(serverConfig); - } - } + sources.push({ + origin: lspConfigPath, + data: JSON.parse(configContent), + }); + } catch (e) { + console.warn('加载用户 .lsp.json 配置失败:', e); } - } catch (e) { - console.warn('加载用户 .lsp.json 配置失败:', e); + } + + for (const source of sources) { + const parsed = this.parseConfigSource(source.data, source.origin); + if (parsed.usedLegacyFormat && parsed.configs.length > 0) { + this.warnLegacyConfig(source.origin); + } + configs.push(...parsed.configs); } return configs; } + private parseConfigSource( + source: unknown, + origin: string, + ): { configs: LspServerConfig[]; usedLegacyFormat: boolean } { + if (!this.isRecord(source)) { + return { configs: [], usedLegacyFormat: false }; + } + + const configs: LspServerConfig[] = []; + let serverMap: Record = source; + let usedLegacyFormat = false; + + if (this.isRecord(source['languageServers'])) { + serverMap = source['languageServers'] as Record; + } else if (this.isNewFormatServerMap(source)) { + serverMap = source; + } else { + usedLegacyFormat = true; + } + + for (const [key, spec] of Object.entries(serverMap)) { + if (!this.isRecord(spec)) { + continue; + } + + const languagesValue = spec['languages']; + const languages = usedLegacyFormat + ? [key] + : (this.normalizeStringArray(languagesValue) ?? + (typeof languagesValue === 'string' ? [languagesValue] : [])); + + const name = usedLegacyFormat + ? typeof spec['command'] === 'string' + ? (spec['command'] as string) + : key + : key; + + const config = this.buildServerConfig(name, languages, spec, origin); + if (config) { + configs.push(config); + } + } + + return { configs, usedLegacyFormat }; + } + + private buildServerConfig( + name: string, + languages: string[], + spec: Record, + origin: string, + ): LspServerConfig | null { + const transport = this.normalizeTransport(spec['transport']); + const command = + typeof spec['command'] === 'string' + ? (spec['command'] as string) + : undefined; + const args = this.normalizeStringArray(spec['args']) ?? []; + const env = this.normalizeEnv(spec['env']); + const initializationOptions = this.isRecord(spec['initializationOptions']) + ? (spec['initializationOptions'] as LspInitializationOptions) + : undefined; + const settings = this.isRecord(spec['settings']) + ? (spec['settings'] as Record) + : undefined; + const extensionToLanguage = this.normalizeExtensionToLanguage( + spec['extensionToLanguage'], + ); + const workspaceFolder = this.resolveWorkspaceFolder( + spec['workspaceFolder'], + ); + const rootUri = pathToFileURL(workspaceFolder).toString(); + const startupTimeout = this.normalizeTimeout(spec['startupTimeout']); + const shutdownTimeout = this.normalizeTimeout(spec['shutdownTimeout']); + const restartOnCrash = + typeof spec['restartOnCrash'] === 'boolean' + ? (spec['restartOnCrash'] as boolean) + : undefined; + const maxRestarts = this.normalizeMaxRestarts(spec['maxRestarts']); + const trustRequired = + typeof spec['trustRequired'] === 'boolean' + ? (spec['trustRequired'] as boolean) + : true; + const socket = this.normalizeSocketOptions(spec); + + if (transport === 'stdio' && !command) { + console.warn(`LSP config error in ${origin}: ${name} missing command`); + return null; + } + + if (transport !== 'stdio' && !socket) { + console.warn( + `LSP config error in ${origin}: ${name} missing socket info`, + ); + return null; + } + + return { + name, + languages, + command, + args, + transport, + env, + initializationOptions, + settings, + extensionToLanguage, + rootUri, + workspaceFolder, + startupTimeout, + shutdownTimeout, + restartOnCrash, + maxRestarts, + trustRequired, + socket, + }; + } + + private isNewFormatServerMap(value: Record): boolean { + return Object.values(value).some( + (entry) => this.isRecord(entry) && this.isNewFormatServerSpec(entry), + ); + } + + private isNewFormatServerSpec(value: Record): boolean { + return ( + Array.isArray(value['languages']) || + this.isRecord(value['extensionToLanguage']) || + this.isRecord(value['settings']) || + value['workspaceFolder'] !== undefined || + value['startupTimeout'] !== undefined || + value['shutdownTimeout'] !== undefined || + value['restartOnCrash'] !== undefined || + value['maxRestarts'] !== undefined || + this.isRecord(value['env']) || + value['socket'] !== undefined + ); + } + + private warnLegacyConfig(origin: string): void { + if (this.warnedLegacyConfig) { + return; + } + console.warn( + `Legacy LSP config detected in ${origin}. Please migrate to the languageServers format.`, + ); + this.warnedLegacyConfig = true; + } + + private isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + private normalizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + return value.filter((item): item is string => typeof item === 'string'); + } + + private normalizeEnv(value: unknown): Record | undefined { + if (!this.isRecord(value)) { + return undefined; + } + const env: Record = {}; + for (const [key, val] of Object.entries(value)) { + if ( + typeof val === 'string' || + typeof val === 'number' || + typeof val === 'boolean' + ) { + env[key] = String(val); + } + } + return Object.keys(env).length > 0 ? env : undefined; + } + + private normalizeExtensionToLanguage( + value: unknown, + ): Record | undefined { + if (!this.isRecord(value)) { + return undefined; + } + const mapping: Record = {}; + for (const [key, lang] of Object.entries(value)) { + if (typeof lang !== 'string') { + continue; + } + const normalized = key.startsWith('.') ? key.slice(1) : key; + if (!normalized) { + continue; + } + mapping[normalized.toLowerCase()] = lang; + } + return Object.keys(mapping).length > 0 ? mapping : undefined; + } + + private normalizeTransport(value: unknown): 'stdio' | 'tcp' | 'socket' { + if (typeof value !== 'string') { + return 'stdio'; + } + const normalized = value.toLowerCase(); + if (normalized === 'tcp' || normalized === 'socket') { + return normalized; + } + return 'stdio'; + } + + private normalizeTimeout(value: unknown): number | undefined { + if (typeof value !== 'number') { + return undefined; + } + if (!Number.isFinite(value) || value <= 0) { + return undefined; + } + return value; + } + + private normalizeMaxRestarts(value: unknown): number | undefined { + if (typeof value !== 'number') { + return undefined; + } + if (!Number.isFinite(value) || value < 0) { + return undefined; + } + return value; + } + + private normalizeSocketOptions( + value: Record, + ): LspSocketOptions | undefined { + const socketValue = value['socket']; + if (typeof socketValue === 'string') { + return { path: socketValue }; + } + + const source = this.isRecord(socketValue) ? socketValue : value; + const host = + typeof source['host'] === 'string' + ? (source['host'] as string) + : undefined; + const pathValue = + typeof source['path'] === 'string' + ? (source['path'] as string) + : typeof source['socketPath'] === 'string' + ? (source['socketPath'] as string) + : undefined; + const portValue = source['port']; + const port = + typeof portValue === 'number' + ? portValue + : typeof portValue === 'string' + ? Number(portValue) + : undefined; + + const socket: LspSocketOptions = {}; + if (host) { + socket.host = host; + } + if (Number.isFinite(port) && (port as number) > 0) { + socket.port = port as number; + } + if (pathValue) { + socket.path = pathValue; + } + + if (!socket.path && !socket.port) { + return undefined; + } + return socket; + } + + private resolveWorkspaceFolder(value: unknown): string { + if (typeof value !== 'string' || value.trim() === '') { + return this.workspaceRoot; + } + + const resolved = path.isAbsolute(value) + ? path.resolve(value) + : path.resolve(this.workspaceRoot, value); + const root = path.resolve(this.workspaceRoot); + + if (resolved === root || resolved.startsWith(root + path.sep)) { + return resolved; + } + + console.warn( + `LSP workspaceFolder must be within ${this.workspaceRoot}; using workspace root instead.`, + ); + return this.workspaceRoot; + } + /** * 启动单个 LSP 服务器 */ @@ -718,13 +1733,22 @@ export class NativeLspService { name: string, handle: LspServerHandle, ): Promise { - if (this.excludedServers?.includes(name)) { + if (handle.status === 'IN_PROGRESS') { + return; + } + handle.stopRequested = false; + + if (this.isServerInList(this.excludedServers, handle.config)) { console.log(`LSP 服务器 ${name} 在排除列表中,跳过启动`); handle.status = 'FAILED'; return; } - if (this.allowedServers && !this.allowedServers.includes(name)) { + if ( + this.allowedServers && + this.allowedServers.length > 0 && + !this.isServerInList(this.allowedServers, handle.config) + ) { console.log(`LSP 服务器 ${name} 不在允许列表中,跳过启动`); handle.status = 'FAILED'; return; @@ -753,22 +1777,37 @@ export class NativeLspService { } // 检查命令是否存在 - if (!(await this.commandExists(handle.config.command))) { - console.warn(`LSP 服务器 ${name} 的命令不存在: ${handle.config.command}`); - handle.status = 'FAILED'; - return; - } + if (handle.config.command) { + const commandCwd = handle.config.workspaceFolder ?? this.workspaceRoot; + if ( + !(await this.commandExists( + handle.config.command, + handle.config.env, + commandCwd, + )) + ) { + console.warn( + `LSP 服务器 ${name} 的命令不存在: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } - // 检查路径安全性 - if (!this.isPathSafe(handle.config.command, this.workspaceRoot)) { - console.warn( - `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, - ); - handle.status = 'FAILED'; - return; + // 检查路径安全性 + if ( + !this.isPathSafe(handle.config.command, this.workspaceRoot, commandCwd) + ) { + console.warn( + `LSP 服务器 ${name} 的命令路径不安全: ${handle.config.command}`, + ); + handle.status = 'FAILED'; + return; + } } try { + handle.error = undefined; + handle.warmedUp = false; handle.status = 'IN_PROGRESS'; // 创建 LSP 连接 @@ -780,6 +1819,7 @@ export class NativeLspService { await this.initializeLspServer(connection, handle.config); handle.status = 'READY'; + this.attachRestartHandler(name, handle); console.log(`LSP 服务器 ${name} 启动成功`); } catch (error) { handle.status = 'FAILED'; @@ -795,19 +1835,146 @@ export class NativeLspService { name: string, handle: LspServerHandle, ): Promise { + handle.stopRequested = true; + if (handle.connection) { try { - await handle.connection.shutdown(); - handle.connection.end(); + await this.shutdownConnection(handle); } catch (error) { console.error(`关闭 LSP 服务器 ${name} 时出错:`, error); } - } else if (handle.process && !handle.process.killed) { + } else if (handle.process && handle.process.exitCode === null) { handle.process.kill(); } handle.connection = undefined; handle.process = undefined; handle.status = 'NOT_STARTED'; + handle.warmedUp = false; + handle.restartAttempts = 0; + } + + private isServerInList( + list: string[] | undefined, + config: LspServerConfig, + ): boolean { + if (!list || list.length === 0) { + return false; + } + if (list.includes(config.name)) { + return true; + } + if (config.command && list.includes(config.command)) { + return true; + } + return false; + } + + private async shutdownConnection(handle: LspServerHandle): Promise { + if (!handle.connection) { + return; + } + try { + const shutdownPromise = handle.connection.shutdown(); + if (typeof handle.config.shutdownTimeout === 'number') { + await Promise.race([ + shutdownPromise, + new Promise((resolve) => + setTimeout(resolve, handle.config.shutdownTimeout), + ), + ]); + } else { + await shutdownPromise; + } + } finally { + handle.connection.end(); + } + } + + private attachRestartHandler(name: string, handle: LspServerHandle): void { + if (!handle.process) { + return; + } + handle.process.once('exit', (code) => { + if (handle.stopRequested) { + return; + } + if (!handle.config.restartOnCrash) { + handle.status = 'FAILED'; + return; + } + const maxRestarts = handle.config.maxRestarts ?? DEFAULT_LSP_MAX_RESTARTS; + if (maxRestarts <= 0) { + handle.status = 'FAILED'; + return; + } + const attempts = handle.restartAttempts ?? 0; + if (attempts >= maxRestarts) { + console.warn( + `LSP 服务器 ${name} 达到最大重启次数 (${maxRestarts}),停止重启`, + ); + handle.status = 'FAILED'; + return; + } + handle.restartAttempts = attempts + 1; + console.warn( + `LSP 服务器 ${name} 退出 (code ${code ?? 'unknown'}),正在重启 (${handle.restartAttempts}/${maxRestarts})`, + ); + this.resetHandle(handle); + void this.startServer(name, handle); + }); + } + + private resetHandle(handle: LspServerHandle): void { + if (handle.connection) { + handle.connection.end(); + } + if (handle.process && handle.process.exitCode === null) { + handle.process.kill(); + } + handle.connection = undefined; + handle.process = undefined; + handle.status = 'NOT_STARTED'; + handle.error = undefined; + handle.warmedUp = false; + handle.stopRequested = false; + } + + private buildProcessEnv( + env: Record | undefined, + ): NodeJS.ProcessEnv | undefined { + if (!env || Object.keys(env).length === 0) { + return undefined; + } + return { ...process.env, ...env }; + } + + private async connectSocketWithRetry( + socket: LspSocketOptions, + timeoutMs: number, + ): Promise< + Awaited> + > { + const deadline = Date.now() + timeoutMs; + let attempt = 0; + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error('LSP server connection timeout'); + } + try { + return await LspConnectionFactory.createSocketConnection( + socket, + remaining, + ); + } catch (error) { + attempt += 1; + if (Date.now() >= deadline) { + throw error; + } + const delay = Math.min(250 * attempt, 1000); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } } /** @@ -815,17 +1982,27 @@ export class NativeLspService { */ private async createLspConnection(config: LspServerConfig): Promise<{ connection: LspConnectionInterface; - process: ChildProcess; + process?: ChildProcess; shutdown: () => Promise; exit: () => void; initialize: (params: unknown) => Promise; }> { + const workspaceFolder = config.workspaceFolder ?? this.workspaceRoot; + const startupTimeout = + config.startupTimeout ?? DEFAULT_LSP_STARTUP_TIMEOUT_MS; + const env = this.buildProcessEnv(config.env); + if (config.transport === 'stdio') { + if (!config.command) { + throw new Error('LSP stdio transport requires a command'); + } + // 修复:使用 cwd 作为 cwd 而不是 rootUri const lspConnection = await LspConnectionFactory.createStdioConnection( config.command, - config.args, - { cwd: this.workspaceRoot }, + config.args ?? [], + { cwd: workspaceFolder, env }, + startupTimeout, ); return { @@ -843,9 +2020,50 @@ export class NativeLspService { initialize: async (params: unknown) => lspConnection.connection.initialize(params), }; - } else if (config.transport === 'tcp') { - // 如果需要 TCP 支持,可以扩展此部分 - throw new Error('TCP transport not yet implemented'); + } else if (config.transport === 'tcp' || config.transport === 'socket') { + if (!config.socket) { + throw new Error('LSP socket transport requires host/port or path'); + } + + let process: ChildProcess | undefined; + if (config.command) { + process = spawn(config.command, config.args ?? [], { + cwd: workspaceFolder, + env, + stdio: 'ignore', + }); + await new Promise((resolve, reject) => { + process?.once('spawn', () => resolve()); + process?.once('error', (error) => { + reject(new Error(`Failed to spawn LSP server: ${error.message}`)); + }); + }); + } + + try { + const lspConnection = await this.connectSocketWithRetry( + config.socket, + startupTimeout, + ); + + return { + connection: lspConnection.connection as LspConnectionInterface, + process, + shutdown: async () => { + await lspConnection.connection.shutdown(); + }, + exit: () => { + lspConnection.connection.end(); + }, + initialize: async (params: unknown) => + lspConnection.connection.initialize(params), + }; + } catch (error) { + if (process && process.exitCode === null) { + process.kill(); + } + throw error; + } } else { throw new Error(`Unsupported transport: ${config.transport}`); } @@ -858,15 +2076,16 @@ export class NativeLspService { connection: Awaited>, config: LspServerConfig, ): Promise { + const workspaceFolderPath = config.workspaceFolder ?? this.workspaceRoot; const workspaceFolder = { - name: path.basename(this.workspaceRoot) || this.workspaceRoot, + name: path.basename(workspaceFolderPath) || workspaceFolderPath, uri: config.rootUri, }; const initializeParams = { processId: process.pid, rootUri: config.rootUri, - rootPath: this.workspaceRoot, + rootPath: workspaceFolderPath, workspaceFolders: [workspaceFolder], capabilities: { textDocument: { @@ -904,8 +2123,21 @@ export class NativeLspService { }, }); + if (config.settings && Object.keys(config.settings).length > 0) { + connection.connection.send({ + jsonrpc: '2.0', + method: 'workspace/didChangeConfiguration', + params: { + settings: config.settings, + }, + }); + } + // Warm up TypeScript server by opening a workspace file so it can create a project. - if (config.name.includes('typescript')) { + if ( + config.name.includes('typescript') || + (config.command?.includes('typescript') ?? false) + ) { try { const tsFile = this.findFirstTypescriptFile(); if (tsFile) { @@ -936,13 +2168,18 @@ export class NativeLspService { /** * 检查命令是否存在 */ - private async commandExists(command: string): Promise { + private async commandExists( + command: string, + env?: Record, + cwd?: string, + ): Promise { // 实现命令存在性检查 return new Promise((resolve) => { let settled = false; const child = spawn(command, ['--version'], { stdio: ['ignore', 'ignore', 'ignore'], - cwd: this.workspaceRoot, + cwd: cwd ?? this.workspaceRoot, + env: this.buildProcessEnv(env), }); child.on('error', () => { @@ -971,23 +2208,19 @@ export class NativeLspService { /** * 检查路径安全性 */ - private isPathSafe(command: string, workspacePath: string): boolean { + private isPathSafe( + command: string, + workspacePath: string, + cwd?: string, + ): boolean { // 检查命令是否在工作区路径内,或者是否在系统 PATH 中 // 允许全局安装的命令(如在 PATH 中的命令) // 只阻止显式指定工作区外绝对路径的情况 - if (path.isAbsolute(command)) { - // 如果是绝对路径,检查是否在工作区路径内 - const resolvedPath = path.resolve(command); - const resolvedWorkspacePath = path.resolve(workspacePath); - return ( - resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || - resolvedPath === resolvedWorkspacePath - ); - } - // 相对路径和命令名(在 PATH 中查找)认为是安全的 - // 但需要确保相对路径不指向工作区外 - const resolvedPath = path.resolve(workspacePath, command); const resolvedWorkspacePath = path.resolve(workspacePath); + const basePath = cwd ? path.resolve(cwd) : resolvedWorkspacePath; + const resolvedPath = path.isAbsolute(command) + ? path.resolve(command) + : path.resolve(basePath, command); return ( resolvedPath.startsWith(resolvedWorkspacePath + path.sep) || resolvedPath === resolvedWorkspacePath @@ -1008,7 +2241,7 @@ export class NativeLspService { if (this.requireTrustedWorkspace || serverConfig.trustRequired) { console.log( - `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command})`, + `工作区未受信任,跳过 LSP 服务器 ${serverName} (${serverConfig.command ?? serverConfig.transport})`, ); return false; } @@ -1056,7 +2289,10 @@ export class NativeLspService { } private isTypescriptServer(handle: LspServerHandle): boolean { - return handle.config.name.includes('typescript'); + return ( + handle.config.name.includes('typescript') || + (handle.config.command?.includes('typescript') ?? false) + ); } private isNoProjectErrorResponse(response: unknown): boolean { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4625896e9..7d504c212 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -64,6 +64,7 @@ import { WriteFileTool } from '../tools/write-file.js'; import { LspWorkspaceSymbolTool } from '../tools/lsp-workspace-symbol.js'; import { LspGoToDefinitionTool } from '../tools/lsp-go-to-definition.js'; import { LspFindReferencesTool } from '../tools/lsp-find-references.js'; +import { LspTool } from '../tools/lsp.js'; import type { LspClient } from '../lsp/types.js'; // Other modules @@ -1583,6 +1584,9 @@ export class Config { registerCoreTool(WebSearchTool, this); } if (this.isLspEnabled() && this.getLspClient()) { + // Register the unified LSP tool (recommended) + registerCoreTool(LspTool, this); + // Keep legacy tools for backward compatibility registerCoreTool(LspGoToDefinitionTool, this); registerCoreTool(LspFindReferencesTool, this); registerCoreTool(LspWorkspaceSymbolTool, this); diff --git a/packages/core/src/lsp/types.ts b/packages/core/src/lsp/types.ts index 309ad43b9..936a784ac 100644 --- a/packages/core/src/lsp/types.ts +++ b/packages/core/src/lsp/types.ts @@ -39,20 +39,140 @@ export interface LspDefinition extends LspLocationWithServer { readonly serverName?: string; } +/** + * Hover result containing documentation or type information. + */ +export interface LspHoverResult { + /** The hover content as a string (normalized from MarkupContent/MarkedString). */ + contents: string; + /** Optional range that the hover applies to. */ + range?: LspRange; + /** The LSP server that provided this result. */ + serverName?: string; +} + +/** + * Call hierarchy item representing a function, method, or callable. + */ +export interface LspCallHierarchyItem { + /** The name of this item. */ + name: string; + /** The kind of this item (function, method, constructor, etc.) as readable string. */ + kind?: string; + /** The raw numeric SymbolKind from LSP, preserved for server communication. */ + rawKind?: number; + /** Additional details like signature or file path. */ + detail?: string; + /** The URI of the document containing this item. */ + uri: string; + /** The full range of this item. */ + range: LspRange; + /** The range that should be selected when navigating to this item. */ + selectionRange: LspRange; + /** Opaque data used by the server for subsequent calls. */ + data?: unknown; + /** The LSP server that provided this item. */ + serverName?: string; +} + +/** + * Incoming call representing a function that calls the target. + */ +export interface LspCallHierarchyIncomingCall { + /** The caller item. */ + from: LspCallHierarchyItem; + /** The ranges where the call occurs within the caller. */ + fromRanges: LspRange[]; +} + +/** + * Outgoing call representing a function called by the target. + */ +export interface LspCallHierarchyOutgoingCall { + /** The callee item. */ + to: LspCallHierarchyItem; + /** The ranges where the call occurs within the caller. */ + fromRanges: LspRange[]; +} + export interface LspClient { + /** + * Search for symbols across the workspace. + */ workspaceSymbols( query: string, limit?: number, ): Promise; + + /** + * Get hover information (documentation, type info) for a symbol. + */ + hover( + location: LspLocation, + serverName?: string, + ): Promise; + + /** + * Get all symbols in a document. + */ + documentSymbols( + uri: string, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find where a symbol is defined. + */ definitions( location: LspLocation, serverName?: string, limit?: number, ): Promise; + + /** + * Find implementations of an interface or abstract method. + */ + implementations( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find all references to a symbol. + */ references( location: LspLocation, serverName?: string, includeDeclaration?: boolean, limit?: number, ): Promise; + + /** + * Prepare call hierarchy item at a position (functions/methods). + */ + prepareCallHierarchy( + location: LspLocation, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find all functions/methods that call the given function. + */ + incomingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit?: number, + ): Promise; + + /** + * Find all functions/methods called by the given function. + */ + outgoingCalls( + item: LspCallHierarchyItem, + serverName?: string, + limit?: number, + ): Promise; } diff --git a/packages/core/src/tools/lsp.test.ts b/packages/core/src/tools/lsp.test.ts new file mode 100644 index 000000000..ca2a2fc0c --- /dev/null +++ b/packages/core/src/tools/lsp.test.ts @@ -0,0 +1,1220 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { Config } from '../config/config.js'; +import type { + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, + LspClient, + LspDefinition, + LspHoverResult, + LspLocation, + LspReference, + LspSymbolInformation, +} from '../lsp/types.js'; +import { LspTool, type LspToolParams, type LspOperation } from './lsp.js'; + +const abortSignal = new AbortController().signal; +const workspaceRoot = '/test/workspace'; + +/** + * Helper to resolve a path relative to workspace root. + */ +const resolvePath = (...segments: string[]) => + path.join(workspaceRoot, ...segments); + +/** + * Helper to convert file path to URI. + */ +const toUri = (filePath: string) => pathToFileURL(filePath).toString(); + +/** + * Helper to create a mock LspLocation. + */ +const createLocation = ( + filePath: string, + line: number, + character: number, +): LspLocation => ({ + uri: toUri(filePath), + range: { + start: { line, character }, + end: { line, character }, + }, +}); + +/** + * Create a mock LspClient with all methods mocked. + */ +const createMockClient = (): LspClient => + ({ + workspaceSymbols: vi.fn().mockResolvedValue([]), + hover: vi.fn().mockResolvedValue(null), + documentSymbols: vi.fn().mockResolvedValue([]), + definitions: vi.fn().mockResolvedValue([]), + implementations: vi.fn().mockResolvedValue([]), + references: vi.fn().mockResolvedValue([]), + prepareCallHierarchy: vi.fn().mockResolvedValue([]), + incomingCalls: vi.fn().mockResolvedValue([]), + outgoingCalls: vi.fn().mockResolvedValue([]), + }) as unknown as LspClient; + +/** + * Create a mock Config for testing. + */ +const createMockConfig = (client?: LspClient, enabled = true): Config => + ({ + getLspClient: () => client, + isLspEnabled: () => enabled, + getProjectRoot: () => workspaceRoot, + }) as unknown as Config; + +/** + * Create a LspTool with mock config. + */ +const createTool = (client?: LspClient, enabled = true) => + new LspTool(createMockConfig(client, enabled)); + +describe('LspTool', () => { + describe('validateToolParams', () => { + let tool: LspTool; + + beforeEach(() => { + tool = createTool(); + }); + + describe('location-based operations', () => { + const locationOperations: LspOperation[] = [ + 'goToDefinition', + 'findReferences', + 'hover', + 'goToImplementation', + 'prepareCallHierarchy', + ]; + + it.each(locationOperations)( + 'requires filePath for %s operation', + (operation) => { + const result = tool.validateToolParams({ + operation, + } as LspToolParams); + expect(result).toBe(`filePath is required for ${operation}.`); + }, + ); + + it.each(locationOperations)( + 'requires line for %s operation', + (operation) => { + const result = tool.validateToolParams({ + operation, + filePath: 'src/app.ts', + } as LspToolParams); + expect(result).toBe(`line is required for ${operation}.`); + }, + ); + + it.each(locationOperations)( + 'passes validation with valid params for %s', + (operation) => { + const result = tool.validateToolParams({ + operation, + filePath: 'src/app.ts', + line: 10, + character: 5, + } as LspToolParams); + expect(result).toBeNull(); + }, + ); + }); + + describe('documentSymbol operation', () => { + it('requires filePath for documentSymbol', () => { + const result = tool.validateToolParams({ + operation: 'documentSymbol', + } as LspToolParams); + expect(result).toBe('filePath is required for documentSymbol.'); + }); + + it('passes validation with filePath', () => { + const result = tool.validateToolParams({ + operation: 'documentSymbol', + filePath: 'src/app.ts', + } as LspToolParams); + expect(result).toBeNull(); + }); + }); + + describe('workspaceSymbol operation', () => { + it('requires query for workspaceSymbol', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + } as LspToolParams); + expect(result).toBe('query is required for workspaceSymbol.'); + }); + + it('rejects empty query', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + query: ' ', + } as LspToolParams); + expect(result).toBe('query is required for workspaceSymbol.'); + }); + + it('passes validation with query', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + query: 'Widget', + } as LspToolParams); + expect(result).toBeNull(); + }); + }); + + describe('call hierarchy operations', () => { + it('requires callHierarchyItem for incomingCalls', () => { + const result = tool.validateToolParams({ + operation: 'incomingCalls', + } as LspToolParams); + expect(result).toBe('callHierarchyItem is required for incomingCalls.'); + }); + + it('requires callHierarchyItem for outgoingCalls', () => { + const result = tool.validateToolParams({ + operation: 'outgoingCalls', + } as LspToolParams); + expect(result).toBe('callHierarchyItem is required for outgoingCalls.'); + }); + + it('passes validation with callHierarchyItem', () => { + const item: LspCallHierarchyItem = { + name: 'testFunc', + uri: 'file:///test.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + selectionRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }; + const result = tool.validateToolParams({ + operation: 'incomingCalls', + callHierarchyItem: item, + } as LspToolParams); + expect(result).toBeNull(); + }); + }); + + describe('numeric parameter validation', () => { + it('rejects non-positive line', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 0, + } as LspToolParams); + expect(result).toBe('line must be a positive number.'); + }); + + it('rejects negative line', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: -1, + } as LspToolParams); + expect(result).toBe('line must be a positive number.'); + }); + + it('rejects non-positive character', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 1, + character: 0, + } as LspToolParams); + expect(result).toBe('character must be a positive number.'); + }); + + it('rejects non-positive limit', () => { + const result = tool.validateToolParams({ + operation: 'documentSymbol', + filePath: 'src/app.ts', + limit: 0, + } as LspToolParams); + expect(result).toBe('limit must be a positive number.'); + }); + }); + + describe('edge case validation', () => { + it('rejects empty filePath', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: '', + line: 1, + } as LspToolParams); + expect(result).toBe('filePath is required for goToDefinition.'); + }); + + it('rejects whitespace-only filePath', () => { + const result = tool.validateToolParams({ + operation: 'goToDefinition', + filePath: ' ', + line: 1, + } as LspToolParams); + expect(result).toBe('filePath is required for goToDefinition.'); + }); + + it('rejects whitespace-only query', () => { + const result = tool.validateToolParams({ + operation: 'workspaceSymbol', + query: ' \t\n ', + } as LspToolParams); + expect(result).toBe('query is required for workspaceSymbol.'); + }); + }); + }); + + describe('execute', () => { + describe('LSP disabled or unavailable', () => { + it('returns unavailable message when LSP is disabled', async () => { + const tool = createTool(undefined, false); + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 1, + character: 1, + }); + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toContain('LSP hover is unavailable'); + expect(result.llmContent).toContain('LSP disabled or not initialized'); + }); + + it('returns unavailable message when no LSP client', async () => { + const tool = createTool(undefined, true); + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 1, + character: 1, + }); + const result = await invocation.execute(abortSignal); + // Note: operation labels are formatted (e.g., "go-to-definition") + expect(result.llmContent).toContain( + 'LSP go-to-definition is unavailable', + ); + }); + }); + + describe('goToDefinition operation', () => { + it('dispatches to definitions and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const definition: LspDefinition = { + ...createLocation(filePath, 10, 5), + serverName: 'tsserver', + }; + (client.definitions as Mock).mockResolvedValue([definition]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(client.definitions).toHaveBeenCalledWith( + expect.objectContaining({ + uri: toUri(filePath), + range: expect.objectContaining({ + start: { line: 4, character: 9 }, // 1-based to 0-based conversion + }), + }), + undefined, + 20, + ); + expect(result.llmContent).toContain('Definitions for'); + expect(result.llmContent).toContain('1.'); + }); + + it('handles empty results', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('No definitions found'); + }); + }); + + describe('findReferences operation', () => { + it('dispatches to references and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const refs: LspReference[] = [ + { ...createLocation(filePath, 10, 5), serverName: 'tsserver' }, + { ...createLocation(filePath, 20, 8) }, + ]; + (client.references as Mock).mockResolvedValue(refs); + + const invocation = tool.build({ + operation: 'findReferences', + filePath: 'src/app.ts', + line: 5, + character: 10, + includeDeclaration: true, + }); + const result = await invocation.execute(abortSignal); + + // Default limit for references is 50 + expect(client.references).toHaveBeenCalledWith( + expect.objectContaining({ uri: toUri(filePath) }), + undefined, + true, + 50, + ); + expect(result.llmContent).toContain('References for'); + expect(result.llmContent).toContain('1.'); + expect(result.llmContent).toContain('2.'); + }); + }); + + describe('hover operation', () => { + it('dispatches to hover and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const hoverResult: LspHoverResult = { + contents: '**Type**: string\n\nA sample variable.', + }; + (client.hover as Mock).mockResolvedValue(hoverResult); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + const result = await invocation.execute(abortSignal); + + expect(client.hover).toHaveBeenCalled(); + expect(result.llmContent).toContain('Hover for'); + expect(result.llmContent).toContain('Type'); + }); + + it('handles null hover result', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.hover as Mock).mockResolvedValue(null); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('No hover information found'); + }); + }); + + describe('documentSymbol operation', () => { + it('dispatches to documentSymbols and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'MyClass', + kind: 'Class', + containerName: 'app', + location: createLocation(filePath, 5, 0), + serverName: 'tsserver', + }, + { + name: 'myFunction', + kind: 'Function', + location: createLocation(filePath, 20, 0), + }, + ]; + (client.documentSymbols as Mock).mockResolvedValue(symbols); + + const invocation = tool.build({ + operation: 'documentSymbol', + filePath: 'src/app.ts', + }); + const result = await invocation.execute(abortSignal); + + // Default limit for documentSymbols is 50 + expect(client.documentSymbols).toHaveBeenCalledWith( + toUri(filePath), + undefined, + 50, + ); + expect(result.llmContent).toContain('Document symbols for'); + expect(result.llmContent).toContain('MyClass'); + expect(result.llmContent).toContain('myFunction'); + }); + }); + + describe('workspaceSymbol operation', () => { + it('dispatches to workspaceSymbols and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'Widget', + kind: 'Class', + location: createLocation(filePath, 10, 0), + }, + ]; + (client.workspaceSymbols as Mock).mockResolvedValue(symbols); + (client.references as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'Widget', + limit: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(client.workspaceSymbols).toHaveBeenCalledWith('Widget', 10); + expect(result.llmContent).toContain('symbols for query "Widget"'); + expect(result.llmContent).toContain('Widget'); + }); + }); + + describe('goToImplementation operation', () => { + it('dispatches to implementations and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'impl.ts'); + const impl: LspDefinition = { + ...createLocation(filePath, 15, 2), + serverName: 'tsserver', + }; + (client.implementations as Mock).mockResolvedValue([impl]); + + const invocation = tool.build({ + operation: 'goToImplementation', + filePath: 'src/interface.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(client.implementations).toHaveBeenCalled(); + expect(result.llmContent).toContain('Implementations for'); + }); + }); + + describe('prepareCallHierarchy operation', () => { + it('dispatches to prepareCallHierarchy and formats results with JSON', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const item: LspCallHierarchyItem = { + name: 'myFunction', + kind: 'Function', + detail: '(param: string)', + uri: toUri(filePath), + range: { + start: { line: 10, character: 0 }, + end: { line: 20, character: 1 }, + }, + selectionRange: { + start: { line: 10, character: 9 }, + end: { line: 10, character: 19 }, + }, + serverName: 'tsserver', + }; + (client.prepareCallHierarchy as Mock).mockResolvedValue([item]); + + const invocation = tool.build({ + operation: 'prepareCallHierarchy', + filePath: 'src/app.ts', + line: 11, + character: 15, + }); + const result = await invocation.execute(abortSignal); + + expect(client.prepareCallHierarchy).toHaveBeenCalled(); + expect(result.llmContent).toContain('Call hierarchy items for'); + expect(result.llmContent).toContain('myFunction'); + expect(result.llmContent).toContain('Call hierarchy items (JSON):'); + expect(result.llmContent).toContain('"name": "myFunction"'); + }); + }); + + describe('incomingCalls operation', () => { + it('dispatches to incomingCalls and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const targetPath = resolvePath('src', 'target.ts'); + const callerPath = resolvePath('src', 'caller.ts'); + + const targetItem: LspCallHierarchyItem = { + name: 'targetFunc', + uri: toUri(targetPath), + range: { + start: { line: 5, character: 0 }, + end: { line: 10, character: 1 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 19 }, + }, + serverName: 'tsserver', + }; + + const callerItem: LspCallHierarchyItem = { + name: 'callerFunc', + kind: 'Function', + uri: toUri(callerPath), + range: { + start: { line: 20, character: 0 }, + end: { line: 30, character: 1 }, + }, + selectionRange: { + start: { line: 20, character: 9 }, + end: { line: 20, character: 19 }, + }, + }; + + const incomingCall: LspCallHierarchyIncomingCall = { + from: callerItem, + fromRanges: [ + { + start: { line: 25, character: 4 }, + end: { line: 25, character: 14 }, + }, + ], + }; + (client.incomingCalls as Mock).mockResolvedValue([incomingCall]); + + const invocation = tool.build({ + operation: 'incomingCalls', + callHierarchyItem: targetItem, + }); + const result = await invocation.execute(abortSignal); + + expect(client.incomingCalls).toHaveBeenCalledWith( + targetItem, + 'tsserver', + 20, + ); + expect(result.llmContent).toContain('Incoming calls for targetFunc'); + expect(result.llmContent).toContain('callerFunc'); + expect(result.llmContent).toContain('Incoming calls (JSON):'); + }); + }); + + describe('outgoingCalls operation', () => { + it('dispatches to outgoingCalls and formats results', async () => { + const client = createMockClient(); + const tool = createTool(client); + const sourcePath = resolvePath('src', 'source.ts'); + const targetPath = resolvePath('src', 'target.ts'); + + const sourceItem: LspCallHierarchyItem = { + name: 'sourceFunc', + uri: toUri(sourcePath), + range: { + start: { line: 5, character: 0 }, + end: { line: 15, character: 1 }, + }, + selectionRange: { + start: { line: 5, character: 9 }, + end: { line: 5, character: 19 }, + }, + }; + + const targetItem: LspCallHierarchyItem = { + name: 'targetFunc', + kind: 'Function', + uri: toUri(targetPath), + range: { + start: { line: 20, character: 0 }, + end: { line: 30, character: 1 }, + }, + selectionRange: { + start: { line: 20, character: 9 }, + end: { line: 20, character: 19 }, + }, + serverName: 'tsserver', + }; + + const outgoingCall: LspCallHierarchyOutgoingCall = { + to: targetItem, + fromRanges: [ + { + start: { line: 10, character: 4 }, + end: { line: 10, character: 14 }, + }, + ], + }; + (client.outgoingCalls as Mock).mockResolvedValue([outgoingCall]); + + const invocation = tool.build({ + operation: 'outgoingCalls', + callHierarchyItem: sourceItem, + }); + const result = await invocation.execute(abortSignal); + + expect(client.outgoingCalls).toHaveBeenCalled(); + expect(result.llmContent).toContain('Outgoing calls for sourceFunc'); + expect(result.llmContent).toContain('targetFunc'); + expect(result.llmContent).toContain('Outgoing calls (JSON):'); + }); + }); + + describe('error handling', () => { + it('handles LSP client errors gracefully', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockRejectedValue( + new Error('Connection refused'), + ); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('failed'); + expect(result.llmContent).toContain('Connection refused'); + }); + + it('handles hover operation errors', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.hover as Mock).mockRejectedValue(new Error('Server timeout')); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('failed'); + expect(result.llmContent).toContain('Server timeout'); + }); + + it('handles call hierarchy errors', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.prepareCallHierarchy as Mock).mockRejectedValue( + new Error('Not supported'), + ); + + const invocation = tool.build({ + operation: 'prepareCallHierarchy', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('failed'); + expect(result.llmContent).toContain('Not supported'); + }); + }); + + describe('workspaceSymbol with references', () => { + it('fetches references for top match when available', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const refPath = resolvePath('src', 'other.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'TopWidget', + kind: 'Class', + location: createLocation(filePath, 10, 0), + serverName: 'tsserver', + }, + ]; + const references: LspReference[] = [ + { ...createLocation(refPath, 5, 10), serverName: 'tsserver' }, + { ...createLocation(refPath, 20, 5) }, + ]; + (client.workspaceSymbols as Mock).mockResolvedValue(symbols); + (client.references as Mock).mockResolvedValue(references); + + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'TopWidget', + }); + const result = await invocation.execute(abortSignal); + + // Should fetch references for top match + expect(client.references).toHaveBeenCalledWith( + symbols[0].location, + 'tsserver', + false, + expect.any(Number), + ); + expect(result.llmContent).toContain('References for top match'); + expect(result.llmContent).toContain('TopWidget'); + }); + + it('handles reference lookup failure gracefully', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const symbols: LspSymbolInformation[] = [ + { + name: 'Widget', + kind: 'Class', + location: createLocation(filePath, 10, 0), + }, + ]; + (client.workspaceSymbols as Mock).mockResolvedValue(symbols); + (client.references as Mock).mockRejectedValue( + new Error('References not supported'), + ); + + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'Widget', + }); + const result = await invocation.execute(abortSignal); + + // Should still return symbols even if references fail + expect(result.llmContent).toContain('Widget'); + expect(result.llmContent).toContain('References lookup failed'); + }); + }); + + describe('returnDisplay verification', () => { + it('returns formatted display for definitions', async () => { + const client = createMockClient(); + const tool = createTool(client); + const filePath = resolvePath('src', 'app.ts'); + const definition: LspDefinition = { + ...createLocation(filePath, 10, 5), + serverName: 'tsserver', + }; + (client.definitions as Mock).mockResolvedValue([definition]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + }); + const result = await invocation.execute(abortSignal); + + // returnDisplay should be concise (without heading) + expect(result.returnDisplay).toBeDefined(); + expect(result.returnDisplay).toContain('1.'); + expect(result.returnDisplay).toContain('[tsserver]'); + }); + + it('returns formatted display for hover with trimmed content', async () => { + const client = createMockClient(); + const tool = createTool(client); + const hoverResult: LspHoverResult = { + contents: ' \n Type: string \n ', + }; + (client.hover as Mock).mockResolvedValue(hoverResult); + + const invocation = tool.build({ + operation: 'hover', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + const result = await invocation.execute(abortSignal); + + // returnDisplay should be trimmed + expect(result.returnDisplay).toBe('Type: string'); + }); + }); + + describe('serverName and limit parameter passing', () => { + it('passes serverName to client methods', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + serverName: 'pylsp', + }); + await invocation.execute(abortSignal); + + expect(client.definitions).toHaveBeenCalledWith( + expect.anything(), + 'pylsp', + expect.any(Number), + ); + }); + + it('passes custom limit to client methods', async () => { + const client = createMockClient(); + const tool = createTool(client); + (client.definitions as Mock).mockResolvedValue([]); + + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 5, + character: 10, + limit: 5, + }); + await invocation.execute(abortSignal); + + expect(client.definitions).toHaveBeenCalledWith( + expect.anything(), + undefined, + 5, + ); + }); + }); + }); + + describe('schema compatibility with Claude Code', () => { + /** + * Claude Code LSP tool schema reference: + * { + * "name": "lsp", + * "input_schema": { + * "type": "object", + * "properties": { + * "operation": { "type": "string", "enum": [...] }, + * "filePath": { "type": "string" }, + * "line": { "type": "number" }, + * "character": { "type": "number" }, + * "includeDeclaration": { "type": "boolean" }, + * "query": { "type": "string" }, + * "callHierarchyItem": { ... } + * }, + * "required": ["operation"] + * } + * } + */ + + it('has correct tool name', () => { + const tool = createTool(); + expect(tool.schema.name).toBe('lsp'); + }); + + it('has operation as only required field', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + required?: string[]; + }; + expect(schema.required).toEqual(['operation']); + }); + + it('operation enum matches Claude Code exactly', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + operation?: { + enum?: string[]; + }; + }; + }; + const expectedOperations = [ + 'goToDefinition', + 'findReferences', + 'hover', + 'documentSymbol', + 'workspaceSymbol', + 'goToImplementation', + 'prepareCallHierarchy', + 'incomingCalls', + 'outgoingCalls', + ]; + expect(schema.properties?.operation?.enum).toEqual(expectedOperations); + }); + + it('has all Claude Code core properties', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: Record; + }; + const properties = Object.keys(schema.properties ?? {}); + + // Core properties that must match Claude Code + const coreProperties = [ + 'operation', + 'filePath', + 'line', + 'character', + 'includeDeclaration', + 'query', + 'callHierarchyItem', + ]; + + for (const prop of coreProperties) { + expect(properties).toContain(prop); + } + }); + + it('extension properties are documented', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: Record; + }; + const properties = Object.keys(schema.properties ?? {}); + + // Our extensions beyond Claude Code + const extensionProperties = ['serverName', 'limit']; + + // All properties should be either core or documented extensions + const knownProperties = [ + 'operation', + 'filePath', + 'line', + 'character', + 'includeDeclaration', + 'query', + 'callHierarchyItem', + ...extensionProperties, + ]; + + for (const prop of properties) { + expect(knownProperties).toContain(prop); + } + }); + + it('filePath property has correct type', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + filePath?: { type?: string }; + }; + }; + expect(schema.properties?.filePath?.type).toBe('string'); + }); + + it('line and character properties have correct type', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + line?: { type?: string }; + character?: { type?: string }; + }; + }; + expect(schema.properties?.line?.type).toBe('number'); + expect(schema.properties?.character?.type).toBe('number'); + }); + + it('includeDeclaration property has correct type', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + includeDeclaration?: { type?: string }; + }; + }; + expect(schema.properties?.includeDeclaration?.type).toBe('boolean'); + }); + + it('callHierarchyItem has required structure', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspCallHierarchyItem?: { + type?: string; + properties?: Record; + required?: string[]; + }; + }; + }; + const itemDef = schema.definitions?.LspCallHierarchyItem; + expect(itemDef?.type).toBe('object'); + expect(itemDef?.required).toEqual([ + 'name', + 'uri', + 'range', + 'selectionRange', + ]); + expect(itemDef?.properties).toHaveProperty('name'); + expect(itemDef?.properties).toHaveProperty('kind'); + expect(itemDef?.properties).toHaveProperty('uri'); + expect(itemDef?.properties).toHaveProperty('range'); + expect(itemDef?.properties).toHaveProperty('selectionRange'); + }); + + it('supports rawKind for SymbolKind numeric preservation', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspCallHierarchyItem?: { + properties?: { + rawKind?: { type?: string }; + }; + }; + }; + }; + const itemDef = schema.definitions?.LspCallHierarchyItem; + expect(itemDef?.properties?.rawKind?.type).toBe('number'); + }); + + describe('schema definitions deep validation', () => { + it('has LspPosition definition with correct structure', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspPosition?: { + type?: string; + properties?: { + line?: { type?: string }; + character?: { type?: string }; + }; + required?: string[]; + }; + }; + }; + const posDef = schema.definitions?.LspPosition; + expect(posDef).toBeDefined(); + expect(posDef?.type).toBe('object'); + expect(posDef?.properties?.line?.type).toBe('number'); + expect(posDef?.properties?.character?.type).toBe('number'); + expect(posDef?.required).toEqual(['line', 'character']); + }); + + it('has LspRange definition with correct structure', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: { + LspRange?: { + type?: string; + properties?: { + start?: { $ref?: string }; + end?: { $ref?: string }; + }; + required?: string[]; + }; + }; + }; + const rangeDef = schema.definitions?.LspRange; + expect(rangeDef).toBeDefined(); + expect(rangeDef?.type).toBe('object'); + expect(rangeDef?.properties?.start?.$ref).toBe( + '#/definitions/LspPosition', + ); + expect(rangeDef?.properties?.end?.$ref).toBe( + '#/definitions/LspPosition', + ); + expect(rangeDef?.required).toEqual(['start', 'end']); + }); + + it('callHierarchyItem uses $ref for range fields', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + properties?: { + callHierarchyItem?: { $ref?: string }; + }; + definitions?: { + LspCallHierarchyItem?: { + properties?: { + range?: { $ref?: string }; + selectionRange?: { $ref?: string }; + }; + }; + }; + }; + // callHierarchyItem property should reference the definition + expect(schema.properties?.callHierarchyItem?.$ref).toBe( + '#/definitions/LspCallHierarchyItem', + ); + // range and selectionRange should use LspRange $ref + const itemDef = schema.definitions?.LspCallHierarchyItem; + expect(itemDef?.properties?.range?.$ref).toBe('#/definitions/LspRange'); + expect(itemDef?.properties?.selectionRange?.$ref).toBe( + '#/definitions/LspRange', + ); + }); + + it('all definitions are present and accounted for', () => { + const tool = createTool(); + const schema = tool.schema.parametersJsonSchema as { + definitions?: Record; + }; + const definitionNames = Object.keys(schema.definitions ?? {}); + // Should have exactly these definitions + expect(definitionNames.sort()).toEqual([ + 'LspCallHierarchyItem', + 'LspPosition', + 'LspRange', + ]); + }); + }); + }); + + describe('invocation description', () => { + it('describes goToDefinition correctly', () => { + const tool = createTool(); + const invocation = tool.build({ + operation: 'goToDefinition', + filePath: 'src/app.ts', + line: 10, + character: 5, + }); + // Uses formatted label "go-to-definition" + expect(invocation.getDescription()).toContain('go-to-definition'); + expect(invocation.getDescription()).toContain('src/app.ts:10:5'); + }); + + it('describes workspaceSymbol correctly', () => { + const tool = createTool(); + const invocation = tool.build({ + operation: 'workspaceSymbol', + query: 'Widget', + }); + // Uses formatted label "workspace symbol search" + expect(invocation.getDescription()).toContain('workspace symbol search'); + expect(invocation.getDescription()).toContain('Widget'); + }); + + it('describes incomingCalls correctly', () => { + const tool = createTool(); + const invocation = tool.build({ + operation: 'incomingCalls', + callHierarchyItem: { + name: 'testFunc', + uri: 'file:///test.ts', + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + selectionRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 10 }, + }, + }, + }); + // Uses formatted label "incoming calls" + expect(invocation.getDescription()).toContain('incoming calls'); + expect(invocation.getDescription()).toContain('testFunc'); + }); + }); +}); diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts new file mode 100644 index 000000000..41487830e --- /dev/null +++ b/packages/core/src/tools/lsp.ts @@ -0,0 +1,960 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { ToolInvocation, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import type { Config } from '../config/config.js'; +import type { + LspCallHierarchyIncomingCall, + LspCallHierarchyItem, + LspCallHierarchyOutgoingCall, + LspClient, + LspDefinition, + LspLocation, + LspRange, + LspReference, + LspSymbolInformation, +} from '../lsp/types.js'; + +/** + * Supported LSP operations. + */ +export type LspOperation = + | 'goToDefinition' + | 'findReferences' + | 'hover' + | 'documentSymbol' + | 'workspaceSymbol' + | 'goToImplementation' + | 'prepareCallHierarchy' + | 'incomingCalls' + | 'outgoingCalls'; + +/** + * Parameters for the unified LSP tool. + */ +export interface LspToolParams { + /** Operation to perform. */ + operation: LspOperation; + /** File path (absolute or workspace-relative). */ + filePath?: string; + /** 1-based line number when targeting a specific file location. */ + line?: number; + /** 1-based character/column number when targeting a specific file location. */ + character?: number; + /** Whether to include the declaration in reference results. */ + includeDeclaration?: boolean; + /** Query string for workspace symbol search. */ + query?: string; + /** Call hierarchy item from a previous call hierarchy operation. */ + callHierarchyItem?: LspCallHierarchyItem; + /** Optional server name override. */ + serverName?: string; + /** Optional maximum number of results. */ + limit?: number; +} + +type ResolvedTarget = + | { + location: LspLocation; + description: string; + } + | { error: string }; + +/** Operations that require filePath and line. */ +const LOCATION_REQUIRED_OPERATIONS = new Set([ + 'goToDefinition', + 'findReferences', + 'hover', + 'goToImplementation', + 'prepareCallHierarchy', +]); + +/** Operations that only require filePath. */ +const FILE_REQUIRED_OPERATIONS = new Set(['documentSymbol']); + +/** Operations that require query. */ +const QUERY_REQUIRED_OPERATIONS = new Set(['workspaceSymbol']); + +/** Operations that require callHierarchyItem. */ +const ITEM_REQUIRED_OPERATIONS = new Set([ + 'incomingCalls', + 'outgoingCalls', +]); + +class LspToolInvocation extends BaseToolInvocation { + constructor( + private readonly config: Config, + params: LspToolParams, + ) { + super(params); + } + + getDescription(): string { + const operationLabel = this.getOperationLabel(); + if (this.params.operation === 'workspaceSymbol') { + return `LSP ${operationLabel} for "${this.params.query ?? ''}"`; + } + if (this.params.operation === 'documentSymbol') { + return this.params.filePath + ? `LSP ${operationLabel} for ${this.params.filePath}` + : `LSP ${operationLabel}`; + } + if ( + this.params.operation === 'incomingCalls' || + this.params.operation === 'outgoingCalls' + ) { + return `LSP ${operationLabel} for ${this.describeCallHierarchyItemShort()}`; + } + if (this.params.filePath && this.params.line !== undefined) { + return `LSP ${operationLabel} at ${this.params.filePath}:${this.params.line}:${this.params.character ?? 1}`; + } + if (this.params.filePath) { + return `LSP ${operationLabel} for ${this.params.filePath}`; + } + return `LSP ${operationLabel}`; + } + + async execute(_signal: AbortSignal): Promise { + const client = this.config.getLspClient(); + if (!client || !this.config.isLspEnabled()) { + const message = `LSP ${this.getOperationLabel()} is unavailable (LSP disabled or not initialized).`; + return { llmContent: message, returnDisplay: message }; + } + + switch (this.params.operation) { + case 'goToDefinition': + return this.executeDefinitions(client); + case 'findReferences': + return this.executeReferences(client); + case 'hover': + return this.executeHover(client); + case 'documentSymbol': + return this.executeDocumentSymbols(client); + case 'workspaceSymbol': + return this.executeWorkspaceSymbols(client); + case 'goToImplementation': + return this.executeImplementations(client); + case 'prepareCallHierarchy': + return this.executePrepareCallHierarchy(client); + case 'incomingCalls': + return this.executeIncomingCalls(client); + case 'outgoingCalls': + return this.executeOutgoingCalls(client); + default: { + const message = `Unsupported LSP operation: ${this.params.operation}`; + return { llmContent: message, returnDisplay: message }; + } + } + } + + private async executeDefinitions(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let definitions: LspDefinition[] = []; + try { + definitions = await client.definitions( + target.location, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP go-to-definition failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!definitions.length) { + const message = `No definitions found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = definitions + .slice(0, limit) + .map( + (definition, index) => + `${index + 1}. ${this.formatLocationWithServer(definition, workspaceRoot)}`, + ); + + const heading = `Definitions for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeImplementations(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let implementations: LspDefinition[] = []; + try { + implementations = await client.implementations( + target.location, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP go-to-implementation failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!implementations.length) { + const message = `No implementations found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = implementations + .slice(0, limit) + .map( + (implementation, index) => + `${index + 1}. ${this.formatLocationWithServer(implementation, workspaceRoot)}`, + ); + + const heading = `Implementations for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeReferences(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 50; + let references: LspReference[] = []; + try { + references = await client.references( + target.location, + this.params.serverName, + this.params.includeDeclaration ?? false, + limit, + ); + } catch (error) { + const message = `LSP find-references failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!references.length) { + const message = `No references found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = references + .slice(0, limit) + .map( + (reference, index) => + `${index + 1}. ${this.formatLocationWithServer(reference, workspaceRoot)}`, + ); + + const heading = `References for ${target.description}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeHover(client: LspClient): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + let hoverText = ''; + try { + const result = await client.hover( + target.location, + this.params.serverName, + ); + if (result) { + hoverText = result.contents ?? ''; + } + } catch (error) { + const message = `LSP hover failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!hoverText || hoverText.trim().length === 0) { + const message = `No hover information found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const heading = `Hover for ${target.description}:`; + const content = hoverText.trim(); + return { + llmContent: `${heading}\n${content}`, + returnDisplay: content, + }; + } + + private async executeDocumentSymbols(client: LspClient): Promise { + const workspaceRoot = this.config.getProjectRoot(); + const filePath = this.params.filePath ?? ''; + const uri = this.resolveUri(filePath, workspaceRoot); + if (!uri) { + const message = 'A valid filePath is required for document symbols.'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 50; + let symbols: LspSymbolInformation[] = []; + try { + symbols = await client.documentSymbols( + uri, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP document symbols failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!symbols.length) { + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const message = `No document symbols found for ${fileLabel}.`; + return { llmContent: message, returnDisplay: message }; + } + + const lines = symbols.slice(0, limit).map((symbol, index) => { + const location = this.formatLocationWithoutServer( + symbol.location, + workspaceRoot, + ); + const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : ''; + const kind = symbol.kind ? ` (${symbol.kind})` : ''; + const container = symbol.containerName + ? ` in ${symbol.containerName}` + : ''; + return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`; + }); + + const fileLabel = this.formatUriForDisplay(uri, workspaceRoot); + const heading = `Document symbols for ${fileLabel}:`; + return { + llmContent: [heading, ...lines].join('\n'), + returnDisplay: lines.join('\n'), + }; + } + + private async executeWorkspaceSymbols( + client: LspClient, + ): Promise { + const limit = this.params.limit ?? 20; + const query = this.params.query ?? ''; + let symbols: LspSymbolInformation[] = []; + try { + symbols = await client.workspaceSymbols(query, limit); + } catch (error) { + const message = `LSP workspace symbol search failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!symbols.length) { + const message = `No symbols found for query "${query}".`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const lines = symbols.slice(0, limit).map((symbol, index) => { + const location = this.formatLocationWithoutServer( + symbol.location, + workspaceRoot, + ); + const serverSuffix = symbol.serverName ? ` [${symbol.serverName}]` : ''; + const kind = symbol.kind ? ` (${symbol.kind})` : ''; + const container = symbol.containerName + ? ` in ${symbol.containerName}` + : ''; + return `${index + 1}. ${symbol.name}${kind}${container} - ${location}${serverSuffix}`; + }); + + const heading = `Found ${Math.min(symbols.length, limit)} of ${ + symbols.length + } symbols for query "${query}":`; + + // Also fetch references for the top match to provide additional context. + let referenceSection = ''; + const topSymbol = symbols[0]; + if (topSymbol) { + try { + const referenceLimit = Math.min(20, Math.max(limit, 5)); + const references = await client.references( + topSymbol.location, + topSymbol.serverName, + false, + referenceLimit, + ); + if (references.length > 0) { + const refLines = references.map((ref, index) => { + const location = this.formatLocationWithoutServer( + ref, + workspaceRoot, + ); + const serverSuffix = ref.serverName ? ` [${ref.serverName}]` : ''; + return `${index + 1}. ${location}${serverSuffix}`; + }); + referenceSection = [ + '', + `References for top match (${topSymbol.name}):`, + ...refLines, + ].join('\n'); + } + } catch (error) { + referenceSection = `\nReferences lookup failed: ${ + (error as Error)?.message || String(error) + }`; + } + } + + const llmParts = referenceSection + ? [heading, ...lines, referenceSection] + : [heading, ...lines]; + const displayParts = referenceSection + ? [...lines, referenceSection] + : [...lines]; + + return { + llmContent: llmParts.join('\n'), + returnDisplay: displayParts.join('\n'), + }; + } + + private async executePrepareCallHierarchy( + client: LspClient, + ): Promise { + const target = this.resolveLocationTarget(); + if ('error' in target) { + return { llmContent: target.error, returnDisplay: target.error }; + } + + const limit = this.params.limit ?? 20; + let items: LspCallHierarchyItem[] = []; + try { + items = await client.prepareCallHierarchy( + target.location, + this.params.serverName, + limit, + ); + } catch (error) { + const message = `LSP call hierarchy prepare failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!items.length) { + const message = `No call hierarchy items found for ${target.description}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const slicedItems = items.slice(0, limit); + const lines = slicedItems.map((item, index) => + this.formatCallHierarchyItemLine(item, index, workspaceRoot), + ); + + const heading = `Call hierarchy items for ${target.description}:`; + const jsonSection = this.formatJsonSection( + 'Call hierarchy items (JSON)', + slicedItems, + ); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + + private async executeIncomingCalls(client: LspClient): Promise { + const item = this.params.callHierarchyItem; + if (!item) { + const message = 'callHierarchyItem is required for incomingCalls.'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 20; + const serverName = this.params.serverName ?? item.serverName; + let calls: LspCallHierarchyIncomingCall[] = []; + try { + calls = await client.incomingCalls(item, serverName, limit); + } catch (error) { + const message = `LSP incoming calls failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!calls.length) { + const message = `No incoming calls found for ${this.describeCallHierarchyItemFull( + item, + )}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const slicedCalls = calls.slice(0, limit); + const lines = slicedCalls.map((call, index) => { + const targetItem = call.from; + const location = this.formatLocationWithServer( + { + uri: targetItem.uri, + range: targetItem.selectionRange, + serverName: targetItem.serverName, + }, + workspaceRoot, + ); + const kind = targetItem.kind ? ` (${targetItem.kind})` : ''; + const detail = targetItem.detail ? ` ${targetItem.detail}` : ''; + const rangeSuffix = this.formatCallRanges(call.fromRanges); + return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`; + }); + + const heading = `Incoming calls for ${this.describeCallHierarchyItemFull( + item, + )}:`; + const jsonSection = this.formatJsonSection( + 'Incoming calls (JSON)', + slicedCalls, + ); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + + private async executeOutgoingCalls(client: LspClient): Promise { + const item = this.params.callHierarchyItem; + if (!item) { + const message = 'callHierarchyItem is required for outgoingCalls.'; + return { llmContent: message, returnDisplay: message }; + } + + const limit = this.params.limit ?? 20; + const serverName = this.params.serverName ?? item.serverName; + let calls: LspCallHierarchyOutgoingCall[] = []; + try { + calls = await client.outgoingCalls(item, serverName, limit); + } catch (error) { + const message = `LSP outgoing calls failed: ${ + (error as Error)?.message || String(error) + }`; + return { llmContent: message, returnDisplay: message }; + } + + if (!calls.length) { + const message = `No outgoing calls found for ${this.describeCallHierarchyItemFull( + item, + )}.`; + return { llmContent: message, returnDisplay: message }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const slicedCalls = calls.slice(0, limit); + const lines = slicedCalls.map((call, index) => { + const targetItem = call.to; + const location = this.formatLocationWithServer( + { + uri: targetItem.uri, + range: targetItem.selectionRange, + serverName: targetItem.serverName, + }, + workspaceRoot, + ); + const kind = targetItem.kind ? ` (${targetItem.kind})` : ''; + const detail = targetItem.detail ? ` ${targetItem.detail}` : ''; + const rangeSuffix = this.formatCallRanges(call.fromRanges); + return `${index + 1}. ${targetItem.name}${kind}${detail} - ${location}${rangeSuffix}`; + }); + + const heading = `Outgoing calls for ${this.describeCallHierarchyItemFull( + item, + )}:`; + const jsonSection = this.formatJsonSection( + 'Outgoing calls (JSON)', + slicedCalls, + ); + return { + llmContent: [heading, ...lines].join('\n') + jsonSection, + returnDisplay: lines.join('\n'), + }; + } + + private resolveLocationTarget(): ResolvedTarget { + const filePath = this.params.filePath; + if (!filePath) { + return { + error: 'filePath is required for this operation.', + }; + } + if (typeof this.params.line !== 'number') { + return { + error: 'line is required for this operation.', + }; + } + + const workspaceRoot = this.config.getProjectRoot(); + const uri = this.resolveUri(filePath, workspaceRoot); + if (!uri) { + return { + error: 'A valid filePath is required when specifying a line/character.', + }; + } + + const position = { + line: Math.max(0, Math.floor(this.params.line - 1)), + character: Math.max(0, Math.floor((this.params.character ?? 1) - 1)), + }; + const location: LspLocation = { + uri, + range: { start: position, end: position }, + }; + const description = this.formatLocationWithServer( + { ...location, serverName: this.params.serverName }, + workspaceRoot, + ); + return { + location, + description, + }; + } + + private resolveUri(filePath: string, workspaceRoot: string): string | null { + if (!filePath) { + return null; + } + if (filePath.startsWith('file://') || filePath.includes('://')) { + return filePath; + } + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(workspaceRoot, filePath); + return pathToFileURL(absolutePath).toString(); + } + + private formatLocationWithServer( + location: LspLocation & { serverName?: string }, + workspaceRoot: string, + ): string { + const start = location.range.start; + let filePath = location.uri; + + if (filePath.startsWith('file://')) { + filePath = fileURLToPath(filePath); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + + const serverSuffix = + location.serverName && location.serverName !== '' + ? ` [${location.serverName}]` + : ''; + + return `${filePath}:${(start.line ?? 0) + 1}:${(start.character ?? 0) + 1}${serverSuffix}`; + } + + private formatLocationWithoutServer( + location: LspLocation, + workspaceRoot: string, + ): string { + const { uri, range } = location; + let filePath = uri; + if (uri.startsWith('file://')) { + filePath = fileURLToPath(uri); + filePath = path.relative(workspaceRoot, filePath) || '.'; + } + const line = (range.start.line ?? 0) + 1; + const character = (range.start.character ?? 0) + 1; + return `${filePath}:${line}:${character}`; + } + + private formatCallHierarchyItemLine( + item: LspCallHierarchyItem, + index: number, + workspaceRoot: string, + ): string { + const location = this.formatLocationWithServer( + { + uri: item.uri, + range: item.selectionRange, + serverName: item.serverName, + }, + workspaceRoot, + ); + const kind = item.kind ? ` (${item.kind})` : ''; + const detail = item.detail ? ` ${item.detail}` : ''; + return `${index + 1}. ${item.name}${kind}${detail} - ${location}`; + } + + private formatCallRanges(ranges: LspRange[]): string { + if (!ranges.length) { + return ''; + } + const formatted = ranges.map((range) => this.formatPosition(range.start)); + const maxShown = 3; + const shown = formatted.slice(0, maxShown); + const extra = + formatted.length > maxShown + ? `, +${formatted.length - maxShown} more` + : ''; + return ` (calls at ${shown.join(', ')}${extra})`; + } + + private formatPosition(position: LspRange['start']): string { + return `${(position.line ?? 0) + 1}:${(position.character ?? 0) + 1}`; + } + + private formatUriForDisplay(uri: string, workspaceRoot: string): string { + let filePath = uri; + if (uri.startsWith('file://')) { + filePath = fileURLToPath(uri); + } + if (path.isAbsolute(filePath)) { + return path.relative(workspaceRoot, filePath) || '.'; + } + return filePath; + } + + private formatJsonSection(label: string, data: unknown): string { + return `\n\n${label}:\n${JSON.stringify(data, null, 2)}`; + } + + private describeCallHierarchyItemShort(): string { + const item = this.params.callHierarchyItem; + if (!item) { + return 'call hierarchy item'; + } + return item.name || 'call hierarchy item'; + } + + private describeCallHierarchyItemFull(item: LspCallHierarchyItem): string { + const workspaceRoot = this.config.getProjectRoot(); + const location = this.formatLocationWithServer( + { + uri: item.uri, + range: item.selectionRange, + serverName: item.serverName, + }, + workspaceRoot, + ); + return `${item.name} at ${location}`; + } + + private getOperationLabel(): string { + switch (this.params.operation) { + case 'goToDefinition': + return 'go-to-definition'; + case 'findReferences': + return 'find-references'; + case 'hover': + return 'hover'; + case 'documentSymbol': + return 'document symbols'; + case 'workspaceSymbol': + return 'workspace symbol search'; + case 'goToImplementation': + return 'go-to-implementation'; + case 'prepareCallHierarchy': + return 'prepare call hierarchy'; + case 'incomingCalls': + return 'incoming calls'; + case 'outgoingCalls': + return 'outgoing calls'; + default: + return this.params.operation; + } + } +} + +/** + * Unified LSP tool that supports multiple operations: + * - goToDefinition: Find where a symbol is defined + * - findReferences: Find all references to a symbol + * - hover: Get hover information (documentation, type info) + * - documentSymbol: Get all symbols in a document + * - workspaceSymbol: Search for symbols across the workspace + * - goToImplementation: Find implementations of an interface or abstract method + * - prepareCallHierarchy: Get call hierarchy item at a position + * - incomingCalls: Find all functions that call the given function + * - outgoingCalls: Find all functions called by the given function + */ +export class LspTool extends BaseDeclarativeTool { + static readonly Name = ToolNames.LSP; + + constructor(private readonly config: Config) { + super( + LspTool.Name, + ToolDisplayNames.LSP, + 'Unified LSP operations for definitions, references, hover, symbols, and call hierarchy.', + Kind.Other, + { + type: 'object', + properties: { + operation: { + type: 'string', + description: 'LSP operation to execute.', + enum: [ + 'goToDefinition', + 'findReferences', + 'hover', + 'documentSymbol', + 'workspaceSymbol', + 'goToImplementation', + 'prepareCallHierarchy', + 'incomingCalls', + 'outgoingCalls', + ], + }, + filePath: { + type: 'string', + description: 'File path (absolute or workspace-relative).', + }, + line: { + type: 'number', + description: '1-based line number for the target location.', + }, + character: { + type: 'number', + description: + '1-based character/column number for the target location.', + }, + includeDeclaration: { + type: 'boolean', + description: + 'Include the declaration itself when looking up references.', + }, + query: { + type: 'string', + description: 'Symbol query for workspace symbol search.', + }, + callHierarchyItem: { + $ref: '#/definitions/LspCallHierarchyItem', + description: 'Call hierarchy item for incoming/outgoing calls.', + }, + serverName: { + type: 'string', + description: 'Optional LSP server name to target.', + }, + limit: { + type: 'number', + description: 'Optional maximum number of results to return.', + }, + }, + required: ['operation'], + definitions: { + LspPosition: { + type: 'object', + properties: { + line: { type: 'number' }, + character: { type: 'number' }, + }, + required: ['line', 'character'], + }, + LspRange: { + type: 'object', + properties: { + start: { $ref: '#/definitions/LspPosition' }, + end: { $ref: '#/definitions/LspPosition' }, + }, + required: ['start', 'end'], + }, + LspCallHierarchyItem: { + type: 'object', + properties: { + name: { type: 'string' }, + kind: { type: 'string' }, + rawKind: { type: 'number' }, + detail: { type: 'string' }, + uri: { type: 'string' }, + range: { $ref: '#/definitions/LspRange' }, + selectionRange: { $ref: '#/definitions/LspRange' }, + data: {}, + serverName: { type: 'string' }, + }, + required: ['name', 'uri', 'range', 'selectionRange'], + }, + }, + }, + false, + false, + ); + } + + protected override validateToolParamValues( + params: LspToolParams, + ): string | null { + const operation = params.operation; + + if (LOCATION_REQUIRED_OPERATIONS.has(operation)) { + if (!params.filePath || params.filePath.trim() === '') { + return `filePath is required for ${operation}.`; + } + if (typeof params.line !== 'number') { + return `line is required for ${operation}.`; + } + } + + if (FILE_REQUIRED_OPERATIONS.has(operation)) { + if (!params.filePath || params.filePath.trim() === '') { + return `filePath is required for ${operation}.`; + } + } + + if (QUERY_REQUIRED_OPERATIONS.has(operation)) { + if (!params.query || params.query.trim() === '') { + return `query is required for ${operation}.`; + } + } + + if (ITEM_REQUIRED_OPERATIONS.has(operation)) { + if (!params.callHierarchyItem) { + return `callHierarchyItem is required for ${operation}.`; + } + } + + if (params.line !== undefined && params.line < 1) { + return 'line must be a positive number.'; + } + if (params.character !== undefined && params.character < 1) { + return 'character must be a positive number.'; + } + if (params.limit !== undefined && params.limit <= 0) { + return 'limit must be a positive number.'; + } + + return null; + } + + protected createInvocation( + params: LspToolParams, + ): ToolInvocation { + return new LspToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 1e0600b0a..d9a5ef772 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -28,6 +28,8 @@ export const ToolNames = { LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol', LSP_GO_TO_DEFINITION: 'lsp_go_to_definition', LSP_FIND_REFERENCES: 'lsp_find_references', + /** Unified LSP tool supporting all LSP operations. */ + LSP: 'lsp', } as const; /** @@ -54,6 +56,8 @@ export const ToolDisplayNames = { LSP_WORKSPACE_SYMBOL: 'LspWorkspaceSymbol', LSP_GO_TO_DEFINITION: 'LspGoToDefinition', LSP_FIND_REFERENCES: 'LspFindReferences', + /** Unified LSP tool display name. */ + LSP: 'Lsp', } as const; // Migration from old tool names to new tool names diff --git a/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md b/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md new file mode 100644 index 000000000..e3660926e --- /dev/null +++ b/packages/vscode-ide-companion/LSP_REFACTORING_PLAN.md @@ -0,0 +1,255 @@ +# LSP 工具重构计划 + +## 背景 + +对比 Claude Code 的 LSP tool 定义和当前实现,发现以下关键差异: + +### Claude Code 的设计(目标) + +```json +{ + "name": "LSP", + "operations": [ + "goToDefinition", + "findReferences", + "hover", + "documentSymbol", + "workspaceSymbol", + "goToImplementation", + "prepareCallHierarchy", + "incomingCalls", + "outgoingCalls" + ], + "required_params": ["operation", "filePath", "line", "character"] +} +``` + +### 当前实现 + +- **分散的 3 个工具**:`lsp_go_to_definition`, `lsp_find_references`, `lsp_workspace_symbol` +- **支持 3 个操作**:goToDefinition, findReferences, workspaceSymbol +- **缺少 6 个操作**:hover, documentSymbol, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls + +--- + +## 重构目标 + +1. **统一工具设计**:将 3 个分散的工具合并为 1 个统一的 `LSP` 工具 +2. **扩展操作支持**:添加缺失的 6 个 LSP 操作 +3. **简化参数设计**:统一使用 operation + filePath + line + character 方式 +4. **保持向后兼容**:旧工具名称继续支持 + +--- + +## 实施步骤 + +### Step 1: 扩展类型定义 + +**文件**: `packages/core/src/lsp/types.ts` + +新增类型: + +```typescript +// Hover 结果 +interface LspHoverResult { + contents: string | { language: string; value: string }[]; + range?: LspRange; +} + +// Call Hierarchy 类型 +interface LspCallHierarchyItem { + name: string; + kind: number; + uri: string; + range: LspRange; + selectionRange: LspRange; + detail?: string; + data?: unknown; + serverName?: string; +} + +interface LspCallHierarchyIncomingCall { + from: LspCallHierarchyItem; + fromRanges: LspRange[]; +} + +interface LspCallHierarchyOutgoingCall { + to: LspCallHierarchyItem; + fromRanges: LspRange[]; +} +``` + +扩展 LspClient 接口: + +```typescript +interface LspClient { + // 现有方法 + workspaceSymbols(query, limit): Promise; + definitions(location, serverName, limit): Promise; + references( + location, + serverName, + includeDeclaration, + limit, + ): Promise; + + // 新增方法 + hover(location, serverName): Promise; + documentSymbols(uri, serverName, limit): Promise; + implementations(location, serverName, limit): Promise; + prepareCallHierarchy(location, serverName): Promise; + incomingCalls( + item, + serverName, + limit, + ): Promise; + outgoingCalls( + item, + serverName, + limit, + ): Promise; +} +``` + +### Step 2: 创建统一 LSP 工具 + +**新文件**: `packages/core/src/tools/lsp.ts` + +参数设计(采用灵活的操作特定验证): + +```typescript +interface LspToolParams { + operation: LspOperation; // 必填 + filePath?: string; // 位置类操作必填 + line?: number; // 精确位置操作必填 (1-based) + character?: number; // 可选 (1-based) + query?: string; // workspaceSymbol 必填 + callHierarchyItem?: object; // incomingCalls/outgoingCalls 必填 + serverName?: string; // 可选 + limit?: number; // 可选 + includeDeclaration?: boolean; // findReferences 可选 +} + +type LspOperation = + | 'goToDefinition' + | 'findReferences' + | 'hover' + | 'documentSymbol' + | 'workspaceSymbol' + | 'goToImplementation' + | 'prepareCallHierarchy' + | 'incomingCalls' + | 'outgoingCalls'; +``` + +各操作参数要求: +| 操作 | filePath | line | character | query | callHierarchyItem | +|------|----------|------|-----------|-------|-------------------| +| goToDefinition | 必填 | 必填 | 可选 | - | - | +| findReferences | 必填 | 必填 | 可选 | - | - | +| hover | 必填 | 必填 | 可选 | - | - | +| documentSymbol | 必填 | - | - | - | - | +| workspaceSymbol | - | - | - | 必填 | - | +| goToImplementation | 必填 | 必填 | 可选 | - | - | +| prepareCallHierarchy | 必填 | 必填 | 可选 | - | - | +| incomingCalls | - | - | - | - | 必填 | +| outgoingCalls | - | - | - | - | 必填 | + +### Step 3: 扩展 NativeLspService + +**文件**: `packages/cli/src/services/lsp/NativeLspService.ts` + +新增 6 个方法: + +1. `hover()` - 调用 `textDocument/hover` +2. `documentSymbols()` - 调用 `textDocument/documentSymbol` +3. `implementations()` - 调用 `textDocument/implementation` +4. `prepareCallHierarchy()` - 调用 `textDocument/prepareCallHierarchy` +5. `incomingCalls()` - 调用 `callHierarchy/incomingCalls` +6. `outgoingCalls()` - 调用 `callHierarchy/outgoingCalls` + +### Step 4: 更新工具名称映射 + +**文件**: `packages/core/src/tools/tool-names.ts` + +```typescript +export const ToolNames = { + LSP: 'lsp', // 新增 + // 保留旧名称(标记 deprecated) + LSP_WORKSPACE_SYMBOL: 'lsp_workspace_symbol', + LSP_GO_TO_DEFINITION: 'lsp_go_to_definition', + LSP_FIND_REFERENCES: 'lsp_find_references', +} as const; + +export const ToolNamesMigration = { + lsp_go_to_definition: ToolNames.LSP, + lsp_find_references: ToolNames.LSP, + lsp_workspace_symbol: ToolNames.LSP, +} as const; +``` + +### Step 5: 更新 Config 工具注册 + +**文件**: `packages/core/src/config/config.ts` + +- 注册新的统一 `LspTool` +- 保留旧工具注册(向后兼容) +- 可通过配置选项禁用旧工具 + +### Step 6: 向后兼容处理 + +**文件**: 现有 3 个 LSP 工具文件 + +- 添加 `@deprecated` 标记 +- 添加 deprecation warning 日志 +- 可选:内部转发到新工具实现 + +--- + +## 关键文件列表 + +| 文件路径 | 操作 | +| --------------------------------------------------- | --------------------------- | +| `packages/core/src/lsp/types.ts` | 修改 - 扩展类型定义 | +| `packages/core/src/tools/lsp.ts` | 新建 - 统一 LSP 工具 | +| `packages/core/src/tools/tool-names.ts` | 修改 - 添加工具名称 | +| `packages/cli/src/services/lsp/NativeLspService.ts` | 修改 - 添加 6 个新方法 | +| `packages/core/src/config/config.ts` | 修改 - 注册新工具 | +| `packages/core/src/tools/lsp-*.ts` (3个) | 修改 - 添加 deprecated 标记 | + +--- + +## 验证方式 + +1. **单元测试**: + - 新 `LspTool` 参数验证测试 + - 各操作执行逻辑测试 + - 向后兼容测试 + +2. **集成测试**: + - TypeScript Language Server 测试所有 9 个操作 + - Python LSP 测试 + - 多服务器场景测试 + +3. **手动验证**: + - 在 VS Code 中测试各操作 + - 验证旧工具名称仍可使用 + - 验证 deprecation warning 输出 + +--- + +## 风险与缓解 + +| 风险 | 缓解措施 | +| --------------------------- | -------------------------------------- | +| 部分 LSP 服务器不支持新操作 | 独立 try-catch,返回清晰错误消息 | +| Call Hierarchy 两步流程复杂 | 文档说明使用方式,提供示例 | +| 向后兼容增加维护成本 | 设置明确弃用时间线,配置选项控制旧工具 | + +--- + +## 后续优化建议 + +1. 考虑是否需要支持更多 LSP 操作(如 `textDocument/rename`, `textDocument/formatting`) +2. 考虑添加 LSP 服务器能力查询,动态返回支持的操作列表 +3. 考虑优化 TypeScript Server warm-up 逻辑,减少首次调用延迟 From 73b4a9b56011cbd24b78f3d5ce7438a4cc58d2be Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Sun, 18 Jan 2026 23:23:16 +0800 Subject: [PATCH 021/113] chore(vscode-ide-companion, webui): update plan md --- eslint.config.js | 402 +++++++++--------- .../src/types/completionItemTypes.ts | 2 - .../{ => docs}/WEBUI_MIGRATION_PLAN_EN.md | 0 .../{ => docs}/WEBUI_MIGRATION_PLAN_ZH.md | 0 .../WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md | 0 .../docs/tool_display_names_documentation.md | 0 6 files changed, 203 insertions(+), 201 deletions(-) rename packages/webui/{ => docs}/WEBUI_MIGRATION_PLAN_EN.md (100%) rename packages/webui/{ => docs}/WEBUI_MIGRATION_PLAN_ZH.md (100%) rename packages/webui/{ => docs}/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md (100%) rename tool_display_names_documentation.md => packages/webui/docs/tool_display_names_documentation.md (100%) diff --git a/eslint.config.js b/eslint.config.js index 86fb9a4e3..67d09f2c1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,3 @@ -// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from "eslint-plugin-storybook"; - /** * @license * Copyright 2025 Google LLC @@ -15,22 +12,26 @@ import prettierConfig from 'eslint-config-prettier'; import importPlugin from 'eslint-plugin-import'; import vitest from '@vitest/eslint-plugin'; import globals from 'globals'; +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; -export default tseslint.config({ - // Global ignores - ignores: [ - 'node_modules/*', - 'packages/**/dist/**', - 'bundle/**', - 'package/bundle/**', - '.integration-tests/**', - 'packages/**/.integration-test/**', - 'dist/**', - 'docs-site/.next/**', - 'docs-site/out/**', - ], -}, eslint.configs.recommended, ...tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactPlugin.configs.flat.recommended, // Add this if you are using React 17+ -reactPlugin.configs.flat['jsx-runtime'], { +export default tseslint.config( + { + // Global ignores + ignores: [ + 'node_modules/*', + 'packages/**/dist/**', + 'bundle/**', + 'package/bundle/**', + '.integration-tests/**', + 'packages/**/.integration-test/**', + 'dist/**', + 'docs-site/.next/**', + 'docs-site/out/**', + ], + }, + eslint.configs.recommended, ...tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactPlugin.configs.flat.recommended, // Add this if you are using React 17+ + reactPlugin.configs.flat['jsx-runtime'], { // Settings for eslint-plugin-react settings: { react: { @@ -54,105 +55,106 @@ reactPlugin.configs.flat['jsx-runtime'], { 'import/no-default-export': 'warn', 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths }, -}, { - // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package - plugins: { - import: importPlugin, - }, - settings: { - 'import/resolver': { - node: true, +}, + { + // General overrides and rules for the project (TS/TSX files) + files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + plugins: { + import: importPlugin, }, - }, - languageOptions: { - globals: { - ...globals.node, - ...globals.es2021, + settings: { + 'import/resolver': { + node: true, + }, }, - }, - rules: { - // We use TypeScript for React components; prop-types are unnecessary - 'react/prop-types': 'off', - // General Best Practice Rules (subset adapted for flat config) - '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], - 'arrow-body-style': ['error', 'as-needed'], - curly: ['error', 'multi-line'], - eqeqeq: ['error', 'always', { null: 'ignore' }], - '@typescript-eslint/consistent-type-assertions': [ - 'error', - { assertionStyle: 'as' }, - ], - '@typescript-eslint/explicit-member-accessibility': [ - 'error', - { accessibility: 'no-public' }, - ], - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-inferrable-types': [ - 'error', - { ignoreParameters: true, ignoreProperties: true }, - ], - '@typescript-eslint/consistent-type-imports': [ - 'error', - { disallowTypeAnnotations: false }, - ], - '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', + languageOptions: { + globals: { + ...globals.node, + ...globals.es2021, }, - ], - 'import/no-internal-modules': [ - 'error', - { - allow: [ - 'react-dom/test-utils', - 'react-dom/client', - 'memfs/lib/volume.js', - 'yargs/**', - 'msw/node', - '**/generated/**', - './styles/tailwind.css', - './styles/App.css', - './styles/style.css' - ], - }, - ], - 'import/no-relative-packages': 'error', - 'no-cond-assign': 'error', - 'no-debugger': 'error', - 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: 'CallExpression[callee.name="require"]', - message: 'Avoid using require(). Use ES6 imports instead.', - }, - { - selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', - message: - 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', - }, - ], - 'no-unsafe-finally': 'error', - 'no-unused-expressions': 'off', // Disable base rule - '@typescript-eslint/no-unused-expressions': [ - // Enable TS version - 'error', - { allowShortCircuit: true, allowTernary: true }, - ], - 'no-var': 'error', - 'object-shorthand': 'error', - 'one-var': ['error', 'never'], - 'prefer-arrow-callback': 'error', - 'prefer-const': ['error', { destructuring: 'all' }], - radix: 'error', - 'default-case': 'error', - }, -}, { + }, + rules: { + // We use TypeScript for React components; prop-types are unnecessary + 'react/prop-types': 'off', + // General Best Practice Rules (subset adapted for flat config) + '@typescript-eslint/array-type': ['error', { default: 'array-simple' }], + 'arrow-body-style': ['error', 'as-needed'], + curly: ['error', 'multi-line'], + eqeqeq: ['error', 'always', { null: 'ignore' }], + '@typescript-eslint/consistent-type-assertions': [ + 'error', + { assertionStyle: 'as' }, + ], + '@typescript-eslint/explicit-member-accessibility': [ + 'error', + { accessibility: 'no-public' }, + ], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-inferrable-types': [ + 'error', + { ignoreParameters: true, ignoreProperties: true }, + ], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { disallowTypeAnnotations: false }, + ], + '@typescript-eslint/no-namespace': ['error', { allowDeclarations: true }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + 'import/no-internal-modules': [ + 'error', + { + allow: [ + 'react-dom/test-utils', + 'react-dom/client', + 'memfs/lib/volume.js', + 'yargs/**', + 'msw/node', + '**/generated/**', + './styles/tailwind.css', + './styles/App.css', + './styles/style.css' + ], + }, + ], + 'import/no-relative-packages': 'error', + 'no-cond-assign': 'error', + 'no-debugger': 'error', + 'no-duplicate-case': 'error', + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.name="require"]', + message: 'Avoid using require(). Use ES6 imports instead.', + }, + { + selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])', + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, + ], + 'no-unsafe-finally': 'error', + 'no-unused-expressions': 'off', // Disable base rule + '@typescript-eslint/no-unused-expressions': [ + // Enable TS version + 'error', + { allowShortCircuit: true, allowTernary: true }, + ], + 'no-var': 'error', + 'object-shorthand': 'error', + 'one-var': ['error', 'never'], + 'prefer-arrow-callback': 'error', + 'prefer-const': ['error', { destructuring: 'all' }], + radix: 'error', + 'default-case': 'error', + }, + }, { files: ['packages/*/src/**/*.test.{ts,tsx}', 'packages/**/test/**/*.test.{ts,tsx}'], plugins: { vitest, @@ -171,26 +173,26 @@ reactPlugin.configs.flat['jsx-runtime'], { ], }, }, // extra settings for scripts that we run directly with node -{ - files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', + { + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/*/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', }, - ], - }, -}, { + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, { files: ['packages/vscode-ide-companion/esbuild.js'], languageOptions: { globals: { @@ -204,72 +206,74 @@ reactPlugin.configs.flat['jsx-runtime'], { '@typescript-eslint/no-require-imports': 'off', }, }, // extra settings for scripts that we run directly with node -{ - files: ['packages/vscode-ide-companion/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', - }, -}, // extra settings for core package scripts -{ - files: ['packages/core/scripts/**/*.js'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - 'no-restricted-syntax': 'off', - '@typescript-eslint/no-require-imports': 'off', - }, -}, // Prettier config must be last -prettierConfig, // extra settings for scripts that we run directly with node -{ - files: ['./integration-tests/**/*.{js,ts,tsx}'], - languageOptions: { - globals: { - ...globals.node, - process: 'readonly', - console: 'readonly', - }, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'error', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - }, -}, // Settings for docs-site directory -{ - files: ['docs-site/**/*.{js,jsx}'], - languageOptions: { - globals: { - ...globals.browser, - ...globals.node, - }, - parserOptions: { - ecmaFeatures: { - jsx: true, + { + files: ['packages/vscode-ide-companion/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', }, }, + rules: { + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, + }, // extra settings for core package scripts + { + files: ['packages/core/scripts/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + 'no-restricted-syntax': 'off', + '@typescript-eslint/no-require-imports': 'off', + }, + }, // Prettier config must be last + prettierConfig, // extra settings for scripts that we run directly with node + { + files: ['./integration-tests/**/*.{js,ts,tsx}'], + languageOptions: { + globals: { + ...globals.node, + process: 'readonly', + console: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, + }, // Settings for docs-site directory + { + files: ['docs-site/**/*.{js,jsx}'], + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + rules: { + // Allow relaxed rules for documentation site + '@typescript-eslint/no-unused-vars': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + }, }, - rules: { - // Allow relaxed rules for documentation site - '@typescript-eslint/no-unused-vars': 'off', - 'react/prop-types': 'off', - 'react/react-in-jsx-scope': 'off', - }, -}, storybook.configs["flat/recommended"]); + storybook.configs["flat/recommended"] +); diff --git a/packages/vscode-ide-companion/src/types/completionItemTypes.ts b/packages/vscode-ide-companion/src/types/completionItemTypes.ts index 97c18a63d..eb105f77a 100644 --- a/packages/vscode-ide-companion/src/types/completionItemTypes.ts +++ b/packages/vscode-ide-companion/src/types/completionItemTypes.ts @@ -2,8 +2,6 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 - * - * Re-export completion item types from webui for backward compatibility */ export type { CompletionItem, CompletionItemType } from '@qwen-code/webui'; diff --git a/packages/webui/WEBUI_MIGRATION_PLAN_EN.md b/packages/webui/docs/WEBUI_MIGRATION_PLAN_EN.md similarity index 100% rename from packages/webui/WEBUI_MIGRATION_PLAN_EN.md rename to packages/webui/docs/WEBUI_MIGRATION_PLAN_EN.md diff --git a/packages/webui/WEBUI_MIGRATION_PLAN_ZH.md b/packages/webui/docs/WEBUI_MIGRATION_PLAN_ZH.md similarity index 100% rename from packages/webui/WEBUI_MIGRATION_PLAN_ZH.md rename to packages/webui/docs/WEBUI_MIGRATION_PLAN_ZH.md diff --git a/packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md b/packages/webui/docs/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md similarity index 100% rename from packages/webui/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md rename to packages/webui/docs/WEBUI_PLATFORM_ADAPTER_GUIDE_ZH.md diff --git a/tool_display_names_documentation.md b/packages/webui/docs/tool_display_names_documentation.md similarity index 100% rename from tool_display_names_documentation.md rename to packages/webui/docs/tool_display_names_documentation.md From a684f07ff470f6057d7b5f0107f3820ed1d3c277 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Mon, 19 Jan 2026 14:21:05 +0800 Subject: [PATCH 022/113] refactor: move contextWindowSize to ContentGeneratorConfig - Add contextWindowSize field to ContentGeneratorConfig interface - Update tokenLimit function to accept contentGeneratorConfig parameter - Implement priority logic: user config > auto-detection - Update chatCompressionService to use new API via getContentGeneratorConfig() - Add contextWindowSize to MODEL_GENERATION_CONFIG_FIELDS for config resolution - Add contextWindowSize to CLI settings schema (model.generationConfig) - Update UI components (Footer, ContextUsageDisplay) to use new config API - Fix test mocks to include getContentGeneratorConfig method This refactor avoids modifying 71+ test files by moving the config to the generator level instead of the Config class level. Modified files: - packages/core/src/core/contentGenerator.ts - packages/core/src/core/tokenLimits.ts - packages/core/src/services/chatCompressionService.ts - packages/core/src/services/chatCompressionService.test.ts - packages/core/src/models/constants.ts - packages/cli/src/config/settingsSchema.ts - packages/cli/src/ui/components/ContextUsageDisplay.tsx - packages/cli/src/ui/components/Footer.tsx --- packages/cli/src/config/settingsSchema.ts | 12 ++++++++++++ .../cli/src/ui/components/ContextUsageDisplay.tsx | 8 ++++++-- packages/cli/src/ui/components/Footer.tsx | 1 + packages/core/src/core/contentGenerator.ts | 3 +++ packages/core/src/core/tokenLimits.ts | 15 +++++++++++++++ packages/core/src/models/constants.ts | 1 + .../src/services/chatCompressionService.test.ts | 1 + .../core/src/services/chatCompressionService.ts | 4 +++- 8 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 74b63a7b9..59b02b196 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -690,6 +690,18 @@ const SETTINGS_SCHEMA = { { value: 'openapi_30', label: 'OpenAPI 3.0 Strict' }, ], }, + contextWindowSize: { + type: 'number', + label: 'Context Window Size', + category: 'Generation Configuration', + requiresRestart: false, + default: -1, + description: + 'Override the automatic context window size detection. Set to -1 to use automatic detection based on the model. Set to a positive number to use a custom context window size.', + parentKey: 'generationConfig', + childKey: 'contextWindowSize', + showInDialog: true, + }, }, }, }, diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 7e585bed4..cf67b9686 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -6,18 +6,22 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { tokenLimit } from '@qwen-code/qwen-code-core'; +import { tokenLimit, type Config } from '@qwen-code/qwen-code-core'; export const ContextUsageDisplay = ({ promptTokenCount, model, terminalWidth, + config, }: { promptTokenCount: number; model: string; terminalWidth: number; + config: Config; }) => { - const percentage = promptTokenCount / tokenLimit(model); + const contentGeneratorConfig = config.getContentGeneratorConfig(); + const contextLimit = tokenLimit(model, 'input', contentGeneratorConfig); + const percentage = promptTokenCount / contextLimit; const percentageLeft = ((1 - percentage) * 100).toFixed(0); const label = terminalWidth < 100 ? '%' : '% context left'; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 71f278df3..fb24fe44a 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -146,6 +146,7 @@ export const Footer: React.FC = () => { promptTokenCount={promptTokenCount} model={model} terminalWidth={terminalWidth} + config={config} /> {showMemoryUsage && } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index fc36fda3c..1f794dfc7 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -91,6 +91,9 @@ export type ContentGeneratorConfig = { userAgent?: string; // Schema compliance mode for tool definitions schemaCompliance?: 'auto' | 'openapi_30'; + // Context window size override. If set to a positive number, it will override + // the automatic detection. Set to -1 to use automatic detection. + contextWindowSize?: number; }; // Keep the public ContentGeneratorConfigSources API, but reuse the generic diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 914715802..a5686043d 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -224,14 +224,29 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ * or output generation based on the model and token type. It uses the same * normalization logic for consistency across both input and output limits. * + * If a contentGeneratorConfig is provided with a contextWindowSize > 0, that value + * will be used for input token limits instead of the automatic detection. + * * @param model - The model name to get the token limit for * @param type - The type of token limit ('input' for context window, 'output' for generation) + * @param contentGeneratorConfig - Optional config that may contain a contextWindowSize override * @returns The maximum number of tokens allowed for this model and type */ export function tokenLimit( model: Model, type: TokenLimitType = 'input', + contentGeneratorConfig?: { contextWindowSize?: number }, ): TokenCount { + // If user configured a specific context window size for input, use it + const configuredLimit = contentGeneratorConfig?.contextWindowSize; + if ( + type === 'input' && + configuredLimit !== undefined && + configuredLimit > 0 + ) { + return configuredLimit; + } + const norm = normalize(model); // Choose the appropriate patterns based on token type diff --git a/packages/core/src/models/constants.ts b/packages/core/src/models/constants.ts index 9dd69620c..024c96893 100644 --- a/packages/core/src/models/constants.ts +++ b/packages/core/src/models/constants.ts @@ -25,6 +25,7 @@ export const MODEL_GENERATION_CONFIG_FIELDS = [ 'disableCacheControl', 'schemaCompliance', 'reasoning', + 'contextWindowSize', ] as const satisfies ReadonlyArray; /** diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 5e1c75c5c..1b85e47a1 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -118,6 +118,7 @@ describe('ChatCompressionService', () => { mockConfig = { getChatCompression: vi.fn(), getContentGenerator: vi.fn(), + getContentGeneratorConfig: vi.fn().mockReturnValue({}), } as unknown as Config; vi.mocked(tokenLimit).mockReturnValue(1000); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index f692be3e3..cb0a19e09 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -110,7 +110,9 @@ export class ChatCompressionService { // Don't compress if not forced and we are under the limit. if (!force) { - if (originalTokenCount < threshold * tokenLimit(model)) { + const contentGeneratorConfig = config.getContentGeneratorConfig(); + const contextLimit = tokenLimit(model, 'input', contentGeneratorConfig); + if (originalTokenCount < threshold * contextLimit) { return { newHistory: null, info: { From af269e6474de88d1a5784a7bcfb2c8840435dca3 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Mon, 19 Jan 2026 14:51:05 +0800 Subject: [PATCH 023/113] fix: add getContentGeneratorConfig mock to Footer.test.tsx --- packages/cli/src/ui/components/Footer.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 3d9b91b8a..4dc7c5957 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -43,6 +43,7 @@ const createMockConfig = (overrides = {}) => ({ getModel: vi.fn(() => defaultProps.model), getTargetDir: vi.fn(() => defaultProps.targetDir), getDebugMode: vi.fn(() => false), + getContentGeneratorConfig: vi.fn(() => ({})), ...overrides, }); From 88bd8ddffdef0386c55203ce5a0d786dab2201a7 Mon Sep 17 00:00:00 2001 From: xwj02155382 Date: Mon, 19 Jan 2026 15:29:23 +0800 Subject: [PATCH 024/113] docs: add contextWindowSize configuration documentation --- docs/users/configuration/settings.md | 41 ++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 668323062..8d2ab3380 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -120,6 +120,7 @@ Settings are organized into categories. All settings should be placed within the "generationConfig": { "timeout": 60000, "disableCacheControl": false, + "contextWindowSize": 128000, "customHeaders": { "X-Request-ID": "req-123", "X-User-ID": "user-456" @@ -134,6 +135,46 @@ Settings are organized into categories. All settings should be placed within the } ``` +**contextWindowSize:** + +The `contextWindowSize` field allows you to manually override the automatic context window size detection. This is useful when you want to: + +- **Optimize performance**: Limit context size to improve response speed +- **Control costs**: Reduce token usage to lower API call costs +- **Handle specific requirements**: Set a custom limit when automatic detection doesn't match your needs +- **Testing scenarios**: Use smaller context windows in test environments + +**Values:** + +- `-1` (default): Use automatic detection based on the model's capabilities +- Positive number: Manually specify the context window size in tokens (e.g., `128000` for 128k tokens) + +**Example with contextWindowSize:** + +```json +{ + "model": { + "generationConfig": { + "contextWindowSize": 128000 // Override to 128k tokens + } + } +} +``` + +Or use `-1` for automatic detection: + +```json +{ + "model": { + "generationConfig": { + "contextWindowSize": -1 // Auto-detect based on model (default) + } + } +} +``` + +**Priority:** User-configured `contextWindowSize` > Automatic detection > Default value + The `customHeaders` field allows you to add custom HTTP headers to all API requests. This is useful for request tracing, monitoring, API gateway routing, or when different models require different headers. If `customHeaders` is defined in `modelProviders[].generationConfig.customHeaders`, it will be used directly; otherwise, headers from `model.generationConfig.customHeaders` will be used. No merging occurs between the two levels. **model.openAILoggingDir examples:** From f6a54146a35ec3132f81db0c52f941e7dc3cf848 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 20 Jan 2026 00:26:58 +0800 Subject: [PATCH 025/113] feat(webui): add ChatViewer component with stories and styles Co-authored-by: Qwen-Coder --- packages/vscode-ide-companion/NOTICES.txt | 6 + .../src/components/ChatViewer/ChatViewer.css | 152 +++++++ .../ChatViewer/ChatViewer.stories.tsx | 428 ++++++++++++++++++ .../src/components/ChatViewer/ChatViewer.tsx | 274 +++++++++++ .../webui/src/components/ChatViewer/index.ts | 14 + packages/webui/src/index.ts | 12 + 6 files changed, 886 insertions(+) create mode 100644 packages/webui/src/components/ChatViewer/ChatViewer.css create mode 100644 packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx create mode 100644 packages/webui/src/components/ChatViewer/ChatViewer.tsx create mode 100644 packages/webui/src/components/ChatViewer/index.ts diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 9e312534d..2b4bdbbce 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -1,5 +1,11 @@ This file contains third-party software notices and license terms. +============================================================ +@qwen-code/webui@undefined +(No repository found) + +License text not found. + ============================================================ semver@7.7.2 (git+https://github.com/npm/node-semver.git) diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.css b/packages/webui/src/components/ChatViewer/ChatViewer.css new file mode 100644 index 000000000..5cc71011a --- /dev/null +++ b/packages/webui/src/components/ChatViewer/ChatViewer.css @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * ChatViewer component styles - matching vscode-ide-companion visual appearance + * Note: Timeline styles are inherited from shared styles/timeline.css + */ + +/* =========================== + Main Chat Viewer Container + =========================== */ +.chat-viewer-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background-color: var(--app-background, var(--app-primary-background, #1e1e1e)); + color: var(--app-primary-foreground, #cccccc); + font-family: var(--vscode-chat-font-family, var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif)); + font-size: var(--vscode-chat-font-size, 13px); + overflow: hidden; +} + +/* =========================== + Messages Container (scrollable) + =========================== */ +.chat-viewer-messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 20px; + display: flex; + flex-direction: column; + position: relative; + min-width: 0; + /* Enable smooth scrolling for auto-scroll */ + scroll-behavior: smooth; +} + +/* Dark theme scrollbar styling */ +.chat-viewer-messages::-webkit-scrollbar { + width: 8px; +} + +.chat-viewer-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-viewer-messages::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 4px; +} + +.chat-viewer-messages::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Light theme scrollbar styling */ +@media (prefers-color-scheme: light) { + .chat-viewer-container.auto-theme .chat-viewer-messages::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + } + + .chat-viewer-container.auto-theme .chat-viewer-messages::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); + } +} + +/* Force light theme scrollbar */ +.chat-viewer-container.light-theme .chat-viewer-messages::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); +} + +.chat-viewer-container.light-theme .chat-viewer-messages::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.3); +} + +/* Message item base styles */ +.chat-viewer-messages > * { + display: flex; + gap: 0; + align-items: flex-start; + text-align: left; + padding: 8px 0; + flex-direction: column; + position: relative; + animation: chatViewerFadeIn 0.2s ease-in; +} + +.chat-viewer-messages > *:not(:last-child) { + padding-bottom: 8px; +} + +/* Disable overflow anchoring on individual items for manual scroll control */ +.chat-viewer-messages > * { + overflow-anchor: none; +} + +/* User message container spacing */ +.chat-viewer-messages .user-message-container:first-child { + margin-top: 0; +} + +/* =========================== + Animations + =========================== */ +@keyframes chatViewerFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* =========================== + Empty State + =========================== */ +.chat-viewer-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; + color: var(--app-secondary-foreground, rgba(255, 255, 255, 0.6)); + font-size: 14px; + text-align: center; + padding: 20px; +} + +.chat-viewer-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.chat-viewer-empty-text { + max-width: 300px; + line-height: 1.5; +} + +/* =========================== + Scroll Anchor (for auto-scroll) + =========================== */ +.chat-viewer-scroll-anchor { + height: 1px; + overflow-anchor: auto; +} diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx new file mode 100644 index 000000000..136efa855 --- /dev/null +++ b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx @@ -0,0 +1,428 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + ChatViewer, + type ChatMessageData, + type ChatViewerHandle, +} from './ChatViewer.js'; + +/** + * ChatViewer component displays a read-only conversation flow. + * It accepts JSONL-formatted chat messages and renders them using + * UserMessage and AssistantMessage components with timeline styling. + * + * Features: + * - Auto-scroll to bottom when new messages arrive + * - Programmatic scroll control via ref + * - Light/dark/auto theme support + * - Empty state with customizable message + */ +const meta: Meta = { + title: 'Chat/ChatViewer', + component: ChatViewer, + parameters: { + layout: 'padded', + }, + tags: ['autodocs'], + argTypes: { + messages: { + control: 'object', + description: 'Array of chat messages in JSONL format', + }, + className: { + control: 'text', + description: 'Additional CSS class name', + }, + onFileClick: { action: 'fileClicked' }, + emptyMessage: { + control: 'text', + description: 'Message to show when there are no messages', + }, + autoScroll: { + control: 'boolean', + description: 'Whether to auto-scroll to bottom when new messages arrive', + }, + theme: { + control: 'select', + options: ['dark', 'light', 'auto'], + description: 'Theme variant for the viewer', + }, + showEmptyIcon: { + control: 'boolean', + description: 'Whether to show the icon in empty state', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Helper function to create message data +const createMessage = ( + uuid: string, + type: 'user' | 'assistant', + text: string, + timestamp: string, + model?: string, +): ChatMessageData => ({ + uuid, + parentUuid: null, + sessionId: 'story-session', + timestamp, + type, + message: { + role: type === 'user' ? 'user' : 'model', + parts: [{ text }], + }, + model, +}); + +export const Default: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'How do I create a React component?', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + "To create a React component, you can use either a function or a class. Here's a simple example of a functional component:\n\n```tsx\nimport React from 'react';\n\nconst MyComponent: React.FC = () => {\n return
Hello, World!
;\n};\n\nexport default MyComponent;\n```\n\nThis creates a basic component that renders \"Hello, World!\". You can then use it in other components like ``.", + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + ], + }, +}; + +export const MultiTurn: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'What is TypeScript?', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'TypeScript is a strongly typed programming language that builds on JavaScript. It adds optional static typing and class-based object-oriented programming to the language.', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + createMessage( + '3', + 'user', + 'How do I define an interface?', + '2026-01-19T10:00:30.000Z', + ), + createMessage( + '4', + 'assistant', + 'You can define an interface in TypeScript like this:\n\n```typescript\ninterface User {\n id: number;\n name: string;\n email?: string; // optional property\n}\n\nconst user: User = {\n id: 1,\n name: "John Doe"\n};\n```\n\nInterfaces help you define the shape of objects and enable better type checking.', + '2026-01-19T10:00:35.000Z', + 'coder-model', + ), + createMessage( + '5', + 'user', + 'Can interfaces extend other interfaces?', + '2026-01-19T10:01:00.000Z', + ), + createMessage( + '6', + 'assistant', + 'Yes! Interfaces can extend one or more interfaces. Here\'s an example:\n\n```typescript\ninterface Person {\n name: string;\n age: number;\n}\n\ninterface Employee extends Person {\n employeeId: string;\n department: string;\n}\n\n// Employee now has: name, age, employeeId, and department\nconst employee: Employee = {\n name: "Jane",\n age: 30,\n employeeId: "E001",\n department: "Engineering"\n};\n```\n\nYou can also extend multiple interfaces: `interface Manager extends Person, Employee { ... }`', + '2026-01-19T10:01:05.000Z', + 'coder-model', + ), + ], + }, +}; + +export const WithCodeBlocks: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'Show me how to implement a debounce function', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + `Here's a debounce function implementation in TypeScript: + +\`\`\`typescript +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeoutId: ReturnType | null = null; + + return function (...args: Parameters) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + func.apply(this, args); + timeoutId = null; + }, wait); + }; +} +\`\`\` + +**Usage example:** + +\`\`\`typescript +const handleSearch = debounce((query: string) => { + console.log('Searching for:', query); + // Make API call here +}, 300); + +// This will only execute once, 300ms after the last call +handleSearch('h'); +handleSearch('he'); +handleSearch('hel'); +handleSearch('hello'); +\`\`\` + +The debounce function delays the execution until there's a pause in calls for the specified wait time (300ms in this case).`, + '2026-01-19T10:00:10.000Z', + 'coder-model', + ), + ], + }, +}; + +export const LongConversation: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'I need to build a REST API. What framework should I use?', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'For building a REST API, there are several excellent options depending on your needs:\n\n1. **Express.js** - Minimal and flexible, great for small to medium projects\n2. **Fastify** - High performance, excellent for high-throughput APIs\n3. **NestJS** - Full-featured framework with TypeScript, great for enterprise applications\n4. **Hono** - Ultra-fast and lightweight, perfect for edge computing\n\nWhat language/runtime are you planning to use?', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + createMessage( + '3', + 'user', + 'I want to use TypeScript with Node.js. The API will handle about 10k requests per second.', + '2026-01-19T10:00:30.000Z', + ), + createMessage( + '4', + 'assistant', + "For high throughput (10k req/s) with TypeScript, I recommend **Fastify**. It's designed for performance and has excellent TypeScript support out of the box.\n\nHere's a quick setup:\n\n```bash\nnpm init -y\nnpm install fastify\nnpm install -D typescript @types/node\n```\n\n```typescript\nimport Fastify from 'fastify';\n\nconst app = Fastify({ logger: true });\n\napp.get('/health', async () => {\n return { status: 'ok' };\n});\n\napp.listen({ port: 3000 }, (err) => {\n if (err) throw err;\n});\n```\n\nFastify benchmarks show it can handle 30k+ req/s on modest hardware.", + '2026-01-19T10:00:35.000Z', + 'coder-model', + ), + createMessage( + '5', + 'user', + 'How do I add request validation?', + '2026-01-19T10:01:00.000Z', + ), + createMessage( + '6', + 'assistant', + "Fastify has built-in JSON Schema validation. Here's how to add it:\n\n```typescript\nconst createUserSchema = {\n body: {\n type: 'object',\n required: ['email', 'name'],\n properties: {\n email: { type: 'string', format: 'email' },\n name: { type: 'string', minLength: 2 },\n age: { type: 'integer', minimum: 0 }\n }\n },\n response: {\n 201: {\n type: 'object',\n properties: {\n id: { type: 'string' },\n email: { type: 'string' },\n name: { type: 'string' }\n }\n }\n }\n};\n\napp.post('/users', { schema: createUserSchema }, async (req, reply) => {\n const { email, name, age } = req.body;\n // Create user...\n reply.code(201).send({ id: '123', email, name });\n});\n```\n\nInvalid requests automatically return 400 with detailed error messages.", + '2026-01-19T10:01:10.000Z', + 'coder-model', + ), + ], + }, +}; + +export const Empty: Story = { + args: { + messages: [], + emptyMessage: 'Start a conversation to see messages here', + }, +}; + +export const CustomEmptyMessage: Story = { + args: { + messages: [], + emptyMessage: 'No chat history available', + }, +}; + +export const SingleUserMessage: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'This is a single user message without any response yet.', + '2026-01-19T10:00:00.000Z', + ), + ], + }, +}; + +export const SingleAssistantMessage: Story = { + args: { + messages: [ + createMessage( + '1', + 'assistant', + 'This is a standalone assistant message, perhaps from a system prompt or welcome message.', + '2026-01-19T10:00:00.000Z', + 'coder-model', + ), + ], + }, +}; + +export const LightTheme: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'Show me how to use the light theme.', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'The ChatViewer supports light, dark, and auto themes. Set `theme="light"` for light mode styling.', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + ], + theme: 'light', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const AutoScrollDisabled: Story = { + args: { + messages: [ + createMessage( + '1', + 'user', + 'This story has auto-scroll disabled.', + '2026-01-19T10:00:00.000Z', + ), + createMessage( + '2', + 'assistant', + 'When `autoScroll={false}`, the viewer will not automatically scroll to the bottom when new messages arrive. This is useful when you want users to manually control the scroll position.', + '2026-01-19T10:00:05.000Z', + 'coder-model', + ), + ], + autoScroll: false, + }, +}; + +export const EmptyWithoutIcon: Story = { + args: { + messages: [], + emptyMessage: 'No messages yet', + showEmptyIcon: false, + }, +}; + +// Interactive story demonstrating ref functionality +const WithRefControlTemplate = () => { + const chatRef = useRef(null); + + const messages: ChatMessageData[] = Array.from({ length: 20 }, (_, i) => + createMessage( + String(i + 1), + i % 2 === 0 ? 'user' : 'assistant', + i % 2 === 0 + ? `Question ${Math.floor(i / 2) + 1}: How does feature ${Math.floor(i / 2) + 1} work?` + : `This is the answer to question ${Math.floor(i / 2) + 1}. The feature works by processing data through multiple stages and returning the result to the caller.`, + new Date(2026, 0, 19, 10, i).toISOString(), + i % 2 === 1 ? 'coder-model' : undefined, + ), + ); + + return ( +
+
+ + +
+
+ +
+
+ ); +}; + +export const WithRefControl: Story = { + render: () => , + parameters: { + docs: { + description: { + story: + 'Demonstrates programmatic scroll control using the `ref` prop. The `ChatViewerHandle` provides `scrollToTop()`, `scrollToBottom()`, and `getScrollContainer()` methods.', + }, + }, + }, +}; diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.tsx new file mode 100644 index 000000000..17cff910d --- /dev/null +++ b/packages/webui/src/components/ChatViewer/ChatViewer.tsx @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from 'react'; +import { UserMessage } from '../messages/UserMessage.js'; +import { AssistantMessage } from '../messages/Assistant/AssistantMessage.js'; +import { ThinkingMessage } from '../messages/ThinkingMessage.js'; +import './ChatViewer.css'; + +/** + * Message part containing text content + */ +export interface MessagePart { + text: string; +} + +/** + * Single chat message from JSONL format + */ +export interface ChatMessageData { + uuid: string; + parentUuid: string | null; + sessionId: string; + timestamp: string; // ISO timestamp string + type: 'user' | 'assistant' | 'system'; + message: { + role: string; + parts: MessagePart[]; + }; + model?: string; // for assistant messages +} + +/** + * ChatViewer ref handle for programmatic control + */ +export interface ChatViewerHandle { + /** Scroll to the bottom of the messages */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + /** Scroll to the top of the messages */ + scrollToTop: (behavior?: ScrollBehavior) => void; + /** Get the scroll container element */ + getScrollContainer: () => HTMLDivElement | null; +} + +/** + * ChatViewer component props + */ +export interface ChatViewerProps { + /** Array of chat messages in JSONL format */ + messages: ChatMessageData[]; + /** Optional additional CSS class name */ + className?: string; + /** Optional callback when a file path is clicked */ + onFileClick?: (path: string) => void; + /** Optional empty state message */ + emptyMessage?: string; + /** Whether to auto-scroll to bottom when new messages arrive (default: true) */ + autoScroll?: boolean; + /** Theme variant: 'dark' | 'light' | 'auto' (default: 'auto') */ + theme?: 'dark' | 'light' | 'auto'; + /** Show empty state icon (default: true) */ + showEmptyIcon?: boolean; +} + +/** + * Extract text content from message parts + */ +function extractContent(parts: MessagePart[]): string { + return parts.map((part) => part.text).join(''); +} + +/** + * Convert ISO timestamp string to numeric timestamp + */ +function parseTimestamp(isoString: string): number { + const date = new Date(isoString); + return isNaN(date.getTime()) ? Date.now() : date.getTime(); +} + +/** + * ChatViewer - A standalone component for displaying chat conversations + * + * Renders a conversation flow from JSONL-formatted data using existing + * message components (UserMessage, AssistantMessage, ThinkingMessage). + * This is a pure UI component without VSCode or external dependencies. + * + * @example + * ```tsx + * const messages = [ + * { uuid: '1', type: 'user', message: { role: 'user', parts: [{ text: 'Hello!' }] }, ... }, + * { uuid: '2', type: 'assistant', message: { role: 'model', parts: [{ text: 'Hi there!' }] }, ... }, + * ]; + * + * console.log(path)} /> + * ``` + * + * @example With ref for programmatic control + * ```tsx + * const chatRef = useRef(null); + * + * // Scroll to bottom programmatically + * chatRef.current?.scrollToBottom('smooth'); + * + * + * ``` + */ +export const ChatViewer = forwardRef( + ( + { + messages, + className = '', + onFileClick, + emptyMessage = 'No messages to display', + autoScroll = true, + theme = 'auto', + showEmptyIcon = true, + }, + ref, + ) => { + const scrollContainerRef = useRef(null); + const scrollAnchorRef = useRef(null); + const prevMessageCountRef = useRef(0); + + // Sort messages by timestamp and filter out system messages + const sortedMessages = useMemo( + () => + messages + .filter((msg) => msg.type !== 'system') + .sort( + (a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp), + ), + [messages], + ); + + // Expose imperative handle for programmatic control + useImperativeHandle( + ref, + () => ({ + scrollToBottom: (behavior: ScrollBehavior = 'smooth') => { + const container = scrollContainerRef.current; + if (container) { + container.scrollTo({ + top: container.scrollHeight, + behavior, + }); + } + }, + scrollToTop: (behavior: ScrollBehavior = 'smooth') => { + const container = scrollContainerRef.current; + if (container) { + container.scrollTo({ + top: 0, + behavior, + }); + } + }, + getScrollContainer: () => scrollContainerRef.current, + }), + [], + ); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (!autoScroll) return; + + const currentCount = sortedMessages.length; + const prevCount = prevMessageCountRef.current; + + // Only auto-scroll when new messages are added + if (currentCount > prevCount && scrollAnchorRef.current) { + scrollAnchorRef.current.scrollIntoView({ behavior: 'smooth' }); + } + + prevMessageCountRef.current = currentCount; + }, [sortedMessages.length, autoScroll]); + + // Render individual message based on type + const renderMessage = (msg: ChatMessageData, index: number) => { + const content = extractContent(msg.message.parts); + const timestamp = parseTimestamp(msg.timestamp); + const key = msg.uuid || `msg-${index}`; + + // Skip empty messages + if (!content.trim()) { + return null; + } + + switch (msg.type) { + case 'user': + return ( + + ); + + case 'assistant': + // Check if this is a thinking message based on role + if (msg.message.role === 'thinking') { + return ( + + ); + } + return ( + + ); + + default: + return null; + } + }; + + // Build container class names + const containerClasses = [ + 'chat-viewer-container', + theme === 'light' ? 'light-theme' : '', + theme === 'auto' ? 'auto-theme' : '', + className, + ] + .filter(Boolean) + .join(' '); + + return ( +
+
+ {sortedMessages.length === 0 ? ( +
+ {showEmptyIcon && ( + + )} +
{emptyMessage}
+
+ ) : ( + <> + {sortedMessages.map((msg, index) => renderMessage(msg, index))} + {/* Scroll anchor for auto-scroll functionality */} +
+ + )} +
+
+ ); + }, +); + +ChatViewer.displayName = 'ChatViewer'; + +export default ChatViewer; diff --git a/packages/webui/src/components/ChatViewer/index.ts b/packages/webui/src/components/ChatViewer/index.ts new file mode 100644 index 000000000..1b56e8562 --- /dev/null +++ b/packages/webui/src/components/ChatViewer/index.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + ChatViewer, + default, + type ChatMessageData, + type ChatViewerHandle, + type ChatViewerProps, + type MessagePart, +} from './ChatViewer.js'; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 0ffa52c8e..315858270 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -79,6 +79,18 @@ export type { AssistantMessageStatus, } from './components/messages/Assistant/AssistantMessage'; +// ChatViewer - standalone chat display component +export { + ChatViewer, + default as ChatViewerDefault, +} from './components/ChatViewer'; +export type { + ChatViewerProps, + ChatViewerHandle, + ChatMessageData, + MessagePart, +} from './components/ChatViewer'; + // UI Elements export { default as Button } from './components/ui/Button'; export { default as Input } from './components/ui/Input'; From cac7138694b5b6e9dbb18ac3bd942cbfd7be70a3 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 20 Jan 2026 10:56:11 +0800 Subject: [PATCH 026/113] feat(webui): add ChatViewer component with stories and styles Co-authored-by: Qwen-Coder --- .../ChatViewer/ChatViewer.stories.tsx | 786 ++++++++++++++++++ .../src/components/ChatViewer/ChatViewer.tsx | 163 +++- .../webui/src/components/ChatViewer/index.ts | 2 + packages/webui/src/index.ts | 2 + 4 files changed, 935 insertions(+), 18 deletions(-) diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx index 136efa855..866380399 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx +++ b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx @@ -10,6 +10,7 @@ import { ChatViewer, type ChatMessageData, type ChatViewerHandle, + type ToolCallData, } from './ChatViewer.js'; /** @@ -306,6 +307,791 @@ export const SingleAssistantMessage: Story = { }, }; +// Real conversation example - Chrome Extension Native Host debugging session +export const RealConversation: Story = { + args: { + messages: [ + { + uuid: '20fb9426-40ca-4e85-92a1-7add59082167', + parentUuid: null, + sessionId: 'f05dd1fc-2947-44e0-9f31-cbddce461844', + timestamp: '2026-01-09T02:58:32.415Z', + type: 'user', + message: { + role: 'user', + content: `Message received: Object from: Object +service-worker.js:137 Attempting to connect to Native Host: com.qwen.cli.bridge +service-worker.js:202 Sending handshake... +service-worker.js:175 Native host disconnected +service-worker.js:177 Disconnect error: Object +service-worker.js:178 Disconnect error message: Native host has exited. +service-worker.js:207 Handshake timeout - no response from Native Host + +接收不到 host 的信息。正常应该是: +service-worker.js:150 [Native Event] host_info Object +service-worker.js:552 [Qwen] host_info Object +service-worker.js:617 [Host] Info Object +service-worker.js:164 [Native Message] handshake_response Object +service-worker.js:231 Handshake successful: Object`, + }, + }, + { + uuid: '86f72c75-4626-4292-a26f-13db6f80d4be', + parentUuid: '20fb9426-40ca-4e85-92a1-7add59082167', + sessionId: 'f05dd1fc-2947-44e0-9f31-cbddce461844', + timestamp: '2026-01-09T02:58:45.239Z', + type: 'assistant', + model: 'claude-opus-4-5-20251101', + message: { + role: 'assistant', + content: `I can see you're experiencing an issue with the Chrome extension's native host connection. The error shows that the native host is disconnecting with "Native host has exited" and there's a handshake timeout. However, the logs also show that sometimes the handshake is successful when the host_info is received properly. + +Let me help you troubleshoot this issue. First, I'll explore the codebase to understand how the native host communication works.`, + }, + }, + { + uuid: '6ffff579-8020-4ccb-9b79-7c1da76bbf82', + parentUuid: '86f72c75-4626-4292-a26f-13db6f80d4be', + sessionId: 'f05dd1fc-2947-44e0-9f31-cbddce461844', + timestamp: '2026-01-09T02:58:52.409Z', + type: 'assistant', + model: 'claude-opus-4-5-20251101', + message: { + role: 'assistant', + content: `Let me create a todo list to track the troubleshooting steps: + +1. **Explore the codebase** to understand the native host implementation +2. **Check the native host configuration** and setup +3. **Analyze the service worker code** that handles native messaging +4. **Identify why the native host exits** unexpectedly +5. **Propose fixes** for the native host connection issue`, + }, + }, + { + uuid: '8fd3dc96-1d34-4dae-845a-4827f435b890', + parentUuid: '6ffff579-8020-4ccb-9b79-7c1da76bbf82', + sessionId: 'f05dd1fc-2947-44e0-9f31-cbddce461844', + timestamp: '2026-01-09T02:58:57.366Z', + type: 'assistant', + model: 'claude-opus-4-5-20251101', + message: { + role: 'assistant', + content: `Let me start by exploring the codebase to understand the native host implementation. + +Looking at the project structure, I found the following relevant files: + +\`\`\` +packages/chrome-extension/ +├── src/ +│ ├── service-worker.ts # Main service worker +│ ├── native-messaging/ +│ │ ├── host.ts # Native host communication +│ │ └── protocol.ts # Message protocol +│ └── manifest.json # Extension manifest +└── native-host/ + ├── index.js # Native host entry + └── com.qwen.cli.bridge.json # Host manifest +\`\`\` + +The issue appears to be in the handshake timing. The native host is exiting before the handshake completes.`, + }, + }, + { + uuid: 'user-follow-up-1', + parentUuid: '8fd3dc96-1d34-4dae-845a-4827f435b890', + sessionId: 'f05dd1fc-2947-44e0-9f31-cbddce461844', + timestamp: '2026-01-09T03:00:15.000Z', + type: 'user', + message: { + role: 'user', + content: '那应该怎么修复这个问题?', + }, + }, + { + uuid: 'assistant-fix-1', + parentUuid: 'user-follow-up-1', + sessionId: 'f05dd1fc-2947-44e0-9f31-cbddce461844', + timestamp: '2026-01-09T03:00:45.000Z', + type: 'assistant', + model: 'claude-opus-4-5-20251101', + message: { + role: 'assistant', + content: `Based on my analysis, here's how to fix the native host connection issue: + +## Root Cause +The native host process is exiting prematurely because it's not keeping the stdin stream open for communication. + +## Solution + +Update the native host's \`index.js\`: + +\`\`\`javascript +// native-host/index.js +const { stdin, stdout } = process; + +// Keep the process alive by not closing stdin +stdin.resume(); + +// Handle incoming messages +stdin.on('data', (data) => { + try { + const length = data.readUInt32LE(0); + const message = JSON.parse(data.slice(4, 4 + length).toString()); + + if (message.type === 'handshake') { + sendMessage({ type: 'handshake_response', status: 'ok' }); + } + // Handle other messages... + } catch (err) { + console.error('Error parsing message:', err); + } +}); + +function sendMessage(msg) { + const json = JSON.stringify(msg); + const length = Buffer.alloc(4); + length.writeUInt32LE(json.length, 0); + stdout.write(Buffer.concat([length, Buffer.from(json)])); +} + +// Send host info on startup +sendMessage({ + type: 'host_info', + pid: process.pid, + node: process.execPath +}); +\`\`\` + +## Key Changes +1. Added \`stdin.resume()\` to prevent the process from exiting +2. Properly handle the native messaging protocol (4-byte length prefix) +3. Send \`host_info\` immediately on startup + +Would you like me to apply these changes to your codebase?`, + }, + }, + ], + }, + parameters: { + docs: { + description: { + story: + 'A real debugging conversation about Chrome extension native host connection issues. Demonstrates multi-turn conversation with code blocks and mixed languages (English and Chinese).', + }, + }, + }, +}; + +// Helper to create tool call data +const createToolCall = ( + id: string, + kind: string, + title: string, + status: 'pending' | 'in_progress' | 'completed' | 'failed', + content?: ToolCallData['content'], + locations?: ToolCallData['locations'], +): ToolCallData => ({ + toolCallId: id, + kind, + title, + status, + content, + locations, +}); + +// Conversation with tool calls - File reading and editing +export const WithToolCalls: Story = { + args: { + messages: [ + { + uuid: 'tc-1', + timestamp: '2026-01-15T14:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [ + { text: '帮我看看 package.json 文件,然后添加一个 lodash 依赖' }, + ], + }, + }, + { + uuid: 'tc-2', + timestamp: '2026-01-15T14:00:05.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: '好的,我先读取 package.json 文件来查看当前的依赖配置。', + }, + }, + { + uuid: 'tc-3', + timestamp: '2026-01-15T14:00:06.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'read-1', + 'read', + 'package.json', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `{ + "name": "my-project", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vite": "^5.0.0" + } +}`, + }, + }, + ], + [{ path: 'package.json', line: 1 }], + ), + }, + { + uuid: 'tc-4', + timestamp: '2026-01-15T14:00:10.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: + '我看到当前项目使用 React 18 和 TypeScript。现在我来添加 lodash 依赖。', + }, + }, + { + uuid: 'tc-5', + timestamp: '2026-01-15T14:00:11.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'edit-1', + 'edit', + 'package.json', + 'completed', + [ + { + type: 'diff', + path: 'package.json', + oldText: ` "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + },`, + newText: ` "dependencies": { + "lodash": "^4.17.21", + "react": "^18.2.0", + "react-dom": "^18.2.0" + },`, + }, + ], + [{ path: 'package.json', line: 4 }], + ), + }, + { + uuid: 'tc-6', + timestamp: '2026-01-15T14:00:15.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: `已经成功添加 lodash 依赖到 package.json。 + +你需要运行以下命令来安装新的依赖: + +\`\`\`bash +npm install +\`\`\` + +或者如果你使用 pnpm: + +\`\`\`bash +pnpm install +\`\`\``, + }, + }, + ], + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates a conversation with Read and Edit tool calls, showing file reading and modification.', + }, + }, + }, +}; + +// Conversation with Shell commands +export const WithShellCommands: Story = { + args: { + messages: [ + { + uuid: 'sh-1', + timestamp: '2026-01-15T15:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: '帮我创建一个新的 React 组件文件夹结构' }], + }, + }, + { + uuid: 'sh-2', + timestamp: '2026-01-15T15:00:05.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: '好的,我来创建一个标准的 React 组件文件夹结构。', + }, + }, + { + uuid: 'sh-3', + timestamp: '2026-01-15T15:00:06.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'shell-1', + 'bash', + 'mkdir -p src/components/Button', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: '', + }, + }, + ], + ), + }, + { + uuid: 'sh-4', + timestamp: '2026-01-15T15:00:08.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'write-1', + 'write', + 'src/components/Button/Button.tsx', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `import React from 'react'; +import './Button.css'; + +interface ButtonProps { + children: React.ReactNode; + onClick?: () => void; + variant?: 'primary' | 'secondary'; +} + +export const Button: React.FC = ({ + children, + onClick, + variant = 'primary', +}) => { + return ( + + ); +};`, + }, + }, + ], + [{ path: 'src/components/Button/Button.tsx' }], + ), + }, + { + uuid: 'sh-5', + timestamp: '2026-01-15T15:00:10.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'write-2', + 'write', + 'src/components/Button/Button.css', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `.button { + padding: 8px 16px; + border-radius: 4px; + border: none; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s; +} + +.button--primary { + background-color: #3b82f6; + color: white; +} + +.button--secondary { + background-color: #e5e7eb; + color: #1f2937; +}`, + }, + }, + ], + [{ path: 'src/components/Button/Button.css' }], + ), + }, + { + uuid: 'sh-6', + timestamp: '2026-01-15T15:00:12.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'write-3', + 'write', + 'src/components/Button/index.ts', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `export { Button } from './Button'; +export type { ButtonProps } from './Button';`, + }, + }, + ], + [{ path: 'src/components/Button/index.ts' }], + ), + }, + { + uuid: 'sh-7', + timestamp: '2026-01-15T15:00:15.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: `已创建完整的 Button 组件结构: + +\`\`\` +src/components/Button/ +├── Button.tsx # 主组件 +├── Button.css # 样式 +└── index.ts # 导出 +\`\`\` + +你可以这样使用: + +\`\`\`tsx +import { Button } from './components/Button'; + + +\`\`\``, + }, + }, + ], + }, + parameters: { + docs: { + description: { + story: + 'Shows a conversation with Shell and Write tool calls, creating a complete component structure.', + }, + }, + }, +}; + +// Conversation with Search and multiple file reads +export const WithSearchAndRead: Story = { + args: { + messages: [ + { + uuid: 'sr-1', + timestamp: '2026-01-15T16:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: '帮我找一下项目中所有使用了 useState 的文件' }], + }, + }, + { + uuid: 'sr-2', + timestamp: '2026-01-15T16:00:05.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: '好的,我来搜索项目中所有使用 useState 的文件。', + }, + }, + { + uuid: 'sr-3', + timestamp: '2026-01-15T16:00:06.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'search-1', + 'grep', + 'useState', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `src/App.tsx:3:import { useState } from 'react'; +src/components/Counter.tsx:1:import { useState } from 'react'; +src/components/Form.tsx:2:import { useState, useEffect } from 'react'; +src/hooks/useLocalStorage.ts:1:import { useState, useEffect } from 'react';`, + }, + }, + ], + [ + { path: 'src/App.tsx', line: 3 }, + { path: 'src/components/Counter.tsx', line: 1 }, + { path: 'src/components/Form.tsx', line: 2 }, + { path: 'src/hooks/useLocalStorage.ts', line: 1 }, + ], + ), + }, + { + uuid: 'sr-4', + timestamp: '2026-01-15T16:00:10.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: `找到了 **4 个文件** 使用了 \`useState\`: + +| 文件 | 行号 | +|------|------| +| \`src/App.tsx\` | 3 | +| \`src/components/Counter.tsx\` | 1 | +| \`src/components/Form.tsx\` | 2 | +| \`src/hooks/useLocalStorage.ts\` | 1 | + +需要我详细查看其中某个文件吗?`, + }, + }, + { + uuid: 'sr-5', + timestamp: '2026-01-15T16:00:30.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: '看看 Counter.tsx' }], + }, + }, + { + uuid: 'sr-6', + timestamp: '2026-01-15T16:00:31.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'read-2', + 'read', + 'src/components/Counter.tsx', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `import { useState } from 'react'; + +export const Counter = () => { + const [count, setCount] = useState(0); + + return ( +
+

Count: {count}

+ + +
+ ); +};`, + }, + }, + ], + [{ path: 'src/components/Counter.tsx' }], + ), + }, + { + uuid: 'sr-7', + timestamp: '2026-01-15T16:00:35.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: `这是一个简单的计数器组件,使用 \`useState\` 管理计数状态。 + +**代码分析:** +- 使用函数式更新 \`c => c + 1\` 来确保状态更新正确 +- 组件结构简洁,只有一个状态变量 + +**潜在改进:** +1. 可以添加 TypeScript 类型注解 +2. 可以提取 min/max 限制 +3. 可以添加 step 参数 + +需要我帮你优化这个组件吗?`, + }, + }, + ], + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates Search (grep) and Read tool calls in a code exploration conversation.', + }, + }, + }, +}; + +// Conversation with Todo/Plan updates +export const WithPlanUpdates: Story = { + args: { + messages: [ + { + uuid: 'plan-1', + timestamp: '2026-01-15T17:00:00.000Z', + type: 'user', + message: { + role: 'user', + parts: [{ text: '帮我重构这个项目的认证模块' }], + }, + }, + { + uuid: 'plan-2', + timestamp: '2026-01-15T17:00:05.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: + '好的,这是一个比较复杂的任务。让我先创建一个计划来跟踪进度。', + }, + }, + { + uuid: 'plan-3', + timestamp: '2026-01-15T17:00:06.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'todo-1', + 'todowrite', + 'Create refactoring plan', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: JSON.stringify([ + { content: '分析现有认证代码结构', status: 'in_progress' }, + { content: '设计新的认证架构', status: 'pending' }, + { content: '实现 JWT token 管理', status: 'pending' }, + { content: '添加 refresh token 机制', status: 'pending' }, + { content: '更新相关测试', status: 'pending' }, + ]), + }, + }, + ], + ), + }, + { + uuid: 'plan-4', + timestamp: '2026-01-15T17:00:10.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: '我已经创建了重构计划。现在让我先分析现有的认证代码。', + }, + }, + { + uuid: 'plan-5', + timestamp: '2026-01-15T17:00:11.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'search-2', + 'glob', + '**/auth/**', + 'completed', + undefined, + [ + { path: 'src/auth/index.ts' }, + { path: 'src/auth/login.ts' }, + { path: 'src/auth/logout.ts' }, + { path: 'src/auth/middleware.ts' }, + { path: 'src/auth/types.ts' }, + ], + ), + }, + { + uuid: 'plan-6', + timestamp: '2026-01-15T17:00:15.000Z', + type: 'tool_call', + toolCall: createToolCall( + 'read-3', + 'read', + 'src/auth/index.ts', + 'completed', + [ + { + type: 'content', + content: { + type: 'text', + text: `// Legacy auth module +export { login } from './login'; +export { logout } from './logout'; +export { authMiddleware } from './middleware'; +export type { AuthUser, AuthToken } from './types';`, + }, + }, + ], + [{ path: 'src/auth/index.ts' }], + ), + }, + { + uuid: 'plan-7', + timestamp: '2026-01-15T17:00:20.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: `分析完成!现有的认证模块结构: + +\`\`\` +src/auth/ +├── index.ts # 导出入口 +├── login.ts # 登录逻辑 +├── logout.ts # 登出逻辑 +├── middleware.ts # 认证中间件 +└── types.ts # 类型定义 +\`\`\` + +**发现的问题:** +1. 没有 token 刷新机制 +2. 缺少认证状态持久化 +3. 错误处理不完善 + +**建议的新架构:** +- 添加 \`tokenManager.ts\` 管理 JWT +- 添加 \`storage.ts\` 处理持久化 +- 添加 \`errors.ts\` 统一错误处理 + +要我继续实现吗?`, + }, + }, + ], + }, + parameters: { + docs: { + description: { + story: + 'Shows a complex refactoring task with Todo/Plan updates, file search, and code analysis.', + }, + }, + }, +}; + export const LightTheme: Story = { args: { messages: [ diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.tsx index 17cff910d..0c47c9a09 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.tsx +++ b/packages/webui/src/components/ChatViewer/ChatViewer.tsx @@ -14,29 +14,64 @@ import { import { UserMessage } from '../messages/UserMessage.js'; import { AssistantMessage } from '../messages/Assistant/AssistantMessage.js'; import { ThinkingMessage } from '../messages/ThinkingMessage.js'; +import { + GenericToolCall, + ThinkToolCall, + EditToolCall, + WriteToolCall, + SearchToolCall, + UpdatedPlanToolCall, + ShellToolCall, + ReadToolCall, + shouldShowToolCall, +} from '../toolcalls/index.js'; +import type { ToolCallData as BaseToolCallData } from '../toolcalls/index.js'; import './ChatViewer.css'; /** - * Message part containing text content + * Message part containing text content (Qwen format) */ export interface MessagePart { text: string; } +/** + * Claude format content item + */ +export interface ClaudeContentItem { + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + name?: string; + input?: unknown; +} + +/** + * Tool call data for rendering tool call UI + */ +export type ToolCallData = BaseToolCallData; + /** * Single chat message from JSONL format + * Supports both Qwen format and Claude format */ export interface ChatMessageData { uuid: string; - parentUuid: string | null; - sessionId: string; + parentUuid?: string | null; + sessionId?: string; timestamp: string; // ISO timestamp string - type: 'user' | 'assistant' | 'system'; - message: { - role: string; - parts: MessagePart[]; + type: 'user' | 'assistant' | 'system' | 'tool_call'; + // Qwen format + message?: { + role?: string; + parts?: MessagePart[]; + content?: string | ClaudeContentItem[]; // Claude format content }; model?: string; // for assistant messages + // Tool call data + toolCall?: ToolCallData; + // Additional Claude format fields + cwd?: string; + gitBranch?: string; } /** @@ -72,10 +107,30 @@ export interface ChatViewerProps { } /** - * Extract text content from message parts + * Extract text content from message (supports both Qwen and Claude formats) */ -function extractContent(parts: MessagePart[]): string { - return parts.map((part) => part.text).join(''); +function extractContent(message: ChatMessageData['message']): string { + if (!message) return ''; + + // Qwen format: message.parts[].text + if (message.parts && Array.isArray(message.parts)) { + return message.parts.map((part) => part.text || '').join(''); + } + + // Claude format: message.content as string + if (typeof message.content === 'string') { + return message.content; + } + + // Claude format: message.content as array of content items + if (Array.isArray(message.content)) { + return message.content + .filter((item) => item.type === 'text' && item.text) + .map((item) => item.text || '') + .join(''); + } + + return ''; } /** @@ -86,6 +141,42 @@ function parseTimestamp(isoString: string): number { return isNaN(date.getTime()) ? Date.now() : date.getTime(); } +/** + * Get the appropriate tool call component based on kind + */ +function getToolCallComponent(kind: string) { + const normalizedKind = kind.toLowerCase(); + + switch (normalizedKind) { + case 'read': + return ReadToolCall; + case 'write': + return WriteToolCall; + case 'edit': + return EditToolCall; + case 'execute': + case 'bash': + case 'command': + return ShellToolCall; + case 'updated_plan': + case 'updatedplan': + case 'todo_write': + case 'update_todos': + case 'todowrite': + return UpdatedPlanToolCall; + case 'search': + case 'grep': + case 'glob': + case 'find': + return SearchToolCall; + case 'think': + case 'thinking': + return ThinkToolCall; + default: + return GenericToolCall; + } +} + /** * ChatViewer - A standalone component for displaying chat conversations * @@ -130,11 +221,18 @@ export const ChatViewer = forwardRef( const scrollAnchorRef = useRef(null); const prevMessageCountRef = useRef(0); - // Sort messages by timestamp and filter out system messages + // Sort messages by timestamp and filter out system messages and hidden tool calls const sortedMessages = useMemo( () => messages - .filter((msg) => msg.type !== 'system') + .filter((msg) => { + if (msg.type === 'system') return false; + // Filter out hidden tool calls + if (msg.type === 'tool_call' && msg.toolCall) { + return shouldShowToolCall(msg.toolCall.kind); + } + return true; + }) .sort( (a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp), ), @@ -183,13 +281,40 @@ export const ChatViewer = forwardRef( prevMessageCountRef.current = currentCount; }, [sortedMessages.length, autoScroll]); + // Determine if a message is a tool call type + const isToolCallType = (msg: ChatMessageData) => + msg.type === 'tool_call' && msg.toolCall; + // Render individual message based on type - const renderMessage = (msg: ChatMessageData, index: number) => { - const content = extractContent(msg.message.parts); - const timestamp = parseTimestamp(msg.timestamp); + const renderMessage = ( + msg: ChatMessageData, + index: number, + allMsgs: ChatMessageData[], + ) => { const key = msg.uuid || `msg-${index}`; - // Skip empty messages + // Handle tool calls + if (msg.type === 'tool_call' && msg.toolCall) { + const ToolCallComponent = getToolCallComponent(msg.toolCall.kind); + const prev = allMsgs[index - 1]; + const next = allMsgs[index + 1]; + const isFirst = !isToolCallType(prev); + const isLast = !isToolCallType(next); + + return ( + + ); + } + + const content = extractContent(msg.message); + const timestamp = parseTimestamp(msg.timestamp); + + // Skip empty messages (but not tool calls) if (!content.trim()) { return null; } @@ -207,7 +332,7 @@ export const ChatViewer = forwardRef( case 'assistant': // Check if this is a thinking message based on role - if (msg.message.role === 'thinking') { + if (msg.message?.role === 'thinking') { return ( (
) : ( <> - {sortedMessages.map((msg, index) => renderMessage(msg, index))} + {sortedMessages.map((msg, index) => + renderMessage(msg, index, sortedMessages), + )} {/* Scroll anchor for auto-scroll functionality */}
Date: Tue, 20 Jan 2026 21:33:21 +0800 Subject: [PATCH 027/113] feat(vscode-ide-companion): enhance UI with timeline positioning and theme variables - Add timeline position calculation (isFirst/isLast) for assistant messages and tool calls - Introduce CSS variables mapping to VSCode theme tokens - Refactor App.tsx to properly calculate message sequence boundaries - Update styling to better integrate with VSCode theming - Remove redundant styles.css and timeline.css files Co-authored-by: Qwen-Coder --- .../vscode-ide-companion/src/webview/App.tsx | 31 +- .../src/webview/index.tsx | 5 +- .../src/webview/styles/App.css | 578 +++--------------- .../src/webview/styles/styles.css | 58 -- .../src/webview/styles/tailwind.css | 202 +----- .../src/webview/styles/timeline.css | 142 ----- 6 files changed, 101 insertions(+), 915 deletions(-) delete mode 100644 packages/vscode-ide-companion/src/webview/styles/styles.css delete mode 100644 packages/vscode-ide-companion/src/webview/styles/timeline.css diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 6a825627f..a83ff9f8a 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -632,10 +632,27 @@ export const App: React.FC = () => { console.log('[App] Rendering messages:', allMessages); + // Helper to check if an item is a user message (breaks AI sequence) + const isUserMessage = (x: unknown): boolean => { + if (!x || typeof x !== 'object') return true; // treat missing as boundary + const item = x as { type: string; data?: { role?: string } }; + if (item.type === 'message') { + const msg = item.data as { role?: string }; + return msg?.role === 'user'; + } + return false; + }; + // Render all messages and tool calls const renderMessages = useCallback<() => React.ReactNode>( () => allMessages.map((item, index) => { + const prev = allMessages[index - 1]; + const next = allMessages[index + 1]; + // Timeline position: first/last in AI response sequence + const isFirst = !prev || isUserMessage(prev); + const isLast = !next || isUserMessage(next); + switch (item.type) { case 'message': { const msg = item.data as TextMessage; @@ -682,6 +699,8 @@ export const App: React.FC = () => { content={content} timestamp={msg.timestamp || 0} onFileClick={handleFileClick} + isFirst={isFirst} + isLast={isLast} /> ); } @@ -689,18 +708,6 @@ export const App: React.FC = () => { case 'in-progress-tool-call': case 'completed-tool-call': { - const prev = allMessages[index - 1]; - const next = allMessages[index + 1]; - const isToolCallType = ( - x: unknown, - ): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } => - !!x && - typeof x === 'object' && - 'type' in (x as Record) && - ((x as { type: string }).type === 'in-progress-tool-call' || - (x as { type: string }).type === 'completed-tool-call'); - const isFirst = !isToolCallType(prev); - const isLast = !isToolCallType(next); return ( * { - /* Disable overflow anchoring on individual items so the UA doesn't auto-adjust scroll */ - overflow-anchor: none; -} - -/* =========================== - Animations (used by message components) - =========================== */ -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } -} - -@keyframes typingPulse { - 0%, - 60%, - 100% { - transform: scale(0.7); - opacity: 0.6; - } - 30% { - transform: scale(1); - opacity: 1; - } -} - -/* =========================== - Input Form Styles - =========================== */ -.input-form { - display: flex; - background-color: var(--app-primary-background); - border-top: 1px solid var(--app-primary-border-color); -} - -.input-field { - flex: 1; - padding: 10px 12px; - background-color: var(--app-input-background); - color: var(--app-input-foreground); - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-small); - font-size: var(--vscode-chat-font-size, 13px); - font-family: var(--vscode-chat-font-family); - outline: none; - line-height: 1.5; -} - -.input-field:focus { - border-color: var(--app-qwen-theme); -} - -.input-field:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.input-field::placeholder { - color: var(--app-input-placeholder-foreground); -} - -.send-button { - padding: 10px 20px; - background-color: var(--app-qwen-clay-button-orange); - color: var(--app-qwen-ivory); - border: none; - border-radius: var(--corner-radius-small); - font-size: var(--vscode-chat-font-size, 13px); - font-weight: 500; - cursor: pointer; - transition: filter 0.15s ease; - display: flex; - align-items: center; - justify-content: center; -} - -.send-button:hover:not(:disabled) { - filter: brightness(1.1); -} - -.send-button:active:not(:disabled) { - filter: brightness(0.9); -} - -.send-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Animation for in-progress status (used by pseudo bullets and spinners) */ -@keyframes pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -.code-block { - font-family: var(--app-monospace-font-family); - font-size: var(--app-monospace-font-size); - background: var(--app-primary-background); - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-small); - padding: var(--app-spacing-medium); - overflow-x: auto; - margin: 4px 0 0 0; - white-space: pre-wrap; - word-break: break-word; - max-height: 300px; - overflow-y: auto; -} - -/* =========================== - Diff Display Styles - =========================== */ -.diff-display-container { - margin: 8px 0; - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-medium); - overflow: hidden; -} - -.diff-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: var(--app-input-secondary-background); - border-bottom: 1px solid var(--app-input-border); -} - -.diff-file-path { - font-family: var(--app-monospace-font-family); - font-size: 13px; - color: var(--app-primary-foreground); -} - -.open-diff-button { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - background: transparent; - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-small); - color: var(--app-primary-foreground); - cursor: pointer; - font-size: 12px; - transition: background-color 0.15s; -} - -.open-diff-button:hover { - background: var(--app-ghost-button-hover-background); -} - -.open-diff-button svg { - width: 16px; - height: 16px; -} - -.diff-section { - margin: 0; -} - -.diff-label { - padding: 8px 12px; - background: var(--app-primary-background); - border-bottom: 1px solid var(--app-input-border); - font-size: 11px; - font-weight: 600; - color: var(--app-secondary-foreground); - text-transform: uppercase; -} - -.diff-section .code-block { - border: none; - border-radius: 0; - margin: 0; - max-height: none; /* Remove height limit for diffs */ - overflow-y: visible; -} - -.diff-section .code-content { - display: block; -} - -/* =========================== - Permission Request Card Styles - =========================== */ -.permission-request-card { - background: var(--app-input-background); - border: 1px solid var(--app-qwen-theme); - border-radius: var(--corner-radius-medium); - margin: var(--app-spacing-medium) 0; - margin-bottom: var(--app-spacing-xlarge); - overflow: visible; - animation: fadeIn 0.2s ease-in; -} - -.permission-card-body { - padding: var(--app-spacing-large); - min-height: fit-content; - height: auto; -} - -.permission-header { - display: flex; - align-items: center; - gap: var(--app-spacing-large); - margin-bottom: var(--app-spacing-large); -} - -.permission-icon-wrapper { - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(97, 95, 255, 0.1); - border-radius: var(--corner-radius-medium); - flex-shrink: 0; -} - -.permission-icon { - font-size: 20px; -} - -.permission-info { - flex: 1; - min-width: 0; -} - -.permission-title { - font-weight: 600; - color: var(--app-primary-foreground); - margin-bottom: 2px; -} - -.permission-subtitle { - font-size: 12px; - color: var(--app-secondary-foreground); -} - -.permission-command-section { - margin-bottom: var(--app-spacing-large); -} - -.permission-command-label { - font-size: 11px; - font-weight: 600; - color: var(--app-secondary-foreground); - margin-bottom: var(--app-spacing-small); - text-transform: uppercase; -} - -.permission-command-code { - display: block; - font-family: var(--app-monospace-font-family); - font-size: var(--app-monospace-font-size); - color: var(--app-primary-foreground); - background: var(--app-primary-background); - padding: var(--app-spacing-medium); - border-radius: var(--corner-radius-small); - overflow-x: auto; - white-space: pre-wrap; - word-break: break-word; -} - -.permission-locations-section { - margin-bottom: var(--app-spacing-large); -} - -.permission-locations-label { - font-size: 11px; - font-weight: 600; - color: var(--app-secondary-foreground); - margin-bottom: var(--app-spacing-small); - text-transform: uppercase; -} - -.permission-location-item { - display: flex; - align-items: center; - gap: var(--app-spacing-small); - padding: var(--app-spacing-small) 0; - font-size: 12px; -} - -.permission-location-icon { - flex-shrink: 0; -} - -.permission-location-path { - color: var(--app-primary-foreground); - font-family: var(--app-monospace-font-family); -} - -.permission-location-line { - color: var(--app-secondary-foreground); -} - -.permission-options-section { - margin-top: var(--app-spacing-large); -} - -.permission-options-label { - font-size: 12px; - font-weight: 500; - color: var(--app-primary-foreground); - margin-bottom: var(--app-spacing-medium); -} - -.permission-options-list { - display: flex; - flex-direction: column; - gap: var(--app-spacing-small); -} - -.permission-option { - display: flex; - align-items: center; - gap: var(--app-spacing-medium); - padding: var(--app-spacing-medium) var(--app-spacing-large); - background: var(--app-primary-background); - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-small); - cursor: pointer; - transition: all 0.15s ease; -} - -.permission-option:hover { - background: var(--app-list-hover-background); - border-color: var(--app-input-active-border); -} - -.permission-option.selected { - border-color: var(--app-qwen-theme); - background: rgba(97, 95, 255, 0.1); -} - -.permission-radio { - flex-shrink: 0; -} - -.permission-option-content { - display: flex; - align-items: center; - gap: var(--app-spacing-small); - flex: 1; -} - -.permission-option-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - font-size: 11px; - font-weight: 600; - color: var(--app-secondary-foreground); - background-color: var(--app-list-hover-background); - border-radius: 4px; - margin-right: 4px; -} - -.permission-option.selected .permission-option-number { - color: var(--app-qwen-ivory); - background-color: var(--app-qwen-theme); -} - -.permission-always-badge { - font-size: 12px; -} - -.permission-no-options { - text-align: center; - padding: var(--app-spacing-large); - color: var(--app-secondary-foreground); -} - -.permission-actions { - margin-top: var(--app-spacing-large); - display: flex; - justify-content: flex-end; -} - -.permission-confirm-button { - padding: var(--app-spacing-medium) var(--app-spacing-xlarge); - background: var(--app-qwen-clay-button-orange); - color: var(--app-qwen-ivory); - border: none; - border-radius: var(--corner-radius-small); - font-size: 13px; - font-weight: 500; - cursor: pointer; - transition: filter 0.15s ease; -} - -.permission-confirm-button:hover:not(:disabled) { - filter: brightness(1.1); -} - -.permission-confirm-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.permission-success { - display: flex; - align-items: center; - justify-content: center; - gap: var(--app-spacing-medium); - padding: var(--app-spacing-large); - background: rgba(76, 175, 80, 0.1); - border-radius: var(--corner-radius-small); - margin-top: var(--app-spacing-large); -} - -.permission-success-icon { - color: #4caf50; - font-weight: bold; -} - -.permission-success-text { - color: #4caf50; - font-size: 13px; } diff --git a/packages/vscode-ide-companion/src/webview/styles/styles.css b/packages/vscode-ide-companion/src/webview/styles/styles.css deleted file mode 100644 index ed99ccded..000000000 --- a/packages/vscode-ide-companion/src/webview/styles/styles.css +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -/* Import component styles */ -@import './timeline.css'; -/* MarkdownRenderer styles are now bundled with @qwen-code/webui */ - -/* =========================== - CSS Variables - =========================== */ -:root { - /* Colors */ - --app-primary-foreground: var(--vscode-foreground); - --app-secondary-foreground: var(--vscode-descriptionForeground); - --app-primary-border-color: var(--vscode-panel-border); - --app-input-placeholder-foreground: var(--vscode-input-placeholderForeground); - - /* Buttons */ - --app-ghost-button-hover-background: var(--vscode-toolbar-hoverBackground); - - /* Border Radius */ - --corner-radius-small: 6px; - - /* Header */ - --app-header-background: var(--vscode-sideBar-background); - - /* List Styles */ - --app-list-padding: 0px; - --app-list-item-padding: 4px 8px; - --app-list-border-color: transparent; - --app-list-border-radius: 4px; - --app-list-hover-background: var(--vscode-list-hoverBackground); - --app-list-active-background: var(--vscode-list-activeSelectionBackground); - --app-list-active-foreground: var(--vscode-list-activeSelectionForeground); - --app-list-gap: 2px; - - /* Menu Styles */ - --app-menu-background: var(--vscode-menu-background); - --app-menu-border: var(--vscode-menu-border); - --app-menu-foreground: var(--vscode-menu-foreground); - --app-menu-selection-background: var(--vscode-menu-selectionBackground); - --app-menu-selection-foreground: var(--vscode-menu-selectionForeground); - - /* Tool Call Styles */ - --app-tool-background: var(--vscode-editor-background); - --app-code-background: var(--vscode-textCodeBlock-background); - - /* Warning/Error Styles */ - --app-warning-background: var( - --vscode-editorWarning-background, - rgba(255, 204, 0, 0.1) - ); - --app-warning-border: var(--vscode-editorWarning-foreground, #ffcc00); - --app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00); -} diff --git a/packages/vscode-ide-companion/src/webview/styles/tailwind.css b/packages/vscode-ide-companion/src/webview/styles/tailwind.css index c307db7d0..ae7be5c58 100644 --- a/packages/vscode-ide-companion/src/webview/styles/tailwind.css +++ b/packages/vscode-ide-companion/src/webview/styles/tailwind.css @@ -2,6 +2,11 @@ * @license * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 + * + * VSCode IDE Companion - Tailwind CSS + * + * Only Tailwind directives and minimal VSCode-specific utilities. + * Component styles are in @qwen-code/webui package. */ @tailwind base; @@ -9,202 +14,7 @@ @tailwind utilities; /* =========================== - Reusable Component Classes - =========================== */ -@layer components { - .btn-ghost { - @apply bg-transparent border border-transparent rounded cursor-pointer outline-none transition-colors duration-200; - color: var(--app-primary-foreground); - font-size: var(--vscode-chat-font-size, 13px); - border-radius: 4px; - } - - .btn-ghost:hover, - .btn-ghost:focus { - background: var(--app-ghost-button-hover-background); - } - - .btn-sm { - @apply p-small; - } - - .btn-md { - @apply py-small px-medium; - } - - .icon-sm { - @apply w-4 h-4; - } - - /* Composer: root container anchored to bottom*/ - .composer-root { - @apply absolute bottom-4 left-4 right-4 flex flex-col z-20; - } - - /* Composer: form wrapper */ - .composer-form { - @apply relative flex flex-col max-w-[680px] mx-auto rounded-large border shadow-sm transition-colors duration-200 z-[1]; - background: var(--app-input-secondary-background); - border-color: var(--app-input-border); - color: var(--app-input-foreground); - } - .composer-form:focus-within { - /* match existing highlight behavior */ - border-color: var(--app-input-highlight); - box-shadow: 0 1px 2px - color-mix(in srgb, var(--app-input-highlight), transparent 80%); - } - - /* Composer: input editable area */ - .composer-input { - /* Use plain CSS for font-family inheritance; Tailwind has no `font-inherit` utility */ - @apply flex-1 self-stretch py-2.5 px-3.5 outline-none overflow-y-auto relative select-text min-h-[1.5em] max-h-[200px] bg-transparent border-0 rounded-none overflow-x-hidden break-words whitespace-pre-wrap; - font-family: inherit; - font-size: var(--vscode-chat-font-size, 13px); - color: var(--app-input-foreground); - } - /* Show placeholder when truly empty OR when flagged as empty via data attribute. - The data attribute is needed because some browsers insert a
in - contentEditable, which breaks :empty matching. */ - .composer-input:empty:before, - .composer-input[data-empty='true']::before { - content: attr(data-placeholder); - color: var(--app-input-placeholder-foreground); - pointer-events: none; - position: absolute; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: calc(100% - 28px); - } - .composer-input:focus { - outline: none; - } - .composer-input:disabled, - .composer-input[contenteditable='false'] { - color: #999; - cursor: not-allowed; - } - - /* Composer: actions row (more compact) */ - .composer-actions { - @apply flex items-center gap-1 min-w-0 z-[1]; - padding: 5px; - color: var(--app-secondary-foreground); - border-top: 0.5px solid var(--app-input-border); - } - - /* Text button (icon + label) */ - .btn-text-compact { - @apply inline-flex items-center gap-1 px-1 py-0.5 rounded-[2px] cursor-pointer appearance-none bg-transparent border-0 min-w-0 shrink text-[0.85em] transition-colors duration-150; - color: var(--app-secondary-foreground); - } - .btn-text-compact--primary { - color: var(--app-secondary-foreground); - /* color: var(--app-primary-foreground); */ - } - .btn-text-compact:hover { - background-color: var(--app-ghost-button-hover-background); - } - .btn-text-compact:active:not(:disabled) { - filter: brightness(1.1); - } - .btn-text-compact > svg { - height: 1em; - width: 1em; - flex-shrink: 0; - } - .btn-text-compact > span { - display: inline-block; - min-width: 0; - max-width: 200px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - vertical-align: middle; - } - - @media screen and (max-width: 300px) { - .btn-text-compact > svg { - display: none; - } - } - - /* Icon-only button, compact square (26x26) */ - .btn-icon-compact { - @apply inline-flex items-center justify-center w-[26px] h-[26px] p-0 rounded-small bg-transparent border border-transparent cursor-pointer shrink-0 transition-all duration-150; - color: var(--app-secondary-foreground); - } - .btn-icon-compact:hover { - background-color: var(--app-ghost-button-hover-background); - } - .btn-icon-compact > svg { - @apply w-4 h-4; - } - /* Active/primary state for icon button (e.g., Thinking on) */ - .btn-icon-compact--active { - background-color: var(--app-qwen-clay-button-orange); - color: var(--app-qwen-ivory); - } - .btn-icon-compact--active > svg { - stroke: var(--app-qwen-ivory); - fill: var(--app-qwen-ivory); - } - - .context-indicator { - @apply inline-flex items-center gap-1 px-1 py-0.5 rounded-small text-[0.8em] select-none; - color: var(--app-secondary-foreground); - } - .context-indicator svg { - width: 20px; - height: 20px; - } - .context-indicator__track, - .context-indicator__progress { - fill: none; - stroke-width: 2.5; - } - .context-indicator__track { - stroke: var(--app-secondary-foreground); - opacity: 0.35; - } - .context-indicator__progress { - stroke: var(--app-secondary-foreground); - stroke-linecap: round; - } - - .composer-overlay { - @apply absolute inset-0 rounded-large z-0; - background: var(--app-input-background); - } - - /* Optional: send button variant */ - .btn-send-compact { - @apply btn-icon-compact ml-auto hover:brightness-110 disabled:opacity-40 disabled:cursor-not-allowed; - background-color: var(--app-qwen-clay-button-orange); - color: var(--app-qwen-ivory); - } - - /* - * File path styling inside tool call content - * Applies to: .toolcall-content-wrapper .file-link-path - * - Use monospace editor font - * - Slightly smaller size - * - Link color - * - Tighten top alignment and allow aggressive breaking for long paths - */ - .toolcall-content-wrapper .file-link-path { - /* Tailwind utilities where possible */ - @apply text-[0.85em] pt-px break-all min-w-0; - /* Not covered by Tailwind defaults: use CSS vars / properties */ - font-family: var(--app-monospace-font-family); - color: var(--app-link-color); - overflow-wrap: anywhere; - } -} - -/* =========================== - Utilities + VSCode-specific Utilities =========================== */ @layer utilities { /* Multi-line clamp with ellipsis (Chromium-based webview supported) */ diff --git a/packages/vscode-ide-companion/src/webview/styles/timeline.css b/packages/vscode-ide-companion/src/webview/styles/timeline.css deleted file mode 100644 index 033e82d22..000000000 --- a/packages/vscode-ide-companion/src/webview/styles/timeline.css +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - * - * Unified timeline styles for tool calls and messages - */ - -/* ========================================== - ToolCallContainer timeline styles - ========================================== */ -.toolcall-container { - position: relative; - padding-left: 30px; - padding-top: 8px; - padding-bottom: 8px; -} - -/* ToolCallContainer timeline connector */ -.toolcall-container::after { - content: ''; - position: absolute; - left: 12px; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--app-primary-border-color); -} - -/* First item: connector starts from status point position */ -.toolcall-container:first-child::after { - top: 24px; -} - -/* Last item: connector shows only upper part */ -.toolcall-container:last-child::after { - height: calc(100% - 24px); - top: 0; - bottom: auto; -} - -/* ========================================== - AssistantMessage timeline styles - ========================================== */ -.assistant-message-container { - position: relative; - padding-left: 30px; - padding-top: 8px; - padding-bottom: 8px; -} - -/* AssistantMessage timeline connector */ -.assistant-message-container::after { - content: ''; - position: absolute; - left: 12px; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--app-primary-border-color); -} - -/* First item: connector starts from status point position */ -.assistant-message-container:first-child::after { - top: 24px; -} - -/* Last item: connector shows only upper part */ -.assistant-message-container:last-child::after { - height: calc(100% - 24px); - top: 0; - bottom: auto; -} - -/* ========================================== - Custom timeline styles for qwen-message message-item elements - ========================================== */ - -/* Default connector style - creates full-height connectors for all AI message items */ -.qwen-message.message-item:not(.user-message-container)::after { - content: ''; - position: absolute; - left: 12px; - top: 0; - bottom: 0; - width: 1px; - background-color: var(--app-primary-border-color); - z-index: 0; -} - -/* Single-item AI sequence (both a start and an end): hide the connector entirely */ -.qwen-message.message-item:not(.user-message-container):is( - :first-child, - .user-message-container - + .qwen-message.message-item:not(.user-message-container), - .chat-messages - > :not(.qwen-message.message-item) - + .qwen-message.message-item:not(.user-message-container) - ):is( - :has(+ .user-message-container), - :has(+ :not(.qwen-message.message-item)), - :last-child - )::after { - display: none; -} - -/* Handle the start of each AI message sequence - includes the first AI message in the entire message list and new AI messages interrupted by user messages */ -.qwen-message.message-item:not(.user-message-container):first-child::after, -.user-message-container + .qwen-message.message-item:not(.user-message-container)::after, -/* If the previous sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, or card-style tool calls), also treat as a new group start */ -.chat-messages > :not(.qwen-message.message-item) - + .qwen-message.message-item:not(.user-message-container)::after { - top: 15px; -} - -/* Handle the end of each AI message sequence */ -/* When the next sibling is a user message */ -.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after, -/* Or when the next sibling is not .qwen-message.message-item (such as waiting prompts, sentinel elements, card-style tool calls, etc.) */ -.qwen-message.message-item:not(.user-message-container):has(+ :not(.qwen-message.message-item))::after, -/* When it's truly the last child element of the parent container */ -.qwen-message.message-item:not(.user-message-container):last-child::after { - /* Note: When setting both top and bottom, the height is (container height - top - bottom). - * Here we expect "15px spacing at the bottom", so bottom should be 15px (not calc(100% - 15px)). */ - top: 0; - bottom: calc(100% - 15px); -} - -.user-message-container:first-child { - margin-top: 0; -} - -.message-item { - padding: 8px 0; - width: 100%; - align-items: flex-start; - padding-left: 30px; - user-select: text; - position: relative; - padding-top: 8px; - padding-bottom: 8px; -} From 1861557d15e579b4e062f070e3ed09c5ac1c7c5c Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 20 Jan 2026 21:33:35 +0800 Subject: [PATCH 028/113] feat(webui): add data adapter layer for ACP and JSONL protocols - Implement ACPAdapter to convert ACP protocol messages to unified format - Implement JSONLAdapter to convert JSONL format messages to unified format - Define unified message types for consistent component rendering - Add helper functions for timeline position calculation (isFirst/isLast) - Enable cross-platform message format compatibility Co-authored-by: Qwen-Coder --- packages/webui/src/adapters/ACPAdapter.ts | 109 +++++++++++++++++ packages/webui/src/adapters/JSONLAdapter.ts | 126 ++++++++++++++++++++ packages/webui/src/adapters/index.ts | 28 +++++ packages/webui/src/adapters/types.ts | 82 +++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 packages/webui/src/adapters/ACPAdapter.ts create mode 100644 packages/webui/src/adapters/JSONLAdapter.ts create mode 100644 packages/webui/src/adapters/index.ts create mode 100644 packages/webui/src/adapters/types.ts diff --git a/packages/webui/src/adapters/ACPAdapter.ts b/packages/webui/src/adapters/ACPAdapter.ts new file mode 100644 index 000000000..eaf699e1b --- /dev/null +++ b/packages/webui/src/adapters/ACPAdapter.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Adapter for ACP protocol messages (used by vscode-ide-companion) + */ + +import type { + UnifiedMessage, + ACPMessage, + ACPMessageData, + ToolCallData, +} from './types.js'; + +/** + * Check if a message is a user message (breaks AI sequence) + */ +function isUserMessage(msg: ACPMessage | undefined): boolean { + if (!msg) return true; + if (msg.type !== 'message') return false; + const data = msg.data as ACPMessageData; + return data?.role === 'user'; +} + +/** + * Adapt ACP messages to unified format + * + * @param messages - Array of ACP messages from vscode-ide-companion + * @returns Array of unified messages with timeline positions calculated + */ +export function adaptACPMessages(messages: ACPMessage[]): UnifiedMessage[] { + return messages.map((item, index, arr) => { + const prev = arr[index - 1]; + const next = arr[index + 1]; + + // Calculate timeline position + const isFirst = isUserMessage(prev); + const isLast = isUserMessage(next); + + switch (item.type) { + case 'message': { + const msg = item.data as ACPMessageData; + return { + id: `msg-${index}`, + type: + msg.role === 'user' + ? 'user' + : msg.role === 'thinking' + ? 'thinking' + : 'assistant', + timestamp: msg.timestamp || Date.now(), + content: msg.content, + fileContext: msg.fileContext, + isFirst, + isLast, + }; + } + + case 'in-progress-tool-call': + case 'completed-tool-call': { + const toolCall = item.data as ToolCallData; + return { + id: `tool-${toolCall.toolCallId}-${item.type}`, + type: 'tool_call', + timestamp: Date.now(), + toolCall, + isFirst, + isLast, + }; + } + + default: + // Fallback for unknown types + return { + id: `unknown-${index}`, + type: 'assistant', + timestamp: Date.now(), + content: '', + isFirst, + isLast, + }; + } + }); +} + +/** + * Type guard to check if data is a tool call + */ +export function isToolCallData(data: unknown): data is ToolCallData { + return ( + typeof data === 'object' && + data !== null && + 'toolCallId' in data && + 'kind' in data + ); +} + +/** + * Type guard to check if data is a message + */ +export function isMessageData(data: unknown): data is ACPMessageData { + return ( + typeof data === 'object' && + data !== null && + 'role' in data && + 'content' in data + ); +} diff --git a/packages/webui/src/adapters/JSONLAdapter.ts b/packages/webui/src/adapters/JSONLAdapter.ts new file mode 100644 index 000000000..51b2c9333 --- /dev/null +++ b/packages/webui/src/adapters/JSONLAdapter.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Adapter for JSONL format messages (used by ChatViewer) + */ + +import type { + UnifiedMessage, + JSONLMessage, + UnifiedMessageType, +} from './types.js'; + +/** + * Extract text content from different message formats + */ +function extractContent(message?: { + parts?: Array<{ text: string }>; + content?: string | unknown[]; +}): string { + if (!message) return ''; + + // Qwen format: parts array + if (message.parts?.length) { + return message.parts.map((p) => p.text).join(''); + } + + // Claude format: string content + if (typeof message.content === 'string') { + return message.content; + } + + // Claude format: content array + if (Array.isArray(message.content)) { + return message.content + .filter( + (item): item is { type: 'text'; text: string } => + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'text', + ) + .map((item) => item.text) + .join(''); + } + + return ''; +} + +/** + * Parse timestamp string to milliseconds + */ +function parseTimestamp(timestamp: string): number { + const parsed = Date.parse(timestamp); + return isNaN(parsed) ? Date.now() : parsed; +} + +/** + * Determine the unified message type from JSONL message + */ +function getMessageType(msg: JSONLMessage): UnifiedMessageType { + if (msg.type === 'tool_call') { + return 'tool_call'; + } + if (msg.type === 'user') { + return 'user'; + } + if (msg.message?.role === 'thinking') { + return 'thinking'; + } + return 'assistant'; +} + +/** + * Check if a message is a user type (breaks AI sequence) + */ +function isUserType(msg: JSONLMessage | undefined): boolean { + return !msg || msg.type === 'user'; +} + +/** + * Adapt JSONL messages to unified format + * + * @param messages - Array of JSONL messages + * @returns Array of unified messages with timeline positions calculated + */ +export function adaptJSONLMessages(messages: JSONLMessage[]): UnifiedMessage[] { + // Sort by timestamp + const sorted = [...messages].sort( + (a, b) => parseTimestamp(a.timestamp) - parseTimestamp(b.timestamp), + ); + + return sorted.map((msg, index, arr) => { + const prev = arr[index - 1]; + const next = arr[index + 1]; + + // Calculate timeline position + const isFirst = isUserType(prev); + const isLast = isUserType(next); + + const type = getMessageType(msg); + + return { + id: msg.uuid, + type, + timestamp: parseTimestamp(msg.timestamp), + content: type !== 'tool_call' ? extractContent(msg.message) : undefined, + toolCall: msg.toolCall, + isFirst, + isLast, + }; + }); +} + +/** + * Filter out empty messages (except tool calls) + */ +export function filterEmptyMessages( + messages: UnifiedMessage[], +): UnifiedMessage[] { + return messages.filter((msg) => { + if (msg.type === 'tool_call') return true; + return msg.content && msg.content.trim().length > 0; + }); +} diff --git a/packages/webui/src/adapters/index.ts b/packages/webui/src/adapters/index.ts new file mode 100644 index 000000000..acacb3e65 --- /dev/null +++ b/packages/webui/src/adapters/index.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Adapter layer for normalizing different data formats to unified message format + */ + +// Type exports +export type { + UnifiedMessage, + UnifiedMessageType, + JSONLMessage, + ACPMessage, + ACPMessageData, + ToolCallData, + FileContext, +} from './types.js'; + +// JSONL Adapter (for ChatViewer) +export { adaptJSONLMessages, filterEmptyMessages } from './JSONLAdapter.js'; + +// ACP Adapter (for vscode-ide-companion) +export { + adaptACPMessages, + isToolCallData, + isMessageData, +} from './ACPAdapter.js'; diff --git a/packages/webui/src/adapters/types.ts b/packages/webui/src/adapters/types.ts new file mode 100644 index 000000000..70c936c22 --- /dev/null +++ b/packages/webui/src/adapters/types.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Unified message types for adapter layer + */ + +import type { ToolCallData } from '../components/toolcalls/shared/types.js'; +import type { FileContext } from '../components/messages/UserMessage.js'; + +/** + * Unified message type used by all webui components + */ +export type UnifiedMessageType = + | 'user' + | 'assistant' + | 'tool_call' + | 'thinking'; + +/** + * Unified message format - normalized from ACP or JSONL sources + */ +export interface UnifiedMessage { + /** Unique identifier */ + id: string; + /** Message type */ + type: UnifiedMessageType; + /** Timestamp in milliseconds */ + timestamp: number; + /** Text content (for user/assistant/thinking messages) */ + content?: string; + /** Tool call data (for tool_call type) */ + toolCall?: ToolCallData; + /** Whether this is the first item in an AI response sequence */ + isFirst: boolean; + /** Whether this is the last item in an AI response sequence */ + isLast: boolean; + /** File context for user messages */ + fileContext?: FileContext[]; +} + +// Re-export FileContext for convenience +export type { FileContext }; + +/** + * JSONL chat message format (ChatViewer input) + */ +export interface JSONLMessage { + uuid: string; + parentUuid?: string | null; + sessionId?: string; + timestamp: string; // ISO timestamp string + type: 'user' | 'assistant' | 'system' | 'tool_call'; + message?: { + role?: string; + parts?: Array<{ text: string }>; // Qwen format + content?: string | unknown[]; // Claude format + }; + model?: string; + toolCall?: ToolCallData; +} + +/** + * ACP message format (vscode-ide-companion input) + */ +export interface ACPMessage { + type: 'message' | 'in-progress-tool-call' | 'completed-tool-call'; + data: ACPMessageData | ToolCallData; +} + +/** + * ACP text message data + */ +export interface ACPMessageData { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp?: number; + fileContext?: FileContext[]; +} + +export type { ToolCallData }; From df787fff64f215fc32852c93c8bc6015d5d914f0 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 20 Jan 2026 21:33:49 +0800 Subject: [PATCH 029/113] feat(webui): enhance ChatViewer and AssistantMessage with timeline positioning - Update ChatViewer to calculate and pass timeline positions (isFirst/isLast) to messages - Enhance AssistantMessage with timeline connector visualization - Add storybook stories for AssistantMessage component - Update SearchToolCall and LayoutComponents for improved UI consistency - Export new adapters in main index file - Add timeline styling to components Co-authored-by: Qwen-Coder --- .../src/components/ChatViewer/ChatViewer.css | 33 +- .../ChatViewer/ChatViewer.stories.tsx | 415 +++++++++++++++++- .../src/components/ChatViewer/ChatViewer.tsx | 36 +- .../messages/Assistant/AssistantMessage.css | 73 ++- .../Assistant/AssistantMessage.stories.tsx | 207 +++++++++ .../messages/Assistant/AssistantMessage.tsx | 8 + .../components/toolcalls/SearchToolCall.tsx | 185 +++----- .../toolcalls/shared/LayoutComponents.css | 114 ++--- .../toolcalls/shared/LayoutComponents.tsx | 12 +- packages/webui/src/index.ts | 16 + packages/webui/src/styles/components.css | 373 +++++++++++++++- packages/webui/src/styles/timeline.css | 143 +----- 12 files changed, 1287 insertions(+), 328 deletions(-) create mode 100644 packages/webui/src/components/messages/Assistant/AssistantMessage.stories.tsx diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.css b/packages/webui/src/components/ChatViewer/ChatViewer.css index 5cc71011a..c3a1a38bf 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.css +++ b/packages/webui/src/components/ChatViewer/ChatViewer.css @@ -89,7 +89,8 @@ } .chat-viewer-messages > *:not(:last-child) { - padding-bottom: 8px; + padding-bottom: 0; + padding-top: 0; } /* Disable overflow anchoring on individual items for manual scroll control */ @@ -102,6 +103,15 @@ margin-top: 0; } +/* =========================== + Timeline Wrapper Styles + =========================== */ + +/* Tool call wrapper - transparent container for timeline continuity */ +.chat-viewer-toolcall-wrapper { + position: relative; +} + /* =========================== Animations =========================== */ @@ -150,3 +160,24 @@ height: 1px; overflow-anchor: auto; } + +/* =========================== + ChatViewer-specific Styles + =========================== */ + +/* Better spacing between message groups */ +.chat-viewer-messages .user-message-container { + margin-top: 16px; +} + +/* Ensure proper stacking context */ +.chat-viewer-messages > * { + isolation: isolate; +} + +/* Responsive adjustments for ChatViewer */ +@media (max-width: 600px) { + .chat-viewer-messages { + padding: 12px; + } +} diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx index 866380399..4673003f9 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx +++ b/packages/webui/src/components/ChatViewer/ChatViewer.stories.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useRef } from 'react'; +import { useRef, useState, useCallback, useEffect } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { ChatViewer, @@ -1212,3 +1212,416 @@ export const WithRefControl: Story = { }, }, }; + +// Comprehensive sample data for playground with all tool types +const PLAYGROUND_SAMPLE = `[ + { + "uuid": "1", + "timestamp": "2026-01-15T14:00:00.000Z", + "type": "user", + "message": { + "role": "user", + "parts": [{ "text": "帮我创建一个 React 组件,并添加到项目中" }] + } + }, + { + "uuid": "2", + "timestamp": "2026-01-15T14:00:05.000Z", + "type": "assistant", + "message": { + "role": "assistant", + "content": "好的,我来帮你创建一个 React 组件。首先让我搜索一下项目结构。" + } + }, + { + "uuid": "3", + "timestamp": "2026-01-15T14:00:06.000Z", + "type": "tool_call", + "toolCall": { + "toolCallId": "search-1", + "kind": "grep", + "title": "Searching for component patterns", + "status": "completed", + "rawInput": "export.*Component", + "content": [{ + "type": "content", + "content": { + "type": "text", + "text": "src/components/Button.tsx:export const Button: FC = () => {\\nsrc/components/Card.tsx:export const Card: FC = () => {" + } + }], + "locations": [ + { "path": "src/components/Button.tsx", "line": 5 }, + { "path": "src/components/Card.tsx", "line": 8 } + ] + } + }, + { + "uuid": "4", + "timestamp": "2026-01-15T14:00:08.000Z", + "type": "tool_call", + "toolCall": { + "toolCallId": "read-1", + "kind": "read", + "title": "src/components/Button.tsx", + "status": "completed", + "content": [{ + "type": "content", + "content": { + "type": "text", + "text": "import type { FC } from 'react';\\n\\nexport interface ButtonProps {\\n label: string;\\n onClick?: () => void;\\n}\\n\\nexport const Button: FC = ({ label, onClick }) => (\\n \\n);" + } + }], + "locations": [{ "path": "src/components/Button.tsx" }] + } + }, + { + "uuid": "5", + "timestamp": "2026-01-15T14:00:10.000Z", + "type": "assistant", + "message": { + "role": "assistant", + "content": "我找到了项目的组件结构。现在我来创建新的组件文件。" + } + }, + { + "uuid": "6", + "timestamp": "2026-01-15T14:00:12.000Z", + "type": "tool_call", + "toolCall": { + "toolCallId": "write-1", + "kind": "write", + "title": "Creating src/components/Modal.tsx", + "status": "completed", + "content": [{ + "type": "diff", + "path": "src/components/Modal.tsx", + "oldText": null, + "newText": "import type { FC, ReactNode } from 'react';\\nimport './Modal.css';\\n\\nexport interface ModalProps {\\n isOpen: boolean;\\n onClose: () => void;\\n title: string;\\n children: ReactNode;\\n}\\n\\nexport const Modal: FC = ({\\n isOpen,\\n onClose,\\n title,\\n children,\\n}) => {\\n if (!isOpen) return null;\\n\\n return (\\n
\\n
\\n
\\n

{title}

\\n \\n
\\n
\\n {children}\\n
\\n
\\n
\\n );\\n};" + }], + "locations": [{ "path": "src/components/Modal.tsx" }] + } + }, + { + "uuid": "7", + "timestamp": "2026-01-15T14:00:15.000Z", + "type": "tool_call", + "toolCall": { + "toolCallId": "edit-1", + "kind": "edit", + "title": "Updating src/components/index.ts", + "status": "completed", + "content": [{ + "type": "diff", + "path": "src/components/index.ts", + "oldText": "export { Button } from './Button';\\nexport { Card } from './Card';", + "newText": "export { Button } from './Button';\\nexport { Card } from './Card';\\nexport { Modal } from './Modal';" + }], + "locations": [{ "path": "src/components/index.ts", "line": 3 }] + } + }, + { + "uuid": "8", + "timestamp": "2026-01-15T14:00:18.000Z", + "type": "assistant", + "message": { + "role": "assistant", + "content": "组件已创建。让我运行测试确保没有问题。" + } + }, + { + "uuid": "9", + "timestamp": "2026-01-15T14:00:20.000Z", + "type": "tool_call", + "toolCall": { + "toolCallId": "bash-1", + "kind": "bash", + "title": "Running tests", + "status": "completed", + "rawInput": "npm run test -- --coverage", + "content": [{ + "type": "content", + "content": { + "type": "text", + "text": "PASS src/components/Modal.test.tsx\\n Modal Component\\n ✓ renders when isOpen is true (15ms)\\n ✓ does not render when isOpen is false (3ms)\\n ✓ calls onClose when close button clicked (8ms)\\n\\nTest Suites: 1 passed, 1 total\\nTests: 3 passed, 3 total\\nCoverage: 92.5%" + } + }] + } + }, + { + "uuid": "10", + "timestamp": "2026-01-15T14:00:25.000Z", + "type": "tool_call", + "toolCall": { + "toolCallId": "plan-1", + "kind": "todowrite", + "title": "Updating task progress", + "status": "completed", + "content": [{ + "type": "content", + "content": { + "type": "plan", + "entries": [ + { "content": "Search project structure", "status": "completed" }, + { "content": "Create Modal component", "status": "completed" }, + { "content": "Update exports", "status": "completed" }, + { "content": "Run tests", "status": "completed" }, + { "content": "Add documentation", "status": "pending" } + ] + } + }] + } + }, + { + "uuid": "11", + "timestamp": "2026-01-15T14:00:30.000Z", + "type": "assistant", + "message": { + "role": "assistant", + "content": "Modal 组件已成功创建并通过所有测试!\\n\\n**创建的文件:**\\n- \`src/components/Modal.tsx\` - 主组件文件\\n- \`src/components/Modal.css\` - 样式文件\\n\\n**功能特性:**\\n- 支持打开/关闭状态控制\\n- 可自定义标题和内容\\n- 点击关闭按钮触发回调\\n\\n还需要我添加文档吗?" + } + } +]`; + +// Playground component for testing JSON input with auto-render +const PlaygroundTemplate = () => { + const [jsonInput, setJsonInput] = useState(PLAYGROUND_SAMPLE); + const [messages, setMessages] = useState([]); + const [error, setError] = useState(null); + const [autoRender, setAutoRender] = useState(true); + const debounceRef = useRef | null>(null); + + const parseAndRender = useCallback((input: string) => { + try { + const parsed = JSON.parse(input); + if (!Array.isArray(parsed)) { + throw new Error('JSON must be an array of messages'); + } + setMessages(parsed); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Invalid JSON'); + setMessages([]); + } + }, []); + + // Auto-render with debounce when JSON input changes + useEffect(() => { + if (!autoRender) return; + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + parseAndRender(jsonInput); + }, 300); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [jsonInput, autoRender, parseAndRender]); + + // Parse on initial load + useEffect(() => { + parseAndRender(jsonInput); + }, [parseAndRender, jsonInput]); + + return ( +
+ {/* Left Panel - JSON Input */} +
+
+

+ JSON Input (Messages Array) +

+
+ + {!autoRender && ( + + )} +
+
+