pi-gortex 0.3.0 → 0.4.1
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/README.md +15 -12
- package/docs/architecture.md +49 -12
- package/docs/tests.md +4 -4
- package/package.json +3 -3
- package/src/config.ts +73 -22
- package/src/diff.ts +404 -0
- package/src/hook.ts +4 -3
- package/src/index.ts +127 -52
- package/src/mcp-client.ts +159 -80
- package/src/native.ts +1 -1
- package/src/render.ts +65 -8
- package/src/runtime.ts +3 -0
- package/src/state.ts +7 -0
- package/src/tools.ts +48 -8
package/src/render.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// TUI rendering for bridged tools: a call line read off Gortex's argument
|
|
2
2
|
// shape (operation, target, options), Pi's own read renderer for a file read,
|
|
3
|
-
// and a result collapsed until the user expands it.
|
|
3
|
+
// an edit's diff, and a result collapsed until the user expands it.
|
|
4
4
|
|
|
5
|
-
import { createReadToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { createReadToolDefinition, renderDiff } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Theme, ToolDefinition, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
8
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
9
|
|
|
10
|
+
import type { EditDetails } from "./diff.ts";
|
|
10
11
|
import { textFromResult } from "./mcp-client.ts";
|
|
12
|
+
import { isNativeGortexTool } from "./native.ts";
|
|
11
13
|
|
|
12
14
|
type ToolRenderContext = Parameters<NonNullable<ToolDefinition["renderCall"]>>[2];
|
|
13
15
|
|
|
14
16
|
export type RenderCall = (args: Record<string, unknown>, theme: Theme, context: ToolRenderContext) => Component;
|
|
15
|
-
export type RenderResult = (
|
|
17
|
+
export type RenderResult = (
|
|
18
|
+
result: unknown,
|
|
19
|
+
options: { expanded?: boolean },
|
|
20
|
+
theme?: Theme,
|
|
21
|
+
context?: ToolRenderContext,
|
|
22
|
+
) => Component;
|
|
16
23
|
|
|
17
24
|
const OPERATION_ARGS = ["operation", "kind"];
|
|
18
25
|
const SUBJECT_ARGS = ["target", "query", "question", "task", "path"];
|
|
@@ -144,6 +151,56 @@ export function resultText(result: unknown): string {
|
|
|
144
151
|
return textFromResult(result) || JSON.stringify(structured ?? result ?? {});
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
/** diffView draws each file an edit changed with Pi's diff renderer, or nothing when the result has none. */
|
|
155
|
+
function diffView(result: unknown, theme: Theme | undefined, context: ToolRenderContext | undefined): Container | undefined {
|
|
156
|
+
const diffs = (result as { details?: EditDetails })?.details?.diffs;
|
|
157
|
+
if (!diffs?.length || !theme || context?.isError) return undefined;
|
|
158
|
+
const view = new Container();
|
|
159
|
+
for (const { path, diff, note } of diffs) {
|
|
160
|
+
view.addChild(new Spacer(1));
|
|
161
|
+
if (note) {
|
|
162
|
+
view.addChild(new Text(`${theme.fg("accent", path)} ${theme.fg("muted", note)}`, 1, 0));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (diffs.length > 1) view.addChild(new Text(theme.fg("accent", path), 1, 0));
|
|
166
|
+
view.addChild(new Text(renderDiff(diff, { filePath: path }), 1, 0));
|
|
167
|
+
}
|
|
168
|
+
return view;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* renderResult shows the diff of every file an edit changed, the way Pi's own
|
|
173
|
+
* edit does. Gortex's text reply stays collapsed until the user expands it
|
|
174
|
+
* with ctrl+o.
|
|
175
|
+
*/
|
|
176
|
+
export const renderResult: RenderResult = (result, options, theme, context) => {
|
|
177
|
+
const text = new Text(options?.expanded ? resultText(result) : "", 0, 0);
|
|
178
|
+
const view = diffView(result, theme, context);
|
|
179
|
+
if (!view) return text;
|
|
180
|
+
if (options?.expanded) view.addChild(text);
|
|
181
|
+
return view;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* nativeRenderers draws the diffs watchNativeEdits stores above Pi's own MCP
|
|
186
|
+
* result, which shows once expanded or on an error. Every other tool keeps its
|
|
187
|
+
* renderers.
|
|
188
|
+
*/
|
|
189
|
+
export function nativeRenderers(toolName: string, next: () => ToolRenderers | undefined): ToolRenderers | undefined {
|
|
190
|
+
const base = next();
|
|
191
|
+
// Without a base, Pi's MCP extension fills in its own renderers.
|
|
192
|
+
if (!base || !isNativeGortexTool(toolName)) return base;
|
|
193
|
+
const baseResult = base.renderResult;
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
renderResult(result, options, theme, context) {
|
|
197
|
+
// Pi's renderer reuses lastComponent as its own and clears it.
|
|
198
|
+
const own = () => baseResult?.(result, options, theme, { ...context, lastComponent: undefined });
|
|
199
|
+
const rest = options.expanded || context.isError ? own() : undefined;
|
|
200
|
+
const view = diffView(result, theme, context);
|
|
201
|
+
if (!view) return rest ?? new Text("", 0, 0);
|
|
202
|
+
if (rest) view.addChild(rest);
|
|
203
|
+
return view;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -32,7 +32,9 @@ export interface BridgeChild {
|
|
|
32
32
|
export interface SpawnOpts {
|
|
33
33
|
stdio?: "ignore" | ("pipe" | "ignore")[];
|
|
34
34
|
detached?: boolean;
|
|
35
|
+
cwd?: string;
|
|
35
36
|
env?: Record<string, string | undefined>;
|
|
37
|
+
windowsHide?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export interface ExecFileSyncOpts {
|
|
@@ -40,6 +42,7 @@ export interface ExecFileSyncOpts {
|
|
|
40
42
|
encoding?: "utf8";
|
|
41
43
|
maxBuffer?: number;
|
|
42
44
|
timeout?: number;
|
|
45
|
+
windowsHide?: boolean;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export interface ProcessDeps {
|
package/src/state.ts
CHANGED
|
@@ -33,6 +33,13 @@ let versionWarning = "";
|
|
|
33
33
|
*/
|
|
34
34
|
export const gortexToolNames = new Set<string>();
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Registered names the daemon has since dropped from its list. Pi cannot
|
|
38
|
+
* unregister a tool, so these are switched off in the active set instead, and
|
|
39
|
+
* back on if the daemon lists them again.
|
|
40
|
+
*/
|
|
41
|
+
export const withdrawnToolNames = new Set<string>();
|
|
42
|
+
|
|
36
43
|
export function getClient(): MCPStdioClient | null {
|
|
37
44
|
return client;
|
|
38
45
|
}
|
package/src/tools.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// Tool registration: each tool the bridge lists becomes a native Pi tool whose
|
|
2
2
|
// execute() forwards to tools/call.
|
|
3
3
|
|
|
4
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { candidatePaths, editDetails, isFileEdit, withFileDiffs } from "./diff.ts";
|
|
7
|
+
import type { FileDiff } from "./diff.ts";
|
|
5
8
|
|
|
6
9
|
import type { ToolDescriptor } from "./mcp-client.ts";
|
|
7
10
|
import { callRenderer, readCallRenderer, renderResult, resultText } from "./render.ts";
|
|
8
11
|
import type { RenderCall, RenderResult } from "./render.ts";
|
|
9
|
-
import { getClient, gortexToolNames } from "./state.ts";
|
|
12
|
+
import { getClient, gortexToolNames, withdrawnToolNames } from "./state.ts";
|
|
10
13
|
import { toolNameMap } from "./hook.ts";
|
|
11
14
|
|
|
12
15
|
// Pi's reserved built-in names (mirrors toolNameMap's keys). A bridge tool that
|
|
@@ -44,7 +47,13 @@ interface BridgeToolDefinition {
|
|
|
44
47
|
label: string;
|
|
45
48
|
description: string;
|
|
46
49
|
parameters: unknown;
|
|
47
|
-
execute(
|
|
50
|
+
execute(
|
|
51
|
+
toolCallId: string,
|
|
52
|
+
params: Record<string, unknown>,
|
|
53
|
+
signal: AbortSignal | undefined,
|
|
54
|
+
onUpdate: unknown,
|
|
55
|
+
ctx: ExtensionContext | undefined,
|
|
56
|
+
): Promise<unknown>;
|
|
48
57
|
renderCall: RenderCall;
|
|
49
58
|
renderResult: RenderResult;
|
|
50
59
|
}
|
|
@@ -86,12 +95,20 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
86
95
|
label: name,
|
|
87
96
|
description: piAliasedDescription(name, piAliasName(name), (desc.description || name).trim()),
|
|
88
97
|
parameters,
|
|
89
|
-
async execute(_id
|
|
98
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
90
99
|
const client = getClient();
|
|
91
100
|
if (!client) throw new Error(`gortex ${name}: MCP bridge is not connected`);
|
|
101
|
+
const args = params ?? {};
|
|
102
|
+
const callTool = () => client.callTool(name, args, signal);
|
|
92
103
|
let result: unknown;
|
|
104
|
+
let diffs: FileDiff[] = [];
|
|
93
105
|
try {
|
|
94
|
-
|
|
106
|
+
if (ctx && isFileEdit(name, args)) {
|
|
107
|
+
const paths = candidatePaths(args, ctx.cwd);
|
|
108
|
+
({ result, diffs } = await withFileDiffs(paths, ctx.cwd, callTool, (r) => !(r as { isError?: boolean })?.isError));
|
|
109
|
+
} else {
|
|
110
|
+
result = await callTool();
|
|
111
|
+
}
|
|
95
112
|
} catch (err) {
|
|
96
113
|
throw new Error(`gortex ${name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
114
|
}
|
|
@@ -103,7 +120,7 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
103
120
|
}
|
|
104
121
|
const text = resultText(result);
|
|
105
122
|
if ((result as { isError?: boolean })?.isError) throw new Error(text || `gortex ${name} failed`);
|
|
106
|
-
return { content: [{ type: "text", text }], details:
|
|
123
|
+
return { content: [{ type: "text", text }], details: editDetails(diffs) };
|
|
107
124
|
},
|
|
108
125
|
renderCall: (name === "read" ? readCallRenderer : callRenderer)(piAliasName(name)),
|
|
109
126
|
renderResult,
|
|
@@ -115,13 +132,36 @@ export function registerOneTool(pi: ExtensionAPI, desc: ToolDescriptor): void {
|
|
|
115
132
|
export async function registerGortexTools(pi: ExtensionAPI): Promise<void> {
|
|
116
133
|
const client = getClient();
|
|
117
134
|
if (!client) return;
|
|
135
|
+
const listed = new Set<string>();
|
|
118
136
|
for (const desc of await client.listTools()) {
|
|
119
|
-
if (!desc) continue;
|
|
137
|
+
if (!desc?.name) continue;
|
|
120
138
|
registerOneTool(pi, desc);
|
|
139
|
+
listed.add(piAliasName(desc.name));
|
|
140
|
+
}
|
|
141
|
+
matchActiveTools(pi, listed);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Keeps the tools offered to the model in step with the daemon's list. */
|
|
145
|
+
function matchActiveTools(pi: ExtensionAPI, listed: Set<string>): void {
|
|
146
|
+
const dropped = [...gortexToolNames].filter((name) => !listed.has(name));
|
|
147
|
+
const relisted = [...withdrawnToolNames].filter((name) => listed.has(name));
|
|
148
|
+
if (dropped.length === 0 && relisted.length === 0) return;
|
|
149
|
+
for (const name of dropped) {
|
|
150
|
+
gortexToolNames.delete(name);
|
|
151
|
+
withdrawnToolNames.add(name);
|
|
152
|
+
}
|
|
153
|
+
for (const name of relisted) withdrawnToolNames.delete(name);
|
|
154
|
+
try {
|
|
155
|
+
const active = new Set(pi.getActiveTools());
|
|
156
|
+
for (const name of dropped) active.delete(name);
|
|
157
|
+
for (const name of relisted) active.add(name);
|
|
158
|
+
pi.setActiveTools([...active]);
|
|
159
|
+
} catch {
|
|
160
|
+
// an invalidated instance keeps its last tool set.
|
|
121
161
|
}
|
|
122
162
|
}
|
|
123
163
|
|
|
124
|
-
/** Re-fetches the tool list after a list_changed and
|
|
164
|
+
/** Re-fetches the tool list after a list_changed and applies the difference. */
|
|
125
165
|
export async function syncTools(pi: ExtensionAPI): Promise<void> {
|
|
126
166
|
try {
|
|
127
167
|
await registerGortexTools(pi);
|