vscode-pi-companion 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/extensions/index.ts +147 -179
  2. package/package.json +1 -1
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * PI Companion Extension
3
- *
3
+ *
4
4
  * Connects to the VS Code extension via SSE for real-time context updates.
5
- * No polling, instant updates, no tool calls visible.
6
5
  */
7
6
 
8
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -13,78 +12,11 @@ import * as http from "http";
13
12
  import { execSync } from "child_process";
14
13
  import { Type } from "@sinclair/typebox";
15
14
 
16
- function isLocalPackageInstall(): boolean {
17
- const filename = __filename;
18
- return !filename.includes(`${path.sep}.pi${path.sep}agent${path.sep}git${path.sep}`)
19
- && !filename.includes(`${path.sep}.pi${path.sep}git${path.sep}`);
20
- }
21
-
22
- // Check if running in VS Code integrated terminal
23
- function isRunningInVSCode(): boolean {
24
- return process.env.TERM_PROGRAM === 'vscode';
25
- }
26
-
27
- // Install VS Code extension from marketplace
28
- function installFromMarketplace(ctx: ExtensionContext): void {
29
- try {
30
- execSync('code --install-extension ravshansbox.vscode-pi-companion --force', { stdio: 'pipe' });
31
- ctx.ui.notify("PI Companion: VS Code extension installed. Reload window to activate.", "info");
32
- } catch {
33
- // Silently ignore
34
- }
35
- }
36
-
37
- // Check if extension is installed
38
- function isExtensionInstalled(): boolean {
39
- try {
40
- const result = execSync('code --list-extensions', { encoding: 'utf8' });
41
- return result.includes('ravshansbox.vscode-pi-companion');
42
- } catch {
43
- return false;
44
- }
45
- }
46
-
47
- // Install/update VS Code extension if running in VS Code terminal
48
- async function installVSCodeExtension(ctx: ExtensionContext): Promise<void> {
49
- if (!isRunningInVSCode()) {
50
- return;
51
- }
52
-
53
- const isInstalled = isExtensionInstalled();
54
-
55
- if (isInstalled) {
56
- // Already installed - check for updates, but skip for local package installs
57
- if (!isLocalPackageInstall()) {
58
- const latestVersion = await getLatestVersion();
59
- if (latestVersion) {
60
- console.log(`[PI Companion] Update available: v${latestVersion}`);
61
- console.log(`[PI Companion] Run: pi install git:github.com/ravshansbox/vscode-pi-companion`);
62
- }
63
- }
64
- return;
65
- }
66
-
67
- // Not installed - install from marketplace
68
- installFromMarketplace(ctx);
69
- }
70
-
71
- // Get latest version from GitHub
72
- async function getLatestVersion(): Promise<string | null> {
73
- try {
74
- const response = await fetch('https://api.github.com/repos/ravshansbox/vscode-pi-companion/releases/latest', {
75
- headers: { 'Accept': 'application/vnd.github.v3+json' }
76
- });
77
-
78
- if (!response.ok) {
79
- return null;
80
- }
81
-
82
- const release = await response.json() as { tag_name?: string };
83
- return release.tag_name?.replace('v', '') || null;
84
- } catch {
85
- return null;
86
- }
87
- }
15
+ const CONNECTION_DIR = path.join(os.tmpdir(), "pi-companion");
16
+ const WIDGET_DEBOUNCE_MS = 50;
17
+ const RECONNECT_DELAY_MS = 2000;
18
+ const PORT_VALIDATE_TIMEOUT_MS = 750;
19
+ const MAX_INJECTED_LINES = 100;
88
20
 
89
21
  interface IdeContext {
90
22
  workspaceState: {
@@ -103,6 +35,12 @@ interface IdeContext {
103
35
  };
104
36
  }
105
37
 
38
+ interface ConnectionInfo {
39
+ port: number;
40
+ workspacePath: string;
41
+ authToken: string;
42
+ }
43
+
106
44
  let currentContext: IdeContext | undefined;
107
45
  let currentCtx: ExtensionContext | undefined;
108
46
  let lastWidgetKey = "";
@@ -110,34 +48,95 @@ let widgetDebounceTimer: NodeJS.Timeout | undefined;
110
48
  let reconnectTimer: NodeJS.Timeout | undefined;
111
49
  let sseRequest: http.ClientRequest | undefined;
112
50
 
113
- function getPort(): number | undefined {
114
- const configDir = path.join(os.tmpdir(), "pi-companion");
51
+ function isRunningInVSCode(): boolean {
52
+ return process.env.TERM_PROGRAM === "vscode";
53
+ }
115
54
 
55
+ function isExtensionInstalled(): boolean {
116
56
  try {
117
- const files = fs.readdirSync(configDir);
57
+ const result = execSync("code --list-extensions", { encoding: "utf8" });
58
+ return result.includes("ravshansbox.vscode-pi-companion");
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function installFromMarketplace(ctx: ExtensionContext): void {
65
+ try {
66
+ execSync("code --install-extension ravshansbox.vscode-pi-companion --force", { stdio: "pipe" });
67
+ ctx.ui.notify("PI Companion: VS Code extension installed. Reload window to activate.", "info");
68
+ } catch {
69
+ ctx.ui.notify("PI Companion: Failed to auto-install VS Code extension.", "warning");
70
+ }
71
+ }
72
+
73
+ function ensureVSCodeExtensionInstalled(ctx: ExtensionContext): void {
74
+ if (!isRunningInVSCode()) return;
75
+ if (!isExtensionInstalled()) {
76
+ installFromMarketplace(ctx);
77
+ }
78
+ }
79
+
80
+ function readLatestConnectionInfo(): ConnectionInfo | undefined {
81
+ try {
82
+ const files = fs.readdirSync(CONNECTION_DIR);
118
83
  const connectionFiles = files
119
84
  .filter((f) => f.startsWith("connection-") && f.endsWith(".json"))
120
85
  .map((f) => ({
121
- name: f,
122
- path: path.join(configDir, f),
123
- mtime: fs.statSync(path.join(configDir, f)).mtime.getTime(),
86
+ path: path.join(CONNECTION_DIR, f),
87
+ mtime: fs.statSync(path.join(CONNECTION_DIR, f)).mtime.getTime(),
124
88
  }))
125
89
  .sort((a, b) => b.mtime - a.mtime);
126
90
 
127
- if (connectionFiles.length > 0) {
128
- const content = fs.readFileSync(connectionFiles[0].path, "utf-8");
129
- const info = JSON.parse(content);
130
- return info.port;
91
+ for (const file of connectionFiles) {
92
+ try {
93
+ const content = fs.readFileSync(file.path, "utf-8");
94
+ const info = JSON.parse(content) as ConnectionInfo;
95
+ if (typeof info.port === "number" && info.port > 0) {
96
+ return info;
97
+ }
98
+ } catch {
99
+ // Ignore malformed files and try the next one.
100
+ }
131
101
  }
132
102
  } catch {
133
- // No connection files found
103
+ // No connection files found.
134
104
  }
135
105
  return undefined;
136
106
  }
137
107
 
108
+ function isPortAlive(port: number): Promise<boolean> {
109
+ return new Promise((resolve) => {
110
+ const req = http.get(
111
+ {
112
+ hostname: "127.0.0.1",
113
+ port,
114
+ path: "/context",
115
+ timeout: PORT_VALIDATE_TIMEOUT_MS,
116
+ },
117
+ (res) => {
118
+ res.resume();
119
+ resolve(res.statusCode === 200);
120
+ },
121
+ );
122
+
123
+ req.on("timeout", () => {
124
+ req.destroy();
125
+ resolve(false);
126
+ });
127
+ req.on("error", () => resolve(false));
128
+ });
129
+ }
130
+
131
+ async function getLivePort(): Promise<number | undefined> {
132
+ const info = readLatestConnectionInfo();
133
+ if (!info) return undefined;
134
+ return (await isPortAlive(info.port)) ? info.port : undefined;
135
+ }
136
+
138
137
  function updateWidget(): void {
139
138
  if (!currentCtx) return;
140
-
139
+
141
140
  if (!currentContext || !currentContext.workspaceState.openFiles.length) {
142
141
  currentCtx.ui.setWidget("pi-companion", ["PI Companion: No files open"]);
143
142
  return;
@@ -145,25 +144,16 @@ function updateWidget(): void {
145
144
 
146
145
  const { workspaceState } = currentContext;
147
146
  const lines: string[] = [];
148
-
149
147
  const activeFile = workspaceState.openFiles.find((f) => f.isActive);
150
148
 
151
149
  if (activeFile) {
152
150
  const filename = activeFile.path.split("/").pop() || activeFile.path;
153
-
154
- // Build location info - ONLY show when text is actually selected
155
151
  let location = "";
156
152
  if (activeFile.selectionStart && activeFile.selectionEnd) {
157
153
  const start = activeFile.selectionStart.line;
158
154
  const end = activeFile.selectionEnd.line;
159
- if (start === end) {
160
- location = ` L${start}`;
161
- } else {
162
- location = ` L${start}:${end}`;
163
- }
155
+ location = start === end ? ` L${start}` : ` L${start}:${end}`;
164
156
  }
165
- // Don't show cursor position when nothing is selected
166
-
167
157
  lines.push(`📄 ${filename}${location}`);
168
158
  }
169
159
 
@@ -173,129 +163,105 @@ function updateWidget(): void {
173
163
  }
174
164
 
175
165
  const widgetKey = lines.join("|");
176
-
177
- // Debounce widget updates to avoid flickering during rapid selection changes
178
- if (widgetDebounceTimer) {
179
- clearTimeout(widgetDebounceTimer);
180
- }
181
-
166
+ if (widgetDebounceTimer) clearTimeout(widgetDebounceTimer);
167
+
182
168
  widgetDebounceTimer = setTimeout(() => {
183
169
  if (widgetKey !== lastWidgetKey) {
184
170
  lastWidgetKey = widgetKey;
185
171
  currentCtx!.ui.setWidget("pi-companion", lines);
186
172
  }
187
173
  widgetDebounceTimer = undefined;
188
- }, 50); // Wait 50ms after last update
174
+ }, WIDGET_DEBOUNCE_MS);
189
175
  }
190
176
 
191
- function connectSSE(): void {
192
- const port = getPort();
177
+ async function connectSSE(): Promise<void> {
178
+ const port = await getLivePort();
193
179
  if (!port) return;
194
180
 
195
- // Close existing connection
196
181
  if (sseRequest) {
197
182
  sseRequest.destroy();
198
183
  sseRequest = undefined;
199
184
  }
200
185
 
201
- const url = new URL(`http://127.0.0.1:${port}/context/stream`);
202
-
203
- sseRequest = http.get({
204
- hostname: url.hostname,
205
- port: url.port,
206
- path: url.pathname,
207
- }, (res) => {
208
- let buffer = "";
209
-
210
- res.on("data", (chunk: string) => {
211
- buffer += chunk;
212
-
213
- // Process complete SSE messages
214
- const lines = buffer.split("\n");
215
- buffer = lines.pop() || "";
216
-
217
- for (const line of lines) {
218
- if (line.startsWith("data: ")) {
186
+ sseRequest = http.get(
187
+ {
188
+ hostname: "127.0.0.1",
189
+ port,
190
+ path: "/context/stream",
191
+ },
192
+ (res) => {
193
+ let buffer = "";
194
+
195
+ res.on("data", (chunk: string) => {
196
+ buffer += chunk;
197
+ const lines = buffer.split("\n");
198
+ buffer = lines.pop() || "";
199
+
200
+ for (const line of lines) {
201
+ if (!line.startsWith("data: ")) continue;
219
202
  const data = line.slice(6);
220
- if (data && data !== "[object Object]") {
221
- try {
222
- const parsed = JSON.parse(data);
223
- currentContext = parsed;
224
- updateWidget();
225
- } catch {
226
- // Ignore parse errors
227
- }
203
+ if (!data || data === "[object Object]") continue;
204
+ try {
205
+ currentContext = JSON.parse(data);
206
+ updateWidget();
207
+ } catch {
208
+ // Ignore malformed SSE payloads.
228
209
  }
229
210
  }
230
- }
231
- });
211
+ });
232
212
 
233
- res.on("end", () => {
234
- scheduleReconnect();
235
- });
236
-
237
- res.on("error", () => {
238
- scheduleReconnect();
239
- });
240
- });
213
+ res.on("end", scheduleReconnect);
214
+ res.on("error", scheduleReconnect);
215
+ },
216
+ );
241
217
 
242
- sseRequest.on("error", () => {
243
- scheduleReconnect();
244
- });
218
+ sseRequest.on("error", scheduleReconnect);
245
219
  }
246
220
 
247
221
  function scheduleReconnect(): void {
248
222
  if (reconnectTimer || !currentCtx) return;
249
223
  reconnectTimer = setTimeout(() => {
250
224
  reconnectTimer = undefined;
251
- connectSSE();
252
- }, 2000);
225
+ void connectSSE();
226
+ }, RECONNECT_DELAY_MS);
253
227
  }
254
228
 
255
229
  export default function (pi: ExtensionAPI) {
256
- // Inject IDE context before each prompt
257
- pi.on("before_agent_start", async (event, ctx) => {
230
+ pi.on("before_agent_start", async () => {
258
231
  if (!currentContext) return;
259
-
232
+
260
233
  const activeFile = currentContext.workspaceState.openFiles.find((f) => f.isActive);
261
234
  if (!activeFile) return;
262
235
 
263
236
  let contextText = "";
264
-
237
+
265
238
  if (activeFile.selectedText) {
266
- // Inject selected text
267
239
  contextText = `Current selection in ${activeFile.path}:\n\`\`\`\n${activeFile.selectedText}\n\`\`\``;
268
240
  } else if (activeFile.content) {
269
- // Inject full file content for small files, or first 100 lines
270
241
  const lines = activeFile.content.split("\n");
271
- if (lines.length <= 100) {
272
- contextText = `Current file ${activeFile.path}:\n\`\`\`\n${activeFile.content}\n\`\`\``;
273
- } else {
274
- contextText = `Current file ${activeFile.path} (first 100 lines):\n\`\`\`\n${lines.slice(0, 100).join("\n")}\n\`\`\``;
275
- }
242
+ contextText = lines.length <= MAX_INJECTED_LINES
243
+ ? `Current file ${activeFile.path}:\n\`\`\`\n${activeFile.content}\n\`\`\``
244
+ : `Current file ${activeFile.path} (first ${MAX_INJECTED_LINES} lines):\n\`\`\`\n${lines.slice(0, MAX_INJECTED_LINES).join("\n")}\n\`\`\``;
276
245
  }
277
246
 
278
- if (contextText) {
279
- return {
280
- message: {
281
- customType: "pi-companion",
282
- content: contextText,
283
- display: false, // Don't show in chat history
284
- },
285
- };
286
- }
247
+ if (!contextText) return;
248
+ return {
249
+ message: {
250
+ customType: "pi-companion",
251
+ content: contextText,
252
+ display: false,
253
+ },
254
+ };
287
255
  });
288
256
 
289
257
  pi.on("session_start", async (_event, ctx) => {
290
258
  currentCtx = ctx;
259
+ ensureVSCodeExtensionInstalled(ctx);
291
260
 
292
- // Auto-install/update VS Code extension if needed
293
- installVSCodeExtension(ctx);
294
-
295
- const port = getPort();
261
+ const port = await getLivePort();
296
262
  if (port) {
297
263
  ctx.ui.notify("PI Companion: Connected to VS Code", "info");
298
- connectSSE();
264
+ await connectSSE();
299
265
  } else {
300
266
  ctx.ui.notify("PI Companion: VS Code companion server not running. Run 'PI Companion: Start Server' in VS Code.", "warning");
301
267
  }
@@ -312,7 +278,6 @@ export default function (pi: ExtensionAPI) {
312
278
  }
313
279
  });
314
280
 
315
- // Register tools
316
281
  pi.registerTool({
317
282
  name: "get_ide_context",
318
283
  label: "Get IDE Context",
@@ -346,12 +311,15 @@ export default function (pi: ExtensionAPI) {
346
311
  return { content: [{ type: "text", text: "No active file" }], details: {} };
347
312
  }
348
313
  return {
349
- content: [{ type: "text", text: JSON.stringify({
350
- path: activeFile.path,
351
- cursor: activeFile.cursor,
352
- selection: activeFile.selectedText,
353
- content: activeFile.content,
354
- }, null, 2) }],
314
+ content: [{
315
+ type: "text",
316
+ text: JSON.stringify({
317
+ path: activeFile.path,
318
+ cursor: activeFile.cursor,
319
+ selection: activeFile.selectedText,
320
+ content: activeFile.content,
321
+ }, null, 2),
322
+ }],
355
323
  details: {},
356
324
  };
357
325
  },
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "PI Companion",
4
4
  "description": "Share VS Code context with pi coding agent",
5
5
  "repository": "https://github.com/ravshansbox/vscode-pi-companion",
6
- "version": "0.1.2",
6
+ "version": "0.1.3",
7
7
  "publisher": "ravshansbox",
8
8
  "keywords": [
9
9
  "pi-package"