fix(images): correct show_image sizing/crop across three layers (#323, #324, #325)#326
fix(images): correct show_image sizing/crop across three layers (#323, #324, #325)#326kshivang wants to merge 5 commits into
Conversation
…#324, #325) Three independent bugs in the inline-image (OSC 1337 / show_image) pipeline: - #323 ImageDimensionCalculator: the Auto+Auto branch only clamped width overflow, never height, so a landscape image narrower than the pane but taller than it rendered at intrinsic size and clipped vertically. Now fits by the most-constrained axis (min of width/height scale), matching the existing "both specified" branch. - #324 Freshly created panes/tabs sized images against the placeholder 80x24 @ 10x20px grid: awaitPromptReady() only waited for the shell prompt (OSC 133;A), which has no ordering guarantee vs the Compose layout pass that supplies real cell metrics and the initial grid resize. BossTerminal gains a one-way isUiLayoutReady latch, set from ProperTerminal's initial layout resize (which now also pushes setCellDimensions explicitly), and the MCP server's wait (renamed awaitPaneReady) awaits both signals under the same timeout. - #325 ImageCell.totalCellsX/Y is a one-time snapshot baked into the buffer at insertion; when it exceeded the live visibleCols at draw time, columns past the pane edge were simply never drawn — a hard, permanent crop. TerminalCanvasRenderer now re-fits per frame: effective footprint = columns actually reachable from the anchor, rows shrunk proportionally to preserve aspect. Self-healing when the pane widens again. Adds ImageDimensionCalculatorTest covering the #323 regression plus overflow/percent sanity cases. Noted while investigating #325: ImageRenderer.renderImages() has no call sites (dead code); the per-cell path in TerminalCanvasRenderer is the only live renderer. Fixes #323 Fixes #324 Fixes #325 Generated with [Claude Code](https://claude.com/claude-code)
|
Code Review — PR #326: show_image sizing/crop fixes Reviewed all three fixes against the source. This is a clean, well-scoped PR — each change targets a distinct layer, the diagnosis in the description matches the code, and I verified the supporting claims (ImageRenderer.renderImages() genuinely has ZERO call sites; ImageCell.cellX/cellY/totalCellsX/Y mean what the re-fit math assumes). 👍 What is correct 323 — Auto+Auto axis clamping. The min(widthScale, heightScale) approach correctly fits by the most-constrained axis and mirrors the pre-existing "both specified" branch. The refactor is behavior-preserving for the old width-only path (targetWidthPx * widthScale == terminalWidthPx modulo rounding) while now also handling height/both. Good. 325 — draw-time re-fit. The logic is sound. I traced the invariants:
324 — layout-ready latch. One-way @volatile latch pushed from the initial onGloballyPositioned resize (guarded by hasPerformedInitialResize), awaited alongside the OSC 133;A signal under a shared timeout. The explicit setCellDimensions push in the layout callback closes the ordering race with the LaunchedEffect. Reasonable design. Minor observations (non-blocking)
Verdict No correctness or security concerns found; the per-cell re-fit adds only a few integer ops per image cell per frame (negligible). Nice work — approve pending the maintainer view on extracting the re-fit helper for testability. 🤖 Generated with Claude Code |
Field testing PR #326 showed show_image on fresh tabs still failed intermittently: full-width images baked into a ~2-row footprint, or content vanishing into scrollback. Three compounding causes: - The uiLayoutReady latch was guarded by hasPerformedInitialResize, which is composition-scoped state — and ProperTerminal's composition is REUSED across tab switches, so the latch never fired for any tab created after the first. Guard on the terminal's own latch instead. - A fresh pane's first layout pass can measure a throwaway near-zero canvas (rows clamped to 2), and injecting against that transient grid bakes the squashed footprint permanently. awaitPaneReady now requires the grid to hold still across 3 consecutive 60ms samples AND to be at least 10x3 before trusting it. - The layout wait shared the shell-prompt budget (mcpRunCommandShellReadyTimeoutMs, default 1.5s), which a slow shell rc could exhaust — expiring the gate at a random mid-settle moment. Layout settle now has its own fixed 3s budget. Verified against the dev build: 6/6 consecutive fresh-tab placements bake full-size (17-21 row) footprints; previously intermittent 2-row strips. Generated with [Claude Code](https://claude.com/claude-code)
|
Follow-up commit aea1379 after field-testing on the dev build: the #324 readiness gate was unreliable for freshly created tabs — images intermittently baked into a ~2-row squashed footprint (full width, 2 rows tall) or vanished into scrollback. Causes fixed:
Verification: 6/6 consecutive fresh-tab |
Review — PR #326: show_image sizing/crop fixesThorough, well-scoped PR. Each of the three fixes targets a genuinely distinct layer, the commit messages and inline comments are excellent (they explain the why and the race conditions clearly), and the follow-up commit shows real field validation. Overall this looks correct and ready. A few notes below. Correctness — looks good
Suggestions (non-blocking)
Nice work — the diagnosis quality here is high. Generated with Claude Code |
…ing (#324 follow-up 2) The readiness gate alone was still flaky under interactive layout churn: it samples the grid BEFORE injection, but a transient degenerate resize (rows clamped to 2 by a near-zero layout measure) could land in the gap between the gate passing and the emulator thread processing the OSC — baking a full-width, 2-row-squashed footprint permanently. Defense in depth, ending on the emulator thread where it is airtight: - ProperTerminal.onGloballyPositioned no longer applies resizes below 4x3 — a sub-3-row measure is a settling artifact, not a pane; the previous grid is kept until a plausible measure arrives. The uiLayoutReady latch likewise only arms off a sane measure, so it can no longer arm while the buffer still holds its unmeasured 80x24. - TabbedTerminal's background-tab grid propagation no longer fans out the active tab's grid until that tab has completed a real layout pass (isUiLayoutReady), and never fans out grids below 4x3. A fresh active tab previously broadcast its placeholder 80x24 — and any transient degenerate measure — to every background tab in the window. - BossTerminal now tracks the last sane grid (>= 10x3) across resizes, and processInlineImage sizes against it whenever the live grid is degenerate at the moment of insertion. This closes the race for ANY resize source, since it runs on the same thread-serialized state the calculator reads. Verified on the dev build: 8/8 back-to-back fresh-tab placements with zero settle time bake full-size footprints (11-25 rows, geometry- dependent); the 2-row squash no longer reproduces. Also observed while stress-testing (separate, pre-existing issue, to be filed): two CONCURRENT new-tab creations can leave a tab whose shell never starts (empty buffer, no prompt) — unrelated to sizing. Generated with [Claude Code](https://claude.com/claude-code)
|
Second follow-up (a455207): the gate alone was still flaky under interactive layout churn — it samples the grid before injection, but a transient degenerate resize (rows clamped to 2 by a near-zero layout measure) could land in the TOCTOU gap between the gate passing and the emulator processing the OSC. Three layers added, the last one airtight:
Verified: 8/8 back-to-back fresh-tab placements with zero settle time bake full-size footprints; the 2-row squash no longer reproduces. Separately filed #327 for an unrelated pre-existing race found while stress-testing (concurrent new-tab creations can leave a shell that never starts). |
Review:
|
…etry flight recorder Pixel-level verification (driving the MCP and screenshotting the window) surfaced why inline images still read as "thumbnail-sized" on retina: ImageDimensionCalculator receives cell metrics in DEVICE px (16.86x35 on a 2x display), but an image's intrinsic size and DimensionSpec.Pixels values are logical quantities. Auto-sized images therefore baked at half their intended size. Percent/Cells specs are unit-free and unaffected. - ImageDimensionCalculator gains a pixelScale param (device px per logical px); Auto/Pixels specs scale by it, aspect-fit ratios use the scaled intrinsic size. Default 1f keeps 1x displays byte-identical. - BossTerminal tracks the display scale (setDisplayScale), pushed by ProperTerminal alongside cell metrics from LocalDensity — including on the layout-ready latch path, and logs one INFO line per image insertion (grid, metrics, scale, computed dims) as a permanent diagnostic. - TerminalCanvasRenderer logs (deduped) whenever draw-time geometry disagrees with an image's baked footprint (re-fit engaged or negative anchor) — the silent-corruption state; healthy frames log nothing. Verified on the dev build via automated window capture + red-frame pixel analysis: auto+auto 960x560 now spans the full 73-col pane (1230px device) vs 57 cols before; percent-spec placements unchanged; 5/5 captured fresh-tab runs pixel-complete. Generated with [Claude Code](https://claude.com/claude-code)
|
Third follow-up (d210803), this time verified at the pixel level — driving the MCP against the dev build and screenshotting the actual window, not just checking buffer contents: New fix — HiDPI half-size images: Permanent flight recorder: one INFO line per image insertion (grid, metrics, scale, computed cells) in Verification summary on the current build: all insertions across the full test-script run + 7 fresh-tab placements sized against real grids (73×30); renderer drew every image row with |
Review —
|
… corruption root cause Deterministic repro found for the "sometimes squashed strip / sometimes missing" reports on this PR: place an image (renders pixel-perfect), then resize its pane (split, window resize, sidebar toggle) — the image collapses permanently into a 1-2 row strip with orphan fragments. Captured live: 876x455 px complete -> 590x32 px strip after one split. Root cause: ChangeWidthOperation reflows ImageCells like text characters — placeImageCell() wraps them onto continuation lines at whatever output column comes next. Each ImageCell encodes its position in the image grid (cellX/cellY vs totalCellsX/Y) and the renderer derives the anchor from `col - cellX`, so rewrapping scatters the geometry and the corruption is baked into the buffer forever. That is why placements looked flaky: every image was correct at insertion and died at the FIRST subsequent resize; when a settling resize landed within the same second, it appeared to never render at all. Fix: image-bearing rows pass through width reflow atomically (deep copy, no rewrap). Cells right of the new width simply don't draw, and the draw-time re-fit from the #325 fix compresses the visible portion to the live pane. If the pane widens again the intact cells render at full size — clipping is recoverable, corruption was not. Verified on the dev build (window capture + red-frame pixel analysis): same place-then-split sequence now yields 843x455 px — compressed, complete, all corners present. Full compose-ui desktopTest suite green (389 tests). Generated with [Claude Code](https://claude.com/claude-code)
|
Root cause of all the residual flakiness found, reproduced deterministically, and fixed (fb68b47). The failing pattern was never about insertion timing: an image renders pixel-perfect, then the first pane/window resize after insertion permanently corrupts it. Reproduced on demand: place image → complete (876×455 px) → split the pane → 590×32 px garbage strip. When a settling resize lands within the same second as placement (fresh tabs/splits), it looks like the image "never rendered" — hence every earlier report of intermittent strips and missing images. Mechanism: Fix: image-bearing rows pass through width reflow atomically (deep-copied, never rewrapped). Overflow cells simply don't draw, and the draw-time re-fit from the #325 fix compresses the visible portion to the live pane; when the pane widens again the intact cells render at full size. Clipping is recoverable — corruption was not. Verified via automated window capture + pixel analysis on the dev build: the same place-then-split sequence now yields a compressed-but-complete image (843×455, all corners present). Full |
Review:
|
Fixes the three
show_image/ OSC 1337 inline-image bugs filed together — each lives in a different layer of the pipeline, so all three are needed for images to render correctly.#323 — Auto+Auto sizing never clamped height
ImageDimensionCalculator.calculate()'s Auto+Auto branch only scaled down when the image was wider than the terminal. A landscape image narrower than the pane but taller than it (e.g. 960×560 in a wide-but-short horizontal split) rendered at full intrinsic size and clipped vertically.Fix: fit by the most-constrained axis — compute width and height scale factors, apply
min(widthScale, heightScale), the same approach the "both explicitly specified" branch already used.#324 — fresh panes sized against the placeholder 80×24 @ 10×20px grid
awaitPromptReady()waited only for the shell prompt (OSC 133;A). Real cell metrics and the real grid arrive via an independent Compose layout path with no ordering guarantee, and the prompt usually wins the race — so images (including explicit%specs) were sized againstterminalWidthPx = 800, terminalHeightPx = 480regardless of actual pane size.Fix:
BossTerminalgains a one-wayisUiLayoutReadylatch (markUiLayoutReady()).ProperTerminal's initial layout resize (inonGloballyPositioned) now pushessetCellDimensions(cellWidth, cellHeight)explicitly and sets the latch — the pre-existingLaunchedEffectpush has no ordering guarantee relative to the layout callback.awaitPaneReady) awaits both prompt readiness and the layout latch under the same timeout.#325 — baked cell footprint permanently cropped images
ImageCell.totalCellsX/Yis a one-time snapshot of the grid at insertion, butTerminalCanvasRendereriteratescol < visibleColscomputed live per frame. If the baked footprint exceeded the live width, the columns past the pane edge were simply never visited — a crisp, hard, permanent crop (the buffer footprint is never revisited, so it didn't self-heal even after the grid settled).Fix: re-fit at draw time. The renderer derives the anchor column (
col - cellX) and the columns actually reachable this frame; if the baked span exceeds that, the full bitmap is sliced across the available columns with the row count shrunk proportionally to preserve aspect ratio. Self-healing: if the pane widens again, the image returns to its original span.Side finding confirming the issue's suspicion:
ImageRenderer.renderImages()has zero call sites — the per-cell path inTerminalCanvasRendereris the only live inline-image renderer. Left in place to keep this PR focused.Tests
New
ImageDimensionCalculatorTest(compose-ui desktopTest): the #323 regression case (960×560 in a 1000×400px pane), width-only overflow, both-axis overflow, fits-unchanged, and explicit-percent sanity — 5/5 green. All touched modules compile.Manual check suggested before release:
show_imagewith a landscape PNG into a freshhorizontal_splitshould now come out full-size and uncropped.Fixes #323
Fixes #324
Fixes #325
🤖 Generated with Claude Code