fix(vscode-ide-companion): improve message logging and permission handling

- Increase message logging truncation limit from 500 to 1500 characters
- Fix permission option mapping logic for reject_once/cancel options
- Add TODO comments for diff accept/cancel responses during permission requests

Resolves issues with permission handling and improves debugging capabilities.
This commit is contained in:
yiliang114
2025-12-05 02:15:48 +08:00
parent 4145f45c7c
commit 2d844d11df
40 changed files with 933 additions and 529 deletions

View File

@@ -50,11 +50,9 @@ export class AcpConnection {
private nextRequestId = { value: 0 }; private nextRequestId = { value: 0 };
private backend: AcpBackend | null = null; private backend: AcpBackend | null = null;
// Module instances
private messageHandler: AcpMessageHandler; private messageHandler: AcpMessageHandler;
private sessionManager: AcpSessionManager; private sessionManager: AcpSessionManager;
// Callback functions
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {}; onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{ onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string; optionId: string;
@@ -206,7 +204,7 @@ export class AcpConnection {
const message = JSON.parse(line) as AcpMessage; const message = JSON.parse(line) as AcpMessage;
console.log( console.log(
'[ACP] <<< Received message:', '[ACP] <<< Received message:',
JSON.stringify(message).substring(0, 500), JSON.stringify(message).substring(0, 500 * 3),
); );
this.handleMessage(message); this.handleMessage(message);
} catch (_error) { } catch (_error) {

View File

@@ -217,7 +217,8 @@ export class AcpMessageHandler {
return { return {
outcome: { outcome: {
outcome, outcome,
optionId: optionId === 'cancel' ? 'reject_once' : optionId, // optionId: optionId === 'cancel' ? 'reject_once' : optionId,
optionId: optionId === 'reject_once' ? 'cancel' : optionId,
}, },
}; };
} catch (_error) { } catch (_error) {
@@ -230,3 +231,21 @@ export class AcpMessageHandler {
} }
} }
} }
// [
// {
// received: 'reject_once',
// code: 'invalid_enum_value',
// options: [
// 'proceed_once',
// 'proceed_always',
// 'proceed_always_server',
// 'proceed_always_tool',
// 'modify_with_editor',
// 'cancel',
// ],
// path: [],
// message:
// "Invalid enum value. Expected 'proceed_once' | 'proceed_always' | 'proceed_always_server' | 'proceed_always_tool' | 'modify_with_editor' | 'cancel', received 'reject_once'",
// },
// ];

View File

@@ -181,12 +181,16 @@ export async function activate(context: vscode.ExtensionContext) {
if (docUri && docUri.scheme === DIFF_SCHEME) { if (docUri && docUri.scheme === DIFF_SCHEME) {
diffManager.acceptDiff(docUri); diffManager.acceptDiff(docUri);
} }
// TODO: 如果 webview 在 request_permission 需要回复 cli
console.log('[Extension] Diff accepted');
}), }),
vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => { vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => {
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
if (docUri && docUri.scheme === DIFF_SCHEME) { if (docUri && docUri.scheme === DIFF_SCHEME) {
diffManager.cancelDiff(docUri); diffManager.cancelDiff(docUri);
} }
// TODO: 如果 webview 在 request_permission 需要回复 cli
console.log('[Extension] Diff cancelled');
}), }),
); );

View File

@@ -39,6 +39,7 @@ import {
AssistantMessage, AssistantMessage,
ThinkingMessage, ThinkingMessage,
WaitingMessage, WaitingMessage,
InterruptedMessage,
} from './components/messages/index.js'; } from './components/messages/index.js';
import { InputForm } from './components/InputForm.js'; import { InputForm } from './components/InputForm.js';
import { SessionSelector } from './components/session/SessionSelector.js'; import { SessionSelector } from './components/session/SessionSelector.js';
@@ -172,15 +173,33 @@ export const App: React.FC = () => {
isStreaming: messageHandling.isStreaming, isStreaming: messageHandling.isStreaming,
}); });
// Handle cancel streaming // Handle cancel/stop from the input bar
// Emit a cancel to the extension and immediately reflect interruption locally.
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
if (messageHandling.isStreaming) { if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) {
vscode.postMessage({ // Proactively end local states and add an 'Interrupted' line
type: 'cancelStreaming', try {
data: {}, messageHandling.endStreaming?.();
} catch {
/* no-op */
}
try {
messageHandling.clearWaitingForResponse?.();
} catch {
/* no-op */
}
messageHandling.addMessage({
role: 'assistant',
content: 'Interrupted',
timestamp: Date.now(),
}); });
} }
}, [messageHandling.isStreaming, vscode]); // Notify extension/agent to cancel server-side work
vscode.postMessage({
type: 'cancelStreaming',
data: {},
});
}, [messageHandling, vscode]);
// Message handling // Message handling
useWebViewMessages({ useWebViewMessages({
@@ -562,14 +581,28 @@ export const App: React.FC = () => {
); );
} }
return ( {
<AssistantMessage const content = (msg.content || '').trim();
key={`message-${index}`} if (
content={msg.content || ''} content === 'Interrupted' ||
timestamp={msg.timestamp || 0} content === 'Tool interrupted'
onFileClick={handleFileClick} ) {
/> return (
); <InterruptedMessage
key={`message-${index}`}
text={content}
/>
);
}
return (
<AssistantMessage
key={`message-${index}`}
content={content}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
} }
// case 'in-progress-tool-call': // case 'in-progress-tool-call':

View File

@@ -84,6 +84,7 @@ export const InputForm: React.FC<InputFormProps> = ({
inputText, inputText,
inputFieldRef, inputFieldRef,
isStreaming, isStreaming,
isWaitingForResponse,
isComposing, isComposing,
editMode, editMode,
thinkingEnabled, thinkingEnabled,
@@ -109,6 +110,12 @@ export const InputForm: React.FC<InputFormProps> = ({
const editModeInfo = getEditModeInfo(editMode); const editModeInfo = getEditModeInfo(editMode);
const handleKeyDown = (e: React.KeyboardEvent) => { 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 composing (Chinese IME input), don't process Enter key
if (e.key === 'Enter' && !e.shiftKey && !isComposing) { if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
// If CompletionMenu is open, let it handle Enter key // If CompletionMenu is open, let it handle Enter key

View File

@@ -1,158 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths
*/
import type React from 'react';
import MarkdownIt from 'markdown-it';
import type { Options as MarkdownItOptions } from 'markdown-it';
import './MarkdownRenderer.css';
interface MarkdownRendererProps {
content: string;
onFileClick?: (filePath: string) => void;
}
/**
* Regular expressions for parsing content
*/
const FILE_PATH_REGEX =
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7
const FILE_PATH_WITH_LINES_REGEX =
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
/**
* MarkdownRenderer component - renders markdown content with enhanced features
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onFileClick,
}) => {
/**
* 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);
// Add syntax highlighting for code blocks
md.use((md) => {
md.renderer.rules.code_block = function (
tokens,
idx: number,
_options,
_env,
) {
const token = tokens[idx];
const lang = token.info || 'plaintext';
const content = token.content;
// Add syntax highlighting classes
return `<pre class="code-block language-${lang}"><code class="language-${lang}">${md.utils.escapeHtml(content)}</code></pre>`;
};
md.renderer.rules.fence = function (tokens, idx: number, _options, _env) {
const token = tokens[idx];
const lang = token.info || 'plaintext';
const content = token.content;
// Add syntax highlighting classes
return `<pre class="code-block language-${lang}"><code class="language-${lang}">${md.utils.escapeHtml(content)}</code></pre>`;
};
});
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
html = processFilePaths(html);
return html;
} catch (error) {
console.error('Error rendering markdown:', error);
// Fallback to plain text if markdown rendering fails
return escapeHtml(content);
}
};
/**
* Escape HTML characters for security
*/
const escapeHtml = (unsafe: string): string =>
unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* Process file paths in HTML to make them clickable
*/
const processFilePaths = (html: string): string => {
// Process file paths with line numbers
html = html.replace(FILE_PATH_WITH_LINES_REGEX, (match) => {
const filePath = match.split('#')[0];
return `<button class="file-path-link" onclick="window.handleFileClick('${filePath}')" title="Open ${match}">${match}</button>`;
});
// Process regular file paths
html = html.replace(FILE_PATH_REGEX, (match) => {
// Skip if this was already processed as a path with line numbers
if (FILE_PATH_WITH_LINES_REGEX.test(match)) {
return match;
}
return `<button class="file-path-link" onclick="window.handleFileClick('${match}')" title="Open ${match}">${match}</button>`;
});
return html;
};
/**
* Handle file click event
*/
const handleFileClick = (filePath: string) => {
if (onFileClick) {
onFileClick(filePath);
}
};
// Attach the handler to window for use in HTML onclick attributes
if (typeof window !== 'undefined') {
(
window as { handleFileClick?: (filePath: string) => void }
).handleFileClick = handleFileClick;
}
return (
<div
className="markdown-content"
dangerouslySetInnerHTML={{ __html: renderMarkdown() }}
style={{
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
}}
/>
);
};

View File

@@ -55,11 +55,44 @@
.markdown-content ul, .markdown-content ul,
.markdown-content ol { .markdown-content ol {
margin-top: 0; margin-top: 1em;
margin-bottom: 1em; margin-bottom: 1em;
padding-left: 2em; padding-left: 2em;
} }
/* Ensure list markers are visible even with global CSS resets */
.markdown-content ul {
list-style-type: disc;
list-style-position: outside;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: outside;
}
/* Nested list styles */
.markdown-content ul ul {
list-style-type: circle;
}
.markdown-content ul ul ul {
list-style-type: square;
}
.markdown-content ol ol {
list-style-type: lower-alpha;
}
.markdown-content ol ol ol {
list-style-type: lower-roman;
}
/* Style the marker explicitly so themes don't hide it */
.markdown-content li::marker {
color: var(--app-secondary-foreground);
}
.markdown-content li { .markdown-content li {
margin-bottom: 0.25em; margin-bottom: 0.25em;
} }

View File

@@ -0,0 +1,263 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths
*/
import type React from 'react';
import MarkdownIt from 'markdown-it';
import type { Options as MarkdownItOptions } from 'markdown-it';
import './MarkdownRenderer.css';
interface MarkdownRendererProps {
content: string;
onFileClick?: (filePath: string) => void;
}
/**
* Regular expressions for parsing content
*/
const FILE_PATH_REGEX =
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
// Match file paths with optional line numbers like: path/file.ts#7-14 or path/file.ts#7
const FILE_PATH_WITH_LINES_REGEX =
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
/**
* MarkdownRenderer component - renders markdown content with enhanced features
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onFileClick,
}) => {
/**
* 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);
// Add syntax highlighting for code blocks
md.use((md) => {
md.renderer.rules.code_block = function (
tokens,
idx: number,
_options,
_env,
) {
const token = tokens[idx];
const lang = token.info || 'plaintext';
const content = token.content;
// Add syntax highlighting classes
return `<pre class="code-block language-${lang}"><code class="language-${lang}">${md.utils.escapeHtml(content)}</code></pre>`;
};
md.renderer.rules.fence = function (tokens, idx: number, _options, _env) {
const token = tokens[idx];
const lang = token.info || 'plaintext';
const content = token.content;
// Add syntax highlighting classes
return `<pre class="code-block language-${lang}"><code class="language-${lang}">${md.utils.escapeHtml(content)}</code></pre>`;
};
});
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
html = processFilePaths(html);
return html;
} catch (error) {
console.error('Error rendering markdown:', error);
// Fallback to plain text if markdown rendering fails
return escapeHtml(content);
}
};
/**
* Escape HTML characters for security
*/
const escapeHtml = (unsafe: string): string =>
unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
/**
* Process file paths in HTML to make them clickable
*/
const processFilePaths = (html: string): string => {
// If DOM is not available, bail out to avoid breaking SSR
if (typeof document === 'undefined') return html;
// Build non-global variants to avoid .test() statefulness
const FILE_PATH_NO_G = new RegExp(
FILE_PATH_REGEX.source,
FILE_PATH_REGEX.flags.replace('g', ''),
);
const FILE_PATH_WITH_LINES_NO_G = new RegExp(
FILE_PATH_WITH_LINES_REGEX.source,
FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''),
);
// Match a bare file name like README.md (no leading slash)
const BARE_FILE_REGEX =
/[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i;
// Parse HTML into a DOM tree so we don't replace inside attributes
const container = document.createElement('div');
container.innerHTML = html;
const union = new RegExp(
`${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}`,
'gi',
);
const makeLink = (text: string) => {
const link = document.createElement('a');
// Pass base path to the handler; keep the full text as label
const filePath = text.split('#')[0];
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', filePath);
return link;
};
const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => {
const href = a.getAttribute('href') || '';
const text = (a.textContent || '').trim();
// If linkify turned a bare filename into http://<filename>, convert it back
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
if (httpMatch && BARE_FILE_REGEX.test(text) && httpMatch[1] === text) {
// Treat as a file link instead of external URL
const filePath = text; // no leading slash
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text}`);
a.setAttribute('data-file-path', filePath);
return;
}
// Ignore other external protocols
if (/^(https?|mailto|ftp|data):/i.test(href)) return;
const candidate = href || text;
if (
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
FILE_PATH_NO_G.test(candidate)
) {
const filePath = candidate.split('#')[0];
a.classList.add('file-path-link');
a.setAttribute('href', '#');
a.setAttribute('title', `Open ${text || href}`);
a.setAttribute('data-file-path', filePath);
}
};
const walk = (node: Node) => {
// Do not transform inside existing anchors
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName.toLowerCase() === 'a') {
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
return; // Don't descend into <a>
}
}
for (let child = node.firstChild; child; ) {
const next = child.nextSibling; // child may be replaced
if (child.nodeType === Node.TEXT_NODE) {
const text = child.nodeValue || '';
union.lastIndex = 0;
const hasMatch = union.test(text);
union.lastIndex = 0;
if (hasMatch) {
const frag = document.createDocumentFragment();
let lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = union.exec(text))) {
const matchText = m[0];
const idx = m.index;
if (idx > lastIndex) {
frag.appendChild(
document.createTextNode(text.slice(lastIndex, idx)),
);
}
frag.appendChild(makeLink(matchText));
lastIndex = idx + matchText.length;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
node.replaceChild(frag, child);
}
} else if (child.nodeType === Node.ELEMENT_NODE) {
walk(child);
}
child = next;
}
};
walk(container);
return container.innerHTML;
};
// Event delegation: intercept clicks on generated file-path links
const handleContainerClick = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
) => {
const target = e.target as HTMLElement | null;
if (!target) return;
// Find nearest anchor with our marker class
const anchor = (target.closest &&
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
if (!anchor) return;
const filePath = anchor.getAttribute('data-file-path');
if (!filePath) return;
e.preventDefault();
e.stopPropagation();
onFileClick?.(filePath);
};
return (
<div
className="markdown-content"
onClick={handleContainerClick}
dangerouslySetInnerHTML={{ __html: renderMarkdown() }}
style={{
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
}}
/>
);
};

View File

@@ -7,7 +7,7 @@
*/ */
import type React from 'react'; import type React from 'react';
import { MarkdownRenderer } from './MarkdownRenderer.tsx'; import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js';
interface MessageContentProps { interface MessageContentProps {
content: string; content: string;

View File

@@ -31,6 +31,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const customInputRef = useRef<HTMLDivElement>(null); const customInputRef = useRef<HTMLDivElement>(null);
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
// Get the title for the permission request // Get the title for the permission request
const getTitle = () => { const getTitle = () => {
if (toolCall.kind === 'edit' || toolCall.kind === 'write') { if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
@@ -47,7 +48,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
); );
} }
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') { if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
return 'Allow command execution?'; return 'Allow this bash command?';
} }
if (toolCall.kind === 'read') { if (toolCall.kind === 'read') {
const fileName = const fileName =
@@ -175,13 +176,8 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
onMouseEnter={() => setFocusedIndex(index)} onMouseEnter={() => setFocusedIndex(index)}
> >
{/* Number badge */} {/* Number badge */}
<span {/* Plain number badge without hover background */}
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${ <span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded">
isFocused
? 'text-inherit'
: 'bg-[var(--app-list-hover-background)]'
}`}
>
{index + 1} {index + 1}
</span> </span>
@@ -208,13 +204,8 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
onClick={() => customInputRef.current?.focus()} onClick={() => customInputRef.current?.focus()}
> >
{/* Number badge (N+1) */} {/* Number badge (N+1) */}
<span {/* Plain number badge without hover background */}
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${ <span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded">
isFocused
? 'text-inherit'
: 'bg-[var(--app-list-hover-background)]'
}`}
>
{options.length + 1} {options.length + 1}
</span> </span>

View File

@@ -4,9 +4,6 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type React from 'react';
import { useState } from 'react';
export interface PermissionOption { export interface PermissionOption {
name: string; name: string;
kind: string; kind: string;
@@ -39,192 +36,192 @@ export interface PermissionRequestProps {
onResponse: (optionId: string) => void; onResponse: (optionId: string) => void;
} }
export const PermissionRequest: React.FC<PermissionRequestProps> = ({ // export const PermissionRequest: React.FC<PermissionRequestProps> = ({
options, // options,
toolCall, // toolCall,
onResponse, // onResponse,
}) => { // }) => {
const [selected, setSelected] = useState<string | null>(null); // const [selected, setSelected] = useState<string | null>(null);
const [isResponding, setIsResponding] = useState(false); // const [isResponding, setIsResponding] = useState(false);
const [hasResponded, setHasResponded] = useState(false); // const [hasResponded, setHasResponded] = useState(false);
const getToolInfo = () => { // const getToolInfo = () => {
if (!toolCall) { // if (!toolCall) {
return { // return {
title: 'Permission Request', // title: 'Permission Request',
description: 'Agent is requesting permission', // description: 'Agent is requesting permission',
icon: '🔐', // icon: '🔐',
}; // };
} // }
const displayTitle = // const displayTitle =
toolCall.title || toolCall.rawInput?.description || 'Permission Request'; // toolCall.title || toolCall.rawInput?.description || 'Permission Request';
const kindIcons: Record<string, string> = { // const kindIcons: Record<string, string> = {
edit: '✏️', // edit: '✏️',
read: '📖', // read: '📖',
fetch: '🌐', // fetch: '🌐',
execute: '⚡', // execute: '⚡',
delete: '🗑️', // delete: '🗑️',
move: '📦', // move: '📦',
search: '🔍', // search: '🔍',
think: '💭', // think: '💭',
other: '🔧', // other: '🔧',
}; // };
return { // return {
title: displayTitle, // title: displayTitle,
icon: kindIcons[toolCall.kind || 'other'] || '🔧', // icon: kindIcons[toolCall.kind || 'other'] || '🔧',
}; // };
}; // };
const { title, icon } = getToolInfo(); // const { title, icon } = getToolInfo();
const handleConfirm = async () => { // const handleConfirm = async () => {
if (hasResponded || !selected) { // if (hasResponded || !selected) {
return; // return;
} // }
setIsResponding(true); // setIsResponding(true);
try { // try {
await onResponse(selected); // await onResponse(selected);
setHasResponded(true); // setHasResponded(true);
} catch (error) { // } catch (error) {
console.error('Error confirming permission:', error); // console.error('Error confirming permission:', error);
} finally { // } finally {
setIsResponding(false); // setIsResponding(false);
} // }
}; // };
if (!toolCall) { // if (!toolCall) {
return null; // return null;
} // }
return ( // return (
<div className="permission-request-card"> // <div className="permission-request-card">
<div className="permission-card-body"> // <div className="permission-card-body">
{/* Header with icon and title */} // {/* Header with icon and title */}
<div className="permission-header"> // <div className="permission-header">
<div className="permission-icon-wrapper"> // <div className="permission-icon-wrapper">
<span className="permission-icon">{icon}</span> // <span className="permission-icon">{icon}</span>
</div> // </div>
<div className="permission-info"> // <div className="permission-info">
<div className="permission-title">{title}</div> // <div className="permission-title">{title}</div>
<div className="permission-subtitle">Waiting for your approval</div> // <div className="permission-subtitle">Waiting for your approval</div>
</div> // </div>
</div> // </div>
{/* Show command if available */} // {/* Show command if available */}
{(toolCall.rawInput?.command || toolCall.title) && ( // {(toolCall.rawInput?.command || toolCall.title) && (
<div className="permission-command-section"> // <div className="permission-command-section">
<div className="permission-command-header"> // <div className="permission-command-header">
<div className="permission-command-status"> // <div className="permission-command-status">
<span className="permission-command-dot"></span> // <span className="permission-command-dot">●</span>
<span className="permission-command-label">COMMAND</span> // <span className="permission-command-label">COMMAND</span>
</div> // </div>
</div> // </div>
<div className="permission-command-content"> // <div className="permission-command-content">
<div className="permission-command-input-section"> // <div className="permission-command-input-section">
<span className="permission-command-io-label">IN</span> // <span className="permission-command-io-label">IN</span>
<code className="permission-command-code"> // <code className="permission-command-code">
{toolCall.rawInput?.command || toolCall.title} // {toolCall.rawInput?.command || toolCall.title}
</code> // </code>
</div> // </div>
{toolCall.rawInput?.description && ( // {toolCall.rawInput?.description && (
<div className="permission-command-description"> // <div className="permission-command-description">
{toolCall.rawInput.description} // {toolCall.rawInput.description}
</div> // </div>
)} // )}
</div> // </div>
</div> // </div>
)} // )}
{/* Show file locations if available */} // {/* Show file locations if available */}
{toolCall.locations && toolCall.locations.length > 0 && ( // {toolCall.locations && toolCall.locations.length > 0 && (
<div className="permission-locations-section"> // <div className="permission-locations-section">
<div className="permission-locations-label">Affected Files</div> // <div className="permission-locations-label">Affected Files</div>
{toolCall.locations.map((location, index) => ( // {toolCall.locations.map((location, index) => (
<div key={index} className="permission-location-item"> // <div key={index} className="permission-location-item">
<span className="permission-location-icon">📄</span> // <span className="permission-location-icon">📄</span>
<span className="permission-location-path"> // <span className="permission-location-path">
{location.path} // {location.path}
</span> // </span>
{location.line !== null && location.line !== undefined && ( // {location.line !== null && location.line !== undefined && (
<span className="permission-location-line"> // <span className="permission-location-line">
::{location.line} // ::{location.line}
</span> // </span>
)} // )}
</div> // </div>
))} // ))}
</div> // </div>
)} // )}
{/* Options */} // {/* Options */}
{!hasResponded && ( // {!hasResponded && (
<div className="permission-options-section"> // <div className="permission-options-section">
<div className="permission-options-label">Choose an action:</div> // <div className="permission-options-label">Choose an action:</div>
<div className="permission-options-list"> // <div className="permission-options-list">
{options && options.length > 0 ? ( // {options && options.length > 0 ? (
options.map((option, index) => { // options.map((option, index) => {
const isSelected = selected === option.optionId; // const isSelected = selected === option.optionId;
const isAllow = option.kind.includes('allow'); // const isAllow = option.kind.includes('allow');
const isAlways = option.kind.includes('always'); // const isAlways = option.kind.includes('always');
return ( // return (
<label // <label
key={option.optionId} // key={option.optionId}
className={`permission-option ${isSelected ? 'selected' : ''} ${ // className={`permission-option ${isSelected ? 'selected' : ''} ${
isAllow ? 'allow' : 'reject' // isAllow ? 'allow' : 'reject'
} ${isAlways ? 'always' : ''}`} // } ${isAlways ? 'always' : ''}`}
> // >
<input // <input
type="radio" // type="radio"
name="permission" // name="permission"
value={option.optionId} // value={option.optionId}
checked={isSelected} // checked={isSelected}
onChange={() => setSelected(option.optionId)} // onChange={() => setSelected(option.optionId)}
className="permission-radio" // className="permission-radio"
/> // />
<span className="permission-option-content"> // <span className="permission-option-content">
<span className="permission-option-number"> // <span className="permission-option-number">
{index + 1} // {index + 1}
</span> // </span>
{isAlways && ( // {isAlways && (
<span className="permission-always-badge"></span> // <span className="permission-always-badge">⚡</span>
)} // )}
{option.name} // {option.name}
</span> // </span>
</label> // </label>
); // );
}) // })
) : ( // ) : (
<div className="permission-no-options"> // <div className="permission-no-options">
No options available // No options available
</div> // </div>
)} // )}
</div> // </div>
<div className="permission-actions"> // <div className="permission-actions">
<button // <button
className="permission-confirm-button" // className="permission-confirm-button"
disabled={!selected || isResponding} // disabled={!selected || isResponding}
onClick={handleConfirm} // onClick={handleConfirm}
> // >
{isResponding ? 'Processing...' : 'Confirm'} // {isResponding ? 'Processing...' : 'Confirm'}
</button> // </button>
</div> // </div>
</div> // </div>
)} // )}
{/* Success message */} // {/* Success message */}
{hasResponded && ( // {hasResponded && (
<div className="permission-success"> // <div className="permission-success">
<span className="permission-success-icon"></span> // <span className="permission-success-icon">✓</span>
<span className="permission-success-text"> // <span className="permission-success-text">
Response sent successfully // Response sent successfully
</span> // </span>
</div> // </div>
)} // )}
</div> // </div>
</div> // </div>
); // );
}; // };

View File

@@ -41,20 +41,12 @@
color: #e1c08d; color: #e1c08d;
} }
/* Loading state - animated bullet (maps to .he) */ /* Loading state - static bullet (maps to .he) */
.assistant-message-container.assistant-message-loading::before { .assistant-message-container.assistant-message-loading::before {
color: var(--app-secondary-foreground); color: var(--app-secondary-foreground);
background-color: var(--app-secondary-background); background-color: var(--app-secondary-background);
animation: assistantMessagePulse 1s linear infinite;
} }
/* Pulse animation for loading state */ .assistant-message-container.assistant-message-loading::after {
@keyframes assistantMessagePulse { display: none
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
} }

View File

@@ -5,7 +5,7 @@
*/ */
import type React from 'react'; import type React from 'react';
import { MessageContent } from '../MessageContent.js'; import { MessageContent } from '../../MessageContent.js';
import './AssistantMessage.css'; import './AssistantMessage.css';
interface AssistantMessageProps { interface AssistantMessageProps {
@@ -13,6 +13,8 @@ interface AssistantMessageProps {
timestamp: number; timestamp: number;
onFileClick?: (path: string) => void; onFileClick?: (path: string) => void;
status?: 'default' | 'success' | 'error' | 'warning' | 'loading'; status?: 'default' | 'success' | 'error' | 'warning' | 'loading';
// When true, render without the left status bullet (no ::before dot)
hideStatusIcon?: boolean;
} }
/** /**
@@ -31,6 +33,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
timestamp: _timestamp, timestamp: _timestamp,
onFileClick, onFileClick,
status = 'default', status = 'default',
hideStatusIcon = false,
}) => { }) => {
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot // Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
if (!content || content.trim().length === 0) { if (!content || content.trim().length === 0) {
@@ -39,6 +42,9 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
// Map status to CSS class (only for ::before pseudo-element) // Map status to CSS class (only for ::before pseudo-element)
const getStatusClass = () => { const getStatusClass = () => {
if (hideStatusIcon) {
return '';
}
switch (status) { switch (status) {
case 'success': case 'success':
return 'assistant-message-success'; return 'assistant-message-success';

View File

@@ -16,7 +16,7 @@ export const StreamingMessage: React.FC<StreamingMessageProps> = ({
content, content,
onFileClick, onFileClick,
}) => ( }) => (
<div className="flex gap-0 items-start text-left flex-col relative animate-[fadeIn_0.2s_ease-in]"> <div className="flex gap-0 items-start text-left flex-col relative">
<div <div
className="inline-block my-1 relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]" className="inline-block my-1 relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
style={{ style={{
@@ -30,7 +30,7 @@ export const StreamingMessage: React.FC<StreamingMessageProps> = ({
<MessageContent content={content} onFileClick={onFileClick} /> <MessageContent content={content} onFileClick={onFileClick} />
</div> </div>
<div <div
className="absolute right-3 bottom-3 animate-[pulse_1.5s_ease-in-out_infinite]" className="absolute right-3 bottom-3"
style={{ color: 'var(--app-primary-foreground)' }} style={{ color: 'var(--app-primary-foreground)' }}
> >

View File

@@ -45,7 +45,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
return ( return (
<div <div
className="qwen-message user-message-container flex gap-0 my-1 items-start text-left flex-col relative animate-[fadeIn_0.2s_ease-in]" className="qwen-message user-message-container flex gap-0 my-1 items-start text-left flex-col relative"
style={{ position: 'relative' }} style={{ position: 'relative' }}
> >
<div <div
@@ -60,20 +60,20 @@ export const UserMessage: React.FC<UserMessageProps> = ({
> >
<MessageContent content={content} onFileClick={onFileClick} /> <MessageContent content={content} onFileClick={onFileClick} />
</div> </div>
{/* File context indicator */} {/* File context indicator */}
{fileContextDisplay && ( {fileContextDisplay && (
<div> <div className="mt-6">
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className="mr" className="mr inline-flex items-center py-0 pl-1 pr-2 ml-1 gap-1 rounded-sm cursor-pointer relative opacity-50 hover:opacity-100"
onClick={() => fileContext && onFileClick?.(fileContext.filePath)} onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
fileContext && onFileClick?.(fileContext.filePath); fileContext && onFileClick?.(fileContext.filePath);
} }
}} }}
style={{ cursor: 'pointer' }}
> >
<div <div
className="gr" className="gr"
@@ -81,7 +81,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
style={{ style={{
fontSize: '12px', fontSize: '12px',
color: 'var(--app-secondary-foreground)', color: 'var(--app-secondary-foreground)',
opacity: 0.8,
}} }}
> >
{fileContextDisplay} {fileContextDisplay}

View File

@@ -0,0 +1,32 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
interface InterruptedMessageProps {
text?: string;
}
// A lightweight status line similar to WaitingMessage but without the left status icon.
export const InterruptedMessage: React.FC<InterruptedMessageProps> = ({
text = 'Interrupted',
}) => (
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
<div
className="qwen-message message-item interrupted-item"
style={{
width: '100%',
alignItems: 'flex-start',
paddingLeft: '30px', // keep alignment with other assistant messages, but no status icon
position: 'relative',
paddingTop: '8px',
paddingBottom: '8px',
}}
>
<span className="opacity-70 italic">{text}</span>
</div>
</div>
);

View File

@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
@import url('../Assistant/AssistantMessage.css');
/* Subtle shimmering highlight across the loading text */ /* Subtle shimmering highlight across the loading text */
@keyframes waitingMessageShimmer { @keyframes waitingMessageShimmer {
0% { 0% {
@@ -31,3 +33,6 @@
animation: waitingMessageShimmer 1.6s linear infinite; animation: waitingMessageShimmer 1.6s linear infinite;
} }
.interrupted-item::after {
display: none;
}

View File

@@ -6,9 +6,8 @@
import type React from 'react'; import type React from 'react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import './AssistantMessage.css';
import './WaitingMessage.css'; import './WaitingMessage.css';
import { WITTY_LOADING_PHRASES } from '../../../constants/loadingMessages.js'; import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js';
interface WaitingMessageProps { interface WaitingMessageProps {
loadingMessage: string; loadingMessage: string;
@@ -66,7 +65,7 @@ export const WaitingMessage: React.FC<WaitingMessageProps> = ({
}, [phrases]); }, [phrases]);
return ( return (
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85 animate-[fadeIn_0.2s_ease-in]"> <div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
{/* Use the same left status icon (pseudo-element) style as assistant-message-container */} {/* Use the same left status icon (pseudo-element) style as assistant-message-container */}
<div <div
className="assistant-message-container assistant-message-loading" className="assistant-message-container assistant-message-loading"

View File

@@ -5,8 +5,9 @@
*/ */
export { UserMessage } from './UserMessage.js'; export { UserMessage } from './UserMessage.js';
export { AssistantMessage } from './AssistantMessage.js'; export { AssistantMessage } from './Assistant/AssistantMessage.js';
export { ThinkingMessage } from './ThinkingMessage.js'; export { ThinkingMessage } from './ThinkingMessage.js';
export { StreamingMessage } from './StreamingMessage.js'; export { StreamingMessage } from './StreamingMessage.js';
export { WaitingMessage } from './WaitingMessage.js'; export { WaitingMessage } from './Waiting/WaitingMessage.js';
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';
export { PlanDisplay } from '../PlanDisplay.js'; export { PlanDisplay } from '../PlanDisplay.js';

View File

@@ -53,10 +53,27 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
} }
}; };
// 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 // Error case
if (errors.length > 0) { if (errors.length > 0) {
return ( return (
<ToolCallContainer label="Bash" status="error" toolCallId={toolCallId}> <ToolCallContainer
label="Bash"
status={containerStatus}
toolCallId={toolCallId}
>
{/* Branch connector summary (Claude-like) */} {/* Branch connector summary (Claude-like) */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"> <div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span> <span className="flex-shrink-0 relative top-[-0.1em]"></span>
@@ -99,7 +116,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
output.length > 500 ? output.substring(0, 500) + '...' : output; output.length > 500 ? output.substring(0, 500) + '...' : output;
return ( return (
<ToolCallContainer label="Bash" status="success" toolCallId={toolCallId}> <ToolCallContainer
label="Bash"
status={containerStatus}
toolCallId={toolCallId}
>
{/* Branch connector summary (Claude-like) */} {/* Branch connector summary (Claude-like) */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"> <div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span> <span className="flex-shrink-0 relative top-[-0.1em]"></span>
@@ -141,7 +162,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success without output: show command with branch connector // Success without output: show command with branch connector
return ( return (
<ToolCallContainer label="Bash" status="success" toolCallId={toolCallId}> <ToolCallContainer
label="Bash"
status={containerStatus}
toolCallId={toolCallId}
>
<div <div
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1" className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
onClick={handleInClick} onClick={handleInClick}

View File

@@ -9,10 +9,13 @@
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js'; import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { DiffDisplay } from '../shared/DiffDisplay.js'; import {
import { groupContent } from '../shared/utils.js'; groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
import { useVSCode } from '../../../hooks/useVSCode.js'; import { useVSCode } from '../../../hooks/useVSCode.js';
import { FileLink } from '../../ui/FileLink.js'; import { FileLink } from '../../ui/FileLink.js';
import { isDevelopmentMode } from '../../../utils/envUtils.js';
/** /**
* Calculate diff summary (added/removed lines) * Calculate diff summary (added/removed lines)
@@ -65,6 +68,7 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const getFileName = (path: string): string => path.split('/').pop() || path; const getFileName = (path: string): string => path.split('/').pop() || path;
// Automatically trigger openDiff when diff content is detected (Claude Code style) // Automatically trigger openDiff when diff content is detected (Claude Code style)
// Only trigger once per tool call by checking toolCallId
useEffect(() => { useEffect(() => {
// Only auto-open if there are diffs and we have the required data // Only auto-open if there are diffs and we have the required data
if (diffs.length > 0) { if (diffs.length > 0) {
@@ -76,16 +80,16 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
firstDiff.oldText !== undefined && firstDiff.oldText !== undefined &&
firstDiff.newText !== undefined firstDiff.newText !== undefined
) { ) {
// TODO: 暂时注释 // TODO: 暂时注释自动打开功能,避免频繁触发
// Add a small delay to ensure the component is fully rendered // Add a small delay to ensure the component is fully rendered
// const timer = setTimeout(() => { const timer = setTimeout(() => {
// handleOpenDiff(path, firstDiff.oldText, firstDiff.newText); handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
// }, 100); }, 100);
let timer; // Proper cleanup function
return () => timer && clearTimeout(timer); return () => timer && clearTimeout(timer);
} }
} }
}, [diffs, locations, handleOpenDiff]); }, [diffs, handleOpenDiff, locations]); // Add missing dependencies
// Error case: show error // Error case: show error
if (errors.length > 0) { if (errors.length > 0) {
@@ -121,15 +125,15 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const openFirstDiff = () => const openFirstDiff = () =>
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText); handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<div <div
className="qwen-message message-item relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)] toolcall-container toolcall-status-success" className={`qwen-message message-item relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)] toolcall-container toolcall-status-${containerStatus}`}
onClick={openFirstDiff} onClick={openFirstDiff}
title="Open diff in VS Code" title="Open diff in VS Code"
> >
{/* Keep content within overall width: pl-[30px] provides the bullet indent; */}
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */} {/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full pl-[30px]"> <div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0"> <div className="flex items-center justify-between min-w-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */} {/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
@@ -143,38 +147,18 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline" className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/> />
)} )}
{/* {toolCallId && (
<span className="text-[10px] opacity-30">
[{toolCallId.slice(-8)}]
</span>
)} */}
</div> </div>
<span className="text-xs opacity-60 ml-2">open</span>
</div> </div>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1"> <div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span> <span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{summary}</span> <span className="flex-shrink-0 w-full">{summary}</span>
</div> </div>
</div>
{/* Content area aligned with bullet indent. Do NOT exceed container width. */} {/* Show toolCallId only in development/debug mode */}
{/* For any custom blocks here, keep: min-w-0 max-w-full and avoid extra horizontal padding/margins. */} {toolCallId && isDevelopmentMode() && (
<div className="pl-[30px] mt-1 min-w-0 max-w-full overflow-hidden"> <span className="text-[10px] opacity-30">
{diffs.map( [{toolCallId.slice(-8)}]
( </span>
item: import('../shared/types.js').ToolCallContent,
idx: number,
) => (
<DiffDisplay
key={`diff-${idx}`}
path={item.path}
oldText={item.oldText}
newText={item.newText}
onOpenDiff={() =>
handleOpenDiff(item.path, item.oldText, item.newText)
}
/>
),
)} )}
</div> </div>
</div> </div>
@@ -184,10 +168,11 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success case without diff: show file in compact format // Success case without diff: show file in compact format
if (locations && locations.length > 0) { if (locations && locations.length > 0) {
const fileName = getFileName(locations[0].path); const fileName = getFileName(locations[0].path);
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<ToolCallContainer <ToolCallContainer
label={`Edited ${fileName}`} label={`Edited ${fileName}`}
status="success" status={containerStatus}
toolCallId={toolCallId} toolCallId={toolCallId}
> >
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1"> <div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">

View File

@@ -32,12 +32,25 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
inputCommand = rawInput; inputCommand = rawInput;
} }
// Map tool status to container status for proper bullet coloring
const containerStatus:
| 'success'
| 'error'
| 'warning'
| 'loading'
| 'default' =
errors.length > 0 || toolCall.status === 'failed'
? 'error'
: toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
// Error case // Error case
if (errors.length > 0) { if (errors.length > 0) {
return ( return (
<ToolCallContainer <ToolCallContainer
label="Execute" label="Execute"
status="error" status={containerStatus}
toolCallId={toolCallId} toolCallId={toolCallId}
className="execute-default-toolcall" className="execute-default-toolcall"
> >
@@ -81,7 +94,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
return ( return (
<ToolCallContainer <ToolCallContainer
label="Execute" label="Execute"
status="success" status={containerStatus}
toolCallId={toolCallId} toolCallId={toolCallId}
> >
{/* Branch connector summary (Claude-like) */} {/* Branch connector summary (Claude-like) */}
@@ -117,7 +130,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success without output: show command with branch connector // Success without output: show command with branch connector
return ( return (
<ToolCallContainer label="Execute" status="success" toolCallId={toolCallId}> <ToolCallContainer
label="Execute"
status={containerStatus}
toolCallId={toolCallId}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"> <div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span> <span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span> <span className="flex-shrink-0 w-full">{commandText}</span>

View File

@@ -9,7 +9,11 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js'; import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../shared/utils.js'; import {
safeTitle,
groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
import './ExecuteNode.css'; import './ExecuteNode.css';
/** /**
@@ -60,7 +64,11 @@ export const ExecuteNodeToolCall: React.FC<BaseToolCallProps> = ({
// Success case: show command with branch connector (similar to the example) // Success case: show command with branch connector (similar to the example)
return ( return (
<ToolCallContainer label="Execute" status="success" toolCallId={toolCallId}> <ToolCallContainer
label="Execute"
status={mapToolStatusToContainerStatus(toolCall.status)}
toolCallId={toolCallId}
>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"> <div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span> <span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span> <span className="flex-shrink-0 w-full">{commandText}</span>

View File

@@ -104,8 +104,16 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
} }
// Short output - compact format // Short output - compact format
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
return ( return (
<ToolCallContainer label={kind} status="success" toolCallId={toolCallId}> <ToolCallContainer
label={kind}
status={statusFlag}
toolCallId={toolCallId}
>
{operationText || output} {operationText || output}
</ToolCallContainer> </ToolCallContainer>
); );
@@ -113,8 +121,16 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success with files: show operation + file list in compact format // Success with files: show operation + file list in compact format
if (locations && locations.length > 0) { if (locations && locations.length > 0) {
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
return ( return (
<ToolCallContainer label={kind} status="success" toolCallId={toolCallId}> <ToolCallContainer
label={kind}
status={statusFlag}
toolCallId={toolCallId}
>
<LocationsList locations={locations} /> <LocationsList locations={locations} />
</ToolCallContainer> </ToolCallContainer>
); );
@@ -122,8 +138,16 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// No output - show just the operation // No output - show just the operation
if (operationText) { if (operationText) {
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
toolCall.status === 'in_progress' || toolCall.status === 'pending'
? 'loading'
: 'success';
return ( return (
<ToolCallContainer label={kind} status="success" toolCallId={toolCallId}> <ToolCallContainer
label={kind}
status={statusFlag}
toolCallId={toolCallId}
>
{operationText} {operationText}
</ToolCallContainer> </ToolCallContainer>
); );

View File

@@ -9,7 +9,10 @@
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js'; import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { groupContent } from '../shared/utils.js'; import {
groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
import { FileLink } from '../../ui/FileLink.js'; import { FileLink } from '../../ui/FileLink.js';
/** /**
@@ -23,6 +26,14 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Group content by type // Group content by type
const { errors } = groupContent(content); const { errors } = groupContent(content);
// Compute container status based on toolCall.status (pending/in_progress -> loading)
const containerStatus:
| 'success'
| 'error'
| 'warning'
| 'loading'
| 'default' = mapToolStatusToContainerStatus(toolCall.status);
// Error case: show error // Error case: show error
if (errors.length > 0) { if (errors.length > 0) {
const path = locations?.[0]?.path || ''; const path = locations?.[0]?.path || '';
@@ -54,7 +65,7 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<ToolCallContainer <ToolCallContainer
label={'Read'} label={'Read'}
className="read-tool-call-success" className="read-tool-call-success"
status="success" status={containerStatus}
toolCallId={toolCallId} toolCallId={toolCallId}
labelSuffix={ labelSuffix={
path ? ( path ? (

View File

@@ -7,14 +7,18 @@
*/ */
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { import {
ToolCallContainer, ToolCallContainer,
ToolCallCard, ToolCallCard,
ToolCallRow, ToolCallRow,
LocationsList, LocationsList,
} from './shared/LayoutComponents.js'; } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from './shared/utils.js'; import {
safeTitle,
groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
/** /**
* Specialized component for Search tool calls * Specialized component for Search tool calls
@@ -44,6 +48,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success case with results: show search query + file list // Success case with results: show search query + file list
if (locations && locations.length > 0) { if (locations && locations.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
// If multiple results, use card layout; otherwise use compact format // If multiple results, use card layout; otherwise use compact format
if (locations.length > 1) { if (locations.length > 1) {
return ( return (
@@ -59,8 +64,13 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
} }
// Single result - compact format // Single result - compact format
return ( return (
<ToolCallContainer label="Search" status="success"> <ToolCallContainer
<span className="font-mono">{queryText}</span> label="Search"
status={containerStatus}
className="search-toolcall"
labelSuffix={`(${queryText})`}
>
{/* <span className="font-mono">{queryText}</span> */}
<span className="mx-2 opacity-50"></span> <span className="mx-2 opacity-50"></span>
<LocationsList locations={locations} /> <LocationsList locations={locations} />
</ToolCallContainer> </ToolCallContainer>
@@ -69,8 +79,13 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// No results - show query only // No results - show query only
if (queryText) { if (queryText) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<ToolCallContainer label="Search" status="success"> <ToolCallContainer
label="Search"
status={containerStatus}
className="search-toolcall"
>
<span className="font-mono">{queryText}</span> <span className="font-mono">{queryText}</span>
</ToolCallContainer> </ToolCallContainer>
); );

View File

@@ -7,13 +7,13 @@
*/ */
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { import {
ToolCallContainer, ToolCallContainer,
ToolCallCard, ToolCallCard,
ToolCallRow, ToolCallRow,
} from './shared/LayoutComponents.js'; } from '../shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js'; import { groupContent } from '../shared/utils.js';
/** /**
* Specialized component for Think tool calls * Specialized component for Think tool calls
@@ -56,8 +56,12 @@ export const ThinkToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
} }
// Short thoughts - compact format // Short thoughts - compact format
const status =
toolCall.status === 'pending' || toolCall.status === 'in_progress'
? 'loading'
: 'default';
return ( return (
<ToolCallContainer label="Thinking" status="default"> <ToolCallContainer label="Thinking" status={status}>
<span className="italic opacity-90">{thoughts}</span> <span className="italic opacity-90">{thoughts}</span>
</ToolCallContainer> </ToolCallContainer>
); );

View File

@@ -7,10 +7,10 @@
*/ */
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js'; import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { groupContent, safeTitle } from './shared/utils.js'; import { groupContent, safeTitle } from '../shared/utils.js';
import { CheckboxDisplay } from '../ui/CheckboxDisplay.js'; import { CheckboxDisplay } from '../../ui/CheckboxDisplay.js';
type EntryStatus = 'pending' | 'in_progress' | 'completed'; type EntryStatus = 'pending' | 'in_progress' | 'completed';
@@ -20,7 +20,7 @@ interface TodoEntry {
} }
const mapToolStatusToBullet = ( const mapToolStatusToBullet = (
status: import('./shared/types.js').ToolCallStatus, status: import('../shared/types.js').ToolCallStatus,
): 'success' | 'error' | 'warning' | 'loading' | 'default' => { ): 'success' | 'error' | 'warning' | 'loading' | 'default' => {
switch (status) { switch (status) {
case 'completed': case 'completed':

View File

@@ -7,10 +7,13 @@
*/ */
import type React from 'react'; import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js'; import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js'; import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { groupContent } from './shared/utils.js'; import {
import { FileLink } from '../ui/FileLink.js'; groupContent,
mapToolStatusToContainerStatus,
} from '../shared/utils.js';
import { FileLink } from '../../ui/FileLink.js';
/** /**
* Specialized component for Write tool calls * Specialized component for Write tool calls
@@ -79,10 +82,11 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
if (locations && locations.length > 0) { if (locations && locations.length > 0) {
const path = locations[0].path; const path = locations[0].path;
const lineCount = writeContent.split('\n').length; const lineCount = writeContent.split('\n').length;
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<ToolCallContainer <ToolCallContainer
label={'Created'} label={'Created'}
status="success" status={containerStatus}
toolCallId={toolCallId} toolCallId={toolCallId}
labelSuffix={ labelSuffix={
path ? ( path ? (
@@ -104,8 +108,13 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Fallback: show generic success // Fallback: show generic success
if (textOutputs.length > 0) { if (textOutputs.length > 0) {
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
return ( return (
<ToolCallContainer label="Write" status="success" toolCallId={toolCallId}> <ToolCallContainer
label="Write"
status={containerStatus}
toolCallId={toolCallId}
>
{textOutputs.join('\n')} {textOutputs.join('\n')}
</ToolCallContainer> </ToolCallContainer>
); );

View File

@@ -11,15 +11,14 @@ import type { BaseToolCallProps } from './shared/types.js';
import { shouldShowToolCall } from './shared/utils.js'; import { shouldShowToolCall } from './shared/utils.js';
import { GenericToolCall } from './GenericToolCall.js'; import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './Read/ReadToolCall.js'; import { ReadToolCall } from './Read/ReadToolCall.js';
import { WriteToolCall } from './WriteToolCall.js'; import { WriteToolCall } from './Write/WriteToolCall.js';
import { EditToolCall } from './Edit/EditToolCall.js'; import { EditToolCall } from './Edit/EditToolCall.js';
import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js'; import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js';
import { ExecuteToolCall } from './Execute/Execute.js'; import { ExecuteToolCall } from './Execute/Execute.js';
import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js'; import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js';
import { ExecuteNodeToolCall } from './ExecuteNode/ExecuteNodeToolCall.js'; import { ExecuteNodeToolCall } from './ExecuteNode/ExecuteNodeToolCall.js';
import { SearchToolCall } from './SearchToolCall.js'; import { SearchToolCall } from './Search/SearchToolCall.js';
import { ThinkToolCall } from './ThinkToolCall.js'; import { ThinkToolCall } from './Think/ThinkToolCall.js';
import { TodoWriteToolCall } from './TodoWriteToolCall.js';
/** /**
* Factory function that returns the appropriate tool call component based on kind * Factory function that returns the appropriate tool call component based on kind
@@ -69,7 +68,10 @@ export const getToolCallComponent = (
case 'updated_plan': case 'updated_plan':
case 'updatedplan': case 'updatedplan':
case 'todo_write': case 'todo_write':
case 'update_todos':
case 'todowrite':
return UpdatedPlanToolCall; return UpdatedPlanToolCall;
// return TodoWriteToolCall;
case 'search': case 'search':
case 'grep': case 'grep':
@@ -81,18 +83,6 @@ export const getToolCallComponent = (
case 'thinking': case 'thinking':
return ThinkToolCall; return ThinkToolCall;
case 'todowrite':
return TodoWriteToolCall;
// case 'todo_write':
case 'update_todos':
return TodoWriteToolCall;
// Add more specialized components as needed
// case 'fetch':
// return FetchToolCall;
// case 'delete':
// return DeleteToolCall;
default: default:
// Fallback to generic component // Fallback to generic component
return GenericToolCall; return GenericToolCall;

View File

@@ -9,6 +9,7 @@
import type React from 'react'; import type React from 'react';
import { FileLink } from '../../ui/FileLink.js'; import { FileLink } from '../../ui/FileLink.js';
import { isDevelopmentMode } from '../../../utils/envUtils.js';
import './LayoutComponents.css'; import './LayoutComponents.css';
/** /**
@@ -53,12 +54,6 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]"> <span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label} {label}
</span> </span>
{/* TODO: for 调试 */}
{_toolCallId && (
<span className="text-[10px] opacity-30">
[{_toolCallId.slice(-8)}]
</span>
)}
{labelSuffix} {labelSuffix}
</div> </div>
{children && ( {children && (
@@ -66,6 +61,13 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
{children} {children}
</div> </div>
)} )}
{/* Show toolCallId only in development/debug mode */}
{_toolCallId && isDevelopmentMode() && (
<span className="text-[10px] opacity-30">
[{_toolCallId.slice(-8)}]
</span>
)}
</div> </div>
</div> </div>
); );

View File

@@ -6,7 +6,11 @@
* Shared utility functions for tool call components * Shared utility functions for tool call components
*/ */
import type { ToolCallContent, GroupedContent } from './types.js'; import type {
ToolCallContent,
GroupedContent,
ToolCallStatus,
} from './types.js';
/** /**
* Format any value to a string for display * Format any value to a string for display
@@ -194,3 +198,26 @@ export const groupContent = (content?: ToolCallContent[]): GroupedContent => {
return { textOutputs, errors, diffs, otherData }; 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';
}
};

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React from 'react'; import type React from 'react';
export interface CheckboxDisplayProps { export interface CheckboxDisplayProps {
checked?: boolean; checked?: boolean;
@@ -29,44 +29,54 @@ export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
style, style,
title, title,
}) => { }) => {
const ref = React.useRef<HTMLInputElement | null>(null); // Render as a span (not <input>) so we can draw a checkmark with CSS.
// Pseudo-elements do not reliably render on <input> in Chromium (VS Code webviews),
React.useEffect(() => { // which caused the missing icon. This version is font-free and uses borders.
const el = ref.current; const showCheck = !!checked && !indeterminate;
if (!el) { const showDash = !!indeterminate;
return;
}
el.indeterminate = !!indeterminate;
if (indeterminate) {
el.setAttribute('data-indeterminate', 'true');
} else {
el.removeAttribute('data-indeterminate');
}
}, [indeterminate, checked]);
return ( return (
<input <span
ref={ref} role="checkbox"
type="checkbox" aria-checked={indeterminate ? 'mixed' : !!checked}
disabled={disabled} aria-disabled={disabled || undefined}
checked={checked}
readOnly
aria-checked={indeterminate ? 'mixed' : checked}
title={title} title={title}
style={style} style={style}
className={[ className={[
// Base box style (equivalent to .q) 'q m-[2px] shrink-0 w-4 h-4 relative rounded-[2px] box-border',
'q appearance-none m-[2px] shrink-0 w-4 h-4 relative rounded-[2px] box-border', 'border border-[var(--app-input-border)] bg-[var(--app-input-background)]',
'border border-[var(--app-input-border)] bg-[var(--app-input-background)] text-[var(--app-primary-foreground)]',
'inline-flex items-center justify-center', 'inline-flex items-center justify-center',
// Checked visual state showCheck ? 'opacity-70' : '',
'checked:opacity-70 checked:text-[#74c991]',
// Checkmark / indeterminate symbol via pseudo-element
'after:absolute after:left-1/2 after:top-1/2 after:-translate-x-1/2 after:-translate-y-1/2 after:opacity-0 after:pointer-events-none after:antialiased',
'checked:after:content-["\\2713"] checked:after:text-[0.9em] checked:after:opacity-100',
'data-[indeterminate=true]:text-[#e1c08d] data-[indeterminate=true]:after:content-["\\273d"] data-[indeterminate=true]:after:text-[0.8em] data-[indeterminate=true]:after:opacity-100',
className, className,
].join(' ')} ].join(' ')}
/> >
{showCheck ? (
<span
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',
].join(' ')}
/>
) : null}
{showDash ? (
<span
aria-hidden
className={[
'absolute block',
'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'w-2 h-[2px] rounded-sm',
'bg-[#e1c08d]',
].join(' ')}
/>
) : null}
</span>
); );
}; };

View File

@@ -112,7 +112,6 @@ export const FileLink: React.FC<FileLinkProps> = ({
<a <a
href="#" href="#"
className={[ className={[
// Keep a semantic handle for scoped overrides (e.g. DiffDisplay.css)
'file-link', 'file-link',
// Layout + interaction // Layout + interaction
// Use items-center + leading-none to vertically center within surrounding rows // Use items-center + leading-none to vertically center within surrounding rows

View File

@@ -9,6 +9,7 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { BaseMessageHandler } from './BaseMessageHandler.js'; import { BaseMessageHandler } from './BaseMessageHandler.js';
import { FileOperations } from '../FileOperations.js';
import { getFileName } from '../utils/webviewUtils.js'; import { getFileName } from '../utils/webviewUtils.js';
/** /**
@@ -322,11 +323,7 @@ export class FileMessageHandler extends BaseMessageHandler {
} }
try { try {
const uri = vscode.Uri.file(path); await FileOperations.openFile(path);
await vscode.window.showTextDocument(uri, {
preview: false,
preserveFocus: false,
});
} catch (error) { } catch (error) {
console.error('[FileMessageHandler] Failed to open file:', error); console.error('[FileMessageHandler] Failed to open file:', error);
vscode.window.showErrorMessage(`Failed to open file: ${error}`); vscode.window.showErrorMessage(`Failed to open file: ${error}`);

View File

@@ -73,6 +73,7 @@ interface UseWebViewMessagesProps {
breakAssistantSegment: () => void; breakAssistantSegment: () => void;
appendThinkingChunk: (chunk: string) => void; appendThinkingChunk: (chunk: string) => void;
clearThinking: () => void; clearThinking: () => void;
setWaitingForResponse: (message: string) => void;
clearWaitingForResponse: () => void; clearWaitingForResponse: () => void;
}; };
@@ -421,6 +422,7 @@ export const useWebViewMessages = ({
toolCallData.type = toolCallData.sessionUpdate; toolCallData.type = toolCallData.sessionUpdate;
} }
handlers.handleToolCallUpdate(toolCallData); handlers.handleToolCallUpdate(toolCallData);
// Split assistant stream at tool boundaries similar to Claude/GPT rhythm // Split assistant stream at tool boundaries similar to Claude/GPT rhythm
const status = (toolCallData.status || '').toString(); const status = (toolCallData.status || '').toString();
const isStart = toolCallData.type === 'tool_call'; const isStart = toolCallData.type === 'tool_call';
@@ -430,6 +432,31 @@ export const useWebViewMessages = ({
if (isStart || isFinalUpdate) { if (isStart || isFinalUpdate) {
handlers.messageHandling.breakAssistantSegment(); handlers.messageHandling.breakAssistantSegment();
} }
// While long-running tools (e.g., execute/bash/command) are in progress,
// surface a lightweight loading indicator and expose the Stop button.
try {
const kind = (toolCallData.kind || '').toString().toLowerCase();
const isExec =
kind === 'execute' || kind === 'bash' || kind === 'command';
if (isExec && (status === 'pending' || status === 'in_progress')) {
const rawInput = toolCallData.rawInput;
let cmd = '';
if (typeof rawInput === 'string') {
cmd = rawInput;
} else if (rawInput && typeof rawInput === 'object') {
const maybe = rawInput as { command?: string };
cmd = maybe.command || '';
}
const hint = cmd ? `Running: ${cmd}` : 'Running command...';
handlers.messageHandling.setWaitingForResponse(hint);
}
if (status === 'completed' || status === 'failed') {
handlers.messageHandling.clearWaitingForResponse();
}
} catch (_err) {
// Best-effort UI hint; ignore errors
}
break; break;
} }

View File

@@ -9,10 +9,10 @@
/* Import component styles */ /* Import component styles */
@import '../components/toolcalls/shared/DiffDisplay.css'; @import '../components/toolcalls/shared/DiffDisplay.css';
@import '../components/messages/AssistantMessage.css'; @import '../components/messages/Assistant/AssistantMessage.css';
@import '../components/toolcalls/shared/SimpleTimeline.css'; @import '../components/toolcalls/shared/SimpleTimeline.css';
@import '../components/messages/QwenMessageTimeline.css'; @import '../components/messages/QwenMessageTimeline.css';
@import '../components/MarkdownRenderer.css'; @import '../components/MarkdownRenderer/MarkdownRenderer.css';
/* =========================== /* ===========================

View File

@@ -155,4 +155,21 @@
background-color: var(--app-qwen-clay-button-orange); background-color: var(--app-qwen-clay-button-orange);
color: var(--app-qwen-ivory); 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;
}
} }

View File

@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export function isDevelopmentMode(): boolean {
// TODO: 调试用
return false;
// return (
// process.env.NODE_ENV === 'development' ||
// process.env.DEBUG === 'true' ||
// process.env.NODE_ENV !== 'production'
// );
}

View File

@@ -19,6 +19,7 @@ export default {
'./src/webview/components/PermissionDrawer.tsx', './src/webview/components/PermissionDrawer.tsx',
'./src/webview/components/PlanDisplay.tsx', './src/webview/components/PlanDisplay.tsx',
'./src/webview/components/session/SessionSelector.tsx', './src/webview/components/session/SessionSelector.tsx',
'./src/webview/components/messages/UserMessage.tsx',
], ],
theme: { theme: {
extend: { extend: {