Trim empty rows

This commit is contained in:
Jared Miller 2026-01-31 11:53:38 -05:00
parent ab61445bcc
commit 1b72d1e4e4
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 143 additions and 2 deletions

View file

@ -97,4 +97,71 @@ describe("terminal", () => {
expect(html).toMatch(/<span|<div|Test content/);
disposeTerminal(term);
});
test("trims empty trailing rows from HTML output", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Line 1\r\n");
await writeSync(term, "Line 2\r\n");
await writeSync(term, "Line 3\r\n");
const html = serializeAsHTML(term);
// Count rows in output - should be 3, not 24
const rowCount = (html.match(/<div><span/g) || []).length;
expect(rowCount).toBe(3);
// Content should be present
expect(html).toContain("Line 1");
expect(html).toContain("Line 2");
expect(html).toContain("Line 3");
disposeTerminal(term);
});
test("trims trailing whitespace within rows", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "Hello\r\n");
const html = serializeAsHTML(term);
// Should not have 80 spaces after "Hello"
expect(html).not.toMatch(/Hello\s{10,}/);
// Content should end cleanly
expect(html).toContain("Hello</span>");
disposeTerminal(term);
});
test("handles empty terminal gracefully", async () => {
const term = createTerminal(80, 24);
// No writes - terminal is empty
const html = serializeAsHTML(term);
// Should produce valid minimal HTML, not 24 empty rows
expect(html).toContain("<div style=");
expect(html).toContain("</pre>");
// Should have minimal row divs (ideally 0)
const rowCount = (html.match(/<div><span/g) || []).length;
expect(rowCount).toBe(0);
disposeTerminal(term);
});
test("preserves content with ANSI colors after trimming", async () => {
const term = createTerminal(80, 24);
await writeSync(term, "\x1b[32mGreen\x1b[0m text\r\n");
await writeSync(term, "Normal line\r\n");
const html = serializeAsHTML(term);
// Should have 2 rows
const rowCount = (html.match(/<div><span/g) || []).length;
expect(rowCount).toBe(2);
// Color styling should be preserved
expect(html).toContain("Green");
expect(html).toContain("Normal line");
// Check for color styling (green is typically #4e9a06 or similar)
expect(html).toMatch(/style='[^']*color[^']*'/);
disposeTerminal(term);
});
});

View file

@ -27,13 +27,87 @@ export function createTerminal(cols: number, rows: number): TerminalSession {
}
/**
* Serialize terminal screen buffer as HTML
* Serialize terminal screen buffer as HTML, trimming empty trailing rows
* and trailing whitespace within each row.
*
* xterm's serializeAsHTML outputs ALL rows (e.g., 50 for a 50-row terminal),
* padding empty lines with spaces. This creates a large black box below actual
* content. We trim these to show only content-bearing rows.
*/
export function serializeAsHTML(session: TerminalSession): string {
return session.serialize.serializeAsHTML({
const html = session.serialize.serializeAsHTML({
onlySelection: false,
includeGlobalBackground: true,
});
return trimHtmlOutput(html);
}
/**
* Trim empty trailing rows and trailing whitespace from xterm HTML output.
*
* Structure: <html><body><!--StartFragment--><pre><div style='...'><div><span>...</span></div>...</div></pre><!--EndFragment--></body></html>
* Each row is: <div><span>content</span></div>
*/
function trimHtmlOutput(html: string): string {
// Find the row container div
const wrapperStart = html.indexOf("<div style=");
const wrapperEnd = html.lastIndexOf("</div></pre>");
if (wrapperStart === -1 || wrapperEnd === -1) return html;
const firstDivEnd = html.indexOf(">", wrapperStart) + 1;
const rowsHtml = html.substring(firstDivEnd, wrapperEnd);
// Parse rows: each is <div><span>...</span></div>
const rows: string[] = [];
let pos = 0;
while (pos < rowsHtml.length) {
const divStart = rowsHtml.indexOf("<div>", pos);
if (divStart === -1) break;
const divEnd = rowsHtml.indexOf("</div>", divStart);
if (divEnd === -1) break;
rows.push(rowsHtml.substring(divStart, divEnd + 6));
pos = divEnd + 6;
}
// Find last non-empty row (working backwards)
let lastNonEmpty = -1;
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (!row) continue;
// Strip HTML tags and check for non-whitespace content
const text = row
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
if (text) {
lastNonEmpty = i;
break;
}
}
if (lastNonEmpty === -1) {
// All rows empty - return minimal HTML
const prefix = html.substring(0, firstDivEnd);
const suffix = html.substring(wrapperEnd);
return prefix + suffix;
}
// Trim trailing whitespace within each span of retained rows
const trimmedRows = rows.slice(0, lastNonEmpty + 1).map((row) => {
return row.replace(
/(<span[^>]*>)([^<]*)(<\/span>)/g,
(_, open, content, close) => {
const trimmed = content.replace(/[\s\u00a0]+$/, "");
return open + trimmed + close;
},
);
});
const prefix = html.substring(0, firstDivEnd);
const suffix = html.substring(wrapperEnd);
return prefix + trimmedRows.join("") + suffix;
}
/**