From b8238a77a5ad606624a79a13f4b7dabc36c141f4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 27 Apr 2026 21:18:34 +0200 Subject: [PATCH] fix(coding-agent): tighten HTML export tool spacing --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/export-html/ansi-to-html.ts | 2 +- .../src/core/export-html/template.css | 9 +++-- .../src/core/export-html/tool-renderer.ts | 18 ++++++++-- .../test/export-html-whitespace.test.ts | 33 +++++++++++++++++-- 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 38ae8740..91c2efdb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed HTML export whitespace around extension-rendered tool output and expandable output hints. - Fixed bash executor temp output streams leaking file descriptors when output was truncated by line count ([#3786](https://github.com/badlogic/pi-mono/issues/3786)) - Fixed extension `pi.setSessionName()` updates to refresh the interactive terminal title immediately ([#3686](https://github.com/badlogic/pi-mono/issues/3686)) - Fixed `/tree` cancellation via `session_before_tree` leaving the session stuck in compaction state ([#3688](https://github.com/badlogic/pi-mono/issues/3688)) diff --git a/packages/coding-agent/src/core/export-html/ansi-to-html.ts b/packages/coding-agent/src/core/export-html/ansi-to-html.ts index 92a3974e..d675fd55 100644 --- a/packages/coding-agent/src/core/export-html/ansi-to-html.ts +++ b/packages/coding-agent/src/core/export-html/ansi-to-html.ts @@ -254,5 +254,5 @@ export function ansiToHtml(text: string): string { * Each line is wrapped in a div element. */ export function ansiLinesToHtml(lines: string[]): string { - return lines.map((line) => `
${ansiToHtml(line) || " "}
`).join("\n"); + return lines.map((line) => `
${ansiToHtml(line) || " "}
`).join(""); } diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index 857fa25d..4c7c5e2e 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -505,11 +505,16 @@ } .tool-output > div, - .output-preview, - .output-full { + .output-preview > div, + .output-full > div { margin: 0; padding: 0; line-height: var(--line-height); + } + + .tool-output > div:not(.output-preview):not(.output-full), + .output-preview > div:not(.expand-hint), + .output-full > div:not(.expand-hint) { white-space: pre-wrap; } diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts index 626472a5..df166459 100644 --- a/packages/coding-agent/src/core/export-html/tool-renderer.ts +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -41,6 +41,20 @@ export interface ToolHtmlRenderer { * The renderer looks up tool definitions and invokes their renderCall/renderResult * methods, converting the resulting TUI Component output (ANSI) to HTML. */ +const ANSI_ESCAPE_REGEX = /\x1b\[[\d;]*m/g; + +function isBlankRenderedLine(line: string): boolean { + return line.replace(ANSI_ESCAPE_REGEX, "").trim().length === 0; +} + +function trimRenderedResultLines(lines: string[]): string[] { + let start = 0; + let end = lines.length; + while (start < end && isBlankRenderedLine(lines[start])) start++; + while (end > start && isBlankRenderedLine(lines[end - 1])) end--; + return lines.slice(start, end); +} + export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRenderer { const { getToolDefinition, theme, cwd, width = 100 } = deps; @@ -133,7 +147,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), false, false, isError), ); renderedResultComponents.set(toolCallId, collapsedComponent); - const collapsed = ansiLinesToHtml(collapsedComponent.render(width)); + const collapsed = ansiLinesToHtml(trimRenderedResultLines(collapsedComponent.render(width))); // Render expanded const expandedComponent = toolDef.renderResult( @@ -143,7 +157,7 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend createRenderContext(toolCallId, renderedResultComponents.get(toolCallId), true, false, isError), ); renderedResultComponents.set(toolCallId, expandedComponent); - const expanded = ansiLinesToHtml(expandedComponent.render(width)); + const expanded = ansiLinesToHtml(trimRenderedResultLines(expandedComponent.render(width))); return { ...(collapsed && collapsed !== expanded ? { collapsed } : {}), diff --git a/packages/coding-agent/test/export-html-whitespace.test.ts b/packages/coding-agent/test/export-html-whitespace.test.ts index dc00de0d..22610df8 100644 --- a/packages/coding-agent/test/export-html-whitespace.test.ts +++ b/packages/coding-agent/test/export-html-whitespace.test.ts @@ -1,12 +1,41 @@ +import type { Component } from "@mariozechner/pi-tui"; import { readFileSync } from "fs"; import { describe, expect, it } from "vitest"; +import { ansiLinesToHtml } from "../src/core/export-html/ansi-to-html.js"; +import { createToolHtmlRenderer } from "../src/core/export-html/tool-renderer.js"; +import type { ToolDefinition } from "../src/core/extensions/types.js"; +import type { Theme } from "../src/modes/interactive/theme/theme.js"; describe("export HTML tool output whitespace", () => { - it("preserves whitespace for plain-text tool output containers", () => { + it("preserves whitespace for plain-text tool output lines without preserving template whitespace", () => { const css = readFileSync(new URL("../src/core/export-html/template.css", import.meta.url), "utf-8"); expect(css).toMatch( - /\.tool-output > div,\s*\.output-preview,\s*\.output-full\s*\{[\s\S]*?white-space:\s*pre-wrap;/, + /\.output-preview > div:not\(\.expand-hint\),\s*\.output-full > div:not\(\.expand-hint\) \{[\s\S]*?white-space:\s*pre-wrap;/, + ); + expect(css).not.toMatch(/\.output-preview,\s*\.output-full\s*\{[\s\S]*?white-space:\s*pre-wrap;/); + }); + + it("does not insert source whitespace between ANSI-rendered lines", () => { + expect(ansiLinesToHtml(["one", "two"])).toBe('
one
two
'); + }); + + it("trims TUI spacing lines from custom tool result HTML", () => { + const component: Component = { render: () => ["", "\u001b[31mone\u001b[0m", "two", ""], invalidate: () => {} }; + const tool = { + name: "custom", + label: "custom", + description: "custom", + renderResult: () => component, + } as unknown as ToolDefinition; + const renderer = createToolHtmlRenderer({ + getToolDefinition: () => tool, + theme: {} as Theme, + cwd: "/tmp", + }); + + expect(renderer.renderResult("id", "custom", [], undefined, false)?.expanded).toBe( + '
one
two
', ); }); });