diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 649aaca2..115927e1 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -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, @@ -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"; @@ -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; }; @@ -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); }, }); @@ -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); }, }); @@ -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) @@ -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(); + } +} diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index c0d351c6..8788da32 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -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; @@ -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 }); @@ -85,6 +102,7 @@ type EventHandler = (...args: any[]) => unknown; function createPi(mode?: string) { const events = new Map(); const commands = new Map(); + const tools = new Map(); const pi = { getFlag: mock((name: string) => (name === "fff-mode" ? mode : undefined)), @@ -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() { @@ -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();