diff --git a/packages/pi-fff/src/index.ts b/packages/pi-fff/src/index.ts index 75d93cfe..cb71b653 100644 --- a/packages/pi-fff/src/index.ts +++ b/packages/pi-fff/src/index.ts @@ -14,7 +14,11 @@ import type { import { type AutocompleteItem, type AutocompleteProvider, + type Component, + MouseRegion, + sliceByColumn, Text, + visibleWidth, } from "@earendil-works/pi-tui"; import type { FileFinderApi, @@ -786,7 +790,91 @@ export default function fffExtension(pi: ExtensionAPI) { // --- Shared render helpers --- - const renderTextResult = ( + class CollapsedText implements Component { + constructor( + private readonly preview: string, + private readonly suffix: string, + private readonly marker: string, + ) {} + + render(width: number): string[] { + const availableWidth = Math.max(1, width); + if (!this.suffix) { + if (visibleWidth(this.preview) <= availableWidth) return [this.preview]; + const markerWidth = visibleWidth(this.marker); + if (markerWidth >= availableWidth) { + return [sliceByColumn(this.marker, 0, availableWidth, true)]; + } + return [ + `${sliceByColumn(this.preview, 0, availableWidth - markerWidth, true)}${this.marker}`, + ]; + } + + const suffixWidth = visibleWidth(this.suffix); + if (suffixWidth >= availableWidth) { + return [sliceByColumn(this.suffix, 0, availableWidth, true)]; + } + + const previewWidth = availableWidth - suffixWidth - 1; + if (visibleWidth(this.preview) <= previewWidth) { + return [`${this.preview} ${this.suffix}`]; + } + + const markerWidth = visibleWidth(this.marker); + return [ + `${sliceByColumn(this.preview, 0, Math.max(0, previewWidth - markerWidth), true)}${this.marker} ${this.suffix}`, + ]; + } + + invalidate(): void {} + } + + function isToolExpanded(context: any): boolean { + return context.state.fffCompactExpanded === true; + } + + function makeToolClickable(component: Component, context: any): Component { + return new MouseRegion(component, (event) => { + if (event.type !== "click" || event.button !== "left") return undefined; + context.state.fffCompactExpanded = !isToolExpanded(context); + context.invalidate(); + return { handled: true }; + }); + } + + const renderCompactTextResult = ( + result: { content?: { type: string; text?: string }[] }, + theme: any, + context: any, + ): Component => { + const output = result.content?.find((c) => c.type === "text")?.text?.trim() ?? ""; + if (!output) { + return makeToolClickable(new Text(theme.fg("muted", "No output"), 0, 0), context); + } + + const lines = output.split("\n"); + if (isToolExpanded(context)) { + const color = context.isError ? "error" : "toolOutput"; + return makeToolClickable( + new Text(lines.map((line) => theme.fg(color, line)).join("\n"), 0, 0), + context, + ); + } + + const color = context.isError ? "error" : "toolOutput"; + const suffix = + lines.length > 1 ? theme.fg("muted", `... (${lines.length - 1} more lines)`) : ""; + return makeToolClickable( + new CollapsedText( + theme.fg(color, lines[0] ?? ""), + suffix, + theme.fg("muted", "..."), + ), + context, + ); + }; + + const renderPreviewResult = ( result: { content?: { type: string; text?: string }[] }, options: { expanded?: boolean }, theme: any, @@ -993,7 +1081,6 @@ export default function fffExtension(pi: ExtensionAPI) { }, renderCall(args, theme, context) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const pattern = args?.pattern ?? ""; const path = args?.path ?? "."; let content = @@ -1001,15 +1088,20 @@ export default function fffExtension(pi: ExtensionAPI) { " " + theme.fg("accent", `/${pattern}/`) + theme.fg("toolOutput", ` in ${path}`); - if (args?.limit !== undefined) - content += theme.fg("toolOutput", ` limit ${args.limit}`); + const options: string[] = []; + if (args?.limit !== undefined) options.push(`limit ${args.limit}`); + if (args?.context !== undefined) options.push(`context ${args.context}`); + if (options.length > 0) + content += theme.fg("toolOutput", ` (${options.join(", ")})`); if (args?.cursor) content += theme.fg("muted", ` (page)`); - text.setText(content); - return text; + return makeToolClickable( + new CollapsedText(content, "", theme.fg("muted", "...")), + context, + ); }, - renderResult(result, options, theme, context) { - return renderTextResult(result, options, theme, context, 15); + renderResult(result, _options, theme, context) { + return renderCompactTextResult(result, theme, context); }, }); @@ -1136,7 +1228,6 @@ export default function fffExtension(pi: ExtensionAPI) { }, renderCall(args, theme, context) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); const pattern = args?.pattern ?? ""; const path = args?.path ?? "."; let content = @@ -1147,12 +1238,14 @@ export default function fffExtension(pi: ExtensionAPI) { if (args?.limit !== undefined) content += theme.fg("toolOutput", ` (limit ${args.limit})`); if (args?.cursor) content += theme.fg("muted", ` (page)`); - text.setText(content); - return text; + return makeToolClickable( + new CollapsedText(content, "", theme.fg("muted", "...")), + context, + ); }, - renderResult(result, options, theme, context) { - return renderTextResult(result, options, theme, context, 20); + renderResult(result, _options, theme, context) { + return renderCompactTextResult(result, theme, context); }, }); @@ -1254,7 +1347,7 @@ export default function fffExtension(pi: ExtensionAPI) { }, renderResult(result, options, theme, context) { - return renderTextResult(result, options, theme, context, 15); + return renderPreviewResult(result, options, theme, context, 15); }, }); } // end if (enableMultiGrep) diff --git a/packages/pi-fff/test/extension.test.ts b/packages/pi-fff/test/extension.test.ts index e9d7fd58..ec74f1b4 100644 --- a/packages/pi-fff/test/extension.test.ts +++ b/packages/pi-fff/test/extension.test.ts @@ -82,6 +82,12 @@ mock.module("@ff-labs/fff-node", () => finderModule); mock.module("@ff-labs/fff-bun", () => finderModule); mock.module("@earendil-works/pi-tui", () => ({ + MouseRegion: class MouseRegion { + constructor( + public component: any, + public onMouse: (event: any) => unknown, + ) {} + }, Text: class Text { text: string; constructor(text: string) { @@ -91,6 +97,8 @@ mock.module("@earendil-works/pi-tui", () => ({ this.text = text; } }, + sliceByColumn: (text: string, _start: number, end: number) => text.slice(0, end), + visibleWidth: (text: string) => text.length, })); const schema = (type: string) => (options?: unknown) => ({ type, options }); @@ -683,6 +691,88 @@ describe("pi-fff autocomplete registration", () => { }); }); +describe("compact tool rendering", () => { + const theme = { + bold: (text: string) => text, + fg: (_color: string, text: string) => text, + }; + + function toolByName( + setup: { pi: { registerTool: ReturnType } }, + name: string, + ) { + const tool = setup.pi.registerTool.mock.calls + .map(([tool]) => tool) + .find((tool) => tool.name === name); + expect(tool).toBeDefined(); + return tool; + } + + test("ffgrep starts collapsed and click expands its complete result", async () => { + const setup = await start("tools-and-ui"); + const tool = toolByName(setup, "ffgrep"); + const context: { + state: { fffCompactExpanded?: boolean }; + invalidate: ReturnType; + isError: boolean; + } = { state: {}, invalidate: mock(() => undefined), isError: false }; + + const call = tool.renderCall( + { pattern: "TODO", path: ".", limit: 3, context: 2 }, + theme, + context, + ); + expect(call.component.render(80)).toEqual([ + "ffgrep /TODO/ in . (limit 3, context 2)", + ]); + + const defaultCall = tool.renderCall({ pattern: "TODO", path: "." }, theme, context); + expect(defaultCall.component.render(80)).toEqual(["ffgrep /TODO/ in ."]); + + const result = tool.renderResult( + { content: [{ type: "text", text: "first\nsecond\nthird" }] }, + { expanded: false }, + theme, + context, + ); + + expect(result.component.render(80)).toEqual(["first ... (2 more lines)"]); + expect(result.onMouse({ type: "click", button: "left" })).toEqual({ handled: true }); + expect(context.state.fffCompactExpanded).toBe(true); + expect(context.invalidate).toHaveBeenCalledTimes(1); + + const expanded = tool.renderResult( + { content: [{ type: "text", text: "first\nsecond\nthird" }] }, + { expanded: false }, + theme, + context, + ); + expect(expanded.component.text).toBe("first\nsecond\nthird"); + }); + + test("fffind call and result share the click expansion state", async () => { + const setup = await start("tools-and-ui"); + const tool = toolByName(setup, "fffind"); + const context: { + state: { fffCompactExpanded?: boolean }; + invalidate: ReturnType; + isError: boolean; + } = { state: {}, invalidate: mock(() => undefined), isError: false }; + const call = tool.renderCall({ pattern: "index", path: "src" }, theme, context); + + expect(call.onMouse({ type: "click", button: "left" })).toEqual({ handled: true }); + expect(context.state.fffCompactExpanded).toBe(true); + + const result = tool.renderResult( + { content: [{ type: "text", text: "src/index.ts\nsrc/main.ts" }] }, + { expanded: false }, + theme, + context, + ); + expect(result.component.text).toBe("src/index.ts\nsrc/main.ts"); + }); +}); + describe("ffgrep per-file cap (#825)", () => { function grepTool(setup: { pi: { registerTool: ReturnType } }) { const tool = setup.pi.registerTool.mock.calls