From 0226c5c9cb804247a88b3bd9c4a46af420ca681b Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 26 Jul 2026 23:36:24 +0000 Subject: [PATCH 1/2] test(js): reproduce padded SVG text stretching --- js/tests/terminal-capture.test.mjs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/js/tests/terminal-capture.test.mjs b/js/tests/terminal-capture.test.mjs index abcac69..b9c6fca 100644 --- a/js/tests/terminal-capture.test.mjs +++ b/js/tests/terminal-capture.test.mjs @@ -173,6 +173,36 @@ describe('PTY terminal capture', () => { expect(duration).toBeLessThan(0.5); }); + test('measures visible glyphs without stretching terminal row padding', async () => { + const artifactDirectory = await mkdtemp( + join(tmpdir(), 'command-stream-tui-padding-') + ); + temporaryDirectories.push(artifactDirectory); + + await captureTerminal({ + file: process.execPath, + args: ['-e', "process.stdout.write('\\u001b[3Gok 表')"], + cols: 12, + rows: 2, + artifactDirectory, + artifactOptions: { + cellWidth: 10, + cellHeight: 20, + padding: 0, + }, + }); + + const snapshot = await readFile( + join(artifactDirectory, 'snapshot.svg'), + 'utf8' + ); + expect(snapshot).toMatch( + /]*textLength="50"[^>]*>ok 表<\/text>/ + ); + expect(snapshot).not.toMatch(/]*>[ \t]/); + expect(snapshot).not.toMatch(/[ \t]<\/text>/); + }); + test('unrolls scrolled-off lines in order and exactly once', async () => { const capture = await captureTerminal({ file: process.execPath, From 6e89fc4987c3ff418a582a2ff0723ac7a90161e4 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 26 Jul 2026 23:37:54 +0000 Subject: [PATCH 2/2] fix(js): exclude row padding from SVG text runs --- js/.changeset/calm-terminals-align.md | 6 ++++++ js/src/terminal-artifacts.mjs | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 js/.changeset/calm-terminals-align.md diff --git a/js/.changeset/calm-terminals-align.md b/js/.changeset/calm-terminals-align.md new file mode 100644 index 0000000..c4abb5c --- /dev/null +++ b/js/.changeset/calm-terminals-align.md @@ -0,0 +1,6 @@ +--- +'command-stream': patch +--- + +Render terminal SVG text at its visible cell-grid position and width without +stretching glyphs across leading or trailing row padding. diff --git a/js/src/terminal-artifacts.mjs b/js/src/terminal-artifacts.mjs index d6f3dcc..e29d7d8 100644 --- a/js/src/terminal-artifacts.mjs +++ b/js/src/terminal-artifacts.mjs @@ -153,6 +153,8 @@ const cellStyle = (cell, options) => { const sameStyle = (left, right) => Object.keys(left).every((key) => left[key] === right[key]); +const isBlankCell = (cell) => (cell.chars || ' ').trim() === ''; + const coalesceRow = (cells, options) => { const runs = []; for (let column = 0; column < cells.length; ) { @@ -161,13 +163,19 @@ const coalesceRow = (cells, options) => { column += 1; continue; } + if (isBlankCell(cell)) { + column += Math.max(cell.width, 1); + continue; + } const style = cellStyle(cell, options); const run = { column, width: Math.max(cell.width, 1), - text: cell.chars || ' ', + text: cell.chars, style, }; + let visibleTextLength = run.text.length; + let visibleWidth = run.width; column += Math.max(cell.width, 1); while (column < cells.length) { const next = cells[column]; @@ -182,7 +190,13 @@ const coalesceRow = (cells, options) => { run.text += next.chars || ' '; run.width += Math.max(next.width, 1); column += Math.max(next.width, 1); + if (!isBlankCell(next)) { + visibleTextLength = run.text.length; + visibleWidth = run.width; + } } + run.text = run.text.slice(0, visibleTextLength); + run.width = visibleWidth; runs.push(run); } return runs;