Version: Terminal.Gui 2.4.17 · net10.0 · Windows 11 (10.0.26200) · default (ansi) driver, but all three drivers are affected.
Summary
OutputBase.Write(IOutputBuffer) walks each dirty row cell by cell. For every cell it finds clean — i.e. every cell it is skipping — it calls SetCursorPositionImpl, which on every driver performs real I/O.
Because OutputBufferImpl marks DirtyLines[Row] = true unconditionally whenever a grapheme is written to a row (while cell-level IsDirty is correctly set only when content actually changes), a steady-state frame where nothing has changed still has every row flagged dirty. The result is a full rows × cols scan issuing one cursor-positioning syscall per cell, producing no visible output.
In a default dotnet new tui app this makes a mouse-hover repaint cost ~100 ms, i.e. ~10 fps, and it is what users perceive as the UI lagging up to a second behind the mouse.
The code
Terminal.Gui/Drivers/Output/OutputBase.cs, in Write(IOutputBuffer) (decompiled from 2.4.17):
for (int j = num; j < cols; j++)
{
lastCol = -1;
int outputWidth = 0;
for (; j < cols; j++)
{
bool flag3 = IsRasterCoveredBlankCell(buffer, i, j, rasterCellRectangles);
if (!buffer.Contents[i, j].IsDirty | flag3) // cell is CLEAN
{
if (flag3) { buffer.Contents[i, j].IsDirty = false; }
if (stringBuilder.Length > 0)
WriteToConsole(stringBuilder, ref lastCol, ref outputWidth);
else if (lastCol == -1)
lastCol = j;
if (lastCol + 1 < cols) { lastCol++; }
SetCursorPositionImpl(lastCol, i); // ← one I/O op per skipped cell
continue;
}
// ...
}
}
lastCol advances by one each iteration, so the position always differs and the call always performs I/O:
ansi — AnsiOutput.SetCursorPositionImpl → Write(ReadOnlySpan<char>), which allocates a fresh StringBuilder, does Encoding.UTF8.GetBytes, and issues its own WriteFile for a ~7-byte CSI r;cH.
dotnet — NetOutput.SetCursorPositionImpl → Console.SetCursorPosition inside a bare try {} catch {}. That calls ConsolePal.GetBufferInfo, and where the buffer can't be queried it throws and swallows a Win32 exception per cell.
Measurements
Stock dotnet new tui app, unmodified except for instrumentation, 6-second runs with synthetic mouse-move events over the button:
|
iterations/s |
frame p50 |
frame p90 |
| stock |
8 |
110 ms |
135 ms |
| with cursor moves coalesced |
44 |
15.8 ms |
31 ms |
- Dirty rows per frame: 30 of 30 (p50 = p90 = max), ~3,540 cells scanned per frame.
SetCursorPositionImpl calls: 861,245 across 246 frames — 538 of which actually needed to move the cursor. 99.94% of the I/O is redundant.
- 51 of 52 frames wrote zero characters to the terminal. The ~100 ms is spent entirely on cursor calls that render nothing.
- 8 of 8 sampled stacks (
dotnet-stack) sat in OutputBase.Write → SetCursorPositionImpl → ConsolePal.SetCursorPosition → GetBufferInfo, four of them inside Win32Marshal.GetExceptionForWin32Error / EH.DispatchEx.
The cost scales with terminal area, and with per-write cost in the host — through ConPTY (VS Code's integrated terminal), where each write round-trips, the same ~3,500 syscalls comfortably reach a second per frame.
Mouse dispatch itself is not implicated: injecting two mouse events costs 0.1 ms, and hover → MouseStateChanged latency is p50 0.0 ms.
Reproducing
dotnet new tui and run it.
- Move the mouse back and forth across the
Increment button.
- The highlight lags conspicuously; in VS Code's integrated terminal it can be ~1 s behind.
To see it directly, count calls into SetCursorPositionImpl versus the number that change the position.
Suggested fix
Don't emit while skipping. Track the desired position and flush at most one cursor escape immediately before content that actually needs it:
protected override bool SetCursorPositionImpl (int col, int row)
{
_want = new Point (col, row); // record only
return true;
}
protected override void Write (StringBuilder output)
{
if (_want is { } w && w != _emitted)
{
_pending.Append (EscSeqUtils.CSI_SetCursorPosition (w.Y + 1, w.X + 1));
_emitted = w;
}
_pending.Append (output); // one write per frame
}
That is what produced the 44 iterations/s column above. I verified it is equivalent and not merely faster: replaying both escape streams through a terminal model yields identical cell grids, and the two streams are byte-identical once redundant cursor escapes are stripped.
Two things are worth fixing independently:
- The scan itself — skipping clean cells should advance a counter, not perform I/O.
DirtyLines[Row] = true being set unconditionally on every grapheme write in OutputBufferImpl, even when the cell content is unchanged. Gating it on an actual change would mean an idle frame scans nothing at all.
Note on working around it
There is currently no supported way for an application to work around this, because ApplicationImpl is internal — so although AnsiComponentFactory accepts an IOutput, nothing public can construct an application with a custom one. The only route is reflection over ApplicationImpl(IComponentFactory). Making that constructor (or an equivalent factory hook) public would give applications an escape hatch.
Environment
OS: Microsoft Windows 11 Home 10.0.26200
Terminal: Windows Terminal 1.24.11911.0
PowerShell: 5.1.26100.9278
.NET SDK: 10.0.204
TargetFramework: net10.0
Terminal.Gui: 2.4.17 (NuGet PackageReference)
Version: Terminal.Gui 2.4.17 · net10.0 · Windows 11 (10.0.26200) · default (
ansi) driver, but all three drivers are affected.Summary
OutputBase.Write(IOutputBuffer)walks each dirty row cell by cell. For every cell it finds clean — i.e. every cell it is skipping — it callsSetCursorPositionImpl, which on every driver performs real I/O.Because
OutputBufferImplmarksDirtyLines[Row] = trueunconditionally whenever a grapheme is written to a row (while cell-levelIsDirtyis correctly set only when content actually changes), a steady-state frame where nothing has changed still has every row flagged dirty. The result is a full rows × cols scan issuing one cursor-positioning syscall per cell, producing no visible output.In a default
dotnet new tuiapp this makes a mouse-hover repaint cost ~100 ms, i.e. ~10 fps, and it is what users perceive as the UI lagging up to a second behind the mouse.The code
Terminal.Gui/Drivers/Output/OutputBase.cs, inWrite(IOutputBuffer)(decompiled from 2.4.17):lastColadvances by one each iteration, so the position always differs and the call always performs I/O:ansi—AnsiOutput.SetCursorPositionImpl→Write(ReadOnlySpan<char>), which allocates a freshStringBuilder, doesEncoding.UTF8.GetBytes, and issues its ownWriteFilefor a ~7-byteCSI r;cH.dotnet—NetOutput.SetCursorPositionImpl→Console.SetCursorPositioninside a baretry {} catch {}. That callsConsolePal.GetBufferInfo, and where the buffer can't be queried it throws and swallows a Win32 exception per cell.Measurements
Stock
dotnet new tuiapp, unmodified except for instrumentation, 6-second runs with synthetic mouse-move events over the button:SetCursorPositionImplcalls: 861,245 across 246 frames — 538 of which actually needed to move the cursor. 99.94% of the I/O is redundant.dotnet-stack) sat inOutputBase.Write→SetCursorPositionImpl→ConsolePal.SetCursorPosition→GetBufferInfo, four of them insideWin32Marshal.GetExceptionForWin32Error/EH.DispatchEx.The cost scales with terminal area, and with per-write cost in the host — through ConPTY (VS Code's integrated terminal), where each write round-trips, the same ~3,500 syscalls comfortably reach a second per frame.
Mouse dispatch itself is not implicated: injecting two mouse events costs 0.1 ms, and hover →
MouseStateChangedlatency is p50 0.0 ms.Reproducing
dotnet new tuiand run it.Incrementbutton.To see it directly, count calls into
SetCursorPositionImplversus the number that change the position.Suggested fix
Don't emit while skipping. Track the desired position and flush at most one cursor escape immediately before content that actually needs it:
That is what produced the 44 iterations/s column above. I verified it is equivalent and not merely faster: replaying both escape streams through a terminal model yields identical cell grids, and the two streams are byte-identical once redundant cursor escapes are stripped.
Two things are worth fixing independently:
DirtyLines[Row] = truebeing set unconditionally on every grapheme write inOutputBufferImpl, even when the cell content is unchanged. Gating it on an actual change would mean an idle frame scans nothing at all.Note on working around it
There is currently no supported way for an application to work around this, because
ApplicationImplisinternal— so althoughAnsiComponentFactoryaccepts anIOutput, nothing public can construct an application with a custom one. The only route is reflection overApplicationImpl(IComponentFactory). Making that constructor (or an equivalent factory hook) public would give applications an escape hatch.Environment