@nmzpy/pi-ember-stack 0.1.6 → 0.2.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/package.json +12 -2
- package/plugins/devin-auth/extensions/index.ts +45 -7
- package/plugins/devin-auth/src/models.ts +42 -196
- package/plugins/index.ts +29 -6
- package/plugins/pi-compact-tools/index.ts +16 -191
- package/plugins/pi-compact-tools/renderer.ts +419 -0
- package/plugins/pi-custom-agents/index.ts +489 -343
- package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
- package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +2 -2
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +31 -5
- package/plugins/pi-ember-fff/index.ts +717 -0
- package/plugins/pi-ember-fff/query.ts +87 -0
- package/plugins/pi-ember-fff/test/query.test.ts +66 -0
- package/plugins/pi-ember-fff/test/renderer.test.ts +376 -0
- package/plugins/pi-ember-tps/index.ts +122 -0
- package/plugins/pi-ember-ui/ember.json +96 -0
- package/plugins/pi-ember-ui/index.ts +618 -0
- package/plugins/pi-ember-ui/mode-colors.ts +168 -0
- package/tsconfig.json +2 -1
- package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
|
@@ -10,199 +10,17 @@ import {
|
|
|
10
10
|
createWriteTool,
|
|
11
11
|
type ExtensionAPI,
|
|
12
12
|
} from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import {
|
|
13
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
14
|
+
import {
|
|
15
|
+
CompactRenderer,
|
|
16
|
+
GROUPABLE_TOOLS,
|
|
17
|
+
type ToolRenderContext,
|
|
18
|
+
type ToolRenderResultOptions,
|
|
19
|
+
} from "./renderer.ts";
|
|
14
20
|
|
|
15
21
|
const SOURCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const BULLET = "• ";
|
|
17
|
-
const DISCOVERY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
18
22
|
|
|
19
23
|
type ToolFactory = (cwd: string) => any;
|
|
20
|
-
type ToolRenderContext = {
|
|
21
|
-
args: any;
|
|
22
|
-
toolCallId: string;
|
|
23
|
-
invalidate: () => void;
|
|
24
|
-
};
|
|
25
|
-
type ToolRenderResultOptions = {
|
|
26
|
-
isPartial: boolean;
|
|
27
|
-
};
|
|
28
|
-
type CompactCall = {
|
|
29
|
-
id: string;
|
|
30
|
-
name: string;
|
|
31
|
-
args: any;
|
|
32
|
-
group?: DiscoveryGroup;
|
|
33
|
-
invalidate?: () => void;
|
|
34
|
-
isError: boolean;
|
|
35
|
-
};
|
|
36
|
-
type DiscoveryGroup = {
|
|
37
|
-
records: CompactCall[];
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
function textValue(value: unknown, fallback = ""): string {
|
|
41
|
-
if (value === undefined || value === null) return fallback;
|
|
42
|
-
return String(value).replace(/[\r\n]+/g, " ");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function toolPath(args: any): string {
|
|
46
|
-
return textValue(args?.file_path ?? args?.path, ".");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function errorText(result: any, isError: boolean): string | undefined {
|
|
50
|
-
const content = result?.content?.find((item: any) => item.type === "text");
|
|
51
|
-
if (!isError && !content?.text?.startsWith("Error")) return undefined;
|
|
52
|
-
return textValue(content?.text, "Tool failed").split("\n")[0];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function diffStats(result: any): { additions: number; removals: number } {
|
|
56
|
-
const diff = typeof result?.details?.diff === "string" ? result.details.diff : "";
|
|
57
|
-
let additions = 0;
|
|
58
|
-
let removals = 0;
|
|
59
|
-
for (const line of diff.split("\n")) {
|
|
60
|
-
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
61
|
-
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
62
|
-
}
|
|
63
|
-
return { additions, removals };
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function formatCallBody(name: string, args: any, theme: any): string {
|
|
67
|
-
const pathName = toolPath(args);
|
|
68
|
-
switch (name) {
|
|
69
|
-
case "read":
|
|
70
|
-
return theme.fg("toolTitle", theme.bold("Read")) +
|
|
71
|
-
theme.fg("accent", ` ${pathName}`);
|
|
72
|
-
case "grep":
|
|
73
|
-
return theme.fg("toolTitle", theme.bold("Search")) +
|
|
74
|
-
theme.fg("accent", ` ${textValue(args?.pattern)}`) +
|
|
75
|
-
theme.fg("toolOutput", ` in ${pathName}`);
|
|
76
|
-
case "find":
|
|
77
|
-
return theme.fg("toolTitle", theme.bold("Find")) +
|
|
78
|
-
theme.fg("accent", ` ${textValue(args?.pattern)}`) +
|
|
79
|
-
theme.fg("toolOutput", ` in ${pathName}`);
|
|
80
|
-
case "ls":
|
|
81
|
-
return theme.fg("toolTitle", theme.bold("List")) +
|
|
82
|
-
theme.fg("accent", ` ${pathName}`);
|
|
83
|
-
case "bash":
|
|
84
|
-
return theme.fg("toolTitle", theme.bold("Run")) +
|
|
85
|
-
theme.fg("accent", ` $ ${textValue(args?.command)}`);
|
|
86
|
-
case "edit":
|
|
87
|
-
return theme.fg("toolTitle", theme.bold("edit")) +
|
|
88
|
-
theme.fg("accent", ` ${pathName}`);
|
|
89
|
-
case "write":
|
|
90
|
-
return theme.fg("toolTitle", theme.bold("write")) +
|
|
91
|
-
theme.fg("accent", ` ${pathName}`);
|
|
92
|
-
default:
|
|
93
|
-
return theme.fg("toolTitle", theme.bold(name));
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function formatGroup(group: DiscoveryGroup, theme: any): string {
|
|
98
|
-
const lines = [
|
|
99
|
-
theme.fg("muted", BULLET) + theme.fg("toolTitle", theme.bold("Explored")),
|
|
100
|
-
];
|
|
101
|
-
for (const [index, record] of group.records.entries()) {
|
|
102
|
-
const prefix = index === 0 ? " └ " : " ";
|
|
103
|
-
lines.push(theme.fg("dim", prefix) + formatCallBody(record.name, record.args, theme));
|
|
104
|
-
}
|
|
105
|
-
return lines.join("\n");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
class CompactRenderer {
|
|
109
|
-
private readonly calls = new Map<string, CompactCall>();
|
|
110
|
-
private lastCall: CompactCall | undefined;
|
|
111
|
-
|
|
112
|
-
beginTurn(): void {
|
|
113
|
-
this.lastCall = undefined;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
observeCall(name: string, id: string, args: any): CompactCall {
|
|
117
|
-
return this.registerCall(name, id, args);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
registerCall(
|
|
121
|
-
name: string,
|
|
122
|
-
id: string,
|
|
123
|
-
args: any,
|
|
124
|
-
invalidate?: () => void,
|
|
125
|
-
): CompactCall {
|
|
126
|
-
const existing = this.calls.get(id);
|
|
127
|
-
if (existing) {
|
|
128
|
-
existing.args = args;
|
|
129
|
-
existing.invalidate = invalidate ?? existing.invalidate;
|
|
130
|
-
return existing;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const record: CompactCall = { id, name, args, isError: false };
|
|
134
|
-
this.calls.set(id, record);
|
|
135
|
-
if (DISCOVERY_TOOLS.has(name) && this.lastCall && DISCOVERY_TOOLS.has(this.lastCall.name)) {
|
|
136
|
-
const group = this.lastCall.group ?? { records: [this.lastCall] };
|
|
137
|
-
this.lastCall.group = group;
|
|
138
|
-
group.records.push(record);
|
|
139
|
-
record.group = group;
|
|
140
|
-
for (const groupedCall of group.records) groupedCall.invalidate?.();
|
|
141
|
-
}
|
|
142
|
-
this.lastCall = record;
|
|
143
|
-
record.invalidate = invalidate;
|
|
144
|
-
return record;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
setResult(record: CompactCall, result: any, isError: boolean): void {
|
|
148
|
-
record.isError = isError;
|
|
149
|
-
if (record.group) {
|
|
150
|
-
record.group.records[0]?.invalidate?.();
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
renderCall(
|
|
155
|
-
name: string,
|
|
156
|
-
args: any,
|
|
157
|
-
theme: any,
|
|
158
|
-
context: ToolRenderContext,
|
|
159
|
-
): Component {
|
|
160
|
-
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
161
|
-
if (record.group && record.group.records.length > 1) {
|
|
162
|
-
if (record.group.records[0] !== record) return new Text("", 0, 0);
|
|
163
|
-
return new Text(formatGroup(record.group, theme), 0, 0);
|
|
164
|
-
}
|
|
165
|
-
return new Text(
|
|
166
|
-
theme.fg("muted", BULLET) + formatCallBody(name, args, theme),
|
|
167
|
-
0,
|
|
168
|
-
0,
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
renderResult(
|
|
173
|
-
name: string,
|
|
174
|
-
args: any,
|
|
175
|
-
result: any,
|
|
176
|
-
options: ToolRenderResultOptions,
|
|
177
|
-
theme: any,
|
|
178
|
-
context: ToolRenderContext & { isError: boolean },
|
|
179
|
-
): Component {
|
|
180
|
-
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
181
|
-
this.setResult(record, result, context.isError);
|
|
182
|
-
if (record.group && record.group.records.length > 1) {
|
|
183
|
-
if (record.group.records[0] !== record) return new Text("", 0, 0);
|
|
184
|
-
const error = errorText(result, context.isError);
|
|
185
|
-
return error ? new Text(theme.fg("error", error), 0, 0) : new Text("", 0, 0);
|
|
186
|
-
}
|
|
187
|
-
if (options.isPartial) return new Text("", 0, 0);
|
|
188
|
-
|
|
189
|
-
const error = errorText(result, context.isError);
|
|
190
|
-
if (error) return new Text(theme.fg("error", error), 0, 0);
|
|
191
|
-
if (name === "edit") {
|
|
192
|
-
const { additions, removals } = diffStats(result);
|
|
193
|
-
return new Text(
|
|
194
|
-
theme.fg("success", `+${additions}`) +
|
|
195
|
-
theme.fg("dim", " / ") +
|
|
196
|
-
theme.fg("error", `-${removals}`),
|
|
197
|
-
0,
|
|
198
|
-
0,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
if (name === "bash") return new Text(theme.fg("success", "Done"), 0, 0);
|
|
202
|
-
if (name === "write") return new Text(theme.fg("success", "Written"), 0, 0);
|
|
203
|
-
return new Text("", 0, 0);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
24
|
|
|
207
25
|
const TOOL_FACTORIES: Record<string, ToolFactory> = {
|
|
208
26
|
bash: createBashTool,
|
|
@@ -253,11 +71,18 @@ function registerCompactTool(
|
|
|
253
71
|
});
|
|
254
72
|
}
|
|
255
73
|
|
|
74
|
+
let sharedRenderer: CompactRenderer | null = null;
|
|
75
|
+
|
|
76
|
+
export function getSharedRenderer(): CompactRenderer {
|
|
77
|
+
if (!sharedRenderer) sharedRenderer = new CompactRenderer();
|
|
78
|
+
return sharedRenderer;
|
|
79
|
+
}
|
|
80
|
+
|
|
256
81
|
export default function piCompactToolsPlugin(pi: ExtensionAPI): void {
|
|
257
|
-
const renderer =
|
|
82
|
+
const renderer = getSharedRenderer();
|
|
258
83
|
pi.on("turn_start", () => renderer.beginTurn());
|
|
259
84
|
pi.on("tool_call", (event: any) => {
|
|
260
|
-
if (TOOL_FACTORIES[event.toolName]) {
|
|
85
|
+
if (GROUPABLE_TOOLS.has(event.toolName) || TOOL_FACTORIES[event.toolName]) {
|
|
261
86
|
renderer.observeCall(event.toolName, event.toolCallId, event.input);
|
|
262
87
|
}
|
|
263
88
|
});
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const BULLET = "• ";
|
|
4
|
+
const DISCOVERY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
5
|
+
const GROUPABLE_TOOLS = new Set([...DISCOVERY_TOOLS, "bash"]);
|
|
6
|
+
|
|
7
|
+
export type ToolRenderContext = {
|
|
8
|
+
args: any;
|
|
9
|
+
toolCallId: string;
|
|
10
|
+
invalidate: () => void;
|
|
11
|
+
state: Record<string, any>;
|
|
12
|
+
expanded?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ToolRenderResultOptions = {
|
|
16
|
+
isPartial: boolean;
|
|
17
|
+
expanded?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CompactCall = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
args: any;
|
|
24
|
+
group?: DiscoveryGroup;
|
|
25
|
+
invalidate?: () => void;
|
|
26
|
+
isError: boolean;
|
|
27
|
+
_completed?: boolean;
|
|
28
|
+
result?: any;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DiscoveryGroup = {
|
|
32
|
+
records: CompactCall[];
|
|
33
|
+
/**
|
|
34
|
+
* The record whose component currently renders the group header.
|
|
35
|
+
* The latest call to join the group becomes the owner so the
|
|
36
|
+
* header always renders in the current turn's visible component.
|
|
37
|
+
*/
|
|
38
|
+
renderOwner?: CompactCall;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function textValue(value: unknown, fallback = ""): string {
|
|
42
|
+
if (value === undefined || value === null) return fallback;
|
|
43
|
+
return String(value).replace(/[\r\n]+/g, " ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toolPath(args: any): string {
|
|
47
|
+
return textValue(args?.file_path ?? args?.path, ".");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bashCdDir(command: string): string | undefined {
|
|
51
|
+
const match = /^\s*cd\s+([^\s&]+)\s*&&\s*/.exec(command);
|
|
52
|
+
return match?.[1];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function groupKey(name: string, args: any): string | undefined {
|
|
56
|
+
if (DISCOVERY_TOOLS.has(name)) return "__discovery__";
|
|
57
|
+
if (name === "bash") {
|
|
58
|
+
const dir = bashCdDir(textValue(args?.command));
|
|
59
|
+
return dir ? `bash:${dir}` : undefined;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errorText(result: any, isError: boolean): string | undefined {
|
|
65
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
66
|
+
if (!isError && !content?.text?.startsWith("Error")) return undefined;
|
|
67
|
+
return textValue(content?.text, "Tool failed").split("\n")[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fullOutputText(result: any): string {
|
|
71
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
72
|
+
const text = content?.text;
|
|
73
|
+
if (typeof text !== "string") return "";
|
|
74
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatExpandedOutput(result: any, theme: any): string {
|
|
78
|
+
const text = fullOutputText(result).trimEnd();
|
|
79
|
+
if (!text) return "";
|
|
80
|
+
return "\n" + text.split("\n").map((line) => theme.fg("text", line)).join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bashLastLine(result: any): string | undefined {
|
|
84
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
85
|
+
const text = content?.text;
|
|
86
|
+
if (typeof text !== "string") return undefined;
|
|
87
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
88
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
89
|
+
const line = lines[i].trim();
|
|
90
|
+
if (line.length > 0) return line;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatBashResultLine(result: any, theme: any): string {
|
|
96
|
+
const lastLine = bashLastLine(result);
|
|
97
|
+
if (lastLine === undefined) return "";
|
|
98
|
+
return "\n" + theme.fg("dim", " ") + theme.fg("text", lastLine);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function diffStats(result: any): { additions: number; removals: number } {
|
|
102
|
+
const diff = typeof result?.details?.diff === "string" ? result.details.diff : "";
|
|
103
|
+
let additions = 0;
|
|
104
|
+
let removals = 0;
|
|
105
|
+
for (const line of diff.split("\n")) {
|
|
106
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
107
|
+
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
108
|
+
}
|
|
109
|
+
return { additions, removals };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function matchCount(result: any): number | undefined {
|
|
113
|
+
const total = result?.details?.totalMatched;
|
|
114
|
+
if (typeof total === "number") return total;
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function matchLabel(result: any, theme: any): string {
|
|
119
|
+
const total = matchCount(result);
|
|
120
|
+
if (total === undefined) return "";
|
|
121
|
+
const label = total === 1 ? "1 match" : `${total} matches`;
|
|
122
|
+
const color = total > 0 ? "success" : "muted";
|
|
123
|
+
return theme.fg("dim", " ") + theme.fg(color, label);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PULSE_INTERVAL_MS = 600;
|
|
127
|
+
|
|
128
|
+
function bulletColor(record: CompactCall, theme: any): string {
|
|
129
|
+
if (record.isError) return theme.fg("error", BULLET);
|
|
130
|
+
if (record._completed) return theme.fg("success", BULLET);
|
|
131
|
+
const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
|
|
132
|
+
return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatEditStats(result: any, theme: any): string {
|
|
136
|
+
const { additions, removals } = diffStats(result);
|
|
137
|
+
return theme.fg("success", `+${additions}`) +
|
|
138
|
+
theme.fg("dim", " / ") +
|
|
139
|
+
theme.fg("error", `-${removals}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatCallBody(name: string, args: any, theme: any, inGroup = false): string {
|
|
143
|
+
const pathName = toolPath(args);
|
|
144
|
+
switch (name) {
|
|
145
|
+
case "read":
|
|
146
|
+
return theme.fg("dim", theme.bold("Read")) +
|
|
147
|
+
theme.fg("dim", ` ${pathName}`);
|
|
148
|
+
case "grep":
|
|
149
|
+
return theme.fg("dim", theme.bold("Search")) +
|
|
150
|
+
theme.fg("dim", ` ${textValue(args?.pattern)}`) +
|
|
151
|
+
theme.fg("dim", ` in ${pathName}`);
|
|
152
|
+
case "find":
|
|
153
|
+
return theme.fg("dim", theme.bold("Find")) +
|
|
154
|
+
theme.fg("dim", ` ${textValue(args?.pattern)}`) +
|
|
155
|
+
theme.fg("dim", ` in ${pathName}`);
|
|
156
|
+
case "ls":
|
|
157
|
+
return theme.fg("dim", theme.bold("List")) +
|
|
158
|
+
theme.fg("dim", ` ${pathName}`);
|
|
159
|
+
case "bash": {
|
|
160
|
+
const cmd = textValue(args?.command);
|
|
161
|
+
const stripped = inGroup ? (cmd.replace(/^\s*cd\s+[^\s&]+\s*&&\s*/, "") || cmd) : cmd;
|
|
162
|
+
return theme.fg("dim", theme.bold("Run")) +
|
|
163
|
+
theme.fg("dim", ` $ ${stripped}`);
|
|
164
|
+
}
|
|
165
|
+
case "edit":
|
|
166
|
+
return theme.fg("dim", theme.bold("edit")) +
|
|
167
|
+
theme.fg("dim", ` ${pathName}`);
|
|
168
|
+
case "write":
|
|
169
|
+
return theme.fg("dim", theme.bold("write")) +
|
|
170
|
+
theme.fg("dim", ` ${pathName}`);
|
|
171
|
+
default:
|
|
172
|
+
return theme.fg("dim", theme.bold(name));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function groupBulletColor(group: DiscoveryGroup, theme: any): string {
|
|
177
|
+
if (group.records.some((r) => r.isError)) return theme.fg("error", BULLET);
|
|
178
|
+
if (group.records.every((r) => r._completed)) return theme.fg("success", BULLET);
|
|
179
|
+
const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
|
|
180
|
+
return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function groupHeaderLabel(group: DiscoveryGroup): string {
|
|
184
|
+
const allDone = group.records.every((r) => r._completed);
|
|
185
|
+
const verb = allDone ? "Explored" : "Exploring";
|
|
186
|
+
const first = group.records[0];
|
|
187
|
+
if (!first) return verb;
|
|
188
|
+
const key = groupKey(first.name, first.args);
|
|
189
|
+
if (key?.startsWith("bash:")) {
|
|
190
|
+
const dir = key.slice(5);
|
|
191
|
+
return `${verb} ${dir}`;
|
|
192
|
+
}
|
|
193
|
+
return verb;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatGroup(group: DiscoveryGroup, theme: any): string {
|
|
197
|
+
const lines = [
|
|
198
|
+
groupBulletColor(group, theme) + theme.fg("text", theme.bold(groupHeaderLabel(group))),
|
|
199
|
+
];
|
|
200
|
+
for (const [index, record] of group.records.entries()) {
|
|
201
|
+
const prefix = index === 0 ? " └ " : " ";
|
|
202
|
+
const suffix = record._completed && (record.name === "grep" || record.name === "find")
|
|
203
|
+
? matchLabel(record.result, theme)
|
|
204
|
+
: "";
|
|
205
|
+
lines.push(
|
|
206
|
+
theme.fg("dim", prefix) +
|
|
207
|
+
bulletColor(record, theme) +
|
|
208
|
+
formatCallBody(record.name, record.args, theme, true) +
|
|
209
|
+
suffix,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class CompactRenderer {
|
|
216
|
+
private readonly calls = new Map<string, CompactCall>();
|
|
217
|
+
private lastCall: CompactCall | undefined;
|
|
218
|
+
private lastGroupKey: string | undefined;
|
|
219
|
+
private pulseTimer: ReturnType<typeof setInterval> | null = null;
|
|
220
|
+
private readonly pendingPulses = new Set<CompactCall>();
|
|
221
|
+
|
|
222
|
+
private startPulse(record: CompactCall): void {
|
|
223
|
+
this.pendingPulses.add(record);
|
|
224
|
+
if (this.pulseTimer) return;
|
|
225
|
+
this.pulseTimer = setInterval(() => {
|
|
226
|
+
if (this.pendingPulses.size === 0) {
|
|
227
|
+
this.stopPulse();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
for (const r of this.pendingPulses) r.invalidate?.();
|
|
231
|
+
}, PULSE_INTERVAL_MS);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private stopPulse(): void {
|
|
235
|
+
if (this.pulseTimer) {
|
|
236
|
+
clearInterval(this.pulseTimer);
|
|
237
|
+
this.pulseTimer = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
beginTurn(): void {
|
|
242
|
+
// Do NOT reset lastCall / lastGroupKey here. Resetting breaks
|
|
243
|
+
// grouping when turn_start fires between renderCall registrations
|
|
244
|
+
// (e.g. parallel bash calls with the same cd dir). Consecutive
|
|
245
|
+
// calls with the same group key should always group, even across
|
|
246
|
+
// turn boundaries. Different keys naturally don't group.
|
|
247
|
+
this.pendingPulses.clear();
|
|
248
|
+
this.stopPulse();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
observeCall(name: string, id: string, args: any): CompactCall {
|
|
252
|
+
return this.registerCall(name, id, args);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
registerCall(
|
|
256
|
+
name: string,
|
|
257
|
+
id: string,
|
|
258
|
+
args: any,
|
|
259
|
+
invalidate?: () => void,
|
|
260
|
+
): CompactCall {
|
|
261
|
+
const existing = this.calls.get(id);
|
|
262
|
+
if (existing) {
|
|
263
|
+
existing.args = args;
|
|
264
|
+
existing.invalidate = invalidate ?? existing.invalidate;
|
|
265
|
+
return existing;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const record: CompactCall = { id, name, args, isError: false };
|
|
269
|
+
this.calls.set(id, record);
|
|
270
|
+
const key = groupKey(name, args);
|
|
271
|
+
if (key && this.lastCall && key === this.lastGroupKey) {
|
|
272
|
+
const group = this.lastCall.group ?? { records: [this.lastCall], renderOwner: this.lastCall };
|
|
273
|
+
this.lastCall.group = group;
|
|
274
|
+
// New record joins the group — become renderOwner so the group
|
|
275
|
+
// header renders in this call's (current-turn) component.
|
|
276
|
+
const prevOwner = group.renderOwner;
|
|
277
|
+
group.renderOwner = record;
|
|
278
|
+
if (prevOwner && prevOwner !== record) prevOwner.invalidate?.();
|
|
279
|
+
group.records.push(record);
|
|
280
|
+
record.group = group;
|
|
281
|
+
for (const groupedCall of group.records) groupedCall.invalidate?.();
|
|
282
|
+
}
|
|
283
|
+
this.lastCall = record;
|
|
284
|
+
this.lastGroupKey = key;
|
|
285
|
+
record.invalidate = invalidate;
|
|
286
|
+
this.startPulse(record);
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setResult(record: CompactCall, result: any, isError: boolean): void {
|
|
291
|
+
if (record._completed && record.result === result && record.isError === isError) return;
|
|
292
|
+
record.isError = isError;
|
|
293
|
+
record._completed = true;
|
|
294
|
+
record.result = result;
|
|
295
|
+
this.pendingPulses.delete(record);
|
|
296
|
+
if (this.pendingPulses.size === 0) this.stopPulse();
|
|
297
|
+
if (record.group) {
|
|
298
|
+
record.group.renderOwner?.invalidate?.();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
renderCall(
|
|
303
|
+
name: string,
|
|
304
|
+
args: any,
|
|
305
|
+
theme: any,
|
|
306
|
+
context: ToolRenderContext,
|
|
307
|
+
): Component {
|
|
308
|
+
try {
|
|
309
|
+
return this.renderCallInner(name, args, theme, context);
|
|
310
|
+
} catch {
|
|
311
|
+
// Never throw: Pi's fallback would dump raw content. Return a
|
|
312
|
+
// compact call row instead.
|
|
313
|
+
return new Text(
|
|
314
|
+
theme.fg("muted", BULLET) + formatCallBody(name, args, theme),
|
|
315
|
+
0, 0,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private renderCallInner(
|
|
321
|
+
name: string,
|
|
322
|
+
args: any,
|
|
323
|
+
theme: any,
|
|
324
|
+
context: ToolRenderContext,
|
|
325
|
+
): Component {
|
|
326
|
+
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
327
|
+
if (record.group && record.group.records.length > 1) {
|
|
328
|
+
if (record.group.renderOwner !== record) return new Text("", 0, 0);
|
|
329
|
+
return new Text(formatGroup(record.group, theme), 0, 0);
|
|
330
|
+
}
|
|
331
|
+
const callText = context.state.callText instanceof Text
|
|
332
|
+
? context.state.callText
|
|
333
|
+
: new Text("", 0, 0);
|
|
334
|
+
context.state.callText = callText;
|
|
335
|
+
callText.setText(
|
|
336
|
+
bulletColor(record, theme) + formatCallBody(name, args, theme),
|
|
337
|
+
);
|
|
338
|
+
return callText;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
renderResult(
|
|
342
|
+
name: string,
|
|
343
|
+
args: any,
|
|
344
|
+
result: any,
|
|
345
|
+
options: ToolRenderResultOptions,
|
|
346
|
+
theme: any,
|
|
347
|
+
context: ToolRenderContext & { isError: boolean },
|
|
348
|
+
): Component {
|
|
349
|
+
try {
|
|
350
|
+
return this.renderResultInner(name, args, result, options, theme, context);
|
|
351
|
+
} catch {
|
|
352
|
+
// Never throw: Pi's fallback would dump the full tool output
|
|
353
|
+
// (e.g. entire file contents for read). Return an empty result
|
|
354
|
+
// row — the call row already shows the compact summary.
|
|
355
|
+
return new Text("", 0, 0);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private renderResultInner(
|
|
360
|
+
name: string,
|
|
361
|
+
args: any,
|
|
362
|
+
result: any,
|
|
363
|
+
options: ToolRenderResultOptions,
|
|
364
|
+
theme: any,
|
|
365
|
+
context: ToolRenderContext & { isError: boolean },
|
|
366
|
+
): Component {
|
|
367
|
+
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
368
|
+
this.setResult(record, result, context.isError);
|
|
369
|
+
const expanded = options.expanded === true;
|
|
370
|
+
|
|
371
|
+
if (record.group && record.group.records.length > 1) {
|
|
372
|
+
if (record.group.renderOwner !== record) return new Text("", 0, 0);
|
|
373
|
+
if (expanded && !options.isPartial) {
|
|
374
|
+
const output = formatExpandedOutput(result, theme);
|
|
375
|
+
if (output) return new Text(output, 0, 0);
|
|
376
|
+
}
|
|
377
|
+
const error = errorText(result, context.isError);
|
|
378
|
+
return error ? new Text(theme.fg("error", error), 0, 0) : new Text("", 0, 0);
|
|
379
|
+
}
|
|
380
|
+
if (options.isPartial) return new Text("", 0, 0);
|
|
381
|
+
|
|
382
|
+
const error = errorText(result, context.isError);
|
|
383
|
+
const callText = context.state.callText;
|
|
384
|
+
if (callText instanceof Text) {
|
|
385
|
+
if (name === "edit") {
|
|
386
|
+
callText.setText(
|
|
387
|
+
bulletColor(record, theme) +
|
|
388
|
+
formatCallBody(name, args, theme) +
|
|
389
|
+
theme.fg("dim", " ") +
|
|
390
|
+
formatEditStats(result, theme),
|
|
391
|
+
);
|
|
392
|
+
} else if (name === "grep" || name === "find") {
|
|
393
|
+
callText.setText(
|
|
394
|
+
bulletColor(record, theme) +
|
|
395
|
+
formatCallBody(name, args, theme) +
|
|
396
|
+
matchLabel(result, theme),
|
|
397
|
+
);
|
|
398
|
+
} else if (name === "bash") {
|
|
399
|
+
callText.setText(
|
|
400
|
+
bulletColor(record, theme) +
|
|
401
|
+
formatCallBody(name, args, theme) +
|
|
402
|
+
formatBashResultLine(result, theme),
|
|
403
|
+
);
|
|
404
|
+
} else {
|
|
405
|
+
callText.setText(
|
|
406
|
+
bulletColor(record, theme) + formatCallBody(name, args, theme),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (error) return new Text(theme.fg("error", error), 0, 0);
|
|
411
|
+
if (expanded) {
|
|
412
|
+
const output = formatExpandedOutput(result, theme);
|
|
413
|
+
if (output) return new Text(output, 0, 0);
|
|
414
|
+
}
|
|
415
|
+
return new Text("", 0, 0);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export { BULLET, DISCOVERY_TOOLS, GROUPABLE_TOOLS };
|