vscode-pi-companion 0.1.1 → 0.1.2

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.
package/.vscodeignore CHANGED
@@ -5,8 +5,10 @@ src/
5
5
  .git/
6
6
  *.ts
7
7
  *.map
8
- !dist/**/*.js
9
- !dist/**/*.d.ts
8
+ !out/**/*.js
9
+ !out/**/*.d.ts
10
+ node_modules/
11
+ extensions/node_modules/
10
12
  *.vsix
11
13
  tsconfig.json
12
14
  .envrc
@@ -1,7 +1,8 @@
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.
5
6
  */
6
7
 
7
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -12,11 +13,78 @@ import * as http from "http";
12
13
  import { execSync } from "child_process";
13
14
  import { Type } from "@sinclair/typebox";
14
15
 
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;
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
+ }
20
88
 
21
89
  interface IdeContext {
22
90
  workspaceState: {
@@ -35,12 +103,6 @@ interface IdeContext {
35
103
  };
36
104
  }
37
105
 
38
- interface ConnectionInfo {
39
- port: number;
40
- workspacePath: string;
41
- authToken: string;
42
- }
43
-
44
106
  let currentContext: IdeContext | undefined;
45
107
  let currentCtx: ExtensionContext | undefined;
46
108
  let lastWidgetKey = "";
@@ -48,95 +110,34 @@ let widgetDebounceTimer: NodeJS.Timeout | undefined;
48
110
  let reconnectTimer: NodeJS.Timeout | undefined;
49
111
  let sseRequest: http.ClientRequest | undefined;
50
112
 
51
- function isRunningInVSCode(): boolean {
52
- return process.env.TERM_PROGRAM === "vscode";
53
- }
113
+ function getPort(): number | undefined {
114
+ const configDir = path.join(os.tmpdir(), "pi-companion");
54
115
 
55
- function isExtensionInstalled(): boolean {
56
116
  try {
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);
117
+ const files = fs.readdirSync(configDir);
83
118
  const connectionFiles = files
84
119
  .filter((f) => f.startsWith("connection-") && f.endsWith(".json"))
85
120
  .map((f) => ({
86
- path: path.join(CONNECTION_DIR, f),
87
- mtime: fs.statSync(path.join(CONNECTION_DIR, f)).mtime.getTime(),
121
+ name: f,
122
+ path: path.join(configDir, f),
123
+ mtime: fs.statSync(path.join(configDir, f)).mtime.getTime(),
88
124
  }))
89
125
  .sort((a, b) => b.mtime - a.mtime);
90
126
 
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
- }
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;
101
131
  }
102
132
  } catch {
103
- // No connection files found.
133
+ // No connection files found
104
134
  }
105
135
  return undefined;
106
136
  }
107
137
 
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
-
137
138
  function updateWidget(): void {
138
139
  if (!currentCtx) return;
139
-
140
+
140
141
  if (!currentContext || !currentContext.workspaceState.openFiles.length) {
141
142
  currentCtx.ui.setWidget("pi-companion", ["PI Companion: No files open"]);
142
143
  return;
@@ -144,16 +145,25 @@ function updateWidget(): void {
144
145
 
145
146
  const { workspaceState } = currentContext;
146
147
  const lines: string[] = [];
148
+
147
149
  const activeFile = workspaceState.openFiles.find((f) => f.isActive);
148
150
 
149
151
  if (activeFile) {
150
152
  const filename = activeFile.path.split("/").pop() || activeFile.path;
153
+
154
+ // Build location info - ONLY show when text is actually selected
151
155
  let location = "";
152
156
  if (activeFile.selectionStart && activeFile.selectionEnd) {
153
157
  const start = activeFile.selectionStart.line;
154
158
  const end = activeFile.selectionEnd.line;
155
- location = start === end ? ` L${start}` : ` L${start}:${end}`;
159
+ if (start === end) {
160
+ location = ` L${start}`;
161
+ } else {
162
+ location = ` L${start}:${end}`;
163
+ }
156
164
  }
165
+ // Don't show cursor position when nothing is selected
166
+
157
167
  lines.push(`📄 ${filename}${location}`);
158
168
  }
159
169
 
@@ -163,105 +173,129 @@ function updateWidget(): void {
163
173
  }
164
174
 
165
175
  const widgetKey = lines.join("|");
166
- if (widgetDebounceTimer) clearTimeout(widgetDebounceTimer);
167
-
176
+
177
+ // Debounce widget updates to avoid flickering during rapid selection changes
178
+ if (widgetDebounceTimer) {
179
+ clearTimeout(widgetDebounceTimer);
180
+ }
181
+
168
182
  widgetDebounceTimer = setTimeout(() => {
169
183
  if (widgetKey !== lastWidgetKey) {
170
184
  lastWidgetKey = widgetKey;
171
185
  currentCtx!.ui.setWidget("pi-companion", lines);
172
186
  }
173
187
  widgetDebounceTimer = undefined;
174
- }, WIDGET_DEBOUNCE_MS);
188
+ }, 50); // Wait 50ms after last update
175
189
  }
176
190
 
177
- async function connectSSE(): Promise<void> {
178
- const port = await getLivePort();
191
+ function connectSSE(): void {
192
+ const port = getPort();
179
193
  if (!port) return;
180
194
 
195
+ // Close existing connection
181
196
  if (sseRequest) {
182
197
  sseRequest.destroy();
183
198
  sseRequest = undefined;
184
199
  }
185
200
 
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;
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: ")) {
202
219
  const data = line.slice(6);
203
- if (!data || data === "[object Object]") continue;
204
- try {
205
- currentContext = JSON.parse(data);
206
- updateWidget();
207
- } catch {
208
- // Ignore malformed SSE payloads.
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
+ }
209
228
  }
210
229
  }
211
- });
230
+ }
231
+ });
212
232
 
213
- res.on("end", scheduleReconnect);
214
- res.on("error", scheduleReconnect);
215
- },
216
- );
233
+ res.on("end", () => {
234
+ scheduleReconnect();
235
+ });
217
236
 
218
- sseRequest.on("error", scheduleReconnect);
237
+ res.on("error", () => {
238
+ scheduleReconnect();
239
+ });
240
+ });
241
+
242
+ sseRequest.on("error", () => {
243
+ scheduleReconnect();
244
+ });
219
245
  }
220
246
 
221
247
  function scheduleReconnect(): void {
222
248
  if (reconnectTimer || !currentCtx) return;
223
249
  reconnectTimer = setTimeout(() => {
224
250
  reconnectTimer = undefined;
225
- void connectSSE();
226
- }, RECONNECT_DELAY_MS);
251
+ connectSSE();
252
+ }, 2000);
227
253
  }
228
254
 
229
255
  export default function (pi: ExtensionAPI) {
230
- pi.on("before_agent_start", async () => {
256
+ // Inject IDE context before each prompt
257
+ pi.on("before_agent_start", async (event, ctx) => {
231
258
  if (!currentContext) return;
232
-
259
+
233
260
  const activeFile = currentContext.workspaceState.openFiles.find((f) => f.isActive);
234
261
  if (!activeFile) return;
235
262
 
236
263
  let contextText = "";
237
-
264
+
238
265
  if (activeFile.selectedText) {
266
+ // Inject selected text
239
267
  contextText = `Current selection in ${activeFile.path}:\n\`\`\`\n${activeFile.selectedText}\n\`\`\``;
240
268
  } else if (activeFile.content) {
269
+ // Inject full file content for small files, or first 100 lines
241
270
  const lines = activeFile.content.split("\n");
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\`\`\``;
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
+ }
245
276
  }
246
277
 
247
- if (!contextText) return;
248
- return {
249
- message: {
250
- customType: "pi-companion",
251
- content: contextText,
252
- display: false,
253
- },
254
- };
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
+ }
255
287
  });
256
288
 
257
289
  pi.on("session_start", async (_event, ctx) => {
258
290
  currentCtx = ctx;
259
- ensureVSCodeExtensionInstalled(ctx);
260
291
 
261
- const port = await getLivePort();
292
+ // Auto-install/update VS Code extension if needed
293
+ installVSCodeExtension(ctx);
294
+
295
+ const port = getPort();
262
296
  if (port) {
263
297
  ctx.ui.notify("PI Companion: Connected to VS Code", "info");
264
- await connectSSE();
298
+ connectSSE();
265
299
  } else {
266
300
  ctx.ui.notify("PI Companion: VS Code companion server not running. Run 'PI Companion: Start Server' in VS Code.", "warning");
267
301
  }
@@ -278,6 +312,7 @@ export default function (pi: ExtensionAPI) {
278
312
  }
279
313
  });
280
314
 
315
+ // Register tools
281
316
  pi.registerTool({
282
317
  name: "get_ide_context",
283
318
  label: "Get IDE Context",
@@ -311,15 +346,12 @@ export default function (pi: ExtensionAPI) {
311
346
  return { content: [{ type: "text", text: "No active file" }], details: {} };
312
347
  }
313
348
  return {
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
- }],
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) }],
323
355
  details: {},
324
356
  };
325
357
  },
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.1",
6
+ "version": "0.1.2",
7
7
  "publisher": "ravshansbox",
8
8
  "keywords": [
9
9
  "pi-package"
@@ -15,7 +15,7 @@
15
15
  "AI",
16
16
  "Other"
17
17
  ],
18
- "main": "./dist/extension.js",
18
+ "main": "./out/extension.js",
19
19
  "scripts": {
20
20
  "compile": "tsc -p ./",
21
21
  "watch": "tsc -watch -p ./",
@@ -59,6 +59,10 @@
59
59
  }
60
60
  },
61
61
  "commands": [
62
+ {
63
+ "command": "pi-companion.run",
64
+ "title": "PI Companion: Run PI"
65
+ },
62
66
  {
63
67
  "command": "pi-companion.start",
64
68
  "title": "PI Companion: Start Server"
@@ -71,6 +75,14 @@
71
75
  "command": "pi-companion.status",
72
76
  "title": "PI Companion: Show Status"
73
77
  }
78
+ ],
79
+ "keybindings": [
80
+ {
81
+ "command": "pi-companion.run",
82
+ "key": "cmd+escape",
83
+ "mac": "cmd+escape",
84
+ "when": "editorTextFocus"
85
+ }
74
86
  ]
75
87
  }
76
88
  }
package/src/extension.ts CHANGED
@@ -3,6 +3,8 @@ import { IDEServer } from './ide-server';
3
3
 
4
4
  let ideServer: IDEServer | undefined;
5
5
  let outputChannel: vscode.OutputChannel;
6
+ let statusBarItem: vscode.StatusBarItem;
7
+
6
8
  export function activate(context: vscode.ExtensionContext) {
7
9
  outputChannel = vscode.window.createOutputChannel('PI Companion');
8
10
  log('Extension activated');
@@ -17,8 +19,23 @@ export function activate(context: vscode.ExtensionContext) {
17
19
  log(`Failed to start server: ${err}`);
18
20
  });
19
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
+
20
32
  // Register commands
21
33
  context.subscriptions.push(
34
+ statusBarItem,
35
+
36
+ vscode.commands.registerCommand('pi-companion.run', () => {
37
+ runPiInTerminal();
38
+ }),
22
39
 
23
40
  vscode.commands.registerCommand('pi-companion.start', async () => {
24
41
  if (ideServer?.isRunning()) {
@@ -44,9 +61,21 @@ export function activate(context: vscode.ExtensionContext) {
44
61
  );
45
62
  }
46
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
+
47
75
  export function deactivate() {
48
76
  log('Extension deactivated');
49
77
  ideServer?.stop();
78
+ statusBarItem?.dispose();
50
79
  }
51
80
 
52
81
  function log(message: string) {