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
',
);
});
});