Flutter desktop app (bxp-gui) that provides a visual config editor, dry-run
debugger, and expression playground on top of the bxp-cli / bxp-fmt CLI
pair. Runs on Linux, macOS, and Windows.
- Why Flutter / Dart
- Getting started
- Development workflow
- Architecture overview
- Source layout
- Subprocess wiring
- json5_ast library
- State management
- Key patterns
- Adding a new config field to the UI
bxp-gui replaced an earlier Electrobun + React + CodeMirror 6 frontend (bxp-ui, shipped as v0.1.0). The switch to Flutter was driven by:
- Cross-platform single binary. Flutter desktop compiles to a self-contained executable with no webview runtime dependency. The same Dart source builds on Linux, macOS, and Windows from one codebase.
- Subprocess streaming fits naturally.
Process.start()returns a stdoutStream<List<int>>that maps directly onto the NDJSON event model. No IPC bridge or marshaling layer — the stream is the protocol. - Hot reload. Flutter hot-reloads UI and state-logic changes in ~1 s without losing app state. Zig backend changes still require a process restart, but Dart-only iterations are immediate.
- Sound null-safety. Dart's type system catches whole classes of runtime
errors that were silent in the JS frontend. The
trace_model.dartevent mirrors are typed exhaustively. - Rich table widgets. PlutoGrid provides a spreadsheet-like trace view for the dry-run debugger — reproducing it in a webview would have required a heavy JS dependency.
The app never calls bxp-core directly. All heavy logic stays in bxp-cli / bxp-fmt subprocesses. Dart's role is: parse subprocess stdout, maintain UI state, render widgets. This boundary keeps the Dart codebase thin, testable, and decoupled from the Zig internals.
| Tool | Version | Notes |
|---|---|---|
| Flutter SDK | ≥ 3.x | See bxp-gui/pubspec.yaml environment.flutter for the minimum. Install from flutter.dev or via fvm. |
| Dart SDK | bundled | Ships with Flutter; no separate install. |
| Zig | 0.15.2 | To build bxp-cli and bxp-fmt. See devel.md for the pinned version. |
| VS Code | any | + Flutter extension. IntelliJ / Android Studio work too. |
# 1. Build the backend binaries (required before flutter run)
cd bxp-cli && zig build
cd ../bxp-fmt && zig build
# 2. Install Dart/Flutter dependencies
cd ../bxp-gui && flutter pub get
# 3. Run on your host platform
flutter run -d linux # Linux
flutter run -d macos # macOS
flutter run -d windows # Windows (PowerShell)The dev-tree binary fallback in findBin() walks up from the Flutter
executable until it finds the bxp-gui/ segment, then resolves
../bxp-cli/zig-out/bin/bxp-cli and ../bxp-fmt/zig-out/bin/bxp-fmt
automatically. No environment variables needed for local dev.
On first launch the app probes bxp-fmt --docs to load the language catalog.
If bxp-fmt is missing or unbuilt a fatal error gate appears — build bxp-fmt
first. Then:
- Open
DEV/bxp-cli.json(the developer reference config) via the file-picker or drag-drop. - Select a template in the toolbar dropdown.
- Click Run — the dry-run trace should populate the bottom panel.
- Click any row to see per-variable and per-rule results.
- Click any expression cell — the ExprPanel on the right shows a live evaluation playground.
# Keep this terminal open while editing Dart
cd bxp-gui
flutter run -d linux
# r — hot reload (preserves app state, picks up most Dart changes)
# R — hot restart (full Dart restart, clears state)
# q — quitVS Code users: the Flutter extension auto-hot-reloads on save when "Hot reload on save" is enabled in settings. F5 launches with a full debugger (breakpoints, variable inspector).
Zig binaries are subprocesses; Flutter does not hot-reload them.
# Terminal 1 — rebuild after editing bxp-fmt or bxp-cli source
cd bxp-fmt && zig build # or bxp-cli
# Terminal 2 — hot-restart the Flutter app to pick up the new binary
# Press Shift+R in the flutter run terminal, or quit and re-rundev_trace.dart provides devTrace(tag, message) — a kDebugMode-gated
print('[bxp_gui] $tag: $message') helper.
- Use the
[bxp_gui]prefix so MCPget_app_logscan filter output. - Use
print(), notdeveloper.log()— MCPget_app_logscaptures stdout only;developer.log()is invisible to it. - Gate all debug prints behind
kDebugModeso release builds stay clean.
MCP live-debug cycle (when working with Claude Code):
# 1. Launch via MCP (root must be a plain path, not file://)
mcp__dart__launch_app(root: "/home/user/workspace/bxp/bxp-gui")
# 2. Edit → hot reload
mcp__dart__hot_reload()
# 3. Read recent print() output
mcp__dart__get_app_logs()# Widget + unit tests (bxp-gui)
flutter test
# json5_ast unit tests (~105 cases + round-trip suite)
dart test packages/json5_ast/
# Full desktop suite: flutter analyze + flutter test + dart test
bash scripts/test-03-desktop.shflutter analyze enforces sound null-safety and catches common issues. Run it
before committing — CI runs it on every release build.
Three layers, top-down:
┌───────────────────────────────────────────────┐
│ ui/ (Provider widgets, no business logic) │
│ ConfigOps flow down; notifyListeners up │
├───────────────────────────────────────────────┤
│ store/ TraceStore ChangeNotifier │
│ Single source of truth for all UI state │
├───────────────────────────────────────────────┤
│ services/ Pure Dart, no Flutter imports │
│ Subprocess wrappers, AST loader, prefs, ... │
└───────────────────────────────────────────────┘
↕ Two transport paths (see "Subprocess wiring" below)
│ Process.start / Process.run (Linux / macOS default)
│ bxp-gui-bridge.{dll,so,dylib} (Win mandatory; eval cross-plat)
↓
bxp-cli (conversions via --trace NDJSON stream)
bxp-fmt (validation, docs, expr eval — out-of-process)
bxp-core (expr.zig linked directly via bridge_eval_expr — in-process)
The Flutter app never parses JSON5 directly for runtime data — all backend
operations go through bxp-cli or bxp-fmt as short-lived subprocesses.
The Dart json5_ast library is used only for in-place AST mutations of
the user's config file (parse → mutate → dump back preserving comments).
bxp-gui/
├── lib/
│ ├── main.dart # Flutter entry: window sizing, theme, Provider wiring
│ ├── services/
│ │ ├── app_runtime.dart # Top-level lifecycle helpers + startup gate
│ │ ├── ast_loader.dart # Parse user config to JsonAstNode tree
│ │ ├── ast_patch_client.dart # Apply AST mutations + dump back to disk
│ │ ├── bridge_client.dart # Dart FFI shim for bxp-gui-bridge (DLL on Win,
│ │ │ # .so/.dylib on Linux/macOS for bridge_eval_expr)
│ │ ├── bxp_process_client.dart # Process.run wrappers for bxp-cli / bxp-fmt
│ │ ├── dart_validator.dart # Dart-side per-edit expression validator
│ │ ├── debug_binding.dart # WidgetsFlutterBinding hook for diagnostic capture
│ │ ├── debug_settings.dart # Opt-in regression knobs (paint, hover, scroll filters)
│ │ ├── desktop_integration_service.dart # First-run .desktop + hicolor icon writer (Linux AppImage)
│ │ ├── dev_trace.dart # kDebugMode-gated print() helper
│ │ ├── diagnostic_log.dart # Opt-in NDJSON trace + engine stderr capture
│ │ ├── op_log.dart # In-memory record of user edits since load
│ │ ├── op_to_ast.dart # Translate ConfigOp → AST mutation calls
│ │ ├── prefs_service.dart # User preferences persistence (visible JSON file)
│ │ ├── schema_doc_lookup.dart # Resolve a config-tree path against the FieldDoc catalog
│ │ ├── schema_gate.dart # Schema-aware "may the user do X here?"
│ │ └── updater_service.dart # GitHub release poller + download/verify/install
│ ├── store/
│ │ ├── trace_store.dart # Central ChangeNotifier (~2.9k lines)
│ │ ├── trace_builder.dart # Fold NDJSON trace events into TraceStore
│ │ └── trace_model.dart # Plain-Dart shapes for trace events
│ └── ui/
│ ├── main_view.dart # 3-pane layout root
│ ├── config_view.dart # JSON5 tree editor pane
│ ├── debug_overlay.dart # Floating debug counter overlay (BXP_DIAGNOSTIC)
│ ├── debug_panes.dart # Trace/output bottom panes
│ ├── settings_inspector.dart # Ctrl+Shift+S internal-state drawer
│ ├── layout_defaults.dart # Fractional split sizes (single source)
│ ├── shader_warmup.dart # Skia shader pre-warmup (Windows perf)
│ ├── zoom_limits.dart # Window / zoom guards
│ ├── theme/ # App theme (bxp_theme, bxp_text, bxp_text_scheme,
│ │ # bxp_theme_animator, theme_inspector)
│ └── components/
│ ├── json_tree.dart # Recursive tree renderer with insert/edit slots
│ ├── expr_editor.dart # Expression input with autocomplete
│ ├── expr_panel.dart # Right-rail expression preview
│ ├── expr_playground.dart # Standalone expression sandbox
│ ├── expr_highlight.dart # Token-aware syntax highlighter
│ ├── row_detail.dart # Per-row variable/rule trace detail
│ ├── row_list.dart # Master row picker
│ ├── file_list.dart # Multi-file dry-run output list
│ ├── output_panel.dart # bxp-cli stdout/stderr viewer
│ ├── top_bar.dart # Title bar + actions
│ ├── panel_header.dart # Reusable header chrome
│ ├── resize_handle.dart # Splitter drag handle
│ ├── open_dialog.dart # Recent-files / file picker
│ ├── integrate_dialog.dart # First-run Linux desktop-integration prompt
│ └── update_dialog.dart # In-app updater prompt
├── packages/json5_ast/ # Dart JSON5 AST library (path dep)
│ ├── lib/
│ │ ├── json5_ast.dart # Top-level umbrella export
│ │ ├── ast.dart # JsonAstNode hierarchy + public API
│ │ ├── parser.dart # JSON5 → AST
│ │ ├── dumper.dart # AST → JSON5 text
│ │ ├── operations.dart # Insert / delete / move / set mutations
│ │ ├── path.dart # Dot-path resolver
│ │ ├── value_builder.dart # Typed value constructors
│ │ └── src/
│ │ └── tokenizer.dart # JSON5 tokenizer (private)
│ └── test/ # ~105 unit tests + round-trip suite
├── linux/, macos/, windows/, web/ # Per-platform Flutter shells
├── test/ # Widget + service tests (desktop_integration,
│ # expr_corpus_bridge, prefs_service, zoom_overflow)
└── pubspec.yaml
BxpProcessClient is the single entry point for all binary calls. Under the
hood there are two transports — out-of-process (dart:io Process.start) and
in-process FFI (bxp-gui-bridge.{dll,so,dylib}). BxpProcessClient picks
between them per call based on host OS and what the call needs.
| Transport | Used on | Used for |
|---|---|---|
Process.start (dart:io) |
Linux/macOS default | bxp-cli --trace, bxp-fmt --config / --docs / --expr / --expr-trace |
bridge_run_streaming |
Windows mandatory | bxp-cli --trace — sidesteps dart-lang/sdk#1727 (~8 KB stdout cutoff) |
bridge_run |
Windows mandatory | one-shot bxp-fmt --config / --docs — same pipe-truncation workaround |
bridge_eval_expr |
All platforms | expr editor live validation per keystroke — avoids ~50 ms bxp-fmt --expr spawn cost |
bridge_eval_expr_trace |
All platforms | ExprPlayground per-call NDJSON — same reason, plus per-token trace stream |
The bridge is implemented as a Zig shared library that links bxp-core/expr
directly (in-proc paths) and spawns subprocesses (proxy paths). For the
two-cause rationale behind this split and a per-call transport matrix
(every GUI call × OS → which bridge_* / Process.* it actually invokes),
see devel.md's "Why the bridge exists" + "Per-call routing"
section. The C-ABI surface and Debug→ReleaseSafe rewrite landmine live in
bxp-gui-bridge/CLAUDE.md.
Windows is single-path. DLL probe failure at startup is fatal (synthetic
error surfaced through the normal startup gate). There is no Process.start
fallback — dart-lang/sdk#1727 makes it unusable.
Linux/macOS dormant proxy. bridge_run / bridge_run_streaming compile
and work on these hosts but are gated behind BXP_FORCE_BRIDGE_PROXY=1 as a
pre-release smoke gate. The eval paths (bridge_eval_expr*) run unconditionally
because no Process.start bug forces the fallback. Making the proxy paths the
default on Linux/macOS is on the v0.3.0 roadmap
(roadmap.md).
Reloading bridge changes. dlopen mmaps the file at process start, so
editing a .so/.dylib and mcp__dart__hot_reload does NOT pick it up. After
zig build in bxp-gui-bridge/, fully stop and relaunch the Flutter app.
Resolved in this order:
- Env override —
$BXP_CLI_PATH/$BXP_FMT_PATH. If set and non-empty, used absolutely (missing file → fatal error, no fallthrough). - Bundle sibling —
<name>next to the Flutter executable inside the app bundle. - Dev-tree fallback — walks up from the exe dir until it finds a
bxp-gui/segment, then looks for<monorepo-root>/<name>/zig-out/bin/<name>. This makesflutter run -d linuxwork without copying binaries after a bundle wipe.
| Method | Binary | Notes |
|---|---|---|
validateConfig(path) |
bxp-fmt --config |
Returns annotated JSON with $err_*/$warn_* siblings |
getDocs() |
bxp-fmt --docs |
Cached at startup; drives FnDoc tooltips + SchemaGate |
listTemplates(path) |
bxp-fmt --config … --list-templates |
Template id array |
validateExpr(text) |
bxp-fmt --expr |
Returns {error, offset, length} on failure |
traceExpr(text, …) |
bxp-fmt --expr-trace |
NDJSON stream of per-call values |
runDryRun(path, tmpl) |
bxp-cli --trace |
NDJSON stream → trace_builder.dart |
getVersion(name) |
bxp-cli --version / bxp-fmt --version |
Both write to stdout |
The Linux CMake config copies bxp-fmt into the bundle at build time. After
changing bxp-fmt, either run a clean Flutter build or rely on the dev-tree
fallback (option 3 above) which reads directly from bxp-fmt/zig-out/bin/.
A standalone Dart package (packages/json5_ast/) that parses JSON5 to a
comment-preserving AST, applies mutations, and dumps back to text. Used to
edit the user's bxp-cli.json without reformatting comments or key order.
Standalone-library candidate.
json5_asthas no bxp-specific concepts — noBrokerConfig, noFieldDoc, nothing from the bxp domain. It lives inside the monorepo only because no second Dart consumer exists yet. When contributing here, prefer full JSON5 spec compliance over bxp-convenience shortcuts so future extraction stays cheap. Seebxp-gui/packages/json5_ast/CLAUDE.mdfor the extraction recipe.
parse → JsonAstNode tree → apply ops → dump → file
Operations (operations.dart): insertChild, deleteChild, moveChild,
setValue, duplicateChild. Each operates on a dot-path into the tree.
op_to_ast.dart translates high-level ConfigOp (the type stored in the
undo ledger) to concrete AST mutations. ast_patch_client.dart runs the
mutations and writes the result to disk.
Verified by scripts/test.sh's "AST round-trip" phase against
DEV/bxp-cli.json. The dumper produces deterministic output; first-save
canonicalizes formatting.
TraceStore (store/trace_store.dart) is the single ChangeNotifier driving
every pane:
- Loaded config — as both a
JsonAstNodetree (for editing) and aMap<String, dynamic>view (for schema lookups). - Dry-run trace — per-file and per-row trace data from
bxp-cli --trace. - Op log — undo/redo stack of
ConfigOpentries. - Schema docs —
FnDoc/FieldDoccatalog loaded frombxp-fmt --docs. - Validation errors —
$err_*/$warn_*map from the last bxp-fmt run.
Edits flow back as ConfigOps:
UI widget → ConfigOp → op_log → op_to_ast → ast_patch_client → disk
Save runs bxp-fmt --config for a validation pass; results refresh the
error map and the tree highlight state.
Calling top-level notifyListeners() per NDJSON event causes PlutoGrid to
reallocate quadratically. Use per-cell ValueNotifier instead
(traceLinesCounter, fileGen, …). Top-level notifyListeners() fires
at most twice per dry-run stream (start + done).
All resizable panels hold fractions, not pixels.
lib/ui/layout_defaults.dart is the single source of truth.
3-pane layout = 2 fractions + middle by subtraction.
Use HardwareKeyboard.instance.addHandler in initState, not
CallbackShortcuts — the latter only fires when focus bubbles up, which
misses shortcuts while e.g. PlutoGrid has focus.
Load-time errors disable the readonly toolbar (_loadedWithErrors). Mid-edit
errors do not lock undo/redo — validation only runs on load and save, not
on every keystroke.
bxp-fmt --docs is loaded at startup and drives FnDoc tooltips,
SchemaGate insert-position logic, autocomplete, and the
_AddChildDialog insert scaffolds. Do not reintroduce hardcoded fallback
catalogs — the startup gate fails fatally if the binary is missing.
- Zig side — add the field to the relevant struct in
bxp-core/src/config.zigwith a co-locatedFieldDocentry (follow the existingpub const fields = [_]FieldDoc{…}pattern on each struct).docs.zigpicks it up automatically. - Validation — if the field needs semantic checks, add them to
BrokerConfig.validate()or thevalidateCollectpath (for bxp-fmt diagnostics). - GUI — rebuild bxp-fmt and restart bxp-gui; the field appears in the
tree editor's
_AddChildDialoginsert scaffold and tooltip automatically viaSchemaGate+FnDoc/FieldDoc. - Tests — add a dataset fixture if the field affects output; add a bxp-fmt smoke test if it adds a new validation path.
This guide covers structure, dev workflow, and the patterns a new contributor needs to ship a first change. Internal-API contracts and design-decision rationales live in:
bxp-gui/CLAUDE.md— Flutter side: services / store / ui split, BxpProcessClient binary resolution, prefs path policy, auto-updater install paths, MCP debug workflow, conventions enforced.bxp-gui/packages/json5_ast/CLAUDE.md— json5_ast public API, comment-ownership rules, round-trip / idempotent canonicalisation contract, future extraction recipe.