Skip to content

docs: add VHS walkthrough demo to README#50

Merged
fhrbata merged 9 commits into
mainfrom
docs/add-walkthrough-demo
May 11, 2026
Merged

docs: add VHS walkthrough demo to README#50
fhrbata merged 9 commits into
mainfrom
docs/add-walkthrough-demo

Conversation

@fhrbata
Copy link
Copy Markdown
Owner

@fhrbata fhrbata commented May 7, 2026

Summary

  • New demo/demo.tape (VHS recording recipe) + rendered demo/demo.gif.
  • README now embeds the GIF right under the tagline so first-time visitors see ice driving a real project end to end.
  • .gitignore excludes the esp-idf/, .ice/, build/ trees that vhs creates under demo/ while recording.

The walkthrough exercises tab completion, ice repo checkout, ice init, ice menuconfig, ice build (toggling verbose with Ctrl-V), ice flash over USB-JTAG, ice monitor, ice qemu, ice qemu --debug, and ice debug (reset, continue, hop into the chip's console via the monitor pane, tab-complete help, hop back, interrupt, backtrace).

Recording requires vhs + ttyd + ffmpeg, an attached ESP32-S3, and a populated ~/.ice/esp-idf reference; full prerequisites and tweakable knobs are documented in the tape's header.

Test plan

  • README renders correctly on github.com (GIF visible at the top, tape link works).
  • vhs demo/demo.tape regenerates demo/demo.gif on a fresh clone with the chip attached.

🤖 Generated with Claude Code

@fhrbata fhrbata force-pushed the docs/add-walkthrough-demo branch from 876f907 to 9264953 Compare May 10, 2026 17:55
fhrbata added 9 commits May 11, 2026 09:25
… prompt

Two issues with the dual-pane debug orchestrator left the user
staring at a screen they couldn't tell was live:

1. No initial paint.  run_debug() entered its main loop with
   `dirty=0`, so the alt screen stayed blank until something set
   dirty=1 -- gdb's first chunk after `target remote :<port>` can
   land outside the 30ms read budget on a slow handshake, so users
   saw an empty alt screen until they pressed a key.  Both
   cmd/target/openocd and cmd/target/qemu --debug share this shape;
   the single-pane callers (cmd/target/qemu, cmd/target/monitor)
   already render once before their loop.

2. (openocd only) gdb prompt buried by trailing openocd "Info"
   lines.  gdb writes "(gdb) " to its pty, then openocd's
   post-attach chatter (typically `Info : Detected FreeRTOS
   version: (10.5.1)`) runs through the same vt100 grid and
   advances the cursor past the prompt.  The user sees
   `(gdb) Info : Detected FreeRTOS...` with the cursor on a blank
   line below; gdb has no reason to redraw until it gets input, so
   the prompt looks dead until you press Enter.

Fix:

- Seed `dirty=1` on the first loop iteration in both run_debug()
  variants so the empty layout + status bar paint immediately.

- (openocd) Track time of last activity on either pipe.  Once gdb
  has been seen AND both streams have been quiet for 300ms AND the
  user hasn't typed yet, send a single bare newline to gdb's pty.
  gdb prints a fresh prompt below the noise.  The empty Enter is
  safe: `set confirm off` is set, and `set` / `target remote` are
  dont_repeat in gdb, so it's a guaranteed no-op apart from the
  redraw.  Suppressed if the user has already typed -- their
  characters went to gdb's stdin, and prefixing them with \n could
  change the command boundary.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
…ate text

Three issues with the one-line attach hint at the top of the UART
pane:

- It used \x1b[2m (dim), which on attach+focus-switch made it easy
  to miss.  Switch to \x1b[33m (yellow) so the user spots it on
  first glance.

- The hint ended in a bare LF.  vt100's LF only moves the cursor
  down -- it doesn't reset the column -- so after rendering the
  hint the cursor sat one row below at the column past the hint
  text.  The first chip UART byte to land then started in the
  middle of an otherwise-empty pane, looking like a cursor parked
  somewhere weird until output started flowing.  Use CRLF.

- The text claimed `Ctrl-T r resets and shows boot logs`, but
  Ctrl-T r runs `monitor reset halt` -- the CPU stays halted at
  the reset vector, so no UART comes out until the user types
  `continue` in the gdb pane anyway.  Reset isn't the simpler
  primary action for "I want to see UART output"; `continue` is.
  Drop the reset reference and point the user at `continue`.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
…ollback

spawn_openocd() streams openocd's startup output (banner, JTAG
probe, listening-port lines) to ice's stderr in real time so the
user can watch the daemon come up and so any failure diagnostic
stays visible after ice debug exits.  Once the gdb listener is
detected the captured prelog was thrown away, so inside the alt
screen there was no way to look back at the banner -- you'd have
to drop out of the dual pane to find it in your terminal's
pre-alt-screen scrollback.

Hand the prelog through to run_debug() and seed gdb_p.L's ring
with tui_log_append() before the main loop.  The banner doesn't
land in the visible grid (vt100 fills the body, leaving 0
scrollback rows visible by default), but PgUp / Ctrl-T inspect
into the gdb pane brings it up immediately.

Ownership: cmd_target_openocd() owns the sbuf, spawn_openocd()
appends, run_debug() consumes (read-only).  On spawn_openocd
failure the caller releases.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
…gdb)

The settle nudge wrote a bare \n to gdb so it would redraw its
prompt below any trailing openocd "Info : ..." line that buried
the original (gdb) prompt.  Two issues with the previous shape:

1. The nudge fired unconditionally on the 300 ms quiet window,
   even when gdb's prompt was the last thing on the grid (no
   burial).  In that case the redraw added a redundant duplicate
   prompt one row below the real one.

2. When the prompt *was* buried, the redraw left a blank row
   between the buried prompt and the new one.  vt100 cursor sits
   at (N+1, 0) after openocd's trailing CRLF; gdb's response to
   \n is "\r\n(gdb) ", so \r → (N+1, 0), \n → (N+2, 0), prompt
   writes at (N+2, 0)-(N+2, 5), and row N+1 stays empty.

Fix:

- Decide whether to fire by reading the vt100 cursor column.
  Cursor at col != 0 means gdb's "(gdb) " is the latest thing on
  the grid -- skip the nudge.  Cursor at col 0 means openocd's
  CRLF is the latest -- fire.

- When firing, push "\x1b[A" into the gdb pane's vt100 before
  writing \n to gdb.  That moves vt100's cursor up onto the
  buried row, so gdb's \r\n echo steps the cursor onto the row
  that *was* the blank successor and "(gdb) " lands there
  instead of one row below it.  Side benefit: vt100's cursor row
  and gdb's mental cursor row stay aligned, so subsequent
  readline manipulation (backspace, tab redraw) operates on a
  consistent terminal model.

Verified on hardware: with the buried "(gdb) Info : Detected
FreeRTOS..." on row N, the redrawn "(gdb) " now appears on row
N+1 with the cursor at col 7, no blank gap.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
…ntry

The previous fix routed the captured prelog into gdb_p.L via
tui_log_append() so it landed in the scrollback ring.  But the
ring isn't visible by default -- vt100 fills the body, leaving 0
visible scrollback rows -- so the banner was reachable only by
PgUp / Ctrl-T inspect.  That defeats the point of preserving it
inside the alt screen: users who don't know to scroll just see
the banner vanish under the dual pane.

Feed it through vt100_input() instead.  The banner occupies the
top of the gdb pane immediately, then is naturally pushed into
the ring as gdb's output and openocd's post-attach Info lines
fill the grid -- exactly the behavior of any other long log
streamed into a viewport.  On a tall enough terminal the banner
stays visible alongside the rest of the session; on a small
terminal it scrolls into the ring as before, just with the same
discoverability path (PgUp).

Drain scrolled-off rows into the ring explicitly here -- pump_pipe
does this each frame, but on entry there's no read yet, so without
the explicit pull any rows that scroll off during the prelog feed
itself sit in vt100's bounded queue rather than in the ring.

Moved the seed past debug_layout() so vt100 is at the pane's final
size before content goes in.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
… app_main)

ice debug previously launched gdb with just -ex "target remote
:<port>" + the elf positional, on the assumption that this kept
chip state intact for post-mortem inspection.  That assumption
was always false on Espressif chips: openocd's standard board
files (esp32s3-builtin.cfg, esp32-wrover-kit-3.3v.cfg, ...)
already issue `reset halt` during their `init` step, *before* gdb
dials in -- you can see it in openocd's own log lines as
"Reset cause (3) - (Software core reset)" right after attach.
The chip is reset whether we like it or not.

The asymmetry between "openocd resets" and "gdb thinks it's
attaching to a free-running chip" produces a nasty trap.  After
openocd's reset-halt-and-init the chip is allowed to run again,
and ~few-hundred-ms later (the wall-clock budget of ice spawning
gdb and gdb dialing tcp/3333) gdb's gdb-stub-on-connect halt
lands somewhere in the post-reset boot path -- almost always on
app_main's prologue, because hello_world spends very little of
that window outside vTaskDelay.  The user then naturally types
`b app_main; c`.  The HW breakpoint they just armed sits at the
exact PC the chip resumes from; the very first instruction fetch
re-trips the match register before any instruction commits;
openocd-esp32's recovery for that state is a software CPU0
reset, which puts the chip back at the reset vector; the chip
runs through boot to app_main; the still-armed breakpoint fires
for real this time; the user `c`s; and we loop at ~10Hz with no
printf ever flushing.

Fix: pre-load gdb with the same connect fragment that
ESP-IDF's tools/cmake/gdbinit.cmake generates --

  target remote :<port>
  monitor reset halt
  maintenance flush register-cache
  thbreak app_main
  continue

The temp HW breakpoint at app_main fires exactly once on the way
through boot, gdb auto-removes it, the chip is parked at app_main
with no breakpoint at PC, and any subsequent `b app_main; c`
works because the user's breakpoint isn't sitting on the resume
PC anymore.  The one-instruction auto-skip required to step
*over* a freshly-installed breakpoint after the user has already
moved past it is the standard gdb path and does work.

The misleading port-resolution comment that referenced
"attaches to a *running* chip" as the rationale for the passive
port picker is also tightened: the rationale (don't toggle DTR/
RTS into ROM bootloader on the same USB device openocd's libusb
just enumerated) is real, but it's not about preserving chip
state -- openocd already destroyed that.

Verified end-to-end on the actual chip: with the preamble,
`b app_main; c` followed by repeated `c`s produces 12+ Hello
world prints across multiple esp_restart cycles, real
breakpoint hits at app_main, no software-reset loop.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
Now that ice debug pre-loads gdb with the idf.py-style preamble
(monitor reset halt + thbreak app_main + continue), the chip
parks at app_main via a clean breakpoint hit and the prompt
sequence no longer races openocd's "Detected FreeRTOS" Info line
the way it did when target remote was the only -ex.  The
defensive belt-and-suspenders I added earlier --

  - first_paint flag forcing a render on iteration 0,
  - settle nudge that wrote a bare \n to gdb after a 300ms quiet
    window when the gdb pane's vt100 cursor parked at col 0,
  - cursor-up trick that pre-emitted \x1b[A so gdb's \r\n echo
    landed the redrawn prompt on the originally-blank row,
  - user_typed gate that suppressed the nudge once the user had
    interacted,

were sized for the previous flow's race window and now produce a
visible regression: the post-preamble prompt is already clean
(cursor parked at col 7 right after "(gdb) "), but the settle
heuristic still fires often enough to draw a redundant second
prompt below the real one.

Drop the lot.  The shape of run_debug() returns to what it was
before commit ecbea1a, plus the keep-from-this-branch
changes (UART pane hint colors and CRLF, prelog seeded into the
gdb pane's live grid, idf.py-style gdb preamble).

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
resolve_idf_arg used to take the @b{<idf>} positional verbatim
(after the bare-name -> ~/.ice/checkouts/<name> shorthand) and
hand the result straight to setenv("IDF_PATH", ...) and the
profile config in .ice/config.  When the user passes a relative
path -- the natural shape inside the project tree, e.g.
@c{ice init esp32s3 ../../..} from
@c{esp-idf/examples/get-started/hello_world/} -- the value works
fine for the directly-spawned cmake configure (which inherits the
project's cwd), but esp-idf's project.cmake re-runs the toolchain
file from cmake's @c try_compile under
@c build/CMakeFiles/CMakeScratch/TryCompile-XXX/, where the
generated @c build/toolchain/toolchain-<chip>.cmake's
@c{include($ENV{IDF_PATH}/tools/cmake/toolchain.cmake)} resolves
the relative path against the wrong cwd and fails with
@c{include could not find requested file: ../../../tools/cmake/toolchain.cmake}.

Canonicalize the resolved path through a new platform helper
@c path_realpath that wraps POSIX @c{realpath(p, NULL)} on POSIX
and @c GetFullPathNameW on Windows.  Lives in platform/{posix,win}/io.c
next to the rest of the path-aware filesystem family
(getcwd_w, fopen_w, ...); the public declaration is in
platform.h with the cross-platform-semantics doc.  POSIX side
gates the file with @c{_XOPEN_SOURCE 500} since the build's
@c{_POSIX_C_SOURCE=200112L} doesn't expose @c realpath under
glibc.

If the path doesn't exist yet, @c path_realpath returns NULL and
@c resolve_idf_arg falls through with the verbatim string -- the
existing @c{<idf_path>/tools/tools.json} access() check below
will report a clean error.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
Add demo/demo.tape and demo/demo.gif: an end-to-end ice
walkthrough on ESP-IDF's hello_world example, driving tab
completion, ice repo checkout, ice init, ice menuconfig, ice
build (spinner-only -- Ctrl-V verbose toggle works
interactively but overwhelms vhs's screen tracking when
ninja streams hundreds of lines through it), ice flash over
USB-JTAG, ice monitor, ice qemu, ice qemu --debug, and
ice debug.  Real chip + real QEMU + real OpenOCD on the host
the chip is wired to.

Synchronization between vhs and ice's progress-driven steps
(checkout / init / build / flash) uses `Wait+Screen /<msg>
done/` against ice's success line so the next Type fires only
after ice has fully released stdin -- a wall-clock Sleep
either wasted time or got truncated, and bytes typed while
ice was still polling stdin in raw mode would get eaten.
+Screen (vs default +Line) is needed because the bash prompt
overwrites the success line right after it prints.  The
prelude clears the screen inside the Hide block so the
setup-cd / PS1-export typing doesn't bleed into the first
captured frame.

Hoist the existing `**Experimental PoC**` callout to the top
of the README so it's the first thing a reader sees, and
extend it to note that the project is tested primarily on
Linux -- Windows may not work as expected, or at all.

Drop the prose paragraph describing the walkthrough; the GIF
plus the tape file alongside it carry the same information
more cheaply.

Signed-off-by: Frantisek Hrbata <frantisek.hrbata@espressif.com>
@fhrbata fhrbata force-pushed the docs/add-walkthrough-demo branch from 9264953 to 7617b3a Compare May 11, 2026 07:26
@fhrbata fhrbata merged commit 794cdc8 into main May 11, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant