mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-05 08:29:15 +00:00
Compare commits
2 Commits
fix/editor
...
feat/vscod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93dcca5147 | ||
|
|
ac0be9fb84 |
@@ -1,6 +1,4 @@
|
||||
# Qwen Code overview
|
||||
[](https://npm-compare.com/@qwen-code/qwen-code)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
> Learn about Qwen Code, Qwen's agentic coding tool that lives in your terminal and helps you turn ideas into code faster than ever before.
|
||||
|
||||
@@ -48,7 +46,7 @@ You'll be prompted to log in on first use. That's it! [Continue with Quickstart
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. Download and install the [Qwen Code Companion](https://marketplace.visualstudio.com/items?itemName=qwenlm.qwen-code-vscode-ide-companion) now.
|
||||
> **New VS Code Extension (Beta)**: Prefer a graphical interface? Our new **VS Code extension** provides an easy-to-use native IDE experience without requiring terminal familiarity. Simply install from the marketplace and start coding with Qwen Code directly in your sidebar. You can search for **Qwen Code** in the VS Code Marketplace and download it.
|
||||
|
||||
## What Qwen Code does for you
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
|
||||
|
||||
describe('file-system', () => {
|
||||
@@ -243,5 +245,12 @@ describe('file-system', () => {
|
||||
successfulReplace,
|
||||
'A successful replace should not have occurred',
|
||||
).toBeUndefined();
|
||||
|
||||
// Final verification: ensure the file was not created.
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
const fileExists = existsSync(filePath);
|
||||
expect(fileExists, 'The non-existent file should not be created').toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,76 +7,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useStdin } from 'ink';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core';
|
||||
import { spawnSync, execSync } from 'child_process';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
|
||||
/**
|
||||
* Editor command configurations for different platforms.
|
||||
* Each editor can have multiple possible command names, listed in order of preference.
|
||||
*/
|
||||
const editorCommands: Record<
|
||||
EditorType,
|
||||
{ win32: string[]; default: string[] }
|
||||
> = {
|
||||
vscode: { win32: ['code.cmd'], default: ['code'] },
|
||||
vscodium: { win32: ['codium.cmd'], default: ['codium'] },
|
||||
windsurf: { win32: ['windsurf'], default: ['windsurf'] },
|
||||
cursor: { win32: ['cursor'], default: ['cursor'] },
|
||||
vim: { win32: ['vim'], default: ['vim'] },
|
||||
neovim: { win32: ['nvim'], default: ['nvim'] },
|
||||
zed: { win32: ['zed'], default: ['zed', 'zeditor'] },
|
||||
emacs: { win32: ['emacs.exe'], default: ['emacs'] },
|
||||
trae: { win32: ['trae'], default: ['trae'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Cache for command existence checks to avoid repeated execSync calls.
|
||||
*/
|
||||
const commandExistsCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* Check if a command exists in the system.
|
||||
* Results are cached to improve performance in test environments.
|
||||
*/
|
||||
function commandExists(cmd: string): boolean {
|
||||
if (commandExistsCache.has(cmd)) {
|
||||
return commandExistsCache.get(cmd)!;
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(
|
||||
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
commandExistsCache.set(cmd, true);
|
||||
return true;
|
||||
} catch {
|
||||
commandExistsCache.set(cmd, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual executable command for an editor type.
|
||||
*/
|
||||
function getExecutableCommand(editorType: EditorType): string {
|
||||
const commandConfig = editorCommands[editorType];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
|
||||
// Try to find the first available command
|
||||
const availableCommand = commands.find((cmd) => commandExists(cmd));
|
||||
|
||||
// Return the first available command, or fall back to the last one in the list
|
||||
return availableCommand || commands[commands.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the editor command to use based on user preferences and platform.
|
||||
*/
|
||||
function getEditorCommand(preferredEditor?: EditorType): string {
|
||||
if (preferredEditor) {
|
||||
return getExecutableCommand(preferredEditor);
|
||||
return preferredEditor;
|
||||
}
|
||||
|
||||
// Platform-specific defaults with UI preference for macOS
|
||||
@@ -124,14 +63,8 @@ export function useLaunchEditor() {
|
||||
try {
|
||||
setRawMode?.(false);
|
||||
|
||||
// On Windows, .cmd and .bat files need shell: true
|
||||
const needsShell =
|
||||
process.platform === 'win32' &&
|
||||
(editorCommand.endsWith('.cmd') || editorCommand.endsWith('.bat'));
|
||||
|
||||
const { status, error } = spawnSync(editorCommand, editorArgs, {
|
||||
stdio: 'inherit',
|
||||
shell: needsShell,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -50,6 +50,9 @@ vi.mock('vscode', () => ({
|
||||
registerTextDocumentContentProvider: vi.fn(),
|
||||
onDidChangeWorkspaceFolders: vi.fn(),
|
||||
onDidGrantWorkspaceTrust: vi.fn(),
|
||||
registerFileSystemProvider: vi.fn(() => ({
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
},
|
||||
commands: {
|
||||
registerCommand: vi.fn(),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
|
||||
import { WebViewProvider } from './webview/WebViewProvider.js';
|
||||
import { registerNewCommands } from './commands/index.js';
|
||||
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
|
||||
|
||||
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
|
||||
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
|
||||
@@ -110,6 +111,19 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
checkForUpdates(context, log);
|
||||
|
||||
// Create and register readonly file system provider
|
||||
// The provider registers itself as a singleton in the constructor
|
||||
const readonlyProvider = new ReadonlyFileSystemProvider();
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.registerFileSystemProvider(
|
||||
ReadonlyFileSystemProvider.getScheme(),
|
||||
readonlyProvider,
|
||||
{ isCaseSensitive: true, isReadonly: true },
|
||||
),
|
||||
readonlyProvider,
|
||||
);
|
||||
log('Readonly file system provider registered');
|
||||
|
||||
const diffContentProvider = new DiffContentProvider();
|
||||
const diffManager = new DiffManager(
|
||||
log,
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock('node:os', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', () => ({
|
||||
detectIdeFromEnv: vi.fn(() => ({ name: 'vscode', displayName: 'VS Code' })),
|
||||
}));
|
||||
|
||||
const vscodeMock = vi.hoisted(() => ({
|
||||
workspace: {
|
||||
workspaceFolders: [
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Readonly file system provider for temporary files
|
||||
* Uses custom URI scheme to create readonly documents in VS Code
|
||||
*/
|
||||
export class ReadonlyFileSystemProvider
|
||||
implements vscode.FileSystemProvider, vscode.Disposable
|
||||
{
|
||||
private static readonly scheme = 'qwen-readonly';
|
||||
private static instance: ReadonlyFileSystemProvider | null = null;
|
||||
|
||||
private readonly files = new Map<string, Uint8Array>();
|
||||
private readonly emitter = new vscode.EventEmitter<
|
||||
vscode.FileChangeEvent[]
|
||||
>();
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
readonly onDidChangeFile = this.emitter.event;
|
||||
|
||||
constructor() {
|
||||
// Ensure only one instance exists
|
||||
if (ReadonlyFileSystemProvider.instance !== null) {
|
||||
console.warn(
|
||||
'[ReadonlyFileSystemProvider] Instance already exists, replacing with new instance',
|
||||
);
|
||||
}
|
||||
this.disposables.push(this.emitter);
|
||||
// Register as global singleton
|
||||
ReadonlyFileSystemProvider.instance = this;
|
||||
}
|
||||
|
||||
static getScheme(): string {
|
||||
return ReadonlyFileSystemProvider.scheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global singleton instance
|
||||
* Returns null if not initialized yet
|
||||
*/
|
||||
static getInstance(): ReadonlyFileSystemProvider | null {
|
||||
return ReadonlyFileSystemProvider.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URI for a readonly temporary file (static version)
|
||||
*/
|
||||
static createUri(fileName: string, content: string): vscode.Uri {
|
||||
// For tool-call related filenames, keep the URI stable so repeated clicks focus the same document.
|
||||
// Note: toolCallId can include underscores (e.g. "call_..."), so match everything after the prefix.
|
||||
const isToolCallFile =
|
||||
/^(bash-input|bash-output|execute-input|execute-output)-.+$/.test(
|
||||
fileName,
|
||||
);
|
||||
|
||||
if (isToolCallFile) {
|
||||
return vscode.Uri.from({
|
||||
scheme: ReadonlyFileSystemProvider.scheme,
|
||||
path: `/${fileName}`,
|
||||
});
|
||||
}
|
||||
|
||||
// For other cases, keep the original approach with timestamp to avoid collisions.
|
||||
const timestamp = Date.now();
|
||||
const hash = Buffer.from(content.substring(0, 100)).toString('base64url');
|
||||
const uniqueId = `${timestamp}-${hash.substring(0, 8)}`;
|
||||
return vscode.Uri.from({
|
||||
scheme: ReadonlyFileSystemProvider.scheme,
|
||||
path: `/${fileName}-${uniqueId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URI for a readonly temporary file (instance method)
|
||||
*/
|
||||
createUri(fileName: string, content: string): vscode.Uri {
|
||||
return ReadonlyFileSystemProvider.createUri(fileName, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set content for a URI
|
||||
*/
|
||||
setContent(uri: vscode.Uri, content: string): void {
|
||||
const buffer = Buffer.from(content, 'utf8');
|
||||
const key = uri.toString();
|
||||
const existed = this.files.has(key);
|
||||
this.files.set(key, buffer);
|
||||
this.emitter.fire([
|
||||
{
|
||||
type: existed
|
||||
? vscode.FileChangeType.Changed
|
||||
: vscode.FileChangeType.Created,
|
||||
uri,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content for a URI
|
||||
*/
|
||||
getContent(uri: vscode.Uri): string | undefined {
|
||||
const buffer = this.files.get(uri.toString());
|
||||
return buffer ? Buffer.from(buffer).toString('utf8') : undefined;
|
||||
}
|
||||
|
||||
// FileSystemProvider implementation
|
||||
|
||||
watch(): vscode.Disposable {
|
||||
// No watching needed for readonly files
|
||||
return new vscode.Disposable(() => {});
|
||||
}
|
||||
|
||||
stat(uri: vscode.Uri): vscode.FileStat {
|
||||
const buffer = this.files.get(uri.toString());
|
||||
if (!buffer) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
return {
|
||||
type: vscode.FileType.File,
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
size: buffer.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
readDirectory(): Array<[string, vscode.FileType]> {
|
||||
// Not needed for our use case
|
||||
return [];
|
||||
}
|
||||
|
||||
createDirectory(): void {
|
||||
throw vscode.FileSystemError.NoPermissions('Readonly file system');
|
||||
}
|
||||
|
||||
readFile(uri: vscode.Uri): Uint8Array {
|
||||
const buffer = this.files.get(uri.toString());
|
||||
if (!buffer) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
writeFile(
|
||||
uri: vscode.Uri,
|
||||
content: Uint8Array,
|
||||
options: { create: boolean; overwrite: boolean },
|
||||
): void {
|
||||
// Check if file exists
|
||||
const exists = this.files.has(uri.toString());
|
||||
|
||||
// For readonly files, only allow creation, not modification
|
||||
if (exists && !options.overwrite) {
|
||||
throw vscode.FileSystemError.FileExists(uri);
|
||||
}
|
||||
if (!exists && !options.create) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
|
||||
this.files.set(uri.toString(), content);
|
||||
this.emitter.fire([
|
||||
{
|
||||
type: exists
|
||||
? vscode.FileChangeType.Changed
|
||||
: vscode.FileChangeType.Created,
|
||||
uri,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
delete(uri: vscode.Uri): void {
|
||||
if (!this.files.has(uri.toString())) {
|
||||
throw vscode.FileSystemError.FileNotFound(uri);
|
||||
}
|
||||
this.files.delete(uri.toString());
|
||||
this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]);
|
||||
}
|
||||
|
||||
rename(): void {
|
||||
throw vscode.FileSystemError.NoPermissions('Readonly file system');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached files
|
||||
*/
|
||||
clear(): void {
|
||||
this.files.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
this.disposables.forEach((d) => d.dispose());
|
||||
// Clear global instance on dispose
|
||||
if (ReadonlyFileSystemProvider.instance === this) {
|
||||
ReadonlyFileSystemProvider.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,40 @@ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a condition to become true, driven by tab-group change events.
|
||||
* Falls back to a timeout to avoid hanging forever.
|
||||
*/
|
||||
function waitForTabGroupsCondition(
|
||||
condition: () => boolean,
|
||||
timeout: number = 2000,
|
||||
): Promise<boolean> {
|
||||
if (condition()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const subscription = vscode.window.tabGroups.onDidChangeTabGroups(() => {
|
||||
if (!condition()) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeoutHandle);
|
||||
subscription.dispose();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
subscription.dispose();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure there is an editor group directly to the left of the Qwen chat webview.
|
||||
* - If one exists, return its ViewColumn.
|
||||
* - If none exists, focus the chat panel and create a new group on its left,
|
||||
* then return the new group's ViewColumn (which equals the chat's previous column).
|
||||
* then return the new group's ViewColumn.
|
||||
* - If the chat webview cannot be located, returns undefined.
|
||||
*/
|
||||
export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
@@ -87,7 +116,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousChatColumn = webviewGroup.viewColumn;
|
||||
const initialGroupCount = vscode.window.tabGroups.all.length;
|
||||
|
||||
// Make the chat group active by revealing the panel
|
||||
try {
|
||||
@@ -104,6 +133,22 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Wait for the new group to actually be created (check that group count increased)
|
||||
const groupCreated = await waitForTabGroupsCondition(
|
||||
() => vscode.window.tabGroups.all.length > initialGroupCount,
|
||||
1000, // 1 second timeout
|
||||
);
|
||||
|
||||
if (!groupCreated) {
|
||||
// Fallback if group creation didn't complete in time
|
||||
return vscode.ViewColumn.One;
|
||||
}
|
||||
|
||||
// After creating a new group to the left, the new group takes ViewColumn.One
|
||||
// and all existing groups shift right. So the new left group is always ViewColumn.One.
|
||||
// However, to be safe, let's query for it again.
|
||||
const newLeftGroup = findLeftGroupOfChatWebview();
|
||||
|
||||
// Restore focus to chat (optional), so we don't disturb user focus
|
||||
try {
|
||||
await vscode.commands.executeCommand(openChatCommand);
|
||||
@@ -111,6 +156,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// The new left group's column equals the chat's previous column
|
||||
return previousChatColumn;
|
||||
// If we successfully found the new left group, return it
|
||||
// Otherwise, fallback to ViewColumn.One (the newly created group should be first)
|
||||
return newLeftGroup ?? vscode.ViewColumn.One;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ 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 './components/messages/toolcalls/shared/utils.js';
|
||||
import { hasToolCallOutput } from './utils/utils.js';
|
||||
import { EmptyState } from './components/layout/EmptyState.js';
|
||||
import { Onboarding } from './components/layout/Onboarding.js';
|
||||
import { type CompletionItem } from '../types/completionItemTypes.js';
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Execute tool call styles - Enhanced styling with semantic class names
|
||||
* Bash tool call styles - Enhanced styling with semantic class names
|
||||
*/
|
||||
|
||||
/* Root container for execute tool call output */
|
||||
/* Root container for bash tool call output */
|
||||
.bash-toolcall-card {
|
||||
border: 0.5px solid var(--app-input-border);
|
||||
border-radius: 5px;
|
||||
@@ -100,3 +100,9 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from '../shared/utils.js';
|
||||
import { safeTitle, groupContent } from '../../../../utils/utils.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js';
|
||||
import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
|
||||
import { CopyButton } from '../shared/copyUtils.js';
|
||||
import './Bash.css';
|
||||
|
||||
/**
|
||||
@@ -37,19 +38,14 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
|
||||
// Handle click on IN section
|
||||
const handleInClick = () => {
|
||||
createAndOpenTempFile(
|
||||
vscode.postMessage,
|
||||
inputCommand,
|
||||
'bash-input',
|
||||
'.sh',
|
||||
);
|
||||
createAndOpenTempFile(vscode, inputCommand, `bash-input-${toolCallId}`);
|
||||
};
|
||||
|
||||
// Handle click on OUT section
|
||||
const handleOutClick = () => {
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt');
|
||||
createAndOpenTempFile(vscode, output, `bash-output-${toolCallId}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,7 +80,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="bash-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
className="bash-toolcall-row bash-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
@@ -92,6 +88,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="bash-toolcall-row-content">
|
||||
<pre className="bash-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
<CopyButton text={inputCommand} />
|
||||
</div>
|
||||
|
||||
{/* ERROR row */}
|
||||
@@ -131,7 +128,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="bash-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
className="bash-toolcall-row bash-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
@@ -139,6 +136,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="bash-toolcall-row-content">
|
||||
<pre className="bash-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
<CopyButton text={inputCommand} />
|
||||
</div>
|
||||
|
||||
{/* OUT row */}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
|
||||
|
||||
@@ -61,11 +61,7 @@
|
||||
/* 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
|
||||
);
|
||||
mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -87,7 +83,6 @@
|
||||
|
||||
/* Output content with subtle styling */
|
||||
.execute-toolcall-output-subtle {
|
||||
background-color: var(--app-code-background);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
@@ -100,3 +95,9 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { safeTitle, groupContent } from '../shared/utils.js';
|
||||
import { safeTitle, groupContent } from '../../../../utils/utils.js';
|
||||
import './Execute.css';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { createAndOpenTempFile } from '../../../../utils/diffUtils.js';
|
||||
import { CopyButton } from '../shared/copyUtils.js';
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
@@ -48,6 +51,7 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const commandText = safeTitle(
|
||||
(rawInput as Record<string, unknown>)?.description || title,
|
||||
);
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Group content by type
|
||||
const { textOutputs, errors } = groupContent(content);
|
||||
@@ -61,6 +65,19 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
inputCommand = rawInput;
|
||||
}
|
||||
|
||||
// Handle click on IN section
|
||||
const handleInClick = () => {
|
||||
createAndOpenTempFile(vscode, inputCommand, `execute-input-${toolCallId}`);
|
||||
};
|
||||
|
||||
// Handle click on OUT section
|
||||
const handleOutClick = () => {
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
createAndOpenTempFile(vscode, output, `execute-output-${toolCallId}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Map tool status to container status for proper bullet coloring
|
||||
const containerStatus:
|
||||
| 'success'
|
||||
@@ -92,11 +109,16 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="execute-toolcall-card">
|
||||
<div className="execute-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div
|
||||
className="execute-toolcall-row execute-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="execute-toolcall-label">IN</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<pre className="execute-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
<CopyButton text={inputCommand} />
|
||||
</div>
|
||||
|
||||
{/* ERROR row */}
|
||||
@@ -135,15 +157,24 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<div className="execute-toolcall-card">
|
||||
<div className="execute-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div
|
||||
className="execute-toolcall-row execute-toolcall-row-with-copy group"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="execute-toolcall-label">IN</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<pre className="execute-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
<CopyButton text={inputCommand} />
|
||||
</div>
|
||||
|
||||
{/* OUT row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div
|
||||
className="execute-toolcall-row"
|
||||
onClick={handleOutClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="execute-toolcall-label">OUT</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<div className="execute-toolcall-output-subtle">
|
||||
@@ -164,7 +195,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
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"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
} from './shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from './shared/utils.js';
|
||||
import { safeTitle, groupContent } from '../../../utils/utils.js';
|
||||
|
||||
/**
|
||||
* Generic tool call component that can display any tool call type
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../../../../utils/diffUtils.js';
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
safeTitle,
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Search tool calls
|
||||
@@ -195,7 +195,7 @@ export const SearchToolCall: React.FC<BaseToolCallProps> = ({
|
||||
isLast={isLast}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{textOutputs.map((text, index) => (
|
||||
{textOutputs.map((text: string, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
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"
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
} from '../shared/LayoutComponents.js';
|
||||
import { groupContent } from '../shared/utils.js';
|
||||
import { groupContent } from '../../../../utils/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Think tool calls
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
import { groupContent, safeTitle } from '../shared/utils.js';
|
||||
import { groupContent, safeTitle } from '../../../../utils/utils.js';
|
||||
import { CheckboxDisplay } from './CheckboxDisplay.js';
|
||||
import type { PlanEntry } from '../../../../../types/chatTypes.js';
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
} from '../../../../utils/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { shouldShowToolCall } from './shared/utils.js';
|
||||
import { shouldShowToolCall } from '../../../utils/utils.js';
|
||||
import { GenericToolCall } from './GenericToolCall.js';
|
||||
import { ReadToolCall } from './Read/ReadToolCall.js';
|
||||
import { WriteToolCall } from './Write/WriteToolCall.js';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
export const handleCopyToClipboard = async (
|
||||
text: string,
|
||||
event: React.MouseEvent,
|
||||
): Promise<void> => {
|
||||
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<CopyButtonProps> = ({ text }) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="col-start-3 bg-transparent border-none px-2 py-1.5 cursor-pointer text-[var(--app-secondary-foreground)] opacity-0 transition-opacity duration-200 ease-out flex items-center justify-center rounded relative group-hover:opacity-70 hover:!opacity-100 hover:bg-[var(--app-input-border)] active:scale-95"
|
||||
onClick={async (e) => {
|
||||
await handleCopyToClipboard(text, e);
|
||||
setShowTooltip(true);
|
||||
setTimeout(() => setShowTooltip(false), 1000);
|
||||
}}
|
||||
title="Copy"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 4V3C4 2.44772 4.44772 2 5 2H13C13.5523 2 14 2.44772 14 3V11C14 11.5523 13.5523 12 13 12H12M3 6H11C11.5523 6 12 6.44772 12 7V13C12 13.5523 11.5523 14 11 14H3C2.44772 14 2 13.5523 2 13V7C2 6.44772 2.44772 6 3 6Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{showTooltip && (
|
||||
<span className="absolute -top-7 right-0 bg-[var(--app-tool-background)] text-[var(--app-primary-foreground)] px-2 py-1 rounded text-xs whitespace-nowrap border border-[var(--app-input-border)] pointer-events-none">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -5,12 +5,14 @@
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import { getFileName } from '../utils/webviewUtils.js';
|
||||
import { showDiffCommand } from '../../commands/index.js';
|
||||
import {
|
||||
findLeftGroupOfChatWebview,
|
||||
ensureLeftGroupOfChatWebview,
|
||||
} from '../../utils/editorGroupUtils.js';
|
||||
import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js';
|
||||
|
||||
/**
|
||||
* File message handler
|
||||
@@ -396,7 +398,7 @@ export class FileMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and open temporary file
|
||||
* Create and open temporary readonly file
|
||||
*/
|
||||
private async handleCreateAndOpenTempFile(
|
||||
data: Record<string, unknown> | undefined,
|
||||
@@ -411,26 +413,78 @@ export class FileMessageHandler extends BaseMessageHandler {
|
||||
try {
|
||||
const content = (data.content as string) || '';
|
||||
const fileName = (data.fileName as string) || 'temp';
|
||||
const fileExtension = (data.fileExtension as string) || '.txt';
|
||||
|
||||
// Create temporary file path
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFileName = `${fileName}-${Date.now()}${fileExtension}`;
|
||||
const tempFilePath = path.join(tempDir, tempFileName);
|
||||
// Get readonly file system provider from global singleton
|
||||
const readonlyProvider = ReadonlyFileSystemProvider.getInstance();
|
||||
if (!readonlyProvider) {
|
||||
const errorMessage = 'Readonly file system provider not initialized';
|
||||
console.error('[FileMessageHandler]', errorMessage);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: errorMessage },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Write content to temporary file
|
||||
await fs.promises.writeFile(tempFilePath, content, 'utf8');
|
||||
// Create readonly URI (without timestamp to ensure consistency)
|
||||
const uri = readonlyProvider.createUri(fileName, content);
|
||||
readonlyProvider.setContent(uri, content);
|
||||
|
||||
// Open the temporary file in VS Code
|
||||
const uri = vscode.Uri.file(tempFilePath);
|
||||
await vscode.window.showTextDocument(uri, {
|
||||
// If the document already has an open tab, focus that same tab instead of opening a new one.
|
||||
let foundExistingTab = false;
|
||||
let existingViewColumn: vscode.ViewColumn | undefined;
|
||||
for (const tabGroup of vscode.window.tabGroups.all) {
|
||||
for (const tab of tabGroup.tabs) {
|
||||
const input = tab.input as { uri?: vscode.Uri } | undefined;
|
||||
if (input?.uri && input.uri.toString() === uri.toString()) {
|
||||
foundExistingTab = true;
|
||||
existingViewColumn = tabGroup.viewColumn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundExistingTab) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundExistingTab) {
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const showOptions: vscode.TextDocumentShowOptions = {
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
};
|
||||
if (existingViewColumn !== undefined) {
|
||||
showOptions.viewColumn = existingViewColumn;
|
||||
}
|
||||
await vscode.window.showTextDocument(document, showOptions);
|
||||
console.log(
|
||||
'[FileMessageHandler] Focused on existing readonly file:',
|
||||
uri.toString(),
|
||||
'in viewColumn:',
|
||||
existingViewColumn,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find or ensure left group of chat webview
|
||||
let targetViewColumn = findLeftGroupOfChatWebview();
|
||||
if (targetViewColumn === undefined) {
|
||||
targetViewColumn = await ensureLeftGroupOfChatWebview();
|
||||
}
|
||||
|
||||
// Open as readonly document in the left group and focus it (single click should be enough)
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
await vscode.window.showTextDocument(document, {
|
||||
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[FileMessageHandler] Created and opened temporary file:',
|
||||
tempFilePath,
|
||||
'[FileMessageHandler] Created and opened readonly file:',
|
||||
uri.toString(),
|
||||
'in viewColumn:',
|
||||
targetViewColumn ?? 'Beside',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
||||
@@ -28,3 +28,22 @@ export const handleOpenDiff = (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a temporary readonly file with the given content and opens it in VS Code
|
||||
* @param content The content to write to the temporary file
|
||||
* @param fileName File name (will be auto-generated with timestamp)
|
||||
*/
|
||||
export const createAndOpenTempFile = (
|
||||
vscode: VSCodeAPI,
|
||||
content: string,
|
||||
fileName: string = 'temp',
|
||||
): void => {
|
||||
vscode.postMessage({
|
||||
type: 'createAndOpenTempFile',
|
||||
data: {
|
||||
content,
|
||||
fileName,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Temporary file manager for creating and opening temporary files in webview
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a temporary file with the given content and opens it in VS Code
|
||||
* @param content The content to write to the temporary file
|
||||
* @param fileName Optional file name (without extension)
|
||||
* @param fileExtension Optional file extension (defaults to .txt)
|
||||
*/
|
||||
export async function createAndOpenTempFile(
|
||||
postMessage: (message: {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void,
|
||||
content: string,
|
||||
fileName: string = 'temp',
|
||||
fileExtension: string = '.txt',
|
||||
): Promise<void> {
|
||||
// Send message to VS Code extension to create and open temp file
|
||||
postMessage({
|
||||
type: 'createAndOpenTempFile',
|
||||
data: {
|
||||
content,
|
||||
fileName,
|
||||
fileExtension,
|
||||
},
|
||||
});
|
||||
}
|
||||
134
packages/vscode-ide-companion/src/webview/utils/utils.test.ts
Normal file
134
packages/vscode-ide-companion/src/webview/utils/utils.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Unit tests for toolcall utility functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractCommandOutput, formatValue } from './utils.js';
|
||||
|
||||
describe('extractCommandOutput', () => {
|
||||
it('should extract output from JSON format', () => {
|
||||
const input = JSON.stringify({ output: 'Hello World' });
|
||||
expect(extractCommandOutput(input)).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should handle uppercase Output in JSON', () => {
|
||||
const input = JSON.stringify({ Output: 'Test Output' });
|
||||
expect(extractCommandOutput(input)).toBe('Test Output');
|
||||
});
|
||||
|
||||
it('should extract output from structured text format', () => {
|
||||
const input = `Command: lsof -i :5173
|
||||
Directory: (root)
|
||||
Output: COMMAND PID USER FD TYPE
|
||||
node 59117 jinjing 17u IPv6
|
||||
Error: (none)
|
||||
Exit Code: 0`;
|
||||
|
||||
const output = extractCommandOutput(input);
|
||||
expect(output).toContain('COMMAND PID USER');
|
||||
expect(output).toContain('node 59117 jinjing');
|
||||
expect(output).not.toContain('Command:');
|
||||
expect(output).not.toContain('Error:');
|
||||
});
|
||||
|
||||
it('should handle multiline output correctly', () => {
|
||||
const input = `Command: ps aux
|
||||
Directory: /home/user
|
||||
Output: USER PID %CPU %MEM
|
||||
root 1 0.0 0.1
|
||||
user 1234 1.5 2.3
|
||||
Error: (none)
|
||||
Exit Code: 0`;
|
||||
|
||||
const output = extractCommandOutput(input);
|
||||
expect(output).toContain('USER PID %CPU %MEM');
|
||||
expect(output).toContain('root 1');
|
||||
expect(output).toContain('user 1234');
|
||||
});
|
||||
|
||||
it('should skip (none) output', () => {
|
||||
const input = `Command: test
|
||||
Output: (none)
|
||||
Error: (none)`;
|
||||
|
||||
const output = extractCommandOutput(input);
|
||||
expect(output).toBe(input); // Should return original if output is (none)
|
||||
});
|
||||
|
||||
it('should return original text if no structured format found', () => {
|
||||
const input = 'Just some random text';
|
||||
expect(extractCommandOutput(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle empty output gracefully', () => {
|
||||
const input = `Command: test
|
||||
Output:
|
||||
Error: (none)`;
|
||||
|
||||
const output = extractCommandOutput(input);
|
||||
// Should return original since output is empty
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
it('should extract from regex match when Output: is present', () => {
|
||||
const input = `Some text before
|
||||
Output: This is the output
|
||||
Error: Some error`;
|
||||
|
||||
expect(extractCommandOutput(input)).toBe('This is the output');
|
||||
});
|
||||
|
||||
it('should handle JSON objects in output field', () => {
|
||||
const input = JSON.stringify({
|
||||
output: { key: 'value', nested: { data: 'test' } },
|
||||
});
|
||||
|
||||
const output = extractCommandOutput(input);
|
||||
expect(output).toContain('"key"');
|
||||
expect(output).toContain('"value"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValue', () => {
|
||||
it('should return empty string for null or undefined', () => {
|
||||
expect(formatValue(null)).toBe('');
|
||||
expect(formatValue(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('should extract output from string using extractCommandOutput', () => {
|
||||
const input = `Command: test
|
||||
Output: Hello World
|
||||
Error: (none)`;
|
||||
|
||||
const output = formatValue(input);
|
||||
expect(output).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('should handle Error objects', () => {
|
||||
const error = new Error('Test error message');
|
||||
expect(formatValue(error)).toBe('Test error message');
|
||||
});
|
||||
|
||||
it('should handle error-like objects', () => {
|
||||
const errorObj = { message: 'Custom error', stack: 'stack trace' };
|
||||
expect(formatValue(errorObj)).toBe('Custom error');
|
||||
});
|
||||
|
||||
it('should stringify objects', () => {
|
||||
const obj = { key: 'value', number: 42 };
|
||||
const output = formatValue(obj);
|
||||
expect(output).toContain('"key"');
|
||||
expect(output).toContain('"value"');
|
||||
expect(output).toContain('42');
|
||||
});
|
||||
|
||||
it('should convert primitives to string', () => {
|
||||
expect(formatValue(123)).toBe('123');
|
||||
expect(formatValue(true)).toBe('true');
|
||||
expect(formatValue(false)).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,102 @@
|
||||
import type {
|
||||
ToolCallContent,
|
||||
GroupedContent,
|
||||
ToolCallData,
|
||||
ToolCallStatus,
|
||||
} from './types.js';
|
||||
} 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
|
||||
@@ -20,13 +114,8 @@ export const formatValue = (value: unknown): string => {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// TODO: Trying to take out the Output part from the string
|
||||
try {
|
||||
value = (JSON.parse(value) as { output?: unknown }).output ?? value;
|
||||
} catch (_error) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return value as string;
|
||||
// Extract command output from structured text
|
||||
return extractCommandOutput(value);
|
||||
}
|
||||
// Handle Error objects specially
|
||||
if (value instanceof Error) {
|
||||
@@ -72,9 +161,7 @@ export const shouldShowToolCall = (kind: string): boolean =>
|
||||
* 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: import('./types.js').ToolCallData,
|
||||
): boolean => {
|
||||
export const hasToolCallOutput = (toolCall: ToolCallData): boolean => {
|
||||
// Always show failed tool calls (even without content)
|
||||
if (toolCall.status === 'failed') {
|
||||
return true;
|
||||
Reference in New Issue
Block a user