Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 51 additions & 18 deletions packages/pi-fff/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
*/

import nodePath from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
type ExtensionAPI,
keyHint,
} from "@earendil-works/pi-coding-agent";
import {
type AutocompleteItem,
type AutocompleteProvider,
type Component,
Text,
truncateToWidth,
} from "@earendil-works/pi-tui";
import type {
FileFinderApi,
Expand All @@ -35,6 +40,7 @@ const DEFAULT_GREP_LIMIT = 20;
const DEFAULT_FIND_LIMIT = 30;
const GREP_MAX_LINE_LENGTH = 500;
const MENTION_MAX_RESULTS = 20;
const COLLAPSED_TOOL_OUTPUT_LINES = 5;

type FffMode = "tools-and-ui" | "tools-only" | "override";

Expand Down Expand Up @@ -573,30 +579,28 @@ export default function fffExtension(pi: ExtensionAPI) {
options: { expanded?: boolean },
theme: any,
context: any,
maxLines = 15,
) => {
const text =
(context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
(context.lastComponent as CollapsibleToolOutput | undefined) ??
new CollapsibleToolOutput();
const output =
result.content?.find((c) => c.type === "text")?.text?.trim() ?? "";
if (!output) {
text.setText(theme.fg("muted", "No output"));
text.setCollapsed(undefined, "");
return text;
}

const lines = output.split("\n");
const displayLines = lines.slice(
0,
options.expanded ? lines.length : maxLines,
text.setText(
output
.split("\n")
.map((line: string) => theme.fg("toolOutput", line))
.join("\n"),
);
text.setCollapsed(
options.expanded ? undefined : COLLAPSED_TOOL_OUTPUT_LINES,
theme.fg("muted", `... (${keyHint("app.tools.expand", "to expand")})`),
);
let content = `\n${displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n")}`;
if (lines.length > displayLines.length) {
content += theme.fg(
"muted",
`\n... (${lines.length - displayLines.length} more lines)`,
);
}
text.setText(content);
return text;
};

Expand Down Expand Up @@ -783,7 +787,7 @@ export default function fffExtension(pi: ExtensionAPI) {
},

renderResult(result, options, theme, context) {
return renderTextResult(result, options, theme, context, 15);
return renderTextResult(result, options, theme, context);
},
});

Expand Down Expand Up @@ -933,7 +937,7 @@ export default function fffExtension(pi: ExtensionAPI) {
},

renderResult(result, options, theme, context) {
return renderTextResult(result, options, theme, context, 20);
return renderTextResult(result, options, theme, context);
},
});

Expand Down Expand Up @@ -1033,7 +1037,7 @@ export default function fffExtension(pi: ExtensionAPI) {
},

renderResult(result, options, theme, context) {
return renderTextResult(result, options, theme, context, 15);
return renderTextResult(result, options, theme, context);
},
});
} // end if (enableMultiGrep)
Expand Down Expand Up @@ -1129,3 +1133,32 @@ export default function fffExtension(pi: ExtensionAPI) {
},
});
}

class CollapsibleToolOutput implements Component {
private readonly text = new Text("", 0, 0);
private maxLines: number | undefined;
private overflowHint = "";

setText(value: string): void {
this.text.setText(value);
}

setCollapsed(maxLines: number | undefined, overflowHint: string): void {
this.maxLines = maxLines;
this.overflowHint = overflowHint;
}

render(width: number): string[] {
const lines = this.text.render(width);
if (this.maxLines === undefined || lines.length <= this.maxLines) return lines;

return [
...lines.slice(0, Math.max(0, this.maxLines - 1)),
truncateToWidth(this.overflowHint, width),
];
}

invalidate(): void {
this.text.invalidate();
}
}
58 changes: 56 additions & 2 deletions packages/pi-fff/test/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const finderModule = {
mock.module("@ff-labs/fff-node", () => finderModule);
mock.module("@ff-labs/fff-bun", () => finderModule);

mock.module("@earendil-works/pi-coding-agent", () => ({
keyHint: (_keybinding: string, description: string) => `Ctrl+O ${description}`,
}));

mock.module("@earendil-works/pi-tui", () => ({
Text: class Text {
text: string;
Expand All @@ -57,7 +61,20 @@ mock.module("@earendil-works/pi-tui", () => ({
setText(text: string) {
this.text = text;
}
render(width: number) {
return this.text
.split("\n")
.flatMap((line) =>
line.length === 0
? [""]
: Array.from({ length: Math.ceil(line.length / width) }, (_, index) =>
line.slice(index * width, (index + 1) * width),
),
);
}
invalidate() {}
},
truncateToWidth: (text: string, width: number) => text.slice(0, width),
}));

const schema = (type: string) => (options?: unknown) => ({ type, options });
Expand Down Expand Up @@ -85,6 +102,7 @@ type EventHandler = (...args: any[]) => unknown;
function createPi(mode?: string) {
const events = new Map<string, EventHandler>();
const commands = new Map<string, any>();
const tools = new Map<string, any>();

const pi = {
getFlag: mock((name: string) => (name === "fff-mode" ? mode : undefined)),
Expand All @@ -95,11 +113,13 @@ function createPi(mode?: string) {
commands.set(name, command);
}),
registerFlag: mock(() => undefined),
registerTool: mock(() => undefined),
registerTool: mock((tool: any) => {
tools.set(tool.name, tool);
}),
appendEntry: mock(() => undefined),
};

return { pi, events, commands };
return { pi, events, commands, tools };
}

function createContext() {
Expand Down Expand Up @@ -146,6 +166,40 @@ beforeEach(() => {
delete process.env.PI_FFF_MODE;
});

describe("pi-fff tool output rendering", () => {
test("caps collapsed output at five rendered lines and expands with Ctrl+O", async () => {
const { tools } = await start("override");
const grep = tools.get("grep");
const theme = { fg: (_color: string, text: string) => text };
const result = {
content: [
{
type: "text",
text: `${"a".repeat(90)}\n${"b".repeat(90)}\n${"c".repeat(90)}`,
},
],
};

const collapsed = grep.renderResult(result, { expanded: false }, theme, {
lastComponent: undefined,
});
expect(collapsed.render(40)).toEqual([
"a".repeat(40),
"a".repeat(40),
"a".repeat(10),
"b".repeat(40),
"... (Ctrl+O to expand)",
]);

const expanded = grep.renderResult(result, { expanded: true }, theme, {
lastComponent: collapsed,
});
expect(expanded).toBe(collapsed);
expect(expanded.render(40)).toHaveLength(9);
expect(expanded.render(40)).not.toContain("... (Ctrl+O to expand)");
});
});

describe("pi-fff autocomplete registration", () => {
test("session_start registers a provider without replacing the editor", async () => {
const { ctx } = await start();
Expand Down
Loading