From ba420dd94641835cd20fe20f022d55808d151e5a Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:38:53 +1300 Subject: [PATCH 1/3] Read iTerm2's cell size scale so About's image fills its space on Retina (#333) ReportCellSize replies in points plus a scale factor; we dropped the scale and drew sixels at half size in each direction on a 2.0 display. Multiply by it, clamped to 1-4, defaulting to 1 when older iTerm2s omit it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 1 + src/TuiCode.Workbench/About/SixelProbe.cs | 8 +++++--- tests/TuiCode.Tests/AboutViewTests.cs | 22 +++++++++++++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9a49fd..7dbe6f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,7 @@ What follows is how TuiCode implements it today. - `tui` opens `AboutView`. If the terminal supports sixel (WezTerm, iTerm2, tmux 3.6, Windows Terminal, foot…) it shows the artwork as an image. Otherwise (kitty, Ghostty, Terminal.app — TG 2.1.0 has no kitty graphics) it shows ASCII art. - `SixelProbe` runs once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. We send DA1, then iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and take the first answer. It also parses tmux's DA1 reply (`…;4c`), which TG's check misses. +- iTerm2 reports sizes in points everywhere, its `CSI 14 t` reply included. Only `ReportCellSize` carries the scale (`2.0` on Retina), so `ParseIterm2CellSize` multiplies by it (clamped to 1–4; absent in older iTerm2s, meaning 1). Sixels are drawn in device pixels, so ignoring it drew the About image at a quarter of its area (#333). - The cell size has to be exact: iTerm2 blanks every row an image touches, so an image that ends mid-row leaves a dark band (`AboutImage.Fit` rounds down to whole rows and `Cover` trims the top/bottom to match). - Encoding runs in the background and is cached per pixel size, so reopening shows the image immediately. - TG re-emits queued sixels on every output write and only rewrites cells whose contents changed, so `AboutView.Dispose` dequeues its sixel and sets `ClearScreenNextIteration`. Otherwise the image stays on screen after close. diff --git a/src/TuiCode.Workbench/About/SixelProbe.cs b/src/TuiCode.Workbench/About/SixelProbe.cs index 26b1020..13fe461 100644 --- a/src/TuiCode.Workbench/About/SixelProbe.cs +++ b/src/TuiCode.Workbench/About/SixelProbe.cs @@ -90,11 +90,13 @@ internal static bool IndicatesSixel(string? response) => return match.Success ? Positive(match.Groups[2].Value, match.Groups[1].Value) : null; } - /// Reads iTerm2's OSC 1337 ; ReportCellSize=height;width;scale ST. + /// Reads iTerm2's OSC 1337 ; ReportCellSize=height;width;scale ST, in points times the scale. internal static SizeF? ParseIterm2CellSize(string? response) { - var match = Regex.Match(response ?? "", @"ReportCellSize=([\d.]+);([\d.]+)"); - return match.Success ? Positive(match.Groups[2].Value, match.Groups[1].Value) : null; + var match = Regex.Match(response ?? "", @"ReportCellSize=([\d.]+);([\d.]+)(?:;([\d.]+))?"); + if (!match.Success || Positive(match.Groups[2].Value, match.Groups[1].Value) is not { } points) return null; + var scale = float.TryParse(match.Groups[3].Value, CultureInfo.InvariantCulture, out var s) ? Math.Clamp(s, 1, 4) : 1; + return scale == 1 ? points : new SizeF(MathF.Round(points.Width * scale), MathF.Round(points.Height * scale)); } private static SizeF? Positive(string width, string height) => diff --git a/tests/TuiCode.Tests/AboutViewTests.cs b/tests/TuiCode.Tests/AboutViewTests.cs index 6bd46be..aad81b0 100644 --- a/tests/TuiCode.Tests/AboutViewTests.cs +++ b/tests/TuiCode.Tests/AboutViewTests.cs @@ -70,18 +70,26 @@ public void Cover_trims_the_top_and_bottom_to_keep_the_aspect_ratio() public class SixelProbeTests { - [Fact] - public void ParseIterm2CellSize_reads_width_and_height() - { - var size = SixelProbe.ParseIterm2CellSize("\x1b]1337;ReportCellSize=26.5;11.0;2.0\x1b\\"); - - Assert.Equal(new System.Drawing.SizeF(11f, 26.5f), size); - } + [Theory] + [InlineData("17.50;8.00;2.0", 16, 35)] + [InlineData("26.0;11.0;2.0", 22, 52)] + [InlineData("26.5;11.0;1.0", 11, 26.5)] + [InlineData("26.5;11.0", 11, 26.5)] + [InlineData("17.5;8.0;1.5", 12, 26)] + [InlineData("20;10;400", 40, 80)] + [InlineData("20;10;0", 10, 20)] + [InlineData("20;10;1.2.3", 10, 20)] + public void ParseIterm2CellSize_reads_the_cell_in_device_pixels(string reply, float width, float height) => + Assert.Equal( + new System.Drawing.SizeF(width, height), + SixelProbe.ParseIterm2CellSize($"\x1b]1337;ReportCellSize={reply}\x1b\\")); [Theory] [InlineData(null)] [InlineData("\x1b[?64;1;2;4c")] [InlineData("\x1b]1337;ReportCellSize=0;11\x1b\\")] + [InlineData("\x1b]1337;ReportCellSize=0;11;2.0\x1b\\")] + [InlineData("\x1b]1337;ReportCellSize=1.2.3;11;2.0\x1b\\")] public void ParseIterm2CellSize_rejects_other_replies(string? response) => Assert.Null(SixelProbe.ParseIterm2CellSize(response)); From 1c96426968f705b88f8873d1e16dc18c96dfe078 Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:40:16 +1300 Subject: [PATCH 2/3] Measure the cell size each time About opens, not once at startup (#333) Moving the window between a Retina and a non-Retina screen changes how many pixels a cell is, so a size measured at startup drew the image too big or too small afterwards. Sixel support still comes from DA1 once at startup. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/TuiCode.Workbench/About/SixelProbe.cs | 19 ++++++----------- src/TuiCode.Workbench/WorkbenchHost.cs | 26 ++++++++++++++++++----- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7dbe6f9..c23d75c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ What follows is how TuiCode implements it today. ## About dialog (#107) - `tui` opens `AboutView`. If the terminal supports sixel (WezTerm, iTerm2, tmux 3.6, Windows Terminal, foot…) it shows the artwork as an image. Otherwise (kitty, Ghostty, Terminal.app — TG 2.1.0 has no kitty graphics) it shows ASCII art. -- `SixelProbe` runs once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. We send DA1, then iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and take the first answer. It also parses tmux's DA1 reply (`…;4c`), which TG's check misses. +- `SixelProbe.Detect` sends DA1 once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. `SixelProbe.MeasureCell` runs each time About opens, because the cell's pixel size changes when the window moves between a Retina and a non-Retina screen. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. `MeasureCell` sends iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and takes the first answer. It also parses tmux's DA1 reply (`…;4c`), which TG's check misses. - iTerm2 reports sizes in points everywhere, its `CSI 14 t` reply included. Only `ReportCellSize` carries the scale (`2.0` on Retina), so `ParseIterm2CellSize` multiplies by it (clamped to 1–4; absent in older iTerm2s, meaning 1). Sixels are drawn in device pixels, so ignoring it drew the About image at a quarter of its area (#333). - The cell size has to be exact: iTerm2 blanks every row an image touches, so an image that ends mid-row leaves a dark band (`AboutImage.Fit` rounds down to whole rows and `Cover` trims the top/bottom to match). - Encoding runs in the background and is cached per pixel size, so reopening shows the image immediately. diff --git a/src/TuiCode.Workbench/About/SixelProbe.cs b/src/TuiCode.Workbench/About/SixelProbe.cs index 13fe461..4c9d85d 100644 --- a/src/TuiCode.Workbench/About/SixelProbe.cs +++ b/src/TuiCode.Workbench/About/SixelProbe.cs @@ -14,29 +14,22 @@ internal static class SixelProbe { private static readonly SizeF DefaultCellPixels = new(10, 20); - /// Asks the terminal whether it draws sixel images and how many pixels a cell is. - public static void Detect(IDriver driver, Action found) + /// Asks the terminal whether it draws sixel images. + public static void Detect(IDriver driver, Action found) { if (driver.IsLegacyConsole) { - found(SixelSupport.Unsupported); + found(false); return; } - Queue(driver, EscSeqUtils.CSI_SendDeviceAttributes, - response => - { - if (IndicatesSixel(response)) - FindCellPixels(driver, cellPixels => found(new SixelSupport(true, cellPixels))); - else - found(SixelSupport.Unsupported); - }, - () => found(SixelSupport.Unsupported)); + Queue(driver, EscSeqUtils.CSI_SendDeviceAttributes, response => found(IndicatesSixel(response)), () => found(false)); } // iTerm2 answers only its own query and most other terminals only CSI 16 t. An unanswered query takes TG a // second to abandon, so ask both at once and take the first answer. - private static void FindCellPixels(IDriver driver, Action found) + /// Asks how many pixels a cell is. + public static void MeasureCell(IDriver driver, Action found) { var settled = false; var misses = 0; diff --git a/src/TuiCode.Workbench/WorkbenchHost.cs b/src/TuiCode.Workbench/WorkbenchHost.cs index 9b36551..f50267c 100644 --- a/src/TuiCode.Workbench/WorkbenchHost.cs +++ b/src/TuiCode.Workbench/WorkbenchHost.cs @@ -75,7 +75,7 @@ public sealed class WorkbenchHost : IDisposable private AboutView? _activeAbout; private DocumentInfoView? _activeDocumentInfo; private BlameView? _activeBlame; - private SixelSupport? _sixelSupport; + private bool? _sixelSupported; private MnemonicView? _activeMnemonics; private OpenView? _activeOpen; private PathPromptView? _activePathPrompt; @@ -126,10 +126,10 @@ public WorkbenchHost( _terminalCursors.Detect(); // Detected up front so About can show a spinner rather than ASCII art that the image then replaces. if (_app.Driver is { } driver) - SixelProbe.Detect(driver, support => _app.Invoke(() => + SixelProbe.Detect(driver, supported => _app.Invoke(() => { - _sixelSupport = support; - _activeAbout?.Present(support); + _sixelSupported = supported; + if (_activeAbout is { } about) PresentAbout(about); })); _workbench = workbench; _commands = commands; @@ -1461,7 +1461,23 @@ private void OpenAbout() _scopes.Push(view.Scope); view.SetFocus(); - view.Present(_sixelSupport); + PresentAbout(view); + } + + private void PresentAbout(AboutView view) + { + if (_sixelSupported is not true || _app.Driver is not { } driver) + { + view.Present(_sixelSupported is null ? null : SixelSupport.Unsupported); + return; + } + + view.Present(null); + // Measured on every open: the window may have moved to a screen with another scale since the last one. + SixelProbe.MeasureCell(driver, cellPixels => _app.Invoke(() => + { + if (ReferenceEquals(_activeAbout, view)) view.Present(new SixelSupport(true, cellPixels)); + })); } private void CloseAbout(AboutView view) From 31b979235e04ae2ba918ef7b5bde254b76a98aae Mon Sep 17 00:00:00 2001 From: "a-team-app[bot]" <334837322+a-team-app[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:40:24 +1300 Subject: [PATCH 3/3] Say which probe parses tmux's DA1 reply Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c23d75c..c32b1e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,7 @@ What follows is how TuiCode implements it today. ## About dialog (#107) - `tui` opens `AboutView`. If the terminal supports sixel (WezTerm, iTerm2, tmux 3.6, Windows Terminal, foot…) it shows the artwork as an image. Otherwise (kitty, Ghostty, Terminal.app — TG 2.1.0 has no kitty graphics) it shows ASCII art. -- `SixelProbe.Detect` sends DA1 once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. `SixelProbe.MeasureCell` runs each time About opens, because the cell's pixel size changes when the window moves between a Retina and a non-Retina screen. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. `MeasureCell` sends iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and takes the first answer. It also parses tmux's DA1 reply (`…;4c`), which TG's check misses. +- `SixelProbe.Detect` sends DA1 once at startup (`WorkbenchHost` ctor), so the dialog knows up front and shows a spinner, not ASCII art that gets replaced. `SixelProbe.MeasureCell` runs each time About opens, because the cell's pixel size changes when the window moves between a Retina and a non-Retina screen. We don't use TG's `SixelSupportDetector`: it asks `CSI 16 t` first and iTerm2 never answers, which costs TG's 1 s abandon timeout, and its fallback (window pixels ÷ cells) counts the title bar and margins. `MeasureCell` sends iTerm2's `OSC 1337 ; ReportCellSize` and `CSI 16 t` together and takes the first answer. `Detect` also parses tmux's DA1 reply (`…;4c`), which TG's check misses. - iTerm2 reports sizes in points everywhere, its `CSI 14 t` reply included. Only `ReportCellSize` carries the scale (`2.0` on Retina), so `ParseIterm2CellSize` multiplies by it (clamped to 1–4; absent in older iTerm2s, meaning 1). Sixels are drawn in device pixels, so ignoring it drew the About image at a quarter of its area (#333). - The cell size has to be exact: iTerm2 blanks every row an image touches, so an image that ends mid-row leaves a dark band (`AboutImage.Fit` rounds down to whole rows and `Cover` trims the top/bottom to match). - Encoding runs in the background and is cached per pixel size, so reopening shows the image immediately.