Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Devolutions.Terminal.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,6 @@
</Folder>
<Folder Name="/tools/">
<Project Path="tools/Devolutions.Terminal.PortInventory/Devolutions.Terminal.PortInventory.csproj" />
<Project Path="tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj" />
</Folder>
</Solution>
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<PackageVersion Include="Avalonia" Version="12.1.1" />
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless" Version="12.1.1" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="12.1.1" />
<PackageVersion Include="Avalonia.Skia" Version="12.1.1" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
Expand Down
21 changes: 16 additions & 5 deletions docs/advanced-vt-protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

> [!NOTE]
> The public out-of-process Windows ConPTY may filter DCS payloads before they
> reach a terminal client on some Windows builds. The Core parser and renderer
> support Sixel when a connection transports DCS bytes unchanged (for example,
> remote/Azure transports); local ConPTY support is limited by the installed
> Windows pseudoconsole implementation.
> reach a terminal client on some Windows builds, and APC sequences (kitty
> graphics) can be affected the same way. The Core parser and renderer
> support Sixel and kitty graphics when a connection transports those bytes
> unchanged (for example, remote/Azure transports); local ConPTY support is
> limited by the installed Windows pseudoconsole implementation.

`Devolutions.Terminal.Core` parses advanced string protocols without depending on Avalonia,
Skia, Win32, or an image codec. It exposes decoded Sixel pixels and bounded
Expand All @@ -27,6 +28,7 @@ encoded OSC 1337/ConEmu images as renderer-neutral overlay metadata.
| `CSI Ps $ w` / `DCS Ps $ t ... ST` | Reports and restores cursor presentation state (`Ps=1`) and tab stops (`Ps=2`). |
| `OSC 1337 ; File=... : base64 ST` | Parses inline iTerm2 image name, declared size, width, height, aspect-ratio preference, and bounded encoded bytes. Non-inline file transfers are explicitly rejected without I/O. |
| `OSC 9 ; 4 ; st=0 ; sz=N ; base64 ST` | Parses bounded, single-part ConEmu encoded images. Multipart, malformed, size-mismatched, and oversized transfers are rejected. |
| `APC G control ; base64 ST` | Implements the kitty graphics protocol: direct transmission (`t=d`) of raw RGBA/RGB (`f=32`/`f=24`) and PNG-encoded (`f=100`) images, optional zlib compression (`o=z`), multi-chunk assembly (`m=`), transmit/transmit-and-display/put (`a=t/T/p`), query probe (`a=q`), and deletes by all/id/placement (`a=d` with `d=a/A/i/I/p`). Placements honor cell sizing (`c`/`r`, aspect-preserving when one axis is given), in-cell pixel offsets (`x`/`y`), source crop (`X`/`Y`/`w`/`h`), and z-index (`z<0` below text, `z>=0` above). `a=T` moves the cursor below the image unless `C=1`; `a=p` never moves it. Responds `APC G i=id ; OK/error ST` honoring the `q` quiet flags. |

The DCS state machine handles 7-bit and C1 entry/termination, parameter and
intermediate collection, passthrough, CAN/SUB cancellation, and an `ESC`
Expand All @@ -42,6 +44,8 @@ Limits are public constants on `TerminalImageLimits` and `VtResourceLimits`.
| --- | ---: |
| Collected DCS payload | 4 MiB |
| Decoded OSC 1337 image | 768 KiB |
| Assembled kitty image (decompressed) | 32 MiB |
| Kitty pixel dimension / count | shared pixel limits below |
| Sixel width or height | 4096 pixels |
| Sixel pixel count | 16,777,216 |
| Sixel pixel writes per sequence | 67,108,864 |
Expand Down Expand Up @@ -71,7 +75,7 @@ Renderers can consume `TerminalEngine.Images`, the detached
- a monotonic ID and protocol;
- the primary/alternate buffer identity;
- the cursor cell where the image was received;
- either a `SixelImage` or an `InlineImage`.
- a `SixelImage`, an `InlineImage`, or a `KittyImage` placement.

`SixelImage.PixelIndices` contains 16-bit palette indexes. Index 256 is
transparent; indexes 0 through 255 address `SixelImage.Palette`, whose entries
Expand Down Expand Up @@ -100,6 +104,13 @@ evicted. Main and alternate buffers retain independent identities.
Core tracks cursor-key and keypad modes for input layers to consume.
- non-inline OSC 1337 file transfer and remote file access (explicitly rejected)
- multipart ConEmu image payloads (explicitly rejected)
- kitty graphics file/temp-file/shared-memory media (`t=f/t/s`) — rejected without
I/O, matching the non-inline OSC 1337 policy
- kitty animation frame control (`a=f`) and Unicode placeholder placements
(answered `ENOTSUP`)
- kitty cell/z-index/number-targeted deletes (`d=c/x/y/z/n`) — silent no-op;
the cursor for `f=100` images without `r` stays put because Core retains codec
bytes without decoding dimensions
- Ghostty image projection (the pinned C ABI exposes no image resources)

These gaps avoid remote I/O and renderer/input dependencies while keeping every
Expand Down
11 changes: 11 additions & 0 deletions docs/ghostty-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ copied into JIT, NativeAOT, and MSIX outputs. The ABI type manifest is validated
at startup. See `native/ghostty/ghostty-upstream.json` and
`native/Restore-NativeLibraries.ps1`.

## Stack headroom

The engine's P/Invoke calls execute on whatever thread the host is using —
including the ConPTY read loop's thread-pool thread with the default ~1 MB stack
reserve. Full libghostty (renderer included) has overflowed a 1 MB stack in
other hosts; the VT-only `libghostty-vt` has a much smaller surface. A test
(`EngineSurvivesAdversarialCorpusOnSmallStackThread`) feeds an adversarial
corpus on a 256 KB-stack thread — 4x below the host default — to pin that
headroom. If the pinned upstream changes and that test fails, route engine calls
onto a dedicated thread with an explicit `maxStackSize`.

## Current rendering boundary

Ghostty owns VT parsing, modes, viewport state, resize/reflow, scrollback,
Expand Down
10 changes: 10 additions & 0 deletions docs/parity-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,22 @@ registrations, and notices for both x64 and ARM64.
| Sixel | Built-in decode/render, DECSDM scrolling/display behavior, retained cell geometry, and stable ownership implemented | The pinned Ghostty C ABI exposes no image resources and reports the capability unavailable |
| OSC 1337 | Built-in bounded inline decode/render and stable ownership implemented; non-inline transfer is explicitly rejected without I/O | The pinned Ghostty C ABI exposes no image resources and reports the capability unavailable |
| ConEmu images | Bounded single-part `st=0;sz=` payloads decode into shared overlay metadata and render safely | Multipart payloads are explicitly rejected; the pinned Ghostty C ABI exposes no image resources |
| Kitty graphics | Direct-medium RGBA/RGB/PNG transmissions with chunking, zlib, placements, crop, z-order, deletes, and query responses implemented; unsupported media and animation are rejected deterministically | File/shared-memory media and animation frames are intentional gaps; the pinned Ghostty C ABI exposes no image resources and reports the capability unavailable |
| Image ownership | Stable logical-line anchors survive scrollback and reflow in main and alternate buffers and are removed on owning-line eviction | Ghostty image projection is unavailable in the pinned C ABI and produces deterministic unsupported diagnostics |
| VT52 | Output plus host cursor/PF/application-keypad encoding implemented, with built-in/Ghostty differential mode coverage | No remaining shared subset work |
| DRCS | Built-in parse/resource mapping, bounded snapshot masks, render planning, and downloaded-pixel rendering implemented | The pinned Ghostty C ABI does not expose DRCS resources; capability is explicitly unavailable there |
| Extended keyboard | Built-in Kitty set/query/push/pop flags, CSI-u event bytes, `modifyOtherKeys`, Win32-input mode, and press/repeat/release encoding implemented | Kitty alternate-key reporting and associated-text reporting are not advertised; the pinned Ghostty C ABI exposes no keyboard protocol state and reports these capabilities unavailable |
| Shader effects | Optional deterministic, bounded Skia retro/scanline pass, toggleable per active terminal | Custom arbitrary HLSL/pixel-shader files are not loaded or advertised |

Output bursts from the PTY read loop coalesce into one UI-thread invalidation
drain per frame instead of one per 16 KiB chunk, and the cursor blink timer is
damage-gated (no repaints for unfocused, steady-cursor, or hidden panes). The
`tools/Devolutions.Terminal.Bench` harness measures the path (`engine` and
`control` modes, 16 KiB chunks, medians over runs); an 8 MiB burst drains once
per frame rather than 514 times. Concurrent host writes to ConPTY are serialized
per sequence so query responses and key input cannot interleave mid-sequence,
pinned by a live two-writer contract test.

## Distribution and validation

The `linux-arm64-hardware` CI job runs on GitHub's native
Expand Down
23 changes: 23 additions & 0 deletions docs/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ contract. The control retains the previous frame and computes changed text and
cursor rows for compositor integration. Dynamic selection, search, and hovered
hyperlink ranges are separate overlays so they do not invalidate glyph entries.

## Output-pump coalescing

The ConPTY read loop raises one engine invalidation per 16 KiB read. `TermControl`
collapses queued invalidations into a single trailing UI-thread drain
(`DrainEngineInvalidation`): a producer that finds the pending flag already set
adds no new dispatcher post, and a drain that observes fresh output re-queues
itself. The engine feed itself stays on the PTY thread so cursor-position report
replies (CSI 6n) return before the application prints more. The drain is what
fires the accessibility, scroll-mark, viewport, and IME-cursor notifications, so
listener fan-out runs once per burst instead of once per chunk.

The cursor blink timer is damage-gated: a tick only invalidates when it can
change pixels — the pane must be focused (unfocused panes draw a static cursor),
the engine must report a blinking, visible cursor (DECSET 12/25), and the
control must be effectively visible. Losing focus out of a mid-blink dark phase
repaints once so the static cursor is solid.

`tools/Devolutions.Terminal.Bench` measures this path end to end
(`bench control --mb 8`): a deterministic 8 MiB corpus is fed in 16 KiB chunks
through a fake connection with dispatcher drains paced at 60 Hz, reporting
engine invalidations vs posts vs drains alongside MB/s (medians over runs).
Coalescing takes an 8 MiB burst from 514 drains to roughly one per frame.

## Performance contract

Steady-state paint reuses Skia paints, cached text blobs, Powerline paths, and a
Expand Down
1 change: 1 addition & 0 deletions src/Devolutions.Terminal.Control/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Devolutions.Terminal.Control.Tests")]
[assembly: InternalsVisibleTo("Devolutions.Terminal.Bench")]
71 changes: 68 additions & 3 deletions src/Devolutions.Terminal.Control/TermControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ public sealed class TermControl : Avalonia.Controls.Control
private bool _selectionAlternateBuffer;
private bool _rendererDisposed;
private bool _shaderEffectsEnabled = true;
private long _invalidationPosts;
private long _invalidationDrains;
private int _invalidationPending;

// Throughput-harness diagnostics (Devolutions.Terminal.Bench): posts requested by
// the engine-invalidated handler vs UI drains actually executed.
internal long InvalidationPosts => Interlocked.Read(ref _invalidationPosts);
internal long InvalidationDrains => Interlocked.Read(ref _invalidationDrains);

// True when a blink-timer tick can change pixels. Unfocused panes draw a static
// cursor, steady/hidden cursor modes (DECRST 12/25) never blink, and hidden
// controls do not present.
internal bool ShouldAnimateCursor =>
IsFocused &&
Engine.CursorBlinking &&
Engine.CursorVisible &&
IsEffectivelyVisible;

public TermControl(ITerminalEngine? engine = null)
{
Expand All @@ -86,11 +103,37 @@ public TermControl(ITerminalEngine? engine = null)
ClipToBounds = true;
TextInputMethodClientRequested += OnTextInputMethodClientRequested;
GotFocus += (_, _) => SendFocusChanged(focused: true);
LostFocus += (_, _) => SendFocusChanged(focused: false);
LostFocus += (_, _) =>
{
SendFocusChanged(focused: false);
if (!_cursorOn)
{
// The unfocused pane draws a static cursor and the blink timer no
// longer animates it — repaint out of a mid-blink dark phase.
_cursorOn = true;
InvalidateVisual();
}
};

_blinkTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(530) };
_blinkTimer.Tick += (_, _) =>
{
// Damage-gated idle rendering: skip the tick when blinking cannot change
// any pixel — unfocused panes draw a static cursor (see the drawCursor
// expression in Render), steady/hidden cursor modes never blink, and
// hidden controls do not present. When the animation is off, pin
// _cursorOn so the cursor is solid, repainting once if it was mid-blink.
if (!ShouldAnimateCursor)
{
if (!_cursorOn)
{
_cursorOn = true;
InvalidateVisual();
}

return;
}

_cursorOn = !_cursorOn;
InvalidateVisual();
};
Expand All @@ -101,13 +144,20 @@ public TermControl(ITerminalEngine? engine = null)
// Avalonia controls and may snapshot history. That work must not
// run on the PTY thread or throw back into Engine.Feed — either
// kills ConPTY ReadLoop and leaves the constructor-sized blank grid.
//
// Coalesce bursts: the PTY ReadLoop raises one invalidation per 16 KiB
// chunk, and each drain fires listener fan-out + InvalidateVisual.
// Collapse queued invalidations into a single trailing drain (the
// winterm-ghostty lesson: per-chunk UI work was the whole throughput
// gap). A drain re-queues itself if more output arrived mid-drain.
Interlocked.Increment(ref _invalidationPosts);
if (Dispatcher.UIThread.CheckAccess())
{
HandleEngineInvalidated();
}
else
else if (Interlocked.Exchange(ref _invalidationPending, 1) == 0)
{
Dispatcher.UIThread.Post(HandleEngineInvalidated, DispatcherPriority.Render);
Dispatcher.UIThread.Post(DrainEngineInvalidation, DispatcherPriority.Render);
}
};
Engine.TitleChanged += (_, title) =>
Expand Down Expand Up @@ -1258,8 +1308,23 @@ private void OnOutput(object? sender, ReadOnlyMemory<byte> data)
}
}

private void DrainEngineInvalidation()
{
// Trailing edge: clear the flag before handling so an invalidation that
// arrives during handling is observed by the loop check below. A producer
// that exchanges the flag from 0 to 1 posts its own drain; one that finds
// it already 1 relies on this loop's recheck.
do
{
Interlocked.Exchange(ref _invalidationPending, 0);
HandleEngineInvalidated();
}
while (Volatile.Read(ref _invalidationPending) != 0);
}

private void HandleEngineInvalidated()
{
Interlocked.Increment(ref _invalidationDrains);
try
{
if (_selection is not null &&
Expand Down
1 change: 1 addition & 0 deletions src/Devolutions.Terminal.Core/ITerminalEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum TerminalEngineCapabilities
SixelImages = 1 << 7,
Iterm2Images = 1 << 8,
ConEmuImages = 1 << 9,
KittyImages = 1 << 10,
}

public sealed record TerminalEngineDiagnostic(string Code, string Message);
Expand Down
3 changes: 3 additions & 0 deletions src/Devolutions.Terminal.Core/IVtDispatch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ void DcsDispatch(
{
}
void OscDispatch(int command, ReadOnlySpan<char> data);
void ApcDispatch(ReadOnlySpan<char> data)
{
}
}
Loading
Loading