Skip to content

feat: headless JSON runner, Web Viewer, widget registry, Octant/Framebuffer#129

Merged
mathiasbourgoin merged 16 commits intomainfrom
pr-to-trilitech
Mar 10, 2026
Merged

feat: headless JSON runner, Web Viewer, widget registry, Octant/Framebuffer#129
mathiasbourgoin merged 16 commits intomainfrom
pr-to-trilitech

Conversation

@mathiasbourgoin
Copy link
Collaborator

  • Headless JSON runner (MIAOU_DRIVER=headless): drive TUI pages over stdin/stdout as newline-delimited JSON for programmatic control and testing
  • Web Viewer: live TUI observation over WebSocket/xterm.js via on_frame callback; supports auto-reconnect and dimension sync
  • miaou-registry: new package for runtime widget discoverability with mli-embedded self-registration
  • Octant rendering mode: sub-character pixel art using Unicode octant blocks
  • Framebuffer widget: raw pixel buffer widget backed by the octant renderer
  • Grid row_gap fix: blank separator lines no longer merge with the next row's first line
  • Bump version to 0.4.2

mathiasbourgoin and others added 16 commits February 28, 2026 10:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…less

Any Miaou TUI app can now be driven headlessly by an AI agent or CI
script without a separate binary or app changes.  Set
MIAOU_DRIVER=headless and the app reads newline-delimited JSON commands
from stdin and writes JSON frames to stdout.

Protocol (subset):
  {"cmd":"render"}              → {"type":"frame","text":"...","rows":R,"cols":C}
  {"cmd":"key","key":"Tab"}     → frame (after 3 idle ticks)
  {"cmd":"tick","n":N}          → frame
  {"cmd":"resize","rows":R,"cols":C} → frame
  {"cmd":"quit"}                → {"type":"nav","action":"quit"}

Implementation:
- New src/miaou_runner/headless_json_runner.ml — JSON loop using
  Lib_miaou_internal.Headless_driver.Stateful; inline ANSI strip.
- runner_tui.ml — env-var check at top of run dispatches to headless
  runner before any terminal backend initialisation.
- dune — adds headless_json_runner module, miaou-core.core,
  miaou-core.lib_miaou_internal, yojson, unix deps.
- New standalone `miaou-registry` opam package (zero deps beyond stdlib):
  `Miaou_registry.{register,list,find,search}` with Mutex-safe global table.

- 23 widgets self-register at library load time via `[%blob "...mli"]`:
  8 display (pager, list, description_list, table, tree, sparkline,
  line_chart, bar_chart), 8 input (textbox, textarea, validated_textbox,
  select, checkbox, radio, button, switch), 7 layout (box, progress,
  spinner, file_browser, card, toast, canvas).

- Non-widget utility modules marked `[@@@enforce_exempt]`.

- `ppx_enforce` (new companion to ppx_forbid, same repo) enforces the
  presence of `Miaou_registry.register` in every widget `.ml` — new
  widgets without a registration call fail to compile.
  Config: `.ppx_enforce` at project root.

- Explicit `(preprocessor_deps (file ...mli))` per widget to make blobs
  work without glob-induced dependency cycles.
…le_key

Pages that implement the new on_key API (like the miaou-composer designer)
had their key events silently dropped because the headless driver was
calling the deprecated handle_key stub (which returns pstate unchanged).
Switch both the batch run() loop and Stateful.install_page to call
P.on_key with a proper Keys.t value converted from the string key name.
The row_gap code emitted \n BEFORE each gap blank line but not AFTER
the last one, so the next row's first content line was concatenated
onto the gap blank. When Flex truncates to width, the actual content
was lost (only whitespace survived).

Fix: emit \n AFTER each gap blank line instead of before. This ensures
each gap blank and each row's first line start on their own buffer line.

Add row_gap and row_gap_multi_line test cases to verify.
The headless driver's idle_wait loop was purely synchronous,
never yielding to the eio scheduler. Fibers spawned via
Fiber.fork (e.g. async LLM calls in crucible) were never
scheduled, causing the app to spin indefinitely with a
spinner but no actual work happening.

Add Eio.Fiber.yield() on each iteration so forked fibers
can make progress between ticks.
The headless JSON runner's main loop used `input_line stdin` which is a
blocking POSIX syscall.  This froze the entire eio event loop between
commands, preventing background fibers (e.g. LLM subprocess I/O) from
making progress.

Replace with Eio_unix.run_in_systhread to run the blocking read in a
system thread, keeping the eio event loop active so background fibers
can process I/O events (subprocess stdout, network calls, etc.).
Add optional ?on_frame:(string -> unit) parameter to
Headless_json_runner.run and Runner_tui.run. When provided, the
callback is invoked with the raw ANSI frame content (before
stripping) on every frame emit.

Add Web_viewer module to miaou-driver-web: a standalone viewer-only
HTTP+WebSocket server that serves the xterm.js viewer page and
broadcasts raw ANSI frames to all connected viewers. Designed to
run alongside the headless driver so a human can observe an AI
agent's TUI session in a browser.
xterm.js with convertEol:false treats bare LF as move-down without
carriage return. The headless driver produces newline-separated text
with bare LF. Convert \n to \r\n in broadcast and prepend clear-screen
so each frame redraws cleanly in the viewer terminal.
- on_frame callback now passes ~rows ~cols so viewers know the terminal size
- Web_viewer.broadcast accepts ~rows ~cols, sends {"type":"dimensions",...}
  JSON to viewers when dimensions change
- New viewers receive current dimensions + last frame on connect (no blank screen)
- client.js: viewers handle dimensions message to resize xterm.js, FitAddon
  auto-fit disabled for viewers (size controlled by server)
- client.js: auto-reconnect with 2s retry when viewer WebSocket disconnects
Document the new Web_viewer module, on_frame callback, viewer
auto-reconnect, and dimension sync in the unreleased section.
When a web viewer is attached (on_frame callback set), fork an Eio
daemon fiber that re-renders the screen every 200ms and broadcasts
changes.  This keeps the viewer up-to-date even when the agent is
idle, catching background activity (timers, async I/O, spinners).
The viewer-refresh daemon fiber was calling idle_wait ~iterations:1
every 200ms.  idle_wait yields to the eio scheduler on every iteration,
allowing the daemon to interleave with the command handler's own
idle_wait call.  Both fibers then concurrently mutate the shared
page-state ref and double-tick clocks/timers.

Fix: the daemon now reads the cached screen content directly
(HD.Screen.get) without advancing any state.  The cache is always
up-to-date because every tick/key/render command handler updates it
before returning a response.
- Implement Octant_canvas for high-resolution (2x4) colored charts using Unicode 16 octants.
- Add Octant mode support to Sparkline, Line_chart, and Bar_chart widgets.
- Introduce Framebuffer_widget for direct pixel/cell-based manipulation.
- Add Terminal_caps for detecting Unicode 16 (octant) support.
- Add "Framebuffer & Octant Charts" demo to the gallery.
- Update System Monitor and Line Chart demos to include Octant toggles.
… in CI

- README: step-by-step build-from-source guide covering system SDL libs,
  ppx_forbid/ppx_enforce pins, and opam install; update the opam snippet
  in the Dependencies section to match
- Makefile: deps target now pins ppx_forbid and ppx_enforce before
  running opam install so `make deps` works out of the box
- CI: also pin ppx_enforce (lives in the same repo as ppx_forbid) so
  `opam install --deps-only` can resolve it

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mathiasbourgoin mathiasbourgoin merged commit 499ae48 into main Mar 10, 2026
1 check 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