Repository: ajalt/mordant
Pull request #61 (2022-06-11 18:01:16)

Add support for prompts


Patch:
diff --git a/.gitignore b/.gitignore
index e37a82d1..75734f44 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ docs/api/
 docs/changelog.md
 docs/index.md
 site/
+kotlin-js-store/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1c97518..a0239060 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,12 +1,21 @@
 # Changelog
 
 ## Unreleased
+### Added
+- Functionality for reading user input: `Terminal.readLineOrNull`, `Terminal.prompt` and various `Prompt` classes
+- `TerminalRecorder` that saves output to memory rather than printing it.
+- `TerminalRecorder.outputAsHtml()` that can render recorded output as an html file.
+- 
 ### Changed
 - When building tables, `borders` has been renamed `cellBorders`, and `outerBorder: Boolean` has been replaced with `tableBorders: Borders?`, which allows more control over the table's outside borders. [(#58)](https://github.com/ajalt/mordant/issues/58)
+- Update Kotlin to 1.7.0
 
 ### Fixed
 - Avoid clobbering output when using `Terminal.forStdErr` while an animation is running. [(#54)](https://github.com/ajalt/mordant/issues/54)
 
+### Deprecated
+- Deprecated the `VirtualTerminalInterface`. Use `TerminalRecorder` instead.
+
 ## 2.0.0-beta6
 ### Changed
 - Update Kotlin to 1.6.20
diff --git a/README.md b/README.md
index 0cd03d76..c77edb29 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,26 @@ manually.
 Call `progress.start` to animate the progress, and `progress.update` or `progress.advance` as your
 task completes.
 
+## Prompting for input
+
+You can ask the user to enter text and wait for a response with `Terminal.prompt`:
+
+```kotlin
+val t = Terminal()
+val response = t.prompt("Choose a size", choices=listOf("small", "large"))
+t.println("You chose: $response")
+```
+
+```text
+$ ./example
+Choose a size [small, large]: small
+You chose: small
+```
+
+You can customize the prompt behavior further or convert the response to other types 
+creating a subclass of the `Prompt` class.
+
+
 ## Installation
 
 Mordant is distributed through Maven Central.
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
index b7f62637..880bb2c2 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppH.kt
@@ -33,6 +33,8 @@ internal expect fun makePrintingTerminalCursor(terminal: Terminal): TerminalCurs
 
 internal expect fun printStderr(message: String, newline: Boolean)
 
+internal expect fun readLineOrNullMpp(hideInput: Boolean): String?
+
 @OptIn(ExperimentalTerminalApi::class)
 internal expect fun sendInterceptedPrintRequest(
     request: PrintRequest,
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt
index 7b5ef8a1..fad79fb9 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Parsing.kt
@@ -17,8 +17,8 @@ private data class Chunk(val text: String, val style: TextStyle)
  *
  * Unknown ANSI codes are discarded.
  */
-internal fun parseText(text: String, style: TextStyle): Lines {
-    val parseAnsi = parseAnsi(text, style)
+internal fun parseText(text: String, defaultStyle: TextStyle): Lines {
+    val parseAnsi = parseAnsi(text, defaultStyle)
     val words = parseAnsi.flatMap { splitWords(it) }.toList()
     val splitLines = splitLines(words)
     return Lines(splitLines)
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt
index 62ec9ffc..55a9733c 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt
@@ -30,6 +30,11 @@ sealed class Theme(
                 "hr.rule" to DEFAULT_STYLE,
                 "panel.border" to DEFAULT_STYLE,
 
+                "prompt.prompt" to DEFAULT_STYLE,
+                "prompt.default" to TextStyle(DEFAULT_HIGHLIGHT, bold = true),
+                "prompt.choices" to TextStyle(DEFAULT_HIGHLIGHT, bold = true),
+                "prompt.choices.invalid" to TextStyle(DEFAULT_RED),
+
                 "progressbar.pending" to TextStyle(DEFAULT_GRAY),
                 "progressbar.complete" to TextStyle(DEFAULT_HIGHLIGHT),
                 "progressbar.indeterminate" to TextStyle(DEFAULT_HIGHLIGHT),
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt
index 8104b27a..0cf2cfdf 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableCsv.kt
@@ -4,7 +4,7 @@ import com.github.ajalt.mordant.rendering.AnsiLevel
 import com.github.ajalt.mordant.rendering.TextAlign
 import com.github.ajalt.mordant.terminal.ExperimentalTerminalApi
 import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.terminal.VirtualTerminalInterface
+import com.github.ajalt.mordant.terminal.TerminalRecorder
 import com.github.ajalt.mordant.widgets.Padded
 import com.github.ajalt.mordant.widgets.Text
 import com.github.ajalt.mordant.widgets.withAlign
@@ -95,7 +95,7 @@ private fun Table.getContentRows(): List<List<String>> {
     }
 
     val t = Terminal(
-        terminalInterface = VirtualTerminalInterface(
+        terminalInterface = TerminalRecorder(
             ansiLevel = AnsiLevel.NONE,
             width = Int.MAX_VALUE,
             height = Int.MAX_VALUE,
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt
index def5f44f..34462891 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/TableLayout.kt
@@ -76,8 +76,10 @@ internal class TableLayout(private val table: TableBuilderInstance) {
         val textAlign = getStyle(TextAlign.LEFT) { it.align }
         val verticalAlign = getStyle(VerticalAlign.TOP) { it.verticalAlign }
         val overflowWrap = getStyle(OverflowWrap.ELLIPSES) { it.overflowWrap }
-        val stripedStyle =
-            if (section.rowStyles.isNotEmpty()) section.rowStyles[startingY % section.rowStyles.size] else null
+        val stripedStyle = when {
+            section.rowStyles.isNotEmpty() -> section.rowStyles[startingY % section.rowStyles.size]
+            else -> null
+        }
         val style = foldStyles(
             cell.style,
             row.style,
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt
new file mode 100644
index 00000000..bbc0f6ef
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/HtmlRenderer.kt
@@ -0,0 +1,71 @@
+package com.github.ajalt.mordant.terminal
+
+import com.github.ajalt.mordant.internal.DEFAULT_STYLE
+import com.github.ajalt.mordant.internal.parseText
+import com.github.ajalt.mordant.rendering.TextStyle
+
+@ExperimentalTerminalApi
+fun TerminalRecorder.outputAsHtml(): String = buildString {
+    val lines = parseText(output(), DEFAULT_STYLE)
+
+    appendLine("<html><body>")
+    // font-family from https://systemfontstack.com/
+    append("""<pre style="font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace">""")
+    appendLine("<code>")
+
+    for (line in lines.lines) {
+        for (span in line) {
+            val href = span.style.hyperlink
+            if (href != null) {
+                append("""<a href="""").append(href).append('"')
+            } else {
+                append("<span")
+            }
+            val rules = span.style.asCssRules()
+            if (rules.isNotEmpty()) {
+                rules.joinTo(this, "; ", prefix = " style=\"", postfix = "\"")
+            }
+            append(">")
+            append(span.text.escapeHtml()).append("</span>")
+        }
+        appendLine()
+    }
+
+    appendLine("</code></pre>")
+    append("</body></html>")
+}
+
+private fun TextStyle.asCssRules(): List<String> {
+    val rules = mutableListOf<String>()
+    val (fg, bg) = if (inverse) bgColor to color else color to bgColor
+    fg?.let {
+        rules.add("color: ${it.toSRGB().toHex()}")
+    }
+    bg?.let {
+        rules.add("background-color: ${it.toSRGB().toHex()}")
+    }
+    if (bold) {
+        rules.add("font-weight: bold")
+    }
+    if (italic) {
+        rules.add("font-style: italic")
+    }
+    if (underline) {
+        rules.add("text-decoration: underline")
+    }
+    if (dim) {
+        rules.add("opacity: 0.5")
+    }
+    if (strikethrough) {
+        rules.add("text-decoration: line-through")
+    }
+    return rules
+}
+
+private fun String.escapeHtml(): String {
+    return replace("&", "&amp;")
+        .replace("<", "&lt;")
+        .replace(">", "&gt;")
+        .replace("\"", "&quot;")
+        .replace("\\", "&#x27;")
+}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Prompt.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Prompt.kt
new file mode 100644
index 00000000..d9139bca
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Prompt.kt
@@ -0,0 +1,237 @@
+package com.github.ajalt.mordant.terminal
+
+import com.github.ajalt.mordant.rendering.Widget
+import com.github.ajalt.mordant.widgets.Text
+
+sealed class ConversionResult<out T> {
+    data class Valid<T>(val value: T) : ConversionResult<T>()
+    data class Invalid(val message: String) : ConversionResult<Nothing>()
+}
+
+/**
+ * The base class for prompts.
+ *
+ * [ask] will print the [prompt][makePrompt] and ask for a line of user input and return the result of passing that line
+ * to [convert]. If [convert] instead raises [PromptInputInvalid], its message will be printed and the user will be
+ * asked for input again.
+ *
+ *
+ * ### Themes used
+ *  - `prompt.prompt`: applied to the [prompt] string
+ *  - `prompt.choices`: applied to the rendered [choices] values when shown in the prompt
+ *  - `prompt.default`: applied to the rendered [default] value when shown in the prompt
+ *  - `prompt.choices.invalid`: applied to the [invalidChoiceMessage] when shown
+ *  - `danger`: applied to the [error message][ConversionResult.Invalid.message] returned by [convert]
+ *
+ * @property prompt The message asking for input to show the user
+ * @property terminal The terminal to use
+ * @property default The value to return if the user enters an empty line, or `null` to require a value
+ * @property showDefault If true and a [default] is specified, [makePrompt] will add the
+ *   [rendered][renderValue] default to the prompt
+ * @property showChoices If true and [choices] are specified, [makePrompt] will add the
+ *   [rendered][renderValue] choices to the prompt
+ * @property hideInput If true, the user's input will be treated like a password and hidden from
+ *   the screen. This value will be ignored on platforms where it is not supported.
+ * @property choices The set of values that the user must choose from.
+ * @property promptSuffix A string to append after [prompt] when showing the user the prompt
+ * @property invalidChoiceMessage The message to show the user if [choices] is specified,
+ *   and they enter a value that isn't one of the choices.
+ */
+abstract class Prompt<T>(
+    protected val prompt: String,
+    protected val terminal: Terminal,
+    protected val default: T? = null,
+    protected val showDefault: Boolean = true,
+    protected val showChoices: Boolean = true,
+    protected val hideInput: Boolean = false,
+    protected val choices: Collection<T> = emptyList(),
+    protected val promptSuffix: String = ": ",
+    protected val invalidChoiceMessage: String = "Invalid value, choose from ",
+) {
+    /**
+     * Convert the string entered by the user into the final type
+     *
+     * Returns a [Valid][ConversionResult.Valid] result with the converted value or an
+     * [Invalid][ConversionResult.Invalid] with an error message.
+     */
+    abstract protected fun convert(input: String): ConversionResult<T>
+
+
+    /**
+     * Convert a value of this type to a string to show the user.
+     *
+     * This is called when displaying the [default] or [choices].
+     */
+    open protected fun renderValue(value: T): String = value.toString()
+
+    /**
+     * Given a [prompt] string, return the widget to show to the user.
+     */
+    open protected fun makePrompt(): Widget {
+        return Text(buildString {
+            append(terminal.theme.style("prompt.prompt")(prompt))
+            if (showChoices && choices.isNotEmpty()) {
+                append(" ")
+                append(terminal.theme.style("prompt.choices")(makePromptChoices()))
+            }
+
+            if (showDefault && default != null) {
+                append(" ")
+                append(terminal.theme.style("prompt.default")(makePromptDefault(default)))
+            }
+            append(promptSuffix)
+        })
+    }
+
+    /**  Return a string to add to the prompt to show the user the default. */
+    open protected fun makePromptDefault(default: T) = "(${renderValue(default)})"
+
+    /**  Return a string to add to the prompt to show the user the choices. */
+    open protected fun makePromptChoices() = choices.joinToString(prefix = "[", postfix = "]") { renderValue(it) }
+
+    /**
+     * Create the message to show the user when [choices] is defined and the entered value isn't a valid choice
+     */
+    open protected fun makeInvalidChoiceMessage(): Widget {
+        return Text(terminal.theme.style("prompt.choices.invalid")(buildString {
+            append(invalidChoiceMessage)
+            append(makePromptChoices())
+        }))
+    }
+
+    /**
+     * Called before the prompt is printed.
+     *
+     * Does nothing by default.
+     */
+    protected fun beforePrompt() {}
+
+    /**
+     * Called when [convert] raises a [PromptInputInvalid] with the exception's [message].
+     *
+     * By default, this prints the [message] with the `danger` style
+     */
+    protected fun handleInvalidInput(message: String) {
+        if (message.isNotBlank()) {
+            terminal.danger(message)
+        }
+    }
+
+    /**
+     * Run the prompt, asking the user for input.
+     *
+     * @return The [converted][convert] user input, or `null` if EOF was reached before this function was called.
+     */
+    open fun ask(): T? {
+        while (true) {
+            beforePrompt()
+            terminal.print(makePrompt())
+            val line = terminal.readLineOrNull(hideInput) ?: return null
+            if (line.isEmpty() && default != null) {
+                return default
+            }
+            val value = when (val result = convert(line)) {
+                is ConversionResult.Valid<T> -> result.value
+                is ConversionResult.Invalid -> {
+                    handleInvalidInput(result.message)
+                    continue
+                }
+            }
+
+            if (choices.isNotEmpty() && value !in choices) {
+                terminal.println(makeInvalidChoiceMessage())
+                continue
+            }
+
+            return value
+        }
+    }
+}
+
+/**
+ * A [Prompt] that returns the user input unchanged.
+ *
+ * @property allowBlank If false, the user will be prompted for a new input if they enter a blank value
+ */
+class StringPrompt(
+    prompt: String,
+    terminal: Terminal,
+    default: String? = null,
+    showDefault: Boolean = !default.isNullOrBlank(),
+    showChoices: Boolean = true,
+    hideInput: Boolean = false,
+    choices: List<String> = emptyList(),
+    promptSuffix: String = ": ",
+    invalidChoiceMessage: String = "Invalid value, choose from ",
+    private val allowBlank: Boolean = true,
+) : Prompt<String>(
+    prompt,
+    terminal,
+    default,
+    showDefault,
+    showChoices,
+    hideInput,
+    choices,
+    promptSuffix,
+    invalidChoiceMessage
+) {
+    override fun convert(input: String): ConversionResult<String> {
+        if (!allowBlank && input.isBlank()) {
+            return ConversionResult.Invalid("")
+        }
+        return ConversionResult.Valid(input)
+    }
+}
+
+/**
+ * A boolean prompt that asks for a yes or no response.
+ *
+ * @param prompt The message asking for input to show the user
+ * @param terminal The terminal to use
+ * @param default The value to return if the user enters an empty line, or `null` to require a value
+ * @param uppercaseDefault If true and [default] is not `null`, the default choice will be shown in uppercase.
+ * @param showChoices If true, the choices will be added to the [prompt]
+ * @param choiceStrings The strings to accept for `true` and `false` inputs
+ * @param promptSuffix A string to append after [prompt] when showing the user the prompt
+ * @param invalidChoiceMessage The message to show the user if they enter a value that isn't one of the [choiceStrings].
+ */
+class YesNoPrompt(
+    prompt: String,
+    terminal: Terminal,
+    default: Boolean? = null,
+    private val uppercaseDefault: Boolean = true,
+    showChoices: Boolean = true,
+    private val choiceStrings: List<String> = listOf("y", "n"),
+    promptSuffix: String = ": ",
+    invalidChoiceMessage: String = "Invalid value, choose from ",
+) : Prompt<Boolean>(
+    prompt,
+    terminal,
+    default,
+    false,
+    showChoices,
+    false,
+    listOf(true, false),
+    promptSuffix,
+    invalidChoiceMessage
+) {
+    override fun convert(input: String): ConversionResult<Boolean> {
+        return when (input.trim().lowercase()) {
+            choiceStrings[0] -> ConversionResult.Valid(true)
+            choiceStrings[1] -> ConversionResult.Valid(false)
+            else -> ConversionResult.Invalid(buildString {
+                append(invalidChoiceMessage)
+                append(makePromptChoices())
+            })
+        }
+    }
+
+    override fun makePromptChoices(): String {
+        return choices.joinToString("/", prefix = "[", postfix = "]") { renderValue(it) }
+    }
+
+    override fun renderValue(value: Boolean): String {
+        val s = if (value) choiceStrings[0] else choiceStrings[1]
+        return if (uppercaseDefault && value == default) s.uppercase() else s
+    }
+}
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
index d00ba908..e4d86861 100644
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StdoutTerminalInterface.kt
@@ -1,6 +1,7 @@
 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
 
 @ExperimentalTerminalApi
@@ -38,4 +39,6 @@ internal class StdoutTerminalInterface private constructor(
     override fun forStdErr(): TerminalInterface {
         return StdoutTerminalInterface(info, errInfo, true)
     }
+
+    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 2ae32a90..bbf4d493 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
@@ -260,6 +260,79 @@ class Terminal private constructor(
         return Terminal(theme, tabWidth, terminalInterface.forStdErr(), interceptors, lock)
     }
 
+    /**
+     * Read a line of input from stdin.
+     *
+     * This is similar to [readlnOrNull], but is supported on NodeJS, and uses this terminal's
+     * [interface][TerminalInterface.readLineOrNull], so it can be customized or mocked for testing.
+     */
+    fun readLineOrNull(hideInput: Boolean): String? {
+        return terminalInterface.readLineOrNull(hideInput)
+    }
+
+    inline fun <T> prompt(
+        prompt: String,
+        default: T? = null,
+        showDefault: Boolean = true,
+        showChoices: Boolean = true,
+        hideInput: Boolean = false,
+        choices: Collection<T> = emptyList(),
+        promptSuffix: String = ": ",
+        invalidChoiceMessage: String = "Invalid value, choose from ",
+        crossinline convert: (String) -> ConversionResult<T>,
+    ): T? {
+        return object : Prompt<T>(
+            prompt = prompt,
+            terminal = this,
+            default = default,
+            showDefault = showDefault,
+            showChoices = showChoices,
+            hideInput = hideInput,
+            choices = choices,
+            promptSuffix = promptSuffix,
+            invalidChoiceMessage = invalidChoiceMessage,
+        ) {
+            override fun convert(input: String): ConversionResult<T> {
+                return convert(input)
+            }
+        }.ask()
+    }
+
+    /**
+     * Print a [prompt] to the user and return the value they enter.
+     *
+     * To customize the prompt behavior, see the [Prompt] class.
+     *
+     * @param prompt The message asking for input to show the user
+     * @param default The value to return if the user enters an empty line, or `null` to require a value
+     * @param showDefault If true and a [default] is specified, add the default value to the prompt
+     * @param hideInput If true, the user's input will be treated like a password and hidden from
+     *   the screen. This value will be ignored on platforms where it is not supported.
+     * @param choices The set of values that the user must choose from.
+     * @param promptSuffix A string to append after [prompt] when showing the user the prompt
+     * @param invalidChoiceMessage The message to show the user if [choices] is specified,
+     *   and they enter a value that isn't one of the choices.
+     */
+    fun prompt(
+        prompt: String,
+        default: String? = null,
+        showDefault: Boolean = true,
+        showChoices: Boolean = true,
+        hideInput: Boolean = false,
+        choices: Collection<String> = emptyList(),
+        promptSuffix: String = ": ",
+        invalidChoiceMessage: String = "Invalid value, choose from ",
+    ): String? = prompt(
+        prompt,
+        default,
+        showDefault,
+        showChoices,
+        hideInput,
+        choices,
+        promptSuffix,
+        invalidChoiceMessage
+    ) { ConversionResult.Valid(it) }
+
     internal fun addInterceptor(interceptor: TerminalInterceptor) {
         synchronizeJvm(lock) {
             interceptors += interceptor
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 8ac10ffe..89155f14 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
@@ -6,6 +6,15 @@ interface TerminalInterface {
     val info: TerminalInfo
     fun completePrintRequest(request: PrintRequest)
 
+    /**
+     * Read a single line of input from stdin, returning null if no input is available
+     *
+     * @param hideInput If true, treat the input like a password that should not be echoed to the screen as it's typed.
+     * @throws UnsupportedOperationException if the current interface doesn't support reading input. Currently, all
+     *   targets are supported by default except Browser JS.
+     */
+    fun readLineOrNull(hideInput: Boolean): String?
+
     /**
      * Return an interface that should print to stderr if possible.
      *
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
new file mode 100644
index 00000000..49039855
--- /dev/null
+++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalRecorder.kt
@@ -0,0 +1,118 @@
+package com.github.ajalt.mordant.terminal
+
+import com.github.ajalt.mordant.rendering.AnsiLevel
+
+/**
+ * @property inputLines Lines of input to return from [readLineOrNull].
+ */
+@ExperimentalTerminalApi
+class TerminalRecorder private constructor(
+    override val info: TerminalInfo,
+    var inputLines: MutableList<String>,
+    private val stdout: StringBuilder,
+    private val stderr: StringBuilder,
+    private val output: StringBuilder,
+    private val useStdErr: Boolean,
+) : TerminalInterface {
+    constructor(
+        ansiLevel: AnsiLevel = AnsiLevel.TRUECOLOR,
+        width: Int = 79,
+        height: Int = 24,
+        hyperlinks: Boolean = ansiLevel != AnsiLevel.NONE,
+        outputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+        inputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+        crClearsLine: Boolean = false,
+    ) : this(
+        info = TerminalInfo(
+            width,
+            height,
+            ansiLevel,
+            hyperlinks,
+            outputInteractive = outputInteractive,
+            inputInteractive = inputInteractive,
+            crClearsLine = crClearsLine,
+        ),
+        inputLines = mutableListOf(),
+        stdout = StringBuilder(),
+        stderr = StringBuilder(),
+        output = StringBuilder(),
+        useStdErr = false
+    )
+
+    fun clearOutput() {
+        stdout.clear()
+        stderr.clear()
+        output.clear()
+    }
+
+    /** The content written to stdout */
+    fun stdout(): String = stdout.toString()
+
+    /** The content written to stderr */
+    fun stderr(): String = stderr.toString()
+
+    /** The combined content of [stdout] and [stderr] */
+    fun output(): String = output.toString()
+
+    /** Return [stdout] by default, or [stderr] instead if this interface was returned from [forStdErr] */
+    fun currentContent(): String = if (useStdErr) stderr() else stdout()
+
+    override fun completePrintRequest(request: PrintRequest) {
+        val sb = if (useStdErr) stderr else stdout
+        sb.append(request.text)
+        output.append(request.text)
+        if (request.trailingLinebreak) {
+            sb.append("\n")
+            output.append("\n")
+        }
+    }
+
+    override fun forStdErr(): TerminalRecorder = TerminalRecorder(
+        info, inputLines, stdout, stderr, output, true
+    )
+
+    override fun readLineOrNull(hideInput: Boolean): String? = inputLines.removeFirstOrNull()
+}
+
+@ExperimentalTerminalApi
+@Deprecated(
+    "VirtualTerminalInterface replaced with TerminalRecorder",
+    replaceWith = ReplaceWith("TerminalRecorder")
+)
+class VirtualTerminalInterface(
+    ansiLevel: AnsiLevel = AnsiLevel.TRUECOLOR,
+    width: Int = 79,
+    height: Int = 24,
+    hyperlinks: Boolean = ansiLevel != AnsiLevel.NONE,
+    outputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+    inputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
+    crClearsLine: Boolean = false,
+) : TerminalInterface {
+    override val info = TerminalInfo(
+        width,
+        height,
+        ansiLevel,
+        hyperlinks,
+        outputInteractive = outputInteractive,
+        inputInteractive = inputInteractive,
+        crClearsLine = crClearsLine,
+    )
+
+    private val sb = StringBuilder()
+
+    fun clearBuffer() {
+        sb.clear()
+    }
+
+    fun buffer(): String = sb.toString()
+
+    override fun completePrintRequest(request: PrintRequest) {
+        sb.append(request.text)
+        if (request.trailingLinebreak) {
+            sb.append("\n")
+        }
+    }
+
+    override fun forStdErr(): TerminalInterface = this
+    override fun readLineOrNull(hideInput: Boolean): String? = null
+}
diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt
deleted file mode 100644
index 77422414..00000000
--- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/VirtualTerminalInterface.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.github.ajalt.mordant.terminal
-
-import com.github.ajalt.mordant.rendering.AnsiLevel
-
-@ExperimentalTerminalApi
-class VirtualTerminalInterface(
-    ansiLevel: AnsiLevel = AnsiLevel.TRUECOLOR,
-    width: Int = 79,
-    height: Int = 24,
-    hyperlinks: Boolean = ansiLevel != AnsiLevel.NONE,
-    outputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
-    inputInteractive: Boolean = ansiLevel != AnsiLevel.NONE,
-    crClearsLine: Boolean = false,
-) : TerminalInterface {
-    override val info = TerminalInfo(
-        width,
-        height,
-        ansiLevel,
-        hyperlinks,
-        outputInteractive = outputInteractive,
-        inputInteractive = inputInteractive,
-        crClearsLine = crClearsLine,
-    )
-
-    private val sb = StringBuilder()
-
-    fun clearBuffer() {
-        sb.clear()
-    }
-
-    fun buffer(): String = sb.toString()
-
-    override fun completePrintRequest(request: PrintRequest) {
-        sb.append(request.text)
-        if (request.trailingLinebreak) {
-            sb.append("\n")
-        }
-    }
-
-    override fun forStdErr(): TerminalInterface = this
-}
-
diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index f4c0b915..231b72f1 100644
--- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -5,6 +5,7 @@ import com.github.ajalt.mordant.terminal.*
 private external val process: dynamic
 private external val console: dynamic
 private external val Symbol: dynamic
+private external val Buffer: dynamic
 
 internal actual class AtomicInt actual constructor(initial: Int) {
     private var backing = initial
@@ -30,6 +31,7 @@ private interface JsMppImpls {
     fun stderrInteractive(): Boolean
     fun getTerminalSize(): Pair<Int, Int>?
     fun printStderr(message: String, newline: Boolean)
+    fun readLineOrNull(): String?
 }
 
 private object BrowserMppImpls : JsMppImpls {
@@ -43,9 +45,12 @@ private object BrowserMppImpls : JsMppImpls {
         // No way to avoid the newline on browsers
         console.error(message)
     }
+
+    // readlnOrNull will just throw an exception on browsers
+    override fun readLineOrNull(): String? = readlnOrNull()
 }
 
-private object NodeMppImpls : JsMppImpls {
+private class NodeMppImpls(private val fs: dynamic) : JsMppImpls {
     override fun readEnvvar(key: String): String? = process.env[key] as? String
     override fun isWindowsMpp(): Boolean = process.platform == "win32"
     override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean
@@ -63,11 +68,27 @@ private object NodeMppImpls : JsMppImpls {
         val s = if (newline) message + "\n" else message
         process.stderr.write(s)
     }
+
+    override fun readLineOrNull(): String? {
+        return try {
+            buildString {
+                var char: String
+                val buf = Buffer.alloc(1)
+                do {
+                    fs.readSync(fd = 0, bufer = buf, offset = 0, len = 1, position = null)
+                    char = buf.toString()
+                    append(char)
+                } while (char != "\n")
+            }
+        } catch (e: Exception) {
+            null
+        }
+    }
 }
 
-private val impls: JsMppImpls = if (isNode) {
-    NodeMppImpls
-} else {
+private val impls: JsMppImpls = try {
+    NodeMppImpls(nodeRequire("fs"))
+} catch (e: Exception) {
     BrowserMppImpls
 }
 
@@ -83,6 +104,10 @@ internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive()
 internal actual fun stderrInteractive(): Boolean = impls.stderrInteractive()
 internal actual fun printStderr(message: String, newline: Boolean) = impls.printStderr(message, newline)
 
+// hideInput is not currently implemented
+internal actual fun readLineOrNullMpp(hideInput: Boolean): String? = impls.readLineOrNull()
+
+
 internal actual fun codepointSequence(string: String): Sequence<Int> {
     val it = string.asDynamic()[Symbol.iterator]()
     return generateSequence {
diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index 25c09107..37505388 100644
--- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -1,6 +1,7 @@
 package com.github.ajalt.mordant.internal
 
 import com.github.ajalt.mordant.terminal.*
+import java.io.Console
 import java.io.IOException
 import java.lang.management.ManagementFactory
 import java.util.concurrent.TimeUnit
@@ -98,6 +99,16 @@ internal actual fun printStderr(message: String, newline: Boolean) {
     }
 }
 
+internal actual fun readLineOrNullMpp(hideInput: Boolean): String? {
+    if (hideInput) {
+        val console = System.console()
+        if (console != null) {
+            return console.readPassword().toString()
+        }
+    }
+    return readlnOrNull()
+}
+
 internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor = JvmTerminalCursor(terminal)
 
 private class JvmTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) {
diff --git a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
index ca9e787e..751105ef 100644
--- a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
+++ b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppImpl.kt
@@ -54,6 +54,10 @@ internal actual fun printStderr(message: String, newline: Boolean) {
     fflush(stderr)
 }
 
+
+// hideInput is not currently implemented
+internal actual fun readLineOrNullMpp(hideInput: Boolean): String? = readlnOrNull()
+
 internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor = NativeTerminalCursor(terminal)
 
 
@@ -73,6 +77,7 @@ private fun cursorAtExitCallback() {
 // In case the user already has a sigint handler installed, we need to keep track of it
 private val existingSigintHandler = AtomicReference<CPointer<CFunction<(Int) -> Unit>>?>(null)
 
+@OptIn(UnsafeNumber::class) // for `write`
 private fun cursorSigintHandler(signum: Int) {
     signal(SIGINT, SIG_IGN) // disable sigint handling to avoid recursive calls
     // signal handlers can't safely access most state or functions due to their async nature, so we
diff --git a/samples/markdown/build.gradle.kts b/samples/markdown/build.gradle.kts
index 0b518f26..3f423d4b 100644
--- a/samples/markdown/build.gradle.kts
+++ b/samples/markdown/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
 
 application {
     mainClass.set("com.github.ajalt.mordant.samples.MainKt")
+    applicationDefaultJvmArgs = listOf("-Dfile.encoding=utf-8")
 }
 
 dependencies {
diff --git a/samples/progress/build.gradle.kts b/samples/progress/build.gradle.kts
index 0b518f26..23411f1d 100644
--- a/samples/progress/build.gradle.kts
+++ b/samples/progress/build.gradle.kts
@@ -5,9 +5,11 @@ plugins {
 
 application {
     mainClass.set("com.github.ajalt.mordant.samples.MainKt")
+    applicationDefaultJvmArgs = listOf("-Dfile.encoding=utf-8")
 }
 
 dependencies {
     api(kotlin("stdlib"))
     api(project(":mordant"))
 }
+
diff --git a/samples/table/build.gradle.kts b/samples/table/build.gradle.kts
index 090303e2..1dcd1284 100644
--- a/samples/table/build.gradle.kts
+++ b/samples/table/build.gradle.kts
@@ -28,4 +28,5 @@ kotlin {
 
 application {
     mainClass.set("com.github.ajalt.mordant.samples.MainKt")
+    applicationDefaultJvmArgs = listOf("-Dfile.encoding=utf-8")
 }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index a7d39a04..ad25076f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,3 +1,5 @@
+@file:Suppress("UnstableApiUsage")
+
 include("mordant")
 include("samples:markdown")
 include("samples:progress")
@@ -7,11 +9,11 @@ include("samples:table")
 dependencyResolutionManagement {
     versionCatalogs {
         create("libs") {
-            version("kotlin", "1.6.20")
+            version("kotlin", "1.7.0")
 
-            plugin("dokka", "org.jetbrains.dokka").version("1.6.10")
+            plugin("dokka", "org.jetbrains.dokka").version("1.6.21")
 
-            library("dokka-base", "org.jetbrains.dokka:dokka-base:1.6.10")
+            library("dokka-base", "org.jetbrains.dokka:dokka-base:1.6.21")
             library("colormath", "com.github.ajalt.colormath:colormath:3.2.0")
             library("markdown", "org.jetbrains:markdown:0.3.1")
 


Test patch:
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt
new file mode 100644
index 00000000..2b50cd0d
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt
@@ -0,0 +1,26 @@
+package com.github.ajalt.mordant.terminal
+
+import com.github.ajalt.mordant.rendering.TextColors.*
+import io.kotest.matchers.shouldBe
+import kotlin.test.Test
+
+@OptIn(ExperimentalTerminalApi::class)
+class HtmlRendererTest {
+    private val vt = TerminalRecorder()
+    private val t = Terminal(terminalInterface = vt)
+
+    @Test
+    fun renderAsHtml() {
+        t.print(red("red"))
+        t.print("plain")
+        t.print(blue("blue"))
+
+        vt.outputAsHtml() shouldBe """
+        <html><body>
+        <pre style="font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace"><code>
+        <span style="color: #800000">red</span><span>plain</span><span style="color: #000080">blue</span>
+        </code></pre>
+        </body></html>
+        """.trimIndent()
+    }
+}
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/PromptTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/PromptTest.kt
new file mode 100644
index 00000000..1bbc920a
--- /dev/null
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/PromptTest.kt
@@ -0,0 +1,73 @@
+package com.github.ajalt.mordant.terminal
+
+import io.kotest.matchers.shouldBe
+import kotlin.js.JsName
+import kotlin.test.Test
+
+@OptIn(ExperimentalTerminalApi::class)
+class PromptTest {
+    private val vt = TerminalRecorder(width = 8)
+    private val t = Terminal(terminalInterface = vt)
+
+    @Test
+    fun stringPrompt() {
+        vt.inputLines = mutableListOf("answer")
+        StringPrompt("pr", t).ask() shouldBe "answer"
+        vt.output() shouldBe "pr: "
+    }
+
+    @Test
+    @JsName("terminal_prompt")
+    fun `terminal prompt`() {
+        vt.inputLines = mutableListOf("answer")
+        t.prompt("pr") shouldBe "answer"
+        vt.output() shouldBe "pr: "
+    }
+
+    @Test
+    @JsName("StringPrompt_with_default")
+    fun `StringPrompt with default`() {
+        vt.inputLines = mutableListOf("")
+        StringPrompt("pr", t, default = "def").ask() shouldBe "def"
+        val style = t.theme.style("prompt.default")
+        vt.output() shouldBe "pr ${style("(def)")}: "
+    }
+
+    @Test
+    @JsName("StringPrompt_with_choices")
+    fun `StringPrompt with choices`() {
+        vt.inputLines = mutableListOf("b")
+        StringPrompt("pr", t, choices = listOf("a", "b")).ask() shouldBe "b"
+        val s = t.theme.style("prompt.choices")
+        vt.output() shouldBe "pr ${s("[a, b]")}: "
+    }
+
+    @Test
+    @JsName("StringPrompt_invalid_choices")
+    fun `StringPrompt invalid choice`() {
+        vt.inputLines = mutableListOf("bad", "a")
+        StringPrompt("pr", t, choices = listOf("a", "b"), ).ask() shouldBe "a"
+        val s = t.theme.style("prompt.choices")
+        val e = t.theme.danger
+        val p = "pr ${s("[a, b]")}: "
+        vt.output() shouldBe "$p${e("Invalid value, choose from [a, b]")}\n$p"
+    }
+
+    @Test
+    @JsName("YesNoPrompt_no_default")
+    fun `YesNoPrompt no default`() {
+        vt.inputLines = mutableListOf("Y")
+        YesNoPrompt("pr", t).ask() shouldBe true
+        val style = t.theme.style("prompt.default")
+        vt.output() shouldBe "pr ${style("[y/n]")}: "
+    }
+
+    @Test
+    @JsName("YesNoPrompt_default")
+    fun `YesNoPrompt default`() {
+        vt.inputLines = mutableListOf("")
+        YesNoPrompt("pr", t, default = false).ask() shouldBe false
+        val style = t.theme.style("prompt.default")
+        vt.output() shouldBe "pr ${style("[y/N]")}: "
+    }
+}
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt
index 1ef19056..5dc1dd02 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalCursorTest.kt
@@ -11,17 +11,17 @@ import kotlin.js.JsName
 import kotlin.test.Test
 
 private fun c(b: CursorMovements.() -> Unit): String {
-    val vt = VirtualTerminalInterface()
+    val vt = TerminalRecorder()
     val t = Terminal(terminalInterface = vt)
     t.cursor.move(b)
-    return vt.buffer()
+    return vt.output()
 }
 
 class TerminalCursorTest {
     @Test
     @JsName("disabled_commands")
     fun `disabled commands`() {
-        val vt = VirtualTerminalInterface(AnsiLevel.NONE, )
+        val vt = TerminalRecorder(AnsiLevel.NONE)
         val t = Terminal(terminalInterface = vt)
         val c = t.cursor
         forAll(
@@ -36,8 +36,8 @@ class TerminalCursorTest {
             row(c.move { clearScreen() }),
             row(c.move { clearScreenAfterCursor() }),
         ) {
-            vt.buffer() shouldBe ""
-            vt.clearBuffer()
+            vt.output() shouldBe ""
+            vt.clearOutput()
         }
     }
 
@@ -55,24 +55,24 @@ class TerminalCursorTest {
     @Test
     @JsName("cursor_show_and_hide")
     fun `cursor show and hide`() {
-        val vt = VirtualTerminalInterface()
+        val vt = TerminalRecorder()
         val t = Terminal(terminalInterface = vt)
         t.cursor.hide(showOnExit = false)
-        vt.buffer() shouldBe "$CSI?25l"
-        vt.clearBuffer()
+        vt.output() shouldBe "$CSI?25l"
+        vt.clearOutput()
         t.cursor.show()
-        vt.buffer() shouldBe "$CSI?25h"
+        vt.output() shouldBe "$CSI?25h"
     }
 
     @Test
     @JsName("disabled_cursor_show_and_hide")
     fun `disabled cursor show and hide`() {
-        val vt = VirtualTerminalInterface(AnsiLevel.NONE, )
+        val vt = TerminalRecorder(AnsiLevel.NONE)
         val t = Terminal(terminalInterface = vt)
         t.cursor.hide(showOnExit = false)
-        vt.buffer() shouldBe ""
+        vt.output() shouldBe ""
         t.cursor.show()
-        vt.buffer() shouldBe ""
+        vt.output() shouldBe ""
     }
 
     @Test
diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt
index 02909954..e48633cc 100644
--- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt
+++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt
@@ -9,56 +9,56 @@ import kotlin.test.Test
 
 @OptIn(ExperimentalTerminalApi::class)
 class TerminalTest {
-    private val vt = VirtualTerminalInterface(width = 8, )
+    private val vt = TerminalRecorder(width = 8)
     private val t = Terminal(terminalInterface = vt)
 
     @Test
     fun success() {
         t.success("success")
-        vt.buffer() shouldBe t.theme.success("success") + "\n"
+        vt.output() shouldBe t.theme.success("success") + "\n"
     }
 
     @Test
     fun danger() {
         t.danger("danger")
-        vt.buffer() shouldBe t.theme.danger("danger") + "\n"
+        vt.output() shouldBe t.theme.danger("danger") + "\n"
     }
 
     @Test
     fun warning() {
         t.warning("warning")
-        vt.buffer() shouldBe t.theme.warning("warning") + "\n"
+        vt.output() shouldBe t.theme.warning("warning") + "\n"
     }
 
     @Test
     fun info() {
         t.info("info")
-        vt.buffer() shouldBe t.theme.info("info") + "\n"
+        vt.output() shouldBe t.theme.info("info") + "\n"
     }
 
     @Test
     fun muted() {
         t.muted("muted")
-        vt.buffer() shouldBe t.theme.muted("muted") + "\n"
+        vt.output() shouldBe t.theme.muted("muted") + "\n"
     }
 
     @Test
     fun print() {
         t.print("print")
-        vt.buffer() shouldBe "print"
+        vt.output() shouldBe "print"
     }
 
     @Test
     fun println() {
         t.println("println")
-        vt.buffer() shouldBe "println\n"
+        vt.output() shouldBe "println\n"
     }
 
     @Test
     @JsName("print_customized")
     fun `print customized`() {
         t.print(cyan("print with a wrap"), whitespace = Whitespace.NORMAL, align = TextAlign.RIGHT)
-        vt.buffer() shouldBe """
+        vt.output() shouldBe """
         |${cyan("  print")}
         |${cyan(" with a")}
         |${cyan("    wrap")}
diff --git a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt
index 2e84e198..1bf12948 100644
--- a/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt
+++ b/mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/ProgressAnimationTest.kt
@@ -5,7 +5,7 @@ import com.github.ajalt.mordant.test.RenderingTest
 import com.github.ajalt.mordant.rendering.Theme
 import com.github.ajalt.mordant.terminal.ExperimentalTerminalApi
 import com.github.ajalt.mordant.terminal.Terminal
-import com.github.ajalt.mordant.terminal.VirtualTerminalInterface
+import com.github.ajalt.mordant.terminal.TerminalRecorder
 import io.kotest.matchers.shouldBe
 import kotlin.test.Test
 import java.util.concurrent.TimeUnit
@@ -16,7 +16,7 @@ class ProgressAnimationTest : RenderingTest() {
 
     @Test
     fun throttling() {
-        val vt = VirtualTerminalInterface()
+        val vt = TerminalRecorder()
         val t = Terminal(terminalInterface = vt)
         val pt = t.progressAnimation {
             timeSource = { (now * TimeUnit.SECONDS.toNanos(1)).toLong() }
@@ -29,29 +29,29 @@ class ProgressAnimationTest : RenderingTest() {
 
         pt.update(0, 1000)
         now = 0.5
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.update(40)
         vt.normalizedBuffer() shouldBe " ---.-it/s|eta -:--:--"
 
         now = 0.6
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.update()
         vt.normalizedBuffer() shouldBe " ---.-it/s|eta -:--:--"
 
         now = 1.0
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.update()
         vt.normalizedBuffer() shouldBe "  40.0it/s|eta 0:00:24"
 
         now = 1.9
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.update()
         vt.normalizedBuffer() shouldBe "  40.0it/s|eta 0:00:24"
     }
 
     @Test
     fun animation() {
-        val vt = VirtualTerminalInterface(width = 56)
+        val vt = TerminalRecorder(width = 56)
         val t = Terminal(
             theme = Theme(Theme.PlainAscii) { strings["progressbar.pending"] = "." },
             terminalInterface = vt
@@ -75,30 +75,30 @@ class ProgressAnimationTest : RenderingTest() {
         vt.normalizedBuffer() shouldBe "text.txt|  0%|......|   0.0/100.0| ---.-it/s|eta -:--:--"
 
         now = 10.0
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.update(40)
         vt.normalizedBuffer() shouldBe "text.txt| 40%|##>...|  40.0/100.0|   4.0it/s|eta 0:00:15"
 
         now = 20.0
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.update()
         vt.normalizedBuffer() shouldBe "text.txt| 40%|##>...|  40.0/100.0|   2.0it/s|eta 0:00:30"
 
         now = 30.0
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.updateTotal(200)
         vt.normalizedBuffer() shouldBe "text.txt| 20%|#>....|  40.0/200.0|   1.3it/s|eta 0:02:00"
 
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.restart()
         vt.normalizedBuffer() shouldBe "text.txt|  0%|......|   0.0/200.0| ---.-it/s|eta -:--:--"
 
-        vt.clearBuffer()
+        vt.clearOutput()
         pt.clear()
         vt.normalizedBuffer() shouldBe ""
     }
 
-    private fun VirtualTerminalInterface.normalizedBuffer(): String {
-        return buffer().substringAfter("${CSI}0J").substringAfter("${CSI}1A").trimEnd()
+    private fun TerminalRecorder.normalizedBuffer(): String {
+        return output().substringAfter("${CSI}0J").substringAfter("${CSI}1A").trimEnd()
     }
 }