From 752703f399fb2b4b4bad34775b1c8ed908d46c60 Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Mon, 24 Aug 2026 15:21:10 +0800 Subject: [PATCH] Fix background block glyph rendering --- scripts/draw_terminal_patterns.sh | 191 ++++++++++++++++++++++++++++++ templates/index.html | 57 ++++++++- tests/agent_browser_smoke.py | 61 +++++++++- 3 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 scripts/draw_terminal_patterns.sh diff --git a/scripts/draw_terminal_patterns.sh b/scripts/draw_terminal_patterns.sh new file mode 100644 index 0000000..08ef74c --- /dev/null +++ b/scripts/draw_terminal_patterns.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +set -euo pipefail + +bar_width="" +clear_screen=1 + +usage() { + cat <<'USAGE' +Usage: scripts/draw_terminal_patterns.sh [--width N] [--no-clear] + +Draw a static ANSI terminal-rendering test card. The paired lower-half and +upper-half block rows are intended to reveal unintended horizontal seams. + +Options: + --width N Set the test bar width. Default: fit the terminal, up to 96. + --no-clear Keep the existing terminal contents above the test card. + -h, --help Show this help. +USAGE +} + +die() { + printf '[!] ERROR: %s\n' "$*" >&2 + exit 1 +} + +is_positive_int() { + case "${1:-}" in + ''|*[!0-9]*) + return 1 + ;; + *) + [ "$1" -gt 0 ] + ;; + esac +} + +detect_width() { + local stty_size + if stty_size="$(stty size 2>/dev/null)"; then + set -- $stty_size + if [ "$#" -eq 2 ] && is_positive_int "$2"; then + printf '%s\n' "$2" + return + fi + fi + + if is_positive_int "${COLUMNS:-}"; then + printf '%s\n' "$COLUMNS" + return + fi + + if command -v tput >/dev/null 2>&1; then + local tput_cols + if tput_cols="$(tput cols 2>/dev/null)" && is_positive_int "$tput_cols"; then + printf '%s\n' "$tput_cols" + return + fi + fi + + printf '80\n' +} + +repeat_text() { + local text="$1" + local count="$2" + local index + for ((index = 0; index < count; index++)); do + printf '%s' "$text" + done +} + +draw_glyph_row() { + local label="$1" + local color="$2" + local glyph="$3" + printf '%-22s%s' "$label" "$color" + repeat_text "$glyph" "$bar_width" + printf '%s\n' "$reset" +} + +draw_progress() { + local label="$1" + local percent="$2" + local filled=$((bar_width * percent / 100)) + local empty=$((bar_width - filled)) + printf '%-22s[' "$label" + printf '%s' "$green" + repeat_text '█' "$filled" + printf '%s' "$dim" + repeat_text '░' "$empty" + printf '%s] %3d%%%s\n' "$reset" "$percent" "$reset" +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --width) + [ "$#" -ge 2 ] || die '--width requires a value' + bar_width="$2" + shift 2 + ;; + --no-clear) + clear_screen=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +terminal_width="$(detect_width)" +if [ -z "$bar_width" ]; then + bar_width=$((terminal_width - 28)) + [ "$bar_width" -gt 96 ] && bar_width=96 +fi +is_positive_int "$bar_width" || die '--width must be a positive integer' +[ "$bar_width" -ge 8 ] || die '--width must be at least 8' + +reset=$'\033[0m' +bold=$'\033[1m' +dim=$'\033[38;5;240m' +cyan=$'\033[38;5;45m' +blue=$'\033[38;5;33m' +green=$'\033[38;5;82m' +yellow=$'\033[38;5;226m' +magenta=$'\033[38;5;201m' +blue_bg=$'\033[48;5;33m' +yellow_on_blue=$'\033[38;5;226;48;5;33m' +yellow_on_yellow=$'\033[38;5;226;48;5;226m' + +if [ "$clear_screen" -eq 1 ]; then + printf '\033[2J\033[H' +fi + +printf '%sStandTerm terminal rendering test card%s\n' "$bold" "$reset" +printf 'Terminal: %s columns | Test bar: %s cells\n\n' "$terminal_width" "$bar_width" + +printf '%s[1] Adjacent full-block rows%s (no dark seam expected)\n' "$bold" "$reset" +draw_glyph_row 'full block row 1' "$cyan" '█' +draw_glyph_row 'full block row 2' "$cyan" '█' +draw_glyph_row 'full block row 3' "$cyan" '█' +printf '\n' + +printf '%s[2] Half-block boundary seam%s\n' "$bold" "$reset" +printf 'The GOOD pair should join at the row boundary; the CONTROL pair has an intentional dark band.\n' +draw_glyph_row 'GOOD top: lower half' "$yellow" '▄' +draw_glyph_row 'GOOD bottom: upper' "$yellow" '▀' +draw_glyph_row 'CONTROL top: upper' "$magenta" '▀' +draw_glyph_row 'CONTROL bottom: lower' "$magenta" '▄' +printf '\n' + +printf '%s[3] Background-cell fill%s (geometry reference; no seam expected)\n' "$bold" "$reset" +draw_glyph_row 'background row 1' "$blue_bg" ' ' +draw_glyph_row 'background row 2' "$blue_bg" ' ' +draw_glyph_row 'background row 3' "$blue_bg" ' ' +printf '\n' + +printf '%s[4] Half blocks on explicit cell backgrounds%s\n' "$bold" "$reset" +printf 'Yellow should meet at the GOOD boundary; any exposed seam should be blue, not black.\n' +draw_glyph_row 'GOOD blue/yellow low' "$yellow_on_blue" '▄' +draw_glyph_row 'GOOD blue/yellow up' "$yellow_on_blue" '▀' +draw_glyph_row 'same-color lower' "$yellow_on_yellow" '▄' +draw_glyph_row 'same-color upper' "$yellow_on_yellow" '▀' +printf '\n' + +printf '%s[5] Progress and fractional-width glyphs%s\n' "$bold" "$reset" +draw_progress 'progress 25%' 25 +draw_progress 'progress 50%' 50 +draw_progress 'progress 75%' 75 +printf '%-22s%s' 'fraction ramp' "$blue" +repeat_text '▏▎▍▌▋▊▉█' "$((bar_width / 8))" +repeat_text '█' "$((bar_width % 8))" +printf '%s\n' "$reset" +draw_glyph_row 'lower half only' "$green" '▄' +draw_glyph_row 'upper half only' "$green" '▀' +printf '\n' + +printf '%s[6] Box drawing, wide text, and ANSI colors%s\n' "$bold" "$reset" +printf 'single: ╭──────────────╮ double: ╔══════════════╗ heavy: ┏━━━━━━━━━━━━━━┓\n' +printf ' │ StandTerm │ ║ StandTerm ║ ┃ StandTerm ┃\n' +printf ' ╰──────────────╯ ╚══════════════╝ ┗━━━━━━━━━━━━━━┛\n' +printf 'wide: 中文測試 日本語 한글 ABC emoji: 🟩🟨🟥\n' +printf 'colors: ' +for color_index in 196 202 226 46 51 33 93 201; do + printf '\033[48;5;%sm %s' "$color_index" "$reset" +done +printf '\n%sEnd of static test card.%s\n' "$bold" "$reset" diff --git a/templates/index.html b/templates/index.html index 0b08097..6f85be2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2641,6 +2641,49 @@

Access token required

}; } + function drawAgentMirrorBlockGlyph(context, chars, x, y, cellWidth, cellHeight) { + const left = Math.floor(x); + const top = Math.floor(y); + const right = Math.ceil(x + cellWidth); + const bottom = Math.ceil(y + cellHeight); + const width = Math.max(1, right - left); + const height = Math.max(1, bottom - top); + let blockLeft = left; + let blockTop = top; + let blockWidth = width; + let blockHeight = height; + + const lowerBlocks = '▁▂▃▄▅▆▇'; + const lowerIndex = lowerBlocks.indexOf(chars); + if (lowerIndex >= 0) { + blockHeight = Math.max(1, Math.ceil(height * (lowerIndex + 1) / 8)); + blockTop = bottom - blockHeight; + } else { + const leftBlocks = '▏▎▍▌▋▊▉'; + const leftIndex = leftBlocks.indexOf(chars); + if (leftIndex >= 0) { + blockWidth = Math.max(1, Math.ceil(width * (leftIndex + 1) / 8)); + } else if (chars === '█') { + // The default rectangle already covers the complete cell. + } else if (chars === '▀') { + blockHeight = Math.max(1, Math.ceil(height / 2)); + } else if (chars === '▐') { + blockWidth = Math.max(1, Math.ceil(width / 2)); + blockLeft = right - blockWidth; + } else if (chars === '▔') { + blockHeight = Math.max(1, Math.ceil(height / 8)); + } else if (chars === '▕') { + blockWidth = Math.max(1, Math.ceil(width / 8)); + blockLeft = right - blockWidth; + } else { + return false; + } + } + + context.fillRect(blockLeft, blockTop, blockWidth, blockHeight); + return true; + } + function renderAgentTerminalMirrorToPng(state, renderElement) { syncAgentTerminalMirrorSize(state); const mirror = state.agentTerminalMirror; @@ -2701,15 +2744,21 @@

Access token required

if (!chars || cell.isInvisible()) continue; const weight = cell.isBold() ? 'bold' : fontWeight; const style = cell.isItalic() ? 'italic' : 'normal'; - context.font = `${style} ${weight} ${fontSize}px ${fontFamily}`; context.globalAlpha = cell.isDim() ? 0.5 : 1; context.fillStyle = foreground; - context.fillText( + const glyphWidth = geometry.cellWidth * Math.max(1, cell.getWidth()); + const drewBlockGlyph = drawAgentMirrorBlockGlyph( + context, chars, x, - y + baselineOffset, - geometry.cellWidth * Math.max(1, cell.getWidth()) + y, + glyphWidth, + geometry.cellHeight ); + if (!drewBlockGlyph) { + context.font = `${style} ${weight} ${fontSize}px ${fontFamily}`; + context.fillText(chars, x, y + baselineOffset, glyphWidth); + } context.globalAlpha = 1; context.strokeStyle = foreground; context.lineWidth = 1; diff --git a/tests/agent_browser_smoke.py b/tests/agent_browser_smoke.py index 8d1cf4b..7bf4e63 100644 --- a/tests/agent_browser_smoke.py +++ b/tests/agent_browser_smoke.py @@ -976,7 +976,14 @@ def test_background_terminal_render_uses_mirror_canvas_png(browser, access_url): page.evaluate("() => window.terminalTest.applyColorScheme('oneHalfLight')") page.evaluate( """payload => window.terminalTest.writeTerminalOutput(payload.data, payload.output_seq)""", - {'data': '\x1b[31mbackground-render-check\x1b[0m\r\n', 'output_seq': 322}, + { + 'data': ( + '\x1b[2J\x1b[H\x1b[38;5;45m' + '████████\r\n████████\r\n████████\r\n' + '\x1b[38;5;226;48;5;33m▄▄▄▄▄▄▄▄\r\n▀▀▀▀▀▀▀▀\x1b[0m' + ), + 'output_seq': 322, + }, ) page.wait_for_function( "() => window.terminalTest.getMirrorSnapshot()?.output_seq === 322", @@ -1016,18 +1023,68 @@ def test_background_terminal_render_uses_mirror_canvas_png(browser, access_url): context.drawImage(image, 0, 0); const pixels = context.getImageData(0, 0, image.width, image.height).data; let nonBackgroundPixels = 0; + const cyanRows = []; + const yellowRows = []; + const cyanColumns = []; for (let index = 0; index < pixels.length; index += 4) { if (pixels[index] < 245 || pixels[index + 1] < 245 || pixels[index + 2] < 245) { nonBackgroundPixels += 1; } } - return { width: image.width, height: image.height, nonBackgroundPixels }; + for (let y = 0; y < image.height; y += 1) { + let cyanCount = 0; + let yellowCount = 0; + for (let x = 0; x < image.width; x += 1) { + const offset = (y * image.width + x) * 4; + const red = pixels[offset]; + const green = pixels[offset + 1]; + const blue = pixels[offset + 2]; + if (red < 40 && green >= 190 && blue >= 220) cyanCount += 1; + if (red >= 220 && green >= 220 && blue < 40) yellowCount += 1; + } + if (cyanCount >= 20) cyanRows.push(y); + if (yellowCount >= 20) yellowRows.push(y); + } + for (let x = 0; x < image.width; x += 1) { + let cyanCount = 0; + for (let y = 0; y < image.height; y += 1) { + const offset = (y * image.width + x) * 4; + const red = pixels[offset]; + const green = pixels[offset + 1]; + const blue = pixels[offset + 2]; + if (red < 40 && green >= 190 && blue >= 220) cyanCount += 1; + } + if (cyanCount >= 10) cyanColumns.push(x); + } + const maxStep = values => values.reduce( + (largest, value, index) => index === 0 + ? largest + : Math.max(largest, value - values[index - 1]), + 0 + ); + return { + width: image.width, + height: image.height, + nonBackgroundPixels, + cyanRowCount: cyanRows.length, + cyanRowMaxStep: maxStep(cyanRows), + cyanColumnCount: cyanColumns.length, + cyanColumnMaxStep: maxStep(cyanColumns), + yellowRowCount: yellowRows.length, + yellowRowMaxStep: maxStep(yellowRows) + }; }""", result, ) check(decoded['width'] == result['pixel_width'], 'background PNG width metadata does not match the image') check(decoded['height'] == result['pixel_height'], 'background PNG height metadata does not match the image') check(decoded['nonBackgroundPixels'] > 0, 'background PNG did not contain terminal glyphs') + check(decoded['cyanRowCount'] > 20, 'background PNG did not render enough full-block rows') + check(decoded['cyanRowMaxStep'] == 1, 'background PNG retained horizontal full-block seams') + check(decoded['cyanColumnCount'] > 20, 'background PNG did not render enough full-block columns') + check(decoded['cyanColumnMaxStep'] == 1, 'background PNG retained vertical full-block seams') + check(decoded['yellowRowCount'] > 8, 'background PNG did not render enough half-block rows') + check(decoded['yellowRowMaxStep'] == 1, 'background PNG retained a half-block boundary seam') finally: close_context(context)