Skip to content

Commit 919560e

Browse files
authored
Merge pull request #1345 from QwenLM/feat/vscode-ida-companion-bash-toolcall-click-2
feat(vscode-ide-companion): in/output part in the bash toolcall can be clicked to open a temporary file
2 parents 26bd4f8 + 93dcca5 commit 919560e

File tree

24 files changed

+744
-98
lines changed

24 files changed

+744
-98
lines changed

packages/vscode-ide-companion/src/extension.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ vi.mock('vscode', () => ({
5050
registerTextDocumentContentProvider: vi.fn(),
5151
onDidChangeWorkspaceFolders: vi.fn(),
5252
onDidGrantWorkspaceTrust: vi.fn(),
53+
registerFileSystemProvider: vi.fn(() => ({
54+
dispose: vi.fn(),
55+
})),
5356
},
5457
commands: {
5558
registerCommand: vi.fn(),

packages/vscode-ide-companion/src/extension.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
1717
import { WebViewProvider } from './webview/WebViewProvider.js';
1818
import { registerNewCommands } from './commands/index.js';
19+
import { ReadonlyFileSystemProvider } from './services/readonlyFileSystemProvider.js';
1920

2021
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
2122
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
@@ -110,6 +111,19 @@ export async function activate(context: vscode.ExtensionContext) {
110111

111112
checkForUpdates(context, log);
112113

114+
// Create and register readonly file system provider
115+
// The provider registers itself as a singleton in the constructor
116+
const readonlyProvider = new ReadonlyFileSystemProvider();
117+
context.subscriptions.push(
118+
vscode.workspace.registerFileSystemProvider(
119+
ReadonlyFileSystemProvider.getScheme(),
120+
readonlyProvider,
121+
{ isCaseSensitive: true, isReadonly: true },
122+
),
123+
readonlyProvider,
124+
);
125+
log('Readonly file system provider registered');
126+
113127
const diffContentProvider = new DiffContentProvider();
114128
const diffManager = new DiffManager(
115129
log,

packages/vscode-ide-companion/src/ide-server.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ vi.mock('node:os', async (importOriginal) => {
3838
};
3939
});
4040

41+
vi.mock('@qwen-code/qwen-code-core/src/ide/detect-ide.js', () => ({
42+
detectIdeFromEnv: vi.fn(() => ({ name: 'vscode', displayName: 'VS Code' })),
43+
}));
44+
4145
const vscodeMock = vi.hoisted(() => ({
4246
workspace: {
4347
workspaceFolders: [
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Qwen Team
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as vscode from 'vscode';
8+
9+
/**
10+
* Readonly file system provider for temporary files
11+
* Uses custom URI scheme to create readonly documents in VS Code
12+
*/
13+
export class ReadonlyFileSystemProvider
14+
implements vscode.FileSystemProvider, vscode.Disposable
15+
{
16+
private static readonly scheme = 'qwen-readonly';
17+
private static instance: ReadonlyFileSystemProvider | null = null;
18+
19+
private readonly files = new Map<string, Uint8Array>();
20+
private readonly emitter = new vscode.EventEmitter<
21+
vscode.FileChangeEvent[]
22+
>();
23+
private readonly disposables: vscode.Disposable[] = [];
24+
25+
readonly onDidChangeFile = this.emitter.event;
26+
27+
constructor() {
28+
// Ensure only one instance exists
29+
if (ReadonlyFileSystemProvider.instance !== null) {
30+
console.warn(
31+
'[ReadonlyFileSystemProvider] Instance already exists, replacing with new instance',
32+
);
33+
}
34+
this.disposables.push(this.emitter);
35+
// Register as global singleton
36+
ReadonlyFileSystemProvider.instance = this;
37+
}
38+
39+
static getScheme(): string {
40+
return ReadonlyFileSystemProvider.scheme;
41+
}
42+
43+
/**
44+
* Get the global singleton instance
45+
* Returns null if not initialized yet
46+
*/
47+
static getInstance(): ReadonlyFileSystemProvider | null {
48+
return ReadonlyFileSystemProvider.instance;
49+
}
50+
51+
/**
52+
* Create a URI for a readonly temporary file (static version)
53+
*/
54+
static createUri(fileName: string, content: string): vscode.Uri {
55+
// For tool-call related filenames, keep the URI stable so repeated clicks focus the same document.
56+
// Note: toolCallId can include underscores (e.g. "call_..."), so match everything after the prefix.
57+
const isToolCallFile =
58+
/^(bash-input|bash-output|execute-input|execute-output)-.+$/.test(
59+
fileName,
60+
);
61+
62+
if (isToolCallFile) {
63+
return vscode.Uri.from({
64+
scheme: ReadonlyFileSystemProvider.scheme,
65+
path: `/${fileName}`,
66+
});
67+
}
68+
69+
// For other cases, keep the original approach with timestamp to avoid collisions.
70+
const timestamp = Date.now();
71+
const hash = Buffer.from(content.substring(0, 100)).toString('base64url');
72+
const uniqueId = `${timestamp}-${hash.substring(0, 8)}`;
73+
return vscode.Uri.from({
74+
scheme: ReadonlyFileSystemProvider.scheme,
75+
path: `/${fileName}-${uniqueId}`,
76+
});
77+
}
78+
79+
/**
80+
* Create a URI for a readonly temporary file (instance method)
81+
*/
82+
createUri(fileName: string, content: string): vscode.Uri {
83+
return ReadonlyFileSystemProvider.createUri(fileName, content);
84+
}
85+
86+
/**
87+
* Set content for a URI
88+
*/
89+
setContent(uri: vscode.Uri, content: string): void {
90+
const buffer = Buffer.from(content, 'utf8');
91+
const key = uri.toString();
92+
const existed = this.files.has(key);
93+
this.files.set(key, buffer);
94+
this.emitter.fire([
95+
{
96+
type: existed
97+
? vscode.FileChangeType.Changed
98+
: vscode.FileChangeType.Created,
99+
uri,
100+
},
101+
]);
102+
}
103+
104+
/**
105+
* Get content for a URI
106+
*/
107+
getContent(uri: vscode.Uri): string | undefined {
108+
const buffer = this.files.get(uri.toString());
109+
return buffer ? Buffer.from(buffer).toString('utf8') : undefined;
110+
}
111+
112+
// FileSystemProvider implementation
113+
114+
watch(): vscode.Disposable {
115+
// No watching needed for readonly files
116+
return new vscode.Disposable(() => {});
117+
}
118+
119+
stat(uri: vscode.Uri): vscode.FileStat {
120+
const buffer = this.files.get(uri.toString());
121+
if (!buffer) {
122+
throw vscode.FileSystemError.FileNotFound(uri);
123+
}
124+
125+
return {
126+
type: vscode.FileType.File,
127+
ctime: Date.now(),
128+
mtime: Date.now(),
129+
size: buffer.byteLength,
130+
};
131+
}
132+
133+
readDirectory(): Array<[string, vscode.FileType]> {
134+
// Not needed for our use case
135+
return [];
136+
}
137+
138+
createDirectory(): void {
139+
throw vscode.FileSystemError.NoPermissions('Readonly file system');
140+
}
141+
142+
readFile(uri: vscode.Uri): Uint8Array {
143+
const buffer = this.files.get(uri.toString());
144+
if (!buffer) {
145+
throw vscode.FileSystemError.FileNotFound(uri);
146+
}
147+
return buffer;
148+
}
149+
150+
writeFile(
151+
uri: vscode.Uri,
152+
content: Uint8Array,
153+
options: { create: boolean; overwrite: boolean },
154+
): void {
155+
// Check if file exists
156+
const exists = this.files.has(uri.toString());
157+
158+
// For readonly files, only allow creation, not modification
159+
if (exists && !options.overwrite) {
160+
throw vscode.FileSystemError.FileExists(uri);
161+
}
162+
if (!exists && !options.create) {
163+
throw vscode.FileSystemError.FileNotFound(uri);
164+
}
165+
166+
this.files.set(uri.toString(), content);
167+
this.emitter.fire([
168+
{
169+
type: exists
170+
? vscode.FileChangeType.Changed
171+
: vscode.FileChangeType.Created,
172+
uri,
173+
},
174+
]);
175+
}
176+
177+
delete(uri: vscode.Uri): void {
178+
if (!this.files.has(uri.toString())) {
179+
throw vscode.FileSystemError.FileNotFound(uri);
180+
}
181+
this.files.delete(uri.toString());
182+
this.emitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]);
183+
}
184+
185+
rename(): void {
186+
throw vscode.FileSystemError.NoPermissions('Readonly file system');
187+
}
188+
189+
/**
190+
* Clear all cached files
191+
*/
192+
clear(): void {
193+
this.files.clear();
194+
}
195+
196+
dispose(): void {
197+
this.clear();
198+
this.disposables.forEach((d) => d.dispose());
199+
// Clear global instance on dispose
200+
if (ReadonlyFileSystemProvider.instance === this) {
201+
ReadonlyFileSystemProvider.instance = null;
202+
}
203+
}
204+
}

packages/vscode-ide-companion/src/utils/editorGroupUtils.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,40 @@ export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined {
5353
}
5454
}
5555

56+
/**
57+
* Wait for a condition to become true, driven by tab-group change events.
58+
* Falls back to a timeout to avoid hanging forever.
59+
*/
60+
function waitForTabGroupsCondition(
61+
condition: () => boolean,
62+
timeout: number = 2000,
63+
): Promise<boolean> {
64+
if (condition()) {
65+
return Promise.resolve(true);
66+
}
67+
68+
return new Promise<boolean>((resolve) => {
69+
const subscription = vscode.window.tabGroups.onDidChangeTabGroups(() => {
70+
if (!condition()) {
71+
return;
72+
}
73+
clearTimeout(timeoutHandle);
74+
subscription.dispose();
75+
resolve(true);
76+
});
77+
78+
const timeoutHandle = setTimeout(() => {
79+
subscription.dispose();
80+
resolve(false);
81+
}, timeout);
82+
});
83+
}
84+
5685
/**
5786
* Ensure there is an editor group directly to the left of the Qwen chat webview.
5887
* - If one exists, return its ViewColumn.
5988
* - If none exists, focus the chat panel and create a new group on its left,
60-
* then return the new group's ViewColumn (which equals the chat's previous column).
89+
* then return the new group's ViewColumn.
6190
* - If the chat webview cannot be located, returns undefined.
6291
*/
6392
export async function ensureLeftGroupOfChatWebview(): Promise<
@@ -87,7 +116,7 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
87116
return undefined;
88117
}
89118

90-
const previousChatColumn = webviewGroup.viewColumn;
119+
const initialGroupCount = vscode.window.tabGroups.all.length;
91120

92121
// Make the chat group active by revealing the panel
93122
try {
@@ -104,13 +133,30 @@ export async function ensureLeftGroupOfChatWebview(): Promise<
104133
return undefined;
105134
}
106135

136+
// Wait for the new group to actually be created (check that group count increased)
137+
const groupCreated = await waitForTabGroupsCondition(
138+
() => vscode.window.tabGroups.all.length > initialGroupCount,
139+
1000, // 1 second timeout
140+
);
141+
142+
if (!groupCreated) {
143+
// Fallback if group creation didn't complete in time
144+
return vscode.ViewColumn.One;
145+
}
146+
147+
// After creating a new group to the left, the new group takes ViewColumn.One
148+
// and all existing groups shift right. So the new left group is always ViewColumn.One.
149+
// However, to be safe, let's query for it again.
150+
const newLeftGroup = findLeftGroupOfChatWebview();
151+
107152
// Restore focus to chat (optional), so we don't disturb user focus
108153
try {
109154
await vscode.commands.executeCommand(openChatCommand);
110155
} catch {
111156
// Ignore
112157
}
113158

114-
// The new left group's column equals the chat's previous column
115-
return previousChatColumn;
159+
// If we successfully found the new left group, return it
160+
// Otherwise, fallback to ViewColumn.One (the newly created group should be first)
161+
return newLeftGroup ?? vscode.ViewColumn.One;
116162
}

packages/vscode-ide-companion/src/webview/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import type { TextMessage } from './hooks/message/useMessageHandling.js';
2727
import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js';
2828
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
2929
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
30-
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
30+
import { hasToolCallOutput } from './utils/utils.js';
3131
import { EmptyState } from './components/layout/EmptyState.js';
3232
import { Onboarding } from './components/layout/Onboarding.js';
3333
import { type CompletionItem } from '../types/completionItemTypes.js';

packages/vscode-ide-companion/src/webview/components/messages/toolcalls/Bash/Bash.css

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
* Copyright 2025 Qwen Team
44
* SPDX-License-Identifier: Apache-2.0
55
*
6-
* Execute tool call styles - Enhanced styling with semantic class names
6+
* Bash tool call styles - Enhanced styling with semantic class names
77
*/
88

9-
/* Root container for execute tool call output */
9+
/* Root container for bash tool call output */
1010
.bash-toolcall-card {
1111
border: 0.5px solid var(--app-input-border);
1212
border-radius: 5px;
@@ -100,3 +100,9 @@
100100
.bash-toolcall-error-content {
101101
color: #c74e39;
102102
}
103+
104+
/* Row with copy button */
105+
.bash-toolcall-row-with-copy {
106+
position: relative;
107+
grid-template-columns: max-content 1fr max-content;
108+
}

0 commit comments

Comments
 (0)