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:
- A centered framed window says hello. The user presses Enter, Space, or Esc to dismiss.
- The overlay waits at a small bottom-right hint. As soon as the
user actually dispatches the
wopencommand, the tutorial captures the first argument and the entry function continues. - 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.
- config.yaml
- config.star
tutorials:
onboarding: docs/onboarding.star
release-checklist: docs/release.star
"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:
- config.yaml
- config.star
# .rune/config.yaml
tutorials:
onboarding: .rune/tutorials/onboarding.star
# .rune/config.star
config["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:
# 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 viaTutorial.Version(). Use whatever scheme suits you.entry(required): a 0-argument callable. The runtime invokes it when the user runstutorial start <name>. Returning normally, callingexit(), or lettingcancel_on_dismissshort-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 showsStep N(the current visible-content step number) on the right. -
alignment(optional): where on the screen the window anchors. One of:Value Position "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 edgexshifts inward (toward the center). On a top/bottom edgeyshifts 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 (typicallywait_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>, plainqfor the letterq, 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 haswaliased towopen, 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 thefloating_windowtitle treatment. Use the same title as the precedingfloating_windowso 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 inargsmust 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 aswait_command'son_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 aswait_command'stitle.
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 (-1on dismissal)..selected:Trueif the user picked an option,Falseon 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
returnfromentryends 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_commandorwait_shelldoes not advance. The IDE's command prompt already shows the error, and the step stays armed with theon_errorhint (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 stopcommand to dismiss the overlay.
See also
- Command Prompt: how
tutorial,wopen,edit, and other commands are dispatched. - Key syntax: the syntax accepted by
wait_key. - Config reference: where the
tutorialsblock lives.