Reference

Plugin recipes

Hello-world versions of each kind of Hammer plugin, in Kotlin. Every recipe is part of one working plugin, the SDK's kotlin/hello, so they build and are tested; copy the parts you need. The API reference has the full rules for each.

A new plugin

A plugin is a .hammerplugin package: manifest.toml, the compiled module, and optionally settings.toml, translations in locales/, and files to read at run time in resources/.

  1. Make a Gradle project, say greeter, with settings.gradle.kts:

    pluginManagement {
        repositories {
            gradlePluginPortal()
            mavenCentral()
        }
    }
    
    dependencyResolutionManagement {
        repositories {
            mavenCentral()
        }
    }
    
    rootProject.name = "greeter"
  2. Apply the Gradle plugin in build.gradle.kts. It sets up Kotlin Multiplatform, adds the kit (com.darkrockstudios.hammer:hammer-plugin-kit, at the plugin's own version), and adds the hammerPlugin task.

    plugins {
        id("com.darkrockstudios.hammer.plugin") version "0.8.0"
    }
  3. Write manifest.toml. Every plugin starts the same way; the recipes below add to it.

    id = "greeter"          # lowercase, and what its settings and exports are named after
    name = "Greeter"
    version = "0.1.0"
    api = 1
    description = "Greets each scene."  # shown when installing and from Info; links are clickable
    # languages = ["en"]   # the project languages it works with, if it depends on them
    
    # [kind]                 # a job it shares with other plugins; see A text check
    # id = "grammar"
    # label = "Grammar checker"
    
    [permissions]
    operations = ["scene.tree", "scene.read"]  # what it may call; see Calling Hammer
  4. Put the plugin's logic in src/commonMain/kotlin, where JVM tests can reach it, and its exports in src/wasmWasiMain/kotlin/Exports.kt.

  5. Build with gradle hammerPlugin, which writes build/greeter.hammerplugin, and install it in Hammer from Settings, Plugins.

To try a recipe inside a checkout of the SDK instead, add a directory under kotlin/ next to hello, apply id("com.darkrockstudios.hammer.plugin") without a version, and add it to the include(...) in kotlin/settings.gradle.kts; the kit then comes from the checkout.

A module has one export of each kind (export, action, diagnose, command), so a plugin with two actions tells them apart by name in its one action export. kotlin/hello's Exports.kt wires up every recipe here.

Settings

settings.toml declares settings Hammer shows as a form in Settings, Plugins; every request carries their values. Types are bool, int (with min and max), string (with multiline), and choice (with options). An action's fields are much the same, with text in place of string.

[[setting]]
key = "greeting"
type = "string"
label = "Greeting"
default = "Hello"

Read them into a class with a property for each:

@Serializable
class HelloSettings(val greeting: String)

val greeting = request.settings<HelloSettings>().greeting

An export format

Adds a format to the Export dialog. The export gets the story's chapters and returns the file's bytes.

[[exporters]]
format = "hello.txt"   # starts with the plugin's id
extension = "txt"
mime = "text/plain"
label = "Hello (TXT)"
fun helloExport(request: ExportRequest): String {
    val greeting = request.settings<HelloSettings>().greeting
    return buildString {
        appendLine("$greeting, ${request.projectName}!")
        request.chapters.forEach { appendLine("${it.name}: ${it.scenes.size} scenes") }
    }
}

@WasmExport
fun export(): Int = exporter { helloExport(it).encodeToByteArray() }

Set input = "prose" on the exporter to get each scene as parsed blocks instead of markdown.

Embedding images

images = true on the exporter sends the files of the images the story shows, and cover = true with it the project's cover as well (cover alone is refused at install). request.images has each one whose file Hammer could read, sorted by id, with its name, extension, mimeType (application/octet-stream for an extension Hammer does not know), and content, the file's bytes; request.image(id) finds one, or null when its file could not be read, and request.cover is the cover, or null. In markdown an image is still ![image](hammer://image/5), whose id parseImageReference or findImageIds reads (match the destination; the alt text means nothing). In prose, each scene is a list of blocks, and a line holding only an image becomes a block {"type": "image", "id": 5, "name": "Map"}, which proseImageId(block) reads, and its id is always in images; without images = true it stays a paragraph of the image's name. An image inside a line, or one whose file could not be read, arrives as its name in text and never as a block.

[[exporters]]
# format, extension, mime, and label as above
input = "prose"
images = true
cover = true

This one is not part of hello; addFile stands for the format's own writer.

request.chapters.flatMap { it.prose }.flatten().forEach { block ->
    val image = proseImageId(block)?.let(request::image)
    if (image != null) addFile("images/${image.id}.${image.extension}", image.content)
}
request.cover?.let { addFile("cover.${it.extension}", it.content) }

Each image may be up to 8 MiB, and base64 adds a third; Hammer sends every image the story refers to, in a request that must fit in the plugin's kernel (256 MiB, or a sixteenth of Hammer's heap if less), which no manifest key raises. A C or Rust plugin should raise [limits] memory to hold the request, the decoded images, and the output at once; a Kotlin plugin's objects live in the guest heap instead. hammer-editor's docs/CLIENT-PLUGINS.md has the rest. Rust reads the same request as hammer::export::Request, with image, cover, and prose_image_id; C walks it with hammer_json.h and decodes each content with hammer_base64.h's hammer_base64_decode.

An archive of files

output = "archive" on the exporter has Hammer write its file as a ZIP archive: the export returns the files to put in it, an ExportArchive's JSON, and Hammer zips them in the order they were added. Each file is text, written as UTF-8, bytes, or an image Hammer sent, named by its id: Hammer writes that image's bytes itself, so the plugin sends none back. A path is relative, with / between its segments: not empty, no leading /, no \, no empty segment, no . or .. segment, and each path once. ExportArchive throws, naming the path, when a file breaks one, and when there is no file at all.

[[exporters]]
format = "hello.zip"
extension = "zip"
mime = "application/zip"
label = "Hello (ZIP)"
output = "archive"
images = true
fun helloArchive(request: ExportRequest): ExportArchive = ExportArchive().apply {
    text("hello.txt", helloExport(request))
    request.chapters.forEachIndexed { i, chapter ->
        val name = chapter.name.replace('/', '-').replace('\\', '-')
        text("chapters/${i + 1}-$name.md", "# ${chapter.name}\n\n" + chapter.scenes.joinToString("\n\n"))
    }
    // Each image is named by its id; Hammer writes its bytes.
    request.images.forEach { image("images/${it.id}.${it.extension}", it) }
}

@WasmExport
fun export(): Int = exporter { request ->
    when (request.format) {
        "hello.zip" -> helloArchive(request).toJson()
        else -> helloExport(request)
    }.encodeToByteArray()
}

This one is not part of hello: a Markdown story with its images beside it, from an exporter that also sets images = true and cover = true. image(path, image) names each image, and the cover, by its id, so the reply carries no bytes for them and the plugin holds each image once, in the request. The story's markdown still names each image hammer://image/<id>, so it rewrites each destination Hammer writes, (hammer://image/5) or (<hammer://image/5>), to the path it chose: findImageIds lists the ids and imageReference builds each reference.

fun storyArchive(request: ExportRequest): ExportArchive {
    val archive = ExportArchive()
    val path = { image: ExportRequest.Image -> "images/${image.id}.${image.extension}" }
    var story = request.chapters.joinToString("\n\n") { "# ${it.name}\n\n" + it.scenes.joinToString("\n\n") }
    val shown = findImageIds(story).mapNotNull(request::image)
    shown.forEach {
        val reference = imageReference(it.id)
        story = story.replace("(<$reference>)", "(${path(it)})").replace("($reference)", "(${path(it)})")
    }
    archive.text("story.md", story)
    shown.forEach { archive.image(path(it), it) }
    request.cover?.let { archive.image("cover.${it.extension}", it) }
    return archive
}

Rust builds the same reply with hammer::export::Archive, whose image takes an id or an Image. C writes it with hammer_archive.h, whose hammer_archive_image takes the id. In every kit, bytes (hammer_archive_bytes in C) encodes bytes a plugin made, and base64 (hammer_archive_base64) passes on base64 a plugin kept itself as it is.

An action with a message

Adds an item to Project Home's menu. Every action replies with an ActionReply, and ActionReply.message is a line shown briefly as the action ends.

[[actions]]
name = "greet"
label = "Say hello"
fun greet(request: ActionRequest): ActionReply = ActionReply.message("${request.settings<HelloSettings>().greeting}, ${request.project}!")

@WasmExport
fun action(): Int = action { request ->
    when (request.action) {
        "greet" -> greet(request)
        else -> null
    }
}

Returning null ends the action without a word.

An action with a document, calling Hammer

ActionReply.document shows markdown in the action's dialog, where the user can copy it or save it as a note. The action reads the project through Hammer's operations, here Hammer.scene.tree, which the manifest must ask for; see Calling Hammer.

[[actions]]
name = "outline"
label = "Hello outline"
fun outline(request: ActionRequest): ActionReply = ActionReply.document(
    buildString {
        appendLine("# ${request.project}")
        fun write(nodes: List<SceneNode>, depth: Int) {
            nodes.forEach { node ->
                appendLine("  ".repeat(depth) + "- ${node.name}")
                write(node.children, depth + 1)
            }
        }
        write(Hammer.scene.tree(request.project).nodes, 0)
    },
)

An action with fields, on more screens, reporting progress

places puts the action on other screens' menus as well: project, scene, note, entry, and event. request.context says which it was run from and the id of the item there.

[[actions.field]] tables are the action's first page: Hammer shows them with a Run button and calls the action with their values in request.input; an action without them is called as soon as it is picked. Field types are:

type Value Keys
text A string format: plain (the default), or rich or rich-extended for the scene editor, whose value is markdown; multiline for plain
bool true or false
int A number min, max
choice The picked option's value options, a list of { value, label }
scenes A list of scene ids, picked from the project's tree multiple = false for one id, or null
entity An item picked by name, { kind, id }, or null kinds, entry_types, tags; multiple = true for a list

Every field has a key and a label, and may have a hint and a default, its starting value; required = true insists on a text that is not blank, at least one scene, or an item picked. Hammer checks these, an int's bounds, a choice's options, and an entity's picks before calling. Action fields say text where settings say string.

A long action calls progress, which Hammer shows with a Stop button; when the user stops it, the call never returns.

[[actions]]
name = "count"
label = "Count words"
places = ["project", "scene"]

[[actions.field]]
key = "scenes"
type = "scenes"
label = "Scenes"
hint = "Leave empty for the whole story, or the scene you are in."
@Serializable
class CountInput(val scenes: List<Int> = emptyList())

// flatten() is a small helper in Hello.kt that lists a node and everything under it.
fun countWords(request: ActionRequest): ActionReply {
    val here = request.context.id.takeIf { request.context.place == "scene" }
    val chosen = request.input<CountInput>().scenes.ifEmpty { listOfNotNull(here) }
    val scenes = Hammer.scene.tree(request.project).nodes.flatMap { it.flatten() }
        .filter { it.kind == SceneKind.Scene && (chosen.isEmpty() || it.id in chosen) }
    val counts = buildString {
        appendLine("# Word counts")
        scenes.forEachIndexed { i, scene ->
            progress(i.toDouble() / scenes.size, "Counting ${scene.name}")
            val text = Hammer.scene.read(request.project, scene.id)
            appendLine("- ${scene.name}: ${text.markdown.split(' ', '\n', '\t').count { it.isNotBlank() }}")
        }
    }
    return ActionReply.document(counts)
}

Picking a character

An entity field picks an item of the project by name: the user types to search scenes, notes, encyclopedia entries, and timeline events, by name and alias. kinds narrows it to some of scene, note, entry, and event; with entry among them, entry_types narrows entries to some of person, place, thing, event, and idea; and tags to items tagged with any of those given. Its value is { "kind": "entry", "id": 12 }, or null when nothing is picked. With multiple = true it is a list of them, empty when nothing is picked; unlike scenes, an entity field picks one item unless it says otherwise. A default has the value's shape.

Before calling, Hammer checks that each item picked still exists and matches the field's filters, and shows a default that is gone or does not match as empty. Read the value into an EntityRef, whose kind and id go to the operations for that kind, or to ActionReply.Open. This one reads a character with entry.read, which the manifest's permissions.operations needs:

[[actions]]
name = "about"
label = "About a character"

[[actions.field]]
key = "who"
type = "entity"
label = "Character"
kinds = ["entry"]
entry_types = ["person"]
required = true
@Serializable
class AboutInput(val who: EntityRef)

fun about(request: ActionRequest): ActionReply {
    val who = request.input<AboutInput>().who
    val entry = Hammer.entry.read(request.project, who.id)
    val scenes = entry.referencedBy.size
    return ActionReply.document(
        buildString {
            appendLine("# ${entry.name}")
            if (entry.aliases.isNotEmpty()) appendLine("Also known as ${entry.aliases.joinToString()}.")
            appendLine("In ${if (scenes == 1) "1 scene" else "$scenes scenes"}.")
        },
    )
}

who is never null here, since the field is required and Run checks it; a field that may be left empty reads as EntityRef?, and one with multiple as List<EntityRef>. On a page, Field.entity("who", "Character", kinds = listOf("entry"), entryTypes = listOf("person"), default = here) makes the same field, starting with here picked. C reads a pick with hammer_input_entity, and each of a list's with hammer_entity_read; Rust reads one into hammer::action::EntityRef.

Images in a scene

Hammer writes a project image into Markdown as ![image](hammer://image/5), the destination sometimes in angle brackets, and the alt text means nothing: Hammer shows the image's own name. findImageIds gives every image id a scene's Markdown refers to, each once and in the order they first appear, and Hammer.image.get an image's name, extension, and bytes. An id can name an image since deleted, so image.get may fail as NotFound. This one runs from a scene, which places allows, and the manifest asks for scene.read and image.get:

[[actions]]
name = "images"
label = "Scene images"
places = ["scene"]
fun sceneImages(request: ActionRequest): ActionReply {
    val scene = request.context.id ?: return ActionReply.message("Run it from a scene")
    val ids = findImageIds(Hammer.scene.read(request.project, scene).markdown)
    if (ids.isEmpty()) return ActionReply.message("No images in this scene")
    return ActionReply.document(
        buildString {
            appendLine("# Images")
            ids.forEach { id ->
                val image = try {
                    Hammer.image.get(request.project, id)
                } catch (e: OperationException) {
                    if (e.kind != "NotFound") throw e
                    null
                }
                appendLine(if (image == null) "- Image $id: not in the project" else "- ${image.name}.${image.extension}: ${image.content.size} bytes")
            }
        },
    )
}

For one reference, getImage(project, "hammer://image/5") reads it and parseImageReference gives its id, or null for any other destination; imageReference(5) writes one. Rust has the same in hammer::images (find_ids, get, parse_reference, reference). C has hammer_image.h: hammer_next_image_id steps through a text's references, once for each time one appears, and hammer_image_id reads one, giving -1 for any other.

Pages, buttons, and state

A reply's Page is what the action's dialog shows next: a title, markdown, fields made with Field.text, bool, int, choice, scenes, and entity (the same as the manifest's), and buttons. Pressing a button calls the action again with its id in request.button and the page's values in request.input. A primary button is the one to press; an inline one sits under the markdown, for acting on something in it, and the rest sit in the dialog's footer.

A module keeps nothing between calls, so a page carries what the next call needs in its state: stateOf(value) writes any @Serializable value, and request.state<T>() reads it back from the next request, giving null when there is none or it no longer reads as a T. Hammer never reads it, and fails a call whose state is over 1 MiB.

Hammer checks a page's fields before calling, as it does the manifest's. Anything else is the plugin's to check, and it answers with errors: messages by field key, or "" for the whole page. The user stays on the page with what they entered, and its state, to put it right. A button with validate = false, such as Back or Cancel, skips Hammer's checks.

A reply with no page, errors, or open ends the action. wave asks for a name on one page and how many waves on the next:

[[actions]]
name = "wave"
label = "Wave"
@Serializable
class WaveName(val name: String)

@Serializable
class WaveTimes(val times: Int)

@Serializable
class Wave(val name: String)

fun wave(request: ActionRequest): ActionReply? = when (request.button) {
    null, "back" -> ActionReply(
        Page(
            markdown = "Hello from **${request.project}**. Who is waving?",
            fields = listOf(Field.text("name", "Your name", required = true, default = request.state<Wave>()?.name)),
            buttons = listOf(Button("next", "Next", primary = true)),
        ),
    )
    "next" -> {
        val name = request.input<WaveName>().name.trim()
        if (name.any { it.isDigit() }) {
            ActionReply(errors = mapOf("name" to "A name has no digits"))
        } else {
            ActionReply(
                Page(
                    markdown = "Hello, **$name**. Wave back?",
                    fields = listOf(Field.int("times", "Waves", min = 1, max = 3)),
                    buttons = listOf(Button("back", "Back", validate = false), Button("wave", "Wave", primary = true)),
                    state = stateOf(Wave(name)),
                ),
            )
        }
    }
    "wave" -> {
        val name = request.state<Wave>()?.name.orEmpty()
        val times = request.input<WaveTimes>().times
        ActionReply.message(if (times == 1) "$name waved" else "$name waved $times times")
    }
    else -> null
}

Page, Field, and Button are ActionReply.Page, ActionReply.Field, and ActionReply.Button, imported by those names. A page with markdown and no fields, as ActionReply.document makes, is offered for copying and saving as a note, buttons or not. C writes the same replies with hammer_action.h's hammer_put_message, hammer_put_document, hammer_put_page, hammer_put_errors, and hammer_put_open; Rust has hammer::action's Reply, Page, Field, Button, and Open.

Opening what it made

open ends the action and takes the user to an item: ActionReply.Open(place, id), with a place as in places and the id of the scene, note, entry, or event (none for project). A plugin that adds an entry can open it, with a message saying so:

val entry = Hammer.entry.create(request.project, name, EntryKind.Person, text)
ActionReply(message = "Added $name", open = ActionReply.Open("entry", entry.id))

A run the user can leave

An action whose manifest entry sets keep = true keeps its run when the user closes the dialog: opening the action again from the same place brings back the page they were on and what they had entered, and the next call has request.resumed set. The run ends as any other does, by a reply that ends the action or opens something. Keep it for an action that asks for work worth keeping, such as answers to an interview; a report or a list of names is better run fresh.

Resources

Files in the plugin's resources/ directory, such as word lists and dictionaries, go into the package as they are, and the plugin reads them by their path there. Up to 1,000 files and 64 MiB in all. Hammer reads a resource from the package each time it is asked for, so keep one you use often. Hello's resources/tips.txt has a tip a line:

[[actions]]
name = "tip"
label = "Writing tip"
fun tip(): ActionReply =
    ActionReply.message(resourceText("tips.txt").orEmpty().lines().filter { it.isNotBlank() }.randomOrNull() ?: "No tips today")

resource(name) gives the bytes, and both give null for a file that isn't there. In JVM tests, FakeHammerHost reads them from the plugin's own resources/. C has hammer_resource_load, with hammer_alloc_keep to keep one between calls, and hammer_resource_lines for a list file (one entry a line, # comments); Rust has hammer::resource.

A text check

[[diagnostics]] checks the text as it is written: the editor sends the paragraphs that changed, and underlines what comes back, with any fixes in the right-click menu. Ranges are indexes into each paragraph's String; the kit turns them into what Hammer reads.

[[diagnostics]]
name = "very"
label = "Very"
fun findVery(request: DiagnoseRequest): List<Diagnostic> = request.paragraphs.flatMapIndexed { paragraph, text ->
    text.words().filter { it.text.equals("very", ignoreCase = true) }.map { word ->
        val end = if (text.getOrNull(word.end) == ' ') word.end + 1 else word.end
        Diagnostic(paragraph, word.start, end, "“Very” rarely helps", listOf(Fix("", "Remove “very”")))
    }
}

@WasmExport
fun diagnose(): Int = diagnose(::findVery)

words() splits a paragraph into its words, with each one's start and end, so "very" inside "every" isn't a match. A fix shows as its replacement unless it has a label, which it needs when the replacement is empty. severity = Severity.Suggestion underlines something the writer may want to change more quietly than a mistake, and request.project names the project, for operations such as entry.list. Kotlin/Wasm's Regex is slow in Hammer's interpreter, so search with words() and String's own functions rather than a Regex.

A plugin that does the same job as others, such as checking grammar, says so with a [kind] in its manifest. A project runs one plugin of each kind: the first installed that works in its language (from languages, or any language when that is left out) until the writer picks another, or none, from a dropdown in the project's settings. Plugins of a kind agree on its id; grammar is the grammar checkers'. The label names the dropdown; a label under [kind] in a locales/<tag>.toml translates it.

[kind]
id = "grammar"
label = "Grammar checker"

A command-line command

[[commands]] adds hammer <name> on desktop. Hammer hands the command each line of standard input, and writes what it returns as a line of output.

[[commands]]
name = "hello"
help = "Greets each line of standard input."
fun helloCommand(request: CommandRequest): String = "${request.settings<HelloSettings>().greeting}, ${request.line}!"

@WasmExport
fun command(): Int = command(::helloCommand)

echo Alice | hammer hello prints Hello, Alice!.

Translations

A package translates its declared words, the manifest's and settings.toml's, in locales/<tag>.toml files beside the manifest, keyed as the manifest keys them. Hammer picks the most specific one for the UI's language (fr-CA, then fr) and falls back to the manifest for anything left out. Hello's locales/fr.toml:

name = "Bonjour"
description = "Un petit exemple de chaque sorte d’extension, tiré des recettes."

[actions.count]
label = "Compter les mots"

[actions.count.fields.scenes]
label = "Scènes"
hint = "Laissez vide pour toute l’histoire, ou la scène où vous êtes."

[settings.greeting]
label = "Salutation"

What the plugin writes at run time is its own to translate: every request carries locale, the UI's language tag, which is not the project's language, the one the story is written in.

private fun ActionRequest.say(english: String, french: String) = if (locale?.startsWith("fr") == true) french else english

Button("back", request.say("Back", "Retour"), validate = false)

Calling Hammer

Hammer, in hammer.ops, has a function for each of Hammer's operations, grouped as they are named: Hammer.scene.read(project, id) runs scene.read and returns a SceneText, and Hammer.entry.list(project, type = EntryKind.Person) passes an optional input by name. Each function's documentation says what the manifest must ask for. They are generated from Hammer's own list of operations, so their types are Hammer's: an output's fields all have defaults, which a test's fake can leave out, as in Json.encodeToJsonElement(SceneText(markdown = "Rain fell.")).

call<T>(operation, input) runs an operation by name and reads its output into your own type, and callJson gives the JSON as it is, for a plugin that picks operations at run time as the MCP server does. A failure throws OperationException, and one the plugin lets escape is shown to the user as its message; an action can catch it and reply with errors instead, keeping the user on the page: ActionReply(errors = mapOf("" to e.message.orEmpty())). The manifest's permissions.operations lists what a plugin may call: operation names, or a scope such as content:read. Deletes are only ever granted by name. hammer help lists every operation, and ops.list describes them.

The plugin's cache, cacheGet and cacheSet, keeps bytes between calls and runs, such as results worth not working out again; Hammer may drop any of it at any time.

Your sync server's services

A user's sync server may offer services, such as languagetool for grammar checks. Hammer sends a plugin's requests to them as the signed-in user, so the plugin never holds their credentials, and only to the services the manifest names:

[permissions]
hammerServer = ["languagetool"]

hammer.languagetool has typed functions for LanguageTool. A match's offset and length index the text checked, as a Kotlin String does:

fun grammar(request: DiagnoseRequest): List<Diagnostic> = request.paragraphs.flatMapIndexed { paragraph, text ->
    LanguageTool.check(CheckRequest(text, language = request.language ?: "auto")).matches.map {
        Diagnostic(paragraph, it.offset, it.end, it.message, it.replacements.map { fix -> Fix(fix.value) })
    }
}

A check per paragraph keeps the example short; a real one sends paragraphs together and caches what comes back, as the LanguageTool plugin in hammer-plugins does.

A failure throws ServerException, whose kind says why: NotSignedIn when the user has no sync account, Unavailable when their server doesn't offer the service, RateLimited past its limit of requests, and others. serverRequest(service, method, path, headers, body) sends any request and returns its status and body.

Testing

Logic in commonMain runs in JVM tests, with FakeHammerHost answering operations and recording progress:

host = FakeHammerHost(mapOf("scene.read" to { _ -> buildJsonObject { put("markdown", "Rain fell hard.") } }))

FakeHammerHost(services = mapOf("languagetool" to { call -> ServerResponse(200, reply) })) answers requests to the sync server's services and records them in serviceCalls.

ActionDriver runs an action as Hammer does, over as many calls as a test needs. open() shows the manifest's fields for the action, read from the plugin's manifest.toml, or calls at once when it has none. fill(key, value) sets a field (an entity's with an EntityRef), run() presses Run on the manifest's page, and press(id) presses a page's button, after Hammer's checks unless the button skips them. With no project behind it, the driver checks an entity's kinds, but not that its picks exist or their entry types and tags. Each call carries the page's state on, and the reply is applied as Hammer applies it, so a test reads what the user would see: page, values, errors, messages, ended, and opened, with state<T>() reading the page's state. resume() stands for closing the dialog and opening the action again, so the next call is resumed.

val wave = ActionDriver("wave", ::wave, project = "Storm").open()
wave.fill("name", "Mara").press("next")
assertEquals("Mara", wave.state<Wave>()?.name)
wave.fill("times", 2).press("wave")
assertEquals(listOf("Mara waved 2 times"), wave.messages)

gradle :hello:jvmTest runs HelloTest.