Documentation
Debug inspect
In-process debugger for Walnut apps: Option+click view picking, expression print (p / $0 / model), dispatch into the TEA loop, soft breakpoints, and msg-fold time travel with a full-screen host console.
This is separate from host scripts and the REPL (jit.md). Use lldb for raw UIKit when needed; model and message inspection go through this console.
Quick start
cd MyApp
walnut debug
Builds, launches with WALNUT_DEBUG=1, waits for 127.0.0.1:8765, opens the debug console (messages | model | scrubber | command).
walnut debug --repl # classic line REPL (↑↓ history)
walnut debug --attach # Simulator, already running with --debug
walnut debug --attach --host 192.168.1.20 # physical device on Wi‑Fi
walnut simulator --debug
Console
Spatial panes (like a file manager). You start in the scoped prompt for the current msg + stack frame.
MESSAGES → STACK → MODEL → PROMPT
↑↓ ↑↓ → Esc/← backs out
| Input | Action |
|---|---|
← → / Enter / Esc | Move between panes (Esc backs toward MESSAGES) |
↑ ↓ | Navigate the focused pane (time or frames; respects msg filter) |
| type anything | Jump to PROMPT in current msg/frame scope |
MESSAGES b | Toggle bookmark on the current index |
MESSAGES B | Jump to next bookmark (wraps) |
MESSAGES s / n | Scrub-step: goto cursor+1; flash if label matches a soft break |
MESSAGES / | Jump to prompt with / for filter entry |
Prompt /Got | Filter MESSAGES to labels containing Got (case-insensitive) |
Prompt / | Clear filter |
Ctrl-L | Jump to live tip |
Ctrl-R | Refresh |
Ctrl-C / quit | Exit |
Row marks in MESSAGES: * fork · ' bookmark · ● soft-breakpoint ctor match. Header shows MESSAGES /filter when filtering. Soft-break queue still uses continue / step (drain one); MESSAGES s is scrub-step for time travel.
Prompt shows scope: debug[#19 Increment · Increment]>.
Device Wi‑Fi
- Build/install the app on a device with debug enabled (
WALNUT_DEBUG=1, same Wi‑Fi as your Mac). - Note the listen address from the device console (
[walnut-debug] listening on 192.168.…:8765). - Allow inbound TCP on that port (Local Network / firewall as needed).
- Attach from the Mac:
walnut debug --attach --host <device-ip> [--port 8765]
WALNUT_DEBUG_HOST can set the default host. Optional WALNUT_DEBUG_PORT sets the listen port inside the app.
Print (p / bindings)
| Expression | Meaning |
|---|---|
p model / model | Current TEA model (wn_debug_format) |
model.count | Record field walk |
$0 | Option+click selection summary |
$0.kind / $0.path / $0.key | Identity |
$0.attrs / $0.attrs.centerX | VNode attrs |
$0.frame / $0.class | Hosted UIView |
$msg | Last dispatched message |
In the TUI, type at debug> (always on); arrows scrub when the line is empty. In --repl mode, ↑↓ / Ctrl-R recall prior commands (~/.walnut/debug_history) — same libedit stack as walnut repl, not the TUI key map:
debug> p model
{ count = 0 }
debug> dispatch Increment
# dispatched Increment
model = { count = 1 }
debug> model.count
1
Dispatch
Ctor expressions are built from walnut_debug_meta.json (emitted next to Main.o at compile time, copied into the app bundle).
debug> dispatch Increment
debug> ctors Msg
debug> tap # re-fire $0's onTap
Supported literals: ints, floats, strings, True / False, nested ctors / (…).
Option+click → $0
- Focus the Simulator.
- Hold Option (⌥) and click a view.
- Orange highlight + selection broadcast.
tapif the node hasonTap.
Time travel (msg fold)
Every dispatch (UI, debug, effect callbacks) appends the message to a journal. goto N rebuilds the model by folding init + msgs [0…N], re-renders, and drops Cmds while scrubbing. Effect results that already entered update (e.g. GotPrefs) are ordinary journal entries — scrubbing replays them as data, without re-hitting the network or Core Data.
| Command | Meaning |
|---|---|
journal / history | List msgs + scrubber strip |
journal --json | Snapshot for the host TUI |
goto N / goto init / goto tip | Fold to that index |
back / forward | goto cursor±1 |
live | Jump to tip; subscriptions on again |
scrubber | One-line progress bar |
export [path] | Session JSON (no path → print; Mac export ./f.json saves on host; bare name → Documents/) |
import <path|json> | Rebuild journal + fold to saved cursor |
Hybrid snapshots every 32 steps keep long gotos fast. Cap ≈ 10k msgs.
debug> dispatch Increment
debug> dispatch Increment
debug> journal
[========|---------------] 1/1 live Increment
> -1: <init>
0: Increment
> 1: Increment
debug> stack
via: debug dispatch
route: Increment
> 0 Msg.Increment
debug> goto 0
# model is count=1 again; view updates; Cmds not re-run
debug> live
Session export / import
Capture a journal as JSON, quit the app, come back later (or hand the file to a teammate), and replay the same msgs into a fresh fold — without re-tapping the UI or re-hitting Core Data / HTTP.
What you get:
- A portable list of dispatchable msg labels (same strings
dispatchaccepts) - The scrubber cursor so import lands on the same step
- Light provenance (
via) for each entry
What you do not get (yet): serialized model blobs, fork pins, or Cmd re-execution. Import rebuilds msgs from labels via walnut_debug_meta.json, then goto folds init + those msgs.
Showcase (Counter)
walnut debug --repl
In the REPL:
debug> dispatch Increment
debug> dispatch Increment
debug> dispatch Increment
debug> goto 1
# model count = 2
debug> export ./.tmp/bug-count-2.json
# exported session → …/bug-count-2.json
Kill the app / quit the console. Later:
walnut debug --repl
debug> import ./.tmp/bug-count-2.json
# imported 3 msgs → cursor 1
debug> journal
> 1: Increment
debug> p model
{ count = 2 }
Same file works from the full-screen TUI prompt (: → import ./…). Commit .tmp/*.json fixtures next to an example when you want a regression “this sequence of msgs → this model.”
Commands and paths
| Command | Where it writes / reads |
|---|---|
export | Print one-line JSON on the wire (copy/paste) |
export ./path.json | Mac host (REPL/TUI intercept) — recommended |
export name | App Documents/name.json (Simulator or device) |
import ./path.json | Mac host reads file, sends JSON to the debug server |
import name | App Documents |
import {…} | Inline JSON (one line) |
Bare names without / always mean the app sandbox. Paths with /, ./, ../, or ~/ are handled on the Mac so you never dig through simctl get_app_container.
JSON shape
{
"formatVersion": 1,
"kind": "walnut-debug-session",
"app": "Counter",
"cursor": 1,
"entries": [
{"label": "Increment", "via": "debug:dispatch"},
{"label": "Increment", "via": "debug:dispatch"},
{"label": "Increment", "via": "debug:dispatch"}
]
}
Nested apps export the same pretty labels you already use in the console, e.g. Nav (SelectTab TodayTab) or Persist (GotPrefs …) — anything dispatch can rebuild.
Limits
- Labels must still parse against the current
Msg/ meta. Renaming a ctor breaks old sessions (expected). - Import clears the live journal first; it does not merge.
- Effect callbacks that were journaled as msgs replay as data; they do not fire the effect again.
TEA stack (nested Msg)
Left column is time. STACK is the structure of the current step — nested ctors plus how the msg entered update:
| Command | Meaning |
|---|---|
stack | Unwrap spine + via: provenance + route |
up / down | Select outer → inner frame ([ / ] in TUI when prompt empty) |
frame | Print selected frame |
bt | Native backtrace captured on soft break |
debug> dispatch Nav (SelectTab TodayTab)
debug> stack
via: debug dispatch
route: Nav -> SelectTab
> 0 Msg.Nav SelectTab(TodayTab)
1 NavMsg.SelectTab TodayTab
debug> down
# frame 1 — NavMsg.SelectTab …
Provenance examples: via: ui button path=0.1.2, via: effect Cmd.CoreData.fetchAll, via: sub Sub.Time.every.
Honest limits
| You get | You do not get (yet) |
|---|---|
| Full msg timeline + scrubber TUI | Re-executing HTTP / timers / Core Data on scrub |
| Nested Msg frame walk + provenance | Full DWARF / lldb integration inside this console |
Soft break + optional native bt | Cheats alone surviving a bare goto (use --fork / replay) |
| Model cheats + what-if forks | Re-running Cmds for a forked timeline |
Session export / import (journal JSON) | IDE DAP (same protocol later) |
Cheats (poke live model)
Skip inventing a Msg when you just want to see the UI react — or when no setter exists (userName in Todo):
| Command | Meaning |
|---|---|
set model.field <val> | wn_record_update + re-render (literals / ctors) |
toggle / inc / dec | Bools and ints |
… --fork / fork / replay | Pin what-if at cursor; later msgs fold from it |
unfork [N|all] | Drop what-if pin(s) |
redraw | Re-run view(model) only |
reinit | Call init again; clears the journal |
checkpoint save|restore|list|clear [name] | Named model snapshots |
watch <path> / unwatch | Print path after each update (Δ only) |
debug> set model.userName "Bob"
debug> p model.userName
"Bob"
debug> watch model.count
debug> dispatch Increment
# watchΔ model.count = 1
Plain cheats are not journaled — a bare goto rebuilds from msgs and overwrites them.
What-if forks (impact later msgs)
To change history at msg #10 and re-fold #11…tip from that model:
debug> goto 10
debug> set model.items [] --fork
debug> replay
# folds msgs 11…tip starting from empty items
Or step by step: set … then fork then goto 20. Journal marks forked indices with *. unfork drops the pin.
Soft breakpoints
debug> break Increment
debug> dispatch Increment
# paused — continue / step / break clear
debug> continue
Matches the pretty-printed ctor name from walnut_debug_meta.json, not lldb.
Other commands
| Command | Meaning |
|---|---|
tree [depth] | Summarize last VNode tree |
highlight <key> | Select by Attr.key or path |
clear | Drop selection + highlight |
ctors [Type] | List constructors from meta |
set / toggle / inc / dec | Poke live model fields |
redraw / reinit / checkpoint / watch | More cheats — see above |
reload [path] [reinit|preserve] | Hot-reload dylib |
ping / help / quit | Misc |
How it works
- Launch sets
WALNUT_DEBUG=1(Simulator:SIMCTL_CHILD_WALNUT_DEBUG=1). - After first paint the app starts the debug server (listens on all interfaces), capturing the init model.
- Pass-through overlay captures Option-modified hits.
- TCP line protocol on port 8765 (
--port/WALNUT_DEBUG_PORTto override). dispatchbuilds a message and feeds it into the TEA loop.- Each update journals the message;
gotofolds the timeline (side-effecting cmds are not re-run).
Limits
- Signing / install on device:
walnut device(Automatic team +devicectl) — see device.md. Then attach withwalnut debug --attach --host <ip>. - One debug server per port (
:8765by default). A previous debug app still listening will steal the connection —walnut debugfrees the port on Simulator launch and checksapp=Namein the greeting. - Scrubbing restores model by msg fold; it does not re-run
Cmd/Subside effects. - Soft breakpoints match pretty-printed ctor names from meta, not lldb instruction breaks.
- Records are not yet expressible in the ctor DSL; lists use
[a, b, …].
Hot reload
See hot-reload.md: --hot --watch recompiles a dylib and reloads without killing the Simulator app.
Related
- effects.md — TEA / Cmd / Sub
- jit.md — host
walnut repl/walnut run - hot-reload.md — in-process program reload
- testing.md — in-language
Teston the host (separate from this console)