Skip to main content

Tutorials

Rune lets you ship interactive, in-IDE tutorials written in a small Starlark DSL. They render as an overlay on top of the editor, walk the user through a sequence of steps, and can wait for the user to actually run a Rune command before moving on.

If you maintain a Rune extension or just want to onboard your teammates into your repo's conventions, a tutorial is often a better first-run experience than a README; it runs inside the IDE, with the user's real keybindings and theme, and it advances when the user makes progress rather than when they remember to switch back to the docs.

What a tutorial looks like

Here is a tiny one:

def run():
floating_window(title="Welcome", text="Press `<enter>` to continue.")
ws = wait_command(command="wopen")
notify(level=success, message="Opened workspace: " + ws.args[0])

tutorial(id="hello", title="Hello, Rune", version="1", entry=run)

Three things happen when the user runs this:

  1. A centered framed window says hello. The user presses Enter, Space, or Esc to dismiss.
  2. The overlay waits at a small bottom-right hint. As soon as the user actually dispatches the wopen command, the tutorial captures the first argument and the entry function continues.
  3. A success notification fires using the workspace path the user supplied, and the tutorial finishes.

Running a tutorial

Tutorials are dispatched from Rune's Command Prompt. Open the prompt with the user's configured command-prompt key (<cmd> in this guide) and run:

  • tutorial start <name>: start the tutorial called <name>.
  • tutorial stop: stop the running tutorial.

Tab-completion works for the subcommand and for <name> after start. Type tutorial start<TAB> to see the available tutorials. If the user types an unknown name, Rune prints unknown tutorial "<name>".

Registering a tutorial

Tutorials are registered through the tutorials block of a Rune config. Each entry maps a name to a path to a .star file. The name is what shows up after the tutorial command (and in completion); the file is read once at IDE startup and parsed into a runnable tutorial.

tutorials:
onboarding: docs/onboarding.star
release-checklist: docs/release.star

Parse errors are surfaced as non-fatal config errors; a typo never takes the IDE down, it just makes that one tutorial unavailable until you fix it.

Where you add the tutorials.<your-tutorial> block depends on who the tutorial is for.

Ship a tutorial with a repository

Drop a workspace-local config at .rune/config.yaml (or .rune/config.star) in the repository root and add a tutorials block that points at .star files committed alongside it:

# .rune/config.yaml
tutorials:
onboarding: .rune/tutorials/onboarding.star

Anyone who opens the workspace gets tutorial basics available automatically, with no per-developer setup, no extra installs. Paths are resolved against the workspace root, so the same configuration works for local checkouts and remote SSH workspaces.

This is the right place for a tutorial that walks new contributors through your repository: the build steps that aren't obvious, the two commands you wish everyone ran before opening a PR, the layout of the codebase. The tutorial ships with the code and stays in sync with it through normal commits and reviews.

Ship a tutorial with an extension

A Rune extension package can ship its own config.star (or config.yaml) at the top of its bundle. When the user installs the extension, Rune shows them the diff your config makes against their resolved config, asks for permission, and on allow merges your additions into ~/.rune/config.yaml. Subsequent IDE starts pick the merged config up like any other user config.

This is the mechanism extensions use to "register" a tutorial: drop the tutorial file inside the package and add a tutorials entry to the package's bundled config. Use the package-version environment variables (RUNE_DATADIR, RUNE_PKG_ID, RUNE_PKG_VERSION) to point at the file from inside the package layout; they expand to the install location at apply time:

config.star
# Ship this file inside your extension bundle.

if "tutorials" not in config:
config["tutorials"] = {}

config["tutorials"]["my-extension"] = (
RUNE_DATADIR + "/pkg/" + RUNE_PKG_ID + "/" +
RUNE_PKG_VERSION + "/tutorials/onboarding.star"
)

The if "tutorials" not in config guard is the conventional pattern for extension configs: write additively so you never clobber a key the user already set or a tutorial another extension contributed.

After install the user has a working tutorial my-extension and no per-user setup steps. When you ship a new version of the extension the same flow re-runs; Rune again shows the diff, asks for permission, and persists the result.

This is the right place for a tutorial that walks the user through your extension: what commands it exposes, what config it understands, how to turn on optional features, and what each shortcut does.

DSL reference

Every tutorial is one Starlark file that calls tutorial(...) exactly once with an entry function. The entry function runs on its own goroutine and uses ordinary Starlark control flow (if/elif/else, for, while, def, comprehensions, string formatting) on top of the builtins below. Blocking builtins return real values, so authors can branch and loop on user input inline.

tutorial(entry, id?, title?, version?)

  • id (optional): stable identifier. Defaults to the name the tutorial is registered under.
  • title (optional): human-readable title. Defaults to the name.
  • version (optional): your own version string, surfaced via Tutorial.Version(). Use whatever scheme suits you.
  • entry (required): a 0-argument callable. The runtime invokes it when the user runs tutorial start <name>. Returning normally, calling exit(), or letting cancel_on_dismiss short-circuit ends the tutorial.

command_key()

Returns the user's configured command-prompt key (default :). Useful when you want to embed it inside step text without hard-coding it:

ck = command_key()
welcome = "Press `" + ck + "` to open the command prompt."

The on_error hint of wait_command gets this for free: the literal token <cmd> is replaced with the command key at render time.

editor_mode()

Returns the user's resolved editor mode, either "modal" or "modeless". When the workspace is set to exo, this resolves to the exo fallback so a tutorial always sees a concrete mode. Use it to adjust prose where the two modes differ conceptually:

if editor_mode() == "modal":
note = "Press `<esc>` to return to normal mode first."
else:
note = ""

key_for(command, *args)

Returns the key spec the user has bound to command (with optional arguments), or "" when nothing is bound. The lookup reflects the user's actual command.key_bindings, so it tracks custom rebindings rather than defaults. When an arguments-qualified lookup misses, it falls back to the binding for the bare command. Use it to show a key hint only when one exists:

def keyhint(cmd, *args):
k = key_for(cmd, *args)
return (" Default key: `" + k + "`.") if k else ""

text = "Split the workspace with `windownew`." + keyhint("windownew")

exit() and cancel_on_dismiss(result)

exit() ends the entry function immediately. It works from any depth (helper functions, loops, nested branches) and is cleaner than threading a return value back up.

cancel_on_dismiss(result) is sugar for "if the user dismissed this prompt, exit; otherwise return the result unchanged." It works with anything that has a .selected boolean attribute; both choice and confirm results qualify:

pick = cancel_on_dismiss(choice(
message="Where to start?",
options=["Open workspace", "Edit a file"]))
# pick.selected is always True here.

Level constants

notify(level=...) accepts the module-level constants error, warn, info, and success instead of magic integers.

Blocking UI builtins

Each blocking builtin pauses the entry function until the user responds. Cancellation (<esc> on a prompt, tutorial stop, or the IDE shutting down) unwinds the function cleanly.

floating_window(text, title=None, alignment=None, offset=None, allow_keys=None, dismiss_keys=None)

A framed window with markdown content. Blocks until the user presses <enter>, <space>, or <esc> to dismiss. By default the window is centered on the screen; alignment and offset let you pin it to a corner or edge instead.

  • text: markdown body. Standard markdown: headings, bold, italics, lists, inline code, fenced blocks.

  • title (optional): single-line title rendered at the top of the window. Setting a title turns the top frame edge into an inverse-video status bar that also shows Step N (the current visible-content step number) on the right.

  • alignment (optional): where on the screen the window anchors. One of:

    ValuePosition
    "center" (default)Centered vertically and horizontally
    "top"Top edge, horizontally centered
    "bottom"Bottom edge, horizontally centered
    "left"Left edge, vertically centered
    "right"Right edge, vertically centered
    "top-left"Top-left corner
    "top-right"Top-right corner
    "bottom-left"Bottom-left corner
    "bottom-right"Bottom-right corner
  • offset (optional): a (x, y) tuple of integers that nudges the window away from the anchor. On a left/right edge x shifts inward (toward the center). On a top/bottom edge y shifts inward. On the centered axis the offset adds directly: positive values move right or down, negative values move left or up. Offsets are clamped so the window always stays fully on screen.

  • allow_keys (optional): list of key specs (e.g. ["<meta-1>", "<meta-2>"]) that the window does not swallow. Matching events fall through to the IDE root without dismissing the window, so the user can act on the very bindings they're reading about. Use this when the text says "Press X to do Y" and pressing X should actually do Y while leaving the window open.

  • dismiss_keys (optional): list of key specs that dismiss the window and fall through to the IDE root. Use this for read-then-act flows: the window says "Press : to open the command prompt", the user presses :, the window resolves, and the IDE root sees the : so the prompt opens. The next step (typically wait_command) sees the prompt the user just opened.

Examples:

# Centered welcome (the default).
floating_window(title="Welcome", text="...")

# Tucked into the top-right, 2 cells in and 1 cell down.
floating_window(
title = "Tip",
text = "Try `<cmd>edit` to open a file.",
alignment = "top-right",
offset = (2, 1))

# Pinned to the bottom edge, slightly above the status bar.
floating_window(
text = "Press `<enter>` to continue.",
alignment = "bottom",
offset = (0, 2))

# Welcome screen that lets the user actually try meta-1..meta-9 and
# that advances when they press the command-prompt key.
ck = command_key()
floating_window(
title = "Welcome",
text = "Press `<meta-1>`..`<meta-9>` to switch slots, or "
"`" + ck + "` to open the command prompt.",
allow_keys = ["<meta-1>", "<meta-2>", "<meta-3>",
"<meta-4>", "<meta-5>", "<meta-6>",
"<meta-7>", "<meta-8>", "<meta-9>"],
dismiss_keys = [ck])

Best for the introduction step, transitions ("now let's look at X"), and anything where the user needs to read a paragraph before moving on. Use alignment and offset when you want to keep the code or output behind the window visible, for instance pinning the window to a corner so the user can still see their cursor.

markdown(text)

A plain top-left banner. Lighter than floating_window; no frame, no centering. Advances on <enter>, <space>, or <esc>. Use for short prompts where a framed window would feel heavy.

wait_key(key)

Pause the tutorial until the user presses a specific key. A framed hint box appears at the top-center showing Press <key> to continue.

  • key: a key spec in the same syntax used elsewhere in Rune (<ctrl-c>, <enter>, <f1>, <a-tab>, plain q for the letter q, etc.). See the key syntax reference.

wait_command(command, on_error=None, title=None)

Pause until the user dispatches a specific Rune command from the command prompt. This is the step type that makes tutorials feel interactive; it advances when the user does the thing rather than when they press a key.

While the step is armed, Rune renders a framed hint box at the top-center that shows the awaited command and inlines its manual (name, synopsis, summary). On a failed dispatch the box switches to the on_error hint and stays armed.

  • command: the command name to wait for. Matches both the user-typed name and any alias that expands to it, so if your user has w aliased to wopen, both forms advance the step.
  • on_error (optional): replacement hint shown after the user attempts the command and it fails (for example, missing arguments). The hint is markdown; the token <cmd> is replaced with the command-prompt key. The step stays armed so the user can fix and try again.
  • title (optional): a status-bar title rendered on the hint box, matching the floating_window title treatment. Use the same title as the preceding floating_window so the waiting step reads as a continuation of the step the user just read.

wait_command returns a command_result with:

  • .name: the matched command name.
  • .args: a tuple of positional argument strings the user passed to the command.
def run():
floating_window(text="Open a file with `<cmd>edit`.")
edit = wait_command(command="edit")
notify(level=success, message="You opened " + edit.args[0])

tutorial(entry=run)

wait_shell(args, on_error=None, title=None)

Pause until the user runs a specific command inside Rune's companion shell. Use it to guide the user through shell workflows such as installing a package or signing in to a model provider.

wait_shell is exclusively about commands run within the shell, so it words its own hint accordingly and only advances on shell activity. It does not cover opening the shell — opening it is just running the shell command from the command prompt, which you wait for with wait_command(command="shell").

  • args (required) — a non-empty list of argument tokens the shell command must contain, for example ["pkg", "install", "rune-agent"]. Matching is containment, not exact equality: every token in args must appear among the command's arguments, in any position, so completion, aliases, and extra flags still match.
  • on_error (optional) — replacement hint shown after a failed shell dispatch. Same semantics as wait_command's on_error: it is markdown, <cmd> expands to the command-prompt key, and the step stays armed so the user can fix and retry.
  • title (optional) — a status-bar title rendered on the hint box, same as wait_command's title.

Like wait_command, wait_shell returns a command_result whose .args is the full tuple of arguments the user actually ran (handy when args matched loosely and you want the exact tokens).

def teach_agent():
# First, get the user into the companion shell — that is just the
# `shell` command run from the command prompt.
floating_window(text="Open Rune's companion shell with `<cmd>shell`.")
wait_command(command="shell")

# Then wait for them to install the agent package inside it.
floating_window(text="Run `pkg install rune-agent` in the shell.")
wait_shell(
args=["pkg", "install", "rune-agent"],
on_error="In the companion shell, run `pkg install rune-agent`.")
notify(level=success, message="Rune Agent installed.")

tutorial(entry=run)

confirm(message)

Opens a real Yes / No prompt. Returns True on Yes, False on No or dismissal. Use it to branch the tutorial on the user's answer:

if confirm("Want to learn how to open a file next?"):
teach_edit()

choice(message, options)

Opens a real prompt with the given options. Returns a choice_result with:

  • .value: the selected option string ("" on dismissal).
  • .index: the 0-based option index (-1 on dismissal).
  • .selected: True if the user picked an option, False on dismissal.
pick = choice(
message="Where to start?",
options=["Open a workspace", "Edit a file", "Done"])
if pick.value == "Open a workspace":
...
elif pick.value == "Edit a file":
...

Side-effect builtins

These do not require user interaction; they execute their work synchronously and return immediately so the entry function keeps running.

notify(message, level=info)

Shows a system notification. The default level is info. Use success for confirmations that something worked, warn for expected errors, error for unexpected failures.

open_file(uri)

Opens uri in the focused editor. Returns when the editor has finished opening; if the editor reports an error the runtime surfaces it as a notification.

highlight_window(anchor)

Currently a stub. Renders nothing and returns immediately; the anchor-based highlight is on the roadmap.

Patterns

Branch on user input

Use choice or confirm and branch with if/elif/else:

def run():
pick = cancel_on_dismiss(choice(
message="Where do you want to start?",
options=["Open a workspace", "Edit a file", "Done"]))

if pick.value == "Open a workspace":
teach_workspace()
elif pick.value == "Edit a file":
teach_edit()
# else "Done", fall through.

Loop over a checklist

Walk the user through a list of related tasks with a for loop:

def run():
for path in ["README.md", "Makefile", "main.go"]:
floating_window(text="Open `" + path + "` next.")
wait_command(command="edit")

Compose with helpers

Tutorials get long; factor out repeated sequences into def:

def teach_edit():
floating_window(title="Open a file", text="Use `<cmd>edit`.")
r = wait_command(command="edit")
notify(level=success, message="You opened " + r.args[0])

def run():
floating_window(title="Welcome", text="...")
if confirm("Want to learn how to open a file?"):
teach_edit()

Bail out cleanly

  • return from entry ends the tutorial normally.
  • exit() ends it from any depth (handy in deeply nested helpers).
  • cancel_on_dismiss(...) ends it when the user dismisses a prompt.
  • fail("...") ends it with an error notification.

Wait for a real action, not a keystroke

Whenever you can, reach for wait_command instead of floating_window followed by wait_key:

# Yes
floating_window(text="Open a workspace with `<cmd>wopen`.")
wait_command(command="wopen")

# Less so
floating_window(text="Open a workspace yourself, then press <enter>.")
wait_key(key="<enter>")

The first form advances only when the user actually opens a workspace; the second only checks that they pressed Enter.

For workflows that happen inside Rune's companion shell — installing a package, signing in to a model provider — reach for wait_shell rather than wait_command(command="shell"). It words its hint for the shell and matches the shell command's argument tokens directly.

Use on_error to teach syntax

If your command takes arguments, give the user a friendly nudge when they get it wrong. The default hint is generic; an on_error hint can be specific:

wait_command(
command = "edit",
on_error = ("`<cmd>edit` needs a `<file>` argument. Use the "
"auto-completer (Tab / arrow keys) to pick a file, "
"or type a path inside the workspace."))

The hint is markdown and can run several lines.

Errors and recovery

  • Parse errors are surfaced as workspace notifications when Rune starts. The offending tutorial is skipped; the rest continue to work.
  • A failed open_file (no editor, bad URI) shows the error as a notification and the entry function continues with the next builtin.
  • A failed wait_command or wait_shell does not advance. The IDE's command prompt already shows the error, and the step stays armed with the on_error hint (when supplied) so the user can fix and retry.
  • A runtime error in the entry function (or anywhere it calls) surfaces as an error notification and ends the tutorial.
  • fail("message") ends the tutorial with an error notification.
  • The user can always run the tutorial stop command to dismiss the overlay.

See also

Ask Rune Agent