pi-x-ide 0.1.0
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/LICENSE +201 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/dist/src/pi/commands.d.ts +12 -0
- package/dist/src/pi/commands.js +96 -0
- package/dist/src/pi/commands.js.map +1 -0
- package/dist/src/pi/connection.d.ts +27 -0
- package/dist/src/pi/connection.js +136 -0
- package/dist/src/pi/connection.js.map +1 -0
- package/dist/src/pi/context.d.ts +8 -0
- package/dist/src/pi/context.js +66 -0
- package/dist/src/pi/context.js.map +1 -0
- package/dist/src/pi/discovery.d.ts +11 -0
- package/dist/src/pi/discovery.js +79 -0
- package/dist/src/pi/discovery.js.map +1 -0
- package/dist/src/pi/index.d.ts +4 -0
- package/dist/src/pi/index.js +182 -0
- package/dist/src/pi/index.js.map +1 -0
- package/dist/src/pi/state.d.ts +24 -0
- package/dist/src/pi/state.js +12 -0
- package/dist/src/pi/state.js.map +1 -0
- package/dist/src/pi/ui.d.ts +6 -0
- package/dist/src/pi/ui.js +84 -0
- package/dist/src/pi/ui.js.map +1 -0
- package/dist/src/shared/format.d.ts +23 -0
- package/dist/src/shared/format.js +84 -0
- package/dist/src/shared/format.js.map +1 -0
- package/dist/src/shared/paths.d.ts +5 -0
- package/dist/src/shared/paths.js +50 -0
- package/dist/src/shared/paths.js.map +1 -0
- package/dist/src/shared/protocol.d.ts +92 -0
- package/dist/src/shared/protocol.js +8 -0
- package/dist/src/shared/protocol.js.map +1 -0
- package/dist/src/shared/schema.d.ts +9 -0
- package/dist/src/shared/schema.js +94 -0
- package/dist/src/shared/schema.js.map +1 -0
- package/dist/src/shared/ws.d.ts +2 -0
- package/dist/src/shared/ws.js +12 -0
- package/dist/src/shared/ws.js.map +1 -0
- package/dist/test/shared.test.d.ts +1 -0
- package/dist/test/shared.test.js +101 -0
- package/dist/test/shared.test.js.map +1 -0
- package/package.json +55 -0
- package/src/pi/commands.ts +122 -0
- package/src/pi/connection.ts +155 -0
- package/src/pi/context.ts +82 -0
- package/src/pi/discovery.ts +88 -0
- package/src/pi/index.ts +190 -0
- package/src/pi/state.ts +29 -0
- package/src/pi/ui.ts +85 -0
- package/src/shared/format.ts +95 -0
- package/src/shared/paths.ts +47 -0
- package/src/shared/protocol.ts +107 -0
- package/src/shared/schema.ts +113 -0
- package/src/shared/ws.ts +8 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent" with {
|
|
2
|
+
"resolution-mode": "import",
|
|
3
|
+
};
|
|
4
|
+
import { formatRangeMention } from "../shared/format";
|
|
5
|
+
import type { LockFileCandidate } from "../shared/protocol";
|
|
6
|
+
import type { PiIdeRuntime } from "./state";
|
|
7
|
+
import { buildWidget, updateIdeUi } from "./ui";
|
|
8
|
+
|
|
9
|
+
export interface IdeCommandActions {
|
|
10
|
+
refreshCandidates: (ctx: ExtensionCommandContext) => Promise<LockFileCandidate[]>;
|
|
11
|
+
connectAuto: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
12
|
+
connectCandidate: (candidate: LockFileCandidate, ctx: ExtensionCommandContext) => Promise<void>;
|
|
13
|
+
disconnect: (ctx: ExtensionCommandContext, disabled?: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerIdeCommand(pi: ExtensionAPI, runtime: PiIdeRuntime, actions: IdeCommandActions): void {
|
|
17
|
+
pi.registerCommand("ide", {
|
|
18
|
+
description: "Manage IDE connection and editor selection context",
|
|
19
|
+
getArgumentCompletions: (argumentPrefix: string) => {
|
|
20
|
+
const subcommands = [
|
|
21
|
+
{ value: "status", label: "status", description: "Show IDE connection status" },
|
|
22
|
+
{ value: "list", label: "list", description: "List available IDE connections" },
|
|
23
|
+
{ value: "auto", label: "auto", description: "Auto-connect to the most recent IDE" },
|
|
24
|
+
{ value: "off", label: "off", description: "Disable IDE integration" },
|
|
25
|
+
{ value: "attach", label: "attach", description: "Attach latest IDE selection to the prompt" },
|
|
26
|
+
];
|
|
27
|
+
const filtered = subcommands.filter((s) => s.value.startsWith(argumentPrefix));
|
|
28
|
+
return filtered.length > 0 ? filtered : null;
|
|
29
|
+
},
|
|
30
|
+
handler: async (args, ctx) => {
|
|
31
|
+
runtime.ctx = ctx;
|
|
32
|
+
const [subcommand] = args.trim().split(/\s+/, 1);
|
|
33
|
+
switch (subcommand || "") {
|
|
34
|
+
case "":
|
|
35
|
+
await showPicker(runtime, actions, ctx);
|
|
36
|
+
return;
|
|
37
|
+
case "status":
|
|
38
|
+
showStatus(runtime, ctx);
|
|
39
|
+
return;
|
|
40
|
+
case "list":
|
|
41
|
+
await listCandidates(actions, ctx);
|
|
42
|
+
return;
|
|
43
|
+
case "auto":
|
|
44
|
+
runtime.enabled = true;
|
|
45
|
+
await actions.connectAuto(ctx);
|
|
46
|
+
return;
|
|
47
|
+
case "off":
|
|
48
|
+
actions.disconnect(ctx, true);
|
|
49
|
+
return;
|
|
50
|
+
case "attach":
|
|
51
|
+
attachLatest(runtime, ctx);
|
|
52
|
+
return;
|
|
53
|
+
default:
|
|
54
|
+
ctx.ui.notify("Usage: /ide [status|list|auto|off|attach]", "warning");
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function showPicker(
|
|
61
|
+
runtime: PiIdeRuntime,
|
|
62
|
+
actions: IdeCommandActions,
|
|
63
|
+
ctx: ExtensionCommandContext,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const candidates = await actions.refreshCandidates(ctx);
|
|
66
|
+
if (candidates.length === 0) {
|
|
67
|
+
ctx.ui.notify("No matching IDE connections found.", "warning");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const labels = candidates.map(
|
|
72
|
+
(candidate, index) => `${index + 1}. ${candidate.lock.name} — ${candidate.workspaceFolder}`,
|
|
73
|
+
);
|
|
74
|
+
labels.push("Disable IDE integration");
|
|
75
|
+
const choice = await ctx.ui.select("Select IDE connection", labels);
|
|
76
|
+
if (!choice) return;
|
|
77
|
+
if (choice === "Disable IDE integration") {
|
|
78
|
+
actions.disconnect(ctx, true);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const index = labels.indexOf(choice);
|
|
83
|
+
const candidate = candidates[index];
|
|
84
|
+
if (candidate) {
|
|
85
|
+
runtime.enabled = true;
|
|
86
|
+
await actions.connectCandidate(candidate, ctx);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function showStatus(runtime: PiIdeRuntime, ctx: ExtensionCommandContext): void {
|
|
91
|
+
const lines = buildWidget(runtime, ctx.cwd) ?? ["IDE: disconnected"];
|
|
92
|
+
ctx.ui.notify(lines.join("\n"), runtime.connectionStatus === "error" ? "error" : "info");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function listCandidates(actions: IdeCommandActions, ctx: ExtensionCommandContext): Promise<void> {
|
|
96
|
+
const candidates = await actions.refreshCandidates(ctx);
|
|
97
|
+
if (candidates.length === 0) {
|
|
98
|
+
ctx.ui.notify("No matching IDE lock files found.", "info");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
ctx.ui.notify(
|
|
102
|
+
candidates
|
|
103
|
+
.map(
|
|
104
|
+
(candidate, index) =>
|
|
105
|
+
`${index + 1}. ${candidate.lock.name} ${candidate.lock.host}:${candidate.lock.port}\n ${candidate.workspaceFolder}\n ${candidate.path}`,
|
|
106
|
+
)
|
|
107
|
+
.join("\n"),
|
|
108
|
+
"info",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function attachLatest(runtime: PiIdeRuntime, ctx: ExtensionCommandContext): void {
|
|
113
|
+
if (!runtime.latestSelection) {
|
|
114
|
+
ctx.ui.notify("No IDE selection is available.", "warning");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const mention = formatRangeMention(runtime.latestSelection, { cwd: ctx.cwd });
|
|
118
|
+
ctx.ui.pasteToEditor(mention);
|
|
119
|
+
runtime.attachState = "pending";
|
|
120
|
+
updateIdeUi(runtime, ctx);
|
|
121
|
+
ctx.ui.notify(`Attached ${mention}`, "info");
|
|
122
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import {
|
|
3
|
+
AUTH_HEADER,
|
|
4
|
+
PROTOCOL_VERSION,
|
|
5
|
+
type AtMentionedParams,
|
|
6
|
+
type EditorSelectionSnapshot,
|
|
7
|
+
type JsonRpcResponse,
|
|
8
|
+
type LockFileCandidate,
|
|
9
|
+
type SelectionChangedParams,
|
|
10
|
+
type SelectionClearedParams,
|
|
11
|
+
} from "../shared/protocol";
|
|
12
|
+
import { isAtMentionedParams, isSelectionChangedParams, isSelectionClearedParams } from "../shared/schema";
|
|
13
|
+
import { decodeRawData } from "../shared/ws";
|
|
14
|
+
|
|
15
|
+
export interface IdeConnectionCallbacks {
|
|
16
|
+
onConnected?: (server: { name: string; version?: string; ide?: string }) => void;
|
|
17
|
+
onDisconnected?: (reason: string) => void;
|
|
18
|
+
onSelectionChanged?: (snapshot: EditorSelectionSnapshot) => void;
|
|
19
|
+
onSelectionCleared?: (params: SelectionClearedParams) => void;
|
|
20
|
+
onAtMentioned?: (params: AtMentionedParams) => void;
|
|
21
|
+
onError?: (error: Error) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class IdeConnection {
|
|
25
|
+
private socket?: WebSocket;
|
|
26
|
+
private nextId = 1;
|
|
27
|
+
private closedByUser = false;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
readonly candidate: LockFileCandidate,
|
|
31
|
+
private readonly cwd: string,
|
|
32
|
+
private readonly callbacks: IdeConnectionCallbacks,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
get isOpen(): boolean {
|
|
36
|
+
return this.socket?.readyState === WebSocket.OPEN;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async connect(): Promise<void> {
|
|
40
|
+
this.closedByUser = false;
|
|
41
|
+
const { lock } = this.candidate;
|
|
42
|
+
const socket = new WebSocket(`ws://${lock.host}:${lock.port}`, {
|
|
43
|
+
headers: {
|
|
44
|
+
[AUTH_HEADER]: lock.authToken,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
this.socket = socket;
|
|
48
|
+
|
|
49
|
+
socket.on("message", (raw) => this.handleMessage(decodeRawData(raw)));
|
|
50
|
+
socket.on("close", (_code, reason) => {
|
|
51
|
+
if (this.socket === socket) this.socket = undefined;
|
|
52
|
+
this.callbacks.onDisconnected?.(reason.toString("utf8") || (this.closedByUser ? "closed" : "disconnected"));
|
|
53
|
+
});
|
|
54
|
+
socket.on("error", (error) => this.callbacks.onError?.(error));
|
|
55
|
+
|
|
56
|
+
await new Promise<void>((resolve, reject) => {
|
|
57
|
+
const onOpen = () => {
|
|
58
|
+
cleanup();
|
|
59
|
+
this.sendInitialize();
|
|
60
|
+
resolve();
|
|
61
|
+
};
|
|
62
|
+
const onError = (error: Error) => {
|
|
63
|
+
cleanup();
|
|
64
|
+
reject(error);
|
|
65
|
+
};
|
|
66
|
+
const cleanup = () => {
|
|
67
|
+
socket.off("open", onOpen);
|
|
68
|
+
socket.off("error", onError);
|
|
69
|
+
};
|
|
70
|
+
socket.once("open", onOpen);
|
|
71
|
+
socket.once("error", onError);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
disconnect(): void {
|
|
76
|
+
this.closedByUser = true;
|
|
77
|
+
this.socket?.close();
|
|
78
|
+
this.socket = undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private sendInitialize(): void {
|
|
82
|
+
this.socket?.send(
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
jsonrpc: "2.0",
|
|
85
|
+
id: this.nextId++,
|
|
86
|
+
method: "initialize",
|
|
87
|
+
params: {
|
|
88
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
89
|
+
client: { name: "pi-x-ide", version: "0.1.0" },
|
|
90
|
+
cwd: this.cwd,
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private handleMessage(text: string): void {
|
|
97
|
+
let parsed: unknown;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(text) as unknown;
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isRpcResponse(parsed)) {
|
|
105
|
+
const server = getServerInfo(parsed);
|
|
106
|
+
if (server) this.callbacks.onConnected?.(server);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!isNotification(parsed)) return;
|
|
111
|
+
if (parsed.method === "selection_changed" && isSelectionChangedParams(parsed.params)) {
|
|
112
|
+
this.callbacks.onSelectionChanged?.(withReceivedAt(parsed.params));
|
|
113
|
+
} else if (parsed.method === "selection_cleared" && isSelectionClearedParams(parsed.params)) {
|
|
114
|
+
this.callbacks.onSelectionCleared?.(withReceivedAt(parsed.params));
|
|
115
|
+
} else if (parsed.method === "at_mentioned" && isAtMentionedParams(parsed.params)) {
|
|
116
|
+
const params = withReceivedAt(parsed.params);
|
|
117
|
+
this.callbacks.onAtMentioned?.(params);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function withReceivedAt<T extends SelectionChangedParams | SelectionClearedParams>(params: T): T {
|
|
123
|
+
return { ...params, receivedAt: params.receivedAt ?? Date.now() };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isNotification(value: unknown): value is { jsonrpc: "2.0"; method: string; params?: unknown } {
|
|
127
|
+
return (
|
|
128
|
+
typeof value === "object" &&
|
|
129
|
+
value !== null &&
|
|
130
|
+
(value as { jsonrpc?: unknown }).jsonrpc === "2.0" &&
|
|
131
|
+
typeof (value as { method?: unknown }).method === "string"
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isRpcResponse(value: unknown): value is JsonRpcResponse {
|
|
136
|
+
return (
|
|
137
|
+
typeof value === "object" && value !== null && (value as { jsonrpc?: unknown }).jsonrpc === "2.0" && "id" in value
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getServerInfo(response: JsonRpcResponse): { name: string; version?: string; ide?: string } | undefined {
|
|
142
|
+
const result = response.result;
|
|
143
|
+
if (!result || typeof result !== "object") return undefined;
|
|
144
|
+
const server = (result as { server?: unknown }).server;
|
|
145
|
+
if (!server || typeof server !== "object") return undefined;
|
|
146
|
+
const name = (server as { name?: unknown }).name;
|
|
147
|
+
if (typeof name !== "string") return undefined;
|
|
148
|
+
const version = (server as { version?: unknown }).version;
|
|
149
|
+
const ide = (server as { ide?: unknown }).ide;
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
version: typeof version === "string" ? version : undefined,
|
|
153
|
+
ide: typeof ide === "string" ? ide : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent" with {
|
|
2
|
+
"resolution-mode": "import",
|
|
3
|
+
};
|
|
4
|
+
import { formatEditorContext, snapshotKey } from "../shared/format";
|
|
5
|
+
import type { EditorSelectionSnapshot } from "../shared/protocol";
|
|
6
|
+
import type { PiIdeRuntime } from "./state";
|
|
7
|
+
import { updateIdeUi } from "./ui";
|
|
8
|
+
|
|
9
|
+
const CONTEXT_MARKER = "pi-x-ide/editor-context";
|
|
10
|
+
|
|
11
|
+
export function registerContextHandlers(pi: ExtensionAPI, runtime: PiIdeRuntime): void {
|
|
12
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
13
|
+
runtime.ctx = ctx;
|
|
14
|
+
if (!runtime.enabled) return;
|
|
15
|
+
if (!runtime.latestSelection) return;
|
|
16
|
+
if (runtime.attachState !== "pending") return;
|
|
17
|
+
|
|
18
|
+
runtime.turnSelection = runtime.latestSelection;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Merge the active editor context into the submitted user prompt rather than
|
|
22
|
+
// adding a separate extension message.
|
|
23
|
+
pi.on("message_end", (event, ctx) => {
|
|
24
|
+
runtime.ctx = ctx;
|
|
25
|
+
if (!runtime.enabled || !runtime.turnSelection) return;
|
|
26
|
+
if (event.message.role !== "user") return;
|
|
27
|
+
if (messageContainsMarker(event.message)) return;
|
|
28
|
+
|
|
29
|
+
const text = `${formatEditorContext(runtime.turnSelection, { cwd: ctx.cwd })}\n<!-- ${CONTEXT_MARKER} -->\n`;
|
|
30
|
+
const message = mergeIntoUserMessage(event.message, text);
|
|
31
|
+
runtime.attachState = "sent";
|
|
32
|
+
runtime.turnSelection = undefined;
|
|
33
|
+
updateIdeUi(runtime, ctx);
|
|
34
|
+
return { message };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function setLatestSelection(
|
|
39
|
+
runtime: PiIdeRuntime,
|
|
40
|
+
snapshot: EditorSelectionSnapshot,
|
|
41
|
+
ctx?: ExtensionContext,
|
|
42
|
+
): void {
|
|
43
|
+
const key = snapshotKey(snapshot);
|
|
44
|
+
runtime.latestSelection = snapshot;
|
|
45
|
+
if (runtime.latestSelectionKey !== key) {
|
|
46
|
+
runtime.latestSelectionKey = key;
|
|
47
|
+
runtime.attachState = "pending";
|
|
48
|
+
}
|
|
49
|
+
updateIdeUi(runtime, ctx);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearLatestSelection(runtime: PiIdeRuntime, ctx?: ExtensionContext): void {
|
|
53
|
+
runtime.latestSelection = undefined;
|
|
54
|
+
runtime.latestSelectionKey = undefined;
|
|
55
|
+
runtime.turnSelection = undefined;
|
|
56
|
+
runtime.attachState = "idle";
|
|
57
|
+
updateIdeUi(runtime, ctx);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type UserContentBlock =
|
|
61
|
+
| { type: "text"; text: string; textSignature?: string }
|
|
62
|
+
| { type: "image"; data: string; mimeType: string };
|
|
63
|
+
|
|
64
|
+
type MergeableUserMessage = {
|
|
65
|
+
role: "user";
|
|
66
|
+
content: string | UserContentBlock[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function mergeIntoUserMessage<T extends MergeableUserMessage>(message: T, text: string): T {
|
|
70
|
+
return {
|
|
71
|
+
...message,
|
|
72
|
+
content: [{ type: "text", text }, ...normalizeUserContent(message.content)],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeUserContent(content: MergeableUserMessage["content"]): UserContentBlock[] {
|
|
77
|
+
return typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function messageContainsMarker(message: MergeableUserMessage): boolean {
|
|
81
|
+
return JSON.stringify(message).includes(CONTEXT_MARKER);
|
|
82
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { LOCK_FILE_EXTENSION, type IdeLockFile, type LockFileCandidate } from "../shared/protocol";
|
|
4
|
+
import { relationshipMatchLength, resolveLockDir } from "../shared/paths";
|
|
5
|
+
import { parseLockFileContent } from "../shared/schema";
|
|
6
|
+
|
|
7
|
+
export interface DiscoverOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
lockDir?: string;
|
|
10
|
+
now?: number;
|
|
11
|
+
maxAgeMs?: number;
|
|
12
|
+
checkPid?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isProcessAlive(pid: number): boolean {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function bestWorkspaceMatch(
|
|
25
|
+
lock: IdeLockFile,
|
|
26
|
+
cwd: string,
|
|
27
|
+
): { matchLength: number; workspaceFolder: string } | undefined {
|
|
28
|
+
let best: { matchLength: number; workspaceFolder: string } | undefined;
|
|
29
|
+
for (const workspaceFolder of lock.workspaceFolders) {
|
|
30
|
+
const matchLength = relationshipMatchLength(workspaceFolder, cwd);
|
|
31
|
+
if (matchLength <= 0) continue;
|
|
32
|
+
if (!best || matchLength > best.matchLength) {
|
|
33
|
+
best = { matchLength, workspaceFolder };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return best;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sortCandidates(candidates: LockFileCandidate[]): LockFileCandidate[] {
|
|
40
|
+
return [...candidates].sort(
|
|
41
|
+
(a, b) => b.matchLength - a.matchLength || b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function discoverIdeCandidates(options: DiscoverOptions): Promise<LockFileCandidate[]> {
|
|
46
|
+
const lockDir = options.lockDir ?? resolveLockDir();
|
|
47
|
+
const now = options.now ?? Date.now();
|
|
48
|
+
const maxAgeMs = options.maxAgeMs ?? 24 * 60 * 60 * 1000;
|
|
49
|
+
const checkPid = options.checkPid ?? true;
|
|
50
|
+
|
|
51
|
+
let entries: string[];
|
|
52
|
+
try {
|
|
53
|
+
entries = await readdir(lockDir);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const candidates: LockFileCandidate[] = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (!entry.endsWith(LOCK_FILE_EXTENSION)) continue;
|
|
61
|
+
const path = join(lockDir, entry);
|
|
62
|
+
let content: string;
|
|
63
|
+
let mtimeMs: number;
|
|
64
|
+
try {
|
|
65
|
+
const [fileContent, fileStat] = await Promise.all([readFile(path, "utf8"), stat(path)]);
|
|
66
|
+
content = fileContent;
|
|
67
|
+
mtimeMs = fileStat.mtimeMs;
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (now - mtimeMs > maxAgeMs) continue;
|
|
73
|
+
const lock = parseLockFileContent(content);
|
|
74
|
+
if (!lock) continue;
|
|
75
|
+
if (checkPid && typeof lock.pid === "number" && !isProcessAlive(lock.pid)) continue;
|
|
76
|
+
|
|
77
|
+
const match = bestWorkspaceMatch(lock, options.cwd);
|
|
78
|
+
if (!match) continue;
|
|
79
|
+
|
|
80
|
+
candidates.push({ path, lock, mtimeMs, ...match });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return sortCandidates(candidates);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function resolveBestIdeCandidate(options: DiscoverOptions): Promise<LockFileCandidate | undefined> {
|
|
87
|
+
return (await discoverIdeCandidates(options))[0];
|
|
88
|
+
}
|
package/src/pi/index.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent" with {
|
|
2
|
+
"resolution-mode": "import",
|
|
3
|
+
};
|
|
4
|
+
import { formatRangeMention } from "../shared/format";
|
|
5
|
+
import type { AtMentionedParams, LockFileCandidate } from "../shared/protocol";
|
|
6
|
+
import { discoverIdeCandidates } from "./discovery";
|
|
7
|
+
import { IdeConnection } from "./connection";
|
|
8
|
+
import { registerIdeCommand } from "./commands";
|
|
9
|
+
import { clearLatestSelection, registerContextHandlers, setLatestSelection } from "./context";
|
|
10
|
+
import { createRuntime, type PiIdeRuntime } from "./state";
|
|
11
|
+
import { clearIdeUi, updateIdeUi } from "./ui";
|
|
12
|
+
|
|
13
|
+
const RECONNECT_DELAY_MS = 2_000;
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI): void {
|
|
16
|
+
const runtime = createRuntime();
|
|
17
|
+
|
|
18
|
+
registerContextHandlers(pi, runtime);
|
|
19
|
+
registerIdeCommand(pi, runtime, {
|
|
20
|
+
refreshCandidates: (ctx) => refreshCandidates(runtime, ctx),
|
|
21
|
+
connectAuto: (ctx) => connectAuto(runtime, ctx),
|
|
22
|
+
connectCandidate: (candidate, ctx) => connectCandidate(runtime, candidate, ctx),
|
|
23
|
+
disconnect: (ctx, disabled) => disconnect(runtime, ctx, disabled),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
27
|
+
runtime.ctx = ctx;
|
|
28
|
+
runtime.cwd = ctx.cwd;
|
|
29
|
+
if (!runtime.enabled) {
|
|
30
|
+
runtime.connectionStatus = "disabled";
|
|
31
|
+
updateIdeUi(runtime, ctx);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await connectAuto(runtime, ctx);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
38
|
+
runtime.ctx = ctx;
|
|
39
|
+
if (runtime.reconnectTimer) clearTimeout(runtime.reconnectTimer);
|
|
40
|
+
runtime.reconnectTimer = undefined;
|
|
41
|
+
runtime.connection?.disconnect();
|
|
42
|
+
runtime.connection = undefined;
|
|
43
|
+
clearIdeUi(runtime, ctx);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function refreshCandidates(
|
|
48
|
+
runtime: PiIdeRuntime,
|
|
49
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
50
|
+
): Promise<LockFileCandidate[]> {
|
|
51
|
+
runtime.ctx = ctx;
|
|
52
|
+
runtime.cwd = ctx.cwd;
|
|
53
|
+
runtime.candidates = await discoverIdeCandidates({ cwd: ctx.cwd });
|
|
54
|
+
return runtime.candidates;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function connectAuto(runtime: PiIdeRuntime, ctx: ExtensionContext | ExtensionCommandContext): Promise<void> {
|
|
58
|
+
runtime.enabled = true;
|
|
59
|
+
const candidates = await refreshCandidates(runtime, ctx);
|
|
60
|
+
const candidate = candidates[0];
|
|
61
|
+
if (!candidate) {
|
|
62
|
+
runtime.connectionStatus = "disconnected";
|
|
63
|
+
runtime.connectionMessage = "No matching IDE lock files found.";
|
|
64
|
+
runtime.currentCandidate = undefined;
|
|
65
|
+
runtime.connectedServer = undefined;
|
|
66
|
+
updateIdeUi(runtime, ctx);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await connectCandidate(runtime, candidate, ctx);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function connectCandidate(
|
|
73
|
+
runtime: PiIdeRuntime,
|
|
74
|
+
candidate: LockFileCandidate,
|
|
75
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
runtime.ctx = ctx;
|
|
78
|
+
runtime.cwd = ctx.cwd;
|
|
79
|
+
runtime.enabled = true;
|
|
80
|
+
if (runtime.reconnectTimer) clearTimeout(runtime.reconnectTimer);
|
|
81
|
+
runtime.reconnectTimer = undefined;
|
|
82
|
+
|
|
83
|
+
const previous = runtime.connection;
|
|
84
|
+
runtime.connection = undefined;
|
|
85
|
+
previous?.disconnect();
|
|
86
|
+
|
|
87
|
+
runtime.currentCandidate = candidate;
|
|
88
|
+
runtime.connectedServer = undefined;
|
|
89
|
+
runtime.connectionStatus = "connecting";
|
|
90
|
+
runtime.connectionMessage = `Connecting to ${candidate.lock.name} at ${candidate.lock.host}:${candidate.lock.port}`;
|
|
91
|
+
updateIdeUi(runtime, ctx);
|
|
92
|
+
|
|
93
|
+
const connection = new IdeConnection(candidate, ctx.cwd, {
|
|
94
|
+
onConnected: (server) => {
|
|
95
|
+
if (runtime.connection !== connection) return;
|
|
96
|
+
runtime.connectedServer = server;
|
|
97
|
+
runtime.connectionStatus = "connected";
|
|
98
|
+
runtime.connectionMessage = undefined;
|
|
99
|
+
updateIdeUi(runtime);
|
|
100
|
+
},
|
|
101
|
+
onDisconnected: (reason) => {
|
|
102
|
+
if (runtime.connection !== connection) return;
|
|
103
|
+
runtime.connection = undefined;
|
|
104
|
+
runtime.connectedServer = undefined;
|
|
105
|
+
runtime.connectionStatus = runtime.enabled ? "disconnected" : "disabled";
|
|
106
|
+
runtime.connectionMessage = reason;
|
|
107
|
+
updateIdeUi(runtime);
|
|
108
|
+
if (runtime.enabled) scheduleReconnect(runtime);
|
|
109
|
+
},
|
|
110
|
+
onSelectionChanged: (snapshot) => {
|
|
111
|
+
if (runtime.connection !== connection) return;
|
|
112
|
+
setLatestSelection(runtime, snapshot);
|
|
113
|
+
},
|
|
114
|
+
onSelectionCleared: () => {
|
|
115
|
+
if (runtime.connection !== connection) return;
|
|
116
|
+
clearLatestSelection(runtime);
|
|
117
|
+
},
|
|
118
|
+
onAtMentioned: (params) => {
|
|
119
|
+
if (runtime.connection !== connection) return;
|
|
120
|
+
handleAtMentioned(runtime, params);
|
|
121
|
+
},
|
|
122
|
+
onError: (error) => {
|
|
123
|
+
if (runtime.connection !== connection) return;
|
|
124
|
+
runtime.connectionStatus = "error";
|
|
125
|
+
runtime.connectionMessage = error.message;
|
|
126
|
+
updateIdeUi(runtime);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
runtime.connection = connection;
|
|
131
|
+
try {
|
|
132
|
+
await connection.connect();
|
|
133
|
+
if (runtime.connection === connection && runtime.connectionStatus === "connecting") {
|
|
134
|
+
runtime.connectionStatus = "connected";
|
|
135
|
+
runtime.connectionMessage = undefined;
|
|
136
|
+
updateIdeUi(runtime, ctx);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (runtime.connection === connection) {
|
|
140
|
+
runtime.connection = undefined;
|
|
141
|
+
runtime.connectionStatus = "error";
|
|
142
|
+
runtime.connectionMessage = error instanceof Error ? error.message : String(error);
|
|
143
|
+
updateIdeUi(runtime, ctx);
|
|
144
|
+
scheduleReconnect(runtime);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function disconnect(runtime: PiIdeRuntime, ctx: ExtensionContext | ExtensionCommandContext, disabled = false): void {
|
|
150
|
+
runtime.ctx = ctx;
|
|
151
|
+
if (runtime.reconnectTimer) clearTimeout(runtime.reconnectTimer);
|
|
152
|
+
runtime.reconnectTimer = undefined;
|
|
153
|
+
const connection = runtime.connection;
|
|
154
|
+
runtime.connection = undefined;
|
|
155
|
+
connection?.disconnect();
|
|
156
|
+
runtime.enabled = !disabled;
|
|
157
|
+
runtime.connectedServer = undefined;
|
|
158
|
+
runtime.connectionStatus = disabled ? "disabled" : "disconnected";
|
|
159
|
+
runtime.connectionMessage = disabled ? "IDE integration disabled." : "Disconnected.";
|
|
160
|
+
if (disabled) {
|
|
161
|
+
runtime.latestSelection = undefined;
|
|
162
|
+
runtime.latestSelectionKey = undefined;
|
|
163
|
+
runtime.turnSelection = undefined;
|
|
164
|
+
runtime.attachState = "idle";
|
|
165
|
+
}
|
|
166
|
+
updateIdeUi(runtime, ctx);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scheduleReconnect(runtime: PiIdeRuntime): void {
|
|
170
|
+
if (runtime.reconnectTimer || !runtime.enabled) return;
|
|
171
|
+
runtime.reconnectTimer = setTimeout(() => {
|
|
172
|
+
runtime.reconnectTimer = undefined;
|
|
173
|
+
const ctx = runtime.ctx;
|
|
174
|
+
if (!ctx || !runtime.enabled) return;
|
|
175
|
+
connectAuto(runtime, ctx).catch((error: unknown) => {
|
|
176
|
+
runtime.connectionStatus = "error";
|
|
177
|
+
runtime.connectionMessage = error instanceof Error ? error.message : String(error);
|
|
178
|
+
updateIdeUi(runtime);
|
|
179
|
+
});
|
|
180
|
+
}, RECONNECT_DELAY_MS);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function handleAtMentioned(runtime: PiIdeRuntime, params: AtMentionedParams): void {
|
|
184
|
+
setLatestSelection(runtime, params);
|
|
185
|
+
const ctx = runtime.ctx;
|
|
186
|
+
if (!ctx?.hasUI) return;
|
|
187
|
+
const text = params.rangeText || formatRangeMention(params, { cwd: ctx.cwd });
|
|
188
|
+
ctx.ui.pasteToEditor(text);
|
|
189
|
+
ctx.ui.notify(`Attached ${text}`, "info");
|
|
190
|
+
}
|
package/src/pi/state.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent" with { "resolution-mode": "import" };
|
|
2
|
+
import type { AttachState, EditorSelectionSnapshot, LockFileCandidate } from "../shared/protocol";
|
|
3
|
+
import type { IdeConnection } from "./connection";
|
|
4
|
+
|
|
5
|
+
export interface PiIdeRuntime {
|
|
6
|
+
ctx?: ExtensionContext;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
connection?: IdeConnection;
|
|
10
|
+
currentCandidate?: LockFileCandidate;
|
|
11
|
+
candidates: LockFileCandidate[];
|
|
12
|
+
connectedServer?: { name: string; version?: string; ide?: string };
|
|
13
|
+
connectionStatus: "idle" | "connecting" | "connected" | "disconnected" | "error" | "disabled";
|
|
14
|
+
connectionMessage?: string;
|
|
15
|
+
latestSelection?: EditorSelectionSnapshot;
|
|
16
|
+
latestSelectionKey?: string;
|
|
17
|
+
attachState: AttachState;
|
|
18
|
+
turnSelection?: EditorSelectionSnapshot;
|
|
19
|
+
reconnectTimer?: NodeJS.Timeout;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createRuntime(): PiIdeRuntime {
|
|
23
|
+
return {
|
|
24
|
+
enabled: true,
|
|
25
|
+
candidates: [],
|
|
26
|
+
connectionStatus: "idle",
|
|
27
|
+
attachState: "idle",
|
|
28
|
+
};
|
|
29
|
+
}
|