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/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 = (result: unknown, options: { expanded?: boolean }) => Component;
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
- /** renderResult collapses the result to nothing until the user expands it with ctrl+o. */
148
- export const renderResult: RenderResult = (result, options) =>
149
- new Text(options?.expanded ? resultText(result) : "", 0, 0);
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(toolCallId: string, params: Record<string, unknown>): Promise<unknown>;
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: string, params: Record<string, unknown>) {
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
- result = await client.callTool(name, params ?? {});
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 registers anything new. */
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);