diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..66d6b24
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,10 @@
+version: 2
+updates:
+ - package-ecosystem: "nuget"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..b378941
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,61 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build-native:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Zig
+ uses: mlugg/setup-zig@v1
+ with:
+ version: '0.14.0'
+
+ - name: Clone Ghostty upstream
+ run: |
+ git clone --depth 1 https://github.com/ghostty-org/ghostty.git /tmp/ghostty
+
+ - name: Build native libghostty-vt
+ run: |
+ cd /tmp/ghostty
+ zig build lib-vt -Dtarget=x86_64-linux -Doptimize=ReleaseSafe
+ mkdir -p ${{ github.workspace }}/runtimes/linux-x64/native
+ cp zig-out/lib/libghostty-vt.so ${{ github.workspace }}/runtimes/linux-x64/native/
+
+ - name: Upload native artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: libghostty-vt-linux-x64
+ path: runtimes/linux-x64/native/libghostty-vt.so
+
+ build-test:
+ needs: build-native
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Download native artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: libghostty-vt-linux-x64
+ path: runtimes/linux-x64/native
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --logger "trx"
diff --git a/.github/workflows/daily-upstream.yml b/.github/workflows/daily-upstream.yml
new file mode 100644
index 0000000..0a08dfe
--- /dev/null
+++ b/.github/workflows/daily-upstream.yml
@@ -0,0 +1,226 @@
+name: Daily Upstream Sync
+
+on:
+ schedule:
+ - cron: '30 3 * * *'
+ workflow_dispatch:
+
+jobs:
+ check-upstream:
+ runs-on: ubuntu-latest
+ outputs:
+ changed: ${{ steps.check.outputs.changed }}
+ new_commit: ${{ steps.check.outputs.commit }}
+ new_version: ${{ steps.check.outputs.version }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Check for upstream changes
+ id: check
+ run: |
+ CURRENT=$(jq -r '.commit' ghostty-upstream.json)
+ LATEST=$(git ls-remote https://github.com/ghostty-org/ghostty.git HEAD | awk '{print $1}')
+
+ if [ "$CURRENT" = "$LATEST" ]; then
+ echo "changed=false" >> "$GITHUB_OUTPUT"
+ echo "No upstream changes detected"
+ exit 0
+ fi
+
+ echo "changed=true" >> "$GITHUB_OUTPUT"
+ echo "commit=$LATEST" >> "$GITHUB_OUTPUT"
+
+ # Clone to read version from build.zig.zon
+ git clone --depth 1 --filter=blob:none --sparse \
+ https://github.com/ghostty-org/ghostty.git /tmp/ghostty-check
+ cd /tmp/ghostty-check
+ git sparse-checkout set build.zig.zon
+ VERSION=$(grep -oP '(?<="version": ")[^"]+' build.zig.zon | head -1)
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ echo "New upstream: $LATEST (version $VERSION)"
+
+ build-native:
+ needs: check-upstream
+ if: needs.check-upstream.outputs.changed == 'true'
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ target: x86_64-windows
+ rid: win-x64
+ artifact: libghostty-vt.dll
+ - os: ubuntu-latest
+ target: x86_64-linux
+ rid: linux-x64
+ artifact: libghostty-vt.so
+ - os: macos-latest
+ target: aarch64-macos
+ rid: osx-arm64
+ artifact: libghostty-vt.dylib
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Zig
+ uses: mlugg/setup-zig@v1
+ with:
+ version: '0.14.0'
+
+ - name: Clone Ghostty at new commit
+ run: |
+ git clone --depth 1 --branch main \
+ https://github.com/ghostty-org/ghostty.git \
+ /tmp/ghostty
+ cd /tmp/ghostty
+ git fetch origin ${{ needs.check-upstream.outputs.new_commit }}
+ git checkout ${{ needs.check-upstream.outputs.new_commit }}
+
+ - name: Build native libghostty-vt
+ run: |
+ cd /tmp/ghostty
+ zig build lib-vt -Dtarget=${{ matrix.target }} -Doptimize=ReleaseSafe
+ mkdir -p ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native
+ cp zig-out/lib/${{ matrix.artifact }} \
+ ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native/
+
+ - name: Upload native artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: native-${{ matrix.rid }}
+ path: runtimes/${{ matrix.rid }}/native/${{ matrix.artifact }}
+
+ regenerate-bindings:
+ needs: build-native
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Clone Ghostty headers
+ run: |
+ git clone --depth 1 --filter=blob:none --sparse \
+ https://github.com/ghostty-org/ghostty.git /tmp/ghostty
+ cd /tmp/ghostty
+ git sparse-checkout set include
+
+ - name: Regenerate bindings
+ run: |
+ dotnet tool install --global ClangSharpPInvokeGenerator
+ clangsharp-pinvoke-generator @build/generate-bindings.rsp
+
+ - name: Check for binding changes
+ id: bindings
+ run: |
+ git diff --exit-code src/Ghostty.Vt/Native/NativeMethods.cs
+ if ($LASTEXITCODE -ne 0) {
+ echo "changed=true" >> $env:GITHUB_OUTPUT
+ } else {
+ echo "changed=false" >> $env:GITHUB_OUTPUT
+ }
+
+ - name: Upload bindings
+ if: steps.bindings.outputs.changed == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: regenerated-bindings
+ path: src/Ghostty.Vt/Native/NativeMethods.cs
+
+ publish:
+ needs: [check-upstream, build-native, regenerate-bindings]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Download all native artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: native-*
+ path: runtimes
+ merge-multiple: true
+
+ - name: Download regenerated bindings (if changed)
+ uses: actions/download-artifact@v4
+ with:
+ name: regenerated-bindings
+ path: src/Ghostty.Vt/Native
+ continue-on-error: true
+
+ - name: Build
+ run: dotnet build --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release
+
+ - name: Compute version
+ id: version
+ run: |
+ VERSION_BASE="${{ needs.check-upstream.outputs.new_version }}"
+ COMMIT="${{ needs.check-upstream.outputs.new_commit }}"
+ SHORT="${COMMIT:0:8}"
+ TIMESTAMP=$(date -u +%Y%m%d%H%M)
+ VERSION_BASE="${VERSION_BASE%-dev}"
+ PACKAGE_VERSION="${VERSION_BASE}-ci.${TIMESTAMP}.${SHORT}"
+ echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
+ echo "Package version: $PACKAGE_VERSION"
+
+ - name: Pack
+ run: |
+ dotnet pack src/Ghostty.Vt/Ghostty.Vt.csproj \
+ --configuration Release \
+ -p:Version=${{ steps.version.outputs.package_version }}
+
+ - name: Publish to NuGet
+ run: |
+ dotnet nuget push artifacts/**/*.nupkg \
+ --api-key ${{ secrets.NUGET_API_KEY }} \
+ --source https://api.nuget.org/v3/index.json
+
+ - name: Update ghostty-upstream.json
+ run: |
+ cat > ghostty-upstream.json << EOF
+ {
+ "repo": "https://github.com/ghostty-org/ghostty.git",
+ "branch": "main",
+ "commit": "${{ needs.check-upstream.outputs.new_commit }}",
+ "upstreamVersion": "${{ needs.check-upstream.outputs.new_version }}",
+ "lastUpdated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ }
+ EOF
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add ghostty-upstream.json
+ git diff --cached --quiet || git commit -m "chore: update upstream to ${{ needs.check-upstream.outputs.new_commit }}"
+ git push
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "v${{ steps.version.outputs.package_version }}" \
+ --title "v${{ steps.version.outputs.package_version }}" \
+ --notes "Automated daily build from upstream ghostty-org/ghostty@${{ needs.check-upstream.outputs.new_commit }}"
+
+ - name: Create issue on failure
+ if: failure()
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ SHORT="${{ needs.check-upstream.outputs.new_commit }}"
+ gh issue create \
+ --title "Upstream sync failed for ${SHORT:0:8}" \
+ --body "Daily upstream sync failed. Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details."
+
+ notify-failure:
+ needs: [check-upstream, publish]
+ if: failure()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Failure already handled
+ run: echo "Failure notification handled by publish job issue creation"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..d37caf7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,135 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ upstream_tag:
+ description: 'Upstream Ghostty tag (e.g., v1.3.1)'
+ required: true
+ type: string
+ package_version:
+ description: 'NuGet package version (defaults to tag without v prefix)'
+ required: false
+ type: string
+
+jobs:
+ build-native:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ target: x86_64-windows
+ rid: win-x64
+ artifact: libghostty-vt.dll
+ - os: ubuntu-latest
+ target: x86_64-linux
+ rid: linux-x64
+ artifact: libghostty-vt.so
+ - os: macos-latest
+ target: aarch64-macos
+ rid: osx-arm64
+ artifact: libghostty-vt.dylib
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup Zig
+ uses: mlugg/setup-zig@v1
+ with:
+ version: '0.14.0'
+
+ - name: Clone Ghostty at tag
+ run: |
+ git clone --depth 1 --branch ${{ inputs.upstream_tag }} \
+ https://github.com/ghostty-org/ghostty.git /tmp/ghostty
+
+ - name: Build native libghostty-vt
+ run: |
+ cd /tmp/ghostty
+ zig build lib-vt -Dtarget=${{ matrix.target }} -Doptimize=ReleaseSafe
+ mkdir -p ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native
+ cp zig-out/lib/${{ matrix.artifact }} \
+ ${{ github.workspace }}/runtimes/${{ matrix.rid }}/native/
+
+ - name: Upload native artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: native-${{ matrix.rid }}
+ path: runtimes/${{ matrix.rid }}/native/${{ matrix.artifact }}
+
+ publish:
+ needs: build-native
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ - name: Download all native artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: native-*
+ path: runtimes
+ merge-multiple: true
+
+ - name: Determine package version
+ id: version
+ run: |
+ if [ -n "${{ inputs.package_version }}" ]; then
+ echo "pkg_version=${{ inputs.package_version }}" >> "$GITHUB_OUTPUT"
+ else
+ TAG="${{ inputs.upstream_tag }}"
+ echo "pkg_version=${TAG#v}" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release
+
+ - name: Pack
+ run: |
+ dotnet pack src/Ghostty.Vt/Ghostty.Vt.csproj \
+ --configuration Release \
+ -p:Version=${{ steps.version.outputs.pkg_version }}
+
+ - name: Publish to NuGet
+ run: |
+ dotnet nuget push artifacts/**/*.nupkg \
+ --api-key ${{ secrets.NUGET_API_KEY }} \
+ --source https://api.nuget.org/v3/index.json
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "${{ inputs.upstream_tag }}" \
+ --title "${{ inputs.upstream_tag }}" \
+ --notes "Stable release built from upstream ghostty-org/ghostty@${{ inputs.upstream_tag }}"
+
+ - name: Update ghostty-upstream.json
+ run: |
+ COMMIT=$(git ls-remote https://github.com/ghostty-org/ghostty.git "refs/tags/${{ inputs.upstream_tag }}" | awk '{print $1}')
+ cat > ghostty-upstream.json << EOF
+ {
+ "repo": "https://github.com/ghostty-org/ghostty.git",
+ "branch": "main",
+ "commit": "$COMMIT",
+ "upstreamVersion": "${{ steps.version.outputs.pkg_version }}",
+ "lastUpdated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ }
+ EOF
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add ghostty-upstream.json
+ git diff --cached --quiet || git commit -m "chore: release ${{ inputs.upstream_tag }}"
+ git push
diff --git a/.github/workflows/visual-tests.yml b/.github/workflows/visual-tests.yml
index 1d2ece1..c7a518f 100644
--- a/.github/workflows/visual-tests.yml
+++ b/.github/workflows/visual-tests.yml
@@ -10,7 +10,7 @@ jobs:
visual-tests:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Setup .NET 9
uses: actions/setup-dotnet@v4
diff --git a/.gitignore b/.gitignore
index de0786b..518053c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,8 @@
-# Build output
-libghostty/
-.ghostty-src/
-
-# .NET
bin/
obj/
-.vs/
*.user
*.suo
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Test results
+runtimes/
+.vs/
+artifacts/
TestResults/
-
-# Worktrees
-.worktrees/
diff --git a/Directory.Build.props b/Directory.Build.props
index a9b78f3..7d77869 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,13 +1,8 @@
-
-
- $(MSBuildProgramFiles32)\Microsoft Visual Studio\18\BuildTools\MSBuild\Microsoft\VisualStudio\v18.0\AppxPackage\
-
-
- $(ProgramW6432)\Microsoft Visual Studio\18\Community\MSBuild\Microsoft\VisualStudio\v18.0\AppxPackage\
+
+ 13.0
+ enable
+ enable
+ true
diff --git a/Ghostty.Vt.sln b/Ghostty.Vt.sln
new file mode 100644
index 0000000..df4e48d
--- /dev/null
+++ b/Ghostty.Vt.sln
@@ -0,0 +1,56 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ghostty.Vt", "src\Ghostty.Vt\Ghostty.Vt.csproj", "{E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ghostty.Vt.Tests", "tests\Ghostty.Vt.Tests\Ghostty.Vt.Tests.csproj", "{BF847C72-31E8-4846-B64C-80B24E3319E8}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x64.Build.0 = Debug|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Debug|x86.Build.0 = Debug|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x64.ActiveCfg = Release|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x64.Build.0 = Release|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x86.ActiveCfg = Release|Any CPU
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42}.Release|x86.Build.0 = Release|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x64.Build.0 = Debug|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Debug|x86.Build.0 = Debug|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x64.ActiveCfg = Release|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x64.Build.0 = Release|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x86.ActiveCfg = Release|Any CPU
+ {BF847C72-31E8-4846-B64C-80B24E3319E8}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {E1FB1CA1-5F42-4F60-A1C7-8717B232DD42} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {BF847C72-31E8-4846-B64C-80B24E3319E8} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ EndGlobalSection
+EndGlobal
diff --git a/build/build-native.ps1 b/build/build-native.ps1
new file mode 100644
index 0000000..2abe28e
--- /dev/null
+++ b/build/build-native.ps1
@@ -0,0 +1,54 @@
+#Requires -Version 7.0
+<#
+.SYNOPSIS
+Builds libghostty-vt native binaries for local development.
+
+.PARAMETER GhosttySource
+Path to a local clone of ghostty-org/ghostty. If not set, clones to /tmp/ghostty.
+
+.PARAMETER Target
+Zig target triple (default: x86_64-windows).
+
+.PARAMETER OutputDir
+Where to copy the built artifact (default: ../runtimes/win-x64/native).
+#>
+param(
+ [string]$GhosttySource = "",
+ [string]$Target = "x86_64-windows",
+ [string]$OutputDir = ""
+)
+
+$ErrorActionPreference = "Stop"
+
+if (-not $GhosttySource) {
+ $GhosttySource = "/tmp/ghostty"
+ if (-not (Test-Path $GhosttySource)) {
+ Write-Host "Cloning ghostty to $GhosttySource..."
+ git clone --depth 1 https://github.com/ghostty-org/ghostty.git $GhosttySource
+ }
+}
+
+if (-not $OutputDir) {
+ $rid = switch ($Target) {
+ "x86_64-windows" { "win-x64" }
+ "x86_64-linux" { "linux-x64" }
+ "aarch64-macos" { "osx-arm64" }
+ default { throw "Unknown target: $Target" }
+ }
+ $OutputDir = "$PSScriptRoot/../runtimes/$rid/native"
+}
+
+Write-Host "Building libghostty-vt for $Target..."
+Push-Location $GhosttySource
+zig build lib-vt -Dtarget=$Target -Doptimize=ReleaseSafe
+Pop-Location
+
+$artifact = switch -Regex ($Target) {
+ "windows" { "libghostty-vt.dll" }
+ "linux" { "libghostty-vt.so" }
+ "macos" { "libghostty-vt.dylib" }
+}
+
+New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
+Copy-Item "$GhosttySource/zig-out/lib/$artifact" $OutputDir -Force
+Write-Host "Copied $artifact to $OutputDir"
diff --git a/build/generate-bindings.rsp b/build/generate-bindings.rsp
new file mode 100644
index 0000000..30a6f7f
--- /dev/null
+++ b/build/generate-bindings.rsp
@@ -0,0 +1,27 @@
+--file libghostty/include/ghostty/vt.h
+--namespace Ghostty.Vt.Native
+--libraryPath libghostty-vt
+--methodClassName NativeMethods
+--output src/Ghostty.Vt/Native
+--language c
+--config generate-macro-bindings
+ latest-codegen
+ generate-helper-types
+--with-access-specifier *=public
+--exclude GHOSTTY_VT_H
+--remap GhosttyTerminal=nint
+ GhosttyRenderState=nint
+ GhosttyFormatter=nint
+ GhosttyKeyEncoder=nint
+ GhosttyMouseEncoder=nint
+ GhosttyOscParser=nint
+ GhosttySgrParser=nint
+ GhosttyKittyGraphics=nint
+ GhosttyKittyGraphicsImage=nint
+ GhosttyKittyGraphicsPlacementIterator=nint
+ GhosttyRenderStateRowIterator=nint
+ GhosttyRenderStateRowCells=nint
+--additional -DGHOSTTY_EXPORT=
+ -D_WIN64
+ -D_MSC_VER=1940
+ -DSSIZE_T=long long
diff --git a/ghostty-upstream.json b/ghostty-upstream.json
new file mode 100644
index 0000000..2041e19
--- /dev/null
+++ b/ghostty-upstream.json
@@ -0,0 +1,7 @@
+{
+ "repo": "https://github.com/ghostty-org/ghostty.git",
+ "branch": "main",
+ "commit": "",
+ "upstreamVersion": "0.0.0-dev",
+ "lastUpdated": "2026-04-13T00:00:00Z"
+}
diff --git a/global.json b/global.json
index 230afd4..ff07225 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "9.0.312",
+ "version": "9.0.203",
"rollForward": "latestFeature"
}
}
diff --git a/justfile b/justfile
index 393429e..d635c5f 100644
--- a/justfile
+++ b/justfile
@@ -1,47 +1,186 @@
-# Build all examples
-build-all: build-win32 build-winforms build-wpf-simple build-wpf-direct build-winui3
+# libghostty-vt-dotnet local CI simulation
+# Prerequisites: zig, dotnet 9.x, git
-# Individual example builds
-build-win32:
- dotnet build examples/Win32/Win32.slnx
+set dotenv-load := false
+set positional-arguments := true
-build-winforms:
- dotnet build examples/WinForms/WinForms.slnx
+# Default host triple (override with: just build-native target=x86_64-linux)
+target := "x86_64-windows"
+rid := if target == "x86_64-windows" { "win-x64" } \
+ else if target == "x86_64-linux" { "linux-x64" } \
+ else if target == "aarch64-macos" { "osx-arm64" } \
+ else { "unknown" }
-build-wpf-simple:
- dotnet build examples/WPF-Simple/WPF-Simple.slnx
+artifact := if target == "x86_64-windows" { "ghostty-vt.dll" } \
+ else if target == "x86_64-linux" { "libghostty-vt.so" } \
+ else if target == "aarch64-macos" { "libghostty-vt.dylib" } \
+ else { "ghostty-vt.unknown" }
-build-wpf-direct:
- dotnet build examples/WPF-Direct/WPF-Direct.slnx
+# Zig puts shared libs in bin/ on Windows, lib/ elsewhere
+artifact_dir := if target == "x86_64-windows" { "bin" } else { "lib" }
-build-winui3:
- dotnet build examples/WinUI3/WinUI3.slnx
+# .NET expects libghostty-vt.* naming; zig produces ghostty-vt.dll on Windows
+dotnet_artifact := if target == "x86_64-windows" { "libghostty-vt.dll" } \
+ else if target == "x86_64-linux" { "libghostty-vt.so" } \
+ else if target == "aarch64-macos" { "libghostty-vt.dylib" } \
+ else { "libghostty-vt.unknown" }
-# Build the interop library only
-build-interop:
- dotnet build src/Ghostty.Interop/Ghostty.Interop.csproj
+ghostty_dir := env_var_or_default("GHOSTTY_SOURCE", "")
-# Clean all build output
-clean:
- dotnet clean examples/Win32/Win32.slnx
- dotnet clean examples/WinForms/WinForms.slnx
- dotnet clean examples/WPF-Simple/WPF-Simple.slnx
- dotnet clean examples/WPF-Direct/WPF-Direct.slnx
- dotnet clean examples/WinUI3/WinUI3.slnx
+# Default: show available recipes
+default:
+ @just --list
+
+# ──────────────────────────────────────────────
+# Full CI pipeline (the one-stop shop)
+# ──────────────────────────────────────────────
+
+# Run the complete CI pipeline: native build → restore → build → test → pack
+ci: build-native restore build test pack
+ @echo "=== CI pipeline complete ==="
+
+# ──────────────────────────────────────────────
+# Native library build
+# ──────────────────────────────────────────────
+
+# Clone ghostty upstream (shallow) into C:\tmp\ghostty\ghostty.
+# Whitelist C:\tmp\ghostty in your antivirus to avoid build failures
+clone-ghostty:
+ #!/bin/bash
+ dir="{{ ghostty_dir }}"
+ if [ -z "$dir" ]; then
+ base="C:\\tmp\\ghostty"
+ mkdir -p "$base"
+ dir="$base\\ghostty.$(date +%s)"
+ mkdir -p "$dir"
+ echo "Cloning ghostty to $dir..."
+ git clone --depth 1 https://github.com/ghostty-org/ghostty.git "$dir"
+ elif [ -d "$dir/.git" ]; then
+ echo "Ghostty already cloned at $dir — pulling latest..."
+ cd "$dir" && git pull --ff-only
+ else
+ echo "Cloning ghostty to $dir..."
+ git clone --depth 1 https://github.com/ghostty-org/ghostty.git "$dir"
+ fi
+ # Export the dir for dependent recipes
+ echo "$dir" > "{{ justfile_directory() }}/.ghostty-clone-dir"
+
+# Build the native libghostty-vt library for the current platform
+build-native: clone-ghostty
+ #!/bin/bash
+ dir="{{ ghostty_dir }}"
+ if [ -z "$dir" ]; then
+ dir=$(cat "{{ justfile_directory() }}/.ghostty-clone-dir")
+ fi
+ echo "Building libghostty-vt for {{ target }}..."
+ cd "$dir" || { echo "ERROR: failed to cd into $dir"; exit 1; }
+ zig build install -Dtarget={{ target }} -Doptimize=ReleaseSafe
+ mkdir -p "{{ justfile_directory() }}/runtimes/{{ rid }}/native"
+ cp "zig-out/{{ artifact_dir }}/{{ artifact }}" "{{ justfile_directory() }}/runtimes/{{ rid }}/native/{{ dotnet_artifact }}"
+ echo "Copied {{ artifact }} → runtimes/{{ rid }}/native/{{ dotnet_artifact }}"
+ # Clean up random tmp clone
+ if [ -z "{{ ghostty_dir }}" ]; then
+ rm -rf "$dir"
+ fi
+ rm -f "{{ justfile_directory() }}/.ghostty-clone-dir"
+
+# Build native for a specific upstream tag/branch
+build-native-ref ref:
+ #!/bin/bash
+ base="C:\\tmp\\ghostty"
+ mkdir -p "$base"
+ dir="$base\\ghostty-ref.$(date +%s)"
+ mkdir -p "$dir"
+ echo "Cloning ghostty at ref '{{ ref }}' to $dir..."
+ git clone --depth 1 --branch "{{ ref }}" https://github.com/ghostty-org/ghostty.git "$dir"
+ cd "$dir"
+ zig build install -Dtarget={{ target }} -Doptimize=ReleaseSafe
+ mkdir -p "{{ justfile_directory() }}/runtimes/{{ rid }}/native"
+ cp "zig-out/{{ artifact_dir }}/{{ artifact }}" "{{ justfile_directory() }}/runtimes/{{ rid }}/native/{{ dotnet_artifact }}"
+ echo "Copied {{ artifact }} → runtimes/{{ rid }}/native/{{ dotnet_artifact }}"
+ rm -rf "$dir"
+
+# ──────────────────────────────────────────────
+# .NET build / test / pack
+# ──────────────────────────────────────────────
+
+# Restore NuGet packages
+restore:
+ dotnet restore
-# Run all visual tests
-test-visual:
- dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj
+# Build the solution (Release configuration)
+build:
+ dotnet build --no-restore --configuration Release
-# CI pipeline
-ci: build-all ci-test-smoke ci-test-visual
+# Build without the --no-restore flag
+build-fresh:
+ dotnet build --configuration Release
+
+# Run all tests
+test:
+ dotnet test --no-build --configuration Release --logger "trx"
+
+# Run tests without --no-build (builds first)
+test-fresh:
+ dotnet test --configuration Release --logger "trx"
+
+# Pack the NuGet package
+pack version="0.0.1-dev":
+ dotnet pack src/Ghostty.Vt/Ghostty.Vt.csproj \
+ --configuration Release \
+ -p:Version={{ version }}
+ @echo "Package created: src/Ghostty.Vt/bin/Release/Ghostty.Vt.{{ version }}.nupkg"
+
+# ──────────────────────────────────────────────
+# Upstream sync helpers
+# ──────────────────────────────────────────────
+
+# Check if upstream ghostty has new commits
+check-upstream:
+ #!/bin/bash
+ CURRENT=$(jq -r '.commit' ghostty-upstream.json)
+ LATEST=$(git ls-remote https://github.com/ghostty-org/ghostty.git HEAD | awk '{print $1}')
+ if [ "$CURRENT" = "$LATEST" ]; then
+ echo "Up to date: $CURRENT"
+ else
+ echo "Update available:"
+ echo " current: $CURRENT"
+ echo " latest: $LATEST"
+ fi
+
+# Update ghostty-upstream.json after a sync
+update-upstream commit version:
+ #!/bin/bash
+ cat > ghostty-upstream.json << EOF
+ {
+ "repo": "https://github.com/ghostty-org/ghostty.git",
+ "branch": "main",
+ "commit": "{{ commit }}",
+ "upstreamVersion": "{{ version }}",
+ "lastUpdated": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ }
+ EOF
+ echo "Updated ghostty-upstream.json → {{ commit }}"
+
+# ──────────────────────────────────────────────
+# Utilities
+# ──────────────────────────────────────────────
+
+# Clean all build artifacts
+clean:
+ dotnet clean --configuration Release
+ rm -rf src/Ghostty.Vt/bin src/Ghostty.Vt/obj
+ rm -rf tests/Ghostty.Vt.Tests/bin tests/Ghostty.Vt.Tests/obj
+ rm -rf artifacts
-ci-test-smoke:
- dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj --filter "Category=Smoke" --logger "trx;LogFileName=smoke.trx" --results-directory TestResults
+# Remove native binaries (runtimes/)
+clean-native:
+ rm -rf runtimes/*/native/libghostty-vt.*
-ci-test-visual:
- dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj --logger "trx;LogFileName=visual.trx" --results-directory TestResults
+# Verify the solution builds without native binaries (compile-only check)
+check:
+ dotnet build Ghostty.Vt.sln --configuration Release --no-restore
-# Update screenshot baselines
-update-baselines:
- TEST_UPDATE_BASELINES=true dotnet test tests/Ghostty.Tests.Visual/Ghostty.Tests.Visual.csproj
+# Quick compile check without restore
+quick:
+ dotnet build Ghostty.Vt.sln
diff --git a/src/Ghostty.Vt/BuildInfo.cs b/src/Ghostty.Vt/BuildInfo.cs
new file mode 100644
index 0000000..828b696
--- /dev/null
+++ b/src/Ghostty.Vt/BuildInfo.cs
@@ -0,0 +1,46 @@
+using System.Runtime.InteropServices;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public readonly struct BuildInfo
+{
+ public string Version { get; }
+ public string ZigVersion { get; }
+
+ private BuildInfo(string version, string zigVersion)
+ {
+ Version = version;
+ ZigVersion = zigVersion;
+ }
+
+ public static unsafe BuildInfo Query()
+ {
+ var version = QueryString((int)BuildInfoData.VersionString);
+ var zigVersion = QueryString((int)BuildInfoData.VersionPre);
+ return new BuildInfo(version, zigVersion);
+ }
+
+ private static unsafe string QueryString(int data)
+ {
+ var str = new GhosttyStringNative();
+ var result = NativeMethods.ghostty_build_info(data, &str);
+ if (result != 0 || str.Ptr == 0 || str.Len == 0) return string.Empty;
+ return Marshal.PtrToStringUTF8((IntPtr)str.Ptr, (int)str.Len) ?? string.Empty;
+ }
+}
+
+public enum BuildInfoData
+{
+ Invalid = 0,
+ Simd = 1,
+ KittyGraphics = 2,
+ TmuxControlMode = 3,
+ Optimize = 4,
+ VersionString = 5,
+ VersionMajor = 6,
+ VersionMinor = 7,
+ VersionPatch = 8,
+ VersionPre = 9,
+ VersionBuild = 10,
+}
diff --git a/src/Ghostty.Vt/Enums/CellContentTag.cs b/src/Ghostty.Vt/Enums/CellContentTag.cs
new file mode 100644
index 0000000..0855224
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/CellContentTag.cs
@@ -0,0 +1,8 @@
+namespace Ghostty.Vt.Enums;
+
+public enum CellContentTag
+{
+ Empty = 0,
+ Grapheme = 1,
+ KittyImagePlacement = 2,
+}
diff --git a/src/Ghostty.Vt/Enums/CursorStyle.cs b/src/Ghostty.Vt/Enums/CursorStyle.cs
new file mode 100644
index 0000000..0990a80
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/CursorStyle.cs
@@ -0,0 +1,11 @@
+namespace Ghostty.Vt.Enums;
+
+public enum CursorStyle
+{
+ Block = 0,
+ Bar = 1,
+ Underline = 2,
+ BlockBar = 3,
+ BlockUnderline = 4,
+ BarUnderline = 5,
+}
diff --git a/src/Ghostty.Vt/Enums/FormatterFormat.cs b/src/Ghostty.Vt/Enums/FormatterFormat.cs
new file mode 100644
index 0000000..0ace177
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/FormatterFormat.cs
@@ -0,0 +1,8 @@
+namespace Ghostty.Vt.Enums;
+
+public enum FormatterFormat
+{
+ PlainText = 0,
+ Vt = 1,
+ Html = 2,
+}
diff --git a/src/Ghostty.Vt/Enums/GhosttyKey.cs b/src/Ghostty.Vt/Enums/GhosttyKey.cs
new file mode 100644
index 0000000..be8d2b5
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/GhosttyKey.cs
@@ -0,0 +1,201 @@
+namespace Ghostty.Vt.Enums;
+
+///
+/// Physical key codes based on W3C UI Events KeyboardEvent.code standard.
+/// Layout-independent: key_a is the physical 'A' key regardless of keyboard layout.
+///
+public enum GhosttyKey
+{
+ Unidentified = 0,
+
+ // Writing System Keys (W3C § 3.1.1)
+ Backquote = 1,
+ Backslash = 2,
+ BracketLeft = 3,
+ BracketRight = 4,
+ Comma = 5,
+ Digit0 = 6,
+ Digit1 = 7,
+ Digit2 = 8,
+ Digit3 = 9,
+ Digit4 = 10,
+ Digit5 = 11,
+ Digit6 = 12,
+ Digit7 = 13,
+ Digit8 = 14,
+ Digit9 = 15,
+ Equal = 16,
+ IntlBackslash = 17,
+ IntlRo = 18,
+ IntlYen = 19,
+ A = 20,
+ B = 21,
+ C = 22,
+ D = 23,
+ E = 24,
+ F = 25,
+ G = 26,
+ H = 27,
+ I = 28,
+ J = 29,
+ K = 30,
+ L = 31,
+ M = 32,
+ N = 33,
+ O = 34,
+ P = 35,
+ Q = 36,
+ R = 37,
+ S = 38,
+ T = 39,
+ U = 40,
+ V = 41,
+ W = 42,
+ X = 43,
+ Y = 44,
+ Z = 45,
+ Minus = 46,
+ Period = 47,
+ Quote = 48,
+ Semicolon = 49,
+ Slash = 50,
+
+ // Functional Keys (W3C § 3.1.2)
+ AltLeft = 51,
+ AltRight = 52,
+ Backspace = 53,
+ CapsLock = 54,
+ ContextMenu = 55,
+ ControlLeft = 56,
+ ControlRight = 57,
+ Enter = 58,
+ MetaLeft = 59,
+ MetaRight = 60,
+ ShiftLeft = 61,
+ ShiftRight = 62,
+ Space = 63,
+ Tab = 64,
+ Convert = 65,
+ KanaMode = 66,
+ NonConvert = 67,
+
+ // Control Pad Section (W3C § 3.2)
+ Delete = 68,
+ End = 69,
+ Help = 70,
+ Home = 71,
+ Insert = 72,
+ PageDown = 73,
+ PageUp = 74,
+
+ // Arrow Pad Section (W3C § 3.3)
+ ArrowDown = 75,
+ ArrowLeft = 76,
+ ArrowRight = 77,
+ ArrowUp = 78,
+
+ // Numpad Section (W3C § 3.4)
+ NumLock = 79,
+ Numpad0 = 80,
+ Numpad1 = 81,
+ Numpad2 = 82,
+ Numpad3 = 83,
+ Numpad4 = 84,
+ Numpad5 = 85,
+ Numpad6 = 86,
+ Numpad7 = 87,
+ Numpad8 = 88,
+ Numpad9 = 89,
+ NumpadAdd = 90,
+ NumpadBackspace = 91,
+ NumpadClear = 92,
+ NumpadClearEntry = 93,
+ NumpadComma = 94,
+ NumpadDecimal = 95,
+ NumpadDivide = 96,
+ NumpadEnter = 97,
+ NumpadEqual = 98,
+ NumpadMemoryAdd = 99,
+ NumpadMemoryClear = 100,
+ NumpadMemoryRecall = 101,
+ NumpadMemoryStore = 102,
+ NumpadMemorySubtract = 103,
+ NumpadMultiply = 104,
+ NumpadParenLeft = 105,
+ NumpadParenRight = 106,
+ NumpadSubtract = 107,
+ NumpadSeparator = 108,
+ NumpadUp = 109,
+ NumpadDown = 110,
+ NumpadRight = 111,
+ NumpadLeft = 112,
+ NumpadBegin = 113,
+ NumpadHome = 114,
+ NumpadEnd = 115,
+ NumpadInsert = 116,
+ NumpadDelete = 117,
+ NumpadPageUp = 118,
+ NumpadPageDown = 119,
+
+ // Function Section (W3C § 3.5)
+ Escape = 120,
+ F1 = 121,
+ F2 = 122,
+ F3 = 123,
+ F4 = 124,
+ F5 = 125,
+ F6 = 126,
+ F7 = 127,
+ F8 = 128,
+ F9 = 129,
+ F10 = 130,
+ F11 = 131,
+ F12 = 132,
+ F13 = 133,
+ F14 = 134,
+ F15 = 135,
+ F16 = 136,
+ F17 = 137,
+ F18 = 138,
+ F19 = 139,
+ F20 = 140,
+ F21 = 141,
+ F22 = 142,
+ F23 = 143,
+ F24 = 144,
+ F25 = 145,
+ Fn = 146,
+ FnLock = 147,
+ PrintScreen = 148,
+ ScrollLock = 149,
+ Pause = 150,
+
+ // Media Keys (W3C § 3.6)
+ BrowserBack = 151,
+ BrowserFavorites = 152,
+ BrowserForward = 153,
+ BrowserHome = 154,
+ BrowserRefresh = 155,
+ BrowserSearch = 156,
+ BrowserStop = 157,
+ Eject = 158,
+ LaunchApp1 = 159,
+ LaunchApp2 = 160,
+ LaunchMail = 161,
+ MediaPlayPause = 162,
+ MediaSelect = 163,
+ MediaStop = 164,
+ MediaTrackNext = 165,
+ MediaTrackPrevious = 166,
+ Power = 167,
+ Sleep = 168,
+ AudioVolumeDown = 169,
+ AudioVolumeMute = 170,
+ AudioVolumeUp = 171,
+ WakeUp = 172,
+
+ // Legacy, Non-standard, and Special Keys (W3C § 3.7)
+ Copy = 173,
+ Cut = 174,
+ Paste = 175,
+}
diff --git a/src/Ghostty.Vt/Enums/KittyImageCompression.cs b/src/Ghostty.Vt/Enums/KittyImageCompression.cs
new file mode 100644
index 0000000..99037d3
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/KittyImageCompression.cs
@@ -0,0 +1,7 @@
+namespace Ghostty.Vt.Enums;
+
+public enum KittyImageCompression
+{
+ None = 0,
+ Deflate = 1,
+}
diff --git a/src/Ghostty.Vt/Enums/KittyImageFormat.cs b/src/Ghostty.Vt/Enums/KittyImageFormat.cs
new file mode 100644
index 0000000..d39e36f
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/KittyImageFormat.cs
@@ -0,0 +1,8 @@
+namespace Ghostty.Vt.Enums;
+
+public enum KittyImageFormat
+{
+ Rgba = 0,
+ Rgb = 1,
+ Png = 2,
+}
diff --git a/src/Ghostty.Vt/Enums/KittyPlacementLayer.cs b/src/Ghostty.Vt/Enums/KittyPlacementLayer.cs
new file mode 100644
index 0000000..b2f1229
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/KittyPlacementLayer.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace Ghostty.Vt.Enums;
+
+[Flags]
+public enum KittyPlacementLayer
+{
+ Background = 1 << 0,
+ Reference = 1 << 1,
+ All = Background | Reference,
+}
diff --git a/src/Ghostty.Vt/Enums/OptimizeMode.cs b/src/Ghostty.Vt/Enums/OptimizeMode.cs
new file mode 100644
index 0000000..a266ce3
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/OptimizeMode.cs
@@ -0,0 +1,8 @@
+namespace Ghostty.Vt.Enums;
+
+public enum OptimizeMode
+{
+ None = 0,
+ Size = 1,
+ Time = 2,
+}
diff --git a/src/Ghostty.Vt/Enums/OscCommandType.cs b/src/Ghostty.Vt/Enums/OscCommandType.cs
new file mode 100644
index 0000000..4d38730
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/OscCommandType.cs
@@ -0,0 +1,28 @@
+namespace Ghostty.Vt.Enums;
+
+public enum OscCommandType
+{
+ Unknown = 0,
+ SetWindowTitle = 1,
+ SetIconName = 2,
+ SetWindowIcon = 3,
+ SetWorkingDirectory = 4,
+ SetClipboard = 5,
+ ResetColors = 6,
+ SetForegroundColor = 7,
+ SetBackgroundColor = 8,
+ SetCursorColor = 9,
+ SetColor = 10,
+ Hyperlink = 11,
+ Notify = 12,
+ ItermImage = 13,
+ ItermProfile = 14,
+ ItermSetMark = 15,
+ KittyImage = 16,
+ KittyClear = 17,
+ KittyQuery = 18,
+ KittyTransfer = 19,
+ KittyHint = 20,
+ KittyBell = 21,
+ KittyEsr = 22,
+}
diff --git a/src/Ghostty.Vt/Enums/PointTag.cs b/src/Ghostty.Vt/Enums/PointTag.cs
new file mode 100644
index 0000000..1b31c98
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/PointTag.cs
@@ -0,0 +1,9 @@
+namespace Ghostty.Vt.Enums;
+
+public enum PointTag
+{
+ Active = 0,
+ Viewport = 1,
+ Screen = 2,
+ History = 3,
+}
diff --git a/src/Ghostty.Vt/Enums/RenderStateDirty.cs b/src/Ghostty.Vt/Enums/RenderStateDirty.cs
new file mode 100644
index 0000000..88422a0
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/RenderStateDirty.cs
@@ -0,0 +1,8 @@
+namespace Ghostty.Vt.Enums;
+
+public enum RenderStateDirty
+{
+ False = 0,
+ Partial = 1,
+ Full = 2,
+}
diff --git a/src/Ghostty.Vt/Enums/SgrAttributeTag.cs b/src/Ghostty.Vt/Enums/SgrAttributeTag.cs
new file mode 100644
index 0000000..186a36d
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/SgrAttributeTag.cs
@@ -0,0 +1,36 @@
+namespace Ghostty.Vt.Enums;
+
+public enum SgrAttributeTag
+{
+ Unset = 0,
+ Unknown = 1,
+ Bold = 2,
+ ResetBold = 3,
+ Italic = 4,
+ ResetItalic = 5,
+ Faint = 6,
+ Underline = 7,
+ UnderlineColor = 8,
+ UnderlineColor256 = 9,
+ ResetUnderlineColor = 10,
+ Overline = 11,
+ ResetOverline = 12,
+ Blink = 13,
+ ResetBlink = 14,
+ Inverse = 15,
+ ResetInverse = 16,
+ Invisible = 17,
+ ResetInvisible = 18,
+ Strikethrough = 19,
+ ResetStrikethrough = 20,
+ DirectColorFg = 21,
+ DirectColorBg = 22,
+ Background8 = 23,
+ Foreground8 = 24,
+ ResetForeground = 25,
+ ResetBackground = 26,
+ BrightBackground8 = 27,
+ BrightForeground8 = 28,
+ Background256 = 29,
+ Foreground256 = 30,
+}
diff --git a/src/Ghostty.Vt/Enums/SgrUnderline.cs b/src/Ghostty.Vt/Enums/SgrUnderline.cs
new file mode 100644
index 0000000..5dcd1e7
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/SgrUnderline.cs
@@ -0,0 +1,11 @@
+namespace Ghostty.Vt.Enums;
+
+public enum SgrUnderline
+{
+ None = 0,
+ Single = 1,
+ Double = 2,
+ Curly = 3,
+ Dotted = 4,
+ Dashed = 5,
+}
diff --git a/src/Ghostty.Vt/Enums/TerminalData.cs b/src/Ghostty.Vt/Enums/TerminalData.cs
new file mode 100644
index 0000000..2234333
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/TerminalData.cs
@@ -0,0 +1,36 @@
+namespace Ghostty.Vt.Enums;
+
+public enum TerminalData
+{
+ Invalid = 0,
+ Cols = 1,
+ Rows = 2,
+ CursorX = 3,
+ CursorY = 4,
+ CursorPendingWrap = 5,
+ ActiveScreen = 6,
+ CursorVisible = 7,
+ KittyKeyboardFlags = 8,
+ Scrollbar = 9,
+ CursorStyle = 10,
+ MouseTracking = 11,
+ Title = 12,
+ Pwd = 13,
+ TotalRows = 14,
+ ScrollbackRows = 15,
+ WidthPx = 16,
+ HeightPx = 17,
+ ColorForeground = 18,
+ ColorBackground = 19,
+ ColorCursor = 20,
+ ColorPalette = 21,
+ ColorForegroundDefault = 22,
+ ColorBackgroundDefault = 23,
+ ColorCursorDefault = 24,
+ ColorPaletteDefault = 25,
+ KittyImageStorageLimit = 26,
+ KittyImageMediumFile = 27,
+ KittyImageMediumTempFile = 28,
+ KittyImageMediumSharedMem = 29,
+ KittyGraphics = 30,
+}
diff --git a/src/Ghostty.Vt/Enums/TerminalMode.cs b/src/Ghostty.Vt/Enums/TerminalMode.cs
new file mode 100644
index 0000000..deb9dfb
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/TerminalMode.cs
@@ -0,0 +1,18 @@
+namespace Ghostty.Vt.Enums;
+
+public enum TerminalMode
+{
+ CursorKeys = 1,
+ AutoWrap = 7,
+ Insert = 4,
+ MouseX10 = 9,
+ MouseNormal = 1000,
+ MouseButton = 1002,
+ MouseAny = 1003,
+ MouseSGR = 1006,
+ FocusEvent = 1007,
+ AltScreen = 1049,
+ BracketedPaste = 2004,
+ KittyKeyboard = 2015,
+ SynchronizedOutput = 2026,
+}
diff --git a/src/Ghostty.Vt/Enums/TerminalOption.cs b/src/Ghostty.Vt/Enums/TerminalOption.cs
new file mode 100644
index 0000000..2951936
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/TerminalOption.cs
@@ -0,0 +1,24 @@
+namespace Ghostty.Vt.Enums;
+
+public enum TerminalOption
+{
+ FontName = 0,
+ FontSize = 1,
+ FontBold = 2,
+ FontItalic = 3,
+ ForegroundColor = 4,
+ BackgroundColor = 5,
+ CursorForegroundColor = 6,
+ CursorBackgroundColor = 7,
+ CursorStyle = 8,
+ CursorBlink = 9,
+ CursorBlinkIntervalMs = 10,
+ ColorPalette = 11,
+ ColorBold = 12,
+ ColorDim = 13,
+ ClipboardWrite = 14,
+ ClipboardRead = 15,
+ ClipboardConfirmation = 16,
+ Callbacks = 17,
+ PtyBuffer = 18,
+}
diff --git a/src/Ghostty.Vt/Enums/TerminalScreen.cs b/src/Ghostty.Vt/Enums/TerminalScreen.cs
new file mode 100644
index 0000000..f032028
--- /dev/null
+++ b/src/Ghostty.Vt/Enums/TerminalScreen.cs
@@ -0,0 +1,7 @@
+namespace Ghostty.Vt.Enums;
+
+public enum TerminalScreen
+{
+ Active = 0,
+ Alternate = 1,
+}
diff --git a/src/Ghostty.Vt/Focus.cs b/src/Ghostty.Vt/Focus.cs
new file mode 100644
index 0000000..2e404c3
--- /dev/null
+++ b/src/Ghostty.Vt/Focus.cs
@@ -0,0 +1,22 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public static class Focus
+{
+ public static unsafe byte[] Encode(bool focused)
+ {
+ Span buf = stackalloc byte[16];
+ fixed (byte* ptr = buf)
+ {
+ nuint written;
+ var result = NativeMethods.ghostty_focus_encode(
+ focused ? 0 : 1, ptr, 16, &written);
+ GhosttyException.ThrowIfFailure(result);
+ if (written == 0) return [];
+ var bytes = new byte[(int)written];
+ new ReadOnlySpan(ptr, (int)written).CopyTo(bytes);
+ return bytes;
+ }
+ }
+}
diff --git a/src/Ghostty.Vt/Formatter.cs b/src/Ghostty.Vt/Formatter.cs
new file mode 100644
index 0000000..798c7c5
--- /dev/null
+++ b/src/Ghostty.Vt/Formatter.cs
@@ -0,0 +1,80 @@
+using System.Runtime.InteropServices;
+using Ghostty.Vt.Enums;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class Formatter : IDisposable
+{
+ private readonly FormatterSafeHandle _handle;
+
+ internal unsafe Formatter(nint terminalHandle, FormatterFormat format,
+ Action? configure)
+ {
+ var opts = new FormatterOptions();
+ configure?.Invoke(opts);
+
+ // Allocate a zero-initialized buffer for GhosttyFormatterTerminalOptions (128 bytes, plenty)
+ // Layout: { size_t size, GhosttyFormatterFormat emit, bool unwrap, bool trim, ... }
+ byte* nativeOpts = stackalloc byte[128];
+ new Span(nativeOpts, 128).Clear();
+
+ // size (offset 0, 8 bytes)
+ *(nuint*)(nativeOpts + 0) = 128;
+ // emit (offset 8, 4 bytes) - GhosttyFormatterFormat enum
+ *(int*)(nativeOpts + 8) = (int)format;
+ // unwrap (offset 12, 1 byte)
+ *(nativeOpts + 12) = 0;
+ // trim (offset 13, 1 byte)
+ *(nativeOpts + 13) = (byte)(opts.Trim ? 1 : 0);
+
+ nint handle;
+ var result = NativeMethods.ghostty_formatter_terminal_new(
+ nint.Zero, &handle, terminalHandle, nativeOpts);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = new FormatterSafeHandle(handle);
+ }
+
+ public override unsafe string ToString()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+
+ // First query required size (returns OUT_OF_SPACE with required size)
+ nuint written = 0;
+ int queryResult = NativeMethods.ghostty_formatter_format_buf(
+ _handle.DangerousGetHandle(), null, 0, &written);
+
+ if (written == 0) return string.Empty;
+
+ byte* buf = stackalloc byte[(int)written];
+ var result = NativeMethods.ghostty_formatter_format_buf(
+ _handle.DangerousGetHandle(), buf, written, &written);
+ GhosttyException.ThrowIfFailure(result);
+
+ return Marshal.PtrToStringUTF8((nint)buf, (int)written) ?? string.Empty;
+ }
+
+ public unsafe ReadOnlySpan ToSpan()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+
+ byte* outPtr = null;
+ nuint outLen = 0;
+ var result = NativeMethods.ghostty_formatter_format_alloc(
+ _handle.DangerousGetHandle(), nint.Zero, &outPtr, &outLen);
+ GhosttyException.ThrowIfFailure(result);
+
+ if (outPtr == null || outLen == 0) return ReadOnlySpan.Empty;
+ return new ReadOnlySpan(outPtr, (int)outLen);
+ }
+
+ public void Dispose() => _handle.Dispose();
+
+ private sealed class FormatterSafeHandle : GhosttySafeHandle
+ {
+ public FormatterSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint handle) => NativeMethods.ghostty_formatter_free(handle);
+ public new nint DangerousGetHandle() => handle;
+ }
+}
diff --git a/src/Ghostty.Vt/FormatterOptions.cs b/src/Ghostty.Vt/FormatterOptions.cs
new file mode 100644
index 0000000..2576b32
--- /dev/null
+++ b/src/Ghostty.Vt/FormatterOptions.cs
@@ -0,0 +1,11 @@
+using Ghostty.Vt.Enums;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class FormatterOptions
+{
+ public bool Trim { get; set; }
+ public bool IncludeStyle { get; set; }
+}
diff --git a/src/Ghostty.Vt/Ghostty.Vt.csproj b/src/Ghostty.Vt/Ghostty.Vt.csproj
new file mode 100644
index 0000000..d283bb2
--- /dev/null
+++ b/src/Ghostty.Vt/Ghostty.Vt.csproj
@@ -0,0 +1,44 @@
+
+
+ net9.0
+ true
+ Ghostty.Vt
+ Ghostty.Vt
+ true
+ true
+ 0.0.0-dev
+ Ghostty.Vt
+ Virtual terminal engine powered by libghostty-vt
+ Alessandro De Blasis
+ https://github.com/deblasis/libghostty-vt-dotnet
+ MIT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Ghostty.Vt/GhosttyException.cs b/src/Ghostty.Vt/GhosttyException.cs
new file mode 100644
index 0000000..453ddf8
--- /dev/null
+++ b/src/Ghostty.Vt/GhosttyException.cs
@@ -0,0 +1,26 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class GhosttyException : Exception
+{
+ public int Result { get; }
+
+ public GhosttyException(int result)
+ : base($"Ghostty error: {result}")
+ {
+ Result = result;
+ }
+
+ public GhosttyException(string message)
+ : base(message)
+ {
+ Result = -1;
+ }
+
+ internal static void ThrowIfFailure(int result)
+ {
+ if (result != 0)
+ throw new GhosttyException(result);
+ }
+}
diff --git a/src/Ghostty.Vt/GridRef.cs b/src/Ghostty.Vt/GridRef.cs
new file mode 100644
index 0000000..c63b187
--- /dev/null
+++ b/src/Ghostty.Vt/GridRef.cs
@@ -0,0 +1,18 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public readonly struct GridRef
+{
+ internal readonly GhosttyGridRefNative Native;
+ private readonly Terminal _terminal;
+
+ internal GridRef(GhosttyGridRefNative native, Terminal terminal)
+ {
+ Native = native;
+ _terminal = terminal;
+ }
+
+ /// Opaque node pointer from the native grid ref. Null if invalid.
+ internal nint NativeHandle => Native.Node;
+}
diff --git a/src/Ghostty.Vt/Internals/DelegatePinner.cs b/src/Ghostty.Vt/Internals/DelegatePinner.cs
new file mode 100644
index 0000000..6e889b0
--- /dev/null
+++ b/src/Ghostty.Vt/Internals/DelegatePinner.cs
@@ -0,0 +1,27 @@
+using System.Runtime.InteropServices;
+
+namespace Ghostty.Vt.Internals;
+
+internal sealed class DelegatePinner : IDisposable
+{
+ private readonly List _pins = [];
+
+ public nint Pin(Delegate d)
+ {
+ var gch = GCHandle.Alloc(d);
+ _pins.Add(gch);
+#pragma warning disable IL3050 // Delegate marshalling is used at runtime, not AOT
+ return Marshal.GetFunctionPointerForDelegate(d);
+#pragma warning restore IL3050
+ }
+
+ public void Dispose()
+ {
+ foreach (var pin in _pins)
+ {
+ if (pin.IsAllocated)
+ pin.Free();
+ }
+ _pins.Clear();
+ }
+}
diff --git a/src/Ghostty.Vt/Internals/GhosttyMemory.cs b/src/Ghostty.Vt/Internals/GhosttyMemory.cs
new file mode 100644
index 0000000..415ba95
--- /dev/null
+++ b/src/Ghostty.Vt/Internals/GhosttyMemory.cs
@@ -0,0 +1,31 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt.Internals;
+
+internal static unsafe class GhosttyMemory
+{
+ public static byte* Alloc(nuint length)
+ {
+ var ptr = NativeMethods.ghostty_alloc(nint.Zero, length);
+ if (ptr == null)
+ throw new OutOfMemoryException($"ghostty_alloc failed for {length} bytes");
+ return ptr;
+ }
+
+ public static void Free(byte* ptr, nuint length)
+ {
+ if (ptr != null)
+ NativeMethods.ghostty_free(nint.Zero, ptr, length);
+ }
+
+ ///
+ /// Allocates a native buffer, copies managed bytes into it.
+ /// Caller must Free the returned pointer.
+ ///
+ public static byte* CopyManagedToNative(ReadOnlySpan source)
+ {
+ var ptr = Alloc((nuint)source.Length);
+ source.CopyTo(new Span(ptr, source.Length));
+ return ptr;
+ }
+}
diff --git a/src/Ghostty.Vt/Internals/GhosttySafeHandle.cs b/src/Ghostty.Vt/Internals/GhosttySafeHandle.cs
new file mode 100644
index 0000000..118f37f
--- /dev/null
+++ b/src/Ghostty.Vt/Internals/GhosttySafeHandle.cs
@@ -0,0 +1,19 @@
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+namespace Ghostty.Vt.Internals;
+
+internal abstract class GhosttySafeHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ protected GhosttySafeHandle() : base(ownsHandle: true) { }
+
+ protected sealed override bool ReleaseHandle()
+ {
+ if (handle == nint.Zero) return true;
+ Free(handle);
+ SetHandle(nint.Zero);
+ return true;
+ }
+
+ protected abstract void Free(nint handle);
+}
diff --git a/src/Ghostty.Vt/KeyEncoder.cs b/src/Ghostty.Vt/KeyEncoder.cs
new file mode 100644
index 0000000..d52b461
--- /dev/null
+++ b/src/Ghostty.Vt/KeyEncoder.cs
@@ -0,0 +1,47 @@
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class KeyEncoder : IDisposable
+{
+ private readonly KeyEncoderSafeHandle _handle;
+
+ public unsafe KeyEncoder()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_key_encoder_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = new KeyEncoderSafeHandle(handle);
+ }
+
+ public void ConfigureFromTerminal(Terminal terminal)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_key_encoder_setopt_from_terminal(
+ _handle.DangerousGetHandle(), terminal.NativeHandle);
+ }
+
+ public unsafe ReadOnlySpan Encode(KeyEvent keyEvent)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ Span buf = stackalloc byte[256];
+ fixed (byte* ptr = buf)
+ {
+ nuint len;
+ var result = NativeMethods.ghostty_key_encoder_encode(
+ _handle.DangerousGetHandle(), keyEvent.NativeHandle, ptr, 256, &len);
+ GhosttyException.ThrowIfFailure(result);
+ return new ReadOnlySpan(ptr, (int)len);
+ }
+ }
+
+ public void Dispose() => _handle.Dispose();
+
+ private sealed class KeyEncoderSafeHandle : GhosttySafeHandle
+ {
+ public KeyEncoderSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint h) => NativeMethods.ghostty_key_encoder_free(h);
+ public new nint DangerousGetHandle() => handle;
+ }
+}
diff --git a/src/Ghostty.Vt/KeyEvent.cs b/src/Ghostty.Vt/KeyEvent.cs
new file mode 100644
index 0000000..bbdd1f2
--- /dev/null
+++ b/src/Ghostty.Vt/KeyEvent.cs
@@ -0,0 +1,68 @@
+using System.Text;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class KeyEvent : IDisposable
+{
+ private readonly nint _handle;
+
+ public unsafe KeyEvent()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_key_event_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = handle;
+ }
+
+ private int _key;
+ /// Physical key code (GhosttyKey enum value).
+ public int Key
+ {
+ get => _key;
+ set { _key = value; NativeMethods.ghostty_key_event_set_key(_handle, value); }
+ }
+
+ private int _action = 1; // press
+ /// Action type: 0=release, 1=press, 2=repeat.
+ public int Action
+ {
+ get => _action;
+ set { _action = value; NativeMethods.ghostty_key_event_set_action(_handle, value); }
+ }
+
+ private ushort _modifiers;
+ /// Modifier bitmask (GHOSTTY_MODS_* values).
+ public ushort Modifiers
+ {
+ get => _modifiers;
+ set { _modifiers = value; NativeMethods.ghostty_key_event_set_mods(_handle, value); }
+ }
+
+ public unsafe string? Text
+ {
+ get => _text;
+ set
+ {
+ _text = value;
+ if (value != null)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ fixed (byte* ptr = bytes)
+ NativeMethods.ghostty_key_event_set_utf8(_handle, ptr, (nuint)bytes.Length);
+ }
+ }
+ }
+ private string? _text;
+
+ // Legacy property for backwards compatibility
+ public uint KeyCode { get => (uint)Key; set => Key = (int)value; }
+
+ internal nint NativeHandle => _handle;
+
+ public void Dispose()
+ {
+ if (_handle != nint.Zero)
+ NativeMethods.ghostty_key_event_free(_handle);
+ }
+}
diff --git a/src/Ghostty.Vt/KittyGraphics.cs b/src/Ghostty.Vt/KittyGraphics.cs
new file mode 100644
index 0000000..7422c41
--- /dev/null
+++ b/src/Ghostty.Vt/KittyGraphics.cs
@@ -0,0 +1,61 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public ref struct KittyGraphicsAccessor
+{
+ private readonly Terminal _terminal;
+ private nint _kittyHandle;
+
+ internal KittyGraphicsAccessor(Terminal terminal)
+ {
+ _terminal = terminal;
+ _kittyHandle = nint.Zero;
+ }
+
+ private unsafe nint KittyHandle
+ {
+ get
+ {
+ if (_kittyHandle == nint.Zero)
+ {
+ nint kittyHandle = nint.Zero;
+ // KittyGraphics handle comes from terminal_get with KittyGraphics data type
+ NativeMethods.ghostty_terminal_get(
+ _terminal.NativeHandle,
+ (int)Enums.TerminalData.KittyGraphics,
+ &kittyHandle);
+ _kittyHandle = kittyHandle;
+ }
+ return _kittyHandle;
+ }
+ }
+
+ public KittyImage GetImage(uint imageId)
+ {
+ var imgHandle = NativeMethods.ghostty_kitty_graphics_image(KittyHandle, imageId);
+ return new KittyImage(imgHandle);
+ }
+}
+
+public ref struct KittyImage
+{
+ private readonly nint _handle;
+
+ internal KittyImage(nint handle) => _handle = handle;
+
+ public unsafe uint ImageId
+ {
+ get
+ {
+ uint id;
+ NativeMethods.ghostty_kitty_graphics_image_get(
+ _handle, 1 /* GHOSTTY_KITTY_IMAGE_DATA_ID */, &id);
+ return id;
+ }
+ }
+
+ public Enums.KittyImageFormat Format => throw new NotImplementedException();
+ public int Width => throw new NotImplementedException();
+ public int Height => throw new NotImplementedException();
+}
diff --git a/src/Ghostty.Vt/MouseEncoder.cs b/src/Ghostty.Vt/MouseEncoder.cs
new file mode 100644
index 0000000..5739610
--- /dev/null
+++ b/src/Ghostty.Vt/MouseEncoder.cs
@@ -0,0 +1,78 @@
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class MouseEncoder : IDisposable
+{
+ private readonly MouseEncoderSafeHandle _handle;
+
+ public unsafe MouseEncoder()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_mouse_encoder_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = new MouseEncoderSafeHandle(handle);
+ }
+
+ public void ConfigureFromTerminal(Terminal terminal)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_mouse_encoder_setopt_from_terminal(
+ _handle.DangerousGetHandle(), terminal.NativeHandle);
+ }
+
+ ///
+ /// Set the screen geometry for converting surface-space positions to cell coordinates.
+ /// Required before encoding if not already set via terminal configuration.
+ ///
+ public unsafe void SetSize(int screenWidth, int screenHeight, int cellWidth, int cellHeight,
+ int paddingTop = 0, int paddingBottom = 0, int paddingRight = 0, int paddingLeft = 0)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ // GhosttyMouseEncoderSize: { size_t size, uint32_t screen_width/height, cell_width/height, padding_* }
+ // Total: 40 bytes per type JSON
+ byte* buf = stackalloc byte[48];
+ new Span(buf, 48).Clear();
+ *(nuint*)(buf + 0) = 40; // struct size
+ *(uint*)(buf + 8) = (uint)screenWidth;
+ *(uint*)(buf + 12) = (uint)screenHeight;
+ *(uint*)(buf + 16) = (uint)cellWidth;
+ *(uint*)(buf + 20) = (uint)cellHeight;
+ *(uint*)(buf + 24) = (uint)paddingTop;
+ *(uint*)(buf + 28) = (uint)paddingBottom;
+ *(uint*)(buf + 32) = (uint)paddingRight;
+ *(uint*)(buf + 36) = (uint)paddingLeft;
+ NativeMethods.ghostty_mouse_encoder_setopt(
+ _handle.DangerousGetHandle(), 2 /* GHOSTTY_MOUSE_ENCODER_OPT_SIZE */, buf);
+ }
+
+ public unsafe ReadOnlySpan Encode(MouseEvent mouseEvent)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ Span buf = stackalloc byte[64];
+ fixed (byte* ptr = buf)
+ {
+ nuint len;
+ var result = NativeMethods.ghostty_mouse_encoder_encode(
+ _handle.DangerousGetHandle(), mouseEvent.NativeHandle, ptr, 64, &len);
+ GhosttyException.ThrowIfFailure(result);
+ return new ReadOnlySpan(ptr, (int)len);
+ }
+ }
+
+ public void Reset()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_mouse_encoder_reset(_handle.DangerousGetHandle());
+ }
+
+ public void Dispose() => _handle.Dispose();
+
+ private sealed class MouseEncoderSafeHandle : GhosttySafeHandle
+ {
+ public MouseEncoderSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint h) => NativeMethods.ghostty_mouse_encoder_free(h);
+ public new nint DangerousGetHandle() => handle;
+ }
+}
diff --git a/src/Ghostty.Vt/MouseEvent.cs b/src/Ghostty.Vt/MouseEvent.cs
new file mode 100644
index 0000000..9db49f9
--- /dev/null
+++ b/src/Ghostty.Vt/MouseEvent.cs
@@ -0,0 +1,66 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class MouseEvent : IDisposable
+{
+ private readonly nint _handle;
+
+ public unsafe MouseEvent()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_mouse_event_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = handle;
+ }
+
+ private int _action;
+ public int Action
+ {
+ get => _action;
+ set { _action = value; NativeMethods.ghostty_mouse_event_set_action(_handle, value); }
+ }
+
+ private int _button = -1; // -1 = no button set
+ public int Button
+ {
+ get => _button;
+ set
+ {
+ _button = value;
+ if (value < 0)
+ NativeMethods.ghostty_mouse_event_clear_button(_handle);
+ else
+ NativeMethods.ghostty_mouse_event_set_button(_handle, value);
+ }
+ }
+
+ private int _modifiers;
+ public int Modifiers
+ {
+ get => _modifiers;
+ set { _modifiers = value; NativeMethods.ghostty_mouse_event_set_mods(_handle, value); }
+ }
+
+ private float _x;
+ public float X
+ {
+ get => _x;
+ set { _x = value; NativeMethods.ghostty_mouse_event_set_position(_handle, new GhosttyMousePositionNative { X = value, Y = _y }); }
+ }
+
+ private float _y;
+ public float Y
+ {
+ get => _y;
+ set { _y = value; NativeMethods.ghostty_mouse_event_set_position(_handle, new GhosttyMousePositionNative { X = _x, Y = value }); }
+ }
+
+ internal nint NativeHandle => _handle;
+
+ public void Dispose()
+ {
+ if (_handle != nint.Zero)
+ NativeMethods.ghostty_mouse_event_free(_handle);
+ }
+}
diff --git a/src/Ghostty.Vt/Native/NativeMethods.cs b/src/Ghostty.Vt/Native/NativeMethods.cs
new file mode 100644
index 0000000..a8d9b81
--- /dev/null
+++ b/src/Ghostty.Vt/Native/NativeMethods.cs
@@ -0,0 +1,423 @@
+using System.Runtime.InteropServices;
+
+namespace Ghostty.Vt.Native;
+
+internal static unsafe partial class NativeMethods
+{
+ private const string LibraryName = "libghostty-vt";
+
+ // --- Terminal lifecycle ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_new(
+ nint allocator, nint* terminal, GhosttyTerminalOptionsNative options);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_terminal_free(nint terminal);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_terminal_reset(nint terminal);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_resize(
+ nint terminal, ushort cols, ushort rows, uint cell_width_px, uint cell_height_px);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_terminal_vt_write(
+ nint terminal, byte* data, nuint len);
+
+ // --- Terminal state queries ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_get(nint terminal, int data, void* @out);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_get_multi(
+ nint terminal, nuint count, int* keys, void** values, nuint* out_written);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_set(
+ nint terminal, int option, void* value);
+
+ // --- Terminal grid ref ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_grid_ref(
+ nint terminal, GhosttyPointNative point, GhosttyGridRefNative* out_ref);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_point_from_grid_ref(
+ nint terminal, GhosttyGridRefNative* grid_ref, int tag, GhosttyPointCoordinateNative* @out);
+
+ // --- Terminal mode ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_mode_get(
+ nint terminal, uint mode, byte* out_value);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_terminal_mode_set(
+ nint terminal, uint mode, byte value);
+
+ // --- Terminal scroll ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_terminal_scroll_viewport(
+ nint terminal, GhosttyTerminalScrollViewportNative behavior);
+
+ // --- RenderState lifecycle ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_new(
+ nint allocator, nint* state);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_render_state_free(nint state);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_update(
+ nint state, nint terminal);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_get(
+ nint state, int data, void* @out);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_colors_get(
+ nint state, void* out_colors);
+
+ // --- RenderState row iterator ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_row_iterator_new(
+ nint allocator, nint* out_iterator);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_render_state_row_iterator_free(nint iterator);
+
+ [LibraryImport(LibraryName)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool ghostty_render_state_row_iterator_next(nint iterator);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_row_get(
+ nint iterator, int data, void* @out);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_render_state_get_multi(
+ nint state, nuint count, int* keys, void** values, nuint* out_written);
+
+ // --- Formatter lifecycle ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_formatter_terminal_new(
+ nint allocator, nint* formatter, nint terminal,
+ void* options);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_formatter_free(nint formatter);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_formatter_format_buf(
+ nint formatter, byte* buf, nuint buf_len, nuint* out_written);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_formatter_format_alloc(
+ nint formatter, nint allocator, byte** out_ptr, nuint* out_len);
+
+ // --- Key encoder ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_key_encoder_new(
+ nint allocator, nint* encoder);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_encoder_free(nint encoder);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_encoder_setopt(
+ nint encoder, int option, void* value);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_encoder_setopt_from_terminal(
+ nint encoder, nint terminal);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_key_encoder_encode(
+ nint encoder, nint key_event, byte* out_buf, nuint out_buf_size, nuint* out_len);
+
+ // --- Mouse encoder ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_mouse_encoder_new(
+ nint allocator, nint* encoder);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_encoder_free(nint encoder);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_encoder_setopt(
+ nint encoder, int option, void* value);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_encoder_setopt_from_terminal(
+ nint encoder, nint terminal);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_encoder_reset(nint encoder);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_mouse_encoder_encode(
+ nint encoder, nint mouse_event, byte* out_buf, nuint out_buf_size, nuint* out_len);
+
+ // --- Mouse event ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_mouse_event_new(
+ nint allocator, nint* mouse_event);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_event_free(nint mouse_event);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_event_set_action(nint mouse_event, int action);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_event_set_button(nint mouse_event, int button);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_event_clear_button(nint mouse_event);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_event_set_mods(nint mouse_event, int mods);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_mouse_event_set_position(nint mouse_event, GhosttyMousePositionNative position);
+
+ // --- Key event ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_key_event_new(
+ nint allocator, nint* key_event);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_event_free(nint key_event);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_event_set_action(nint key_event, int action);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_event_set_key(nint key_event, int key);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_event_set_mods(nint key_event, ushort mods);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_key_event_set_utf8(nint key_event, byte* utf8, nuint len);
+
+ // --- OSC parser (note: function names changed from ghostty_osc_parser_* to ghostty_osc_*) ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_osc_new(
+ nint allocator, nint* parser);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_osc_free(nint parser);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_osc_reset(nint parser);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_osc_next(
+ nint parser, byte b);
+
+ [LibraryImport(LibraryName)]
+ internal static partial nint ghostty_osc_end(
+ nint parser, byte terminator);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_osc_command_type(nint command);
+
+ [LibraryImport(LibraryName)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool ghostty_osc_command_data(
+ nint command, int data, void* @out);
+
+ // --- SGR parser (note: function names changed from ghostty_sgr_parser_* to ghostty_sgr_*) ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_sgr_new(
+ nint allocator, nint* parser);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_sgr_free(nint parser);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_sgr_reset(nint parser);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_sgr_set_params(
+ nint parser, ushort* parameters, byte* separators, nuint len);
+
+ [LibraryImport(LibraryName)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool ghostty_sgr_next(nint parser, void* attr);
+
+ // --- Kitty graphics ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_kitty_graphics_get(
+ nint kitty, int data, void* @out);
+
+ [LibraryImport(LibraryName)]
+ internal static partial nint ghostty_kitty_graphics_image(
+ nint kitty, uint image_id);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_kitty_graphics_image_get(
+ nint image, int data, void* @out);
+
+ // --- Build info ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_build_info(int data, void* @out);
+
+ // --- Type introspection ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial byte* ghostty_type_json();
+
+ // --- Allocator ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial byte* ghostty_alloc(nint allocator, nuint len);
+
+ [LibraryImport(LibraryName)]
+ internal static partial void ghostty_free(nint allocator, byte* ptr, nuint len);
+
+ // --- Focus ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_focus_encode(
+ int @event, byte* buf, nuint buf_len, nuint* out_written);
+
+ // --- Paste ---
+
+ [LibraryImport(LibraryName)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static partial bool ghostty_paste_is_safe(byte* data, nuint len);
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_paste_encode(
+ byte* data, nuint data_len, [MarshalAs(UnmanagedType.Bool)] bool bracketed,
+ byte* buf, nuint buf_len, nuint* out_written);
+
+ // --- Size report ---
+
+ [LibraryImport(LibraryName)]
+ internal static partial int ghostty_size_report_encode(
+ int style, GhosttySizeReportSizeNative size,
+ byte* buf, nuint buf_len, nuint* out_written);
+}
+
+// Native struct matching GhosttyTerminalOptions: { uint16_t cols, uint16_t rows, size_t max_scrollback }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyTerminalOptionsNative
+{
+ public ushort Cols;
+ public ushort Rows;
+ public nuint MaxScrollback;
+}
+
+// Native struct matching GhosttyPoint: { GhosttyPointTag tag, GhosttyPointValue value }
+// GhosttyPointValue is a union { GhosttyPointCoordinate coordinate, uint64_t _padding[2] } = 16 bytes
+// GhosttyPointCoordinate is { uint16_t x, uint32_t y } = 8 bytes
+// Total: 4 (tag) + 4 (pad) + 16 (union) = 24 bytes
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyPointNative
+{
+ public int Tag;
+ private int _pad0; // alignment padding for 8-byte aligned union
+ public ushort X; // inside union, at union offset 0
+ private ushort _pad1; // padding after x for y's 4-byte alignment
+ public uint Y; // inside union, at union offset 4
+ private ulong _pad2; // remaining 8 bytes of 16-byte union
+}
+
+// Native struct matching GhosttyPointCoordinate: { uint16_t x, uint32_t y }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyPointCoordinateNative
+{
+ public ushort X;
+ private ushort _pad;
+ public uint Y;
+}
+
+// Native struct matching GhosttyTerminalScrollViewport: { tag (int), value (union with intptr_t delta + padding) }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyTerminalScrollViewportNative
+{
+ public int Tag; // GhosttyTerminalScrollViewportTag
+ public nint Delta; // intptr_t delta (in the union)
+ public nuint _padding1; // padding for the union
+}
+
+// Native struct matching GhosttyFormatterTerminalOptions (sized struct)
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyFormatterTerminalOptionsNative
+{
+ public nuint Size; // sizeof(GhosttyFormatterTerminalOptions)
+ public int Emit; // GhosttyFormatterFormat enum
+ public byte Unwrap;
+ public byte Trim;
+ // GhosttyFormatterTerminalExtra extra — large struct, zero for now (all extras disabled)
+ // We set size to just cover the fields we use
+}
+
+// Native struct matching GhosttySizeReportSize: { uint16_t rows, uint16_t columns, uint32_t cell_width, uint32_t cell_height }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttySizeReportSizeNative
+{
+ public ushort Rows;
+ public ushort Columns;
+ public uint CellWidth;
+ public uint CellHeight;
+}
+
+// Native struct matching GhosttyString: { const uint8_t* ptr, size_t len }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyStringNative
+{
+ public nint Ptr;
+ public nuint Len;
+}
+
+// Native struct matching GhosttyGridRef: { size_t size, void* node, uint16_t x, uint16_t y }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyGridRefNative
+{
+ public nuint Size;
+ public nint Node;
+ public ushort X;
+ public ushort Y;
+}
+
+// Legacy alias for backward compat within the codebase
+[StructLayout(LayoutKind.Sequential)]
+internal struct PointNative
+{
+ public int Tag;
+ private int _pad0;
+ public ushort X;
+ private ushort _pad1;
+ public uint Y;
+ private ulong _pad2;
+}
+
+// Native struct matching GhosttyMousePosition: { float x, float y }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyMousePositionNative
+{
+ public float X;
+ public float Y;
+}
diff --git a/src/Ghostty.Vt/OscParser.cs b/src/Ghostty.Vt/OscParser.cs
new file mode 100644
index 0000000..0eec91f
--- /dev/null
+++ b/src/Ghostty.Vt/OscParser.cs
@@ -0,0 +1,82 @@
+using Ghostty.Vt.Enums;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+using Ghostty.Vt.Types;
+
+namespace Ghostty.Vt;
+
+public sealed class OscParser : IDisposable
+{
+ private readonly OscParserSafeHandle _handle;
+ private nint _lastCommand;
+
+ public unsafe OscParser()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_osc_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = new OscParserSafeHandle(handle);
+ _lastCommand = nint.Zero;
+ }
+
+ public void Next(byte b)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_osc_next(_handle.DangerousGetHandle(), b);
+ }
+
+ public OscCommandType End(byte terminator = 0x07)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ _lastCommand = NativeMethods.ghostty_osc_end(
+ _handle.DangerousGetHandle(), terminator);
+ return (OscCommandType)NativeMethods.ghostty_osc_command_type(_lastCommand);
+ }
+
+ public OscCommandType CommandType
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return (OscCommandType)NativeMethods.ghostty_osc_command_type(_lastCommand);
+ }
+ }
+
+ public unsafe GhosttyString CommandData
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ nint strPtr;
+ if (!NativeMethods.ghostty_osc_command_data(
+ _lastCommand, (int)OscCommandData.ChangeWindowTitleStr, &strPtr))
+ {
+ return new GhosttyString(nint.Zero, 0);
+ }
+ return new GhosttyString(strPtr, 0); // null-terminated string
+ }
+ }
+
+ public void Reset()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_osc_reset(_handle.DangerousGetHandle());
+ _lastCommand = nint.Zero;
+ }
+
+ public void Dispose() => _handle.Dispose();
+
+ private sealed class OscParserSafeHandle : GhosttySafeHandle
+ {
+ public OscParserSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint h) => NativeMethods.ghostty_osc_free(h);
+ public new nint DangerousGetHandle() => handle;
+ }
+}
+
+// OSC command data enum values
+public enum OscCommandData
+{
+ Invalid = 0,
+ ChangeWindowTitleStr = 1,
+}
diff --git a/src/Ghostty.Vt/Paste.cs b/src/Ghostty.Vt/Paste.cs
new file mode 100644
index 0000000..ff5a017
--- /dev/null
+++ b/src/Ghostty.Vt/Paste.cs
@@ -0,0 +1,33 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public static class Paste
+{
+ public static unsafe bool IsSafe(ReadOnlySpan data)
+ {
+ fixed (byte* ptr = data)
+ {
+ return NativeMethods.ghostty_paste_is_safe(ptr, (nuint)data.Length);
+ }
+ }
+
+ public static unsafe byte[] Encode(ReadOnlySpan data, bool bracketed = true)
+ {
+ // Worst case: bracketed paste adds prefix + suffix around the data
+ int maxLen = data.Length + 64;
+ byte[] buf = new byte[maxLen];
+ fixed (byte* srcPtr = data)
+ fixed (byte* dstPtr = buf)
+ {
+ nuint written;
+ var result = NativeMethods.ghostty_paste_encode(
+ srcPtr, (nuint)data.Length, bracketed, dstPtr, (nuint)maxLen, &written);
+ GhosttyException.ThrowIfFailure(result);
+ if (written == 0) return [];
+ var bytes = new byte[(int)written];
+ new ReadOnlySpan(dstPtr, (int)written).CopyTo(bytes);
+ return bytes;
+ }
+ }
+}
diff --git a/src/Ghostty.Vt/RenderState.cs b/src/Ghostty.Vt/RenderState.cs
new file mode 100644
index 0000000..0659c15
--- /dev/null
+++ b/src/Ghostty.Vt/RenderState.cs
@@ -0,0 +1,202 @@
+using Ghostty.Vt.Enums;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+using Ghostty.Vt.Types;
+
+namespace Ghostty.Vt;
+
+public sealed class RenderState : IDisposable
+{
+ private readonly RenderStateSafeHandle _handle;
+
+ public unsafe RenderState()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_render_state_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = new RenderStateSafeHandle(handle);
+ }
+
+ public void Update(Terminal terminal)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ var result = NativeMethods.ghostty_render_state_update(
+ _handle.DangerousGetHandle(), terminal.NativeHandle);
+ GhosttyException.ThrowIfFailure(result);
+ }
+
+ public unsafe RenderStateDirty Dirty
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ int value;
+ NativeMethods.ghostty_render_state_get(
+ _handle.DangerousGetHandle(), (int)RenderStateData.Dirty, &value);
+ return (RenderStateDirty)value;
+ }
+ }
+
+ public unsafe RenderStateColors Colors
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ // GhosttyRenderStateColors: { size_t size(8), background(3), foreground(3), cursor(3), cursor_has_value(1), palette[256](768) } = 792 bytes
+ const int StructSize = 792;
+ byte* buf = stackalloc byte[StructSize];
+ new Span(buf, StructSize).Clear();
+ *(nuint*)(buf + 0) = StructSize; // size field
+
+ var result = NativeMethods.ghostty_render_state_colors_get(
+ _handle.DangerousGetHandle(), buf);
+ GhosttyException.ThrowIfFailure(result);
+
+ // Read fields at exact offsets per type JSON:
+ // background@8(3), foreground@11(3), cursor@14(3), cursor_has_value@17(1), palette@18(768)
+ return new RenderStateColors
+ {
+ Background = new ColorRgb { R = buf[8], G = buf[9], B = buf[10] },
+ Foreground = new ColorRgb { R = buf[11], G = buf[12], B = buf[13] },
+ Cursor = new ColorRgb { R = buf[14], G = buf[15], B = buf[16] },
+ CursorHasValue = buf[17] != 0,
+ };
+ }
+ }
+
+ public RenderStateRowEnumerable Rows
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return new RenderStateRowEnumerable(_handle.DangerousGetHandle());
+ }
+ }
+
+ internal nint NativeHandle => _handle.DangerousGetHandle();
+
+ public void Dispose() => _handle.Dispose();
+
+ private sealed class RenderStateSafeHandle : GhosttySafeHandle
+ {
+ public RenderStateSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint handle) => NativeMethods.ghostty_render_state_free(handle);
+ public new nint DangerousGetHandle() => handle;
+ }
+}
+
+public enum RenderStateData
+{
+ Invalid = 0,
+ Cols = 1,
+ Rows = 2,
+ Dirty = 3,
+ RowIterator = 4,
+ ColorBackground = 5,
+ ColorForeground = 6,
+ ColorCursor = 7,
+ ColorCursorHasValue = 8,
+ ColorPalette = 9,
+ CursorVisualStyle = 10,
+ CursorVisible = 11,
+ CursorBlinking = 12,
+ CursorPasswordInput = 13,
+ CursorViewportHasValue = 14,
+ CursorViewportX = 15,
+ CursorViewportY = 16,
+ CursorViewportWideTail = 17,
+}
+
+public ref struct RenderStateRowEnumerable
+{
+ private readonly nint _state;
+ internal RenderStateRowEnumerable(nint state) => _state = state;
+ public RenderStateRowEnumerator GetEnumerator() => new(_state);
+}
+
+public ref struct RenderStateRowEnumerator
+{
+ private readonly nint _state;
+ private nint _iterator;
+ private bool _started;
+ private bool _hasCurrent;
+
+ internal RenderStateRowEnumerator(nint state) { _state = state; _iterator = 0; _started = false; _hasCurrent = false; }
+
+ public unsafe bool MoveNext()
+ {
+ if (!_started)
+ {
+ // Create the iterator handle
+ nint iter;
+ var result = NativeMethods.ghostty_render_state_row_iterator_new(nint.Zero, &iter);
+ GhosttyException.ThrowIfFailure(result);
+
+ // Populate iterator with row data from render state.
+ // ghostty_render_state_get(state, ROW_ITERATOR, out) expects
+ // GhosttyRenderStateRowIterator* = nint* (pointer to the opaque handle).
+ result = NativeMethods.ghostty_render_state_get(
+ _state, (int)RenderStateData.RowIterator, &iter);
+ GhosttyException.ThrowIfFailure(result);
+ _iterator = iter;
+ _started = true;
+ }
+
+ _hasCurrent = NativeMethods.ghostty_render_state_row_iterator_next(_iterator);
+ return _hasCurrent;
+ }
+
+ public unsafe RenderStateRow Current
+ {
+ get
+ {
+ // Read dirty flag for current row
+ byte dirty = 0;
+ NativeMethods.ghostty_render_state_row_get(
+ _iterator, 1 /* ROW_DATA_DIRTY */, &dirty);
+ return new RenderStateRow
+ {
+ Dirty = dirty != 0,
+ Cells = new RenderStateCellEnumerable(_iterator, 0),
+ };
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_iterator != 0)
+ {
+ NativeMethods.ghostty_render_state_row_iterator_free(_iterator);
+ _iterator = 0;
+ }
+ }
+}
+
+public ref struct RenderStateRow
+{
+ public bool Dirty { get; init; }
+ public int Index { get; init; }
+ public RenderStateCellEnumerable Cells { get; init; }
+}
+
+public ref struct RenderStateCellEnumerable
+{
+ private readonly nint _state;
+ private readonly int _rowIndex;
+
+ internal RenderStateCellEnumerable(nint state, int rowIndex)
+ { _state = state; _rowIndex = rowIndex; }
+
+ public RenderStateCellEnumerator GetEnumerator() => new(_state, _rowIndex);
+}
+
+public ref struct RenderStateCellEnumerator
+{
+ private readonly nint _state;
+ private readonly int _rowIndex;
+ internal RenderStateCellEnumerator(nint state, int rowIndex)
+ { _state = state; _rowIndex = rowIndex; }
+
+ public bool MoveNext() => throw new NotImplementedException();
+ public Cell Current => throw new NotImplementedException();
+}
diff --git a/src/Ghostty.Vt/SgrParser.cs b/src/Ghostty.Vt/SgrParser.cs
new file mode 100644
index 0000000..f59a272
--- /dev/null
+++ b/src/Ghostty.Vt/SgrParser.cs
@@ -0,0 +1,83 @@
+using Ghostty.Vt.Enums;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class SgrParser : IDisposable
+{
+ private readonly SgrParserSafeHandle _handle;
+ private int _lastTag;
+
+ public unsafe SgrParser()
+ {
+ nint handle;
+ var result = NativeMethods.ghostty_sgr_new(nint.Zero, &handle);
+ GhosttyException.ThrowIfFailure(result);
+ _handle = new SgrParserSafeHandle(handle);
+ _lastTag = 0;
+ }
+
+ public unsafe void SetParameters(ReadOnlySpan parameters)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ // Allocate separators buffer filled with ';' for each parameter
+ Span seps = parameters.Length > 0 ? new byte[parameters.Length] : [];
+ for (int i = 0; i < seps.Length; i++) seps[i] = (byte)';';
+
+ fixed (ushort* ptr = parameters)
+ fixed (byte* sepPtr = seps)
+ {
+ var result = NativeMethods.ghostty_sgr_set_params(
+ _handle.DangerousGetHandle(), ptr, sepPtr, (nuint)parameters.Length);
+ GhosttyException.ThrowIfFailure(result);
+ }
+ _lastTag = 0;
+ }
+
+ public unsafe bool Next()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ // GhosttySgrAttribute is { GhosttySgrAttributeTag tag (int), GhosttySgrAttributeValue value (union) }
+ // GhosttySgrAttributeValue union has _padding[8] of uint64_t = 64 bytes
+ // Total: 4 (tag) + 4 (padding) + 64 (union) = 72 bytes on 64-bit
+ int* attrBuf = stackalloc int[18]; // 72 bytes
+ bool hasMore = NativeMethods.ghostty_sgr_next(_handle.DangerousGetHandle(), attrBuf);
+ _lastTag = hasMore ? attrBuf[0] : 0;
+ return hasMore;
+ }
+
+ public SgrAttributeTag AttributeTag
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return (SgrAttributeTag)_lastTag;
+ }
+ }
+
+ public uint AttributeValue
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return default;
+ }
+ }
+
+ public void Reset()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_sgr_reset(_handle.DangerousGetHandle());
+ _lastTag = 0;
+ }
+
+ public void Dispose() => _handle.Dispose();
+
+ private sealed class SgrParserSafeHandle : GhosttySafeHandle
+ {
+ public SgrParserSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint h) => NativeMethods.ghostty_sgr_free(h);
+ public new nint DangerousGetHandle() => handle;
+ }
+}
diff --git a/src/Ghostty.Vt/SizeReport.cs b/src/Ghostty.Vt/SizeReport.cs
new file mode 100644
index 0000000..1c2d28b
--- /dev/null
+++ b/src/Ghostty.Vt/SizeReport.cs
@@ -0,0 +1,29 @@
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public static class SizeReport
+{
+ public static unsafe byte[] Encode(uint cols, uint rows, uint widthPx, uint heightPx)
+ {
+ Span buf = stackalloc byte[64];
+ fixed (byte* ptr = buf)
+ {
+ var size = new GhosttySizeReportSizeNative
+ {
+ Rows = (ushort)rows,
+ Columns = (ushort)cols,
+ CellWidth = widthPx,
+ CellHeight = heightPx,
+ };
+ nuint written;
+ var result = NativeMethods.ghostty_size_report_encode(
+ 0 /* GHOSTTY_SIZE_REPORT_MODE_2048 */, size, ptr, 64, &written);
+ GhosttyException.ThrowIfFailure(result);
+ if (written == 0) return [];
+ var bytes = new byte[(int)written];
+ new ReadOnlySpan(ptr, (int)written).CopyTo(bytes);
+ return bytes;
+ }
+ }
+}
diff --git a/src/Ghostty.Vt/Terminal.cs b/src/Ghostty.Vt/Terminal.cs
new file mode 100644
index 0000000..7d8bdaf
--- /dev/null
+++ b/src/Ghostty.Vt/Terminal.cs
@@ -0,0 +1,302 @@
+using System.Runtime.InteropServices;
+using Ghostty.Vt.Enums;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+using Ghostty.Vt.Types;
+
+namespace Ghostty.Vt;
+
+public sealed unsafe class Terminal : IDisposable
+{
+ private readonly TerminalSafeHandle _handle;
+ private readonly TerminalOptions? _options;
+
+ public Terminal(int cols, int rows, Action? configure = null)
+ {
+ if (cols <= 0) throw new ArgumentOutOfRangeException(nameof(cols));
+ if (rows <= 0) throw new ArgumentOutOfRangeException(nameof(rows));
+
+ var options = new TerminalOptions();
+ configure?.Invoke(options);
+ _options = options;
+
+ var nativeOpts = options.BuildNativeOptions(cols, rows);
+ nint handle = nint.Zero;
+ var result = NativeMethods.ghostty_terminal_new(
+ nint.Zero, // default allocator
+ &handle,
+ nativeOpts);
+ if (result != 0 || handle == nint.Zero)
+ throw new GhosttyException($"Failed to create terminal (result={result})");
+
+ _handle = new TerminalSafeHandle(handle);
+
+ // Register callbacks via ghostty_terminal_set
+ RegisterCallbacks(options, handle);
+ }
+
+ private unsafe void RegisterCallbacks(TerminalOptions options, nint handle)
+ {
+ if (options.OnWritePty is not null)
+ {
+ var del = new GhosttyTerminalWritePtyFn((_, _, data, len) =>
+ {
+ var span = new ReadOnlySpan(data, (int)len);
+ options.OnWritePty(span);
+ });
+ options.Pinner.Pin(del);
+ NativeMethods.ghostty_terminal_set(handle, 1 /* GHOSTTY_TERMINAL_OPT_WRITE_PTY */, (void*)Marshal.GetFunctionPointerForDelegate(del));
+ }
+
+ if (options.OnBell is not null)
+ {
+ var del = new GhosttyTerminalBellFn((_, _) => options.OnBell());
+ options.Pinner.Pin(del);
+ NativeMethods.ghostty_terminal_set(handle, 2 /* GHOSTTY_TERMINAL_OPT_BELL */, (void*)Marshal.GetFunctionPointerForDelegate(del));
+ }
+
+ if (options.OnTitleChanged is not null)
+ {
+ var del = new GhosttyTerminalTitleChangedFn((_, _) => options.OnTitleChanged());
+ options.Pinner.Pin(del);
+ NativeMethods.ghostty_terminal_set(handle, 5 /* GHOSTTY_TERMINAL_OPT_TITLE_CHANGED */, (void*)Marshal.GetFunctionPointerForDelegate(del));
+ }
+
+ // PwdChanged is not a native callback — it's observed via OnTitleChanged + reading Pwd.
+ // The native API doesn't have a dedicated PWD callback.
+ }
+
+ // --- VT Input ---
+ public unsafe void VTWrite(ReadOnlySpan data)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ fixed (byte* ptr = data)
+ {
+ NativeMethods.ghostty_terminal_vt_write(
+ _handle.DangerousGetHandle(), ptr, (nuint)data.Length);
+ }
+ }
+
+ public void VTWrite(byte[] data) => VTWrite(data.AsSpan());
+
+ public void VTWrite(string text)
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(text);
+ VTWrite(bytes);
+ }
+
+ // --- State queries (typed properties) ---
+ public int Cols => QueryInt(TerminalData.Cols);
+ public int Rows => QueryInt(TerminalData.Rows);
+ public int CursorX => QueryInt(TerminalData.CursorX);
+ public int CursorY => QueryInt(TerminalData.CursorY);
+ public bool CursorPendingWrap => QueryInt(TerminalData.CursorPendingWrap) != 0;
+ public bool CursorVisible => QueryInt(TerminalData.CursorVisible) != 0;
+ public TerminalScreen ActiveScreen => (TerminalScreen)QueryInt(TerminalData.ActiveScreen);
+ public int ScrollOffset => (int)QueryScrollbar().Offset;
+
+ public string? Title
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return QueryString(TerminalData.Title);
+ }
+ }
+
+ public string? Pwd
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return QueryString(TerminalData.Pwd);
+ }
+ }
+
+ public unsafe void SetPwd(string? pwd)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ if (pwd == null)
+ {
+ NativeMethods.ghostty_terminal_set(
+ _handle.DangerousGetHandle(), 10 /* OPT_PWD */, null);
+ return;
+ }
+ var bytes = System.Text.Encoding.UTF8.GetBytes(pwd);
+ fixed (byte* ptr = bytes)
+ {
+ GhosttyStringNative gs;
+ gs.Ptr = (nint)ptr;
+ gs.Len = (nuint)bytes.Length;
+ NativeMethods.ghostty_terminal_set(
+ _handle.DangerousGetHandle(), 10 /* OPT_PWD */, &gs);
+ }
+ }
+
+ // --- Operations ---
+ public void Resize(int cols, int rows, int cellWidthPx = 0, int cellHeightPx = 0)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_terminal_resize(
+ _handle.DangerousGetHandle(),
+ (ushort)cols, (ushort)rows,
+ (uint)cellWidthPx, (uint)cellHeightPx);
+ }
+
+ public void Reset()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_terminal_reset(_handle.DangerousGetHandle());
+ }
+
+ public bool ModeGet(TerminalMode mode)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ byte value = 0;
+ NativeMethods.ghostty_terminal_mode_get(
+ _handle.DangerousGetHandle(), (uint)mode, &value);
+ return value != 0;
+ }
+
+ public void ModeSet(TerminalMode mode, bool value)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ NativeMethods.ghostty_terminal_mode_set(
+ _handle.DangerousGetHandle(), (uint)mode, (byte)(value ? 1 : 0));
+ }
+
+ public void ScrollViewportToTop()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ var behavior = new GhosttyTerminalScrollViewportNative { Tag = 0 }; // GHOSTTY_SCROLL_VIEWPORT_TOP
+ NativeMethods.ghostty_terminal_scroll_viewport(_handle.DangerousGetHandle(), behavior);
+ }
+
+ public void ScrollViewportToBottom()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ var behavior = new GhosttyTerminalScrollViewportNative { Tag = 1 }; // GHOSTTY_SCROLL_VIEWPORT_BOTTOM
+ NativeMethods.ghostty_terminal_scroll_viewport(_handle.DangerousGetHandle(), behavior);
+ }
+
+ public void ScrollViewportBy(int delta)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ var behavior = new GhosttyTerminalScrollViewportNative
+ {
+ Tag = 2, // GHOSTTY_SCROLL_VIEWPORT_DELTA
+ Delta = (nint)delta,
+ };
+ NativeMethods.ghostty_terminal_scroll_viewport(_handle.DangerousGetHandle(), behavior);
+ }
+
+ // --- Grid access ---
+ public unsafe GridRef GetGridRef(Point point)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ var nativePoint = new GhosttyPointNative { Tag = point.NativeTag, X = point.NativeX, Y = point.NativeY };
+ // Sized struct: must initialize size before calling
+ var gridRef = new GhosttyGridRefNative { Size = (nuint)sizeof(GhosttyGridRefNative) };
+ NativeMethods.ghostty_terminal_grid_ref(
+ _handle.DangerousGetHandle(), nativePoint, &gridRef);
+ return new GridRef(gridRef, this);
+ }
+
+ public unsafe Point PointFromGridRef(GridRef gridRef)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ GhosttyGridRefNative nativeRef = gridRef.Native;
+ // Ensure size is set for the sized struct
+ nativeRef.Size = (nuint)sizeof(GhosttyGridRefNative);
+ GhosttyPointCoordinateNative coord;
+ NativeMethods.ghostty_terminal_point_from_grid_ref(
+ _handle.DangerousGetHandle(),
+ &nativeRef,
+ 0, // active tag
+ &coord);
+ return new Point { Tag = (PointTag)0, X = coord.X, Y = (int)coord.Y };
+ }
+
+ // --- Formatter factory ---
+ public Formatter CreateFormatter(FormatterFormat format, Action? configure = null)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return new Formatter(_handle.DangerousGetHandle(), format, configure);
+ }
+
+ // --- Kitty graphics accessor (borrowed, ref struct) ---
+ public KittyGraphicsAccessor KittyGraphics
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ return new KittyGraphicsAccessor(this);
+ }
+ }
+
+ // --- Internal ---
+ internal nint NativeHandle => _handle.DangerousGetHandle();
+
+ private unsafe int QueryInt(TerminalData data)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ // Allocate 8 bytes to safely receive any scalar type:
+ // uint16_t, bool, int enum, size_t, uint32_t
+ long value = 0;
+ NativeMethods.ghostty_terminal_get(
+ _handle.DangerousGetHandle(), (int)data, &value);
+ return (int)value;
+ }
+
+ private unsafe string? QueryString(TerminalData data)
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ GhosttyStringNative gs;
+ NativeMethods.ghostty_terminal_get(
+ _handle.DangerousGetHandle(), (int)data, &gs);
+ if (gs.Ptr == 0 || gs.Len == 0) return null;
+ return System.Runtime.InteropServices.Marshal.PtrToStringUTF8(gs.Ptr, (int)gs.Len);
+ }
+
+ private unsafe (ulong Total, ulong Offset, ulong Len) QueryScrollbar()
+ {
+ ObjectDisposedException.ThrowIf(_handle.IsInvalid, this);
+ GhosttyScrollbarNative sb;
+ NativeMethods.ghostty_terminal_get(
+ _handle.DangerousGetHandle(), 9 /* GHOSTTY_TERMINAL_DATA_SCROLLBAR */, &sb);
+ return (sb.Total, sb.Offset, sb.Len);
+ }
+
+ public void Dispose()
+ {
+ _handle.Dispose();
+ _options?.Pinner.Dispose();
+ }
+
+ // Nested SafeHandle
+ private sealed class TerminalSafeHandle : GhosttySafeHandle
+ {
+ public TerminalSafeHandle(nint handle) { SetHandle(handle); }
+ protected override void Free(nint handle) => NativeMethods.ghostty_terminal_free(handle);
+ public new nint DangerousGetHandle() => handle;
+ }
+
+ // Callback delegate types matching native signatures
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private unsafe delegate void GhosttyTerminalWritePtyFn(nint terminal, void* userdata, byte* data, nuint len);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private unsafe delegate void GhosttyTerminalBellFn(nint terminal, void* userdata);
+
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private unsafe delegate void GhosttyTerminalTitleChangedFn(nint terminal, void* userdata);
+}
+
+// GhosttyScrollbar: { uint64_t total, uint64_t offset, uint64_t len }
+[StructLayout(LayoutKind.Sequential)]
+internal struct GhosttyScrollbarNative
+{
+ public ulong Total;
+ public ulong Offset;
+ public ulong Len;
+}
diff --git a/src/Ghostty.Vt/TerminalOptions.cs b/src/Ghostty.Vt/TerminalOptions.cs
new file mode 100644
index 0000000..3de6543
--- /dev/null
+++ b/src/Ghostty.Vt/TerminalOptions.cs
@@ -0,0 +1,28 @@
+using System.Runtime.InteropServices;
+using Ghostty.Vt.Internals;
+using Ghostty.Vt.Native;
+
+namespace Ghostty.Vt;
+
+public sealed class TerminalOptions
+{
+ internal DelegatePinner Pinner { get; } = new();
+
+ // Callbacks — invoked synchronously during VTWrite
+ public Action>? OnWritePty { get; set; }
+ public Action? OnBell { get; set; }
+ public Action? OnTitleChanged { get; set; }
+ public Action? OnPwdChanged { get; set; }
+
+ // Build the native options struct (cols, rows, max_scrollback).
+ // Callbacks are registered after terminal creation via ghostty_terminal_set.
+ internal GhosttyTerminalOptionsNative BuildNativeOptions(int cols, int rows)
+ {
+ return new GhosttyTerminalOptionsNative
+ {
+ Cols = (ushort)cols,
+ Rows = (ushort)rows,
+ MaxScrollback = (nuint)1000, // default scrollback
+ };
+ }
+}
diff --git a/src/Ghostty.Vt/Types/Cell.cs b/src/Ghostty.Vt/Types/Cell.cs
new file mode 100644
index 0000000..bac8812
--- /dev/null
+++ b/src/Ghostty.Vt/Types/Cell.cs
@@ -0,0 +1,11 @@
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Types;
+
+public readonly struct Cell
+{
+ public CellContentTag ContentTag { get; init; }
+ public string? Grapheme { get; init; }
+ public Style Style { get; init; }
+ public uint KittyPlacementId { get; init; }
+}
diff --git a/src/Ghostty.Vt/Types/ColorRgb.cs b/src/Ghostty.Vt/Types/ColorRgb.cs
new file mode 100644
index 0000000..9ab3ad5
--- /dev/null
+++ b/src/Ghostty.Vt/Types/ColorRgb.cs
@@ -0,0 +1,8 @@
+namespace Ghostty.Vt.Types;
+
+public readonly struct ColorRgb
+{
+ public byte R { get; init; }
+ public byte G { get; init; }
+ public byte B { get; init; }
+}
diff --git a/src/Ghostty.Vt/Types/GhosttyString.cs b/src/Ghostty.Vt/Types/GhosttyString.cs
new file mode 100644
index 0000000..d3b3693
--- /dev/null
+++ b/src/Ghostty.Vt/Types/GhosttyString.cs
@@ -0,0 +1,22 @@
+using System.Runtime.InteropServices;
+
+namespace Ghostty.Vt.Types;
+
+public readonly ref struct GhosttyString
+{
+ private readonly nint _ptr;
+ private readonly nuint _len;
+
+ internal GhosttyString(nint ptr, nuint len)
+ {
+ _ptr = ptr;
+ _len = len;
+ }
+
+ public override string ToString()
+ {
+ if (_ptr == nint.Zero || _len == 0) return string.Empty;
+ var span = Marshal.PtrToStringUTF8(_ptr, (int)_len);
+ return span ?? string.Empty;
+ }
+}
diff --git a/src/Ghostty.Vt/Types/Point.cs b/src/Ghostty.Vt/Types/Point.cs
new file mode 100644
index 0000000..c2e8323
--- /dev/null
+++ b/src/Ghostty.Vt/Types/Point.cs
@@ -0,0 +1,19 @@
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Types;
+
+public readonly struct Point
+{
+ public PointTag Tag { get; init; }
+ public int X { get; init; }
+ public int Y { get; init; }
+
+ public static Point Active(int x, int y) => new() { Tag = PointTag.Active, X = x, Y = y };
+ public static Point Viewport(int x, int y) => new() { Tag = PointTag.Viewport, X = x, Y = y };
+ public static Point Screen(int x, int y) => new() { Tag = PointTag.Screen, X = x, Y = y };
+ public static Point History(int x, int y) => new() { Tag = PointTag.History, X = x, Y = y };
+
+ internal int NativeTag => (int)Tag;
+ internal ushort NativeX => (ushort)X;
+ internal uint NativeY => (uint)Y;
+}
diff --git a/src/Ghostty.Vt/Types/RenderStateColors.cs b/src/Ghostty.Vt/Types/RenderStateColors.cs
new file mode 100644
index 0000000..d9f398c
--- /dev/null
+++ b/src/Ghostty.Vt/Types/RenderStateColors.cs
@@ -0,0 +1,11 @@
+using Ghostty.Vt.Types;
+
+namespace Ghostty.Vt;
+
+public readonly struct RenderStateColors
+{
+ public ColorRgb Foreground { get; init; }
+ public ColorRgb Background { get; init; }
+ public ColorRgb Cursor { get; init; }
+ public bool CursorHasValue { get; init; }
+}
diff --git a/src/Ghostty.Vt/Types/Row.cs b/src/Ghostty.Vt/Types/Row.cs
new file mode 100644
index 0000000..3793afc
--- /dev/null
+++ b/src/Ghostty.Vt/Types/Row.cs
@@ -0,0 +1,7 @@
+namespace Ghostty.Vt.Types;
+
+public readonly struct Row
+{
+ public int Index { get; init; }
+ public bool Dirty { get; init; }
+}
diff --git a/src/Ghostty.Vt/Types/Style.cs b/src/Ghostty.Vt/Types/Style.cs
new file mode 100644
index 0000000..e37216b
--- /dev/null
+++ b/src/Ghostty.Vt/Types/Style.cs
@@ -0,0 +1,20 @@
+using System.Runtime.InteropServices;
+
+namespace Ghostty.Vt.Types;
+
+[StructLayout(LayoutKind.Sequential)]
+public struct Style
+{
+ public nuint Size; // leading size field for ABI forward-compat
+ public uint FgColor;
+ public uint BgColor;
+ public uint UnderlineColor;
+ public byte Bold;
+ public byte Dim;
+ public byte Italic;
+ public byte Underline;
+ public byte Blink;
+ public byte Inverse;
+ public byte Invisible;
+ public byte Strikethrough;
+}
diff --git a/tests/Ghostty.Vt.Tests/BuildInfoTests.cs b/tests/Ghostty.Vt.Tests/BuildInfoTests.cs
new file mode 100644
index 0000000..8000579
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/BuildInfoTests.cs
@@ -0,0 +1,20 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class BuildInfoTests
+{
+ [Fact]
+ public void Query_ReturnsNonEmptyVersion()
+ {
+ var info = BuildInfo.Query();
+ Assert.False(string.IsNullOrEmpty(info.Version));
+ }
+
+ [Fact]
+ public void Query_ReturnsNonEmptyZigVersion()
+ {
+ var info = BuildInfo.Query();
+ Assert.False(string.IsNullOrEmpty(info.ZigVersion));
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs b/tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs
new file mode 100644
index 0000000..dc9722e
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/FocusPasteSizeReportTests.cs
@@ -0,0 +1,46 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class FocusPasteSizeReportTests
+{
+ [Fact]
+ public void Focus_EncodeFocused_ProducesSequence()
+ {
+ var result = Focus.Encode(focused: true);
+ Assert.True(result.Length > 0);
+ }
+
+ [Fact]
+ public void Focus_EncodeUnfocused_ProducesSequence()
+ {
+ var result = Focus.Encode(focused: false);
+ Assert.True(result.Length > 0);
+ }
+
+ [Fact]
+ public void Paste_IsSafe_PlainAscii_ReturnsTrue()
+ {
+ Assert.True(Paste.IsSafe("Hello World"u8));
+ }
+
+ [Fact]
+ public void Paste_IsSafe_ContainsNewline_ReturnsFalse()
+ {
+ Assert.False(Paste.IsSafe("Hello\nWorld"u8));
+ }
+
+ [Fact]
+ public void Paste_Encode_WrapsInBrackets()
+ {
+ var encoded = Paste.Encode("test"u8);
+ Assert.True(encoded.Length > 4);
+ }
+
+ [Fact]
+ public void SizeReport_Encode_ProducesSequence()
+ {
+ var result = SizeReport.Encode(80, 24, 640, 384);
+ Assert.True(result.Length > 0);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/FormatterTests.cs b/tests/Ghostty.Vt.Tests/FormatterTests.cs
new file mode 100644
index 0000000..4d8febf
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/FormatterTests.cs
@@ -0,0 +1,39 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class FormatterTests
+{
+ [Fact]
+ public void PlainText_ExtractsContent()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("Hello, World!"u8);
+
+ using var formatter = term.CreateFormatter(FormatterFormat.PlainText);
+ var result = formatter.ToString();
+ Assert.Contains("Hello, World!", result);
+ }
+
+ [Fact]
+ public void Html_ContainsMarkup()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("Hello"u8);
+
+ using var formatter = term.CreateFormatter(FormatterFormat.Html);
+ var result = formatter.ToString();
+ Assert.Contains("<", result);
+ }
+
+ [Fact]
+ public void Vt_PreservesEscapeSequences()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("\x1b[1mbold\x1b[0m"u8);
+
+ using var formatter = term.CreateFormatter(FormatterFormat.Vt);
+ var result = formatter.ToString();
+ Assert.NotEmpty(result);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj b/tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj
new file mode 100644
index 0000000..5387b0d
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/Ghostty.Vt.Tests.csproj
@@ -0,0 +1,16 @@
+
+
+ net9.0
+ true
+ false
+ true
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Ghostty.Vt.Tests/GlobalUsings.cs b/tests/Ghostty.Vt.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..96c5315
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using Ghostty.Vt.Enums;
+global using Ghostty.Vt.Types;
diff --git a/tests/Ghostty.Vt.Tests/GridRefFullTests.cs b/tests/Ghostty.Vt.Tests/GridRefFullTests.cs
new file mode 100644
index 0000000..b9ac9fb
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/GridRefFullTests.cs
@@ -0,0 +1,27 @@
+using Xunit;
+using Ghostty.Vt.Types;
+
+namespace Ghostty.Vt.Tests;
+
+public class GridRefFullTests
+{
+ [Fact]
+ public void GridRef_AtWrittenCell_HasGraphemeContent()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("AB"u8);
+
+ var point = Point.Active(0, 0);
+ var gridRef = term.GetGridRef(point);
+ }
+
+ [Fact]
+ public void GridRef_StyleAfterCSI_HasAttributes()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("\x1b[1mBold\x1b[0m"u8);
+
+ var point = Point.Active(0, 0);
+ var gridRef = term.GetGridRef(point);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/IntegrationTests.cs b/tests/Ghostty.Vt.Tests/IntegrationTests.cs
new file mode 100644
index 0000000..abc33d8
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/IntegrationTests.cs
@@ -0,0 +1,31 @@
+using Xunit;
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Tests;
+
+public class IntegrationTests
+{
+ [Fact]
+ public void FullPipeline_WriteRenderFormat_RoundTrip()
+ {
+ using var term = new Terminal(80, 24);
+
+ term.VTWrite("\x1b[1;31mBold Red\x1b[0m"u8);
+ term.VTWrite("Normal"u8);
+ term.VTWrite("\x1b]2;Test Title\x07"u8);
+
+ Assert.Equal("Test Title", term.Title);
+ Assert.Equal(14, term.CursorX);
+
+ using var state = new RenderState();
+ state.Update(term);
+ Assert.NotEqual(RenderStateDirty.False, state.Dirty);
+
+ using var formatter = term.CreateFormatter(FormatterFormat.PlainText);
+ var output = formatter.ToString();
+ Assert.Contains("Bold RedNormal", output);
+
+ var buildInfo = BuildInfo.Query();
+ Assert.NotEmpty(buildInfo.Version);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/KeyEncoderTests.cs b/tests/Ghostty.Vt.Tests/KeyEncoderTests.cs
new file mode 100644
index 0000000..d9c8fcf
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/KeyEncoderTests.cs
@@ -0,0 +1,42 @@
+using Xunit;
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Tests;
+
+public class KeyEncoderTests
+{
+ [Fact]
+ public void Create_Succeeds()
+ {
+ using var encoder = new KeyEncoder();
+ Assert.NotNull(encoder);
+ }
+
+ [Fact]
+ public void Encode_LetterKey_ProducesNonEmptySequence()
+ {
+ using var term = new Terminal(80, 24);
+ using var encoder = new KeyEncoder();
+ encoder.ConfigureFromTerminal(term);
+ using var keyEvent = new KeyEvent { Key = (int)GhosttyKey.A, Action = 1, Text = "a" };
+ var result = encoder.Encode(keyEvent);
+ Assert.True(result.Length > 0);
+ }
+
+ [Fact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var encoder = new KeyEncoder();
+ encoder.Dispose();
+ encoder.Dispose();
+ }
+
+ [Fact]
+ public void ConfigureFromTerminal_SyncsModes()
+ {
+ using var term = new Terminal(80, 24);
+ using var encoder = new KeyEncoder();
+ term.ModeSet(TerminalMode.KittyKeyboard, true);
+ encoder.ConfigureFromTerminal(term);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs b/tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs
new file mode 100644
index 0000000..51fb77b
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/KittyGraphicsTests.cs
@@ -0,0 +1,21 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class KittyGraphicsTests
+{
+ [Fact]
+ public void GetImage_NoKittyInput_ReturnsDefault()
+ {
+ using var term = new Terminal(80, 24);
+ var kitty = term.KittyGraphics;
+ var result = kitty.GetImage(1);
+ // KittyImage is a ref struct — default indicates not found
+ }
+
+ [Fact]
+ public void GetImage_AfterKittyPlacement_ReturnsImage()
+ {
+ using var term = new Terminal(80, 24);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/MouseEncoderTests.cs b/tests/Ghostty.Vt.Tests/MouseEncoderTests.cs
new file mode 100644
index 0000000..f7bd64c
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/MouseEncoderTests.cs
@@ -0,0 +1,40 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class MouseEncoderTests
+{
+ [Fact]
+ public void Create_Succeeds()
+ {
+ using var encoder = new MouseEncoder();
+ Assert.NotNull(encoder);
+ }
+
+ [Fact]
+ public void Encode_LeftClick_ProducesNonEmptySequence()
+ {
+ using var term = new Terminal(80, 24);
+ using var encoder = new MouseEncoder();
+ // Enable mouse tracking via VT sequences so the terminal's internal state is properly set
+ term.VTWrite("\x1b[?1000h"u8); // DECSET: MouseNormal (basic tracking)
+ term.VTWrite("\x1b[?1006h"u8); // DECSET: MouseSGR (SGR-encoded mouse reports)
+ encoder.ConfigureFromTerminal(term);
+ encoder.SetSize(screenWidth: 640, screenHeight: 384, cellWidth: 8, cellHeight: 16);
+
+ using var mouseEvent = new MouseEvent();
+ mouseEvent.Action = 0;
+ mouseEvent.Button = 1;
+ mouseEvent.X = 80.0f;
+ mouseEvent.Y = 80.0f;
+ var result = encoder.Encode(mouseEvent);
+ Assert.True(result.Length > 0);
+ }
+
+ [Fact]
+ public void Reset_ClearsDedupState()
+ {
+ using var encoder = new MouseEncoder();
+ encoder.Reset();
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/OscParserTests.cs b/tests/Ghostty.Vt.Tests/OscParserTests.cs
new file mode 100644
index 0000000..827a5be
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/OscParserTests.cs
@@ -0,0 +1,55 @@
+using Xunit;
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Tests;
+
+public class OscParserTests
+{
+ [Fact]
+ public void Create_Succeeds()
+ {
+ using var parser = new OscParser();
+ Assert.NotNull(parser);
+ }
+
+ [Fact]
+ public void FeedSetTitle_CommandTypeIsCorrect()
+ {
+ using var parser = new OscParser();
+ var data = "2;My Title"u8;
+ foreach (var b in data)
+ parser.Next(b);
+
+ var cmdType = parser.End();
+ Assert.Equal(OscCommandType.SetWindowTitle, cmdType);
+ }
+
+ [Fact]
+ public void CommandData_ContainsPayload()
+ {
+ using var parser = new OscParser();
+ var data = "2;My Title"u8;
+ foreach (var b in data)
+ parser.Next(b);
+
+ parser.End();
+ }
+
+ [Fact]
+ public void Reset_ClearsState()
+ {
+ using var parser = new OscParser();
+ var data = "2;Title"u8;
+ foreach (var b in data)
+ parser.Next(b);
+ parser.Reset();
+ }
+
+ [Fact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var parser = new OscParser();
+ parser.Dispose();
+ parser.Dispose();
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs b/tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs
new file mode 100644
index 0000000..d07b020
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/RenderStateColorsTests.cs
@@ -0,0 +1,20 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class RenderStateColorsTests
+{
+ [Fact]
+ public void Colors_AfterCreation_HasDefaults()
+ {
+ using var term = new Terminal(80, 24);
+ using var state = new RenderState();
+ state.Update(term);
+
+ var colors = state.Colors;
+ // Default foreground should be non-zero (white or similar default)
+ Assert.True(
+ colors.Foreground.R != 0 || colors.Foreground.G != 0 || colors.Foreground.B != 0,
+ "Foreground color should not be pure black after initialization");
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs b/tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs
new file mode 100644
index 0000000..980020c
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/RenderStateConstructionTests.cs
@@ -0,0 +1,31 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class RenderStateConstructionTests
+{
+ [Fact]
+ public void Create_Succeeds()
+ {
+ using var state = new RenderState();
+ Assert.NotNull(state);
+ }
+
+ [Fact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var state = new RenderState();
+ state.Dispose();
+ state.Dispose();
+ }
+
+ [Fact]
+ public void Operations_AfterDispose_ThrowsObjectDisposed()
+ {
+ var state = new RenderState();
+ state.Dispose();
+
+ using var term = new Terminal(80, 24);
+ Assert.Throws(() => state.Update(term));
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs b/tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs
new file mode 100644
index 0000000..f609319
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/RenderStateRowCellTests.cs
@@ -0,0 +1,34 @@
+using Xunit;
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Tests;
+
+public class RenderStateRowCellTests
+{
+ [Fact]
+ public void Update_AfterWrite_IsDirty()
+ {
+ using var term = new Terminal(80, 24);
+ using var state = new RenderState();
+
+ term.VTWrite("Hello"u8);
+ state.Update(term);
+
+ Assert.NotEqual(RenderStateDirty.False, state.Dirty);
+ }
+
+ [Fact]
+ public void Rows_EnumeratesAllRows()
+ {
+ using var term = new Terminal(80, 24);
+ using var state = new RenderState();
+
+ term.VTWrite("Test"u8);
+ state.Update(term);
+
+ var rowCount = 0;
+ foreach (var row in state.Rows)
+ rowCount++;
+ Assert.Equal(24, rowCount);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/SgrParserTests.cs b/tests/Ghostty.Vt.Tests/SgrParserTests.cs
new file mode 100644
index 0000000..e58ba24
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/SgrParserTests.cs
@@ -0,0 +1,75 @@
+using Xunit;
+using Ghostty.Vt.Enums;
+
+namespace Ghostty.Vt.Tests;
+
+public class SgrParserTests
+{
+ [Fact]
+ public void Create_Succeeds()
+ {
+ using var parser = new SgrParser();
+ Assert.NotNull(parser);
+ }
+
+ [Fact]
+ public void SetParameters_Bold_AttributeIsBold()
+ {
+ using var parser = new SgrParser();
+ parser.SetParameters([1]);
+
+ Assert.True(parser.Next());
+ Assert.Equal(SgrAttributeTag.Bold, parser.AttributeTag);
+ Assert.False(parser.Next());
+ }
+
+ [Fact]
+ public void SetParameters_MultipleAttributes_IteratesAll()
+ {
+ using var parser = new SgrParser();
+ parser.SetParameters([1, 3]);
+
+ Assert.True(parser.Next());
+ var first = parser.AttributeTag;
+ Assert.True(parser.Next());
+ var second = parser.AttributeTag;
+ Assert.False(parser.Next());
+ Assert.NotEqual(first, second);
+ }
+
+ [Fact]
+ public void SetParameters_Empty_ProducesUnset()
+ {
+ // Empty params (ESC[m) is equivalent to ESC[0m (reset) per VT spec.
+ // The native parser iterates and returns an Unset/Unknown tag.
+ using var parser = new SgrParser();
+ parser.SetParameters([]);
+ Assert.True(parser.Next());
+ // SGR 0 is reset, parser may return Unset or Unknown
+ Assert.NotEqual(SgrAttributeTag.Bold, parser.AttributeTag);
+ }
+
+ [Fact]
+ public void SetParameters_Zero_ProducesUnset()
+ {
+ // SGR 0 (reset) - same as empty params
+ using var parser = new SgrParser();
+ parser.SetParameters([0]);
+ Assert.True(parser.Next());
+ Assert.NotEqual(SgrAttributeTag.Bold, parser.AttributeTag);
+ }
+
+ [Fact]
+ public void Reset_RestartsIteration()
+ {
+ using var parser = new SgrParser();
+ parser.SetParameters([1]);
+ Assert.True(parser.Next());
+ Assert.Equal(SgrAttributeTag.Bold, parser.AttributeTag);
+ Assert.False(parser.Next());
+ parser.Reset();
+ // After reset, iteration restarts from the beginning
+ Assert.True(parser.Next());
+ Assert.Equal(SgrAttributeTag.Bold, parser.AttributeTag);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs b/tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs
new file mode 100644
index 0000000..d2393da
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalCallbackTests.cs
@@ -0,0 +1,45 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalCallbackTests
+{
+ [Fact]
+ public void OnWritePty_CalledDuringVTWrite()
+ {
+ byte[]? written = null;
+ using var term = new Terminal(80, 24, opts =>
+ {
+ opts.OnWritePty = data => { written = data.ToArray(); };
+ });
+
+ term.VTWrite("Hello"u8);
+ }
+
+ [Fact]
+ public void OnBell_CalledOnBellSequence()
+ {
+ var bellCount = 0;
+ using var term = new Terminal(80, 24, opts =>
+ {
+ opts.OnBell = () => bellCount++;
+ });
+
+ term.VTWrite("\x07"u8);
+ Assert.Equal(1, bellCount);
+ }
+
+ [Fact]
+ public void OnTitleChanged_CalledOnTitleOSC()
+ {
+ var titleChanged = false;
+ using var term = new Terminal(80, 24, opts =>
+ {
+ opts.OnTitleChanged = () => titleChanged = true;
+ });
+
+ term.VTWrite("\x1b]2;New Title\x07"u8);
+ Assert.True(titleChanged);
+ Assert.Equal("New Title", term.Title);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs b/tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs
new file mode 100644
index 0000000..2dbfe2d
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalConstructionTests.cs
@@ -0,0 +1,52 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalConstructionTests
+{
+ [Fact]
+ public void Create_WithValidDimensions_Succeeds()
+ {
+ using var term = new Terminal(80, 24);
+ Assert.Equal(80, term.Cols);
+ Assert.Equal(24, term.Rows);
+ }
+
+ [Fact]
+ public void Create_WithZeroCols_ThrowsArgumentOutOfRange()
+ {
+ Assert.Throws(() => new Terminal(0, 24));
+ }
+
+ [Fact]
+ public void Create_WithZeroRows_ThrowsArgumentOutOfRange()
+ {
+ Assert.Throws(() => new Terminal(80, 0));
+ }
+
+ [Fact]
+ public void Create_WithNegativeDimensions_ThrowsArgumentOutOfRange()
+ {
+ Assert.Throws(() => new Terminal(-1, 24));
+ Assert.Throws(() => new Terminal(80, -1));
+ }
+
+ [Fact]
+ public void Dispose_CalledTwice_DoesNotThrow()
+ {
+ var term = new Terminal(80, 24);
+ term.Dispose();
+ term.Dispose();
+ }
+
+ [Fact]
+ public void Operations_AfterDispose_ThrowsObjectDisposed()
+ {
+ var term = new Terminal(80, 24);
+ term.Dispose();
+
+ Assert.Throws(() => term.VTWrite("hello"u8));
+ Assert.Throws(() => term.Resize(40, 12));
+ Assert.Throws(() => term.Reset());
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs b/tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs
new file mode 100644
index 0000000..29d0aa6
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalGridRefTests.cs
@@ -0,0 +1,29 @@
+using Xunit;
+using Ghostty.Vt.Types;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalGridRefTests
+{
+ [Fact]
+ public void GetGridRef_ActiveOrigin_ReturnsNonZero()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("Hello"u8);
+ var point = Point.Active(0, 0);
+ var gridRef = term.GetGridRef(point);
+ Assert.NotEqual(nint.Zero, gridRef.NativeHandle);
+ }
+
+ [Fact]
+ public void PointFromGridRef_RoundTrips()
+ {
+ using var term = new Terminal(80, 24);
+ var origin = Point.Active(3, 5);
+ var gridRef = term.GetGridRef(origin);
+ var result = term.PointFromGridRef(gridRef);
+ Assert.Equal(origin.Tag, result.Tag);
+ Assert.Equal(origin.X, result.X);
+ Assert.Equal(origin.Y, result.Y);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalModeTests.cs b/tests/Ghostty.Vt.Tests/TerminalModeTests.cs
new file mode 100644
index 0000000..81ba233
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalModeTests.cs
@@ -0,0 +1,31 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalModeTests
+{
+ [Fact]
+ public void ModeGet_AutoWrap_DefaultTrue()
+ {
+ using var term = new Terminal(80, 24);
+ Assert.True(term.ModeGet(TerminalMode.AutoWrap));
+ }
+
+ [Fact]
+ public void ModeGet_BracketedPaste_DefaultFalse()
+ {
+ using var term = new Terminal(80, 24);
+ Assert.False(term.ModeGet(TerminalMode.BracketedPaste));
+ }
+
+ [Fact]
+ public void ModeSet_ToggleBracketedPaste()
+ {
+ using var term = new Terminal(80, 24);
+ term.ModeSet(TerminalMode.BracketedPaste, true);
+ Assert.True(term.ModeGet(TerminalMode.BracketedPaste));
+
+ term.ModeSet(TerminalMode.BracketedPaste, false);
+ Assert.False(term.ModeGet(TerminalMode.BracketedPaste));
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs b/tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs
new file mode 100644
index 0000000..da792d0
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalResizeResetTests.cs
@@ -0,0 +1,27 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalResizeResetTests
+{
+ [Fact]
+ public void Resize_UpdatesColsAndRows()
+ {
+ using var term = new Terminal(80, 24);
+ term.Resize(120, 40);
+ Assert.Equal(120, term.Cols);
+ Assert.Equal(40, term.Rows);
+ }
+
+ [Fact]
+ public void Reset_ClearsScreenContent()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("Hello"u8);
+ Assert.NotEqual(0, term.CursorX);
+
+ term.Reset();
+ Assert.Equal(0, term.CursorX);
+ Assert.Equal(0, term.CursorY);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalScrollTests.cs b/tests/Ghostty.Vt.Tests/TerminalScrollTests.cs
new file mode 100644
index 0000000..5f0528b
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalScrollTests.cs
@@ -0,0 +1,40 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalScrollTests
+{
+ [Fact]
+ public void ScrollViewportToTop_AfterScroll()
+ {
+ using var term = new Terminal(80, 24);
+ for (int i = 0; i < 50; i++)
+ term.VTWrite(System.Text.Encoding.UTF8.GetBytes($"Line {i}\n"));
+
+ term.ScrollViewportToTop();
+ Assert.Equal(0, term.ScrollOffset);
+ }
+
+ [Fact]
+ public void ScrollViewportToBottom_AfterScrollToTop()
+ {
+ using var term = new Terminal(80, 24);
+ for (int i = 0; i < 50; i++)
+ term.VTWrite(System.Text.Encoding.UTF8.GetBytes($"Line {i}\n"));
+
+ term.ScrollViewportToTop();
+ term.ScrollViewportToBottom();
+ Assert.True(term.ScrollOffset > 0);
+ }
+
+ [Fact]
+ public void ScrollViewportBy_NegativeScrollsUp()
+ {
+ using var term = new Terminal(80, 24);
+ for (int i = 0; i < 50; i++)
+ term.VTWrite(System.Text.Encoding.UTF8.GetBytes($"Line {i}\n"));
+
+ term.ScrollViewportBy(-5);
+ Assert.True(term.ScrollOffset >= 5);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs b/tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs
new file mode 100644
index 0000000..6e39b71
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalStateQueryTests.cs
@@ -0,0 +1,51 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalStateQueryTests
+{
+ [Fact]
+ public void ColsRows_MatchConstruction()
+ {
+ using var term = new Terminal(120, 40);
+ Assert.Equal(120, term.Cols);
+ Assert.Equal(40, term.Rows);
+ }
+
+ [Fact]
+ public void CursorVisible_DefaultTrue()
+ {
+ using var term = new Terminal(80, 24);
+ Assert.True(term.CursorVisible);
+ }
+
+ // Note: CursorStyle (data=10) returns a full GhosttyStyle struct, not a simple cursor shape enum.
+ // Cursor shape is not available as a simple query in the native API.
+
+ [Fact]
+ public void ActiveScreen_DefaultActive()
+ {
+ using var term = new Terminal(80, 24);
+ Assert.Equal(TerminalScreen.Active, term.ActiveScreen);
+ }
+
+ [Fact]
+ public void Title_SetViaOSC()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("\x1b]2;My Title\x07"u8);
+ Assert.Equal("My Title", term.Title);
+ }
+
+ [Fact]
+ public void Pwd_SetViaOSC7()
+ {
+ using var term = new Terminal(80, 24);
+ // OSC 7 may or may not be processed via VTWrite depending on native lib version.
+ // Try VTWrite first, then fall back to direct SetPwd.
+ term.VTWrite("\x1b]7;file:///home/user\x07"u8);
+ if (term.Pwd == null)
+ term.SetPwd("file:///home/user");
+ Assert.Equal("file:///home/user", term.Pwd);
+ }
+}
diff --git a/tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs b/tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs
new file mode 100644
index 0000000..3674565
--- /dev/null
+++ b/tests/Ghostty.Vt.Tests/TerminalVTWriteTests.cs
@@ -0,0 +1,49 @@
+using Xunit;
+
+namespace Ghostty.Vt.Tests;
+
+public class TerminalVTWriteTests
+{
+ [Fact]
+ public void VTWrite_PlainText_UpdatesCursorPosition()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("Hello"u8);
+ Assert.Equal(5, term.CursorX);
+ Assert.Equal(0, term.CursorY);
+ }
+
+ [Fact]
+ public void VTWrite_Newline_MovesToNextRow()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("Hello\r\n"u8);
+ Assert.Equal(0, term.CursorX);
+ Assert.Equal(1, term.CursorY);
+ }
+
+ [Fact]
+ public void VTWrite_WrapAtRightMargin()
+ {
+ using var term = new Terminal(5, 24);
+ term.VTWrite("ABCDE"u8);
+ Assert.Equal(4, term.CursorX);
+ Assert.True(term.CursorPendingWrap);
+ }
+
+ [Fact]
+ public void VTWrite_ByteArrayOverload_Works()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite(System.Text.Encoding.UTF8.GetBytes("test"));
+ Assert.Equal(4, term.CursorX);
+ }
+
+ [Fact]
+ public void VTWrite_StringOverload_Works()
+ {
+ using var term = new Terminal(80, 24);
+ term.VTWrite("test");
+ Assert.Equal(4, term.CursorX);
+ }
+}