Repository: ajalt/mordant
Pull request #200 (2024-08-11 16:39:57)

Split JVM syscall implemetations into separate modules, add FFM

ajalt: Hm, looks like the graal test isn't running on nativeimage, it's just the regular JVM. The only GHA change was to go from JDK 21 -> 22, so not sure why that would have broken...
hubvd: It doesn't say much in the logs, but maybe it is because we're missing some `--initialize-at-build-time` flags
Those are also needed with Graal 21 if we enable the new behavior `--strict-image-heap`

I use those ones
https://github.com/hubvd/odoo-tools/blob/master/buildSrc/src/main/kotlin/NativeImageConvention.kt#L29-L33

I haven't made a PR, as I'm not sure if we can pass every flag for every platform. If it's the case, we could add them to the native-image.properties.

If not, I guess we could implement org.graalvm.nativeimage.hosted.Feature
hubvd: Ok, so It had nothing to do with that (even if the flags are still required)

The output when running locally is

```
GraalSmokeTest > terminal detection test() FAILED
    org.opentest4j.AssertionFailedError: Incorrect terminal interface: TerminalInterfaceJnaLinux
        at app//org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:38)
        at app//org.junit.jupiter.api.Assertions.fail(Assertions.java:138)
        at app//kotlin.test.junit5.JUnit5Asserter.fail(JUnitSupport.kt:56)
        at app//kotlin.test.AssertionsKt__AssertionsKt.fail(Assertions.kt:520)
        at app//kotlin.test.AssertionsKt.fail(Unknown Source)
        at app//com.github.ajalt.mordant.graalvm.GraalSmokeTest.terminal detection test(GraalSmokeTest.kt:28)
```

Since the test runs two times, we can do something like this https://github.com/ajalt/mordant/pull/186#discussion_r1669341639

But then I have another weird error

```

The build process encountered an unexpected error... :

> java.lang.IncompatibleClassChangeError: com.github.ajalt.mordant.terminal.terminalinterface.WinKernel32Lib and com.github.ajalt.mordant.terminal.terminalinterface.WinKernel32Lib$EventUnion disagree on InnerClasses attribute
```
ajalt: Thanks for looking into it! I implemented your suggestions, and ended up with the same error. The only lead I have right now is that the `uChar` and `Event` declarations are [unions nested inside structs in C][1]. Removing the `CStruct` annotations from those two interfaces is enough to get linux working, but that obviously breaks windows. I'll need to investigate some more.

[1]: https://learn.microsoft.com/en-us/windows/console/input-record-str#syntax
hubvd: I made a PR that should hopefully solve those issues https://github.com/ajalt/mordant/pull/201

Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 61d8620f..d57a21f2 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -28,13 +28,13 @@ jobs:
       - uses: actions/checkout@v4
       - uses: graalvm/setup-graalvm@v1
         with:
-          java-version: 21
+          java-version: 22
           distribution: 'graalvm-community'
           set-java-home: false
       - uses: actions/setup-java@v4
         with:
           distribution: 'zulu'
-          java-version: 21
+          java-version: 22
       - uses: gradle/actions/setup-gradle@v3
       - name: Run tests
         run: >-
@@ -62,7 +62,7 @@ jobs:
       - uses: actions/setup-java@v4
         with:
           distribution: 'zulu'
-          java-version: 21
+          java-version: 22
       - uses: gradle/actions/setup-gradle@v3
       - name: Deploy to sonatype
         # disable configuration cache due to https://github.com/gradle/gradle/issues/22779
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d9f56685..7af1fef2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,7 +14,7 @@ jobs:
       - uses: actions/setup-java@v4
         with:
           distribution: 'zulu'
-          java-version: 21
+          java-version: 22
       - uses: actions/setup-python@v5
         with:
           python-version: '3.12'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f653c744..b24ac356 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,18 @@
 # Changelog
 
 ## Unreleased
+## Added
+- New `TerminalDetection` object that can be used to detect terminal capabilities without creating a terminal instance.
+- Added new optional methods to `TerminalInterface` to control raw mode: `getTerminalSize`, `readInputEvent`, `enterRawMode`, and `shouldAutoUpdateSize`.
+- Added new terminal implementation that uses the [Foreign Function and Memory (FFM) API](https://openjdk.java.net/jeps/419) added in JDK 22.
+- Split the library up into modules, so you can produce smaller JVM artifacts by only using the parts you need.
+
+### Changed
+- **Breaking Change** Moved `Terminal.info.width` and `height` to `Terminal.size.width` and `height`.
+- **Breaking Change** `TerminalInterface.info` is now a method with parameters instead of a property.
+
+### Removed
+- Removed constructor overloads for `Terminal`. There is now one constructor with all default parameters. 
 
 ### Fixed
 - Fixed ConcurrentModificationException from progress bars when updated under very high concurrency [(#240)](https://github.com/ajalt/mordant/issues/240)
diff --git a/LICENSE.txt b/LICENSE.txt
old mode 100755
new mode 100644
diff --git a/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts
index 983503d7..3fcedecf 100644
--- a/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts
+++ b/buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts
@@ -9,12 +9,15 @@ kotlin {
 
     sourceSets {
         jvmMain.dependencies {
-            implementation(project(":mordant"))
+            implementation(project(":mordant-omnibus"))
         }
     }
 }
 
 application {
     mainClass.set("com.github.ajalt.mordant.samples.MainKt")
-    applicationDefaultJvmArgs = listOf("-Dfile.encoding=utf-8")
+    applicationDefaultJvmArgs = listOf(
+        "-Dfile.encoding=utf-8",
+        "--enable-native-access=ALL-UNNAMED"
+    )
 }
diff --git a/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts
index 182af3ae..cb870d72 100644
--- a/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts
+++ b/buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts
@@ -8,5 +8,8 @@ kotlin {
         commonMain.dependencies {
             implementation(project(":mordant"))
         }
+        jvmMain.dependencies {
+            implementation(project(":mordant-omnibus"))
+        }
     }
 }
diff --git a/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts
index b2bf445c..67210d52 100644
--- a/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts
+++ b/buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts
@@ -4,7 +4,6 @@ import com.vanniktech.maven.publish.JavadocJar
 import com.vanniktech.maven.publish.KotlinMultiplatform
 import com.vanniktech.maven.publish.SonatypeHost
 import org.jetbrains.dokka.gradle.DokkaTaskPartial
-import java.io.ByteArrayOutputStream
 
 plugins {
     id("com.vanniktech.maven.publish.base")
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 213f1084..76b8ca6f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
 [versions]
-kotlin = "2.0.0"
+kotlin = "2.0.10"
 coroutines = "1.8.1"
 
 [libraries]
diff --git a/mordant-jvm-ffm/api/mordant-jvm-ffm.api b/mordant-jvm-ffm/api/mordant-jvm-ffm.api
new file mode 100644
index 00000000..1f1eb18e
--- /dev/null
+++ b/mordant-jvm-ffm/api/mordant-jvm-ffm.api
@@ -0,0 +1,5 @@
+public final class com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterfaceProviderFfm : com/github/ajalt/mordant/terminal/TerminalInterfaceProvider {
+	public fun <init> ()V
+	public fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface;
+}
+
diff --git a/mordant-jvm-ffm/build.gradle.kts b/mordant-jvm-ffm/build.gradle.kts
new file mode 100644
index 00000000..28cfa309
--- /dev/null
+++ b/mordant-jvm-ffm/build.gradle.kts
@@ -0,0 +1,16 @@
+plugins {
+    id("mordant-kotlin-conventions")
+    id("mordant-publishing-conventions")
+}
+
+kotlin {
+    jvm()
+    sourceSets {
+        commonMain.dependencies {
+            implementation(project(":mordant"))
+        }
+        jvmMain.dependencies {
+            compileOnly(libs.graalvm.svm)
+        }
+    }
+}
diff --git a/mordant-jvm-ffm/gradle.properties b/mordant-jvm-ffm/gradle.properties
new file mode 100644
index 00000000..6bf69a9a
--- /dev/null
+++ b/mordant-jvm-ffm/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=mordant-jvm-ffm
+POM_NAME=Mordant Foreign Function & Memory Interface
diff --git a/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/FfmLayouts.kt b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/FfmLayouts.kt
new file mode 100644
index 00000000..6d62a159
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/FfmLayouts.kt
@@ -0,0 +1,169 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.ffm
+
+import java.lang.foreign.*
+import java.lang.foreign.MemoryLayout.PathElement
+import java.lang.invoke.MethodHandle
+import java.lang.invoke.MethodHandles
+import java.lang.invoke.VarHandle
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+internal object Layouts {
+//    val BOOL: ValueLayout.OfBoolean = ValueLayout.JAVA_BOOLEAN
+    val BYTE: ValueLayout.OfByte = ValueLayout.JAVA_BYTE
+    val SHORT: ValueLayout.OfShort = ValueLayout.JAVA_SHORT
+    val INT: ValueLayout.OfInt = ValueLayout.JAVA_INT
+    val LONG: ValueLayout.OfLong = ValueLayout.JAVA_LONG
+//    val FLOAT: ValueLayout.OfFloat = ValueLayout.JAVA_FLOAT
+//    val DOUBLE: ValueLayout.OfDouble = ValueLayout.JAVA_DOUBLE
+    val POINTER: AddressLayout = ValueLayout.ADDRESS
+}
+
+internal class FieldLayout<T>(
+    val name: String,
+    val layout: MemoryLayout,
+    val access: (MemorySegment) -> T,
+    val set: (MemorySegment, T) -> Unit = { _, _ -> },
+)
+
+internal abstract class StructLayout {
+    private val fields = mutableListOf<FieldLayout<*>>()
+
+    fun registerField(layout: FieldLayout<*>) {
+        fields.add(layout)
+    }
+
+    val layout: MemoryLayout
+        get() = MemoryLayout.structLayout(
+            *fields.map { it.layout.withName(it.name) }.toTypedArray()
+        )
+}
+
+
+@Suppress("UnusedReceiverParameter")
+internal inline fun <reified T> StructLayout.scalarField(
+    layout: MemoryLayout,
+    crossinline convert: (Any) -> T = { it as T },
+): PropertyDelegateProvider<StructLayout, ReadOnlyProperty<Any?, FieldLayout<T>>> {
+    return fieldDelegate(layout,
+        { name, parent, segment -> convert(parent.layout.varHandle(name).get(segment)) },
+        { name, parent, segment, value -> parent.layout.varHandle(name).set(segment, value) }
+    )
+}
+
+@Suppress("UnusedReceiverParameter")
+internal inline fun <T : StructAccessor> StructLayout.structField(
+    field: StructLayout,
+    crossinline construct: (MemorySegment) -> T,
+): PropertyDelegateProvider<StructLayout, ReadOnlyProperty<Any?, FieldLayout<T>>> {
+    return fieldDelegate(field.layout, { name, parent, segment ->
+        construct(segment.offsetOf(name, parent.layout, field.layout))
+    })
+}
+
+@Suppress("UnusedReceiverParameter")
+internal inline fun <reified T> StructLayout.customField(
+    layout: MemoryLayout,
+    crossinline construct: (MemorySegment, parent: StructLayout) -> T,
+): PropertyDelegateProvider<StructLayout, ReadOnlyProperty<Any?, FieldLayout<T>>> {
+    return fieldDelegate(layout,
+        { _, parent, segment -> construct(segment, parent) }
+    )
+}
+
+private inline fun <T> fieldDelegate(
+    layout: MemoryLayout,
+    crossinline access: (name: String, parent: StructLayout, MemorySegment) -> T,
+    crossinline set: (name: String, parent: StructLayout, MemorySegment, T) -> Unit = { _, _, _, _ -> },
+): PropertyDelegateProvider<StructLayout, ReadOnlyProperty<Any?, FieldLayout<T>>> {
+    return PropertyDelegateProvider { parent, property ->
+        val fl = FieldLayout(
+            property.name, layout,
+            { segment -> access(property.name, parent, segment) },
+            { segment, value -> set(property.name, parent, segment, value) },
+        )
+        parent.registerField(fl)
+        ReadOnlyProperty { _, _ -> fl }
+    }
+}
+
+internal fun StructLayout.byteField() = scalarField<Byte>(Layouts.BYTE)
+internal fun StructLayout.shortField() = scalarField<Short>(Layouts.SHORT)
+internal fun StructLayout.intField() = scalarField<Int>(Layouts.INT)
+internal fun StructLayout.longField() = scalarField<Long>(Layouts.LONG)
+//internal fun StructLayout.floatField() = scalarField<Float>(Layouts.FLOAT)
+//internal fun StructLayout.doubleField() = scalarField<Double>(Layouts.DOUBLE)
+//internal fun StructLayout.boolField() = scalarField<Boolean>(Layouts.BOOL)
+@Suppress("UnusedReceiverParameter")
+internal fun StructLayout.arrayField(
+    size: Long,
+    elementLayout: MemoryLayout = Layouts.BYTE,
+): PropertyDelegateProvider<StructLayout, ReadOnlyProperty<Any?, FieldLayout<MemorySegment>>> {
+    return fieldDelegate(
+        MemoryLayout.sequenceLayout(size, elementLayout), { name, parent, segment ->
+            segment.asSlice(parent.layout.byteOffset(name), size * elementLayout.byteSize())
+        }
+    )
+}
+
+internal fun StructLayout.paddingField(size: Long) =
+    scalarField<Unit>(MemoryLayout.paddingLayout(size)) { }
+
+internal interface StructAccessor {
+    val segment: MemorySegment
+
+    operator fun <T> FieldLayout<T>.getValue(thisRef: StructAccessor, property: KProperty<*>): T {
+        return access(thisRef.segment)
+    }
+
+    operator fun <T> FieldLayout<T>.setValue(
+        thisRef: StructAccessor,
+        property: KProperty<*>,
+        value: T,
+    ) {
+        set(thisRef.segment, value)
+    }
+}
+
+
+internal fun Arena.allocateInt(): MemorySegment = allocate(ValueLayout.JAVA_INT)
+internal fun MemoryLayout.varHandle(vararg path: String): VarHandle {
+    val handle = varHandle(*path.map { PathElement.groupElement(it) }.toTypedArray())
+    return MethodHandles.insertCoordinates(handle, handle.coordinateTypes().lastIndex, 0)
+}
+
+internal fun MemoryLayout.byteOffset(name: String): Long {
+    return byteOffset(PathElement.groupElement(name))
+}
+
+internal fun MemorySegment.offsetOf(
+    name: String,
+    parent: MemoryLayout,
+    layout: MemoryLayout,
+): MemorySegment {
+    return asSlice(parent.byteOffset(name), layout.byteSize())
+}
+
+internal abstract class MethodHandlesHolder(
+    private val linker: Linker = Linker.nativeLinker(),
+    private val lookup: SymbolLookup = SymbolLookup.loaderLookup().or(linker.defaultLookup()),
+) {
+    protected fun handle(
+        resLayout: MemoryLayout,
+        vararg argLayouts: MemoryLayout,
+    ) =
+        PropertyDelegateProvider<MethodHandlesHolder, ReadOnlyProperty<Any?, MethodHandle>> { _, property ->
+            val name = property.name
+            ReadOnlyProperty { _, _ ->
+                lookup.find(name)
+                    .map {
+                        linker.downcallHandle(
+                            it,
+                            FunctionDescriptor.of(resLayout, *argLayouts)
+                        )
+                    }
+                    .orElseThrow()
+            }
+        }
+}
diff --git a/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.linux.kt b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.linux.kt
new file mode 100644
index 00000000..ad1c0264
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.linux.kt
@@ -0,0 +1,122 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.ffm
+
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJvmPosix
+import java.lang.foreign.Arena
+import java.lang.foreign.MemorySegment
+
+@Suppress("ClassName", "PropertyName", "SpellCheckingInspection")
+private class LinuxCLibrary {
+    class winsize(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val ws_row by shortField()
+            val ws_col by shortField()
+            val ws_xpixel by shortField()
+            val ws_ypixel by shortField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val ws_row by Layout.ws_row
+        val ws_col by Layout.ws_col
+        val ws_xpixel by Layout.ws_xpixel
+        val ws_ypixel by Layout.ws_ypixel
+    }
+
+    class termios(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val c_iflag by intField()
+            val c_oflag by intField()
+            val c_cflag by intField()
+            val c_lflag by intField()
+            val c_line by byteField()
+            val c_cc by arrayField(32)
+            @Suppress("unused")
+            val padding by paddingField(3)
+            val c_ispeed by intField()
+            val c_ospeed by intField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        var c_iflag by Layout.c_iflag
+        var c_oflag by Layout.c_oflag
+        var c_cflag by Layout.c_cflag
+        var c_lflag by Layout.c_lflag
+        var c_line by Layout.c_line
+        val c_cc by Layout.c_cc
+        var c_ispeed by Layout.c_ispeed
+        var c_ospeed by Layout.c_ospeed
+    }
+
+    object MethodHandles : MethodHandlesHolder() {
+        val isatty by handle(Layouts.INT, Layouts.INT)
+        val ioctl by handle(Layouts.INT, Layouts.INT, Layouts.LONG, Layouts.POINTER)
+        val tcgetattr by handle(Layouts.INT, Layouts.INT, Layouts.POINTER)
+        val tcsetattr by handle(Layouts.INT, Layouts.INT, Layouts.INT, Layouts.POINTER)
+    }
+
+    fun isatty(fd: Int): Boolean {
+        return MethodHandles.isatty.invokeExact(fd) as Int == 1
+    }
+
+    fun ioctl(fd: Int, cmd: Int, data: MemorySegment): Boolean {
+        return MethodHandles.ioctl.invoke(fd, cmd.toLong(), data) as Int == 0
+    }
+
+    fun tcgetattr(fd: Int, termios: termios): Boolean {
+        return MethodHandles.tcgetattr.invokeExact(fd, termios.segment) as Int == 0
+    }
+
+    fun tcsetattr(fd: Int, cmd: Int, termios: termios): Boolean {
+        return MethodHandles.tcsetattr.invokeExact(fd, cmd, termios.segment) as Int == 0
+    }
+}
+
+internal class TerminalInterfaceFfmLinux : TerminalInterfaceJvmPosix() {
+    override val termiosConstants: TermiosConstants get() = LinuxTermiosConstants
+
+    private companion object {
+        const val TIOCGWINSZ = 0x00005413
+        const val TCSADRAIN: Int = 0x1
+    }
+
+    private val libC = LinuxCLibrary()
+    override fun isatty(fd: Int): Boolean = libC.isatty(fd)
+
+    override fun getTerminalSize(): Size? = Arena.ofConfined().use { arena ->
+        val size = LinuxCLibrary.winsize(arena)
+        if (!libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size.segment)) {
+            null
+        } else {
+            Size(width = size.ws_col.toInt(), height = size.ws_row.toInt())
+        }
+    }
+
+    override fun getStdinTermios(): Termios = Arena.ofConfined().use { arena ->
+        val termios = LinuxCLibrary.termios(arena)
+        if (!libC.tcgetattr(STDIN_FILENO, termios)) {
+            throw RuntimeException("failed to read terminal settings")
+        }
+        return Termios(
+            iflag = termios.c_iflag.toUInt(),
+            oflag = termios.c_oflag.toUInt(),
+            cflag = termios.c_cflag.toUInt(),
+            lflag = termios.c_lflag.toUInt(),
+            cc = termios.c_cc.toArray(Layouts.BYTE),
+        )
+    }
+
+    override fun setStdinTermios(termios: Termios): Unit = Arena.ofConfined().use { arena ->
+        val nativeTermios = LinuxCLibrary.termios(arena)
+        if (!libC.tcgetattr(STDIN_FILENO, nativeTermios)) {
+            throw RuntimeException("failed to update terminal settings")
+        }
+        nativeTermios.c_iflag = termios.iflag.toInt()
+        nativeTermios.c_oflag = termios.oflag.toInt()
+        nativeTermios.c_cflag = termios.cflag.toInt()
+        nativeTermios.c_lflag = termios.lflag.toInt()
+        nativeTermios.c_cc.copyFrom(MemorySegment.ofArray(termios.cc))
+        libC.tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios)
+    }
+}
diff --git a/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.macos.kt b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.macos.kt
new file mode 100644
index 00000000..7da927da
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.macos.kt
@@ -0,0 +1,114 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.ffm
+
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJvmPosix
+import java.lang.foreign.Arena
+import java.lang.foreign.MemorySegment
+
+@Suppress("ClassName", "PropertyName", "SpellCheckingInspection")
+private class MacosCLibrary {
+    class winsize(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val ws_row by shortField()
+            val ws_col by shortField()
+            val ws_xpixel by shortField()
+            val ws_ypixel by shortField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val ws_row by Layout.ws_row
+        val ws_col by Layout.ws_col
+        val ws_xpixel by Layout.ws_xpixel
+        val ws_ypixel by Layout.ws_ypixel
+    }
+
+    class termios(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val c_iflag by longField()
+            val c_oflag by longField()
+            val c_cflag by longField()
+            val c_lflag by longField()
+            val c_cc by arrayField(32)
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        var c_iflag by Layout.c_iflag
+        var c_oflag by Layout.c_oflag
+        var c_cflag by Layout.c_cflag
+        var c_lflag by Layout.c_lflag
+        val c_cc by Layout.c_cc
+    }
+
+    object MethodHandles : MethodHandlesHolder() {
+        val isatty by handle(Layouts.INT, Layouts.INT)
+        val ioctl by handle(Layouts.INT, Layouts.INT, Layouts.LONG, Layouts.POINTER)
+        val tcgetattr by handle(Layouts.INT, Layouts.INT, Layouts.POINTER)
+        val tcsetattr by handle(Layouts.INT, Layouts.INT, Layouts.INT, Layouts.POINTER)
+    }
+
+    fun isatty(fd: Int): Boolean {
+        return MethodHandles.isatty.invokeExact(fd) as Int == 1
+    }
+
+    fun ioctl(fd: Int, cmd: Int, data: MemorySegment): Boolean {
+        return MethodHandles.ioctl.invoke(fd, cmd.toLong(), data) as Int == 0
+    }
+
+    fun tcgetattr(fd: Int, termios: termios): Boolean {
+        return MethodHandles.tcgetattr.invokeExact(fd, termios.segment) as Int == 0
+    }
+
+    fun tcsetattr(fd: Int, cmd: Int, termios: termios): Boolean {
+        return MethodHandles.tcsetattr.invokeExact(fd, cmd, termios.segment) as Int == 0
+    }
+}
+
+internal class TerminalInterfaceFfmMacos : TerminalInterfaceJvmPosix() {
+    override val termiosConstants: TermiosConstants get() = MacosTermiosConstants
+
+    private companion object {
+        const val TIOCGWINSZ = 0x00005413
+        const val TCSADRAIN: Int = 0x1
+    }
+
+    private val libC = MacosCLibrary()
+    override fun isatty(fd: Int): Boolean = libC.isatty(fd)
+
+    override fun getTerminalSize(): Size? = Arena.ofConfined().use { arena ->
+        val size = MacosCLibrary.winsize(arena)
+        if (!libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size.segment)) {
+            null
+        } else {
+            Size(width = size.ws_col.toInt(), height = size.ws_row.toInt())
+        }
+    }
+
+    override fun getStdinTermios(): Termios = Arena.ofConfined().use { arena ->
+        val termios = MacosCLibrary.termios(arena)
+        if (!libC.tcgetattr(STDIN_FILENO, termios)) {
+            throw RuntimeException("failed to read terminal settings")
+        }
+        return Termios(
+            iflag = termios.c_iflag.toUInt(),
+            oflag = termios.c_oflag.toUInt(),
+            cflag = termios.c_cflag.toUInt(),
+            lflag = termios.c_lflag.toUInt(),
+            cc = termios.c_cc.toArray(Layouts.BYTE),
+        )
+    }
+
+    override fun setStdinTermios(termios: Termios): Unit = Arena.ofConfined().use { arena ->
+        val nativeTermios = MacosCLibrary.termios(arena)
+        if (!libC.tcgetattr(STDIN_FILENO, nativeTermios)) {
+            throw RuntimeException("failed to update terminal settings")
+        }
+        nativeTermios.c_iflag = termios.iflag.toLong()
+        nativeTermios.c_oflag = termios.oflag.toLong()
+        nativeTermios.c_cflag = termios.cflag.toLong()
+        nativeTermios.c_lflag = termios.lflag.toLong()
+        nativeTermios.c_cc.copyFrom(MemorySegment.ofArray(termios.cc))
+        libC.tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios)
+    }
+}
diff --git a/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.windows.kt b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.windows.kt
new file mode 100644
index 00000000..e49cd0cf
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterface.ffm.windows.kt
@@ -0,0 +1,323 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.ffm
+
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceWindows
+import java.lang.foreign.*
+
+private object WinLayouts {
+    val HANDLE: AddressLayout = Layouts.POINTER
+    val WORD: ValueLayout.OfShort = ValueLayout.JAVA_SHORT
+    val DWORD: ValueLayout.OfInt = Layouts.INT
+    val LPDWORD: AddressLayout = Layouts.POINTER
+    val CHAR: ValueLayout.OfByte = ValueLayout.JAVA_BYTE
+    val WCHAR: ValueLayout.OfChar = ValueLayout.JAVA_CHAR
+}
+
+
+@Suppress("FunctionName", "ClassName", "unused", "PropertyName")
+private class WinKernel32Library {
+
+    class COORD(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val X by shortField()
+            val Y by shortField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val X by Layout.X
+        val Y by Layout.Y
+    }
+
+    class SMALL_RECT(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val Left by shortField()
+            val Top by shortField()
+            val Right by shortField()
+            val Bottom by shortField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val left by Layout.Left
+        val top by Layout.Top
+        val right by Layout.Right
+        val bottom by Layout.Bottom
+    }
+
+    class CONSOLE_SCREEN_BUFFER_INFO(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val dwSize by structField(COORD.Layout, ::COORD)
+            val dwCursorPosition by structField(COORD.Layout, ::COORD)
+            val wAttributes by wordField()
+            val srWindow by structField(SMALL_RECT.Layout, ::SMALL_RECT)
+            val dwMaximumWindowSize by structField(COORD.Layout, ::COORD)
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val dwSize by Layout.dwSize
+        val dwCursorPosition by Layout.dwCursorPosition
+        val wAttributes by Layout.wAttributes
+        val srWindow by Layout.srWindow
+        val dwMaximumWindowSize by Layout.dwMaximumWindowSize
+    }
+
+    class MOUSE_EVENT_RECORD(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val dwMousePosition by structField(COORD.Layout, ::COORD)
+            val dwButtonState by dwordField()
+            val dwControlKeyState by dwordField()
+            val dwEventFlags by dwordField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val dwMousePosition by Layout.dwMousePosition
+        val dwButtonState by Layout.dwButtonState
+        val dwControlKeyState by Layout.dwControlKeyState
+        val dwEventFlags by Layout.dwEventFlags
+    }
+
+    class KEY_EVENT_RECORD(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val bKeyDown by winBoolField()
+            val wRepeatCount by wordField()
+            val wVirtualKeyCode by wordField()
+            val wVirtualScanCode by wordField()
+            val uChar by customField<Char>(
+                MemoryLayout.unionLayout(
+                    WinLayouts.WCHAR.withName("UnicodeChar"),
+                    WinLayouts.CHAR.withName("AsciiChar")
+                ).withName("uChar")
+            ) { segment, parent ->
+                parent.layout.varHandle("uChar", "UnicodeChar").get(segment) as Char
+            }
+            val dwControlKeyState by dwordField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val bKeyDown by Layout.bKeyDown
+        val wRepeatCount by Layout.wRepeatCount
+        val wVirtualKeyCode by Layout.wVirtualKeyCode
+        val wVirtualScanCode by Layout.wVirtualScanCode
+        val uChar by Layout.uChar
+        val dwControlKeyState by Layout.dwControlKeyState
+    }
+
+    class WINDOW_BUFFER_SIZE_RECORD(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val dwSize by structField(COORD.Layout, ::COORD)
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val dwSize by Layout.dwSize
+    }
+
+    class MENU_EVENT_RECORD(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val dwCommandId by dwordField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val dwCommandId by Layout.dwCommandId
+    }
+
+    class FOCUS_EVENT_RECORD(override val segment: MemorySegment) : StructAccessor {
+        object Layout : StructLayout() {
+            val bSetFocus by winBoolField()
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val bSetFocus by Layout.bSetFocus
+    }
+
+    class INPUT_RECORD(override val segment: MemorySegment) : StructAccessor {
+        companion object {
+            const val KEY_EVENT: Short = 0x0001
+            const val MOUSE_EVENT: Short = 0x0002
+            const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004
+            const val MENU_EVENT: Short = 0x0008
+            const val FOCUS_EVENT: Short = 0x0010
+        }
+
+        object Layout : StructLayout() {
+            val EventType by wordField()
+            val padding by paddingField(2) // needed for alignment
+            val Event by customField<Any>(
+                MemoryLayout.unionLayout(
+                    KEY_EVENT_RECORD.Layout.layout.withName("KeyEvent"),
+                    MOUSE_EVENT_RECORD.Layout.layout.withName("MouseEvent"),
+                    WINDOW_BUFFER_SIZE_RECORD.Layout.layout.withName("WindowBufferSizeEvent"),
+                    MENU_EVENT_RECORD.Layout.layout.withName("MenuEvent"),
+                    FOCUS_EVENT_RECORD.Layout.layout.withName("FocusEvent")
+                ).withName("Event")
+            ) { segment, _ -> segment }
+        }
+
+        constructor(arena: Arena) : this(arena.allocate(Layout.layout))
+
+        val EventType by Layout.EventType
+        val Event by Layout.Event
+        val keyEvent
+            get() = KEY_EVENT_RECORD(
+                segment.offsetOf(
+                    "Event",
+                    Layout.layout,
+                    KEY_EVENT_RECORD.Layout.layout
+                )
+            )
+        val mouseEvent
+            get() = MOUSE_EVENT_RECORD(
+                segment.offsetOf(
+                    "Event",
+                    Layout.layout,
+                    MOUSE_EVENT_RECORD.Layout.layout
+                )
+            )
+    }
+
+    object MethodHandles : MethodHandlesHolder() {
+        init {
+            System.loadLibrary("kernel32")
+            System.loadLibrary("msvcrt")
+        }
+
+        val WaitForSingleObject by handle(WinLayouts.DWORD, WinLayouts.HANDLE, WinLayouts.DWORD)
+        val GetStdHandle by handle(WinLayouts.HANDLE, WinLayouts.DWORD)
+        val GetConsoleMode by handle(Layouts.INT, WinLayouts.HANDLE, WinLayouts.LPDWORD)
+        val GetConsoleScreenBufferInfo by handle(Layouts.INT, WinLayouts.HANDLE, Layouts.POINTER)
+        val SetConsoleMode by handle(Layouts.INT, WinLayouts.HANDLE, WinLayouts.DWORD)
+        val ReadConsoleInputW by handle(
+            Layouts.INT, WinLayouts.HANDLE, Layouts.POINTER, WinLayouts.DWORD, WinLayouts.LPDWORD
+        )
+    }
+
+    fun WaitForSingleObject(hHandle: MemorySegment, dwMilliseconds: Int): Int {
+        return MethodHandles.WaitForSingleObject.invokeExact(hHandle, dwMilliseconds) as Int
+    }
+
+    fun GetStdHandle(nStdHandle: Int): MemorySegment {
+        return MethodHandles.GetStdHandle.invokeExact(nStdHandle) as MemorySegment
+    }
+
+    fun GetConsoleMode(
+        hConsoleHandle: MemorySegment, lpMode: MemorySegment,
+    ): Boolean {
+        return MethodHandles.GetConsoleMode.invokeExact(hConsoleHandle, lpMode) as Int != 0
+    }
+
+    fun GetConsoleScreenBufferInfo(
+        hConsoleOutput: MemorySegment, lpConsoleScreenBufferInfo: CONSOLE_SCREEN_BUFFER_INFO,
+    ): Boolean {
+        return MethodHandles.GetConsoleScreenBufferInfo.invokeExact(
+            hConsoleOutput, lpConsoleScreenBufferInfo.segment
+        ) as Int != 0
+    }
+
+    fun SetConsoleMode(hConsoleHandle: MemorySegment, dwMode: Int): Boolean {
+        return MethodHandles.SetConsoleMode.invokeExact(hConsoleHandle, dwMode) as Int != 0
+    }
+
+    fun ReadConsoleInputW(
+        hConsoleOutput: MemorySegment,
+        lpBuffer: MemorySegment,
+        nLength: Int,
+        lpNumberOfEventsRead: MemorySegment,
+    ): Boolean {
+        return MethodHandles.ReadConsoleInputW.invokeExact(
+            hConsoleOutput, lpBuffer, nLength, lpNumberOfEventsRead
+        ) as Int != 0
+    }
+}
+
+
+internal class TerminalInterfaceFfmWindows : TerminalInterfaceWindows() {
+    private companion object {
+        const val STD_INPUT_HANDLE: Int = -10
+        const val STD_OUTPUT_HANDLE: Int = -11
+        const val STD_ERROR_HANDLE: Int = -12
+    }
+
+    private val kernel = WinKernel32Library()
+    private val stdoutHandle get() = kernel.GetStdHandle(STD_OUTPUT_HANDLE)
+    private val stdinHandle get() = kernel.GetStdHandle(STD_INPUT_HANDLE)
+    private val stderrHandle get() = kernel.GetStdHandle(STD_ERROR_HANDLE)
+    private fun handleInteractive(handle: MemorySegment): Boolean {
+        return Arena.ofConfined().use { arena ->
+            kernel.GetConsoleMode(handle, arena.allocateInt())
+        }
+    }
+
+    override fun stdoutInteractive(): Boolean = handleInteractive(stdoutHandle)
+    override fun stdinInteractive(): Boolean = handleInteractive(stdinHandle)
+
+    override fun getTerminalSize(): Size? = Arena.ofConfined().use { arena ->
+        val csbi = WinKernel32Library.CONSOLE_SCREEN_BUFFER_INFO(arena)
+        if (!kernel.GetConsoleScreenBufferInfo(stdoutHandle, csbi)) {
+            return null
+        }
+        return csbi.srWindow.run { Size(width = right - left + 1, height = bottom - top + 1) }
+    }
+
+
+    override fun getStdinConsoleMode(): UInt = Arena.ofConfined().use { arena ->
+        val originalMode = arena.allocateInt()
+        kernel.GetConsoleMode(stdinHandle, originalMode)
+        return originalMode.get(Layouts.INT, 0).toUInt()
+    }
+
+    override fun setStdinConsoleMode(dwMode: UInt) {
+        kernel.SetConsoleMode(stdinHandle, dwMode.toInt())
+    }
+
+    override fun readRawEvent(dwMilliseconds: Int): EventRecord? = Arena.ofConfined().use { arena ->
+        val stdin = stdinHandle
+        val waitResult = kernel.WaitForSingleObject(stdin, dwMilliseconds)
+        if (waitResult != 0) {
+            throw RuntimeException("Timeout reading from console input")
+        }
+        val inputEvents = arena.allocate(WinKernel32Library.INPUT_RECORD.Layout.layout, 1)
+        val eventsReadSeg = arena.allocateInt()
+        if (!kernel.ReadConsoleInputW(stdin, inputEvents, 1, eventsReadSeg)
+            || eventsReadSeg.get(Layouts.INT, 0) != 1
+        ) {
+            throw RuntimeException("Error reading from console input")
+        }
+        val inputEvent = inputEvents.elements(WinKernel32Library.INPUT_RECORD.Layout.layout)
+            .map(WinKernel32Library::INPUT_RECORD).toList().single()
+
+        return when (inputEvent.EventType) {
+            WinKernel32Library.INPUT_RECORD.KEY_EVENT -> {
+                val keyEvent = inputEvent.keyEvent
+                EventRecord.Key(
+                    bKeyDown = keyEvent.bKeyDown,
+                    wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(),
+                    uChar = keyEvent.uChar,
+                    dwControlKeyState = keyEvent.dwControlKeyState.toUInt(),
+                )
+            }
+
+            WinKernel32Library.INPUT_RECORD.MOUSE_EVENT -> {
+                val mouseEvent = inputEvent.mouseEvent
+                EventRecord.Mouse(
+                    dwMousePositionX = mouseEvent.dwMousePosition.X,
+                    dwMousePositionY = mouseEvent.dwMousePosition.Y,
+                    dwButtonState = mouseEvent.dwButtonState.toUInt(),
+                    dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(),
+                    dwEventFlags = mouseEvent.dwEventFlags.toUInt(),
+                )
+            }
+
+            else -> null // Ignore other event types like FOCUS_EVENT that we can't opt out of
+        }
+    }
+}
+
+internal fun StructLayout.wordField() = scalarField<Short>(WinLayouts.WORD)
+internal fun StructLayout.dwordField() = scalarField<Int>(WinLayouts.DWORD)
+internal fun StructLayout.winBoolField() = scalarField(Layouts.INT) { it != 0 }
diff --git a/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterfaceProvider.ffm.kt b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterfaceProvider.ffm.kt
new file mode 100644
index 00000000..19da4fe2
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterfaceProvider.ffm.kt
@@ -0,0 +1,34 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.ffm
+
+import com.github.ajalt.mordant.terminal.TerminalInterface
+import com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
+import com.oracle.svm.core.annotate.Substitute
+import com.oracle.svm.core.annotate.TargetClass
+
+class TerminalInterfaceProviderFfm : TerminalInterfaceProvider {
+    override fun load(): TerminalInterface? {
+        try {
+            if (!TerminalInterfaceProviderFfm::class.java.module.isNativeAccessEnabled()) {
+                return null
+            }
+        } catch (e: NoSuchMethodError) {
+           // isNativeAccessEnabled doesn't exist on old JDKs
+           return null
+        }
+        val os = System.getProperty("os.name")
+        return when {
+            os.startsWith("Windows") -> TerminalInterfaceFfmWindows()
+            os == "Linux" -> TerminalInterfaceFfmLinux()
+            os == "Mac OS X" -> TerminalInterfaceFfmMacos()
+            else -> null
+        }
+    }
+}
+
+@TargetClass(TerminalInterfaceProviderFfm::class)
+private class TerminalInterfaceProviderFfmNative {
+
+    @Substitute
+    fun load(): TerminalInterface? = null
+
+}
diff --git a/mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/reflection-config.json b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/reflection-config.json
new file mode 100644
index 00000000..0f744a7e
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/reflection-config.json
@@ -0,0 +1,11 @@
+[
+  {
+    "name" : "com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceProviderFfm",
+    "allDeclaredConstructors" : true,
+    "allPublicConstructors" : true,
+    "allDeclaredMethods" : false,
+    "allPublicMethods" : false,
+    "allDeclaredFields" : false,
+    "allPublicFields" : false
+  }
+]
diff --git a/mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/resource-config.json b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/resource-config.json
new file mode 100644
index 00000000..dfb684ea
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/resource-config.json
@@ -0,0 +1,5 @@
+{
+  "resources": [
+    {"pattern": "META-INF/services/.*"}
+  ]
+}
diff --git a/mordant/src/jvmMain/resources/META-INF/proguard/mordant.pro b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/proguard/mordant.pro
similarity index 78%
rename from mordant/src/jvmMain/resources/META-INF/proguard/mordant.pro
rename to mordant-jvm-ffm/src/jvmMain/resources/META-INF/proguard/mordant.pro
index b43afd0b..92ec51c9 100644
--- a/mordant/src/jvmMain/resources/META-INF/proguard/mordant.pro
+++ b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/proguard/mordant.pro
@@ -3,5 +3,3 @@
 -keep class com.sun.jna.** { *; }
 -keep class * implements com.sun.jna.** { *; }
 -keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeVisibleTypeAnnotations,AnnotationDefault
--dontwarn org.graalvm.**
--dontwarn com.oracle.svm.core.annotate.Delete
diff --git a/mordant-jvm-ffm/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
new file mode 100644
index 00000000..c67dadbf
--- /dev/null
+++ b/mordant-jvm-ffm/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
@@ -0,0 +1,1 @@
+com.github.ajalt.mordant.terminal.terminalinterface.ffm.TerminalInterfaceProviderFfm
diff --git a/mordant-jvm-graal-ffi/api/mordant-jvm-graal-ffi.api b/mordant-jvm-graal-ffi/api/mordant-jvm-graal-ffi.api
new file mode 100644
index 00000000..3693a005
--- /dev/null
+++ b/mordant-jvm-graal-ffi/api/mordant-jvm-graal-ffi.api
@@ -0,0 +1,5 @@
+public final class com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterfaceProviderNativeImage : com/github/ajalt/mordant/terminal/TerminalInterfaceProvider {
+	public fun <init> ()V
+	public fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface;
+}
+
diff --git a/mordant-jvm-graal-ffi/build.gradle.kts b/mordant-jvm-graal-ffi/build.gradle.kts
new file mode 100644
index 00000000..28bdb1db
--- /dev/null
+++ b/mordant-jvm-graal-ffi/build.gradle.kts
@@ -0,0 +1,23 @@
+plugins {
+    id("mordant-kotlin-conventions")
+    id("mordant-publishing-conventions")
+}
+
+kotlin {
+    jvm()
+    sourceSets {
+        commonMain.dependencies {
+            implementation(project(":mordant"))
+        }
+        jvmMain.dependencies {
+            compileOnly(libs.graalvm.svm)
+        }
+    }
+    compilerOptions {
+        freeCompilerArgs.addAll(
+            "-Xno-param-assertions",
+            "-Xno-call-assertions",
+            "-Xno-receiver-assertions",
+        )
+    }
+}
diff --git a/mordant-jvm-graal-ffi/gradle.properties b/mordant-jvm-graal-ffi/gradle.properties
new file mode 100644
index 00000000..16c7d997
--- /dev/null
+++ b/mordant-jvm-graal-ffi/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=mordant-jvm-graal-ffi
+POM_NAME=Mordant GraalVM Foreign Function Interface
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.linux.kt b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.linux.kt
similarity index 93%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.linux.kt
rename to mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.linux.kt
index 3c9ecd29..fe709751 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.linux.kt
+++ b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.linux.kt
@@ -1,7 +1,7 @@
-package com.github.ajalt.mordant.internal.syscalls.nativeimage
+package com.github.ajalt.mordant.terminal.terminalinterface.nativeimage
 
-import com.github.ajalt.mordant.internal.Size
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJvmPosix
 import org.graalvm.nativeimage.Platform
 import org.graalvm.nativeimage.Platforms
 import org.graalvm.nativeimage.StackValue
@@ -86,7 +86,7 @@ private object LinuxLibC {
 }
 
 @Platforms(Platform.LINUX::class)
-internal class SyscallHandlerNativeImageLinux : SyscallHandlerJvmPosix() {
+internal class TerminalInterfaceNativeImageLinux : TerminalInterfaceJvmPosix() {
     override val termiosConstants: TermiosConstants get() = LinuxTermiosConstants
     override fun isatty(fd: Int): Boolean = LinuxLibC.isatty(fd)
 
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.macos.kt b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.macos.kt
similarity index 93%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.macos.kt
rename to mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.macos.kt
index 35005b7f..2fe253da 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.macos.kt
+++ b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.macos.kt
@@ -1,7 +1,7 @@
-package com.github.ajalt.mordant.internal.syscalls.nativeimage
+package com.github.ajalt.mordant.terminal.terminalinterface.nativeimage
 
-import com.github.ajalt.mordant.internal.Size
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJvmPosix
 import org.graalvm.nativeimage.Platform
 import org.graalvm.nativeimage.Platforms
 import org.graalvm.nativeimage.StackValue
@@ -86,7 +86,7 @@ private object MacosLibC {
 }
 
 @Platforms(Platform.MACOS::class)
-internal class SyscallHandlerNativeImageMacos : SyscallHandlerJvmPosix() {
+internal class TerminalInterfaceNativeImageMacos : TerminalInterfaceJvmPosix() {
     override val termiosConstants: TermiosConstants get() = MacosTermiosConstants
     override fun isatty(fd: Int): Boolean = MacosLibC.isatty(fd)
 
diff --git a/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.windows.kt b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.windows.kt
new file mode 100644
index 00000000..0acaf4f0
--- /dev/null
+++ b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterface.nativeimage.windows.kt
@@ -0,0 +1,196 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.nativeimage
+
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceWindows
+import org.graalvm.nativeimage.Platform
+import org.graalvm.nativeimage.Platforms
+import org.graalvm.nativeimage.StackValue
+import org.graalvm.nativeimage.c.CContext
+import org.graalvm.nativeimage.c.constant.CConstant
+import org.graalvm.nativeimage.c.function.CFunction
+import org.graalvm.nativeimage.c.struct.CField
+import org.graalvm.nativeimage.c.struct.CStruct
+import org.graalvm.nativeimage.c.type.CIntPointer
+import org.graalvm.word.Pointer
+import org.graalvm.word.PointerBase
+
+@Platforms(Platform.WINDOWS::class)
+@CContext(WinKernel32Lib.Directives::class)
+@Suppress("FunctionName", "PropertyName", "ClassName")
+private object WinKernel32Lib {
+
+    class Directives : CContext.Directives {
+        override fun getHeaderFiles() = listOf("<Windows.h>")
+    }
+
+    @CConstant("STD_INPUT_HANDLE")
+    external fun STD_INPUT_HANDLE(): Int
+
+    @CConstant("STD_OUTPUT_HANDLE")
+    external fun STD_OUTPUT_HANDLE(): Int
+
+    @CStruct("CONSOLE_SCREEN_BUFFER_INFO")
+    interface CONSOLE_SCREEN_BUFFER_INFO : PointerBase {
+
+        @get:CField("srWindow.Left")
+        val Left: Short
+
+        @get:CField("srWindow.Top")
+        val Top: Short
+
+        @get:CField("srWindow.Right")
+        val Right: Short
+
+        @get:CField("srWindow.Bottom")
+        val Bottom: Short
+
+    }
+
+    class COORD(private val pointer: Pointer) {
+        val X: Short get() = pointer.readShort(0)
+        val Y: Short get() = pointer.readShort(2)
+    }
+
+    sealed class EventUnion {
+        class KeyEvent(private val pointer: Pointer) : EventUnion() {
+            val bKeyDown: Boolean get() = pointer.readInt(0) != 0
+            val wVirtualKeyCode: Short get() = pointer.readShort(6)
+            val uChar: Char get() = pointer.readChar(10)
+            val dwControlKeyState: Int get() = pointer.readInt(12)
+        }
+
+        class MouseEvent(private val pointer: Pointer) : EventUnion() {
+            val dwMousePosition: COORD get() = COORD(pointer)
+            val dwButtonState: Int get() = pointer.readInt(4)
+            val dwControlKeyState: Int get() = pointer.readInt(8)
+            val dwEventFlags: Int get() = pointer.readInt(12)
+        }
+    }
+
+    class InputRecord(private val pointer: Pointer) {
+        companion object {
+            const val BYTE_SIZE = 20
+            const val KEY_EVENT: Short = 0x0001
+            const val MOUSE_EVENT: Short = 0x0002
+            const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004
+            const val MENU_EVENT: Short = 0x0008
+            const val FOCUS_EVENT: Short = 0x0010
+        }
+
+        val EventType: Short
+            get() = pointer.readShort(0)
+
+        val Event: EventUnion?
+            get() = when (EventType) {
+                KEY_EVENT -> EventUnion.KeyEvent(pointer.add(4))
+                MOUSE_EVENT -> EventUnion.MouseEvent(pointer.add(4))
+                else -> null
+            }
+    }
+
+
+    @CFunction("GetStdHandle")
+    external fun GetStdHandle(nStdHandle: Int): PointerBase?
+
+    @CFunction("GetConsoleMode")
+    external fun GetConsoleMode(hConsoleHandle: PointerBase?, lpMode: CIntPointer?): Boolean
+
+    @CFunction("SetConsoleMode")
+    external fun SetConsoleMode(hConsoleHandle: PointerBase?, dwMode: Int): Boolean
+
+    @CFunction("GetConsoleScreenBufferInfo")
+    external fun GetConsoleScreenBufferInfo(
+        hConsoleOutput: PointerBase?,
+        lpConsoleScreenBufferInfo: Long,
+    ): Boolean
+
+    @CFunction("WaitForSingleObject")
+    external fun WaitForSingleObject(hHandle: PointerBase?, dwMilliseconds: Int): Int
+
+    @CFunction("ReadConsoleInputW")
+    external fun ReadConsoleInputW(
+        hConsoleOutput: PointerBase?,
+        lpBuffer: Pointer?,
+        nLength: Int,
+        lpNumberOfEventsRead: CIntPointer?,
+    )
+}
+
+@Platforms(Platform.WINDOWS::class)
+internal class TerminalInterfaceNativeImageWindows : TerminalInterfaceWindows() {
+
+    override fun stdoutInteractive(): Boolean {
+        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE())
+        return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java))
+    }
+
+    override fun stdinInteractive(): Boolean {
+        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
+        return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java))
+    }
+
+    override fun getTerminalSize(): Size? {
+        val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java)
+        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE())
+        return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) {
+            null
+        } else {
+            Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1)
+        }
+    }
+
+    override fun getStdinConsoleMode(): UInt {
+        val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
+        val lpMode = StackValue.get(CIntPointer::class.java)
+        if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) {
+            throw RuntimeException("Error reading console mode")
+        }
+        return lpMode.read().toUInt()
+    }
+
+    override fun setStdinConsoleMode(dwMode: UInt) {
+        val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
+        if (!WinKernel32Lib.SetConsoleMode(stdinHandle, dwMode.toInt())) {
+            throw RuntimeException("Error setting console mode")
+        }
+    }
+
+    override fun readRawEvent(dwMilliseconds: Int): EventRecord? {
+        val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
+        val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds)
+        if (waitResult != 0) {
+            throw RuntimeException("Error reading from console input: waitResult=$waitResult")
+        }
+        val inputEvents = StackValue.get<Pointer>(WinKernel32Lib.InputRecord.BYTE_SIZE)
+        val eventsRead = StackValue.get(CIntPointer::class.java)
+        WinKernel32Lib.ReadConsoleInputW(stdinHandle, inputEvents, 1, eventsRead)
+        if (eventsRead.read() == 0) {
+            throw RuntimeException("Error reading from console input")
+        }
+
+        val inputRecord = WinKernel32Lib.InputRecord(inputEvents)
+
+        return when (val event = inputRecord.Event) {
+            is WinKernel32Lib.EventUnion.KeyEvent -> {
+                EventRecord.Key(
+                    bKeyDown = event.bKeyDown,
+                    wVirtualKeyCode = event.wVirtualKeyCode.toUShort(),
+                    uChar = event.uChar,
+                    dwControlKeyState = event.dwControlKeyState.toUInt(),
+                )
+            }
+
+            is WinKernel32Lib.EventUnion.MouseEvent -> {
+                EventRecord.Mouse(
+                    dwMousePositionX = event.dwMousePosition.X,
+                    dwMousePositionY = event.dwMousePosition.Y,
+                    dwButtonState = event.dwButtonState.toUInt(),
+                    dwControlKeyState = event.dwControlKeyState.toUInt(),
+                    dwEventFlags = event.dwEventFlags.toUInt(),
+                )
+            }
+
+            else -> null // Ignore other event types like FOCUS_EVENT that we can't opt out of
+        }
+    }
+}
diff --git a/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterfaceProvider.nativeimage.kt b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterfaceProvider.nativeimage.kt
new file mode 100644
index 00000000..0970852a
--- /dev/null
+++ b/mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterfaceProvider.nativeimage.kt
@@ -0,0 +1,39 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.nativeimage
+
+import com.github.ajalt.mordant.terminal.TerminalInterface
+import com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
+import com.oracle.svm.core.annotate.Substitute
+import com.oracle.svm.core.annotate.TargetClass
+import org.graalvm.nativeimage.Platform
+import org.graalvm.nativeimage.Platforms
+
+class TerminalInterfaceProviderNativeImage : TerminalInterfaceProvider {
+    override fun load(): TerminalInterface? = null
+}
+
+@Platforms(Platform.LINUX::class)
+@TargetClass(TerminalInterfaceProviderNativeImage::class)
+private class TerminalInterfaceProviderNativeImageLinux {
+
+    @Substitute
+    fun load(): TerminalInterface = TerminalInterfaceNativeImageLinux()
+
+}
+
+@Platforms(Platform.WINDOWS::class)
+@TargetClass(TerminalInterfaceProviderNativeImage::class)
+private class TerminalInterfaceProviderNativeImageWindows {
+
+    @Substitute
+    fun load(): TerminalInterface = TerminalInterfaceNativeImageWindows()
+
+}
+
+@Platforms(Platform.MACOS::class)
+@TargetClass(TerminalInterfaceProviderNativeImage::class)
+private class TerminalInterfaceProviderNativeImageMacos {
+
+    @Substitute
+    fun load(): TerminalInterface = TerminalInterfaceNativeImageMacos()
+
+}
diff --git a/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/reflection-config.json b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/reflection-config.json
new file mode 100644
index 00000000..7897cb62
--- /dev/null
+++ b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/reflection-config.json
@@ -0,0 +1,11 @@
+[
+  {
+    "name" : "com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceProviderNativeImage",
+    "allDeclaredConstructors" : true,
+    "allPublicConstructors" : true,
+    "allDeclaredMethods" : false,
+    "allPublicMethods" : false,
+    "allDeclaredFields" : false,
+    "allPublicFields" : false
+  }
+]
diff --git a/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/resource-config.json b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/resource-config.json
new file mode 100644
index 00000000..dfb684ea
--- /dev/null
+++ b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/resource-config.json
@@ -0,0 +1,5 @@
+{
+  "resources": [
+    {"pattern": "META-INF/services/.*"}
+  ]
+}
diff --git a/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/proguard/mordant.pro b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/proguard/mordant.pro
new file mode 100644
index 00000000..665563bd
--- /dev/null
+++ b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/proguard/mordant.pro
@@ -0,0 +1,4 @@
+# Keep rules for those who are using ProGuard.
+
+-dontwarn org.graalvm.**
+-dontwarn com.oracle.svm.core.annotate.Delete
diff --git a/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
new file mode 100644
index 00000000..231eb5a0
--- /dev/null
+++ b/mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
@@ -0,0 +1,1 @@
+com.github.ajalt.mordant.terminal.terminalinterface.nativeimage.TerminalInterfaceProviderNativeImage
diff --git a/mordant-jvm-jna/api/mordant-jvm-jna.api b/mordant-jvm-jna/api/mordant-jvm-jna.api
new file mode 100644
index 00000000..fc474049
--- /dev/null
+++ b/mordant-jvm-jna/api/mordant-jvm-jna.api
@@ -0,0 +1,5 @@
+public final class com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterfaceProviderJna : com/github/ajalt/mordant/terminal/TerminalInterfaceProvider {
+	public fun <init> ()V
+	public fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface;
+}
+
diff --git a/mordant-jvm-jna/build.gradle.kts b/mordant-jvm-jna/build.gradle.kts
new file mode 100644
index 00000000..9e8232f3
--- /dev/null
+++ b/mordant-jvm-jna/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+    id("mordant-kotlin-conventions")
+    id("mordant-publishing-conventions")
+}
+
+kotlin {
+    jvm()
+    sourceSets {
+        commonMain.dependencies {
+            implementation(project(":mordant"))
+        }
+        jvmMain.dependencies {
+            implementation(libs.jna.core)
+            compileOnly(libs.graalvm.svm)
+        }
+    }
+}
diff --git a/mordant-jvm-jna/gradle.properties b/mordant-jvm-jna/gradle.properties
new file mode 100644
index 00000000..ceaec130
--- /dev/null
+++ b/mordant-jvm-jna/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=mordant-jvm-jna
+POM_NAME=Mordant JNA Interface
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.linux.kt
similarity index 85%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt
rename to mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.linux.kt
index 87402288..eb407a4d 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt
+++ b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.linux.kt
@@ -1,12 +1,9 @@
-package com.github.ajalt.mordant.internal.syscalls.jna
+package com.github.ajalt.mordant.terminal.terminalinterface.jna
 
-import com.github.ajalt.mordant.internal.Size
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerPosix
-import com.oracle.svm.core.annotate.Delete
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJvmPosix
 import com.sun.jna.*
 
-@Delete
 @Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection")
 private interface PosixLibC : Library {
 
@@ -66,10 +63,9 @@ private interface PosixLibC : Library {
     fun tcsetattr(fd: Int, cmd: Int, termios: termios)
 }
 
-@Delete
-internal object SyscallHandlerJnaLinux : SyscallHandlerJvmPosix() {
-    private const val TIOCGWINSZ = 0x00005413
-    private const val TCSADRAIN: Int = 0x1
+internal class TerminalInterfaceJnaLinux : TerminalInterfaceJvmPosix() {
+    private val TIOCGWINSZ = 0x00005413
+    private val TCSADRAIN: Int = 0x1
     override val termiosConstants: TermiosConstants get() = LinuxTermiosConstants
     private val libC: PosixLibC = Native.load(Platform.C_LIBRARY_NAME, PosixLibC::class.java)
     override fun isatty(fd: Int): Boolean = libC.isatty(fd) != 0
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.macos.kt
similarity index 83%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt
rename to mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.macos.kt
index adb50a2e..c0e398aa 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt
+++ b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.macos.kt
@@ -1,13 +1,11 @@
-package com.github.ajalt.mordant.internal.syscalls.jna
+package com.github.ajalt.mordant.terminal.terminalinterface.jna
 
-import com.github.ajalt.mordant.internal.Size
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix
-import com.oracle.svm.core.annotate.Delete
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJvmPosix
 import com.sun.jna.*
 import java.io.IOException
 import java.util.concurrent.TimeUnit
 
-@Delete
 @Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection")
 private interface MacosLibC : Library {
 
@@ -66,11 +64,10 @@ private interface MacosLibC : Library {
     fun tcsetattr(fd: Int, cmd: Int, termios: termios)
 }
 
-@Delete
 @Suppress("SpellCheckingInspection")
-internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() {
+internal class TerminalInterfaceJnaMacos : TerminalInterfaceJvmPosix() {
     override val termiosConstants: TermiosConstants get() = MacosTermiosConstants
-    private const val TCSANOW: Int = 0x0
+    private val TCSANOW: Int = 0x0
     private val TIOCGWINSZ = when {
         Platform.isMIPS() || Platform.isPPC() || Platform.isSPARC() -> 0x40087468L
         else -> 0x00005413L
@@ -78,15 +75,15 @@ internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() {
 
     private val libC: MacosLibC = Native.load(Platform.C_LIBRARY_NAME, MacosLibC::class.java)
     override fun isatty(fd: Int): Boolean = libC.isatty(fd) != 0
-    override fun fastIsTty(): Boolean = false
+
     override fun getTerminalSize(): Size? {
-        // TODO: JNA has a bug that causes this to fail on macosArm64, use stty on mac for now
-//        val size = MacosLibC.winsize()
-//        return if (libC.ioctl(STDIN_FILENO, NativeLong(TIOCGWINSZ), size) < 0) {
-//            null
-//        } else {
-//            size.ws_col.toInt() to size.ws_row.toInt()
-//        }
+        // XXX: JNA has a bug that causes this to fail on macosArm64, use stty on mac for now
+        // val size = MacosLibC.winsize()
+        // return if (libC.ioctl(STDIN_FILENO, NativeLong(TIOCGWINSZ), size) < 0) {
+        //     null
+        // } else {
+        //     size.ws_col.toInt() to size.ws_row.toInt()
+        // }
         return getSttySize(100)
     }
 
@@ -112,6 +109,10 @@ internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() {
         termios.cc.copyInto(nativeTermios.c_cc)
         libC.tcsetattr(STDIN_FILENO, TCSANOW, nativeTermios)
     }
+
+    override fun shouldAutoUpdateSize(): Boolean {
+        return false // Shelling out to STTY is slow, so don't do it automatically
+    }
 }
 
 @Suppress("SameParameterValue")
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.windows.kt
similarity index 94%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt
rename to mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.windows.kt
index ef632496..6736def1 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt
+++ b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterface.jna.windows.kt
@@ -1,8 +1,7 @@
-package com.github.ajalt.mordant.internal.syscalls.jna
+package com.github.ajalt.mordant.terminal.terminalinterface.jna
 
-import com.github.ajalt.mordant.internal.Size
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWindows
-import com.oracle.svm.core.annotate.Delete
+import com.github.ajalt.mordant.rendering.Size
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceWindows
 import com.sun.jna.*
 import com.sun.jna.ptr.IntByReference
 import com.sun.jna.win32.W32APIOptions
@@ -10,7 +9,6 @@ import com.sun.jna.win32.W32APIOptions
 // Interface definitions from
 // https://github.com/java-native-access/jna/blob/master/contrib/platform/src/com/sun/jna/platform/win32/Kernel32.java
 // copied here so that we don't need the entire platform dependency
-@Delete
 @Suppress("FunctionName", "PropertyName", "ClassName", "unused")
 private interface WinKernel32Lib : Library {
     companion object {
@@ -204,7 +202,7 @@ private interface WinKernel32Lib : Library {
                 MOUSE_EVENT -> Event!!.setType(MOUSE_EVENT_RECORD::class.java)
                 WINDOW_BUFFER_SIZE_EVENT -> Event!!.setType(WINDOW_BUFFER_SIZE_RECORD::class.java)
                 MENU_EVENT -> Event!!.setType(MENU_EVENT_RECORD::class.java)
-                FOCUS_EVENT -> Event!!.setType(MENU_EVENT_RECORD::class.java)
+                FOCUS_EVENT -> Event!!.setType(FOCUS_EVENT_RECORD::class.java)
             }
             super.read()
         }
@@ -237,14 +235,12 @@ private interface WinKernel32Lib : Library {
 }
 
 
-@Delete
-internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() {
+internal class TerminalInterfaceJnaWindows : TerminalInterfaceWindows() {
     private val kernel = Native.load(
         "kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS
     )
     private val stdoutHandle = kernel.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE)
     private val stdinHandle = kernel.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE)
-    private val stderrHandle = kernel.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE)
     private fun handleInteractive(handle: WinKernel32Lib.HANDLE): Boolean {
         return try {
             kernel.GetConsoleMode(handle, IntByReference())
@@ -256,7 +252,6 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() {
 
     override fun stdoutInteractive(): Boolean = handleInteractive(stdoutHandle)
     override fun stdinInteractive(): Boolean = handleInteractive(stdinHandle)
-    override fun stderrInteractive(): Boolean = handleInteractive(stderrHandle)
 
     override fun getTerminalSize(): Size? {
         val csbi = WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO()
diff --git a/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterfaceProvider.jna.kt b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterfaceProvider.jna.kt
new file mode 100644
index 00000000..3e2d957f
--- /dev/null
+++ b/mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterfaceProvider.jna.kt
@@ -0,0 +1,35 @@
+package com.github.ajalt.mordant.terminal.terminalinterface.jna
+
+import com.github.ajalt.mordant.terminal.TerminalInterface
+import com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
+import com.oracle.svm.core.annotate.Substitute
+import com.oracle.svm.core.annotate.TargetClass
+
+class TerminalInterfaceProviderJna : TerminalInterfaceProvider {
+    override fun load(): TerminalInterface? {
+        // Inlined version of ImageInfo.inImageCode()
+        val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode")
+        val isNativeImage = imageCode == "buildtime" || imageCode == "runtime"
+        if (isNativeImage) return null
+
+        val os = System.getProperty("os.name")
+        return try {
+            when {
+                os.startsWith("Windows") -> TerminalInterfaceJnaWindows()
+                os == "Linux" -> TerminalInterfaceJnaLinux()
+                os == "Mac OS X" -> TerminalInterfaceJnaMacos()
+                else -> null
+            }
+        } catch (e: UnsatisfiedLinkError) {
+            null
+        }
+    }
+}
+
+@TargetClass(TerminalInterfaceProviderJna::class)
+private class TerminalInterfaceProviderJnaNative {
+
+    @Substitute
+    fun load(): TerminalInterface? = null
+
+}
diff --git a/mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/reflection-config.json b/mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/reflection-config.json
new file mode 100644
index 00000000..0d8a0e17
--- /dev/null
+++ b/mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/reflection-config.json
@@ -0,0 +1,11 @@
+[
+  {
+    "name" : "com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceProviderJna",
+    "allDeclaredConstructors" : true,
+    "allPublicConstructors" : true,
+    "allDeclaredMethods" : false,
+    "allPublicMethods" : false,
+    "allDeclaredFields" : false,
+    "allPublicFields" : false
+  }
+]
diff --git a/mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/resource-config.json b/mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/resource-config.json
new file mode 100644
index 00000000..dfb684ea
--- /dev/null
+++ b/mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/resource-config.json
@@ -0,0 +1,5 @@
+{
+  "resources": [
+    {"pattern": "META-INF/services/.*"}
+  ]
+}
diff --git a/mordant-jvm-jna/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider b/mordant-jvm-jna/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
new file mode 100644
index 00000000..5aeb8c7b
--- /dev/null
+++ b/mordant-jvm-jna/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider
@@ -0,0 +1,1 @@
+com.github.ajalt.mordant.terminal.terminalinterface.jna.TerminalInterfaceProviderJna
diff --git a/mordant-omnibus/build.gradle.kts b/mordant-omnibus/build.gradle.kts
new file mode 100644
index 00000000..c76bf60b
--- /dev/null
+++ b/mordant-omnibus/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+    id("mordant-kotlin-conventions")
+    id("mordant-publishing-conventions")
+}
+
+kotlin {
+    jvm()
+    sourceSets {
+        commonMain.dependencies {
+            api(project(":mordant"))
+        }
+        jvmMain.dependencies {
+            implementation(project(":mordant-jvm-jna"))
+            implementation(project(":mordant-jvm-ffm"))
+            implementation(project(":mordant-jvm-graal-ffi"))
+        }
+    }
+}
diff --git a/mordant-omnibus/gradle.properties b/mordant-omnibus/gradle.properties
new file mode 100644
index 00000000..ff294891
--- /dev/null
+++ b/mordant-omnibus/gradle.properties
@@ -0,0 +1,2 @@
+POM_ARTIFACT_ID=mordant
+POM_NAME=Mordant
diff --git a/mordant/api/mordant.api b/mordant/api/mordant.api
index e0aa9202..68aecd55 100644
--- a/mordant/api/mordant.api
+++ b/mordant/api/mordant.api
@@ -539,6 +539,13 @@ public final class com/github/ajalt/mordant/rendering/OverflowWrap : java/lang/E
 	public static fun values ()[Lcom/github/ajalt/mordant/rendering/OverflowWrap;
 }
 
+public final class com/github/ajalt/mordant/rendering/Size {
+	public fun <init> (II)V
+	public final fun getHeight ()I
+	public final fun getWidth ()I
+	public fun toString ()Ljava/lang/String;
+}
+
 public final class com/github/ajalt/mordant/rendering/Span {
 	public static final field Companion Lcom/github/ajalt/mordant/rendering/Span$Companion;
 	public synthetic fun <init> (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
@@ -1167,23 +1174,35 @@ public abstract class com/github/ajalt/mordant/terminal/Prompt {
 	protected fun renderValue (Ljava/lang/Object;)Ljava/lang/String;
 }
 
+public abstract class com/github/ajalt/mordant/terminal/StandardTerminalInterface : com/github/ajalt/mordant/terminal/TerminalInterface {
+	public fun <init> ()V
+	public fun completePrintRequest (Lcom/github/ajalt/mordant/terminal/PrintRequest;)V
+	public fun enterRawMode (Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/AutoCloseable;
+	public fun getTerminalSize ()Lcom/github/ajalt/mordant/rendering/Size;
+	public fun info (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public fun readInputEvent-VtjQ1oo (JLcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent;
+	public fun readLineOrNull (Z)Ljava/lang/String;
+	public fun shouldAutoUpdateSize ()Z
+	public fun stdinInteractive ()Z
+	public fun stdoutInteractive ()Z
+}
+
 public final class com/github/ajalt/mordant/terminal/StringPrompt : com/github/ajalt/mordant/terminal/Prompt {
 	public fun <init> (Ljava/lang/String;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/String;ZZZLjava/util/List;Ljava/lang/String;Ljava/lang/String;Z)V
 	public synthetic fun <init> (Ljava/lang/String;Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/String;ZZZLjava/util/List;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
 }
 
 public final class com/github/ajalt/mordant/terminal/Terminal {
-	public fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;)V
-	public synthetic fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
-	public fun <init> (Lcom/github/ajalt/mordant/rendering/Theme;ILcom/github/ajalt/mordant/terminal/TerminalInterface;)V
-	public fun <init> (Lcom/github/ajalt/mordant/rendering/Theme;Lcom/github/ajalt/mordant/terminal/TerminalInterface;)V
-	public fun <init> (Lcom/github/ajalt/mordant/terminal/TerminalInterface;)V
+	public fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;Lcom/github/ajalt/mordant/terminal/TerminalInterface;)V
+	public synthetic fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Lcom/github/ajalt/mordant/rendering/Theme;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Boolean;ILjava/lang/Boolean;Lcom/github/ajalt/mordant/terminal/TerminalInterface;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public final fun danger (Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;Z)V
 	public static synthetic fun danger$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;ZILjava/lang/Object;)V
 	public final fun getColors ()Lcom/github/ajalt/mordant/terminal/TerminalColors;
 	public final fun getCursor ()Lcom/github/ajalt/mordant/terminal/TerminalCursor;
 	public final fun getInfo ()Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public final fun getSize ()Lcom/github/ajalt/mordant/rendering/Size;
 	public final fun getTabWidth ()I
+	public final fun getTerminalInterface ()Lcom/github/ajalt/mordant/terminal/TerminalInterface;
 	public final fun getTheme ()Lcom/github/ajalt/mordant/rendering/Theme;
 	public final fun info (Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;Z)V
 	public static synthetic fun info$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;ZILjava/lang/Object;)V
@@ -1211,6 +1230,7 @@ public final class com/github/ajalt/mordant/terminal/Terminal {
 	public static synthetic fun render$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;ILjava/lang/Object;)Ljava/lang/String;
 	public final fun success (Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;Z)V
 	public static synthetic fun success$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;ZILjava/lang/Object;)V
+	public final fun updateSize ()Lcom/github/ajalt/mordant/rendering/Size;
 	public final fun warning (Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;Z)V
 	public static synthetic fun warning$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/lang/Object;Lcom/github/ajalt/mordant/rendering/Whitespace;Lcom/github/ajalt/mordant/rendering/TextAlign;Lcom/github/ajalt/mordant/rendering/OverflowWrap;Ljava/lang/Integer;ZILjava/lang/Object;)V
 }
@@ -1266,40 +1286,52 @@ public final class com/github/ajalt/mordant/terminal/TerminalCursor$DefaultImpls
 	public static synthetic fun hide$default (Lcom/github/ajalt/mordant/terminal/TerminalCursor;ZILjava/lang/Object;)V
 }
 
+public final class com/github/ajalt/mordant/terminal/TerminalDetection {
+	public static final field INSTANCE Lcom/github/ajalt/mordant/terminal/TerminalDetection;
+	public final fun detectTerminal (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;ZZ)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+}
+
 public final class com/github/ajalt/mordant/terminal/TerminalInfo {
-	public fun <init> (IILcom/github/ajalt/mordant/rendering/AnsiLevel;ZZZZ)V
-	public final fun component1 ()I
-	public final fun component2 ()I
-	public final fun component3 ()Lcom/github/ajalt/mordant/rendering/AnsiLevel;
+	public fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;ZZZZ)V
+	public final fun component1 ()Lcom/github/ajalt/mordant/rendering/AnsiLevel;
+	public final fun component2 ()Z
+	public final fun component3 ()Z
 	public final fun component4 ()Z
 	public final fun component5 ()Z
-	public final fun component6 ()Z
-	public final fun component7 ()Z
-	public final fun copy (IILcom/github/ajalt/mordant/rendering/AnsiLevel;ZZZZ)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
-	public static synthetic fun copy$default (Lcom/github/ajalt/mordant/terminal/TerminalInfo;IILcom/github/ajalt/mordant/rendering/AnsiLevel;ZZZZILjava/lang/Object;)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public final fun copy (Lcom/github/ajalt/mordant/rendering/AnsiLevel;ZZZZ)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public static synthetic fun copy$default (Lcom/github/ajalt/mordant/terminal/TerminalInfo;Lcom/github/ajalt/mordant/rendering/AnsiLevel;ZZZZILjava/lang/Object;)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
 	public fun equals (Ljava/lang/Object;)Z
 	public final fun getAnsiHyperLinks ()Z
 	public final fun getAnsiLevel ()Lcom/github/ajalt/mordant/rendering/AnsiLevel;
 	public final fun getCrClearsLine ()Z
-	public final fun getHeight ()I
 	public final fun getInputInteractive ()Z
 	public final fun getInteractive ()Z
 	public final fun getOutputInteractive ()Z
-	public final fun getWidth ()I
 	public fun hashCode ()I
 	public final fun setAnsiHyperLinks (Z)V
 	public final fun setAnsiLevel (Lcom/github/ajalt/mordant/rendering/AnsiLevel;)V
-	public final fun setHeight (I)V
-	public final fun setWidth (I)V
 	public fun toString ()Ljava/lang/String;
-	public final fun updateTerminalSize ()Z
-	public final fun updateTerminalSize (J)Z
 }
 
 public abstract interface class com/github/ajalt/mordant/terminal/TerminalInterface {
 	public abstract fun completePrintRequest (Lcom/github/ajalt/mordant/terminal/PrintRequest;)V
-	public abstract fun getInfo ()Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public abstract fun enterRawMode (Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/AutoCloseable;
+	public abstract fun getTerminalSize ()Lcom/github/ajalt/mordant/rendering/Size;
+	public abstract fun info (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public abstract fun readInputEvent-VtjQ1oo (JLcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent;
 	public abstract fun readLineOrNull (Z)Ljava/lang/String;
+	public abstract fun shouldAutoUpdateSize ()Z
+}
+
+public final class com/github/ajalt/mordant/terminal/TerminalInterface$DefaultImpls {
+	public static fun enterRawMode (Lcom/github/ajalt/mordant/terminal/TerminalInterface;Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/AutoCloseable;
+	public static fun getTerminalSize (Lcom/github/ajalt/mordant/terminal/TerminalInterface;)Lcom/github/ajalt/mordant/rendering/Size;
+	public static fun readInputEvent-VtjQ1oo (Lcom/github/ajalt/mordant/terminal/TerminalInterface;JLcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent;
+	public static fun shouldAutoUpdateSize (Lcom/github/ajalt/mordant/terminal/TerminalInterface;)Z
+}
+
+public abstract interface class com/github/ajalt/mordant/terminal/TerminalInterfaceProvider {
+	public abstract fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface;
 }
 
 public final class com/github/ajalt/mordant/terminal/TerminalRecorder : com/github/ajalt/mordant/terminal/TerminalInterface {
@@ -1307,11 +1339,15 @@ public final class com/github/ajalt/mordant/terminal/TerminalRecorder : com/gith
 	public synthetic fun <init> (Lcom/github/ajalt/mordant/rendering/AnsiLevel;IIZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public final fun clearOutput ()V
 	public fun completePrintRequest (Lcom/github/ajalt/mordant/terminal/PrintRequest;)V
-	public fun getInfo ()Lcom/github/ajalt/mordant/terminal/TerminalInfo;
+	public fun enterRawMode (Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/AutoCloseable;
 	public final fun getInputLines ()Ljava/util/List;
+	public fun getTerminalSize ()Lcom/github/ajalt/mordant/rendering/Size;
+	public fun info (Lcom/github/ajalt/mordant/rendering/AnsiLevel;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;)Lcom/github/ajalt/mordant/terminal/TerminalInfo;
 	public final fun output ()Ljava/lang/String;
+	public fun readInputEvent-VtjQ1oo (JLcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent;
 	public fun readLineOrNull (Z)Ljava/lang/String;
 	public final fun setInputLines (Ljava/util/List;)V
+	public fun shouldAutoUpdateSize ()Z
 	public final fun stderr ()Ljava/lang/String;
 	public final fun stdout ()Ljava/lang/String;
 }
@@ -1322,6 +1358,156 @@ public final class com/github/ajalt/mordant/terminal/YesNoPrompt : com/github/aj
 	public synthetic fun renderValue (Ljava/lang/Object;)Ljava/lang/String;
 }
 
+public abstract class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceJvmPosix : com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix {
+	public fun <init> ()V
+	protected fun readRawByte-HG0u8IE (Lkotlin/time/ComparableTimeMark;J)C
+}
+
+public abstract class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix : com/github/ajalt/mordant/terminal/StandardTerminalInterface {
+	protected static final field Companion Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Companion;
+	public static final field STDERR_FILENO I
+	public static final field STDIN_FILENO I
+	public static final field STDOUT_FILENO I
+	public fun <init> ()V
+	public fun enterRawMode (Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/AutoCloseable;
+	public abstract fun getStdinTermios ()Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios;
+	public abstract fun getTermiosConstants ()Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants;
+	protected abstract fun isatty (I)Z
+	public fun readInputEvent-VtjQ1oo (JLcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent;
+	protected abstract fun readRawByte-HG0u8IE (Lkotlin/time/ComparableTimeMark;J)C
+	public abstract fun setStdinTermios (Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios;)V
+	public fun stdinInteractive ()Z
+	public fun stdoutInteractive ()Z
+}
+
+protected final class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Companion {
+	public final fun getLinuxTermiosConstants ()Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants;
+	public final fun getMacosTermiosConstants ()Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants;
+}
+
+public final class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios {
+	public synthetic fun <init> (IIII[BLkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun component1-pVg5ArA ()I
+	public final fun component2-pVg5ArA ()I
+	public final fun component3-pVg5ArA ()I
+	public final fun component4-pVg5ArA ()I
+	public final fun component5 ()[B
+	public final fun copy-guggwrw (IIII[B)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios;
+	public static synthetic fun copy-guggwrw$default (Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios;IIII[BILjava/lang/Object;)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getCc ()[B
+	public final fun getCflag-pVg5ArA ()I
+	public final fun getIflag-pVg5ArA ()I
+	public final fun getLflag-pVg5ArA ()I
+	public final fun getOflag-pVg5ArA ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants {
+	public synthetic fun <init> (IIIIIIIIIIIIIILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun component1 ()I
+	public final fun component10-pVg5ArA ()I
+	public final fun component11-pVg5ArA ()I
+	public final fun component12-pVg5ArA ()I
+	public final fun component13-pVg5ArA ()I
+	public final fun component14-pVg5ArA ()I
+	public final fun component2 ()I
+	public final fun component3-pVg5ArA ()I
+	public final fun component4-pVg5ArA ()I
+	public final fun component5-pVg5ArA ()I
+	public final fun component6-pVg5ArA ()I
+	public final fun component7-pVg5ArA ()I
+	public final fun component8-pVg5ArA ()I
+	public final fun component9-pVg5ArA ()I
+	public final fun copy-aaEChFo (IIIIIIIIIIIIII)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants;
+	public static synthetic fun copy-aaEChFo$default (Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants;IIIIIIIIIIIIIIILjava/lang/Object;)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getCS8-pVg5ArA ()I
+	public final fun getECHO-pVg5ArA ()I
+	public final fun getICANON-pVg5ArA ()I
+	public final fun getICRNL-pVg5ArA ()I
+	public final fun getIEXTEN-pVg5ArA ()I
+	public final fun getIGNCR-pVg5ArA ()I
+	public final fun getINLCR-pVg5ArA ()I
+	public final fun getINPCK-pVg5ArA ()I
+	public final fun getISIG-pVg5ArA ()I
+	public final fun getISTRIP-pVg5ArA ()I
+	public final fun getIXON-pVg5ArA ()I
+	public final fun getOPOST-pVg5ArA ()I
+	public final fun getVMIN ()I
+	public final fun getVTIME ()I
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public abstract class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows : com/github/ajalt/mordant/terminal/StandardTerminalInterface {
+	public static final field DOUBLE_CLICK I
+	public static final field ENABLE_EXTENDED_FLAGS I
+	public static final field ENABLE_MOUSE_INPUT I
+	public static final field ENABLE_PROCESSED_INPUT I
+	public static final field ENABLE_QUICK_EDIT_MODE I
+	public static final field ENABLE_WINDOW_INPUT I
+	public static final field FROM_LEFT_1ST_BUTTON_PRESSED I
+	public static final field FROM_LEFT_2ND_BUTTON_PRESSED I
+	public static final field FROM_LEFT_3RD_BUTTON_PRESSED I
+	public static final field FROM_LEFT_4TH_BUTTON_PRESSED I
+	public static final field LEFT_ALT_PRESSED I
+	public static final field LEFT_CTRL_PRESSED I
+	public static final field MOUSE_HWHEELED I
+	public static final field MOUSE_MOVED I
+	public static final field MOUSE_WHEELED I
+	public static final field RIGHTMOST_BUTTON_PRESSED I
+	public static final field RIGHT_ALT_PRESSED I
+	public static final field RIGHT_CTRL_PRESSED I
+	public static final field SHIFT_PRESSED I
+	public fun <init> ()V
+	public final fun enterRawMode (Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/AutoCloseable;
+	protected abstract fun getStdinConsoleMode-pVg5ArA ()I
+	public fun readInputEvent-VtjQ1oo (JLcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent;
+	protected abstract fun readRawEvent (I)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord;
+	protected abstract fun setStdinConsoleMode-WZ4Q5Ns (I)V
+}
+
+protected abstract class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord {
+}
+
+public final class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Key : com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord {
+	public synthetic fun <init> (ZSCILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun component1 ()Z
+	public final fun component2-Mh2AYeg ()S
+	public final fun component3 ()C
+	public final fun component4-pVg5ArA ()I
+	public final fun copy-BxohIvk (ZSCI)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Key;
+	public static synthetic fun copy-BxohIvk$default (Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Key;ZSCIILjava/lang/Object;)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Key;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getBKeyDown ()Z
+	public final fun getDwControlKeyState-pVg5ArA ()I
+	public final fun getUChar ()C
+	public final fun getWVirtualKeyCode-Mh2AYeg ()S
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
+public final class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Mouse : com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord {
+	public synthetic fun <init> (SSIIILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun component1 ()S
+	public final fun component2 ()S
+	public final fun component3-pVg5ArA ()I
+	public final fun component4-pVg5ArA ()I
+	public final fun component5-pVg5ArA ()I
+	public final fun copy-Z3u013Y (SSIII)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Mouse;
+	public static synthetic fun copy-Z3u013Y$default (Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Mouse;SSIIIILjava/lang/Object;)Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceWindows$EventRecord$Mouse;
+	public fun equals (Ljava/lang/Object;)Z
+	public final fun getDwButtonState-pVg5ArA ()I
+	public final fun getDwControlKeyState-pVg5ArA ()I
+	public final fun getDwEventFlags-pVg5ArA ()I
+	public final fun getDwMousePositionX ()S
+	public final fun getDwMousePositionY ()S
+	public fun hashCode ()I
+	public fun toString ()Ljava/lang/String;
+}
+
 public final class com/github/ajalt/mordant/widgets/Caption : com/github/ajalt/mordant/rendering/Widget {
 	public fun <init> (Lcom/github/ajalt/mordant/rendering/Widget;Lcom/github/ajalt/mordant/rendering/Widget;Lcom/github/ajalt/mordant/rendering/Widget;)V
 	public synthetic fun <init> (Lcom/github/ajalt/mordant/rendering/Widget;Lcom/github/ajalt/mordant/rendering/Widget;Lcom/github/ajalt/mordant/rendering/Widget;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
diff --git a/mordant/build.gradle.kts b/mordant/build.gradle.kts
index 3cdccc82..0ff74dcc 100644
--- a/mordant/build.gradle.kts
+++ b/mordant/build.gradle.kts
@@ -12,15 +12,11 @@ kotlin {
         commonMain.dependencies {
             api(libs.colormath)
             implementation(libs.markdown)
-            implementation(libs.jna.core)
         }
         commonTest.dependencies {
             implementation(kotlin("test"))
             implementation(libs.kotest)
         }
-        jvmMain.dependencies {
-            compileOnly(libs.graalvm.svm)
-        }
         jvmTest.dependencies {
             api(libs.systemrules)
         }
diff --git a/mordant/gradle.properties b/mordant/gradle.properties
index ff294891..47afa25f 100644
--- a/mordant/gradle.properties
+++ b/mordant/gradle.properties
@@ -1,2 +1,2 @@
-POM_ARTIFACT_ID=mordant
-POM_NAME=Mordant
+POM_ARTIFACT_ID=mordant-core
+POM_NAME=Mordant Core
diff --git a/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt
index 914e457e..672faed6 100644
--- a/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt
+++ b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt
@@ -1,6 +1,8 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandler
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativeApple
+import com.github.ajalt.mordant.terminal.TerminalInterface
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceNativeApple
 
-internal actual fun getSyscallHandler(): SyscallHandler = SyscallHandlerNativeApple
+internal actual fun getStandardTerminalInterface(): TerminalInterface {
+    return TerminalInterfaceNativeApple()
+}
diff --git a/mordant/src/appleMain/kotlin/internal/syscalls/SyscallHanlder.native.apple.kt b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.apple.kt
similarity index 93%
rename from mordant/src/appleMain/kotlin/internal/syscalls/SyscallHanlder.native.apple.kt
rename to mordant/src/appleMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.apple.kt
index 20897b1e..7015e59d 100644
--- a/mordant/src/appleMain/kotlin/internal/syscalls/SyscallHanlder.native.apple.kt
+++ b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.apple.kt
@@ -1,6 +1,6 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
-import com.github.ajalt.mordant.internal.Size
+import com.github.ajalt.mordant.rendering.Size
 import kotlinx.cinterop.*
 import platform.posix.*
 
@@ -8,7 +8,7 @@ import platform.posix.*
 // different bit widths for some of the termios fields, so the compileMetadata task would fail if we
 // don't use separate files.
 
-internal object SyscallHandlerNativeApple : SyscallHandlerNativePosix() {
+internal class TerminalInterfaceNativeApple : TerminalInterfaceNativePosix() {
     override val termiosConstants: TermiosConstants = TermiosConstants(
         VTIME = VTIME,
         VMIN = VMIN,
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
index 62285867..9e45af77 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt
@@ -1,6 +1,8 @@
 package com.github.ajalt.mordant.animation
 
-import com.github.ajalt.mordant.internal.*
+import com.github.ajalt.mordant.internal.CR_IMPLIES_LF
+import com.github.ajalt.mordant.internal.MppAtomicRef
+import com.github.ajalt.mordant.internal.update
 import com.github.ajalt.mordant.rendering.*
 import com.github.ajalt.mordant.terminal.PrintRequest
 import com.github.ajalt.mordant.terminal.Terminal
@@ -46,7 +48,7 @@ abstract class Animation<T>(
     private val state = MppAtomicRef(State())
 
     private val interceptor: TerminalInterceptor = TerminalInterceptor { req ->
-        val terminalSize = Size(terminal.info.width, terminal.info.height)
+        val terminalSize = Size(terminal.size.width, terminal.size.height)
         val (st, _) = state.update {
             copy(
                 firstDraw = false,
@@ -95,7 +97,7 @@ abstract class Animation<T>(
             clearScreen = true,
             lastSize = old.size,
             size = null,
-            terminalSize = Size(terminal.info.width, terminal.info.height),
+            terminalSize = Size(terminal.size.width, terminal.size.height),
             lastTerminalSize = old.lastTerminalSize,
             // if we previously stopped, we need to move up past the final newline we added
             extraUp = if (old.firstDraw && old.size != null) 1 else 0,
@@ -137,7 +139,7 @@ abstract class Animation<T>(
      * place.
      */
     fun update(data: T) {
-        if (SYSCALL_HANDLER.fastIsTty()) terminal.info.updateTerminalSize()
+        if (terminal.terminalInterface.shouldAutoUpdateSize()) terminal.updateSize()
 
         val rendered = renderData(data).render(terminal)
         val (old, _) = state.update {
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt
index 35f403d8..509839cb 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/progress/ProgressBarAnimation.kt
@@ -1,7 +1,6 @@
 package com.github.ajalt.mordant.animation.progress
 
 import com.github.ajalt.mordant.animation.Refreshable
-import com.github.ajalt.mordant.widgets.progress.ProgressBarCell
 import com.github.ajalt.mordant.widgets.progress.ProgressBarDefinition
 import com.github.ajalt.mordant.widgets.progress.ProgressState
 import com.github.ajalt.mordant.widgets.progress.TaskId
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt
index eff78ae7..178d3c01 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt
@@ -28,9 +28,3 @@ internal val DEFAULT_PADDING = Padding(0)
 @Suppress("RegExpRedundantEscape") // JS requires escaping the lone `]` at the beginning of the pattern, so we can't use $OSC
 internal val ANSI_RE = Regex("""$ESC\][^$ESC]*$ESC\\|$ESC(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])""")
 internal const val HYPERLINK_RESET = "__mordant_reset__"
-
-internal data class Size(val width: Int, val height: Int) {
-    override fun toString(): String {
-        return "${width}x$height"
-    }
-}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt
index f87caedf..0ae89af6 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt
@@ -1,6 +1,5 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandler
 import com.github.ajalt.mordant.terminal.*
 
 internal interface MppAtomicInt {
@@ -55,6 +54,6 @@ internal expect fun readFileIfExists(filename: String): String?
 
 internal expect fun hasFileSystem(): Boolean
 
-internal expect fun getSyscallHandler() : SyscallHandler
+internal expect fun getStandardTerminalInterface() : TerminalInterface
 
-internal val SYSCALL_HANDLER: SyscallHandler = getSyscallHandler()
+internal val STANDARD_TERM_INTERFACE: TerminalInterface = getStandardTerminalInterface()
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt
deleted file mode 100644
index 97d40eed..00000000
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.github.ajalt.mordant.internal.syscalls
-
-import com.github.ajalt.mordant.input.InputEvent
-import com.github.ajalt.mordant.input.MouseTracking
-import com.github.ajalt.mordant.internal.Size
-import kotlin.time.Duration
-
-internal sealed class SysInputEvent {
-    data class Success(val event: InputEvent) : SysInputEvent()
-    data object Retry : SysInputEvent()
-}
-
-internal interface SyscallHandler {
-    fun stdoutInteractive(): Boolean
-    fun stdinInteractive(): Boolean
-    fun stderrInteractive(): Boolean
-    fun getTerminalSize(): Size?
-    fun fastIsTty(): Boolean = true
-    fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent
-    fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable
-}
-
-internal object DumbSyscallHandler : SyscallHandler {
-    override fun stdoutInteractive(): Boolean = false
-    override fun stdinInteractive(): Boolean = false
-    override fun stderrInteractive(): Boolean = false
-    override fun getTerminalSize(): Size? = null
-    override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
-        throw UnsupportedOperationException("Cannot enter raw mode on this system")
-    }
-    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent {
-        throw UnsupportedOperationException("Cannot read input on this system")
-    }
-}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Size.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Size.kt
new file mode 100644
index 00000000..66ab0122
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Size.kt
@@ -0,0 +1,5 @@
+package com.github.ajalt.mordant.rendering
+
+class Size(val width: Int, val height: Int) {
+    override fun toString(): String = "${width}×$height"
+}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Widget.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Widget.kt
index c55e391a..30b85628 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Widget.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Widget.kt
@@ -3,7 +3,7 @@ package com.github.ajalt.mordant.rendering
 import com.github.ajalt.mordant.terminal.Terminal
 
 interface Widget {
-    fun measure(t: Terminal, width: Int = t.info.width): WidthRange
-    fun render(t: Terminal, width: Int = t.info.width): Lines
+    fun measure(t: Terminal, width: Int = t.size.width): WidthRange
+    fun render(t: Terminal, width: Int = t.size.width): Lines
 }
 
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StandardTerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StandardTerminalInterface.kt
new file mode 100644
index 00000000..c5bfbfbe
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StandardTerminalInterface.kt
@@ -0,0 +1,49 @@
+package com.github.ajalt.mordant.terminal
+
+import com.github.ajalt.mordant.internal.printStderr
+import com.github.ajalt.mordant.internal.readLineOrNullMpp
+import com.github.ajalt.mordant.rendering.AnsiLevel
+
+/**
+ * A base class for terminal interfaces that prints using standard `kotlin.io` functions.
+ */
+abstract class StandardTerminalInterface : TerminalInterface {
+    override fun info(
+        ansiLevel: AnsiLevel?,
+        hyperlinks: Boolean?,
+        outputInteractive: Boolean?,
+        inputInteractive: Boolean?,
+    ): TerminalInfo {
+        return TerminalDetection.detectTerminal(
+            ansiLevel = ansiLevel,
+            hyperlinks = hyperlinks,
+            forceInputInteractive = inputInteractive,
+            forceOutputInteractive = outputInteractive,
+            detectedStdinInteractive = stdinInteractive(),
+            detectedStdoutInteractive = stdoutInteractive(),
+        )
+    }
+
+    override fun completePrintRequest(request: PrintRequest) {
+        when {
+            request.stderr -> printStderr(request.text, request.trailingLinebreak)
+            request.trailingLinebreak -> {
+                if (request.text.isEmpty()) {
+                    println()
+                } else {
+                    println(request.text)
+                }
+            }
+
+            else -> print(request.text)
+        }
+    }
+
+    override fun readLineOrNull(hideInput: Boolean): String? = readLineOrNullMpp(hideInput)
+
+    /** Whether the output stream is detected as interactive. */
+    open fun stdoutInteractive(): Boolean = true
+
+    /** Whether the input stream is detected as interactive. */
+    open fun stdinInteractive(): Boolean = true
+}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
deleted file mode 100644
index 6e0f4bae..00000000
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package com.github.ajalt.mordant.terminal
-
-import com.github.ajalt.mordant.internal.printStderr
-import com.github.ajalt.mordant.internal.readLineOrNullMpp
-import com.github.ajalt.mordant.rendering.AnsiLevel
-
-internal class StdoutTerminalInterface private constructor(
-    override val info: TerminalInfo,
-) : TerminalInterface {
-    constructor(
-        ansiLevel: AnsiLevel?,
-        width: Int?,
-        height: Int?,
-        hyperlinks: Boolean?,
-        interactive: Boolean?,
-    ) : this(
-        TerminalDetection.detectTerminal(ansiLevel, width, height, hyperlinks, interactive)
-    )
-
-    override fun completePrintRequest(request: PrintRequest) {
-        when {
-            request.stderr -> printStderr(request.text, request.trailingLinebreak)
-            request.trailingLinebreak -> {
-                if (request.text.isEmpty()) {
-                    println()
-                } else {
-                    println(request.text)
-                }
-            }
-            else -> print(request.text)
-        }
-    }
-
-    override fun readLineOrNull(hideInput: Boolean): String? = readLineOrNullMpp(hideInput)
-}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt
index 22fc0148..82d83fd1 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt
@@ -20,23 +20,23 @@ import com.github.ajalt.mordant.widgets.Text
 class Terminal private constructor(
     val theme: Theme,
     val tabWidth: Int,
-    private val terminalInterface: TerminalInterface,
-    private val interceptors: MppAtomicRef<List<TerminalInterceptor>>,
+    val terminalInterface: TerminalInterface,
+    private val forceWidth: Int?,
+    private val forceHeight: Int?,
+    /** The terminal capabilities that were detected or set in the constructor. */
+    val info: TerminalInfo,
 ) {
     /**
      * @param ansiLevel The level of color support to use, or `null` to detect the level of the current terminal
      * @param theme The theme to use for widgets and styles like [success]
      * @param width The width to render widget and wrap text, or `null` to detect the current width.
-     *   On JVM, you'll need to call [info.updateTerminalSize][TerminalInfo.updateTerminalSize] to
-     *   detect the size correctly.
-     * @param height The height of th terminal to use when rendering widgets, or `null` to detect the current width.
-     *   On JVM, you'll need to call [info.updateTerminalSize][TerminalInfo.updateTerminalSize] to
-     *   detect the size correctly.
+     * @param height The height of the terminal to use when rendering widgets, or `null` to detect the current width.
      * @param hyperlinks whether to render hyperlinks using ANSI codes, or `null` to detect the capability
      * @param tabWidth The number of spaces to use for `\t` characters
      * @param interactive Set to true to always use color codes, even if stdout is redirected to a
      *   file, or `null` to detect the capability. This can be useful if you expect to call your
      *   program from some other source like gradle that forwards the output to the terminal.
+     * @param terminalInterface The terminal interface to use to interact with the terminal.
      */
     constructor(
         ansiLevel: AnsiLevel? = null,
@@ -46,43 +46,43 @@ class Terminal private constructor(
         hyperlinks: Boolean? = null,
         tabWidth: Int = 8,
         interactive: Boolean? = null,
+        terminalInterface: TerminalInterface = STANDARD_TERM_INTERFACE,
     ) : this(
-        theme,
-        tabWidth,
-        StdoutTerminalInterface(ansiLevel, width, height, hyperlinks, interactive)
+        theme = theme,
+        tabWidth = tabWidth,
+        terminalInterface = terminalInterface,
+        forceWidth = width,
+        forceHeight = height,
+        info = terminalInterface.info(
+            ansiLevel = ansiLevel,
+            hyperlinks = hyperlinks,
+            outputInteractive = interactive,
+            inputInteractive = interactive,
+        )
     )
 
-    /**
-     * @param theme The theme to use for widgets and styles like [success]
-     * @param tabWidth The number of spaces to use for `\t` characters
-     * @param terminalInterface The [TerminalInterface] to use to read and write
-     */
-    constructor(
-        theme: Theme,
-        tabWidth: Int,
-        terminalInterface: TerminalInterface,
-    ) : this(theme, tabWidth, terminalInterface, MppAtomicRef(emptyList()))
+    private val interceptors: MppAtomicRef<List<TerminalInterceptor>> =
+        MppAtomicRef(emptyList())
+
+    private val atomicSize: MppAtomicRef<Size> =
+        MppAtomicRef(terminalInterface.detectSize(forceWidth, forceHeight))
 
-    /**
-     * @param theme The theme to use for widgets and styles like [success]
-     * @param terminalInterface The [TerminalInterface] to use to read and write
-     */
-    constructor(
-        theme: Theme,
-        terminalInterface: TerminalInterface,
-    ) : this(theme, 8, terminalInterface, MppAtomicRef(emptyList()))
 
     /**
-     * @param terminalInterface The [TerminalInterface] to use to read and write
+     * The current size of the terminal.
+     *
+     * This value is updated automatically whenever you print to the terminal.
      */
-    constructor(
-        terminalInterface: TerminalInterface,
-    ) : this(Theme.Default, 8, terminalInterface, MppAtomicRef(emptyList()))
+    val size: Size get() = atomicSize.value
 
     /**
-     * The terminal capabilities that were detected or set in the constructor.
+     * Detect the current terminal's size and update [size].
+     *
+     * This is called automatically whenever you print to the terminal.
      */
-    val info: TerminalInfo = terminalInterface.info
+    fun updateSize(): Size {
+        return atomicSize.update { terminalInterface.detectSize(forceWidth, forceHeight) }.second
+    }
 
     /**
      * Colors and styles that are downsampled based on the current terminal [info].
@@ -91,7 +91,7 @@ class Terminal private constructor(
      * [kotlin.io.print]. If you're using the terminal's [print], you can use [TextColors] and
      * [TextStyles] directly instead.
      */
-    @Deprecated("Use TextColors or .theme instead")
+    @Deprecated("Use TextColors or .theme instead") // TODO(3.0) remove
     val colors: TerminalColors = TerminalColors(info, theme)
 
     /**
@@ -99,13 +99,16 @@ class Terminal private constructor(
      *
      * If the terminal is not interactive, all the cursor functions are no-ops.
      */
-    val cursor: TerminalCursor =
-        if (info.interactive) makePrintingTerminalCursor(this) else DisabledTerminalCursor
+    val cursor: TerminalCursor = when {
+        info.interactive -> makePrintingTerminalCursor(this)
+        else -> DisabledTerminalCursor
+    }
 
     /**
      * Print a line styled with the theme's [success][Theme.success] style.
      */
     fun success(
+        // TODO(3.0): make extensions
         message: Any?,
         whitespace: Whitespace = Whitespace.PRE,
         align: TextAlign = TextAlign.NONE,
@@ -374,7 +377,7 @@ class Terminal private constructor(
     }
 
     private fun sendPrintRequest(request: PrintRequest) {
-        if (SYSCALL_HANDLER.fastIsTty()) info.updateTerminalSize()
+        if (terminalInterface.shouldAutoUpdateSize()) updateSize()
         sendInterceptedPrintRequest(request, terminalInterface, interceptors.value)
     }
 }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt
index 43fc7321..2ae988bc 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalColors.kt
@@ -14,7 +14,7 @@ import com.github.ajalt.mordant.rendering.Theme
  * Strings decorated with these styles will be downsampled correctly even if they are printed
  * directly rather than through [Terminal.print].
  */
-class TerminalColors internal constructor(
+class TerminalColors internal constructor( // TODO(3.0): remove
     private val terminalInfo: TerminalInfo,
     private val theme: Theme,
 ) {
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt
index 72e1e789..1ad4a69b 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt
@@ -1,30 +1,40 @@
 package com.github.ajalt.mordant.terminal
 
-import com.github.ajalt.mordant.internal.SYSCALL_HANDLER
-import com.github.ajalt.mordant.internal.Size
 import com.github.ajalt.mordant.internal.getEnv
 import com.github.ajalt.mordant.internal.runningInIdeaJavaAgent
 import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.rendering.AnsiLevel.*
-
-internal object TerminalDetection {
+import com.github.ajalt.mordant.rendering.Size
+
+/**
+ * Detect information about the current terminal.
+ */
+object TerminalDetection {
+    /**
+     * Detect information about the current terminal.
+     *
+     * @param ansiLevel The desired level of ANSI support. If `null`, the level will be detected.
+     * @param hyperlinks Whether to enable hyperlink support. If `null`, hyperlinks support will be detected.
+     * @param forceInputInteractive Force stdin to be treated as interactive. If `null`, stdin will be detected.
+     * @param forceOutputInteractive Force stdout to be treated as interactive. If `null`, stdout will be detected.
+     * @param detectedStdinInteractive Whether stdin was detected as interactive.
+     * @param detectedStdoutInteractive Whether stdout was detected as interactive.
+     */
     fun detectTerminal(
         ansiLevel: AnsiLevel?,
-        width: Int?,
-        height: Int?,
         hyperlinks: Boolean?,
-        interactive: Boolean?,
+        forceInputInteractive: Boolean?,
+        forceOutputInteractive: Boolean?,
+        detectedStdinInteractive: Boolean,
+        detectedStdoutInteractive: Boolean,
     ): TerminalInfo {
         // intellij console is interactive, even though isatty returns false
         val ij = isIntellijRunActionConsole()
-        val inputInteractive = interactive ?: (ij || SYSCALL_HANDLER.stdinInteractive())
-        val outputInteractive = interactive ?: (ij || SYSCALL_HANDLER.stdoutInteractive())
+        val inputInteractive = forceInputInteractive ?: (ij || detectedStdinInteractive)
+        val outputInteractive = forceOutputInteractive ?: (ij || detectedStdoutInteractive)
         val level = ansiLevel ?: ansiLevel(outputInteractive)
         val ansiHyperLinks = hyperlinks ?: (outputInteractive && level != NONE && ansiHyperLinks())
-        val (w, h) = detectInitialSize()
         return TerminalInfo(
-            width = width ?: w,
-            height = height ?: h,
             ansiLevel = level,
             ansiHyperLinks = ansiHyperLinks,
             outputInteractive = outputInteractive,
@@ -33,16 +43,6 @@ internal object TerminalDetection {
         )
     }
 
-    /** Returns the size, or `null` if the size can't be detected */
-    fun detectSize(): Size? = SYSCALL_HANDLER.getTerminalSize()
-
-    private fun detectInitialSize(): Size {
-        return detectSize() ?: Size(
-            width = (getEnv("COLUMNS")?.toIntOrNull() ?: 79),
-            height = (getEnv("LINES")?.toIntOrNull() ?: 24)
-        )
-    }
-
     // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
     private fun ansiHyperLinks(): Boolean {
         if (forcedColor() == NONE || isCI()) return false
@@ -199,3 +199,12 @@ internal object TerminalDetection {
         return !isJediTerm() && runningInIdeaJavaAgent()
     }
 }
+
+internal fun TerminalInterface.detectSize(width: Int?, height: Int?): Size {
+    if (width != null && height != null) return Size(width, height)
+    val detected = getTerminalSize() ?: Size(
+        width = (getEnv("COLUMNS")?.toIntOrNull() ?: 79),
+        height = (getEnv("LINES")?.toIntOrNull() ?: 24)
+    )
+    return Size(width = width ?: detected.width, height = height ?: detected.height)
+}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt
index 49bc300f..70223436 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt
@@ -5,15 +5,8 @@ import com.github.ajalt.mordant.rendering.AnsiLevel
 
 /**
  * Information about the current terminal
- *
- * [width] and [height] don't automatically change if the terminal is resized. Call
- * [updateTerminalSize] to get the latest values.
  */
 data class TerminalInfo(
-    /** The terminal width, in cells */
-    var width: Int,
-    /** The terminal height, in cells */
-    var height: Int,
     /** The level of ANSI codes to use when printing to the terminal */
     var ansiLevel: AnsiLevel,
     /** If true, ANSI hyperlink codes can be used */
@@ -36,24 +29,4 @@ data class TerminalInfo(
 ) {
     /** Return true if both input and output are interactive */
     val interactive: Boolean get() = inputInteractive && outputInteractive
-
-    /**
-     * Query the terminal for its current size, updating [width] and [height] if successful.
-     *
-     * @return `true` if the terminal size was queried successfully (even if the size hasn't
-     *  changed), or false if the size could not be determined.
-     */
-    fun updateTerminalSize(): Boolean {
-        val (w, h) = TerminalDetection.detectSize() ?: return false
-        width = w
-        height = h
-        return true
-    }
-
-    @Deprecated(
-        "The timeoutMs parameter is no longer used; this function does not open a subprocess",
-        ReplaceWith("updateTerminalSize()")
-    )
-    @Suppress("UNUSED_PARAMETER")
-    fun updateTerminalSize(timeoutMs: Long): Boolean = updateTerminalSize()
 }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt
index e05e28cd..f061e990 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt
@@ -1,11 +1,27 @@
 package com.github.ajalt.mordant.terminal
 
+import com.github.ajalt.mordant.input.InputEvent
+import com.github.ajalt.mordant.input.MouseTracking
+import com.github.ajalt.mordant.rendering.AnsiLevel
+import com.github.ajalt.mordant.rendering.Size
+import kotlin.time.Duration
+
 
 interface TerminalInterface {
     /**
-     * Information about the current terminal.
+     * Get information about the current terminal.
+     *
+     * @param ansiLevel The level of ANSI codes to use, or `null` to autodetect.
+     * @param hyperlinks Whether to enable hyperlink support, or `null` to autodetect.
+     * @param outputInteractive Whether the output stream is interactive. If `null`, it will be autodetected.
+     * @param inputInteractive Whether the input stream is interactive. If `null`, it will be autodetected.
      */
-    val info: TerminalInfo
+    fun info(
+        ansiLevel: AnsiLevel?,
+        hyperlinks: Boolean?,
+        outputInteractive: Boolean?,
+        inputInteractive: Boolean?,
+    ): TerminalInfo
 
     /**
      * Display a PrintRequest on this terminal.
@@ -20,6 +36,37 @@ interface TerminalInterface {
      *   targets are supported by default except Browser JS.
      */
     fun readLineOrNull(hideInput: Boolean): String?
+
+    /**
+     * Return the current size, in cells, of the terminal, or null if the size is unknown.
+     */
+    fun getTerminalSize(): Size? = null
+
+    /**
+     * Read a single input event from the terminal, or null if no event is available but this call
+     * should be retried immediately.
+     *
+     * You would return `null` if you receive an event that should be ignored, like a mouse
+     * event when mouse tracking is off.
+     */
+    fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
+        throw NotImplementedError("Reading input events is not supported on this terminal")
+    }
+
+    /**
+     * Enter raw mode on the terminal, disabling line buffering and echoing to enable reading
+     * individual character.
+     *
+     * @return A scope that will restore the terminal to its previous state when closed.
+     */
+    fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
+        throw NotImplementedError("Raw mode is not supported on this terminal")
+    }
+
+    /**
+     * Return true if the [getTerminalSize] method should be called frequently to update the size.
+     */
+    fun shouldAutoUpdateSize(): Boolean = true
 }
 
 data class PrintRequest(
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterfaceProvider.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterfaceProvider.kt
new file mode 100644
index 00000000..1b928a80
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterfaceProvider.kt
@@ -0,0 +1,18 @@
+package com.github.ajalt.mordant.terminal
+
+/**
+ * A provider for a [TerminalInterface].
+ *
+ * Implementations of this interface are loaded via the `ServiceLoader` mechanism, and should be
+ * declared in the `META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider`
+ * file.
+ */
+interface TerminalInterfaceProvider {
+    /**
+     * Load the terminal interface.
+     *
+     * @return The terminal interface, or null if it could not be loaded.
+     */
+    fun load(): TerminalInterface?
+}
+
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalRecorder.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalRecorder.kt
index dad0975b..add22fb9 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalRecorder.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalRecorder.kt
@@ -1,6 +1,10 @@
 package com.github.ajalt.mordant.terminal
 
+import com.github.ajalt.mordant.input.InputEvent
+import com.github.ajalt.mordant.input.MouseTracking
 import com.github.ajalt.mordant.rendering.AnsiLevel
+import com.github.ajalt.mordant.rendering.Size
+import kotlin.time.Duration
 
 /**
  * A [TerminalInterface] that records all output and allows you to provide input.
@@ -14,7 +18,8 @@ import com.github.ajalt.mordant.rendering.AnsiLevel
  * assertEquals(recorder.output(), "Hello, world!\n")
  */
 class TerminalRecorder private constructor(
-    override val info: TerminalInfo,
+    private val info: TerminalInfo,
+    private val size: Size,
 ) : TerminalInterface {
     constructor(
         ansiLevel: AnsiLevel = AnsiLevel.TRUECOLOR,
@@ -23,17 +28,16 @@ class TerminalRecorder private constructor(
         hyperlinks: Boolean = ansiLevel != AnsiLevel.NONE,
         outputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
         inputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
-        crClearsLine: Boolean = false,
+        crClearsLine: Boolean = false, // TODO(3.0): rename this to supportsAnsiCursor
     ) : this(
-        info = TerminalInfo(
-            width,
-            height,
-            ansiLevel,
-            hyperlinks,
+        TerminalInfo(
+            ansiLevel = ansiLevel,
+            ansiHyperLinks = hyperlinks,
             outputInteractive = outputInteractive,
             inputInteractive = inputInteractive,
             crClearsLine = crClearsLine,
         ),
+        Size(width, height),
     )
 
     /**
@@ -70,4 +74,27 @@ class TerminalRecorder private constructor(
     }
 
     override fun readLineOrNull(hideInput: Boolean): String? = inputLines.removeFirstOrNull()
+    override fun getTerminalSize(): Size = size
+    override fun info(
+        ansiLevel: AnsiLevel?,
+        hyperlinks: Boolean?,
+        outputInteractive: Boolean?,
+        inputInteractive: Boolean?,
+    ): TerminalInfo {
+        return TerminalInfo(
+            ansiLevel = ansiLevel ?: info.ansiLevel,
+            ansiHyperLinks = hyperlinks ?: info.ansiHyperLinks,
+            outputInteractive = outputInteractive ?: info.outputInteractive,
+            inputInteractive = inputInteractive ?: info.inputInteractive,
+            crClearsLine = info.crClearsLine,
+        )
+    }
+
+    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
+        TODO()
+    }
+
+    override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
+        TODO()
+    }
 }
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt
index 857e2c04..d6c667fb 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt
@@ -1,7 +1,7 @@
 package com.github.ajalt.mordant.widgets
 
-import com.github.ajalt.mordant.internal.MppAtomicInt
 import com.github.ajalt.mordant.internal.DEFAULT_STYLE
+import com.github.ajalt.mordant.internal.MppAtomicInt
 import com.github.ajalt.mordant.rendering.Lines
 import com.github.ajalt.mordant.rendering.TextStyle
 import com.github.ajalt.mordant.rendering.Widget
diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt
index 5727b779..46306831 100644
--- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt
+++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt
@@ -1,14 +1,13 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandler
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerBrowser
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsCommon
 import com.github.ajalt.mordant.terminal.*
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceBrowser
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsCommon
 
 
 // Since `js()` and `external` work differently in wasm and js, we need to define the functions that
 // use them twice
-internal expect fun makeNodeSyscallHandler(): SyscallHandlerJsCommon?
+internal expect fun makeNodeTerminalInterface(): TerminalInterfaceJsCommon?
 internal expect fun browserPrintln(message: String)
 
 private class JsAtomicRef<T>(override var value: T) : MppAtomicRef<T> {
@@ -44,11 +43,11 @@ internal actual fun MppAtomicInt(initial: Int): MppAtomicInt = JsAtomicInt(initi
 internal actual fun <T> MppAtomicRef(value: T): MppAtomicRef<T> = JsAtomicRef(value)
 
 
-internal actual fun getSyscallHandler(): SyscallHandler {
-    return makeNodeSyscallHandler() ?: SyscallHandlerBrowser
+internal actual fun getStandardTerminalInterface(): TerminalInterface {
+    return makeNodeTerminalInterface() ?: TerminalInterfaceBrowser
 }
 
-private val impls get() = SYSCALL_HANDLER as SyscallHandlerJsCommon
+private val impls get() = STANDARD_TERM_INTERFACE as TerminalInterfaceJsCommon
 
 internal actual fun runningInIdeaJavaAgent(): Boolean = false
 
@@ -69,9 +68,6 @@ internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCurs
 
 internal actual fun readFileIfExists(filename: String): String? = impls.readFileIfExists(filename)
 
-// There are no shutdown hooks on browsers, so we don't need to do anything here
-private class BrowserTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal)
-
 
 internal actual fun sendInterceptedPrintRequest(
     request: PrintRequest,
@@ -83,4 +79,4 @@ internal actual fun sendInterceptedPrintRequest(
     )
 }
 
-internal actual fun hasFileSystem(): Boolean = impls !is SyscallHandlerBrowser
+internal actual fun hasFileSystem(): Boolean = impls !is TerminalInterfaceBrowser
diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt
similarity index 75%
rename from mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt
rename to mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt
index 60603bac..4a60d52c 100644
--- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt
+++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt
@@ -1,24 +1,25 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
 import com.github.ajalt.mordant.input.InputEvent
 import com.github.ajalt.mordant.input.MouseTracking
-import com.github.ajalt.mordant.internal.Size
 import com.github.ajalt.mordant.internal.browserPrintln
+import com.github.ajalt.mordant.rendering.Size
 import com.github.ajalt.mordant.terminal.PrintTerminalCursor
+import com.github.ajalt.mordant.terminal.StandardTerminalInterface
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.terminal.TerminalCursor
 import kotlin.time.Duration
 
-internal interface SyscallHandlerJsCommon: SyscallHandler {
-    fun readEnvvar(key: String): String?
-    fun printStderr(message: String, newline: Boolean)
-    fun readLineOrNull(): String?
-    fun makeTerminalCursor(terminal: Terminal): TerminalCursor
-    fun exitProcess(status: Int)
-    fun readFileIfExists(filename: String): String?
+internal abstract class TerminalInterfaceJsCommon: StandardTerminalInterface() {
+    abstract fun readEnvvar(key: String): String?
+    abstract fun printStderr(message: String, newline: Boolean)
+    abstract fun readLineOrNull(): String?
+    abstract fun makeTerminalCursor(terminal: Terminal): TerminalCursor
+    abstract fun exitProcess(status: Int)
+    abstract fun readFileIfExists(filename: String): String?
 
     // The public interface never is in nonJsMain, so these will never be called
-    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent {
+    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
         throw UnsupportedOperationException("Reading keyboard is not supported on this platform")
     }
     override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
@@ -26,7 +27,7 @@ internal interface SyscallHandlerJsCommon: SyscallHandler {
     }
 }
 
-internal abstract class SyscallHandlerNode<BufferT> : SyscallHandlerJsCommon {
+internal abstract class TerminalInterfaceNode<BufferT> : TerminalInterfaceJsCommon() {
     final override fun readLineOrNull(): String? {
         return try {
             buildString {
@@ -49,11 +50,10 @@ internal abstract class SyscallHandlerNode<BufferT> : SyscallHandlerJsCommon {
     abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int
 }
 
-internal object SyscallHandlerBrowser : SyscallHandlerJsCommon {
+internal object TerminalInterfaceBrowser : TerminalInterfaceJsCommon() {
     override fun readEnvvar(key: String): String? = null
     override fun stdoutInteractive(): Boolean = false
     override fun stdinInteractive(): Boolean = false
-    override fun stderrInteractive(): Boolean = false
     override fun getTerminalSize(): Size? = null
     override fun printStderr(message: String, newline: Boolean) = browserPrintln(message)
     override fun exitProcess(status: Int) {}
diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt
index 1f187763..1ea22400 100644
--- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt
+++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt
@@ -1,11 +1,7 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsCommon
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsNode
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNode
-import com.github.ajalt.mordant.terminal.PrintTerminalCursor
-import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.terminal.TerminalCursor
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsCommon
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsNode
 
 private external val process: dynamic
 private external val console: dynamic
@@ -17,9 +13,9 @@ internal actual fun browserPrintln(message: String) {
     console.error(message)
 }
 
-internal actual fun makeNodeSyscallHandler(): SyscallHandlerJsCommon? {
+internal actual fun makeNodeTerminalInterface(): TerminalInterfaceJsCommon? {
     return try {
-        SyscallHandlerJsNode(nodeRequire("fs"))
+        TerminalInterfaceJsNode(nodeRequire("fs"))
     } catch (e: Exception) {
         null
     }
diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.js.kt
similarity index 87%
rename from mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt
rename to mordant/src/jsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.js.kt
index 7b0d7881..dd532842 100644
--- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt
+++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.js.kt
@@ -1,6 +1,6 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
-import com.github.ajalt.mordant.internal.Size
+import com.github.ajalt.mordant.rendering.Size
 import com.github.ajalt.mordant.terminal.PrintTerminalCursor
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.terminal.TerminalCursor
@@ -9,11 +9,10 @@ import com.github.ajalt.mordant.terminal.TerminalCursor
 private external val process: dynamic
 private external val Buffer: dynamic
 
-internal class SyscallHandlerJsNode(private val fs: dynamic): SyscallHandlerNode<dynamic>() {
+internal class TerminalInterfaceJsNode(private val fs: dynamic): TerminalInterfaceNode<dynamic>() {
     override fun readEnvvar(key: String): String? = process.env[key] as? String
     override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean
     override fun stdinInteractive(): Boolean = js("Boolean(process.stdin.isTTY)") as Boolean
-    override fun stderrInteractive(): Boolean = js("Boolean(process.stderr.isTTY)") as Boolean
     override fun exitProcess(status: Int) {
         process.exit(status)
     }
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt
index 63b3ef9d..6be7a602 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt
@@ -1,22 +1,18 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.DumbSyscallHandler
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandler
-import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaLinux
-import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaMacos
-import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaWindows
-import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImageLinux
-import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImageMacos
-import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImageWindows
+import com.github.ajalt.mordant.input.InputEvent
+import com.github.ajalt.mordant.input.MouseTracking
+import com.github.ajalt.mordant.rendering.Size
 import com.github.ajalt.mordant.terminal.*
-import java.io.File
 import java.io.IOException
 import java.lang.management.ManagementFactory
 import java.nio.file.Path
+import java.util.*
 import java.util.concurrent.atomic.AtomicInteger
 import java.util.concurrent.atomic.AtomicReference
 import kotlin.io.path.readText
 import kotlin.system.exitProcess
+import kotlin.time.Duration
 
 private class JvmAtomicRef<T>(value: T) : MppAtomicRef<T> {
     private val ref = AtomicReference(value)
@@ -126,24 +122,24 @@ internal actual fun sendInterceptedPrintRequest(
     })
 }
 
-internal actual fun getSyscallHandler(): SyscallHandler {
-    return try {
-        // Inlined version of ImageInfo.inImageCode()
-        val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode")
-        val isNativeImage = imageCode == "buildtime" || imageCode == "runtime"
-        val os = System.getProperty("os.name")
-        when {
-            isNativeImage && os.startsWith("Windows") -> SyscallHandlerNativeImageWindows()
-            isNativeImage && (os == "Linux") -> SyscallHandlerNativeImageLinux()
-            isNativeImage && (os == "Mac OS X") -> SyscallHandlerNativeImageMacos()
-            os.startsWith("Windows") -> SyscallHandlerJnaWindows
-            os == "Linux" -> SyscallHandlerJnaLinux
-            os == "Mac OS X" -> SyscallHandlerJnaMacos
-            else -> DumbSyscallHandler
-        }
-    } catch (e: UnsatisfiedLinkError) {
-        DumbSyscallHandler
+internal actual fun getStandardTerminalInterface(): TerminalInterface {
+    val providers = ServiceLoader.load(TerminalInterfaceProvider::class.java)
+        .associateBy { it::class.qualifiedName }
+    // The built-in providers in the order that they should be loaded
+    val builtins = listOf(
+        "com.github.ajalt.mordant.terminal.terminalinterface.ffm.TerminalInterfaceProviderFfm",
+        "com.github.ajalt.mordant.terminal.terminalinterface.nativeimage.TerminalInterfaceProviderNativeImage",
+        "com.github.ajalt.mordant.terminal.terminalinterface.jna.TerminalInterfaceProviderJna",
+    )
+
+    // All providers, including user-provided ones
+    val allProviders = builtins + (providers.keys - builtins)
+
+    for (provider in allProviders) {
+        return providers[provider]?.load() ?: continue
     }
+
+    return DumbTerminalInterface
 }
 
 internal actual val CR_IMPLIES_LF: Boolean = false
@@ -160,3 +156,16 @@ internal actual fun readFileIfExists(filename: String): String? {
         null
     }
 }
+
+private object DumbTerminalInterface : StandardTerminalInterface() {
+    override fun stdoutInteractive(): Boolean = true
+    override fun stdinInteractive(): Boolean = true
+    override fun getTerminalSize(): Size? = null
+    override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
+        throw UnsupportedOperationException("Cannot enter raw mode on this system")
+    }
+
+    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
+        throw UnsupportedOperationException("Cannot read input on this system")
+    }
+}
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt
deleted file mode 100644
index 45838c42..00000000
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt
+++ /dev/null
@@ -1,255 +0,0 @@
-package com.github.ajalt.mordant.internal.syscalls.nativeimage
-
-import com.github.ajalt.mordant.internal.Size
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWindows
-import org.graalvm.nativeimage.Platform
-import org.graalvm.nativeimage.Platforms
-import org.graalvm.nativeimage.StackValue
-import org.graalvm.nativeimage.c.CContext
-import org.graalvm.nativeimage.c.constant.CConstant
-import org.graalvm.nativeimage.c.function.CFunction
-import org.graalvm.nativeimage.c.struct.CField
-import org.graalvm.nativeimage.c.struct.CFieldAddress
-import org.graalvm.nativeimage.c.struct.CStruct
-import org.graalvm.nativeimage.c.type.CIntPointer
-import org.graalvm.word.PointerBase
-
-@Platforms(Platform.WINDOWS::class)
-@CContext(WinKernel32Lib.Directives::class)
-@Suppress("FunctionName", "PropertyName", "ClassName")
-private object WinKernel32Lib {
-
-    class Directives : CContext.Directives {
-        override fun getHeaderFiles() = listOf("<Windows.h>")
-    }
-
-    @CConstant("STD_INPUT_HANDLE")
-    external fun STD_INPUT_HANDLE(): Int
-
-    @CConstant("STD_OUTPUT_HANDLE")
-    external fun STD_OUTPUT_HANDLE(): Int
-
-    @CConstant("STD_ERROR_HANDLE")
-    external fun STD_ERROR_HANDLE(): Int
-
-//    @CConstant("ENABLE_PROCESSED_INPUT")
-//    external fun ENABLE_PROCESSED_INPUT(): Int
-
-    @CStruct("CONSOLE_SCREEN_BUFFER_INFO")
-    interface CONSOLE_SCREEN_BUFFER_INFO : PointerBase {
-
-        @get:CField("srWindow.Left")
-        val Left: Short
-
-        @get:CField("srWindow.Top")
-        val Top: Short
-
-        @get:CField("srWindow.Right")
-        val Right: Short
-
-        @get:CField("srWindow.Bottom")
-        val Bottom: Short
-
-    }
-
-//    @CStruct("uChar")
-//    interface UnionChar : PointerBase {
-//        @get:CFieldAddress("UnicodeChar")
-//        val UnicodeChar: Char
-//
-//        @get:CFieldAddress("AsciiChar")
-//        val AsciiChar: Byte
-//    }
-//
-//    @CStruct("COORD")
-//    interface COORD : PointerBase {
-//        @get:CField("X")
-//        var X: Short
-//
-//        @get:CField("Y")
-//        var Y: Short
-//    }
-//
-//    @CStruct("KEY_EVENT_RECORD")
-//    interface KEY_EVENT_RECORD : PointerBase {
-//        @get:CField("bKeyDown")
-//        val bKeyDown: Boolean
-//
-//        @get:CField("wRepeatCount")
-//        val wRepeatCount: Short
-//
-//        @get:CField("wVirtualKeyCode")
-//        val wVirtualKeyCode: Short
-//
-//        @get:CField("wVirtualScanCode")
-//        val wVirtualScanCode: Short
-//
-//        @get:CField("uChar")
-//        val uChar: UnionChar?
-//
-//        @get:CField("dwControlKeyState")
-//        val dwControlKeyState: Int
-//    }
-//
-//    @CStruct("MOUSE_EVENT_RECORD")
-//    interface MOUSE_EVENT_RECORD : PointerBase {
-//        @get:CField("dwMousePosition")
-//        var dwMousePosition: COORD
-//
-//        @get:CField("dwButtonState")
-//        var dwButtonState: Int
-//
-//        @get:CField("dwControlKeyState")
-//        var dwControlKeyState: Int
-//
-//        @get:CField("dwEventFlags")
-//        var dwEventFlags: Int
-//    }
-//
-//    @CStruct("Event")
-//    interface EventUnion : PointerBase {
-//        @get:CFieldAddress("KeyEvent")
-//        val KeyEvent: KEY_EVENT_RECORD
-//
-//        @get:CFieldAddress("MouseEvent")
-//        val MouseEvent: MOUSE_EVENT_RECORD
-//        // ... other fields omitted until we need them
-//    }
-//
-//    @CStruct("INPUT_RECORD")
-//    interface INPUT_RECORD : PointerBase {
-//        companion object {
-//            const val KEY_EVENT: Short = 0x0001
-//            const val MOUSE_EVENT: Short = 0x0002
-//            const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004
-//            const val MENU_EVENT: Short = 0x0008
-//            const val FOCUS_EVENT: Short = 0x0010
-//        }
-//
-//        @get:CField("EventType")
-//        val EventType: Short
-//
-//        @get:CField("Event")
-//        val Event: EventUnion
-//    }
-
-
-    @CFunction("GetStdHandle")
-    external fun GetStdHandle(nStdHandle: Int): PointerBase?
-
-    @CFunction("GetConsoleMode")
-    external fun GetConsoleMode(hConsoleHandle: PointerBase?, lpMode: CIntPointer?): Boolean
-
-    @CFunction("SetConsoleMode")
-    external fun SetConsoleMode(hConsoleHandle: PointerBase?, dwMode: Int): Boolean
-
-    @CFunction("GetConsoleScreenBufferInfo")
-    external fun GetConsoleScreenBufferInfo(
-        hConsoleOutput: PointerBase?,
-        lpConsoleScreenBufferInfo: Long,
-    ): Boolean
-//
-//    @CFunction("WaitForSingleObject")
-//    external fun WaitForSingleObject(hHandle: PointerBase?, dwMilliseconds: Int): Int
-//
-//    @CFunction("ReadConsoleInput")
-//    external fun ReadConsoleInput(
-//        hConsoleOutput: PointerBase?,
-//        lpBuffer: INPUT_RECORD,
-//        nLength: Int,
-//        lpNumberOfEventsRead: CIntPointer?,
-//    )
-}
-
-@Platforms(Platform.WINDOWS::class)
-internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() {
-
-    override fun stdoutInteractive(): Boolean {
-        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE())
-        return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java))
-    }
-
-    override fun stderrInteractive(): Boolean {
-        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE())
-        return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java))
-    }
-
-    override fun stdinInteractive(): Boolean {
-        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
-        return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java))
-    }
-
-    override fun getTerminalSize(): Size? {
-        val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java)
-        val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE())
-        return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) {
-            null
-        } else {
-            Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1)
-        }
-    }
-
-    override fun getStdinConsoleMode(): UInt {
-        throw NotImplementedError(
-            "Raw mode is not currently supported for native-image. If you are familiar with " +
-                    "GraalVM native-image and would like to contribute, see the commented out " +
-                    "code in the file SyscallHandler.nativeimage.posix"
-        )
-//        val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
-//        val lpMode = StackValue.get(CIntPointer::class.java)
-//        if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) {
-//            throw RuntimeException("Error reading console mode")
-//        }
-//        return lpMode.read().toUInt()
-    }
-
-    override fun setStdinConsoleMode(dwMode: UInt) {
-//        val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
-//        if (!WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT())) {
-//            throw RuntimeException("Error setting console mode")
-//        }
-    }
-
-    override fun readRawEvent(dwMilliseconds: Int): EventRecord? {
-        throw NotImplementedError(
-            "Raw mode is not currently supported for native-image. If you are familiar with " +
-                    "GraalVM native-image and would like to contribute, see the commented out " +
-                    "code in the file SyscallHandler.nativeimage.posix"
-        )
-//        val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE())
-//        val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds)
-//        if (waitResult != 0) {
-//            throw RuntimeException("Error reading from console input: waitResult=$waitResult")
-//        }
-//        val inputEvents = StackValue.get(WinKernel32Lib.INPUT_RECORD::class.java)
-//        val eventsRead = StackValue.get(CIntPointer::class.java)
-//        WinKernel32Lib.ReadConsoleInput(stdinHandle, inputEvents, 1, eventsRead)
-//        if (eventsRead.read() == 0) {
-//            throw RuntimeException("Error reading from console input")
-//        }
-//        return when (inputEvents.EventType) {
-//            WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> {
-//                val keyEvent = inputEvents.Event.KeyEvent
-//                EventRecord.Key(
-//                    bKeyDown = keyEvent.bKeyDown,
-//                    wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(),
-//                    uChar = keyEvent.uChar!!.UnicodeChar,
-//                    dwControlKeyState = keyEvent.dwControlKeyState.toUInt(),
-//                )
-//            }
-//
-//            WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> {
-//                val mouseEvent = inputEvents.Event.MouseEvent
-//                EventRecord.Mouse(
-//                    dwMousePositionX = mouseEvent.dwMousePosition.X,
-//                    dwMousePositionY = mouseEvent.dwMousePosition.Y,
-//                    dwButtonState = mouseEvent.dwButtonState.toUInt(),
-//                    dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(),
-//                    dwEventFlags = mouseEvent.dwEventFlags.toUInt(),
-//                )
-//            }
-//
-//            else -> null // Ignore other event types like FOCUS_EVENT that we can't opt out of
-//        }
-    }
-}
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt
similarity index 61%
rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt
rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt
index 0f4bf9db..cccd7b45 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt
@@ -1,9 +1,12 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
 import kotlin.time.ComparableTimeMark
 import kotlin.time.Duration
 
-internal abstract class SyscallHandlerJvmPosix : SyscallHandlerPosix() {
+/**
+ * A base class for terminal interfaces for JVM POSIX systems that uses `System.in` for input.
+ */
+abstract class TerminalInterfaceJvmPosix : TerminalInterfacePosix() {
     override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char {
         do {
             val c = System.`in`.read()
diff --git a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties
deleted file mode 100644
index c0b7416a..00000000
--- a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties
+++ /dev/null
@@ -1,1 +0,0 @@
-Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.MppInternal_jvmKt,com.github.ajalt.mordant.internal.MppInternalKt
diff --git a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt
index db32c0c1..a4865421 100644
--- a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt
+++ b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt
@@ -1,7 +1,7 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandler
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativeLinux
+import com.github.ajalt.mordant.terminal.TerminalInterface
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceNativeLinux
 
 internal actual fun hasFileSystem(): Boolean = true
-internal actual fun getSyscallHandler(): SyscallHandler = SyscallHandlerNativeLinux
+internal actual fun getStandardTerminalInterface(): TerminalInterface = TerminalInterfaceNativeLinux
diff --git a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.linux.kt b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.linux.kt
similarity index 84%
rename from mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.linux.kt
rename to mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.linux.kt
index d7d9b191..0232d8ba 100644
--- a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.linux.kt
+++ b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.linux.kt
@@ -1,6 +1,6 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
-import com.github.ajalt.mordant.internal.Size
+import com.github.ajalt.mordant.rendering.Size
 import kotlinx.cinterop.*
 import platform.posix.*
 
@@ -8,7 +8,7 @@ import platform.posix.*
 // different bit widths for some of the termios fields, so the compileMetadata task would fail if we
 // don't use separate files.
 
-internal object SyscallHandlerNativeLinux : SyscallHandlerNativePosix() {
+internal object TerminalInterfaceNativeLinux : TerminalInterfaceNativePosix() {
     override val termiosConstants: TermiosConstants = TermiosConstants(
         VTIME = VTIME,
         VMIN = VMIN,
@@ -37,7 +37,7 @@ internal object SyscallHandlerNativeLinux : SyscallHandlerNativePosix() {
 
     override fun getStdinTermios(): Termios = memScoped {
         val termios = alloc<termios>()
-        if (tcgetattr(STDIN_FILENO, termios.ptr) != 0) {
+        if (tcgetattr(platform.posix.STDIN_FILENO, termios.ptr) != 0) {
             throw RuntimeException("Error reading terminal attributes")
         }
         return Termios(
@@ -53,7 +53,7 @@ internal object SyscallHandlerNativeLinux : SyscallHandlerNativePosix() {
         val nativeTermios = alloc<termios>()
         // different platforms have different fields in termios, so we need to read the current
         // struct before we set the fields we care about.
-        if (tcgetattr(STDIN_FILENO, nativeTermios.ptr) != 0) {
+        if (tcgetattr(platform.posix.STDIN_FILENO, nativeTermios.ptr) != 0) {
             throw RuntimeException("Error reading terminal attributes")
         }
         nativeTermios.c_iflag = termios.iflag.convert<tcflag_t>()
@@ -61,7 +61,7 @@ internal object SyscallHandlerNativeLinux : SyscallHandlerNativePosix() {
         nativeTermios.c_cflag = termios.cflag.convert<tcflag_t>()
         nativeTermios.c_lflag = termios.lflag.convert<tcflag_t>()
         repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].convert<UByte>() }
-        if (tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) != 0) {
+        if (tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) != 0) {
             throw RuntimeException("Error setting terminal attributes")
         }
     }
diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt
index 329fc316..8ad15cc4 100644
--- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt
+++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt
@@ -1,8 +1,8 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandler
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativeWindows
+import com.github.ajalt.mordant.terminal.TerminalInterface
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceNativeWindows
 
-internal actual fun ttySetEcho(echo: Boolean) = SyscallHandlerNativeWindows.ttySetEcho(echo)
+internal actual fun ttySetEcho(echo: Boolean) = TerminalInterfaceNativeWindows.ttySetEcho(echo)
 internal actual fun hasFileSystem(): Boolean = true
-internal actual fun getSyscallHandler(): SyscallHandler = SyscallHandlerNativeWindows
+internal actual fun getStandardTerminalInterface(): TerminalInterface = TerminalInterfaceNativeWindows
diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.windows.kt
similarity index 92%
rename from mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt
rename to mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.windows.kt
index 09d534e1..023f0286 100644
--- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt
+++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.windows.kt
@@ -1,10 +1,10 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
-import com.github.ajalt.mordant.internal.Size
+import com.github.ajalt.mordant.rendering.Size
 import kotlinx.cinterop.*
 import platform.windows.*
 
-internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() {
+internal object TerminalInterfaceNativeWindows : TerminalInterfaceWindows() {
     // https://docs.microsoft.com/en-us/windows/console/getconsolemode
     override fun stdoutInteractive(): Boolean = memScoped {
         GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), alloc<UIntVar>().ptr) != 0
@@ -14,10 +14,6 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() {
         GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), alloc<UIntVar>().ptr) != 0
     }
 
-    override fun stderrInteractive(): Boolean = memScoped {
-        GetConsoleMode(GetStdHandle(STD_ERROR_HANDLE), alloc<UIntVar>().ptr) != 0
-    }
-
     // https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo
     override fun getTerminalSize(): Size? = memScoped {
         val csbi = alloc<CONSOLE_SCREEN_BUFFER_INFO>()
diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt
index fcfdff7e..278f7cf9 100644
--- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt
+++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt
@@ -1,7 +1,5 @@
 package com.github.ajalt.mordant.input
 
-import com.github.ajalt.mordant.internal.SYSCALL_HANDLER
-import com.github.ajalt.mordant.internal.syscalls.SysInputEvent
 import com.github.ajalt.mordant.terminal.Terminal
 import kotlin.time.Duration
 import kotlin.time.TimeSource
@@ -18,7 +16,7 @@ fun Terminal.enterRawMode(mouseTracking: MouseTracking = MouseTracking.Off): Raw
     if (!info.inputInteractive) {
         throw IllegalStateException("Cannot enter raw mode on a non-interactive terminal")
     }
-    return RawModeScope(SYSCALL_HANDLER.enterRawMode(mouseTracking), mouseTracking)
+    return RawModeScope(this, terminalInterface.enterRawMode(mouseTracking), mouseTracking)
 }
 
 /**
@@ -31,11 +29,12 @@ fun Terminal.enterRawMode(mouseTracking: MouseTracking = MouseTracking.Off): Raw
  */
 fun Terminal.enterRawModeOrNull(mouseTracking: MouseTracking = MouseTracking.Off): RawModeScope? {
     return runCatching {
-        RawModeScope(SYSCALL_HANDLER.enterRawMode(mouseTracking), mouseTracking)
+        RawModeScope(this, terminalInterface.enterRawMode(mouseTracking), mouseTracking)
     }.getOrNull()
 }
 
 class RawModeScope internal constructor(
+    private val terminal: Terminal,
     closeable: AutoCloseable,
     private val mouseTracking: MouseTracking,
 ) : AutoCloseable by closeable {
@@ -56,11 +55,7 @@ class RawModeScope internal constructor(
      * @return The event, or `null` if no event was received before the timeout.
      */
     fun readKeyOrNull(timeout: Duration = Duration.INFINITE): KeyboardEvent? {
-        return readEventsUntilTimeout(timeout) { inputEvent ->
-            if (inputEvent is KeyboardEvent) {
-                return inputEvent
-            }
-        }
+        return readEventsUntilTimeout(timeout) { it !is KeyboardEvent } as KeyboardEvent?
     }
 
     /**
@@ -80,11 +75,7 @@ class RawModeScope internal constructor(
      * @return The event, or `null` if no event was received before the timeout.
      */
     fun readMouseOrNull(timeout: Duration = Duration.INFINITE): MouseEvent? {
-        return readEventsUntilTimeout(timeout) { inputEvent ->
-            if (inputEvent is MouseEvent && mouseTracking != MouseTracking.Off) {
-                return inputEvent
-            }
-        }
+        return readEventsUntilTimeout(timeout) { it !is MouseEvent } as MouseEvent?
     }
 
     /**
@@ -104,19 +95,18 @@ class RawModeScope internal constructor(
      * @return The event, or `null` if no event was received before the timeout.
      */
     fun readEventOrNull(timeout: Duration = Duration.INFINITE): InputEvent? {
-        return readEventsUntilTimeout(timeout) { inputEvent ->
-            if (inputEvent !is MouseEvent || mouseTracking != MouseTracking.Off) {
-                return inputEvent
-            }
-        }
+        return readEventsUntilTimeout(timeout) { false }
     }
 
-    private inline fun readEventsUntilTimeout(timeout: Duration, handler: (InputEvent) -> Unit): Nothing? {
+    private inline fun readEventsUntilTimeout(
+        timeout: Duration, skip: (InputEvent) -> Boolean,
+    ): InputEvent? {
         val t0 = TimeSource.Monotonic.markNow()
         do {
-            val event = SYSCALL_HANDLER.readInputEvent(timeout - t0.elapsedNow(), mouseTracking)
-            if (event is SysInputEvent.Success) {
-                handler(event.event)
+            val event = terminal.terminalInterface
+                .readInputEvent(timeout - t0.elapsedNow(), mouseTracking) ?: continue
+            if (event !is MouseEvent || mouseTracking != MouseTracking.Off || skip(event)) {
+                return event
             }
         } while (t0.elapsedNow() < timeout)
         return null
diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt
similarity index 93%
rename from mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt
rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt
index 3e8f5af0..6be5ab7d 100644
--- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt
+++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt
@@ -1,16 +1,20 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
+import com.github.ajalt.mordant.input.InputEvent
 import com.github.ajalt.mordant.input.KeyboardEvent
 import com.github.ajalt.mordant.input.MouseEvent
 import com.github.ajalt.mordant.input.MouseTracking
 import com.github.ajalt.mordant.internal.CSI
 import com.github.ajalt.mordant.internal.readBytesAsUtf8
+import com.github.ajalt.mordant.terminal.StandardTerminalInterface
 import kotlin.time.ComparableTimeMark
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.TimeSource
 
-internal abstract class SyscallHandlerPosix : SyscallHandler {
+// TODO: docs
+@Suppress("unused", "SpellCheckingInspection")
+abstract class TerminalInterfacePosix : StandardTerminalInterface() {
     protected companion object {
         const val STDIN_FILENO = 0
         const val STDOUT_FILENO = 1
@@ -68,6 +72,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler {
      * The values for these are platform-specific.
      * https://www.man7.org/linux/man-pages/man3/termios.3.html
      */
+    @Suppress("PropertyName")
     data class TermiosConstants(
         val VTIME: Int,
         val VMIN: Int,
@@ -94,7 +99,6 @@ internal abstract class SyscallHandlerPosix : SyscallHandler {
 
     override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO)
     override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO)
-    override fun stderrInteractive(): Boolean = isatty(STDERR_FILENO)
 
     // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html
     // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking
@@ -129,7 +133,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler {
       Some patterns seen in terminal key escape codes, derived from combos seen
       at https://github.com/nodejs/node/blob/main/lib/internal/readline/utils.js
     */
-    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent {
+    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
         val t0 = TimeSource.Monotonic.markNow()
         var ctrl = false
         var alt = false
@@ -151,10 +155,10 @@ internal abstract class SyscallHandlerPosix : SyscallHandler {
             try {
                 read()
             } catch (e: RuntimeException) {
-                return SysInputEvent.Success(KeyboardEvent("Escape"))
+                return KeyboardEvent("Escape")
             }
             if (ch == ESC) {
-                return SysInputEvent.Success(KeyboardEvent("Escape"))
+                return KeyboardEvent("Escape")
             }
         }
 
@@ -440,17 +444,15 @@ internal abstract class SyscallHandlerPosix : SyscallHandler {
             alt = true
         }
 
-        return SysInputEvent.Success(
-            KeyboardEvent(
-                key = name ?: ch.toString(),
-                ctrl = ctrl,
-                alt = alt,
-                shift = shift,
-            )
+        return KeyboardEvent(
+            key = name ?: ch.toString(),
+            ctrl = ctrl,
+            alt = alt,
+            shift = shift,
         )
     }
 
-    private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): SysInputEvent {
+    private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): InputEvent {
         // Mouse event coordinates are raw values, not decimal text, and they're sometimes utf-8
         // encoded to fit larger values.
         val cb = readUtf8Byte(t0, timeout)
@@ -465,20 +467,18 @@ internal abstract class SyscallHandlerPosix : SyscallHandler {
         val alt = (cb and 8) != 0
         val ctrl = (cb and 16) != 0
         // cb == 3 means "a button was released", but there's no way to know which one -_-
-        return SysInputEvent.Success(
-            MouseEvent(
-                x = cx,
-                y = cy,
-                // On button-motion events, xterm adds 32 to cb
-                left = cb and 3 == 0,
-                right = cb and 3 == 1,
-                middle = cb and 3 == 2,
-                wheelUp = cb == 64,
-                wheelDown = cb == 65,
-                ctrl = ctrl,
-                alt = alt,
-                shift = shift,
-            )
+        return MouseEvent(
+            x = cx,
+            y = cy,
+            // On button-motion events, xterm adds 32 to cb
+            left = cb and 3 == 0,
+            right = cb and 3 == 1,
+            middle = cb and 3 == 2,
+            wheelUp = cb == 64,
+            wheelDown = cb == 65,
+            ctrl = ctrl,
+            alt = alt,
+            shift = shift,
         )
     }
 
diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.windows.kt
similarity index 70%
rename from mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt
rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.windows.kt
index ac97a3f6..2c0c99c5 100644
--- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt
+++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.windows.kt
@@ -1,12 +1,15 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
+import com.github.ajalt.mordant.input.InputEvent
 import com.github.ajalt.mordant.input.KeyboardEvent
 import com.github.ajalt.mordant.input.MouseEvent
 import com.github.ajalt.mordant.input.MouseTracking
+import com.github.ajalt.mordant.terminal.StandardTerminalInterface
 import kotlin.time.Duration
 import kotlin.time.TimeSource
 
-internal abstract class SyscallHandlerWindows : SyscallHandler {
+// TODO: docs
+abstract class TerminalInterfaceWindows: StandardTerminalInterface() {
     private companion object {
         // https://learn.microsoft.com/en-us/windows/console/key-event-record-str
         const val RIGHT_ALT_PRESSED: UInt = 0x0001u
@@ -70,19 +73,19 @@ internal abstract class SyscallHandlerWindows : SyscallHandler {
         return AutoCloseable { setStdinConsoleMode(originalMode) }
     }
 
-    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent {
+    override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
         val t0 = TimeSource.Monotonic.markNow()
         val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds
             .coerceIn(0, Int.MAX_VALUE.toLong()).toInt()
         return when (val event = readRawEvent(dwMilliseconds)) {
-            null -> SysInputEvent.Retry
+            null -> null
             is EventRecord.Key -> processKeyEvent(event)
             is EventRecord.Mouse -> processMouseEvent(event, mouseTracking)
         }
     }
 
-    private fun processKeyEvent(event: EventRecord.Key): SysInputEvent {
-        if (!event.bKeyDown) return SysInputEvent.Retry // ignore key up events
+    private fun processKeyEvent(event: EventRecord.Key): InputEvent? {
+        if (!event.bKeyDown) return null // ignore key up events
         val virtualName = WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode)
         val shift = event.dwControlKeyState and SHIFT_PRESSED != 0u
         val key = when {
@@ -95,48 +98,44 @@ internal abstract class SyscallHandlerWindows : SyscallHandler {
             event.uChar.code != 0 -> event.uChar.toString()
             else -> "Unidentified"
         }
-        return SysInputEvent.Success(
-            KeyboardEvent(
-                key = key,
-                ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u,
-                alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u,
-                shift = shift,
-            )
+        return KeyboardEvent(
+            key = key,
+            ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u,
+            alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u,
+            shift = shift,
         )
     }
 
     private fun processMouseEvent(
         event: EventRecord.Mouse,
         tracking: MouseTracking,
-    ): SysInputEvent {
+    ): InputEvent? {
         val eventFlags = event.dwEventFlags
         val buttons = event.dwButtonState.toInt()
         if (tracking == MouseTracking.Off
             || tracking == MouseTracking.Normal && eventFlags == MOUSE_MOVED
             || tracking == MouseTracking.Button && eventFlags == MOUSE_MOVED && buttons == 0
-        ) return SysInputEvent.Retry
+        ) return null
 
-        return SysInputEvent.Success(
-            MouseEvent(
-                x = event.dwMousePositionX.toInt(),
-                y = event.dwMousePositionY.toInt(),
-                left = buttons and FROM_LEFT_1ST_BUTTON_PRESSED != 0,
-                right = buttons and RIGHTMOST_BUTTON_PRESSED != 0,
-                middle = buttons and FROM_LEFT_2ND_BUTTON_PRESSED != 0,
-                mouse4 = buttons and FROM_LEFT_3RD_BUTTON_PRESSED != 0,
-                mouse5 = buttons and FROM_LEFT_4TH_BUTTON_PRESSED != 0,
-                // If the high word of the dwButtonState member contains a positive value, the wheel
-                // was rotated forward, away from the user.
-                wheelUp = eventFlags and MOUSE_WHEELED != 0u && buttons shr 16 > 0,
-                wheelDown = eventFlags and MOUSE_WHEELED != 0u && buttons shr 16 <= 0,
-                // If the high word of the dwButtonState member contains a positive value, the wheel
-                // was rotated to the right.
-                wheelLeft = eventFlags and MOUSE_HWHEELED != 0u && buttons shr 16 <= 0,
-                wheelRight = eventFlags and MOUSE_HWHEELED != 0u && buttons shr 16 > 0,
-                ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u,
-                alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u,
-                shift = event.dwControlKeyState and SHIFT_PRESSED != 0u,
-            )
+        return MouseEvent(
+            x = event.dwMousePositionX.toInt(),
+            y = event.dwMousePositionY.toInt(),
+            left = buttons and FROM_LEFT_1ST_BUTTON_PRESSED != 0,
+            right = buttons and RIGHTMOST_BUTTON_PRESSED != 0,
+            middle = buttons and FROM_LEFT_2ND_BUTTON_PRESSED != 0,
+            mouse4 = buttons and FROM_LEFT_3RD_BUTTON_PRESSED != 0,
+            mouse5 = buttons and FROM_LEFT_4TH_BUTTON_PRESSED != 0,
+            // If the high word of the dwButtonState member contains a positive value, the wheel
+            // was rotated forward, away from the user.
+            wheelUp = eventFlags and MOUSE_WHEELED != 0u && buttons shr 16 > 0,
+            wheelDown = eventFlags and MOUSE_WHEELED != 0u && buttons shr 16 <= 0,
+            // If the high word of the dwButtonState member contains a positive value, the wheel
+            // was rotated to the right.
+            wheelLeft = eventFlags and MOUSE_HWHEELED != 0u && buttons shr 16 <= 0,
+            wheelRight = eventFlags and MOUSE_HWHEELED != 0u && buttons shr 16 > 0,
+            ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u,
+            alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u,
+            shift = event.dwControlKeyState and SHIFT_PRESSED != 0u,
         )
     }
 }
diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/WindowsVirtualKeyCodeToKeyEvent.kt
similarity index 99%
rename from mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt
rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/WindowsVirtualKeyCodeToKeyEvent.kt
index c76a314e..8c253086 100644
--- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt
+++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/WindowsVirtualKeyCodeToKeyEvent.kt
@@ -1,4 +1,4 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
 // https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
 // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values
diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt
index d6805ed7..4015feb9 100644
--- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt
+++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt
@@ -1,11 +1,11 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerPosix
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfacePosix
 import platform.posix.ECHO
 
 // https://www.gnu.org/software/libc/manual/html_node/getpass.html
 internal actual fun ttySetEcho(echo: Boolean) {
-    val handlerPosix = SYSCALL_HANDLER as SyscallHandlerPosix
+    val handlerPosix = STANDARD_TERM_INTERFACE as TerminalInterfacePosix
     val termios = handlerPosix.getStdinTermios()
     handlerPosix.setStdinTermios(
         termios.copy(
diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt
similarity index 83%
rename from mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt
rename to mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt
index a9efc4b4..8a897e90 100644
--- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt
+++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt
@@ -1,11 +1,11 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
 import kotlinx.cinterop.*
 import platform.posix.read
 import kotlin.time.ComparableTimeMark
 import kotlin.time.Duration
 
-internal abstract class SyscallHandlerNativePosix : SyscallHandlerPosix() {
+internal abstract class TerminalInterfaceNativePosix : TerminalInterfacePosix() {
     override fun isatty(fd: Int): Boolean {
         return platform.posix.isatty(fd) != 0
     }
diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt
index 9fe5e9d4..beae04a2 100644
--- a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt
+++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt
@@ -1,12 +1,12 @@
 package com.github.ajalt.mordant.internal
 
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsCommon
-import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWasm
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsCommon
+import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceWasm
 
 internal actual fun browserPrintln(message: String): Unit = js("console.error(message)")
 
-internal actual fun makeNodeSyscallHandler(): SyscallHandlerJsCommon? {
-    return if (runningOnNode()) SyscallHandlerWasm() else null
+internal actual fun makeNodeTerminalInterface(): TerminalInterfaceJsCommon? {
+    return if (runningOnNode()) TerminalInterfaceWasm() else null
 }
 
 private external interface CodePointString {
diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.wasm.kt
similarity index 93%
rename from mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt
rename to mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.wasm.kt
index 2ebb985e..fef7df35 100644
--- a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt
+++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.wasm.kt
@@ -1,6 +1,6 @@
-package com.github.ajalt.mordant.internal.syscalls
+package com.github.ajalt.mordant.terminal.terminalinterface
 
-import com.github.ajalt.mordant.internal.Size
+import com.github.ajalt.mordant.rendering.Size
 import com.github.ajalt.mordant.terminal.PrintTerminalCursor
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.terminal.TerminalCursor
@@ -53,13 +53,12 @@ private fun nodeReadFileSync(filename: String): String? =
     )
 
 
-internal class SyscallHandlerWasm : SyscallHandlerNode<JsAny>() {
+internal class TerminalInterfaceWasm : TerminalInterfaceNode<JsAny>() {
     private val fs: FsModule = importNodeFsModule()
 
     override fun readEnvvar(key: String): String? = nodeReadEnvvar(key)
     override fun stdoutInteractive(): Boolean = process.stdout.isTTY
     override fun stdinInteractive(): Boolean = process.stdin.isTTY
-    override fun stderrInteractive(): Boolean = process.stderr.isTTY
     override fun exitProcess(status: Int): Unit = process.exit(status)
     override fun getTerminalSize(): Size? {
         if (!nodeWidowSizeIsDefined()) return null
diff --git a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
index 7e0e6d5c..97bc0615 100644
--- a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
+++ b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt
@@ -24,8 +24,8 @@ import kotlinx.coroutines.launch
 suspend fun main() = coroutineScope {
     val terminal = Terminal(ansiLevel = AnsiLevel.TRUECOLOR, interactive = true)
     var hue = 0
-    val canvas = List(terminal.info.height - 1) {
-        MutableList<Color>(terminal.info.width) { RGB("#000") }
+    val canvas = List(terminal.size.height - 1) {
+        MutableList<Color>(terminal.size.width) { RGB("#000") }
     }
     val animation = terminal.textAnimation<Unit> {
         buildString {
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 03054663..6dff601f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -2,6 +2,10 @@ rootProject.name = "mordant"
 
 include(
     "mordant",
+    "mordant-omnibus",
+    "mordant-jvm-jna",
+    "mordant-jvm-ffm",
+    "mordant-jvm-graal-ffi",
     "extensions:mordant-coroutines",
     "samples:detection",
     "samples:drawing",


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt
index 6fd23542..384e5fd2 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/animation/AnimationTest.kt
@@ -1,6 +1,5 @@
 package com.github.ajalt.mordant.animation
 
-import com.github.ajalt.mordant.internal.CSI
 import com.github.ajalt.mordant.terminal.Terminal
 import com.github.ajalt.mordant.terminal.TerminalRecorder
 import com.github.ajalt.mordant.test.normalizedOutput
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
index 867c5397..30ca4c2f 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalColorsTest.kt
@@ -9,7 +9,6 @@ import io.kotest.data.blocking.forAll
 import io.kotest.data.row
 import io.kotest.matchers.shouldBe
 import kotlin.js.JsName
-import kotlin.test.Ignore
 import kotlin.test.Test
 
 @Suppress("DEPRECATION")
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt
index ce8af9ae..6e9a1323 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt
@@ -4,7 +4,6 @@ import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.rendering.Theme
 import com.github.ajalt.mordant.rendering.Widget
 import com.github.ajalt.mordant.terminal.Terminal
-import io.kotest.matchers.shouldBe
 
 abstract class RenderingTest(
     private val width: Int = 79,
diff --git a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt
index 46b2248e..32b82094 100644
--- a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt
+++ b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt
@@ -8,8 +8,6 @@ import com.github.ajalt.mordant.widgets.progress.completed
 import com.github.ajalt.mordant.widgets.progress.progressBarLayout
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.string.shouldContain
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
 import kotlin.test.Test
 
 private const val HIDE_CURSOR = "$CSI?25l"
diff --git a/test/graalvm/build.gradle.kts b/test/graalvm/build.gradle.kts
index 39510230..cd8f71fc 100644
--- a/test/graalvm/build.gradle.kts
+++ b/test/graalvm/build.gradle.kts
@@ -8,7 +8,7 @@ tasks.test {
 }
 
 dependencies {
-    implementation(project(":mordant"))
+    implementation(project(":mordant-omnibus"))
     testImplementation(kotlin("test"))
 }
 
diff --git a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
index 06fb9314..5614de69 100644
--- a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
+++ b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt
@@ -2,7 +2,6 @@ package com.github.ajalt.mordant.graalvm
 
 import com.github.ajalt.mordant.animation.progress.animateOnThread
 import com.github.ajalt.mordant.animation.progress.execute
-import com.github.ajalt.mordant.input.enterRawMode
 import com.github.ajalt.mordant.markdown.Markdown
 import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.rendering.TextStyles.bold
@@ -12,21 +11,25 @@ import com.github.ajalt.mordant.widgets.progress.progressBar
 import com.github.ajalt.mordant.widgets.progress.progressBarLayout
 import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.condition.EnabledInNativeImage
 import java.util.concurrent.TimeUnit
-import kotlin.test.Ignore
-import kotlin.test.assertNull
-import kotlin.time.Duration.Companion.milliseconds
+import kotlin.test.fail
 
 /**
  * Smoke tests for the GraalVM platform.
  *
  * They just make sure nothing crashes; the actual output is verified in the normal test suite.
  */
+@EnabledInNativeImage
 class GraalSmokeTest {
     @Test
     fun `terminal detection test`() {
+        val name = Terminal().terminalInterface::class.simpleName
         Terminal()
+        val assertion = name!!.startsWith("TerminalInterfaceNativeImage")
+        if (!assertion) {
+            fail("Incorrect terminal interface: $name")
+        }
     }
 
     @Test