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
InputAction
/ Enter / EscMove between panes (Esc backs toward MESSAGES)
Navigate the focused pane (time or frames; respects msg filter)
type anythingJump to PROMPT in current msg/frame scope
MESSAGES bToggle bookmark on the current index
MESSAGES BJump to next bookmark (wraps)
MESSAGES s / nScrub-step: goto cursor+1; flash if label matches a soft break
MESSAGES /Jump to prompt with / for filter entry
Prompt /GotFilter MESSAGES to labels containing Got (case-insensitive)
Prompt /Clear filter
Ctrl-LJump to live tip
Ctrl-RRefresh
Ctrl-C / quitExit

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

  1. Build/install the app on a device with debug enabled (WALNUT_DEBUG=1, same Wi‑Fi as your Mac).
  2. Note the listen address from the device console ([walnut-debug] listening on 192.168.…:8765).
  3. Allow inbound TCP on that port (Local Network / firewall as needed).
  4. 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.

ExpressionMeaning
p model / modelCurrent TEA model (wn_debug_format)
model.countRecord field walk
$0Option+click selection summary
$0.kind / $0.path / $0.keyIdentity
$0.attrs / $0.attrs.centerXVNode attrs
$0.frame / $0.classHosted UIView
$msgLast 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

  1. Focus the Simulator.
  2. Hold Option (⌥) and click a view.
  3. Orange highlight + selection broadcast.
  4. tap if the node has onTap.

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.

CommandMeaning
journal / historyList msgs + scrubber strip
journal --jsonSnapshot for the host TUI
goto N / goto init / goto tipFold to that index
back / forwardgoto cursor±1
liveJump to tip; subscriptions on again
scrubberOne-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:

  1. A portable list of dispatchable msg labels (same strings dispatch accepts)
  2. The scrubber cursor so import lands on the same step
  3. 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

CommandWhere it writes / reads
exportPrint one-line JSON on the wire (copy/paste)
export ./path.jsonMac host (REPL/TUI intercept) — recommended
export nameApp Documents/name.json (Simulator or device)
import ./path.jsonMac host reads file, sends JSON to the debug server
import nameApp 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:

CommandMeaning
stackUnwrap spine + via: provenance + route
up / downSelect outer → inner frame ([ / ] in TUI when prompt empty)
framePrint selected frame
btNative 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 getYou do not get (yet)
Full msg timeline + scrubber TUIRe-executing HTTP / timers / Core Data on scrub
Nested Msg frame walk + provenanceFull DWARF / lldb integration inside this console
Soft break + optional native btCheats alone surviving a bare goto (use --fork / replay)
Model cheats + what-if forksRe-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):

CommandMeaning
set model.field <val>wn_record_update + re-render (literals / ctors)
toggle / inc / decBools and ints
… --fork / fork / replayPin what-if at cursor; later msgs fold from it
unfork [N|all]Drop what-if pin(s)
redrawRe-run view(model) only
reinitCall init again; clears the journal
checkpoint save|restore|list|clear [name]Named model snapshots
watch <path> / unwatchPrint 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

CommandMeaning
tree [depth]Summarize last VNode tree
highlight <key>Select by Attr.key or path
clearDrop selection + highlight
ctors [Type]List constructors from meta
set / toggle / inc / decPoke live model fields
redraw / reinit / checkpoint / watchMore cheats — see above
reload [path] [reinit|preserve]Hot-reload dylib
ping / help / quitMisc

How it works

  1. Launch sets WALNUT_DEBUG=1 (Simulator: SIMCTL_CHILD_WALNUT_DEBUG=1).
  2. After first paint the app starts the debug server (listens on all interfaces), capturing the init model.
  3. Pass-through overlay captures Option-modified hits.
  4. TCP line protocol on port 8765 (--port / WALNUT_DEBUG_PORT to override).
  5. dispatch builds a message and feeds it into the TEA loop.
  6. Each update journals the message; goto folds the timeline (side-effecting cmds are not re-run).

Limits

  • Signing / install on device: walnut device (Automatic team + devicectl) — see device.md. Then attach with walnut debug --attach --host <ip>.
  • One debug server per port (:8765 by default). A previous debug app still listening will steal the connection — walnut debug frees the port on Simulator launch and checks app=Name in the greeting.
  • Scrubbing restores model by msg fold; it does not re-run Cmd / Sub side 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.