vscode-pi-companion 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ import * as vscode from 'vscode';
2
+ import { IDEServer } from './ide-server';
3
+
4
+ let ideServer: IDEServer | undefined;
5
+ let outputChannel: vscode.OutputChannel;
6
+ let statusBarItem: vscode.StatusBarItem;
7
+
8
+ export function activate(context: vscode.ExtensionContext) {
9
+ outputChannel = vscode.window.createOutputChannel('PI Companion');
10
+ log('Extension activated');
11
+
12
+ const server = new IDEServer(log, outputChannel);
13
+ ideServer = server;
14
+
15
+ // Start server immediately
16
+ server.start(context).then(() => {
17
+ log(`Server started on port ${server.getPort()}`);
18
+ }).catch((err) => {
19
+ log(`Failed to start server: ${err}`);
20
+ });
21
+
22
+ // Create status bar button
23
+ statusBarItem = vscode.window.createStatusBarItem(
24
+ vscode.StatusBarAlignment.Right,
25
+ 100
26
+ );
27
+ statusBarItem.text = '$(terminal) Run PI';
28
+ statusBarItem.tooltip = 'Run PI in terminal';
29
+ statusBarItem.command = 'pi-companion.run';
30
+ statusBarItem.show();
31
+
32
+ // Register commands
33
+ context.subscriptions.push(
34
+ statusBarItem,
35
+
36
+ vscode.commands.registerCommand('pi-companion.run', () => {
37
+ runPiInTerminal();
38
+ }),
39
+
40
+ vscode.commands.registerCommand('pi-companion.start', async () => {
41
+ if (ideServer?.isRunning()) {
42
+ vscode.window.showInformationMessage('PI Companion: Server already running');
43
+ return;
44
+ }
45
+ await ideServer?.start(context);
46
+ vscode.window.showInformationMessage(`PI Companion: Server started on port ${ideServer?.getPort()}`);
47
+ }),
48
+
49
+ vscode.commands.registerCommand('pi-companion.stop', async () => {
50
+ await ideServer?.stop();
51
+ vscode.window.showInformationMessage('PI Companion: Server stopped');
52
+ }),
53
+
54
+ vscode.commands.registerCommand('pi-companion.status', () => {
55
+ const running = ideServer?.isRunning();
56
+ const port = ideServer?.getPort();
57
+ vscode.window.showInformationMessage(
58
+ `PI Companion: ${running ? `Running on port ${port}` : 'Stopped'}`
59
+ );
60
+ }),
61
+ );
62
+ }
63
+
64
+ function runPiInTerminal() {
65
+ // Create new terminal so shell rc files (including direnv hook) are sourced
66
+ const terminal = vscode.window.createTerminal({
67
+ name: 'PI',
68
+ isTransient: true
69
+ });
70
+ terminal.show();
71
+ // Ensure direnv is loaded, then run pi
72
+ terminal.sendText('eval "$(direnv export json 2>/dev/null)" 2>/dev/null; pi');
73
+ }
74
+
75
+ export function deactivate() {
76
+ log('Extension deactivated');
77
+ ideServer?.stop();
78
+ statusBarItem?.dispose();
79
+ }
80
+
81
+ function log(message: string) {
82
+ const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
83
+ outputChannel?.appendLine(`[${timestamp}] ${message}`);
84
+ }
@@ -0,0 +1,381 @@
1
+ import * as vscode from 'vscode';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import { randomUUID } from 'crypto';
8
+ import express, { Request, Response, NextFunction } from 'express';
9
+ import cors from 'cors';
10
+ import { z } from 'zod';
11
+
12
+ const IDE_SERVER_PORT_ENV_VAR = 'PI_COMPANION_PORT';
13
+ const IDE_WORKSPACE_PATH_ENV_VAR = 'PI_COMPANION_WORKSPACE_PATH';
14
+ const IDE_AUTH_TOKEN_ENV_VAR = 'PI_COMPANION_AUTH_TOKEN';
15
+
16
+ export class IDEServer {
17
+ private server: ReturnType<typeof express> | undefined;
18
+ private httpServer: ReturnType<ReturnType<typeof express>['listen']> | undefined;
19
+ private port: number = 0;
20
+ private authToken: string = '';
21
+ private context: vscode.ExtensionContext | undefined;
22
+ private transports: Map<string, StreamableHTTPServerTransport> = new Map();
23
+ private mcpServer: McpServer | undefined;
24
+ private openFilesManager: OpenFilesManager | undefined;
25
+ private log: (message: string) => void = console.log;
26
+
27
+ constructor(log: (message: string) => void, _outputChannel: vscode.OutputChannel) {
28
+ this.log = log;
29
+ }
30
+
31
+ getPort(): number {
32
+ return this.port;
33
+ }
34
+
35
+ isRunning(): boolean {
36
+ return this.httpServer !== undefined;
37
+ }
38
+
39
+ async start(context: vscode.ExtensionContext): Promise<void> {
40
+ this.context = context;
41
+ this.authToken = randomUUID();
42
+
43
+ // Create MCP server
44
+ this.mcpServer = this.createMcpServer();
45
+
46
+ // Create OpenFilesManager
47
+ this.openFilesManager = new OpenFilesManager(context);
48
+ this.openFilesManager.onDidChange(() => {
49
+ this.broadcastIdeContextUpdate();
50
+ });
51
+
52
+ // Create Express app
53
+ const app = express();
54
+ app.use(express.json({ limit: '10mb' }));
55
+ app.use(cors());
56
+
57
+ // Context endpoints (no auth required)
58
+ app.get('/context', (_req, res) => {
59
+ const context = this.openFilesManager?.getState();
60
+ res.json(context);
61
+ });
62
+
63
+ // SSE endpoint for real-time context updates (no auth required)
64
+ const sseClients = new Set<{ res: Response }>();
65
+ app.get('/context/stream', (_req, res) => {
66
+ this.log(`SSE client connected. Total: ${sseClients.size + 1}`);
67
+ res.setHeader('Content-Type', 'text/event-stream');
68
+ res.setHeader('Cache-Control', 'no-cache');
69
+ res.setHeader('Connection', 'keep-alive');
70
+ res.flushHeaders();
71
+
72
+ const client = { res };
73
+ sseClients.add(client);
74
+
75
+ // Send initial context
76
+ const context = this.openFilesManager?.getState();
77
+ res.write(`data: ${JSON.stringify(context)}\n\n`);
78
+
79
+ _req.on('close', () => {
80
+ sseClients.delete(client);
81
+ this.log(`SSE client disconnected. Total: ${sseClients.size}`);
82
+ });
83
+ });
84
+
85
+ // Store SSE clients for broadcasting
86
+ (this as any)._sseClients = sseClients;
87
+
88
+ // Auth middleware (only for MCP endpoint)
89
+ app.use((req: Request, res: Response, next: NextFunction) => {
90
+ const authHeader = req.headers.authorization;
91
+ if (!authHeader) {
92
+ res.status(401).send('Unauthorized');
93
+ return;
94
+ }
95
+ const parts = authHeader.split(' ');
96
+ if (parts.length !== 2 || parts[0] !== 'Bearer' || parts[1] !== this.authToken) {
97
+ res.status(401).send('Unauthorized');
98
+ return;
99
+ }
100
+ next();
101
+ });
102
+
103
+ // MCP endpoint
104
+ app.post('/mcp', async (req: Request, res: Response) => {
105
+ let transport: StreamableHTTPServerTransport;
106
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
107
+
108
+ if (sessionId && this.transports.has(sessionId)) {
109
+ transport = this.transports.get(sessionId)!;
110
+ } else if (!sessionId && req.body?.method === 'initialize') {
111
+ transport = new StreamableHTTPServerTransport({
112
+ sessionIdGenerator: () => randomUUID(),
113
+ onsessioninitialized: (newSessionId) => {
114
+ this.log(`Session initialized: ${newSessionId}`);
115
+ this.transports.set(newSessionId, transport);
116
+ },
117
+ });
118
+
119
+ transport.onclose = () => {
120
+ if (transport.sessionId) {
121
+ this.transports.delete(transport.sessionId);
122
+ }
123
+ };
124
+
125
+ await this.mcpServer!.connect(transport);
126
+ } else {
127
+ res.status(400).json({
128
+ jsonrpc: '2.0',
129
+ error: { code: -32000, message: 'Invalid request' },
130
+ id: null,
131
+ });
132
+ return;
133
+ }
134
+
135
+ try {
136
+ await transport.handleRequest(req, res, req.body);
137
+ } catch (error) {
138
+ this.log(`Error handling request: ${error}`);
139
+ if (!res.headersSent) {
140
+ res.status(500).json({
141
+ jsonrpc: '2.0',
142
+ error: { code: -32603, message: 'Internal error' },
143
+ id: null,
144
+ });
145
+ }
146
+ }
147
+ });
148
+
149
+ app.get('/mcp', async (req: Request, res: Response) => {
150
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
151
+ if (!sessionId || !this.transports.has(sessionId)) {
152
+ res.status(400).send('Invalid or missing session ID');
153
+ return;
154
+ }
155
+
156
+ const transport = this.transports.get(sessionId)!;
157
+ try {
158
+ await transport.handleRequest(req, res);
159
+ } catch (error) {
160
+ this.log(`Error handling session request: ${error}`);
161
+ }
162
+ });
163
+
164
+ // Start HTTP server
165
+ return new Promise((resolve, reject) => {
166
+ this.httpServer = app.listen(0, '127.0.0.1', async () => {
167
+ const address = this.httpServer!.address();
168
+ if (address && typeof address !== 'string') {
169
+ this.port = address.port;
170
+ this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
171
+
172
+ // Write connection info to file
173
+ await this.writeConnectionInfo();
174
+
175
+ // Set environment variables
176
+ if (this.context) {
177
+ this.context.environmentVariableCollection.replace(IDE_SERVER_PORT_ENV_VAR, this.port.toString());
178
+ this.context.environmentVariableCollection.replace(IDE_WORKSPACE_PATH_ENV_VAR, this.getWorkspacePath());
179
+ this.context.environmentVariableCollection.replace(IDE_AUTH_TOKEN_ENV_VAR, this.authToken);
180
+ }
181
+
182
+ resolve();
183
+ } else {
184
+ reject(new Error('Failed to get server address'));
185
+ }
186
+ });
187
+
188
+ this.httpServer.on('error', (error: Error) => {
189
+ this.log(`Server error: ${error}`);
190
+ reject(error);
191
+ });
192
+ });
193
+ }
194
+
195
+ private async writeConnectionInfo(): Promise<void> {
196
+ const configDir = path.join(os.tmpdir(), 'pi-companion');
197
+ const pid = process.ppid || process.pid;
198
+ const infoFile = path.join(configDir, `connection-${pid}.json`);
199
+
200
+ try {
201
+ await fs.promises.mkdir(configDir, { recursive: true });
202
+ const info = {
203
+ port: this.port,
204
+ workspacePath: this.getWorkspacePath(),
205
+ authToken: this.authToken,
206
+ };
207
+ await fs.promises.writeFile(infoFile, JSON.stringify(info, null, 2));
208
+ await fs.promises.chmod(infoFile, 0o600);
209
+ this.log(`Wrote connection info to ${infoFile}`);
210
+ } catch (error) {
211
+ this.log(`Failed to write connection info: ${error}`);
212
+ }
213
+ }
214
+
215
+ private getWorkspacePath(): string {
216
+ const folders = vscode.workspace.workspaceFolders;
217
+ if (folders && folders.length > 0) {
218
+ return folders.map(f => f.uri.fsPath).join(path.delimiter);
219
+ }
220
+ return '';
221
+ }
222
+
223
+ private createMcpServer(): McpServer {
224
+ const server = new McpServer(
225
+ {
226
+ name: 'pi-companion',
227
+ version: '1.0.0',
228
+ },
229
+ {
230
+ capabilities: {
231
+ tools: {},
232
+ },
233
+ },
234
+ );
235
+
236
+ // Tool: get_open_files
237
+ // Using 'tool' method with type assertion to avoid TypeScript/Zod compatibility issues
238
+ (server as any).tool(
239
+ 'get_open_files',
240
+ 'Get all open files with their content, cursor position, and selection',
241
+ {},
242
+ async () => {
243
+ const context = this.openFilesManager?.getState();
244
+ return {
245
+ content: [
246
+ {
247
+ type: 'text',
248
+ text: JSON.stringify(context, null, 2),
249
+ },
250
+ ],
251
+ };
252
+ },
253
+ );
254
+
255
+ // Tool: read_file
256
+ (server as any).tool(
257
+ 'read_file',
258
+ 'Read the contents of a file',
259
+ { path: { type: 'string', description: 'Absolute path to the file' } },
260
+ async (args: { path: string }) => {
261
+ try {
262
+ const content = await fs.promises.readFile(args.path, 'utf-8');
263
+ return {
264
+ content: [
265
+ {
266
+ type: 'text',
267
+ text: content,
268
+ },
269
+ ],
270
+ };
271
+ } catch (error) {
272
+ return {
273
+ content: [
274
+ {
275
+ type: 'text',
276
+ text: `Error reading file: ${error}`,
277
+ },
278
+ ],
279
+ isError: true,
280
+ };
281
+ }
282
+ },
283
+ );
284
+
285
+ // Tool: goto
286
+ (server as any).tool(
287
+ 'goto',
288
+ 'Open a file at a specific location',
289
+ {
290
+ path: { type: 'string', description: 'Absolute path to the file' },
291
+ line: { type: 'number', description: 'Line number (1-based)' },
292
+ character: { type: 'number', description: 'Character position (1-based)' },
293
+ },
294
+ async (args: { path: string; line?: number; character?: number }) => {
295
+ try {
296
+ const uri = vscode.Uri.file(args.path);
297
+ const document = await vscode.workspace.openTextDocument(uri);
298
+ const editor = await vscode.window.showTextDocument(document, {
299
+ viewColumn: vscode.ViewColumn.Active,
300
+ preserveFocus: false,
301
+ });
302
+
303
+ if (args.line !== undefined) {
304
+ const position = new vscode.Position(
305
+ Math.max(0, args.line - 1),
306
+ Math.max(0, (args.character || 1) - 1),
307
+ );
308
+ editor.selection = new vscode.Selection(position, position);
309
+ editor.revealRange(
310
+ new vscode.Range(position, position),
311
+ vscode.TextEditorRevealType.InCenter,
312
+ );
313
+ }
314
+
315
+ return {
316
+ content: [
317
+ {
318
+ type: 'text',
319
+ text: `Opened ${args.path}${args.line ? `:${args.line}` : ''}`,
320
+ },
321
+ ],
322
+ };
323
+ } catch (error) {
324
+ return {
325
+ content: [
326
+ {
327
+ type: 'text',
328
+ text: `Error opening file: ${error}`,
329
+ },
330
+ ],
331
+ isError: true,
332
+ };
333
+ }
334
+ },
335
+ );
336
+
337
+ return server;
338
+ }
339
+
340
+ private broadcastIdeContextUpdate(): void {
341
+ if (!this.openFilesManager) return;
342
+
343
+ const context = this.openFilesManager.getState();
344
+
345
+ // Send to SSE clients
346
+ const sseClients = (this as any)._sseClients as Set<{ res: Response }> | undefined;
347
+ if (sseClients) {
348
+ for (const client of sseClients) {
349
+ try {
350
+ client.res.write(`data: ${JSON.stringify(context)}\n\n`);
351
+ } catch (error) {
352
+ this.log(`SSE write error: ${error}`);
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ async stop(): Promise<void> {
359
+ if (this.httpServer) {
360
+ await new Promise<void>((resolve) => {
361
+ this.httpServer!.close(() => resolve());
362
+ });
363
+ this.httpServer = undefined;
364
+ }
365
+
366
+ if (this.context) {
367
+ this.context.environmentVariableCollection.clear();
368
+ }
369
+
370
+ // Clean up connection file
371
+ const configDir = path.join(os.tmpdir(), 'pi-companion');
372
+ const pid = process.ppid || process.pid;
373
+ const infoFile = path.join(configDir, `connection-${pid}.json`);
374
+ fs.promises.unlink(infoFile).catch(() => {});
375
+
376
+ this.log('Server stopped');
377
+ }
378
+ }
379
+
380
+ // OpenFilesManager must be imported at runtime to avoid circular dependency
381
+ import { OpenFilesManager } from './open-files-manager';
@@ -0,0 +1,249 @@
1
+ import * as vscode from 'vscode';
2
+ import * as fs from 'fs/promises';
3
+
4
+ const MAX_FILES = 10;
5
+ const MAX_SELECTED_TEXT_LENGTH = 16384;
6
+
7
+ interface File {
8
+ path: string;
9
+ timestamp: number;
10
+ isActive?: boolean;
11
+ selectedText?: string;
12
+ cursor?: {
13
+ line: number;
14
+ character: number;
15
+ };
16
+ selectionStart?: {
17
+ line: number;
18
+ character: number;
19
+ };
20
+ selectionEnd?: {
21
+ line: number;
22
+ character: number;
23
+ };
24
+ content?: string;
25
+ }
26
+
27
+ interface IdeContext {
28
+ workspaceState: {
29
+ openFiles: File[];
30
+ isTrusted: boolean;
31
+ workspacePath: string;
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Keeps track of the workspace state: open files, cursor position, and selected text.
37
+ */
38
+ export class OpenFilesManager {
39
+ private readonly onDidChangeEmitter = new vscode.EventEmitter<void>();
40
+ readonly onDidChange = this.onDidChangeEmitter.event;
41
+ private debounceTimer: NodeJS.Timeout | undefined;
42
+ private openFiles: File[] = [];
43
+
44
+ constructor(private readonly context: vscode.ExtensionContext) {
45
+ // Watch for active editor changes
46
+ const editorWatcher = vscode.window.onDidChangeActiveTextEditor((editor) => {
47
+ if (editor && this.isFileUri(editor.document.uri)) {
48
+ this.addOrMoveToFront(editor);
49
+ this.fireWithDebounce();
50
+ }
51
+ });
52
+
53
+ // Watch for selection changes
54
+ const selectionWatcher = vscode.window.onDidChangeTextEditorSelection((event) => {
55
+ if (this.isFileUri(event.textEditor.document.uri)) {
56
+ this.updateActiveContext(event.textEditor);
57
+ this.fireWithDebounce();
58
+ }
59
+ });
60
+
61
+ // Watch for file close
62
+ const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
63
+ if (this.isFileUri(document.uri)) {
64
+ this.remove(document.uri);
65
+ this.fireWithDebounce();
66
+ }
67
+ });
68
+
69
+ // Watch for file delete
70
+ const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
71
+ for (const uri of event.files) {
72
+ if (this.isFileUri(uri)) {
73
+ this.remove(uri);
74
+ }
75
+ }
76
+ this.fireWithDebounce();
77
+ });
78
+
79
+ // Watch for file rename
80
+ const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
81
+ for (const { oldUri, newUri } of event.files) {
82
+ if (this.isFileUri(oldUri)) {
83
+ if (this.isFileUri(newUri)) {
84
+ this.rename(oldUri, newUri);
85
+ } else {
86
+ this.remove(oldUri);
87
+ }
88
+ }
89
+ }
90
+ this.fireWithDebounce();
91
+ });
92
+
93
+ // Watch for file save (to update content)
94
+ const saveWatcher = vscode.workspace.onDidSaveTextDocument((document) => {
95
+ if (this.isFileUri(document.uri)) {
96
+ const file = this.openFiles.find((f) => f.path === document.uri.fsPath);
97
+ if (file) {
98
+ file.content = document.getText();
99
+ }
100
+ this.fireWithDebounce();
101
+ }
102
+ });
103
+
104
+ context.subscriptions.push(
105
+ editorWatcher,
106
+ selectionWatcher,
107
+ closeWatcher,
108
+ deleteWatcher,
109
+ renameWatcher,
110
+ saveWatcher,
111
+ );
112
+
113
+ // Add current active file on startup
114
+ if (
115
+ vscode.window.activeTextEditor &&
116
+ this.isFileUri(vscode.window.activeTextEditor.document.uri)
117
+ ) {
118
+ this.addOrMoveToFront(vscode.window.activeTextEditor);
119
+ }
120
+ }
121
+
122
+ private isFileUri(uri: vscode.Uri): boolean {
123
+ return uri.scheme === 'file';
124
+ }
125
+
126
+ private addOrMoveToFront(editor: vscode.TextEditor) {
127
+ // Deactivate previous active file
128
+ const currentActive = this.openFiles.find((f) => f.isActive);
129
+ if (currentActive) {
130
+ currentActive.isActive = false;
131
+ currentActive.cursor = undefined;
132
+ currentActive.selectedText = undefined;
133
+ }
134
+
135
+ // Remove if exists
136
+ const index = this.openFiles.findIndex(
137
+ (f) => f.path === editor.document.uri.fsPath,
138
+ );
139
+ if (index !== -1) {
140
+ this.openFiles.splice(index, 1);
141
+ }
142
+
143
+ // Add to front as active
144
+ this.openFiles.unshift({
145
+ path: editor.document.uri.fsPath,
146
+ timestamp: Date.now(),
147
+ isActive: true,
148
+ });
149
+
150
+ // Enforce max files
151
+ if (this.openFiles.length > MAX_FILES) {
152
+ this.openFiles.pop();
153
+ }
154
+
155
+ this.updateActiveContext(editor);
156
+ }
157
+
158
+ private remove(uri: vscode.Uri) {
159
+ const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
160
+ if (index !== -1) {
161
+ this.openFiles.splice(index, 1);
162
+ }
163
+ }
164
+
165
+ private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
166
+ const file = this.openFiles.find((f) => f.path === oldUri.fsPath);
167
+ if (file) {
168
+ file.path = newUri.fsPath;
169
+ }
170
+ }
171
+
172
+ private async updateActiveContext(editor: vscode.TextEditor) {
173
+ const file = this.openFiles.find(
174
+ (f) => f.path === editor.document.uri.fsPath,
175
+ );
176
+ if (!file || !file.isActive) {
177
+ return;
178
+ }
179
+
180
+ // Update cursor position (where cursor is)
181
+ if (editor.selection.active) {
182
+ file.cursor = {
183
+ line: editor.selection.active.line + 1,
184
+ character: editor.selection.active.character + 1,
185
+ };
186
+ }
187
+
188
+ // Update selection boundaries
189
+ if (!editor.selection.isEmpty) {
190
+ const startLine = Math.min(editor.selection.anchor.line, editor.selection.active.line) + 1;
191
+ const endLine = Math.max(editor.selection.anchor.line, editor.selection.active.line) + 1;
192
+ const startChar = Math.min(editor.selection.anchor.character, editor.selection.active.character) + 1;
193
+ const endChar = Math.max(editor.selection.anchor.character, editor.selection.active.character) + 1;
194
+
195
+ file.selectionStart = { line: startLine, character: startChar };
196
+ file.selectionEnd = { line: endLine, character: endChar };
197
+ } else {
198
+ file.selectionStart = undefined;
199
+ file.selectionEnd = undefined;
200
+ }
201
+
202
+ // Update selected text
203
+ let selectedText = editor.document.getText(editor.selection) || undefined;
204
+ if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
205
+ selectedText = selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH);
206
+ }
207
+ file.selectedText = selectedText;
208
+
209
+ // Update content
210
+ file.content = editor.document.getText();
211
+ }
212
+
213
+ private fireWithDebounce() {
214
+ if (this.debounceTimer) {
215
+ clearTimeout(this.debounceTimer);
216
+ }
217
+ this.debounceTimer = setTimeout(() => {
218
+ this.onDidChangeEmitter.fire();
219
+ }, 50);
220
+ }
221
+
222
+ getState(): IdeContext {
223
+ const folders = vscode.workspace.workspaceFolders;
224
+ const workspacePath = folders?.map((f) => f.uri.fsPath).join(',') || '';
225
+
226
+ // Only return files that are actually open in editors
227
+ const openTextEditors = vscode.window.tabGroups.all
228
+ .flatMap((group) => group.tabs)
229
+ .map((tab) => {
230
+ if (tab.input instanceof vscode.TabInputText) {
231
+ return tab.input.uri.fsPath;
232
+ }
233
+ return null;
234
+ })
235
+ .filter((p): p is string => p !== null);
236
+
237
+ const actualOpenFiles = this.openFiles.filter((f) =>
238
+ openTextEditors.includes(f.path)
239
+ );
240
+
241
+ return {
242
+ workspaceState: {
243
+ openFiles: actualOpenFiles,
244
+ isTrusted: vscode.workspace.isTrusted,
245
+ workspacePath,
246
+ },
247
+ };
248
+ }
249
+ }