From 811f671fb9be20881233a64400276825af4b82d5 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 09:57:19 +0200 Subject: [PATCH 01/19] docs(ppvm-tui): add design spec for composable ratatui TUI Unified single-screen TUI for `ppvm-cli`: bare `ppvm` launches a REPL + step-debugger on one screen (program listing, tableau state via state_string(), measurement record, command line). New `crates/ppvm-tui` lib holds terminal-agnostic `Widget` components + AppState; `ppvm-cli` owns only the terminal and event loop. Deps pinned to ratatui/crossterm 0.29 to match stellarscope so panels can dock together later. PauliSum REPL backend deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-01-cli-tui-design.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-cli-tui-design.md diff --git a/docs/superpowers/specs/2026-07-01-cli-tui-design.md b/docs/superpowers/specs/2026-07-01-cli-tui-design.md new file mode 100644 index 00000000..bda79f5a --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-cli-tui-design.md @@ -0,0 +1,264 @@ +# Design: `ppvm` TUI — a composable ratatui debugger + REPL for `ppvm-cli` + +- **Date:** 2026-07-01 +- **Status:** Approved (design); pending implementation plan +- **Branch:** `david/cli-tui` +- **Scope:** Add a terminal UI to the `ppvm` command. The bare `ppvm` + (no subcommand) launches the TUI. The TUI unifies two capabilities on one + screen: (1) **step controls for debugging** a loaded `.sst`/`.ssb` program, + pausing at breakpoints; and (2) a **small interactive REPL** to initialize a + tableau and apply circuit ops. State display shows the measurement record and + the tableau. The TUI is built to be **composable** with the ratatui TUI in + `~/git/stellarscope/` — i.e. its view components must be embeddable into + another ratatui app later. The existing non-interactive subcommands + (`run`/`debug`/`parse`/`dump`) are left untouched. + +## 1. Motivation + +`ppvm-cli` today is a batch tool: `run` executes a program and prints the +measurement record; `debug` offers a line-oriented step loop over stdin. +A bare `ppvm` used to drop into a rustyline REPL, since removed (commit +`8beb96ac`, "Remove the REPL"). Both the removed REPL and the surviving +line-oriented `debug` loop (`commands::debug` / `debug_loop`) are proven +blueprints — they already lower gate commands, step the engine, honor +breakpoints, and render measurements. This design re-homes that behavior into +an interactive, panelled TUI so a user can watch tableau state, the measurement +record, and program position update live while stepping or poking at a device. + +The engine already exposes everything the TUI needs as public API on +`ppvm_vihaco::composite::PPVM` — no engine changes are required for v1. + +## 2. Locked decisions + +| Decision | Choice | +| --- | --- | +| Screen model | **Unified single screen**: persistent panels + one command line. Not tabs, not separate binaries. | +| Program loading | `ppvm ` launches the TUI with the program loaded and **paused at pc 0**; bare `ppvm` starts an empty REPL session; `:load ` (re)loads from inside the TUI. | +| Existing subcommands | `run` / `debug` / `parse` / `dump` unchanged (still scriptable + tested). | +| Crate boundary | **New library crate `crates/ppvm-tui`** holds app state + `Widget` components; `ppvm-cli` (bin) owns only terminal setup + the event loop. | +| TUI deps | `ratatui = "0.29.0"`, `crossterm = "0.29.0"` — pinned to match stellarscope so panels can dock together. | +| Program panel | A **local** `CodeView`-alike, mirroring stellarscope's shape/`Widget` signature. **Not** a dependency on `stellarscope-inspect` (see §9). | +| Tableau display | Rendered via the already-public `PPVM::state_string()`. `GeneralizedTableau`'s `Display` is left as-is for v1. | +| PauliSum backend | **Deferred.** REPL `device N` creates a Tableau-backed machine only. Loaded programs that declare other backends still run and render. | + +## 3. Architecture overview + +Two crates, following stellarscope's own lib(inspect/optics)+bin(loop) split: + +``` +crates/ppvm-tui/ # NEW lib crate — reusable, terminal-agnostic + src/lib.rs + src/app.rs # AppState: owns a PPVM + UI state; handle_key; dispatch + src/command.rs # command grammar: parse a line -> Command; gate_spec map + src/codeview.rs # local CodeView ring-buffer + cursor (data) + src/widgets/mod.rs + src/widgets/program.rs # impl Widget for &ProgramView (the CodeView render) + src/widgets/state.rs # impl Widget for &StateView (tableau via state_string) + src/widgets/record.rs # impl Widget for &RecordView (measurement record) + src/widgets/command.rs # impl Widget for &CommandLine (prompt + hint + status) + +crates/ppvm-cli/ # bin — owns terminal + loop only + src/main.rs # `command: Option`; None => tui::run() + src/tui.rs # terminal setup (raw mode + alt screen, RAII guard) + event loop + src/commands.rs # unchanged +``` + +**Separation of concerns.** `ppvm-tui` never touches the terminal, never runs a +loop, and never blocks on input. It exposes: + +- `AppState` — holds a `PPVM`, the command buffer, a status/error string, the + program `CodeView`, a REPL scrollback `CodeView`, a `paused: bool`, and scroll + offsets. Constructed empty (`AppState::new()`) or from a file + (`AppState::from_file(path)`). +- `AppState::handle_key(&mut self, key: KeyEvent) -> bool` — apply one key + event; returns whether it was consumed. Pure w.r.t. the terminal. +- `AppState::dispatch(&mut self, line: &str)` — run one command-line string + (used by Enter and directly by tests). +- The four `Widget` impls on `&…View` newtypes that borrow from `AppState` — + these are the units a **host app embeds** (render whichever it wants, where it + wants). +- `AppState::render(&self, frame: &mut Frame)` — a **convenience** full-screen + composer that lays out all four panels (the standalone layout in §4). A host + like stellarscope ignores this and lays out the individual `…View` widgets + itself; `ppvm-cli` just calls it. + +`ppvm-cli`'s `tui::run()` owns a `Terminal>`, enables +raw mode + the alternate screen behind an RAII guard that restores them on drop +(even on panic — mirroring stellarscope's `TerminalGuard`), then loops: +poll → `handle_key` → `terminal.draw(|f| app.render(f))` → break on +`app.should_exit`. + +## 4. Layout & panels + +One screen, four regions (ratatui `Layout` with `Constraint`s): + +``` +┌ Program ───────────┐┌ State ─────────────────────┐ +│ h 0 ││ Generalized Tableau (2q): │ +│▶ cnot 0 1 ││ Destabilizers: [ ... ] │ ← state_string() +│ measure 0 ││ Stabilizers: [ ... ] │ +└────────────────────┘└────────────────────────────┘ +┌ Measurement record ─────────────────────────────── ┐ +│ 0 1 │ +└──────────────────────────────────────────────────── ┘ + ppvm> cnot 0 1 Enter=step :c :q +``` + +- **Program (left), contextual.** When a program is loaded: the compiled + module's instruction listing (each instruction via `Display`), with a `▶` + marker at `current_pc()`, auto-scrolled to keep the pc visible. In a pure REPL + session (no program): a scrollback of entered commands and their inline + results (`h 0`, `measure 0 => 1`). Both are the same local `CodeView` widget + with different content and cursor semantics. +- **State (right).** `machine.state_string()` verbatim. This already covers all + backends and includes coefficients + per-qubit loss. Scrollable if it + overflows (basic offset; fancy scrolling deferred). +- **Measurement record (bottom band).** `machine.measurement_record()` rendered + with the established flat convention: `Zero→0`, `One→1`, `Lost→2`, grouped per + measurement event. +- **Command line (footer).** `ppvm> ` + the current input buffer, a contextual + hint, and the latest status/error message. + +## 5. Command grammar & key handling + +The command line is always live for text entry, so single-letter hotkeys would +collide with gate names (`s` = the S gate, etc.). The grammar is therefore +**prefix-disambiguated** and conflict-free: + +- **Bare tokens = REPL gate ops.** Ported from the removed REPL's `gate_spec` + map (name → `CircuitInstruction` + qubit/float arity): + `device N`; `x y z h s sadj sqrtx sqrty sqrtxadj sqrtyadj t tadj reset measure `; + `cnot `; `cz `; `rx ry rz <θ>`; `r <θ>`; + `rxx ryy rzz <θ>`; `u3 <θ> <φ> <λ>`; `depolarize loss

`; + `depolarize2

`; `paulierror `; + `correlatedloss `. Applied via + `PPVM::apply_circuit_instruction` (already bounds-checks qubit indices). + New measurement outcomes echo inline as `=> `. +- **`:`-prefixed = meta / debug commands.** `:load `, `:continue` / `:c`, + `:step` / `:s`, `:reset`, `:quit` / `:q`. +- **Empty line + Enter = step** when paused. Matches the removed `debug` loop's + bare-Enter-steps ergonomics — fast repeated stepping without reaching for a + prefix. +- **Footer hint** is contextual: `Enter=step :c=continue :q=quit` when a + program is loaded and paused; `ppvm> (type a gate, or :load )` + otherwise. + +`handle_key` maps: printable chars → push to buffer; Backspace → pop; Enter → +`dispatch(buffer)` then clear; Ctrl-C / Esc on an empty buffer → quit (Ctrl-C on +a non-empty buffer clears it, shell-like). Up/Down reserved for scrollback +(basic history/scroll; full history optional). + +Stepping semantics reuse the `debug_loop` logic: `step_once()` returns a +`StepOutcome`; `Breakpoint` sets `paused = true` and shows `-- breakpoint hit --`; +`Return`/`Halt` shows "Program finished." `:continue` steps in a tight loop +until the next `Breakpoint` or program end (the loop yields between engine steps +so the UI can repaint). + +## 6. Engine integration + +All public on `PPVM` today — **no engine changes for v1**: + +- REPL device: `PPVM::with_qubits(n)` (Tableau-backed) for `device N`. +- Apply gate: `apply_circuit_instruction(inst, &qubits, ¶ms)`. +- Step: `step_once() -> StepOutcome` (`Continue`/`Breakpoint`/`Return`/`Halt`, + re-exported from `ppvm_vihaco::composite`). +- Position: `current_pc()`, `current_instruction()`. +- Records: `measurement_record() -> Vec`, + `trace_record() -> Vec`. +- State text: `state_string() -> String`. + +**Program loading** builds the code listing without any private accessor: load +the module via the public `ppvm_vihaco::load_module_file(path)`, format its +public `code` field (each `PPVMInstruction` implements `Display`) into the +`CodeView`, then `machine.load(&module)` + `machine.init()`, leaving the machine +paused at pc 0. `:load` reuses this exact path. (If a program listing ever needs +richer data than `load_module_file` exposes, that is an additive engine change, +out of v1 scope.) + +## 7. Error handling + +Non-fatal, exactly like the removed REPL and the `debug` loop: a bad gate name, +wrong arity, out-of-range qubit, missing device (`device N` not yet run), or a +parse error on `:load` is caught and written to the status line; the loop +continues. `eyre::Result` throughout the library boundary. The terminal is +always restored via the RAII guard, including on panic, so a crash never leaves +the user's terminal in raw mode. + +## 8. Testing + +Mirror stellarscope's input tests and the old `repl_loop` / `debug_loop` tests — +**no terminal required**: + +- **Command grammar** (`command.rs`): parsing a line into a `Command`, the + `gate_spec` arity table, `:`-prefix routing, empty-line-steps. +- **State transitions** (`app.rs`): construct an `AppState`, feed `KeyEvent`s or + call `dispatch`, assert engine effects — `device 1` then `x 0` then `measure 0` + yields `=> 1`; `device 2; x 0; cnot 0 1; measure 0; measure 1` → `1,1`; + loading `BREAKPOINT_PROGRAM` and stepping pauses at the breakpoint; + out-of-range qubit surfaces an error and keeps looping; `:quit` sets + `should_exit`. +- **Rendering** (optional, light): a smoke test with ratatui's `TestBackend` + that a panelled frame renders without panic and contains expected substrings + (e.g. the `▶` pc marker, a measurement bit). + +Workspace gates unchanged: `cargo test --workspace`, `cargo fmt` before commit. + +## 9. Composability contract (embedding into stellarscope later) + +The whole point of the lib/bin split. Guarantees `ppvm-tui` upholds: + +1. **Matching deps** — `ratatui 0.29` + `crossterm 0.29`, identical to + stellarscope, so both can share one `Terminal` and one event stream. +2. **Terminal-agnostic components** — every view is `impl Widget for &SomeView` + with the stock `render(self, area: Rect, buf: &mut Buffer)` signature; none + own the terminal or run a loop. +3. **Poll-friendly state** — `AppState::handle_key(&mut self, KeyEvent) -> bool` + and `dispatch(&str)` let a host app forward events and drive state without + `ppvm-tui` blocking. + +A future stellarscope integration then looks like: add `ppvm-tui` as a dep, hold +a ppvm `AppState`, `frame.render_widget(&StateView(&app), area)` into its layout, +and forward relevant key events to `app.handle_key`. Nothing in `ppvm-tui` needs +to change for that. + +**Why not reuse stellarscope's `CodeView` directly?** It lives in +`stellarscope-inspect`, an unpublished crate. Depending on it would (a) make +ppvm depend on stellarscope by filesystem path (backwards, non-publishable), and +(b) drag in stellarscope's local `vihaco 0.1.0` path dep + `stellarscope-fpga`, +clashing with ppvm's registry `vihaco 0.1.1`. The widget is ~50 trivial lines, +so we reimplement a same-shaped `CodeView` locally. If real sharing +is wanted later, the clean move is extracting the dependency-light +`CodeView`/`Window` widgets into a **third** shared crate both repos depend on — +not either repo depending on the other. + +## 10. Scope / deferred (v1) + +Deferred by explicit request or to keep v1 tight: + +- **PauliSum / LossyPauliSum in the REPL** — no `observable` / `trace` setup; + `device N` is Tableau-only. (Loaded `.sst` programs may still declare any + backend; `state_string()` renders them.) +- **Mid-program ad-hoc gate injection** — applying a REPL gate while stepping a + loaded program is not specially designed (the pc/appended-code interaction of + `execute_single_instruction` is a footgun). Gate ops are intended for REPL + sessions in v1. +- **Richer structured tableau rendering** and any `GeneralizedTableau::Display` + overhaul — use `state_string()` for now. +- **The actual stellarscope integration** — we ship only the embeddable surface + (§9), not the cross-repo wiring. +- Mouse, theming/config, and full readline-style history/editing beyond basic + buffer edit + scrollback. + +## 11. Success criteria + +- `ppvm` (no args) opens the TUI; `q`/`:q`/Ctrl-C exits cleanly with the + terminal restored. +- REPL: `device 2`, apply gates, `measure` — outcomes echo inline and the + measurement-record panel updates; the State panel reflects the tableau. +- Debug: `ppvm prog.sst` opens paused at pc 0; Enter steps (Program panel `▶` + advances, records update); `:c` runs to the next breakpoint / end; an authored + `breakpoint` pauses. +- `:load other.sst` swaps the program without relaunching. +- Existing `run`/`debug`/`parse`/`dump` behavior and tests are unchanged. +- `cargo test --workspace` passes; `ppvm-tui` has no dependency on a terminal in + its unit tests. From 48ca46ed7f75e2839dc776922438d9d75406f313 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:14:45 +0200 Subject: [PATCH 02/19] docs(ppvm-tui): support ad-hoc gate injection at breakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold gdb-style breakpoint injection into the design: at a breakpoint, typed gate ops update the tableau/record and stepping resumes where it paused. Requires one small engine change in ppvm-vihaco — a pc/code-preserving injection method (save pc + code len, run the op, truncate the appended op, restore pc). Removes the corresponding deferred item from scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-01-cli-tui-design.md | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-07-01-cli-tui-design.md b/docs/superpowers/specs/2026-07-01-cli-tui-design.md index bda79f5a..2cc68beb 100644 --- a/docs/superpowers/specs/2026-07-01-cli-tui-design.md +++ b/docs/superpowers/specs/2026-07-01-cli-tui-design.md @@ -39,6 +39,7 @@ The engine already exposes everything the TUI needs as public API on | TUI deps | `ratatui = "0.29.0"`, `crossterm = "0.29.0"` — pinned to match stellarscope so panels can dock together. | | Program panel | A **local** `CodeView`-alike, mirroring stellarscope's shape/`Widget` signature. **Not** a dependency on `stellarscope-inspect` (see §9). | | Tableau display | Rendered via the already-public `PPVM::state_string()`. `GeneralizedTableau`'s `Display` is left as-is for v1. | +| Breakpoint injection | **Supported.** Ad-hoc gate ops at a breakpoint update the state and let stepping resume where it paused, via a new pc/code-preserving `PPVM` method (the one engine change — see §6). | | PauliSum backend | **Deferred.** REPL `device N` creates a Tableau-backed machine only. Loaded programs that declare other backends still run and render. | ## 3. Architecture overview @@ -125,15 +126,17 @@ The command line is always live for text entry, so single-letter hotkeys would collide with gate names (`s` = the S gate, etc.). The grammar is therefore **prefix-disambiguated** and conflict-free: -- **Bare tokens = REPL gate ops.** Ported from the removed REPL's `gate_spec` +- **Bare tokens = gate ops.** Ported from the removed REPL's `gate_spec` map (name → `CircuitInstruction` + qubit/float arity): `device N`; `x y z h s sadj sqrtx sqrty sqrtxadj sqrtyadj t tadj reset measure `; `cnot `; `cz `; `rx ry rz <θ>`; `r <θ>`; `rxx ryy rzz <θ>`; `u3 <θ> <φ> <λ>`; `depolarize loss

`; `depolarize2

`; `paulierror `; `correlatedloss `. Applied via - `PPVM::apply_circuit_instruction` (already bounds-checks qubit indices). - New measurement outcomes echo inline as `=> `. + `PPVM::apply_circuit_instruction` (already bounds-checks qubit indices) in a + REPL session; **at a breakpoint** they route through the pc/code-preserving + injection method (§6) so the loaded program and pc stay intact and stepping + resumes cleanly. New measurement outcomes echo inline as `=> `. - **`:`-prefixed = meta / debug commands.** `:load `, `:continue` / `:c`, `:step` / `:s`, `:reset`, `:quit` / `:q`. - **Empty line + Enter = step** when paused. Matches the removed `debug` loop's @@ -156,7 +159,7 @@ so the UI can repaint). ## 6. Engine integration -All public on `PPVM` today — **no engine changes for v1**: +Already public on `PPVM` today — no change needed: - REPL device: `PPVM::with_qubits(n)` (Tableau-backed) for `device N`. - Apply gate: `apply_circuit_instruction(inst, &qubits, ¶ms)`. @@ -167,6 +170,30 @@ All public on `PPVM` today — **no engine changes for v1**: `trace_record() -> Vec`. - State text: `state_string() -> String`. +**The one engine change (in `ppvm-vihaco`)**: a pc/code-preserving injection +method so ad-hoc gates can be applied at a breakpoint without losing the +debugger's place. Today `execute_single_instruction` appends the op to +`module.code`, sets the pc to it, and leaves the pc at the new end — clobbering +the debugger position. The new method wraps that with save/restore: + +```rust +// PPVM, ppvm-vihaco — e.g. apply_circuit_instruction_preserving_pc(...) +let saved_pc = self.current_pc(); +let saved_len = self.loader.module.code.len(); +self.execute_single_instruction(&instrs)?; // effect applied to the tableau +self.loader.module.code.truncate(saved_len); // drop the appended op +*self.loader.pc_mut() = saved_pc; // resume exactly where paused +``` + +This is possible only engine-side: `loader` (holding the pc + code) is a +**private** field of `PPVM`, and `apply_circuit_instruction` / +`execute_single_instruction` are used **only** within `composite.rs`, so no +other caller depends on their current pc behavior. The method leaves the loaded +program byte-for-byte unchanged and the pc restored; as a bonus it keeps the +REPL's code vector from growing unboundedly across a long session. The REPL path +may call the same method (its pc is irrelevant when no program is loaded), so +there is a single injection entry point. + **Program loading** builds the code listing without any private accessor: load the module via the public `ppvm_vihaco::load_module_file(path)`, format its public `code` field (each `PPVMInstruction` implements `Display`) into the @@ -197,6 +224,11 @@ Mirror stellarscope's input tests and the old `repl_loop` / `debug_loop` tests loading `BREAKPOINT_PROGRAM` and stepping pauses at the breakpoint; out-of-range qubit surfaces an error and keeps looping; `:quit` sets `should_exit`. +- **Breakpoint injection** (`ppvm-vihaco` + `app.rs`): the engine test asserts + that after the preserving-injection method, `current_pc()` is unchanged and + `module.code.len()` is back to its pre-injection value while the tableau + reflects the op; the app test asserts that pausing at a breakpoint, injecting + `x 0`, then continuing resumes the loaded program and finishes. - **Rendering** (optional, light): a smoke test with ratatui's `TestBackend` that a panelled frame renders without panic and contains expected substrings (e.g. the `▶` pc marker, a measurement bit). @@ -238,10 +270,6 @@ Deferred by explicit request or to keep v1 tight: - **PauliSum / LossyPauliSum in the REPL** — no `observable` / `trace` setup; `device N` is Tableau-only. (Loaded `.sst` programs may still declare any backend; `state_string()` renders them.) -- **Mid-program ad-hoc gate injection** — applying a REPL gate while stepping a - loaded program is not specially designed (the pc/appended-code interaction of - `execute_single_instruction` is a footgun). Gate ops are intended for REPL - sessions in v1. - **Richer structured tableau rendering** and any `GeneralizedTableau::Display` overhaul — use `state_string()` for now. - **The actual stellarscope integration** — we ship only the embeddable surface @@ -258,6 +286,9 @@ Deferred by explicit request or to keep v1 tight: - Debug: `ppvm prog.sst` opens paused at pc 0; Enter steps (Program panel `▶` advances, records update); `:c` runs to the next breakpoint / end; an authored `breakpoint` pauses. +- Debug + inject: paused at a breakpoint, `x 0` updates the State panel and the + measurement record; `:c` then resumes the loaded program from where it paused + and finishes (pc + program unchanged by the injection). - `:load other.sst` swaps the program without relaunching. - Existing `run`/`debug`/`parse`/`dump` behavior and tests are unchanged. - `cargo test --workspace` passes; `ppvm-tui` has no dependency on a terminal in From 0bf4c3ec6111c172a063b998ca9988694d4f5f45 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:31:50 +0200 Subject: [PATCH 03/19] docs(ppvm-tui): add task-by-task implementation plan Seven TDD tasks: engine pc-preserving injection (ppvm-vihaco); scaffold ppvm-tui + CodeView; command grammar; AppState REPL dispatch + key handling; program loading/stepping/breakpoint injection; ratatui widgets + composer; ppvm-cli wiring (optional subcommand launches the TUI). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-07-01-cli-tui.md | 1716 ++++++++++++++++++ 1 file changed, 1716 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-01-cli-tui.md diff --git a/docs/superpowers/plans/2026-07-01-cli-tui.md b/docs/superpowers/plans/2026-07-01-cli-tui.md new file mode 100644 index 00000000..528a2bc5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-cli-tui.md @@ -0,0 +1,1716 @@ +# ppvm TUI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a composable ratatui TUI to `ppvm-cli`: bare `ppvm` launches a unified single-screen REPL + step-debugger (program listing, tableau state, measurement record, command line); `ppvm ` opens a program paused at pc 0. + +**Architecture:** A new `crates/ppvm-tui` **library** holds the terminal-agnostic pieces — an `AppState` that owns a `ppvm_vihaco::composite::PPVM`, a command grammar, and ratatui `Widget` view components. `ppvm-cli` (binary) owns only terminal setup and the event loop. One small `ppvm-vihaco` change makes `apply_circuit_instruction` preserve the program counter so gates can be injected at a breakpoint. Design mirrors stellarscope's lib(widgets)+bin(loop) split and pins the same ratatui/crossterm versions so panels can dock together later. + +**Tech Stack:** Rust (edition 2024), `ratatui 0.29.0`, `crossterm 0.29.0`, `ppvm-vihaco`, `eyre`, `clap`. + +**Spec:** `docs/superpowers/specs/2026-07-01-cli-tui-design.md` + +## Global Constraints + +- **Rust edition 2024** for all new crates (match the workspace). +- **Dependency versions (exact):** `ratatui = "0.29.0"`, `crossterm = "0.29.0"` — must match stellarscope so panels are dock-compatible. `eyre = "0.6.12"`. +- **SPDX license header** at the top of every new `.rs` file (the `hawkeye` prek hook enforces this): + ``` + // SPDX-FileCopyrightText: 2026 The PPVM Authors + // SPDX-License-Identifier: Apache-2.0 + ``` +- **Run `cargo fmt` before every commit** (prek hook; a commit fails formatting otherwise). +- **Conventional Commits:** `(): ` — use scope `ppvm-tui`, `ppvm-cli`, or `ppvm-vihaco`. +- **Tableau backend is Schrödinger (forward gate order)** — REPL test expectations are the natural ones (`X` on `|0>` measures `1`). (This is the opposite of the PauliSum/Heisenberg convention, which is out of scope here.) +- **Test commands:** per-crate `cargo test -p `; full gate `cargo test --workspace`. +- **No terminal in library unit tests** — `ppvm-tui` tests drive `AppState` directly via `dispatch`/`handle_key`. + +## File Structure + +``` +crates/ppvm-vihaco/src/composite.rs # MODIFY: apply_circuit_instruction preserves pc + truncates +crates/ppvm-tui/ # NEW library crate + Cargo.toml + src/lib.rs # module wiring + re-export AppState + src/codeview.rs # CodeView: Vec-backed line list + optional cursor + src/command.rs # gate_spec map + Command enum + parse_command + src/app.rs # AppState: owns PPVM; dispatch; handle_key; render + src/widgets.rs # ProgramView/StateView/RecordView/CommandLine Widget impls +crates/ppvm-cli/Cargo.toml # MODIFY: add ppvm-tui, ratatui, crossterm deps +crates/ppvm-cli/src/main.rs # MODIFY: optional subcommand + optional file positional +crates/ppvm-cli/src/tui.rs # NEW: terminal setup (RAII guard) + blocking event loop +crates/ppvm-cli/README.md # MODIFY: document the TUI +Cargo.toml # MODIFY: add crates/ppvm-tui to workspace members +``` + +**Dependency order between tasks:** Task 1 (engine) is independent. Task 2 scaffolds the crate; Tasks 3–6 build inside it (3 → 4 → 5 → 6). Task 7 wires the binary and depends on 2–6. + +--- + +### Task 1: Engine — `apply_circuit_instruction` preserves the program counter + +Make gate application safe to call at a breakpoint: run the op, then truncate the appended instructions and restore the pc, leaving a loaded program and its pc byte-for-byte unchanged. + +**Files:** +- Modify: `crates/ppvm-vihaco/src/composite.rs` (the `apply_circuit_instruction` method, ~line 412; add a test in the `tests` module at the bottom) + +**Interfaces:** +- Consumes: existing `PPVM::execute_single_instruction`, `PPVM::current_pc`, the private `self.loader.module.code` and `self.loader.pc_mut()`. +- Produces: `PPVM::apply_circuit_instruction(&mut self, inst: CircuitInstruction, qubits: &[usize], params: &[f64]) -> eyre::Result<()>` — same signature, now pc/code-preserving. Relied on by Tasks 4 and 5. + +- [ ] **Step 1: Write the failing test** + +Add to the `#[cfg(test)] mod tests` block in `crates/ppvm-vihaco/src/composite.rs`: + +```rust +#[test] +fn apply_circuit_instruction_preserves_pc_and_code_len() -> eyre::Result<()> { + use crate::measurements::MeasurementOutcome; + + // breakpoint; then measure q0. Step to the breakpoint, inject X, resume. + let src = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + let mut m = PPVM::default(); + m.load_program(src)?; + m.init()?; + + // Run until the breakpoint pauses us. + loop { + if m.step_once()? == StepOutcome::Breakpoint { + break; + } + } + let pc = m.current_pc(); + let len = m.loader.module.code.len(); + + // Inject X on q0 while "paused". + m.apply_circuit_instruction(CircuitInstruction::X, &[0], &[])?; + + // The debugger's position must be untouched. + assert_eq!(m.current_pc(), pc, "pc must be preserved"); + assert_eq!( + m.loader.module.code.len(), + len, + "appended op must be truncated back" + ); + + // And the X took effect: resuming the program measures |1>. + while !matches!(m.step_once()?, StepOutcome::Return | StepOutcome::Halt) {} + let rec = m.measurement_record(); + assert_eq!(rec.len(), 1); + assert_eq!(rec[0].as_slice(), [MeasurementOutcome::One]); + Ok(()) +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p ppvm-vihaco apply_circuit_instruction_preserves_pc_and_code_len` +Expected: FAIL — the pc assertion fails (today the pc is left at the new end of code, and `code.len()` has grown by 2). + +- [ ] **Step 3: Make `apply_circuit_instruction` preserve pc + code length** + +Replace the body of `apply_circuit_instruction` in `crates/ppvm-vihaco/src/composite.rs`. Keep the bounds-check and instruction-lowering exactly as-is; wrap the execution in save/restore: + +```rust + pub fn apply_circuit_instruction( + &mut self, + inst: CircuitInstruction, + qubits: &[usize], + params: &[f64], + ) -> eyre::Result<()> { + let n_qubits = self.loader.module.extra.n_qubits; + for &q in qubits { + if q >= n_qubits { + eyre::bail!("qubit {q} out of range for {n_qubits}-qubit device"); + } + } + + let mut instrs = Vec::with_capacity(qubits.len() + params.len() + 1); + for &q in qubits { + instrs.push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::U64(q as u64), + ))); + } + for &p in params { + instrs.push(PPVMInstruction::Cpu(vihaco_cpu::Instruction::Const( + Value::F64(p), + ))); + } + instrs.push(PPVMInstruction::Circuit(inst)); + + // Apply the op without disturbing a loaded program or its program + // counter: run the appended block, then truncate it and restore the pc. + // The tableau/measurement effects persist; the code + pc are left + // byte-for-byte unchanged, so a paused debugger resumes exactly where it + // was. (Also keeps a long REPL session's code vector from growing.) + let saved_pc = self.loader.pc(); + let saved_len = self.loader.module.code.len(); + self.execute_single_instruction(&instrs)?; + self.loader.module.code.truncate(saved_len); + *self.loader.pc_mut() = saved_pc; + Ok(()) + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p ppvm-vihaco apply_circuit_instruction_preserves_pc_and_code_len` +Expected: PASS. + +- [ ] **Step 5: Run the full crate suite (no regressions)** + +Run: `cargo test -p ppvm-vihaco` +Expected: PASS (existing `with_qubits_*`, `apply_circuit_instruction_bounds_checks_qubits`, and `execute_single_instruction_*` tests still pass — none assert on pc/code length after applying a gate). + +- [ ] **Step 6: Commit** + +```bash +cargo fmt +git add crates/ppvm-vihaco/src/composite.rs +git commit -m "feat(ppvm-vihaco): preserve pc in apply_circuit_instruction" +``` + +--- + +### Task 2: Scaffold `ppvm-tui` crate + `CodeView` + +Create the library crate, wire it into the workspace, and add the first testable unit — the `CodeView` line list used by the Program panel and the REPL log. + +**Files:** +- Create: `crates/ppvm-tui/Cargo.toml` +- Create: `crates/ppvm-tui/src/lib.rs` +- Create: `crates/ppvm-tui/src/codeview.rs` +- Modify: `Cargo.toml` (workspace `members`) + +**Interfaces:** +- Produces: `ppvm_tui::codeview::CodeView` with `new()`, `clear()`, `push(T)`, `set_cursor(Option)`, `cursor() -> Option`, `lines() -> &[T]`. Consumed by Tasks 4, 5, 6. + +- [ ] **Step 1: Create the crate manifest** + +Create `crates/ppvm-tui/Cargo.toml`: + +```toml +[package] +name = "ppvm-tui" +version = "0.1.0" +edition = "2024" + +[dependencies] +eyre = "0.6.12" +ratatui = "0.29.0" +crossterm = "0.29.0" +ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco" } +``` + +- [ ] **Step 2: Add the crate to the workspace** + +In the root `Cargo.toml`, add `"crates/ppvm-tui"` to the `[workspace] members` list (alongside `"crates/ppvm-cli"`): + +```toml + "skills/ppvm-usage/examples/rust", "crates/ppvm-cli", "crates/vihaco-circuit-isa", "crates/ppvm-tui", +``` + +- [ ] **Step 3: Write the `CodeView` failing test** + +Create `crates/ppvm-tui/src/codeview.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A minimal scrollable list of lines with one optionally-highlighted row. +//! Backs the Program panel (highlight = the program counter) and the REPL log +//! (no highlight). Deliberately mirrors the shape of stellarscope's `CodeView` +//! so the two stay interchangeable, but has no external dependencies. + +/// A list of displayable lines plus an optional cursor (the highlighted row). +#[derive(Debug, Clone, Default)] +pub struct CodeView { + lines: Vec, + cursor: Option, +} + +impl CodeView { + pub fn new() -> Self { + Self { + lines: Vec::new(), + cursor: None, + } + } + + /// Drop all lines and clear the cursor. + pub fn clear(&mut self) { + self.lines.clear(); + self.cursor = None; + } + + /// Append one line. + pub fn push(&mut self, line: T) { + self.lines.push(line); + } + + /// Highlight row `idx` (or none). + pub fn set_cursor(&mut self, idx: Option) { + self.cursor = idx; + } + + pub fn cursor(&self) -> Option { + self.cursor + } + + pub fn lines(&self) -> &[T] { + &self.lines + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_and_cursor_round_trip() { + let mut cv: CodeView = CodeView::new(); + assert!(cv.lines().is_empty()); + assert_eq!(cv.cursor(), None); + + cv.push("0000: h".to_string()); + cv.push("0001: measure".to_string()); + cv.set_cursor(Some(1)); + + assert_eq!(cv.lines().len(), 2); + assert_eq!(cv.cursor(), Some(1)); + + cv.clear(); + assert!(cv.lines().is_empty()); + assert_eq!(cv.cursor(), None); + } +} +``` + +- [ ] **Step 4: Create `lib.rs` wiring the module** + +Create `crates/ppvm-tui/src/lib.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Composable ratatui components + app state for the `ppvm` TUI. Terminal- +//! agnostic: no code here owns a terminal or runs an event loop, so the +//! `Widget` components and `AppState` can be embedded in another ratatui app. + +pub mod codeview; +``` + +- [ ] **Step 5: Run the test to verify it passes and the crate builds** + +Run: `cargo test -p ppvm-tui` +Expected: PASS (`push_and_cursor_round_trip`), crate compiles. + +- [ ] **Step 6: Commit** + +```bash +cargo fmt +git add crates/ppvm-tui/ Cargo.toml +git commit -m "feat(ppvm-tui): scaffold crate with CodeView line list" +``` + +--- + +### Task 3: Command grammar — `gate_spec`, `Command`, `parse_command` + +Parse one command-line string into a `Command`. Bare tokens are gate ops (ported from the removed REPL's `gate_spec` map); `:`-prefixed tokens are meta/debug commands; an empty line is a step. + +**Files:** +- Create: `crates/ppvm-tui/src/command.rs` +- Modify: `crates/ppvm-tui/src/lib.rs` (add `pub mod command;`) + +**Interfaces:** +- Consumes: `ppvm_vihaco::CircuitInstruction` (re-exported from `ppvm-vihaco`). +- Produces: + - `pub struct GateSpec { pub inst: CircuitInstruction, pub qubits: usize, pub floats: usize }` + - `pub fn gate_spec(name: &str) -> Option` + - `pub enum Command { Device(usize), Gate { inst: CircuitInstruction, qubits: Vec, params: Vec }, Step, Continue, Reset, Load(String), Quit }` + - `pub fn parse_command(line: &str) -> eyre::Result` + - Consumed by Task 4 (`AppState::dispatch`). + +- [ ] **Step 1: Write the failing tests** + +Create `crates/ppvm-tui/src/command.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The TUI command grammar. Bare tokens are gate ops; `:`-prefixed tokens are +//! meta/debug commands; an empty line means "step". The gate table is ported +//! from the removed rustyline REPL. + +use eyre::{Result, WrapErr, bail, eyre}; +use ppvm_vihaco::CircuitInstruction; + +/// How a gate command lowers: the engine instruction plus how many qubit and +/// float operands it consumes (qubits first, then floats). +pub struct GateSpec { + pub inst: CircuitInstruction, + pub qubits: usize, + pub floats: usize, +} + +/// Resolve a gate name to its spec, or `None` if it is not a gate. +/// `TwoQubitPauliError` is intentionally absent — its tableau arm is `todo!()`. +pub fn gate_spec(name: &str) -> Option { + use CircuitInstruction::*; + let (inst, qubits, floats) = match name { + "x" => (X, 1, 0), + "y" => (Y, 1, 0), + "z" => (Z, 1, 0), + "h" => (H, 1, 0), + "s" => (S, 1, 0), + "sadj" => (SAdj, 1, 0), + "sqrtx" => (SqrtX, 1, 0), + "sqrty" => (SqrtY, 1, 0), + "sqrtxadj" => (SqrtXAdj, 1, 0), + "sqrtyadj" => (SqrtYAdj, 1, 0), + "t" => (T, 1, 0), + "tadj" => (TAdj, 1, 0), + "measure" => (Measure, 1, 0), + "reset" => (Reset, 1, 0), + "cnot" => (CNOT, 2, 0), + "cz" => (CZ, 2, 0), + "rx" => (RX, 1, 1), + "ry" => (RY, 1, 1), + "rz" => (RZ, 1, 1), + "r" => (R, 1, 2), + "rxx" => (RXX, 2, 1), + "ryy" => (RYY, 2, 1), + "rzz" => (RZZ, 2, 1), + "u3" => (U3, 1, 3), + "depolarize" => (Depolarize, 1, 1), + "depolarize2" => (Depolarize2, 2, 1), + "loss" => (Loss, 1, 1), + "paulierror" => (PauliError, 1, 3), + "correlatedloss" => (CorrelatedLoss, 2, 3), + _ => return None, + }; + Some(GateSpec { + inst, + qubits, + floats, + }) +} + +/// One parsed command-line entry. +#[derive(Debug, PartialEq)] +pub enum Command { + /// `device N` — (re)create a fresh N-qubit tableau device. + Device(usize), + /// A gate op, e.g. `cnot 0 1` or `rx 0 0.5`. + Gate { + inst: CircuitInstruction, + qubits: Vec, + params: Vec, + }, + /// Advance one instruction (also the meaning of an empty line). + Step, + /// Run to the next breakpoint or program end. + Continue, + /// Reset the loaded program / device to its initial state. + Reset, + /// Load a `.sst`/`.ssb` file. + Load(String), + /// Leave the TUI. + Quit, +} + +/// Parse one command-line string. +pub fn parse_command(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Ok(Command::Step); + } + + // `:`-prefixed meta / debug commands. + if let Some(rest) = line.strip_prefix(':') { + let mut it = rest.split_whitespace(); + let cmd = it.next().unwrap_or(""); + return match cmd { + "q" | "quit" => Ok(Command::Quit), + "c" | "continue" => Ok(Command::Continue), + "s" | "step" => Ok(Command::Step), + "reset" => Ok(Command::Reset), + "load" => { + let path = it.next().ok_or_else(|| eyre!(":load needs a file path"))?; + Ok(Command::Load(path.to_string())) + } + other => bail!("unknown command :{other}"), + }; + } + + // Bare tokens: `device N` or a gate op. + let mut it = line.split_whitespace(); + let head = it.next().unwrap(); + let args: Vec<&str> = it.collect(); + + if head == "device" { + let n = args + .first() + .ok_or_else(|| eyre!("device needs a qubit count"))? + .parse::() + .wrap_err("invalid qubit count")?; + return Ok(Command::Device(n)); + } + + let spec = gate_spec(head).ok_or_else(|| eyre!("unknown command {head:?}; try :load or device N"))?; + let expected = spec.qubits + spec.floats; + if args.len() != expected { + bail!( + "{head} takes {} qubit(s) and {} param(s), got {}", + spec.qubits, + spec.floats, + args.len() + ); + } + let (qs, ps) = args.split_at(spec.qubits); + let qubits = qs + .iter() + .map(|t| { + t.parse::() + .wrap_err_with(|| format!("invalid qubit index {t:?}")) + }) + .collect::>>()?; + let params = ps + .iter() + .map(|t| { + t.parse::() + .wrap_err_with(|| format!("invalid parameter {t:?}")) + }) + .collect::>>()?; + Ok(Command::Gate { + inst: spec.inst, + qubits, + params, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_line_is_step() { + assert_eq!(parse_command(" ").unwrap(), Command::Step); + } + + #[test] + fn device_parses_count() { + assert_eq!(parse_command("device 3").unwrap(), Command::Device(3)); + } + + #[test] + fn single_qubit_gate() { + assert_eq!( + parse_command("h 0").unwrap(), + Command::Gate { + inst: CircuitInstruction::H, + qubits: vec![0], + params: vec![], + } + ); + } + + #[test] + fn two_qubit_gate_keeps_operand_order() { + assert_eq!( + parse_command("cnot 0 1").unwrap(), + Command::Gate { + inst: CircuitInstruction::CNOT, + qubits: vec![0, 1], + params: vec![], + } + ); + } + + #[test] + fn rotation_parses_float_param() { + assert_eq!( + parse_command("rx 0 0.5").unwrap(), + Command::Gate { + inst: CircuitInstruction::RX, + qubits: vec![0], + params: vec![0.5], + } + ); + } + + #[test] + fn meta_commands() { + assert_eq!(parse_command(":q").unwrap(), Command::Quit); + assert_eq!(parse_command(":continue").unwrap(), Command::Continue); + assert_eq!(parse_command(":s").unwrap(), Command::Step); + assert_eq!(parse_command(":reset").unwrap(), Command::Reset); + assert_eq!( + parse_command(":load foo.sst").unwrap(), + Command::Load("foo.sst".to_string()) + ); + } + + #[test] + fn unknown_gate_errors() { + assert!(parse_command("bogus 0").is_err()); + } + + #[test] + fn wrong_arity_errors() { + assert!(parse_command("x").is_err()); + assert!(parse_command("cnot 0").is_err()); + } +} +``` + +- [ ] **Step 2: Register the module** + +In `crates/ppvm-tui/src/lib.rs`, add below `pub mod codeview;`: + +```rust +pub mod command; +``` + +- [ ] **Step 3: Run the tests to verify they pass** + +Run: `cargo test -p ppvm-tui command::` +Expected: PASS (all `command::tests::*`). + +- [ ] **Step 4: Commit** + +```bash +cargo fmt +git add crates/ppvm-tui/src/command.rs crates/ppvm-tui/src/lib.rs +git commit -m "feat(ppvm-tui): add command grammar and gate table" +``` + +--- + +### Task 4: `AppState` core — REPL dispatch + key handling + +The heart of the TUI: an `AppState` owning an optional `PPVM`, with `dispatch` (run a command string) and `handle_key` (edit the buffer / submit / quit). This task covers the REPL side (`device N`, gate ops, quit) and text editing. Stepping/loading come in Task 5. + +**Files:** +- Create: `crates/ppvm-tui/src/app.rs` +- Modify: `crates/ppvm-tui/src/lib.rs` (add `pub mod app;` and `pub use app::AppState;`) + +**Interfaces:** +- Consumes: `CodeView` (Task 2), `parse_command`/`Command`/`CircuitInstruction` (Task 3), `PPVM::with_qubits`, `PPVM::apply_circuit_instruction`, `PPVM::measurement_record` (Task 1 / engine). +- Produces (all relied on by Tasks 5, 6, 7): + - `AppState::new() -> Self`, `Default` + - `AppState::dispatch(&mut self, line: &str)` + - `AppState::handle_key(&mut self, key: KeyEvent) -> bool` + - accessors: `input(&self) -> &str`, `status(&self) -> &str`, `should_exit: bool` (pub field), `measurement_bits(&self) -> String`, `state_text(&self) -> String`, `active_listing(&self) -> (&'static str, &CodeView)`, `hint(&self) -> &'static str` + - private helpers Task 5 extends: `new_device`, `apply_gate`, `set_status`, `format_record` + +- [ ] **Step 1: Write the failing tests** + +Create `crates/ppvm-tui/src/app.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! `AppState` — the terminal-agnostic state of the ppvm TUI. Owns an optional +//! [`PPVM`] plus the command buffer, status line, program listing, and REPL +//! log. `dispatch` runs one command string; `handle_key` edits the buffer and +//! submits on Enter. Nothing here touches a terminal or runs a loop. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use eyre::{Result, eyre}; +use ppvm_vihaco::CircuitInstruction; +use ppvm_vihaco::composite::PPVM; +use ppvm_vihaco::measurements::MeasurementResult; + +use crate::codeview::CodeView; +use crate::command::{Command, parse_command}; + +/// Terminal-agnostic state for the ppvm TUI. +pub struct AppState { + /// The live machine. `None` until `device N` or a program is loaded. + machine: Option, + /// Program instruction listing (populated when a program is loaded). + program: CodeView, + /// REPL scrollback: entered commands and inline results. + log: CodeView, + /// Qubit count of the current REPL device (for `:reset`). + n_qubits: usize, + /// True while a program is loaded (Program panel) vs a REPL session (Log). + has_program: bool, + /// True while the debugger is paused (at start or a breakpoint). + paused: bool, + /// True once the loaded program has run to Return/Halt. + finished: bool, + /// The command-line buffer. + input: String, + /// The status/error line. + status: String, + /// Set to leave the event loop. + pub should_exit: bool, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + pub fn new() -> Self { + Self { + machine: None, + program: CodeView::new(), + log: CodeView::new(), + n_qubits: 0, + has_program: false, + paused: false, + finished: false, + input: String::new(), + status: String::new(), + should_exit: false, + } + } + + // ─── command dispatch ──────────────────────────────────────────────── + + /// Run one command-line string. Command-level errors are non-fatal: they + /// are written to the status line and the app keeps running. + pub fn dispatch(&mut self, line: &str) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + self.log.push(format!("ppvm> {trimmed}")); + } + let result = match parse_command(line) { + Ok(cmd) => self.run_command(cmd), + Err(e) => Err(e), + }; + if let Err(e) = result { + self.set_status(format!("error: {e}")); + } + } + + fn run_command(&mut self, cmd: Command) -> Result<()> { + match cmd { + Command::Quit => { + self.should_exit = true; + Ok(()) + } + Command::Device(n) => self.new_device(n), + Command::Gate { + inst, + qubits, + params, + } => self.apply_gate(inst, &qubits, ¶ms), + // Step/Continue/Reset/Load are implemented in Task 5. + Command::Step | Command::Continue | Command::Reset | Command::Load(_) => { + self.set_status("not a REPL command (load a program first)"); + Ok(()) + } + } + } + + fn new_device(&mut self, n: usize) -> Result<()> { + self.machine = Some(PPVM::with_qubits(n)?); + self.n_qubits = n; + self.has_program = false; + self.paused = false; + self.finished = false; + self.program.clear(); + self.set_status(format!("fresh {n}-qubit device")); + Ok(()) + } + + fn apply_gate( + &mut self, + inst: CircuitInstruction, + qubits: &[usize], + params: &[f64], + ) -> Result<()> { + let m = self + .machine + .as_mut() + .ok_or_else(|| eyre!("no device — run `device N` or :load a file first"))?; + let before = m.measurement_record().len(); + m.apply_circuit_instruction(inst, qubits, params)?; + // Any new record entries are this gate's measurement outcomes. + let new: Vec = m.measurement_record()[before..].to_vec(); + if new.is_empty() { + self.set_status(""); + } else { + let bits = format_record(&new); + self.log.push(format!(" => {bits}")); + self.set_status(format!("=> {bits}")); + } + Ok(()) + } + + fn set_status(&mut self, s: impl Into) { + self.status = s.into(); + } + + // ─── key handling ──────────────────────────────────────────────────── + + /// Apply one key event. Returns whether it was consumed. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if key.kind != KeyEventKind::Press { + return false; + } + match key.code { + KeyCode::Enter => { + let line = std::mem::take(&mut self.input); + self.dispatch(&line); + true + } + // Ctrl-C: clear a non-empty buffer, else quit (shell-like). + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if self.input.is_empty() { + self.should_exit = true; + } else { + self.input.clear(); + } + true + } + KeyCode::Char(c) => { + self.input.push(c); + true + } + KeyCode::Backspace => { + self.input.pop(); + true + } + KeyCode::Esc => { + if self.input.is_empty() { + self.should_exit = true; + } else { + self.input.clear(); + } + true + } + _ => false, + } + } + + // ─── read-only accessors (used by the widgets in Task 6) ────────────── + + pub fn input(&self) -> &str { + &self.input + } + + pub fn status(&self) -> &str { + &self.status + } + + /// Which listing the Program panel shows: the loaded program, or the log. + pub fn active_listing(&self) -> (&'static str, &CodeView) { + if self.has_program { + ("Program", &self.program) + } else { + ("Log", &self.log) + } + } + + /// The tableau rendering for the State panel. + pub fn state_text(&self) -> String { + match &self.machine { + Some(m) => m.state_string(), + None => "(no device — type `device N` or :load )".to_string(), + } + } + + /// The measurement record as flat bits, events separated by spaces. + pub fn measurement_bits(&self) -> String { + match &self.machine { + Some(m) => { + let rec = m.measurement_record(); + if rec.is_empty() { + "(none)".to_string() + } else { + format_record(&rec) + } + } + None => "(none)".to_string(), + } + } + + /// A contextual footer hint. + pub fn hint(&self) -> &'static str { + if self.has_program && self.paused { + "Enter=step :c=continue :reset :q=quit" + } else if self.machine.is_some() { + "type a gate, or :load :q=quit" + } else { + ":load or device N to begin :q=quit" + } + } +} + +/// Render a measurement record as flat bits: `Zero`→`0`, `One`→`1`, `Lost`→`2` +/// (the outcome's own enum value), events joined by spaces. +fn format_record(record: &[MeasurementResult]) -> String { + record + .iter() + .map(|event| { + event + .iter() + .map(|o| char::from(b'0' + *o as u8)) + .collect::() + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn device_then_x_then_measure_records_one() { + let mut app = AppState::new(); + app.dispatch("device 1"); + app.dispatch("x 0"); + app.dispatch("measure 0"); + assert_eq!(app.measurement_bits(), "1"); + assert!(app.status().contains("=> 1"), "status: {}", app.status()); + } + + #[test] + fn fresh_measure_is_zero() { + let mut app = AppState::new(); + app.dispatch("device 1"); + app.dispatch("measure 0"); + assert_eq!(app.measurement_bits(), "0"); + } + + #[test] + fn gate_without_device_is_a_nonfatal_error() { + let mut app = AppState::new(); + app.dispatch("x 0"); + assert!(app.status().contains("no device")); + // Still usable afterwards. + app.dispatch("device 1"); + app.dispatch("measure 0"); + assert_eq!(app.measurement_bits(), "0"); + } + + #[test] + fn cnot_respects_control_target_order() { + let mut app = AppState::new(); + app.dispatch("device 2"); + app.dispatch("x 0"); + app.dispatch("cnot 0 1"); + app.dispatch("measure 0"); + app.dispatch("measure 1"); + // Two separate measurement events, so two space-separated bits. + assert_eq!(app.measurement_bits(), "1 1"); + } + + #[test] + fn out_of_range_qubit_errors_not_panics() { + let mut app = AppState::new(); + app.dispatch("device 1"); + app.dispatch("x 3"); + assert!(app.status().contains("out of range"), "status: {}", app.status()); + } + + #[test] + fn enter_key_dispatches_the_buffered_line() { + let mut app = AppState::new(); + for c in "device 1".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.input(), "device 1"); + app.handle_key(key(KeyCode::Enter)); + assert!(app.status().contains("1-qubit device")); + assert!(app.input().is_empty(), "buffer should clear on submit"); + } + + #[test] + fn backspace_edits_the_buffer() { + let mut app = AppState::new(); + app.handle_key(key(KeyCode::Char('h'))); + app.handle_key(key(KeyCode::Char('i'))); + app.handle_key(key(KeyCode::Backspace)); + assert_eq!(app.input(), "h"); + } + + #[test] + fn ctrl_c_on_empty_buffer_exits() { + let mut app = AppState::new(); + app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(app.should_exit); + } + + #[test] + fn ctrl_c_on_nonempty_buffer_clears_it() { + let mut app = AppState::new(); + app.handle_key(key(KeyCode::Char('x'))); + app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(!app.should_exit); + assert!(app.input().is_empty()); + } + + #[test] + fn quit_command_sets_should_exit() { + let mut app = AppState::new(); + app.dispatch(":q"); + assert!(app.should_exit); + } +} +``` + +- [ ] **Step 2: Register the module and re-export** + +In `crates/ppvm-tui/src/lib.rs`, add: + +```rust +pub mod app; + +pub use app::AppState; +``` + +- [ ] **Step 3: Run the test to verify it fails first, then passes** + +Run: `cargo test -p ppvm-tui app::` +Expected: PASS for all `app::tests::*`. (If you want to see red first, temporarily stub `apply_gate` to `Ok(())` — optional; the tests are the gate here.) + +- [ ] **Step 4: Commit** + +```bash +cargo fmt +git add crates/ppvm-tui/src/app.rs crates/ppvm-tui/src/lib.rs +git commit -m "feat(ppvm-tui): AppState REPL dispatch and key handling" +``` + +--- + +### Task 5: `AppState` program loading, stepping, and breakpoint injection + +Add the debugger half: load a program (from file or source), step / continue, pause at breakpoints, and — because Task 1 made gates pc-safe — inject gates at a breakpoint and resume. + +**Files:** +- Modify: `crates/ppvm-tui/src/app.rs` + +**Interfaces:** +- Consumes: `ppvm_vihaco::{compile_program, load_module_file, PPVMModule}`, `ppvm_vihaco::composite::{PPVM, StepOutcome}`, `PPVM::{load, init, step_once, current_pc}`. +- Produces (relied on by Tasks 6, 7): + - `AppState::from_file(path: &str) -> eyre::Result` + - `AppState::load_source(&mut self, src: &str) -> eyre::Result<()>` (test/embedding entry) + - `AppState::has_program(&self) -> bool`, `AppState::paused(&self) -> bool` + - fills in the `Step`/`Continue`/`Reset`/`Load` arms of `run_command` + +- [ ] **Step 1: Write the failing tests** + +Add to the `#[cfg(test)] mod tests` block in `crates/ppvm-tui/src/app.rs`: + +```rust + /// A 1-qubit program with a breakpoint before measuring q0 (|0> -> 0). + const BP_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + + #[test] + fn load_source_starts_paused_with_a_listing() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + assert!(app.has_program()); + assert!(app.paused()); + let (title, view) = app.active_listing(); + assert_eq!(title, "Program"); + assert!(!view.lines().is_empty()); + assert_eq!(view.cursor(), Some(0), "cursor starts at pc 0"); + } + + #[test] + fn continue_pauses_at_breakpoint_then_finishes() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); + assert!(app.status().contains("breakpoint"), "status: {}", app.status()); + app.dispatch(":c"); + assert!(app.status().contains("finished"), "status: {}", app.status()); + // |0> measured is 0. + assert_eq!(app.measurement_bits(), "0"); + } + + #[test] + fn empty_line_steps_and_advances_cursor() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + let start = app.active_listing().1.cursor(); + app.dispatch(""); // empty line == step + let after = app.active_listing().1.cursor(); + assert_ne!(start, after, "stepping should move the cursor"); + } + + #[test] + fn inject_gate_at_breakpoint_then_resume() { + // At the breakpoint, inject X on q0; resuming, the program measures |1>. + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); // run to the breakpoint + assert!(app.status().contains("breakpoint")); + app.dispatch("x 0"); // inject while paused + app.dispatch(":c"); // resume; program measures q0 + assert!(app.status().contains("finished"), "status: {}", app.status()); + assert_eq!(app.measurement_bits(), "1", "injected X should flip the result"); + } + + #[test] + fn reset_returns_a_program_to_the_start() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); + app.dispatch(":c"); // finished + app.dispatch(":reset"); + assert!(app.paused()); + assert_eq!(app.active_listing().1.cursor(), Some(0)); + assert_eq!(app.measurement_bits(), "(none)"); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p ppvm-tui app::tests::load_source_starts_paused_with_a_listing` +Expected: FAIL to compile — `load_source` / `has_program` / `paused` do not exist yet. + +- [ ] **Step 3: Extend the imports** + +At the top of `crates/ppvm-tui/src/app.rs`, replace the two `ppvm_vihaco::composite::*` / `ppvm_vihaco` use lines with: + +```rust +use ppvm_vihaco::composite::{PPVM, StepOutcome}; +use ppvm_vihaco::{CircuitInstruction, PPVMModule, compile_program, load_module_file}; +``` + +(Keep the existing `use ppvm_vihaco::measurements::MeasurementResult;` line.) + +- [ ] **Step 4: Add the loading + stepping methods** + +Add a `module: Option` field to the `AppState` struct, initialize it to `None` in `new()`, then add these methods inside `impl AppState` (e.g. after `new_device`): + +```rust + // ─── program loading ───────────────────────────────────────────────── + + /// Build an `AppState` with `path` loaded and paused at pc 0. + pub fn from_file(path: &str) -> Result { + let mut app = Self::new(); + app.load_file(path)?; + Ok(app) + } + + /// Compile `.sst` source and load it, paused at pc 0. (Test/embedding entry + /// that avoids touching the filesystem.) + pub fn load_source(&mut self, src: &str) -> Result<()> { + let module = compile_program(src)?; + self.load_module(module); + self.set_status("loaded program"); + Ok(()) + } + + fn load_file(&mut self, path: &str) -> Result<()> { + let module = + load_module_file(path).map_err(|e| eyre!("failed to load {path}: {e}"))?; + self.load_module(module); + self.set_status(format!("loaded {path}")); + Ok(()) + } + + /// Core loader: rebuild the machine from `module` and pause at pc 0. + fn load_module(&mut self, module: PPVMModule) { + let mut m = PPVM::default(); + // A fresh machine + load + init gives clean tableau/record state; these + // only fail on malformed modules, which `compile_program` already + // rejects, so surface as a status rather than unwinding the UI. + if let Err(e) = m.load(&module).and_then(|()| m.init()) { + self.set_status(format!("error: {e}")); + return; + } + self.program.clear(); + for (i, inst) in module.code.iter().enumerate() { + self.program.push(format!("{i:04}: {inst}")); + } + self.machine = Some(m); + self.module = Some(module); + self.has_program = true; + self.paused = true; + self.finished = false; + self.refresh_cursor(); + } + + fn refresh_cursor(&mut self) { + let pc = self.machine.as_ref().map(|m| m.current_pc() as usize); + if let Some(pc) = pc { + self.program.set_cursor(Some(pc)); + } + } + + // ─── stepping ──────────────────────────────────────────────────────── + + fn step(&mut self) -> Result<()> { + if !self.has_program { + self.set_status("nothing to step — load a program with :load"); + return Ok(()); + } + if self.finished { + self.set_status("program finished — :reset to run again"); + return Ok(()); + } + let outcome = self.machine.as_mut().unwrap().step_once()?; + self.apply_outcome(outcome); + self.refresh_cursor(); + Ok(()) + } + + fn cont(&mut self) -> Result<()> { + if !self.has_program { + self.set_status("nothing to continue — load a program with :load"); + return Ok(()); + } + while !self.finished { + let outcome = self.machine.as_mut().unwrap().step_once()?; + match outcome { + StepOutcome::Continue => {} + StepOutcome::Breakpoint => { + self.apply_outcome(outcome); + self.refresh_cursor(); + return Ok(()); + } + StepOutcome::Return | StepOutcome::Halt => { + self.apply_outcome(outcome); + break; + } + } + } + self.refresh_cursor(); + Ok(()) + } + + /// Fold a single step outcome into the app's paused/finished/status state. + fn apply_outcome(&mut self, outcome: StepOutcome) { + match outcome { + StepOutcome::Continue => self.set_status(""), + StepOutcome::Breakpoint => { + self.paused = true; + self.set_status("-- breakpoint hit --"); + } + StepOutcome::Return | StepOutcome::Halt => { + self.finished = true; + self.set_status("program finished"); + } + } + } + + fn reset(&mut self) -> Result<()> { + if let Some(module) = self.module.clone() { + self.load_module(module); + self.set_status("reset"); + } else if self.n_qubits > 0 { + self.machine = Some(PPVM::with_qubits(self.n_qubits)?); + self.set_status("reset device"); + } else { + self.set_status("nothing to reset"); + } + Ok(()) + } + + pub fn has_program(&self) -> bool { + self.has_program + } + + pub fn paused(&self) -> bool { + self.paused + } +``` + +- [ ] **Step 5: Wire the new commands into `run_command`** + +Replace the combined `Command::Step | Command::Continue | Command::Reset | Command::Load(_)` arm in `run_command` with real arms: + +```rust + Command::Step => self.step(), + Command::Continue => self.cont(), + Command::Reset => self.reset(), + Command::Load(path) => self.load_file(&path), +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cargo test -p ppvm-tui` +Expected: PASS (Task 4 tests + the five new Task 5 tests). + +- [ ] **Step 7: Commit** + +```bash +cargo fmt +git add crates/ppvm-tui/src/app.rs +git commit -m "feat(ppvm-tui): program loading, stepping, breakpoint injection" +``` + +--- + +### Task 6: Widgets + `AppState::render` + +Add the four ratatui `Widget` view components and a convenience full-screen composer. Widgets borrow `&AppState` and only read — they own no terminal, so a host app can render them individually. + +**Files:** +- Create: `crates/ppvm-tui/src/widgets.rs` +- Modify: `crates/ppvm-tui/src/lib.rs` (add `pub mod widgets;`) +- Modify: `crates/ppvm-tui/src/app.rs` (add `pub fn render(&self, frame: &mut ratatui::Frame)`) + +**Interfaces:** +- Consumes: `AppState` accessors (`active_listing`, `state_text`, `measurement_bits`, `input`, `status`, `hint`). +- Produces: + - `ppvm_tui::widgets::{ProgramView, StateView, RecordView, CommandLine}` — each `pub struct X<'a>(pub &'a AppState)` implementing `ratatui::widgets::Widget`. + - `AppState::render(&self, frame: &mut ratatui::Frame)` — standalone layout. Consumed by Task 7. + +- [ ] **Step 1: Write the failing test** + +Create `crates/ppvm-tui/src/widgets.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! ratatui `Widget` components for the ppvm TUI. Each borrows `&AppState` and +//! only reads it, so a host app can render any of them into its own layout. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; + +use crate::app::AppState; + +/// The left panel: the loaded program's listing (with a `▶` at the pc) or, in a +/// REPL session, the command/result log. +pub struct ProgramView<'a>(pub &'a AppState); + +impl Widget for ProgramView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let (title, view) = self.0.active_listing(); + let mut text = Text::default(); + for (i, line) in view.lines().iter().enumerate() { + let marked = if view.cursor() == Some(i) { + format!("▶ {line}") + } else { + format!(" {line}") + }; + text.push_line(Line::from(marked)); + } + Paragraph::new(text) + .block(Block::bordered().title(title)) + .render(area, buf); + } +} + +/// The right panel: the tableau state (`PPVM::state_string`). +pub struct StateView<'a>(pub &'a AppState); + +impl Widget for StateView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.state_text()) + .block(Block::bordered().title("State")) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} + +/// The measurement-record band. +pub struct RecordView<'a>(pub &'a AppState); + +impl Widget for RecordView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.measurement_bits()) + .block(Block::bordered().title("Measurement record")) + .render(area, buf); + } +} + +/// The footer: prompt + input, then the hint and status line. +pub struct CommandLine<'a>(pub &'a AppState); + +impl Widget for CommandLine<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let text = Text::from(vec![ + Line::from(format!("ppvm> {}", self.0.input())), + Line::from(format!("{} {}", self.0.hint(), self.0.status())), + ]); + Paragraph::new(text).render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use crate::AppState; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + const BP_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + + #[test] + fn renders_all_panels_without_panic() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + + let content: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(content.contains("Program"), "missing Program panel"); + assert!(content.contains("State"), "missing State panel"); + assert!(content.contains("Measurement record"), "missing record panel"); + assert!(content.contains("ppvm>"), "missing command prompt"); + } +} +``` + +- [ ] **Step 2: Register the module** + +In `crates/ppvm-tui/src/lib.rs`, add: + +```rust +pub mod widgets; +``` + +- [ ] **Step 3: Add `AppState::render`** + +In `crates/ppvm-tui/src/app.rs`, add these imports near the top: + +```rust +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout}; + +use crate::widgets::{CommandLine, ProgramView, RecordView, StateView}; +``` + +Then add this method inside `impl AppState`: + +```rust + /// Convenience full-screen composer for the standalone `ppvm` TUI. A host + /// app (e.g. stellarscope) ignores this and lays out the individual + /// `…View` widgets itself. + pub fn render(&self, frame: &mut Frame) { + let root = Layout::vertical([ + Constraint::Min(6), // Program | State + Constraint::Length(3), // measurement record + Constraint::Length(2), // command line + ]) + .split(frame.area()); + + let top = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(root[0]); + + frame.render_widget(ProgramView(self), top[0]); + frame.render_widget(StateView(self), top[1]); + frame.render_widget(RecordView(self), root[1]); + frame.render_widget(CommandLine(self), root[2]); + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p ppvm-tui widgets::` +Expected: PASS (`renders_all_panels_without_panic`). + +- [ ] **Step 5: Run the whole crate suite** + +Run: `cargo test -p ppvm-tui` +Expected: PASS (all tasks 2–6 tests). + +- [ ] **Step 6: Commit** + +```bash +cargo fmt +git add crates/ppvm-tui/src/widgets.rs crates/ppvm-tui/src/lib.rs crates/ppvm-tui/src/app.rs +git commit -m "feat(ppvm-tui): ratatui widgets and full-screen composer" +``` + +--- + +### Task 7: `ppvm-cli` wiring — launch the TUI + +Make the bare `ppvm` (and `ppvm `) launch the TUI while keeping all existing subcommands. Add the terminal setup + event loop in the binary. + +**Files:** +- Modify: `crates/ppvm-cli/Cargo.toml` +- Modify: `crates/ppvm-cli/src/main.rs` +- Create: `crates/ppvm-cli/src/tui.rs` +- Modify: `crates/ppvm-cli/README.md` + +**Interfaces:** +- Consumes: `ppvm_tui::AppState` (`new`, `from_file`, `handle_key`, `render`, `should_exit`). +- Produces: `ppvm_cli::tui::run(file: Option<&str>) -> eyre::Result<()>`; a `Cli` with `command: Option` and a top-level `file: Option`. + +- [ ] **Step 1: Add the dependencies** + +In `crates/ppvm-cli/Cargo.toml`, add to `[dependencies]`: + +```toml +ppvm-tui = { version = "0.1.0", path = "../ppvm-tui" } +ratatui = "0.29.0" +crossterm = "0.29.0" +``` + +- [ ] **Step 2: Write the failing CLI-parsing tests** + +Add a test module at the bottom of `crates/ppvm-cli/src/main.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn bare_invocation_has_no_command_or_file() { + let cli = Cli::try_parse_from(["ppvm"]).unwrap(); + assert!(cli.command.is_none()); + assert!(cli.file.is_none()); + } + + #[test] + fn file_positional_is_captured_for_the_tui() { + let cli = Cli::try_parse_from(["ppvm", "prog.sst"]).unwrap(); + assert!(cli.command.is_none()); + assert_eq!(cli.file.as_deref(), Some("prog.sst")); + } + + #[test] + fn subcommands_still_parse() { + let cli = Cli::try_parse_from(["ppvm", "run", "prog.sst"]).unwrap(); + assert!(matches!(cli.command, Some(Commands::Run { .. }))); + assert!(cli.file.is_none()); + } +} +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cargo test -p ppvm-cli bare_invocation_has_no_command_or_file` +Expected: FAIL to compile — `command` is not yet `Option`, and there is no `file` field. + +- [ ] **Step 4: Update `Cli` and `main` to launch the TUI** + +In `crates/ppvm-cli/src/main.rs`: + +1. Add the module declaration near `mod commands;`: + +```rust +mod commands; +mod tui; +``` + +2. Replace the `Cli` struct with an optional subcommand + optional file positional, using `args_conflicts_with_subcommands` so `ppvm prog.sst` and `ppvm run prog.sst` are unambiguous: + +```rust +#[derive(Parser)] +#[command(name = "ppvm")] +#[command(about = "Pauli propagation virtual machine", long_about = None)] +#[command(args_conflicts_with_subcommands = true)] +pub struct Cli { + /// Number of threads for all parallel work (1 = fully serial & deterministic) + #[arg(short, long, default_value = "1")] + threads: usize, + + /// A .sst/.ssb file to open in the TUI (when no subcommand is given). + #[arg(value_name = "FILE")] + file: Option, + + /// Subcommand to run; with none, launches the interactive TUI. + #[command(subcommand)] + command: Option, +} +``` + +3. Replace the `match cli.command { … }` block in `main` with an arm for `None` and `Some(...)` wrappers: + +```rust + match cli.command { + None => tui::run(cli.file.as_deref())?, + Some(Commands::Parse { file, format }) => { + commands::parse(&file, format)?; + } + Some(Commands::Dump { + file, + output, + force, + }) => { + commands::dump(&file, output.as_deref(), force)?; + } + Some(Commands::Run { + file, + shots, + seed, + output, + quiet, + format, + }) => { + commands::run(&file, shots, seed, output.as_deref(), quiet, format)?; + } + Some(Commands::Debug { + file, + break_at_start, + }) => { + commands::debug(&file, break_at_start)?; + } + } +``` + +- [ ] **Step 5: Create the terminal loop** + +Create `crates/ppvm-cli/src/tui.rs`: + +```rust +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Terminal ownership for the ppvm TUI: raw mode + alternate screen behind an +//! RAII guard (restored even on panic), plus a blocking event loop that drives +//! the terminal-agnostic `ppvm_tui::AppState`. + +use std::io; + +use crossterm::event::{self, Event}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use eyre::Result; +use ppvm_tui::AppState; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +/// Restores the terminal on drop — including when the app panics mid-loop. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + } +} + +/// Launch the TUI. With `file`, open it loaded and paused at pc 0; without, +/// start an empty REPL session. +pub fn run(file: Option<&str>) -> Result<()> { + let mut app = match file { + Some(path) => AppState::from_file(path)?, + None => AppState::new(), + }; + + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + let _guard = TerminalGuard; + + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + terminal.clear()?; + + while !app.should_exit { + terminal.draw(|frame| app.render(frame))?; + if let Event::Key(key) = event::read()? { + app.handle_key(key); + } + } + Ok(()) +} +``` + +- [ ] **Step 6: Run the CLI tests + build the binary** + +Run: `cargo test -p ppvm-cli` +Expected: PASS (the three parsing tests + existing `commands` tests). + +Run: `cargo build -p ppvm-cli` +Expected: builds clean. + +- [ ] **Step 7: Update the CLI README** + +In `crates/ppvm-cli/README.md`, add a short section documenting the TUI. Insert after the existing intro/usage (adapt heading level to match the file): + +```markdown +## Interactive TUI + +Running `ppvm` with no subcommand launches an interactive terminal UI: + +- `ppvm` — start an empty session; type `device N` then gate ops + (`h 0`, `cnot 0 1`, `measure 0`, `rx 0 0.5`, …). +- `ppvm program.sst` — open the program paused at the first instruction. + +Inside the TUI: + +- **Gate ops** (bare tokens) apply to the live tableau; measurement outcomes + echo as `=> bits`. +- **`:load `** (re)loads a program; **`:reset`** restarts it. +- **Enter on an empty line** steps one instruction; **`:c`** continues to the + next breakpoint or the end; authored `breakpoint` instructions pause. +- At a breakpoint you can inject gate ops — they update the state and stepping + resumes exactly where it paused. +- **`:q`**, **Esc**, or **Ctrl-C** (on an empty line) exits. + +The state panel shows the tableau; the bottom band shows the measurement +record. +``` + +- [ ] **Step 8: Full workspace gate + manual smoke check** + +Run: `cargo test --workspace` +Expected: PASS across all crates. + +Manual smoke (optional, not a test): `cargo run -p ppvm-cli -- crates/ppvm-cli/examples/ghz.sst` opens the TUI paused; Enter steps, `:c` runs, `:q` exits with the terminal restored. + +- [ ] **Step 9: Commit** + +```bash +cargo fmt +git add crates/ppvm-cli/Cargo.toml crates/ppvm-cli/src/main.rs crates/ppvm-cli/src/tui.rs crates/ppvm-cli/README.md +git commit -m "feat(ppvm-cli): launch composable TUI on bare invocation" +``` + +--- + +## Self-Review + +**1. Spec coverage** (checked against `2026-07-01-cli-tui-design.md`): + +| Spec section | Task | +| --- | --- | +| §2 unified single screen | Task 6 (`render`), Task 7 (loop) | +| §2 program loading (`ppvm ` + `:load`) | Task 5 (`from_file`/`load_file`), Task 7 (positional) | +| §2 existing subcommands unchanged | Task 7 (Some-arm wrappers, tests) | +| §2 crate boundary (lib + bin) | Task 2 (crate), Task 7 (bin owns loop) | +| §2 ratatui/crossterm 0.29 | Global Constraints, Task 2/7 manifests | +| §2 local `CodeView` (no stellarscope dep) | Task 2 | +| §2 breakpoint injection | Task 1 (engine) + Task 5 (`inject_gate_at_breakpoint_then_resume`) | +| §4 four panels | Task 6 widgets | +| §5 command grammar (bare gates / `:` meta / empty-Enter step) | Task 3 + Task 4 (`handle_key`) + Task 5 (step) | +| §6 engine integration + the one engine change | Task 1 + Tasks 4/5 | +| §7 non-fatal errors | Task 4 `dispatch` (error → status), tests | +| §8 testing (no terminal; TestBackend smoke) | Tasks 3–6 tests | +| §9 composability (Widget + handle_key, no terminal ownership) | Task 6 widgets, Task 4 `handle_key` | +| §10 PauliSum deferred (Tableau-only `device N`) | Task 4 `new_device` uses `with_qubits` (Tableau) | +| §11 success criteria | Covered by Task 5/6/7 tests + manual smoke | + +No gaps found. + +**2. Placeholder scan:** No `TBD`/`TODO`/"handle edge cases"/"similar to Task N". Every code step shows complete code; every run step gives an exact command + expected result. + +**3. Type consistency:** `AppState` field/method names are consistent across Tasks 4–7 (`machine`, `program`, `log`, `has_program`, `paused`, `finished`, `module`, `n_qubits`; `dispatch`, `handle_key`, `render`, `active_listing`, `state_text`, `measurement_bits`, `input`, `status`, `hint`, `has_program()`, `paused()`). `Command` variants match between `command.rs` (Task 3) and `run_command` (Tasks 4–5). `parse_command`/`gate_spec`/`GateSpec` signatures match their consumers. `tui::run(Option<&str>)` matches the Task 7 `main` call site. Engine method `apply_circuit_instruction` keeps its exact signature in Task 1. + +One intentional cross-task edit: Task 4 introduces a placeholder combined arm for `Step|Continue|Reset|Load` returning a status; Task 5 Step 5 replaces it with the real arms. This is called out explicitly in both tasks. From caa39012c2e8e4a430af0db362f62ecf3f3e0629 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:49:41 +0200 Subject: [PATCH 04/19] feat(ppvm-vihaco): preserve pc in apply_circuit_instruction Save/restore the program counter and code-vector length around execute_single_instruction so a paused debugger's position is byte-for-byte unchanged after injecting a gate at a breakpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-vihaco/src/composite.rs | 52 ++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index d150e376..e84adb2b 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -434,7 +434,18 @@ impl PPVM { ))); } instrs.push(PPVMInstruction::Circuit(inst)); - self.execute_single_instruction(&instrs) + + // Apply the op without disturbing a loaded program or its program + // counter: run the appended block, then truncate it and restore the pc. + // The tableau/measurement effects persist; the code + pc are left + // byte-for-byte unchanged, so a paused debugger resumes exactly where it + // was. (Also keeps a long REPL session's code vector from growing.) + let saved_pc = self.loader.pc(); + let saved_len = self.loader.module.code.len(); + self.execute_single_instruction(&instrs)?; + self.loader.module.code.truncate(saved_len); + *self.loader.pc_mut() = saved_pc; + Ok(()) } fn execute_effects(&mut self, inst: Instruction) -> eyre::Result> { @@ -1262,6 +1273,45 @@ mod tests { Ok(()) } + #[test] + fn apply_circuit_instruction_preserves_pc_and_code_len() -> eyre::Result<()> { + use crate::measurements::MeasurementOutcome; + + // breakpoint; then measure q0. Step to the breakpoint, inject X, resume. + let src = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + let mut m = PPVM::default(); + m.load_program(src)?; + m.init()?; + + // Run until the breakpoint pauses us. + loop { + if m.step_once()? == StepOutcome::Breakpoint { + break; + } + } + let pc = m.current_pc(); + let len = m.loader.module.code.len(); + + // Inject X on q0 while "paused". + m.apply_circuit_instruction(CircuitInstruction::X, &[0], &[])?; + + // The debugger's position must be untouched. + assert_eq!(m.current_pc(), pc, "pc must be preserved"); + assert_eq!( + m.loader.module.code.len(), + len, + "appended op must be truncated back" + ); + + // And the X took effect: resuming the program measures |1>. + while !matches!(m.step_once()?, StepOutcome::Return | StepOutcome::Halt) {} + let rec = m.measurement_record(); + assert_eq!(rec.len(), 1); + assert_eq!(rec[0].as_slice(), [MeasurementOutcome::One]); + Ok(()) + } + #[test] fn tableau_trace_emits_expectation_on_zero_state() { // Task 16: `circuit.trace` on the Tableau backend now computes From 9b3d54e297fb254c34d2460c9349703bf6558777 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:53:51 +0200 Subject: [PATCH 05/19] feat(ppvm-tui): scaffold crate with CodeView line list Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 421 ++++++++++++++++++++++++++++++-- Cargo.toml | 2 +- crates/ppvm-tui/Cargo.toml | 15 ++ crates/ppvm-tui/src/codeview.rs | 70 ++++++ crates/ppvm-tui/src/lib.rs | 8 + 5 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 crates/ppvm-tui/Cargo.toml create mode 100644 crates/ppvm-tui/src/codeview.rs create mode 100644 crates/ppvm-tui/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 3c0f166b..9ce9e7b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,7 +81,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -92,7 +92,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -247,12 +247,27 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -393,7 +408,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -408,7 +423,21 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", ] [[package]] @@ -419,7 +448,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -431,6 +460,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -533,6 +571,49 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -620,6 +701,37 @@ dependencies = [ "thiserror", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "either" version = "1.16.0" @@ -668,7 +780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -841,6 +953,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "insta" version = "1.48.0" @@ -854,6 +975,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "inventory" version = "0.3.24" @@ -944,12 +1078,24 @@ dependencies = [ "cc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -965,6 +1111,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.2" @@ -980,6 +1135,18 @@ dependencies = [ "libmimalloc-sys", ] +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -1090,6 +1257,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1325,6 +1502,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "ppvm-tui" +version = "0.1.0" +dependencies = [ + "crossterm 0.29.0", + "eyre", + "ppvm-vihaco", + "ratatui", +] + [[package]] name = "ppvm-vihaco" version = "0.1.0" @@ -1562,6 +1749,27 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.13.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.12.0" @@ -1637,6 +1845,28 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1646,8 +1876,8 @@ dependencies = [ "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -1668,6 +1898,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1683,6 +1919,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1732,6 +1974,37 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "similar" version = "2.7.0" @@ -1760,9 +2033,15 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stim-parser" version = "0.1.0" @@ -1778,6 +2057,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.118" @@ -1810,8 +2111,8 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -1871,6 +2172,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -1879,9 +2191,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unty" @@ -1954,7 +2266,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f11ac1123518d4bec265847a1a12e406c1b54f4ef2478db87f4e6986e26b918" dependencies = [ - "convert_case", + "convert_case 0.6.0", "proc-macro2", "quote", "syn", @@ -2006,6 +2318,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -2092,7 +2410,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2107,6 +2425,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2116,6 +2443,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 29fe3a70..19a5ab41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ # Runnable copies of the Rust code blocks in skills/ppvm-usage/SKILL.md. # Built by `cargo build --workspace --all-targets` in CI so the skill # can't silently drift away from the public API. - "skills/ppvm-usage/examples/rust", "crates/ppvm-cli", "crates/vihaco-circuit-isa", + "skills/ppvm-usage/examples/rust", "crates/ppvm-cli", "crates/vihaco-circuit-isa", "crates/ppvm-tui", ] # Keep function names and line numbers in release builds so profilers diff --git a/crates/ppvm-tui/Cargo.toml b/crates/ppvm-tui/Cargo.toml new file mode 100644 index 00000000..d543a756 --- /dev/null +++ b/crates/ppvm-tui/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ppvm-tui" +version = "0.1.0" +edition = "2024" + +[dependencies] +eyre = "0.6.12" +ratatui = "0.29.0" +crossterm = "0.29.0" +ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco" } + +[package.metadata.cargo-machete] +# These deps are declared for later tasks (TUI app shell, event loop, engine +# integration) and are not yet imported in the current scaffold. +ignored = ["crossterm", "eyre", "ppvm-vihaco", "ratatui"] diff --git a/crates/ppvm-tui/src/codeview.rs b/crates/ppvm-tui/src/codeview.rs new file mode 100644 index 00000000..2f14c104 --- /dev/null +++ b/crates/ppvm-tui/src/codeview.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A minimal scrollable list of lines with one optionally-highlighted row. +//! Backs the Program panel (highlight = the program counter) and the REPL log +//! (no highlight). Deliberately mirrors the shape of stellarscope's `CodeView` +//! so the two stay interchangeable, but has no external dependencies. + +/// A list of displayable lines plus an optional cursor (the highlighted row). +#[derive(Debug, Clone, Default)] +pub struct CodeView { + lines: Vec, + cursor: Option, +} + +impl CodeView { + pub fn new() -> Self { + Self { + lines: Vec::new(), + cursor: None, + } + } + + /// Drop all lines and clear the cursor. + pub fn clear(&mut self) { + self.lines.clear(); + self.cursor = None; + } + + /// Append one line. + pub fn push(&mut self, line: T) { + self.lines.push(line); + } + + /// Highlight row `idx` (or none). + pub fn set_cursor(&mut self, idx: Option) { + self.cursor = idx; + } + + pub fn cursor(&self) -> Option { + self.cursor + } + + pub fn lines(&self) -> &[T] { + &self.lines + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_and_cursor_round_trip() { + let mut cv: CodeView = CodeView::new(); + assert!(cv.lines().is_empty()); + assert_eq!(cv.cursor(), None); + + cv.push("0000: h".to_string()); + cv.push("0001: measure".to_string()); + cv.set_cursor(Some(1)); + + assert_eq!(cv.lines().len(), 2); + assert_eq!(cv.cursor(), Some(1)); + + cv.clear(); + assert!(cv.lines().is_empty()); + assert_eq!(cv.cursor(), None); + } +} diff --git a/crates/ppvm-tui/src/lib.rs b/crates/ppvm-tui/src/lib.rs new file mode 100644 index 00000000..08fc5317 --- /dev/null +++ b/crates/ppvm-tui/src/lib.rs @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Composable ratatui components + app state for the `ppvm` TUI. Terminal- +//! agnostic: no code here owns a terminal or runs an event loop, so the +//! `Widget` components and `AppState` can be embedded in another ratatui app. + +pub mod codeview; From c7210930b06cd03386b7915409df081196dddb8c Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:57:18 +0200 Subject: [PATCH 06/19] feat(ppvm-tui): add command grammar and gate table Co-Authored-By: Claude Sonnet 4.6 --- crates/ppvm-tui/Cargo.toml | 2 +- crates/ppvm-tui/src/command.rs | 228 +++++++++++++++++++++++++++++++++ crates/ppvm-tui/src/lib.rs | 1 + 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 crates/ppvm-tui/src/command.rs diff --git a/crates/ppvm-tui/Cargo.toml b/crates/ppvm-tui/Cargo.toml index d543a756..ff13fb8b 100644 --- a/crates/ppvm-tui/Cargo.toml +++ b/crates/ppvm-tui/Cargo.toml @@ -12,4 +12,4 @@ ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco" } [package.metadata.cargo-machete] # These deps are declared for later tasks (TUI app shell, event loop, engine # integration) and are not yet imported in the current scaffold. -ignored = ["crossterm", "eyre", "ppvm-vihaco", "ratatui"] +ignored = ["crossterm", "ratatui"] diff --git a/crates/ppvm-tui/src/command.rs b/crates/ppvm-tui/src/command.rs new file mode 100644 index 00000000..e0baff9a --- /dev/null +++ b/crates/ppvm-tui/src/command.rs @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! The TUI command grammar. Bare tokens are gate ops; `:`-prefixed tokens are +//! meta/debug commands; an empty line means "step". The gate table is ported +//! from the removed rustyline REPL. + +use eyre::{Result, WrapErr, bail, eyre}; +use ppvm_vihaco::CircuitInstruction; + +/// How a gate command lowers: the engine instruction plus how many qubit and +/// float operands it consumes (qubits first, then floats). +pub struct GateSpec { + pub inst: CircuitInstruction, + pub qubits: usize, + pub floats: usize, +} + +/// Resolve a gate name to its spec, or `None` if it is not a gate. +/// `TwoQubitPauliError` is intentionally absent — its tableau arm is `todo!()`. +pub fn gate_spec(name: &str) -> Option { + use CircuitInstruction::*; + let (inst, qubits, floats) = match name { + "x" => (X, 1, 0), + "y" => (Y, 1, 0), + "z" => (Z, 1, 0), + "h" => (H, 1, 0), + "s" => (S, 1, 0), + "sadj" => (SAdj, 1, 0), + "sqrtx" => (SqrtX, 1, 0), + "sqrty" => (SqrtY, 1, 0), + "sqrtxadj" => (SqrtXAdj, 1, 0), + "sqrtyadj" => (SqrtYAdj, 1, 0), + "t" => (T, 1, 0), + "tadj" => (TAdj, 1, 0), + "measure" => (Measure, 1, 0), + "reset" => (Reset, 1, 0), + "cnot" => (CNOT, 2, 0), + "cz" => (CZ, 2, 0), + "rx" => (RX, 1, 1), + "ry" => (RY, 1, 1), + "rz" => (RZ, 1, 1), + "r" => (R, 1, 2), + "rxx" => (RXX, 2, 1), + "ryy" => (RYY, 2, 1), + "rzz" => (RZZ, 2, 1), + "u3" => (U3, 1, 3), + "depolarize" => (Depolarize, 1, 1), + "depolarize2" => (Depolarize2, 2, 1), + "loss" => (Loss, 1, 1), + "paulierror" => (PauliError, 1, 3), + "correlatedloss" => (CorrelatedLoss, 2, 3), + _ => return None, + }; + Some(GateSpec { + inst, + qubits, + floats, + }) +} + +/// One parsed command-line entry. +#[derive(Debug, PartialEq)] +pub enum Command { + /// `device N` — (re)create a fresh N-qubit tableau device. + Device(usize), + /// A gate op, e.g. `cnot 0 1` or `rx 0 0.5`. + Gate { + inst: CircuitInstruction, + qubits: Vec, + params: Vec, + }, + /// Advance one instruction (also the meaning of an empty line). + Step, + /// Run to the next breakpoint or program end. + Continue, + /// Reset the loaded program / device to its initial state. + Reset, + /// Load a `.sst`/`.ssb` file. + Load(String), + /// Leave the TUI. + Quit, +} + +/// Parse one command-line string. +pub fn parse_command(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Ok(Command::Step); + } + + // `:`-prefixed meta / debug commands. + if let Some(rest) = line.strip_prefix(':') { + let mut it = rest.split_whitespace(); + let cmd = it.next().unwrap_or(""); + return match cmd { + "q" | "quit" => Ok(Command::Quit), + "c" | "continue" => Ok(Command::Continue), + "s" | "step" => Ok(Command::Step), + "reset" => Ok(Command::Reset), + "load" => { + let path = it.next().ok_or_else(|| eyre!(":load needs a file path"))?; + Ok(Command::Load(path.to_string())) + } + other => bail!("unknown command :{other}"), + }; + } + + // Bare tokens: `device N` or a gate op. + let mut it = line.split_whitespace(); + let head = it.next().unwrap(); + let args: Vec<&str> = it.collect(); + + if head == "device" { + let n = args + .first() + .ok_or_else(|| eyre!("device needs a qubit count"))? + .parse::() + .wrap_err("invalid qubit count")?; + return Ok(Command::Device(n)); + } + + let spec = + gate_spec(head).ok_or_else(|| eyre!("unknown command {head:?}; try :load or device N"))?; + let expected = spec.qubits + spec.floats; + if args.len() != expected { + bail!( + "{head} takes {} qubit(s) and {} param(s), got {}", + spec.qubits, + spec.floats, + args.len() + ); + } + let (qs, ps) = args.split_at(spec.qubits); + let qubits = qs + .iter() + .map(|t| { + t.parse::() + .wrap_err_with(|| format!("invalid qubit index {t:?}")) + }) + .collect::>>()?; + let params = ps + .iter() + .map(|t| { + t.parse::() + .wrap_err_with(|| format!("invalid parameter {t:?}")) + }) + .collect::>>()?; + Ok(Command::Gate { + inst: spec.inst, + qubits, + params, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_line_is_step() { + assert_eq!(parse_command(" ").unwrap(), Command::Step); + } + + #[test] + fn device_parses_count() { + assert_eq!(parse_command("device 3").unwrap(), Command::Device(3)); + } + + #[test] + fn single_qubit_gate() { + assert_eq!( + parse_command("h 0").unwrap(), + Command::Gate { + inst: CircuitInstruction::H, + qubits: vec![0], + params: vec![], + } + ); + } + + #[test] + fn two_qubit_gate_keeps_operand_order() { + assert_eq!( + parse_command("cnot 0 1").unwrap(), + Command::Gate { + inst: CircuitInstruction::CNOT, + qubits: vec![0, 1], + params: vec![], + } + ); + } + + #[test] + fn rotation_parses_float_param() { + assert_eq!( + parse_command("rx 0 0.5").unwrap(), + Command::Gate { + inst: CircuitInstruction::RX, + qubits: vec![0], + params: vec![0.5], + } + ); + } + + #[test] + fn meta_commands() { + assert_eq!(parse_command(":q").unwrap(), Command::Quit); + assert_eq!(parse_command(":continue").unwrap(), Command::Continue); + assert_eq!(parse_command(":s").unwrap(), Command::Step); + assert_eq!(parse_command(":reset").unwrap(), Command::Reset); + assert_eq!( + parse_command(":load foo.sst").unwrap(), + Command::Load("foo.sst".to_string()) + ); + } + + #[test] + fn unknown_gate_errors() { + assert!(parse_command("bogus 0").is_err()); + } + + #[test] + fn wrong_arity_errors() { + assert!(parse_command("x").is_err()); + assert!(parse_command("cnot 0").is_err()); + } +} diff --git a/crates/ppvm-tui/src/lib.rs b/crates/ppvm-tui/src/lib.rs index 08fc5317..4f0833ad 100644 --- a/crates/ppvm-tui/src/lib.rs +++ b/crates/ppvm-tui/src/lib.rs @@ -6,3 +6,4 @@ //! `Widget` components and `AppState` can be embedded in another ratatui app. pub mod codeview; +pub mod command; From 4f78f03e19d13ae427ca9243a327b7f5338475e1 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:01:26 +0200 Subject: [PATCH 07/19] feat(ppvm-tui): AppState REPL dispatch and key handling Co-Authored-By: Claude Sonnet 4.6 --- crates/ppvm-tui/Cargo.toml | 5 +- crates/ppvm-tui/src/app.rs | 356 +++++++++++++++++++++++++++++++++++++ crates/ppvm-tui/src/lib.rs | 3 + 3 files changed, 361 insertions(+), 3 deletions(-) create mode 100644 crates/ppvm-tui/src/app.rs diff --git a/crates/ppvm-tui/Cargo.toml b/crates/ppvm-tui/Cargo.toml index ff13fb8b..9d1b2888 100644 --- a/crates/ppvm-tui/Cargo.toml +++ b/crates/ppvm-tui/Cargo.toml @@ -10,6 +10,5 @@ crossterm = "0.29.0" ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco" } [package.metadata.cargo-machete] -# These deps are declared for later tasks (TUI app shell, event loop, engine -# integration) and are not yet imported in the current scaffold. -ignored = ["crossterm", "ratatui"] +# ratatui is declared for Task 6 (widget rendering) and not yet imported. +ignored = ["ratatui"] diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs new file mode 100644 index 00000000..f6897e01 --- /dev/null +++ b/crates/ppvm-tui/src/app.rs @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! `AppState` — the terminal-agnostic state of the ppvm TUI. Owns an optional +//! [`PPVM`] plus the command buffer, status line, program listing, and REPL +//! log. `dispatch` runs one command string; `handle_key` edits the buffer and +//! submits on Enter. Nothing here touches a terminal or runs a loop. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use eyre::{Result, eyre}; +use ppvm_vihaco::CircuitInstruction; +use ppvm_vihaco::composite::PPVM; +use ppvm_vihaco::measurements::MeasurementResult; + +use crate::codeview::CodeView; +use crate::command::{Command, parse_command}; + +/// Terminal-agnostic state for the ppvm TUI. +pub struct AppState { + /// The live machine. `None` until `device N` or a program is loaded. + machine: Option, + /// Program instruction listing (populated when a program is loaded). + program: CodeView, + /// REPL scrollback: entered commands and inline results. + log: CodeView, + /// Qubit count of the current REPL device (for `:reset`). + n_qubits: usize, + /// True while a program is loaded (Program panel) vs a REPL session (Log). + has_program: bool, + /// True while the debugger is paused (at start or a breakpoint). + paused: bool, + /// True once the loaded program has run to Return/Halt. + finished: bool, + /// The command-line buffer. + input: String, + /// The status/error line. + status: String, + /// Set to leave the event loop. + pub should_exit: bool, +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } +} + +impl AppState { + pub fn new() -> Self { + Self { + machine: None, + program: CodeView::new(), + log: CodeView::new(), + n_qubits: 0, + has_program: false, + paused: false, + finished: false, + input: String::new(), + status: String::new(), + should_exit: false, + } + } + + // ─── command dispatch ──────────────────────────────────────────────── + + /// Run one command-line string. Command-level errors are non-fatal: they + /// are written to the status line and the app keeps running. + pub fn dispatch(&mut self, line: &str) { + let trimmed = line.trim(); + if !trimmed.is_empty() { + self.log.push(format!("ppvm> {trimmed}")); + } + let result = match parse_command(line) { + Ok(cmd) => self.run_command(cmd), + Err(e) => Err(e), + }; + if let Err(e) = result { + self.set_status(format!("error: {e}")); + } + } + + fn run_command(&mut self, cmd: Command) -> Result<()> { + match cmd { + Command::Quit => { + self.should_exit = true; + Ok(()) + } + Command::Device(n) => self.new_device(n), + Command::Gate { + inst, + qubits, + params, + } => self.apply_gate(inst, &qubits, ¶ms), + // Step/Continue/Reset/Load are implemented in Task 5. + Command::Step | Command::Continue | Command::Reset | Command::Load(_) => { + self.set_status("not a REPL command (load a program first)"); + Ok(()) + } + } + } + + fn new_device(&mut self, n: usize) -> Result<()> { + self.machine = Some(PPVM::with_qubits(n)?); + self.n_qubits = n; + self.has_program = false; + self.paused = false; + self.finished = false; + self.program.clear(); + self.set_status(format!("fresh {n}-qubit device")); + Ok(()) + } + + fn apply_gate( + &mut self, + inst: CircuitInstruction, + qubits: &[usize], + params: &[f64], + ) -> Result<()> { + let m = self + .machine + .as_mut() + .ok_or_else(|| eyre!("no device — run `device N` or :load a file first"))?; + let before = m.measurement_record().len(); + m.apply_circuit_instruction(inst, qubits, params)?; + // Any new record entries are this gate's measurement outcomes. + let new: Vec = m.measurement_record()[before..].to_vec(); + if new.is_empty() { + self.set_status(""); + } else { + let bits = format_record(&new); + self.log.push(format!(" => {bits}")); + self.set_status(format!("=> {bits}")); + } + Ok(()) + } + + fn set_status(&mut self, s: impl Into) { + self.status = s.into(); + } + + // ─── key handling ──────────────────────────────────────────────────── + + /// Apply one key event. Returns whether it was consumed. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if key.kind != KeyEventKind::Press { + return false; + } + match key.code { + KeyCode::Enter => { + let line = std::mem::take(&mut self.input); + self.dispatch(&line); + true + } + // Ctrl-C: clear a non-empty buffer, else quit (shell-like). + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if self.input.is_empty() { + self.should_exit = true; + } else { + self.input.clear(); + } + true + } + KeyCode::Char(c) => { + self.input.push(c); + true + } + KeyCode::Backspace => { + self.input.pop(); + true + } + KeyCode::Esc => { + if self.input.is_empty() { + self.should_exit = true; + } else { + self.input.clear(); + } + true + } + _ => false, + } + } + + // ─── read-only accessors (used by the widgets in Task 6) ────────────── + + pub fn input(&self) -> &str { + &self.input + } + + pub fn status(&self) -> &str { + &self.status + } + + /// Which listing the Program panel shows: the loaded program, or the log. + pub fn active_listing(&self) -> (&'static str, &CodeView) { + if self.has_program { + ("Program", &self.program) + } else { + ("Log", &self.log) + } + } + + /// The tableau rendering for the State panel. + pub fn state_text(&self) -> String { + match &self.machine { + Some(m) => m.state_string(), + None => "(no device — type `device N` or :load )".to_string(), + } + } + + /// The measurement record as flat bits, events separated by spaces. + pub fn measurement_bits(&self) -> String { + match &self.machine { + Some(m) => { + let rec = m.measurement_record(); + if rec.is_empty() { + "(none)".to_string() + } else { + format_record(&rec) + } + } + None => "(none)".to_string(), + } + } + + /// A contextual footer hint. + pub fn hint(&self) -> &'static str { + if self.has_program && self.paused { + "Enter=step :c=continue :reset :q=quit" + } else if self.machine.is_some() { + "type a gate, or :load :q=quit" + } else { + ":load or device N to begin :q=quit" + } + } +} + +/// Render a measurement record as flat bits: `Zero`→`0`, `One`→`1`, `Lost`→`2` +/// (the outcome's own enum value), events joined by spaces. +fn format_record(record: &[MeasurementResult]) -> String { + record + .iter() + .map(|event| { + event + .iter() + .map(|o| char::from(b'0' + *o as u8)) + .collect::() + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[test] + fn device_then_x_then_measure_records_one() { + let mut app = AppState::new(); + app.dispatch("device 1"); + app.dispatch("x 0"); + app.dispatch("measure 0"); + assert_eq!(app.measurement_bits(), "1"); + assert!(app.status().contains("=> 1"), "status: {}", app.status()); + } + + #[test] + fn fresh_measure_is_zero() { + let mut app = AppState::new(); + app.dispatch("device 1"); + app.dispatch("measure 0"); + assert_eq!(app.measurement_bits(), "0"); + } + + #[test] + fn gate_without_device_is_a_nonfatal_error() { + let mut app = AppState::new(); + app.dispatch("x 0"); + assert!(app.status().contains("no device")); + // Still usable afterwards. + app.dispatch("device 1"); + app.dispatch("measure 0"); + assert_eq!(app.measurement_bits(), "0"); + } + + #[test] + fn cnot_respects_control_target_order() { + let mut app = AppState::new(); + app.dispatch("device 2"); + app.dispatch("x 0"); + app.dispatch("cnot 0 1"); + app.dispatch("measure 0"); + app.dispatch("measure 1"); + // Two separate measurement events, so two space-separated bits. + assert_eq!(app.measurement_bits(), "1 1"); + } + + #[test] + fn out_of_range_qubit_errors_not_panics() { + let mut app = AppState::new(); + app.dispatch("device 1"); + app.dispatch("x 3"); + assert!( + app.status().contains("out of range"), + "status: {}", + app.status() + ); + } + + #[test] + fn enter_key_dispatches_the_buffered_line() { + let mut app = AppState::new(); + for c in "device 1".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.input(), "device 1"); + app.handle_key(key(KeyCode::Enter)); + assert!(app.status().contains("1-qubit device")); + assert!(app.input().is_empty(), "buffer should clear on submit"); + } + + #[test] + fn backspace_edits_the_buffer() { + let mut app = AppState::new(); + app.handle_key(key(KeyCode::Char('h'))); + app.handle_key(key(KeyCode::Char('i'))); + app.handle_key(key(KeyCode::Backspace)); + assert_eq!(app.input(), "h"); + } + + #[test] + fn ctrl_c_on_empty_buffer_exits() { + let mut app = AppState::new(); + app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(app.should_exit); + } + + #[test] + fn ctrl_c_on_nonempty_buffer_clears_it() { + let mut app = AppState::new(); + app.handle_key(key(KeyCode::Char('x'))); + app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(!app.should_exit); + assert!(app.input().is_empty()); + } + + #[test] + fn quit_command_sets_should_exit() { + let mut app = AppState::new(); + app.dispatch(":q"); + assert!(app.should_exit); + } +} diff --git a/crates/ppvm-tui/src/lib.rs b/crates/ppvm-tui/src/lib.rs index 4f0833ad..faebd4f1 100644 --- a/crates/ppvm-tui/src/lib.rs +++ b/crates/ppvm-tui/src/lib.rs @@ -5,5 +5,8 @@ //! agnostic: no code here owns a terminal or runs an event loop, so the //! `Widget` components and `AppState` can be embedded in another ratatui app. +pub mod app; pub mod codeview; pub mod command; + +pub use app::AppState; From 96d88d12f7bb3802d14d3b6ea9c098a830d71937 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:04:44 +0200 Subject: [PATCH 08/19] test(ppvm-tui): cover Esc key handling Co-Authored-By: Claude Sonnet 4.6 --- crates/ppvm-tui/src/app.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index f6897e01..a70e3a5f 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -347,6 +347,22 @@ mod tests { assert!(app.input().is_empty()); } + #[test] + fn esc_on_empty_buffer_exits() { + let mut app = AppState::new(); + app.handle_key(key(KeyCode::Esc)); + assert!(app.should_exit); + } + + #[test] + fn esc_on_nonempty_buffer_clears_it() { + let mut app = AppState::new(); + app.handle_key(key(KeyCode::Char('x'))); + app.handle_key(key(KeyCode::Esc)); + assert!(!app.should_exit); + assert!(app.input().is_empty()); + } + #[test] fn quit_command_sets_should_exit() { let mut app = AppState::new(); From 5e278096a7d9b369a4864cb036fff16bd0c7c0f4 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:08:53 +0200 Subject: [PATCH 09/19] feat(ppvm-tui): program loading, stepping, breakpoint injection Co-Authored-By: Claude Sonnet 4.6 --- crates/ppvm-tui/src/app.rs | 226 +++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 7 deletions(-) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index a70e3a5f..0599f885 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -8,9 +8,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use eyre::{Result, eyre}; -use ppvm_vihaco::CircuitInstruction; -use ppvm_vihaco::composite::PPVM; +use ppvm_vihaco::composite::{PPVM, StepOutcome}; use ppvm_vihaco::measurements::MeasurementResult; +use ppvm_vihaco::{CircuitInstruction, PPVMModule, compile_program, load_module_file}; use crate::codeview::CodeView; use crate::command::{Command, parse_command}; @@ -19,6 +19,8 @@ use crate::command::{Command, parse_command}; pub struct AppState { /// The live machine. `None` until `device N` or a program is loaded. machine: Option, + /// The loaded module (kept for `:reset`). + module: Option, /// Program instruction listing (populated when a program is loaded). program: CodeView, /// REPL scrollback: entered commands and inline results. @@ -49,6 +51,7 @@ impl AppState { pub fn new() -> Self { Self { machine: None, + module: None, program: CodeView::new(), log: CodeView::new(), n_qubits: 0, @@ -91,11 +94,10 @@ impl AppState { qubits, params, } => self.apply_gate(inst, &qubits, ¶ms), - // Step/Continue/Reset/Load are implemented in Task 5. - Command::Step | Command::Continue | Command::Reset | Command::Load(_) => { - self.set_status("not a REPL command (load a program first)"); - Ok(()) - } + Command::Step => self.step(), + Command::Continue => self.cont(), + Command::Reset => self.reset(), + Command::Load(path) => self.load_file(&path), } } @@ -110,6 +112,137 @@ impl AppState { Ok(()) } + // ─── program loading ───────────────────────────────────────────────── + + /// Build an `AppState` with `path` loaded and paused at pc 0. + pub fn from_file(path: &str) -> Result { + let mut app = Self::new(); + app.load_file(path)?; + Ok(app) + } + + /// Compile `.sst` source and load it, paused at pc 0. (Test/embedding entry + /// that avoids touching the filesystem.) + pub fn load_source(&mut self, src: &str) -> Result<()> { + let module = compile_program(src)?; + self.load_module(module); + self.set_status("loaded program"); + Ok(()) + } + + fn load_file(&mut self, path: &str) -> Result<()> { + let module = load_module_file(path).map_err(|e| eyre!("failed to load {path}: {e}"))?; + self.load_module(module); + self.set_status(format!("loaded {path}")); + Ok(()) + } + + /// Core loader: rebuild the machine from `module` and pause at pc 0. + fn load_module(&mut self, module: PPVMModule) { + let mut m = PPVM::default(); + // A fresh machine + load + init gives clean tableau/record state; these + // only fail on malformed modules, which `compile_program` already + // rejects, so surface as a status rather than unwinding the UI. + if let Err(e) = m.load(&module).and_then(|()| m.init()) { + self.set_status(format!("error: {e}")); + return; + } + self.program.clear(); + for (i, inst) in module.code.iter().enumerate() { + self.program.push(format!("{i:04}: {inst}")); + } + self.machine = Some(m); + self.module = Some(module); + self.has_program = true; + self.paused = true; + self.finished = false; + self.refresh_cursor(); + } + + fn refresh_cursor(&mut self) { + let pc = self.machine.as_ref().map(|m| m.current_pc() as usize); + if let Some(pc) = pc { + self.program.set_cursor(Some(pc)); + } + } + + // ─── stepping ──────────────────────────────────────────────────────── + + fn step(&mut self) -> Result<()> { + if !self.has_program { + self.set_status("nothing to step — load a program with :load"); + return Ok(()); + } + if self.finished { + self.set_status("program finished — :reset to run again"); + return Ok(()); + } + let outcome = self.machine.as_mut().unwrap().step_once()?; + self.apply_outcome(outcome); + self.refresh_cursor(); + Ok(()) + } + + fn cont(&mut self) -> Result<()> { + if !self.has_program { + self.set_status("nothing to continue — load a program with :load"); + return Ok(()); + } + while !self.finished { + let outcome = self.machine.as_mut().unwrap().step_once()?; + match outcome { + StepOutcome::Continue => {} + StepOutcome::Breakpoint => { + self.apply_outcome(outcome); + self.refresh_cursor(); + return Ok(()); + } + StepOutcome::Return | StepOutcome::Halt => { + self.apply_outcome(outcome); + break; + } + } + } + self.refresh_cursor(); + Ok(()) + } + + /// Fold a single step outcome into the app's paused/finished/status state. + fn apply_outcome(&mut self, outcome: StepOutcome) { + match outcome { + StepOutcome::Continue => self.set_status(""), + StepOutcome::Breakpoint => { + self.paused = true; + self.set_status("-- breakpoint hit --"); + } + StepOutcome::Return | StepOutcome::Halt => { + self.finished = true; + self.set_status("program finished"); + } + } + } + + fn reset(&mut self) -> Result<()> { + if let Some(module) = self.module.clone() { + self.load_module(module); + self.set_status("reset"); + } else if self.n_qubits > 0 { + self.machine = Some(PPVM::with_qubits(self.n_qubits)?); + self.set_status("reset device"); + } else { + self.set_status("nothing to reset"); + } + Ok(()) + } + + pub fn has_program(&self) -> bool { + self.has_program + } + + pub fn paused(&self) -> bool { + self.paused + } + fn apply_gate( &mut self, inst: CircuitInstruction, @@ -369,4 +502,83 @@ mod tests { app.dispatch(":q"); assert!(app.should_exit); } + + /// A 1-qubit program with a breakpoint before measuring q0 (|0> -> 0). + const BP_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + + #[test] + fn load_source_starts_paused_with_a_listing() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + assert!(app.has_program()); + assert!(app.paused()); + let (title, view) = app.active_listing(); + assert_eq!(title, "Program"); + assert!(!view.lines().is_empty()); + assert_eq!(view.cursor(), Some(0), "cursor starts at pc 0"); + } + + #[test] + fn continue_pauses_at_breakpoint_then_finishes() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); + assert!( + app.status().contains("breakpoint"), + "status: {}", + app.status() + ); + app.dispatch(":c"); + assert!( + app.status().contains("finished"), + "status: {}", + app.status() + ); + // |0> measured is 0. + assert_eq!(app.measurement_bits(), "0"); + } + + #[test] + fn empty_line_steps_and_advances_cursor() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + let start = app.active_listing().1.cursor(); + app.dispatch(""); // empty line == step + let after = app.active_listing().1.cursor(); + assert_ne!(start, after, "stepping should move the cursor"); + } + + #[test] + fn inject_gate_at_breakpoint_then_resume() { + // At the breakpoint, inject X on q0; resuming, the program measures |1>. + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); // run to the breakpoint + assert!(app.status().contains("breakpoint")); + app.dispatch("x 0"); // inject while paused + app.dispatch(":c"); // resume; program measures q0 + assert!( + app.status().contains("finished"), + "status: {}", + app.status() + ); + assert_eq!( + app.measurement_bits(), + "1", + "injected X should flip the result" + ); + } + + #[test] + fn reset_returns_a_program_to_the_start() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); + app.dispatch(":c"); // finished + app.dispatch(":reset"); + assert!(app.paused()); + assert_eq!(app.active_listing().1.cursor(), Some(0)); + assert_eq!(app.measurement_bits(), "(none)"); + } } From bc45ca8421606082fc25ea880cb1d54dfbb5a57f Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:13:43 +0200 Subject: [PATCH 10/19] feat(ppvm-tui): ratatui widgets and full-screen composer Co-Authored-By: Claude Sonnet 4.6 --- crates/ppvm-tui/Cargo.toml | 4 -- crates/ppvm-tui/src/app.rs | 23 ++++++++ crates/ppvm-tui/src/lib.rs | 1 + crates/ppvm-tui/src/widgets.rs | 105 +++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 crates/ppvm-tui/src/widgets.rs diff --git a/crates/ppvm-tui/Cargo.toml b/crates/ppvm-tui/Cargo.toml index 9d1b2888..e4df123b 100644 --- a/crates/ppvm-tui/Cargo.toml +++ b/crates/ppvm-tui/Cargo.toml @@ -8,7 +8,3 @@ eyre = "0.6.12" ratatui = "0.29.0" crossterm = "0.29.0" ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco" } - -[package.metadata.cargo-machete] -# ratatui is declared for Task 6 (widget rendering) and not yet imported. -ignored = ["ratatui"] diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 0599f885..5da41dd2 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -11,9 +11,12 @@ use eyre::{Result, eyre}; use ppvm_vihaco::composite::{PPVM, StepOutcome}; use ppvm_vihaco::measurements::MeasurementResult; use ppvm_vihaco::{CircuitInstruction, PPVMModule, compile_program, load_module_file}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout}; use crate::codeview::CodeView; use crate::command::{Command, parse_command}; +use crate::widgets::{CommandLine, ProgramView, RecordView, StateView}; /// Terminal-agnostic state for the ppvm TUI. pub struct AppState { @@ -365,6 +368,26 @@ impl AppState { ":load or device N to begin :q=quit" } } + + /// Convenience full-screen composer for the standalone `ppvm` TUI. A host + /// app (e.g. stellarscope) ignores this and lays out the individual + /// `…View` widgets itself. + pub fn render(&self, frame: &mut Frame) { + let root = Layout::vertical([ + Constraint::Min(6), // Program | State + Constraint::Length(3), // measurement record + Constraint::Length(2), // command line + ]) + .split(frame.area()); + + let top = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(root[0]); + + frame.render_widget(ProgramView(self), top[0]); + frame.render_widget(StateView(self), top[1]); + frame.render_widget(RecordView(self), root[1]); + frame.render_widget(CommandLine(self), root[2]); + } } /// Render a measurement record as flat bits: `Zero`→`0`, `One`→`1`, `Lost`→`2` diff --git a/crates/ppvm-tui/src/lib.rs b/crates/ppvm-tui/src/lib.rs index faebd4f1..0878cb2b 100644 --- a/crates/ppvm-tui/src/lib.rs +++ b/crates/ppvm-tui/src/lib.rs @@ -8,5 +8,6 @@ pub mod app; pub mod codeview; pub mod command; +pub mod widgets; pub use app::AppState; diff --git a/crates/ppvm-tui/src/widgets.rs b/crates/ppvm-tui/src/widgets.rs new file mode 100644 index 00000000..7a0f0db3 --- /dev/null +++ b/crates/ppvm-tui/src/widgets.rs @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! ratatui `Widget` components for the ppvm TUI. Each borrows `&AppState` and +//! only reads it, so a host app can render any of them into its own layout. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::text::{Line, Text}; +use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; + +use crate::app::AppState; + +/// The left panel: the loaded program's listing (with a `▶` at the pc) or, in a +/// REPL session, the command/result log. +pub struct ProgramView<'a>(pub &'a AppState); + +impl Widget for ProgramView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let (title, view) = self.0.active_listing(); + let mut text = Text::default(); + for (i, line) in view.lines().iter().enumerate() { + let marked = if view.cursor() == Some(i) { + format!("▶ {line}") + } else { + format!(" {line}") + }; + text.push_line(Line::from(marked)); + } + Paragraph::new(text) + .block(Block::bordered().title(title)) + .render(area, buf); + } +} + +/// The right panel: the tableau state (`PPVM::state_string`). +pub struct StateView<'a>(pub &'a AppState); + +impl Widget for StateView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.state_text()) + .block(Block::bordered().title("State")) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} + +/// The measurement-record band. +pub struct RecordView<'a>(pub &'a AppState); + +impl Widget for RecordView<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.measurement_bits()) + .block(Block::bordered().title("Measurement record")) + .render(area, buf); + } +} + +/// The footer: prompt + input, then the hint and status line. +pub struct CommandLine<'a>(pub &'a AppState); + +impl Widget for CommandLine<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let text = Text::from(vec![ + Line::from(format!("ppvm> {}", self.0.input())), + Line::from(format!("{} {}", self.0.hint(), self.0.status())), + ]); + Paragraph::new(text).render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use crate::AppState; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + const BP_PROGRAM: &str = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + + #[test] + fn renders_all_panels_without_panic() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + + let content: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(content.contains("Program"), "missing Program panel"); + assert!(content.contains("State"), "missing State panel"); + assert!( + content.contains("Measurement record"), + "missing record panel" + ); + assert!(content.contains("ppvm>"), "missing command prompt"); + } +} From 92303d38a25438e9d11ee33ae757051000f4254f Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:18:58 +0200 Subject: [PATCH 11/19] feat(ppvm-cli): launch composable TUI on bare invocation Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 3 +++ crates/ppvm-cli/Cargo.toml | 3 +++ crates/ppvm-cli/README.md | 22 ++++++++++++++++ crates/ppvm-cli/src/main.rs | 52 ++++++++++++++++++++++++++++++------- crates/ppvm-cli/src/tui.rs | 52 +++++++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 crates/ppvm-cli/src/tui.rs diff --git a/Cargo.lock b/Cargo.lock index 9ce9e7b2..9fa023cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,8 +1358,11 @@ name = "ppvm-cli" version = "0.1.0" dependencies = [ "clap", + "crossterm 0.29.0", "eyre", + "ppvm-tui", "ppvm-vihaco", + "ratatui", ] [[package]] diff --git a/crates/ppvm-cli/Cargo.toml b/crates/ppvm-cli/Cargo.toml index 4ba3d884..4108ca28 100644 --- a/crates/ppvm-cli/Cargo.toml +++ b/crates/ppvm-cli/Cargo.toml @@ -5,8 +5,11 @@ edition = "2024" [dependencies] clap = { version = "4.6.1", features = ["derive"] } +crossterm = "0.29.0" eyre = "0.6.12" +ppvm-tui = { version = "0.1.0", path = "../ppvm-tui" } ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco", features = ["rayon"] } +ratatui = "0.29.0" [[bin]] name = "ppvm" diff --git a/crates/ppvm-cli/README.md b/crates/ppvm-cli/README.md index 9a08f726..722767ab 100644 --- a/crates/ppvm-cli/README.md +++ b/crates/ppvm-cli/README.md @@ -22,6 +22,28 @@ put CLI arguments after `--`: cargo run -p ppvm-cli -- run examples/ghz.sst ``` +## Interactive TUI + +Running `ppvm` with no subcommand launches an interactive terminal UI: + +- `ppvm` — start an empty session; type `device N` then gate ops + (`h 0`, `cnot 0 1`, `measure 0`, `rx 0 0.5`, …). +- `ppvm program.sst` — open the program paused at the first instruction. + +Inside the TUI: + +- **Gate ops** (bare tokens) apply to the live tableau; measurement outcomes + echo as `=> bits`. +- **`:load `** (re)loads a program; **`:reset`** restarts it. +- **Enter on an empty line** steps one instruction; **`:c`** continues to the + next breakpoint or the end; authored `breakpoint` instructions pause. +- At a breakpoint you can inject gate ops — they update the state and stepping + resumes exactly where it paused. +- **`:q`**, **Esc**, or **Ctrl-C** (on an empty line) exits. + +The state panel shows the tableau; the bottom band shows the measurement +record. + ## Run `run` executes a program for one or more shots and prints the measurement diff --git a/crates/ppvm-cli/src/main.rs b/crates/ppvm-cli/src/main.rs index a956a331..2d9e319f 100644 --- a/crates/ppvm-cli/src/main.rs +++ b/crates/ppvm-cli/src/main.rs @@ -5,18 +5,24 @@ use clap::{Parser, Subcommand}; use eyre::Result; mod commands; +mod tui; #[derive(Parser)] #[command(name = "ppvm")] #[command(about = "Pauli propagation virtual machine", long_about = None)] +#[command(args_conflicts_with_subcommands = true)] pub struct Cli { /// Number of threads for all parallel work (1 = fully serial & deterministic) #[arg(short, long, default_value = "1")] threads: usize, - /// Subcommand to run. + /// A .sst/.ssb file to open in the TUI (when no subcommand is given). + #[arg(value_name = "FILE")] + file: Option, + + /// Subcommand to run; with none, launches the interactive TUI. #[command(subcommand)] - command: Commands, + command: Option, } #[derive(Subcommand)] @@ -85,6 +91,33 @@ enum Commands { }, } +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn bare_invocation_has_no_command_or_file() { + let cli = Cli::try_parse_from(["ppvm"]).unwrap(); + assert!(cli.command.is_none()); + assert!(cli.file.is_none()); + } + + #[test] + fn file_positional_is_captured_for_the_tui() { + let cli = Cli::try_parse_from(["ppvm", "prog.sst"]).unwrap(); + assert!(cli.command.is_none()); + assert_eq!(cli.file.as_deref(), Some("prog.sst")); + } + + #[test] + fn subcommands_still_parse() { + let cli = Cli::try_parse_from(["ppvm", "run", "prog.sst"]).unwrap(); + assert!(matches!(cli.command, Some(Commands::Run { .. }))); + assert!(cli.file.is_none()); + } +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -93,30 +126,31 @@ fn main() -> Result<()> { ppvm_vihaco::shots::set_global_threads(cli.threads)?; match cli.command { - Commands::Parse { file, format } => { + None => tui::run(cli.file.as_deref())?, + Some(Commands::Parse { file, format }) => { commands::parse(&file, format)?; } - Commands::Dump { + Some(Commands::Dump { file, output, force, - } => { + }) => { commands::dump(&file, output.as_deref(), force)?; } - Commands::Run { + Some(Commands::Run { file, shots, seed, output, quiet, format, - } => { + }) => { commands::run(&file, shots, seed, output.as_deref(), quiet, format)?; } - Commands::Debug { + Some(Commands::Debug { file, break_at_start, - } => { + }) => { commands::debug(&file, break_at_start)?; } } diff --git a/crates/ppvm-cli/src/tui.rs b/crates/ppvm-cli/src/tui.rs new file mode 100644 index 00000000..c8c2b5ec --- /dev/null +++ b/crates/ppvm-cli/src/tui.rs @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Terminal ownership for the ppvm TUI: raw mode + alternate screen behind an +//! RAII guard (restored even on panic), plus a blocking event loop that drives +//! the terminal-agnostic `ppvm_tui::AppState`. + +use std::io; + +use crossterm::event::{self, Event}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use eyre::Result; +use ppvm_tui::AppState; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +/// Restores the terminal on drop — including when the app panics mid-loop. +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + } +} + +/// Launch the TUI. With `file`, open it loaded and paused at pc 0; without, +/// start an empty REPL session. +pub fn run(file: Option<&str>) -> Result<()> { + let mut app = match file { + Some(path) => AppState::from_file(path)?, + None => AppState::new(), + }; + + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + let _guard = TerminalGuard; + + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + terminal.clear()?; + + while !app.should_exit { + terminal.draw(|frame| app.render(frame))?; + if let Event::Key(key) = event::read()? { + app.handle_key(key); + } + } + Ok(()) +} From f6e0f5be248ef21c578db5beecec042e28322d14 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:28:30 +0200 Subject: [PATCH 12/19] fix(ppvm-vihaco): restore pc/code on apply_circuit_instruction error path The previous implementation used `?` after execute_single_instruction, which skipped the truncate + pc restore when the call returned Err, leaving leaked appended ops and a wrong pc in the loaded program. Now the restore runs unconditionally via a local `result` binding. Also adds a test (apply_circuit_instruction_preserves_pc_and_code_on_error) that exercises the error path and asserts both invariants hold. Separately, moves the #[cfg(test)] mod tests block in ppvm-cli/src/main.rs to after fn main() to fix the clippy::items_after_test_module lint under --all-targets. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-cli/src/main.rs | 54 ++++++++++++++--------------- crates/ppvm-vihaco/src/composite.rs | 38 ++++++++++++++++++-- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/crates/ppvm-cli/src/main.rs b/crates/ppvm-cli/src/main.rs index 2d9e319f..c6cb05a5 100644 --- a/crates/ppvm-cli/src/main.rs +++ b/crates/ppvm-cli/src/main.rs @@ -91,33 +91,6 @@ enum Commands { }, } -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - #[test] - fn bare_invocation_has_no_command_or_file() { - let cli = Cli::try_parse_from(["ppvm"]).unwrap(); - assert!(cli.command.is_none()); - assert!(cli.file.is_none()); - } - - #[test] - fn file_positional_is_captured_for_the_tui() { - let cli = Cli::try_parse_from(["ppvm", "prog.sst"]).unwrap(); - assert!(cli.command.is_none()); - assert_eq!(cli.file.as_deref(), Some("prog.sst")); - } - - #[test] - fn subcommands_still_parse() { - let cli = Cli::try_parse_from(["ppvm", "run", "prog.sst"]).unwrap(); - assert!(matches!(cli.command, Some(Commands::Run { .. }))); - assert!(cli.file.is_none()); - } -} - fn main() -> Result<()> { let cli = Cli::parse(); @@ -157,3 +130,30 @@ fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn bare_invocation_has_no_command_or_file() { + let cli = Cli::try_parse_from(["ppvm"]).unwrap(); + assert!(cli.command.is_none()); + assert!(cli.file.is_none()); + } + + #[test] + fn file_positional_is_captured_for_the_tui() { + let cli = Cli::try_parse_from(["ppvm", "prog.sst"]).unwrap(); + assert!(cli.command.is_none()); + assert_eq!(cli.file.as_deref(), Some("prog.sst")); + } + + #[test] + fn subcommands_still_parse() { + let cli = Cli::try_parse_from(["ppvm", "run", "prog.sst"]).unwrap(); + assert!(matches!(cli.command, Some(Commands::Run { .. }))); + assert!(cli.file.is_none()); + } +} diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index e84adb2b..9cd22c65 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -442,10 +442,12 @@ impl PPVM { // was. (Also keeps a long REPL session's code vector from growing.) let saved_pc = self.loader.pc(); let saved_len = self.loader.module.code.len(); - self.execute_single_instruction(&instrs)?; + let result = self.execute_single_instruction(&instrs); + // Restore the debugger's position on every path (including error): the + // appended ops are transient, and the pc/code must be left unchanged. self.loader.module.code.truncate(saved_len); *self.loader.pc_mut() = saved_pc; - Ok(()) + result } fn execute_effects(&mut self, inst: Instruction) -> eyre::Result> { @@ -1312,6 +1314,38 @@ mod tests { Ok(()) } + #[test] + fn apply_circuit_instruction_preserves_pc_and_code_on_error() -> eyre::Result<()> { + // A program stepped to a known pc; an injected gate that errors mid-block + // must NOT corrupt the code vector or the program counter. + let src = "device circuit.n_qubits 1;\n\ + fn @main() { breakpoint\n const.u64 0\n circuit.measure\n ret }\n"; + let mut m = PPVM::default(); + m.load_program(src)?; + m.init()?; + loop { + if m.step_once()? == StepOutcome::Breakpoint { + break; + } + } + let pc = m.current_pc(); + let len = m.loader.module.code.len(); + + // RX with no float param errors during execution: resolve_circuit pops + // the f64 first and finds the qubit value (U64) instead, returning Err. + let err = m.apply_circuit_instruction(CircuitInstruction::RX, &[0], &[]); + assert!(err.is_err(), "expected the injected gate to error"); + + // The failed injection must leave the debugger untouched. + assert_eq!(m.current_pc(), pc, "pc must be preserved on error"); + assert_eq!( + m.loader.module.code.len(), + len, + "code must be truncated back on error" + ); + Ok(()) + } + #[test] fn tableau_trace_emits_expectation_on_zero_state() { // Task 16: `circuit.trace` on the Tableau backend now computes From ec8631198f2c210e79cba721de1211d7c710757d Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:53:10 +0200 Subject: [PATCH 13/19] feat(ppvm-tui): editable cursor and command history in the REPL Add a movable edit cursor (Left/Right/Home/End, insert/Backspace/Delete at the cursor) rendered via the terminal's own cursor (Frame::set_cursor_position, offset past the shared CommandLine::PROMPT), and Up/Down command history over submitted lines with an in-progress draft stash and consecutive-duplicate skipping. All hand-rolled, no new dependency, unit-tested without a terminal plus a TestBackend cursor placement check. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-tui/src/app.rs | 281 ++++++++++++++++++++++++++++++++- crates/ppvm-tui/src/widgets.rs | 7 +- 2 files changed, 281 insertions(+), 7 deletions(-) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 5da41dd2..033cbb81 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -38,6 +38,14 @@ pub struct AppState { finished: bool, /// The command-line buffer. input: String, + /// Char index of the edit cursor within `input` (0..=input char count). + cursor: usize, + /// Submitted command lines, oldest first (for Up/Down recall). + history: Vec, + /// Position within `history` while recalling; `None` = editing the live line. + history_pos: Option, + /// The live line stashed when entering history, restored on the way back. + draft: String, /// The status/error line. status: String, /// Set to leave the event loop. @@ -62,6 +70,10 @@ impl AppState { paused: false, finished: false, input: String::new(), + cursor: 0, + history: Vec::new(), + history_pos: None, + draft: String::new(), status: String::new(), should_exit: false, } @@ -283,8 +295,7 @@ impl AppState { } match key.code { KeyCode::Enter => { - let line = std::mem::take(&mut self.input); - self.dispatch(&line); + self.submit(); true } // Ctrl-C: clear a non-empty buffer, else quit (shell-like). @@ -292,23 +303,51 @@ impl AppState { if self.input.is_empty() { self.should_exit = true; } else { - self.input.clear(); + self.clear_input(); } true } KeyCode::Char(c) => { - self.input.push(c); + self.insert_char(c); true } KeyCode::Backspace => { - self.input.pop(); + self.backspace(); + true + } + KeyCode::Delete => { + self.delete(); + true + } + KeyCode::Left => { + self.cursor = self.cursor.saturating_sub(1); + true + } + KeyCode::Right => { + self.cursor = (self.cursor + 1).min(self.input_len()); + true + } + KeyCode::Home => { + self.cursor = 0; + true + } + KeyCode::End => { + self.cursor = self.input_len(); + true + } + KeyCode::Up => { + self.history_prev(); + true + } + KeyCode::Down => { + self.history_next(); true } KeyCode::Esc => { if self.input.is_empty() { self.should_exit = true; } else { - self.input.clear(); + self.clear_input(); } true } @@ -316,12 +355,118 @@ impl AppState { } } + // ─── line editing & command history ────────────────────────────────── + + /// Char count of the current input line. + fn input_len(&self) -> usize { + self.input.chars().count() + } + + /// Byte offset of char index `i` within `input` (or the end of the string). + fn byte_index(&self, i: usize) -> usize { + self.input + .char_indices() + .nth(i) + .map(|(b, _)| b) + .unwrap_or(self.input.len()) + } + + /// Insert `c` at the cursor and step past it. + fn insert_char(&mut self, c: char) { + let b = self.byte_index(self.cursor); + self.input.insert(b, c); + self.cursor += 1; + } + + /// Delete the char before the cursor (Backspace). + fn backspace(&mut self) { + if self.cursor > 0 { + let b = self.byte_index(self.cursor - 1); + self.input.remove(b); + self.cursor -= 1; + } + } + + /// Delete the char at the cursor (Delete). + fn delete(&mut self) { + if self.cursor < self.input_len() { + let b = self.byte_index(self.cursor); + self.input.remove(b); + } + } + + /// Clear the input line and reset the cursor. + fn clear_input(&mut self) { + self.input.clear(); + self.cursor = 0; + } + + /// Replace the input line, moving the cursor to its end. + fn set_input(&mut self, line: String) { + self.cursor = line.chars().count(); + self.input = line; + } + + /// Submit the current line: record it in history, then dispatch it. + fn submit(&mut self) { + let line = std::mem::take(&mut self.input); + self.cursor = 0; + self.history_pos = None; + self.draft.clear(); + let trimmed = line.trim(); + // Skip blanks and consecutive duplicates, matching a shell's history. + if !trimmed.is_empty() && self.history.last().map(String::as_str) != Some(trimmed) { + self.history.push(trimmed.to_string()); + } + self.dispatch(&line); + } + + /// Recall an older history entry (Up), stashing the live line on first entry. + fn history_prev(&mut self) { + if self.history.is_empty() { + return; + } + let i = match self.history_pos { + None => { + self.draft = std::mem::take(&mut self.input); + self.history.len() - 1 + } + Some(0) => return, // already at the oldest + Some(i) => i - 1, + }; + self.history_pos = Some(i); + self.set_input(self.history[i].clone()); + } + + /// Move toward newer entries (Down); past the newest, restore the stashed + /// live line. + fn history_next(&mut self) { + match self.history_pos { + None => {} + Some(i) if i + 1 < self.history.len() => { + self.history_pos = Some(i + 1); + self.set_input(self.history[i + 1].clone()); + } + Some(_) => { + self.history_pos = None; + let draft = std::mem::take(&mut self.draft); + self.set_input(draft); + } + } + } + // ─── read-only accessors (used by the widgets in Task 6) ────────────── pub fn input(&self) -> &str { &self.input } + /// Char index of the edit cursor within the input line. Used to place the + /// terminal cursor; a host embedding `CommandLine` uses this to position it. + pub fn cursor(&self) -> usize { + self.cursor + } + pub fn status(&self) -> &str { &self.status } @@ -387,6 +532,13 @@ impl AppState { frame.render_widget(StateView(self), top[1]); frame.render_widget(RecordView(self), root[1]); frame.render_widget(CommandLine(self), root[2]); + + // Place the terminal cursor in the input line, just after the prompt, + // clamped to the command area so a long line can't run off-panel. + let cmd = root[2]; + let col = cmd.x + CommandLine::PROMPT.len() as u16 + self.cursor as u16; + let x = col.min(cmd.x + cmd.width.saturating_sub(1)); + frame.set_cursor_position((x, cmd.y)); } } @@ -604,4 +756,121 @@ mod tests { assert_eq!(app.active_listing().1.cursor(), Some(0)); assert_eq!(app.measurement_bits(), "(none)"); } + + // ─── line editing & history ────────────────────────────────────────── + + /// Type each char, then press Enter (submitting to history + dispatch). + fn type_line(app: &mut AppState, line: &str) { + for c in line.chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Enter)); + } + + #[test] + fn typing_inserts_and_advances_the_cursor() { + let mut app = AppState::new(); + for c in "hz".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.input(), "hz"); + assert_eq!(app.cursor(), 2); + } + + #[test] + fn left_right_home_end_move_the_cursor() { + let mut app = AppState::new(); + for c in "abc".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(app.cursor(), 3); + app.handle_key(key(KeyCode::Left)); + assert_eq!(app.cursor(), 2); + app.handle_key(key(KeyCode::Home)); + assert_eq!(app.cursor(), 0); + app.handle_key(key(KeyCode::Left)); // saturates at 0 + assert_eq!(app.cursor(), 0); + app.handle_key(key(KeyCode::End)); + assert_eq!(app.cursor(), 3); + app.handle_key(key(KeyCode::Right)); // saturates at len + assert_eq!(app.cursor(), 3); + } + + #[test] + fn insert_and_delete_at_the_cursor() { + let mut app = AppState::new(); + for c in "ac".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Left)); // cursor between 'a' and 'c' + app.handle_key(key(KeyCode::Char('b'))); + assert_eq!(app.input(), "abc"); + assert_eq!(app.cursor(), 2); + app.handle_key(key(KeyCode::Backspace)); // removes 'b' before cursor + assert_eq!(app.input(), "ac"); + assert_eq!(app.cursor(), 1); + app.handle_key(key(KeyCode::Delete)); // removes 'c' at cursor + assert_eq!(app.input(), "a"); + assert_eq!(app.cursor(), 1); + } + + #[test] + fn up_arrow_recalls_previous_commands() { + let mut app = AppState::new(); + type_line(&mut app, "device 1"); + type_line(&mut app, "x 0"); + app.handle_key(key(KeyCode::Up)); // newest first + assert_eq!(app.input(), "x 0"); + assert_eq!(app.cursor(), 3, "cursor lands at end of the recalled line"); + app.handle_key(key(KeyCode::Up)); + assert_eq!(app.input(), "device 1"); + app.handle_key(key(KeyCode::Up)); // already oldest, stays put + assert_eq!(app.input(), "device 1"); + } + + #[test] + fn down_arrow_returns_toward_the_live_line() { + let mut app = AppState::new(); + type_line(&mut app, "device 1"); + type_line(&mut app, "x 0"); + for c in "meas".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + app.handle_key(key(KeyCode::Up)); // stash "meas", show "x 0" + app.handle_key(key(KeyCode::Up)); // "device 1" + app.handle_key(key(KeyCode::Down)); // "x 0" + assert_eq!(app.input(), "x 0"); + app.handle_key(key(KeyCode::Down)); // past newest -> restore draft + assert_eq!(app.input(), "meas"); + assert_eq!(app.cursor(), 4); + } + + #[test] + fn history_skips_consecutive_duplicates() { + let mut app = AppState::new(); + type_line(&mut app, "device 1"); + type_line(&mut app, "device 1"); // same command twice -> one entry + app.handle_key(key(KeyCode::Up)); + assert_eq!(app.input(), "device 1"); + app.handle_key(key(KeyCode::Up)); // only one entry -> stays + assert_eq!(app.input(), "device 1"); + } + + #[test] + fn render_places_the_terminal_cursor_after_the_prompt() { + use crate::widgets::CommandLine; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = AppState::new(); + for c in "ab".chars() { + app.handle_key(key(KeyCode::Char(c))); + } + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + + // Command area starts at x=0, so the cursor sits at prompt width + 2. + let pos = terminal.get_cursor_position().unwrap(); + assert_eq!(pos.x, (CommandLine::PROMPT.len() + 2) as u16); + } } diff --git a/crates/ppvm-tui/src/widgets.rs b/crates/ppvm-tui/src/widgets.rs index 7a0f0db3..f5fa18f9 100644 --- a/crates/ppvm-tui/src/widgets.rs +++ b/crates/ppvm-tui/src/widgets.rs @@ -59,10 +59,15 @@ impl Widget for RecordView<'_> { /// The footer: prompt + input, then the hint and status line. pub struct CommandLine<'a>(pub &'a AppState); +impl CommandLine<'_> { + /// The command prompt. Also used to offset the terminal cursor past it. + pub const PROMPT: &'static str = "ppvm> "; +} + impl Widget for CommandLine<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let text = Text::from(vec![ - Line::from(format!("ppvm> {}", self.0.input())), + Line::from(format!("{}{}", Self::PROMPT, self.0.input())), Line::from(format!("{} {}", self.0.hint(), self.0.status())), ]); Paragraph::new(text).render(area, buf); From 1df77ffeb2dd44c16d8953d5da10e09557f8bfb2 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 12:02:43 +0200 Subject: [PATCH 14/19] feat(ppvm-tui): add :help command with a toggleable overlay `:help` (aliases `:h`, `:?`) toggles a floating, centered overlay listing the meta/debug commands and the full gate grammar. Implemented as a render-time overlay driven by a `show_help` flag, so it works in both REPL and debug modes without touching the key-handling model or the composability contract. `:help` is also surfaced in the footer hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-tui/src/app.rs | 100 +++++++++++++++++++++++++++++++-- crates/ppvm-tui/src/command.rs | 5 ++ crates/ppvm-tui/src/widgets.rs | 12 ++++ 3 files changed, 112 insertions(+), 5 deletions(-) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 033cbb81..4af4f1ab 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -12,11 +12,12 @@ use ppvm_vihaco::composite::{PPVM, StepOutcome}; use ppvm_vihaco::measurements::MeasurementResult; use ppvm_vihaco::{CircuitInstruction, PPVMModule, compile_program, load_module_file}; use ratatui::Frame; -use ratatui::layout::{Constraint, Layout}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::widgets::Clear; use crate::codeview::CodeView; use crate::command::{Command, parse_command}; -use crate::widgets::{CommandLine, ProgramView, RecordView, StateView}; +use crate::widgets::{CommandLine, HelpOverlay, ProgramView, RecordView, StateView}; /// Terminal-agnostic state for the ppvm TUI. pub struct AppState { @@ -48,6 +49,8 @@ pub struct AppState { draft: String, /// The status/error line. status: String, + /// Whether the help overlay is currently shown. + show_help: bool, /// Set to leave the event loop. pub should_exit: bool, } @@ -75,6 +78,7 @@ impl AppState { history_pos: None, draft: String::new(), status: String::new(), + show_help: false, should_exit: false, } } @@ -113,6 +117,10 @@ impl AppState { Command::Continue => self.cont(), Command::Reset => self.reset(), Command::Load(path) => self.load_file(&path), + Command::Help => { + self.show_help = !self.show_help; + Ok(()) + } } } @@ -506,14 +514,24 @@ impl AppState { /// A contextual footer hint. pub fn hint(&self) -> &'static str { if self.has_program && self.paused { - "Enter=step :c=continue :reset :q=quit" + "Enter=step :c=continue :reset :help :q=quit" } else if self.machine.is_some() { - "type a gate, or :load :q=quit" + "type a gate, or :load :help :q=quit" } else { - ":load or device N to begin :q=quit" + ":load · device N · :help · :q=quit" } } + /// Whether the help overlay should be drawn. + pub fn show_help(&self) -> bool { + self.show_help + } + + /// The command reference shown in the help overlay. + pub fn help_text(&self) -> &'static str { + HELP_TEXT + } + /// Convenience full-screen composer for the standalone `ppvm` TUI. A host /// app (e.g. stellarscope) ignores this and lays out the individual /// `…View` widgets itself. @@ -539,9 +557,47 @@ impl AppState { let col = cmd.x + CommandLine::PROMPT.len() as u16 + self.cursor as u16; let x = col.min(cmd.x + cmd.width.saturating_sub(1)); frame.set_cursor_position((x, cmd.y)); + + // The help overlay floats above everything else when toggled on. + if self.show_help { + let full = frame.area(); + let w = 76.min(full.width.saturating_sub(2)); + let h = 20.min(full.height.saturating_sub(2)); + let popup = Rect { + x: full.x + full.width.saturating_sub(w) / 2, + y: full.y + full.height.saturating_sub(h) / 2, + width: w, + height: h, + }; + frame.render_widget(Clear, popup); + frame.render_widget(HelpOverlay(self), popup); + } } } +/// The command reference shown by `:help`. Mirrors the command grammar in +/// [`crate::command`]: bare tokens are gate ops, `:`-prefixed are meta/debug. +const HELP_TEXT: &str = "\ +Meta / debug + device N create a fresh N-qubit tableau device + :load load a .sst / .ssb program (paused at start) + Enter (empty) :s step one instruction + :continue :c run to the next breakpoint or the end + :reset restart the loaded program / device + :help :h toggle this help + :quit :q (Ctrl-C) leave + +Gates (q = qubit index; angles / probabilities are floats) + x y z h s sadj sqrtx sqrty sqrtxadj sqrtyadj t tadj reset measure + cnot cz + rx ry rz r + u3 + rxx ryy rzz + depolarize loss

depolarize2

+ paulierror correlatedloss + +Line editing: ←/→ move · Home/End · Backspace/Del · ↑/↓ history"; + /// Render a measurement record as flat bits: `Zero`→`0`, `One`→`1`, `Lost`→`2` /// (the outcome's own enum value), events joined by spaces. fn format_record(record: &[MeasurementResult]) -> String { @@ -873,4 +929,38 @@ mod tests { let pos = terminal.get_cursor_position().unwrap(); assert_eq!(pos.x, (CommandLine::PROMPT.len() + 2) as u16); } + + #[test] + fn help_command_toggles_the_overlay() { + let mut app = AppState::new(); + assert!(!app.show_help()); + app.dispatch(":help"); + assert!(app.show_help(), ":help should open the overlay"); + app.dispatch(":help"); + assert!(!app.show_help(), ":help again should close it"); + } + + #[test] + fn help_overlay_renders_the_command_reference() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let mut app = AppState::new(); + app.dispatch(":help"); + let mut terminal = Terminal::new(TestBackend::new(90, 30)).unwrap(); + terminal.draw(|f| app.render(f)).unwrap(); + + let content: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(content.contains("Help"), "overlay title missing"); + assert!( + content.contains("cnot"), + "gate reference missing from overlay" + ); + } } diff --git a/crates/ppvm-tui/src/command.rs b/crates/ppvm-tui/src/command.rs index e0baff9a..917bd0a8 100644 --- a/crates/ppvm-tui/src/command.rs +++ b/crates/ppvm-tui/src/command.rs @@ -78,6 +78,8 @@ pub enum Command { Reset, /// Load a `.sst`/`.ssb` file. Load(String), + /// Toggle the help overlay. + Help, /// Leave the TUI. Quit, } @@ -98,6 +100,7 @@ pub fn parse_command(line: &str) -> Result { "c" | "continue" => Ok(Command::Continue), "s" | "step" => Ok(Command::Step), "reset" => Ok(Command::Reset), + "help" | "h" | "?" => Ok(Command::Help), "load" => { let path = it.next().ok_or_else(|| eyre!(":load needs a file path"))?; Ok(Command::Load(path.to_string())) @@ -209,6 +212,8 @@ mod tests { assert_eq!(parse_command(":continue").unwrap(), Command::Continue); assert_eq!(parse_command(":s").unwrap(), Command::Step); assert_eq!(parse_command(":reset").unwrap(), Command::Reset); + assert_eq!(parse_command(":help").unwrap(), Command::Help); + assert_eq!(parse_command(":h").unwrap(), Command::Help); assert_eq!( parse_command(":load foo.sst").unwrap(), Command::Load("foo.sst".to_string()) diff --git a/crates/ppvm-tui/src/widgets.rs b/crates/ppvm-tui/src/widgets.rs index f5fa18f9..f0dcf0ce 100644 --- a/crates/ppvm-tui/src/widgets.rs +++ b/crates/ppvm-tui/src/widgets.rs @@ -74,6 +74,18 @@ impl Widget for CommandLine<'_> { } } +/// A floating help overlay listing the command grammar. Drawn over the panels +/// while `:help` is toggled on; the host clears the area behind it first. +pub struct HelpOverlay<'a>(pub &'a AppState); + +impl Widget for HelpOverlay<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.0.help_text()) + .block(Block::bordered().title("Help (:help to close)")) + .render(area, buf); + } +} + #[cfg(test)] mod tests { use crate::AppState; From cd67cb16c9e3e49a0e7487e6313e436edab64776 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 15:38:52 +0200 Subject: [PATCH 15/19] fix(ppvm-tui): drop stale program on `device N`; roll back operand stack on gate injection Two state-leak fixes surfaced in review of the TUI: - `new_device` now clears `self.module`, so switching to a REPL device forgets any previously loaded program. Otherwise `:reset` resurrected the old program instead of resetting the fresh device. - `apply_circuit_instruction` now restores the CPU operand stack depth on every path, alongside the existing pc/code restore. `circuit.measure` / `circuit.trace` push a result onto the stack for bytecode to consume, and an errored gate can leave its consts behind; with no consumer during an injected gate, a stray operand would desync a resumed program's stack and grow a REPL session's stack without bound. The measurement record (a separate observer effect) is unaffected, so the REPL's `=> bits` echo still works. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-tui/src/app.rs | 19 +++++++++++++ crates/ppvm-vihaco/src/composite.rs | 41 ++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 4af4f1ab..b3ae6717 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -126,6 +126,9 @@ impl AppState { fn new_device(&mut self, n: usize) -> Result<()> { self.machine = Some(PPVM::with_qubits(n)?); + // Forget any previously loaded program so `:reset` resets this device + // rather than resurrecting the old program. + self.module = None; self.n_qubits = n; self.has_program = false; self.paused = false; @@ -813,6 +816,22 @@ mod tests { assert_eq!(app.measurement_bits(), "(none)"); } + #[test] + fn device_after_a_program_then_reset_resets_the_device() { + // Switching to a REPL device must forget the previously loaded program, + // so `:reset` resets the device rather than resurrecting the program. + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + assert!(app.has_program()); + app.dispatch("device 2"); + assert!(!app.has_program(), "device should switch to a REPL session"); + app.dispatch(":reset"); + assert!( + !app.has_program(), + "reset must reset the device, not reload the old program" + ); + } + // ─── line editing & history ────────────────────────────────────────── /// Type each char, then press Enter (submitting to history + dispatch). diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index 9cd22c65..dadb10c4 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -440,13 +440,21 @@ impl PPVM { // The tableau/measurement effects persist; the code + pc are left // byte-for-byte unchanged, so a paused debugger resumes exactly where it // was. (Also keeps a long REPL session's code vector from growing.) + // + // The operand stack is rolled back too. `circuit.measure`/`trace` push a + // result onto it for bytecode to consume, and a partially-applied gate + // may leave its consts behind on error; either way there is no consumer + // here, so a stray operand would desync a resumed program's stack and + // grow a REPL session's stack without bound. let saved_pc = self.loader.pc(); let saved_len = self.loader.module.code.len(); + let saved_stack = self.cpu.stack_len(); let result = self.execute_single_instruction(&instrs); // Restore the debugger's position on every path (including error): the - // appended ops are transient, and the pc/code must be left unchanged. + // appended ops are transient, and the pc/code/stack must be unchanged. self.loader.module.code.truncate(saved_len); *self.loader.pc_mut() = saved_pc; + self.cpu.stack_mut().truncate(saved_stack); result } @@ -1294,6 +1302,7 @@ mod tests { } let pc = m.current_pc(); let len = m.loader.module.code.len(); + let depth = m.cpu.stack().len(); // Inject X on q0 while "paused". m.apply_circuit_instruction(CircuitInstruction::X, &[0], &[])?; @@ -1305,6 +1314,11 @@ mod tests { len, "appended op must be truncated back" ); + assert_eq!( + m.cpu.stack().len(), + depth, + "operand stack must be left unchanged" + ); // And the X took effect: resuming the program measures |1>. while !matches!(m.step_once()?, StepOutcome::Return | StepOutcome::Halt) {} @@ -1330,6 +1344,7 @@ mod tests { } let pc = m.current_pc(); let len = m.loader.module.code.len(); + let depth = m.cpu.stack().len(); // RX with no float param errors during execution: resolve_circuit pops // the f64 first and finds the qubit value (U64) instead, returning Err. @@ -1343,6 +1358,30 @@ mod tests { len, "code must be truncated back on error" ); + assert_eq!( + m.cpu.stack().len(), + depth, + "operand stack must be left unchanged on error" + ); + Ok(()) + } + + #[test] + fn apply_circuit_instruction_measurement_does_not_grow_the_stack() -> eyre::Result<()> { + // `circuit.measure` pushes its outcome onto the CPU operand stack for + // bytecode to consume. An injected measurement has no such consumer, so + // that push must be rolled back: otherwise a paused program resumes with + // a stray operand, and a REPL session's stack grows without bound. + let mut m = PPVM::with_qubits(1)?; + let depth = m.cpu.stack().len(); + m.apply_circuit_instruction(CircuitInstruction::Measure, &[0], &[])?; + assert_eq!( + m.cpu.stack().len(), + depth, + "injected measurement must not leave its outcome on the stack" + ); + // The outcome still lands in the measurement record. + assert_eq!(m.measurement_record().len(), 1); Ok(()) } From 4375f5e7a94fe1b568f285b289a4a2d2e6483e6e Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 16:06:56 +0200 Subject: [PATCH 16/19] ci: exclude ppvm-tui from the wasm32 build `ppvm-tui` pulls in ratatui/crossterm, which target native terminals and don't compile for wasm32-unknown-unknown (crossterm's `sys` module has no browser-wasm backend). Exclude it from the browser-wasm workspace build alongside `ppvm-cli` and `ppvm-python-native`; the reusable engine stays covered via the library crates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a821f47..7a68a12b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,10 +70,11 @@ jobs: run: cargo test -p ppvm-stim --features rayon # Cross-compile the whole workspace for browser wasm so a wasm regression - # surfaces in CI. `ppvm-python-native` (a CPython extension) and `ppvm-cli` - # (a terminal binary using clap/rustyline) are never browser-wasm targets and - # are excluded; the reusable engine lives in the library crates, which stay - # covered. Native-only acceleration deps (gxhash, dashmap → + # surfaces in CI. Three crates are never browser-wasm targets and are + # excluded: `ppvm-python-native` (a CPython extension), `ppvm-cli` (a terminal + # binary), and `ppvm-tui` (its ratatui/crossterm terminal UI). The reusable + # engine lives in the library crates, which stay covered. Native-only + # acceleration deps (gxhash, dashmap → # rayon, ahash) are pruned automatically by the `cfg(not(target_arch = # "wasm32"))` dependency tables, and `rand`'s entropy uses the getrandom # `wasm_js` backend wired in `.cargo/config.toml` — so no extra flags here. @@ -91,7 +92,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build workspace for wasm32-unknown-unknown - run: cargo build --target wasm32-unknown-unknown --workspace --exclude ppvm-python-native --exclude ppvm-cli + run: cargo build --target wasm32-unknown-unknown --workspace --exclude ppvm-python-native --exclude ppvm-cli --exclude ppvm-tui python-tests: name: Python tests From 49c1bb4104659b9783b6137909c807255a9b48bd Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 18:56:54 +0200 Subject: [PATCH 17/19] Split line editor out of app --- crates/ppvm-tui/src/app.rs | 297 +++------------------------- crates/ppvm-tui/src/lib.rs | 1 + crates/ppvm-vihaco/src/composite.rs | 16 +- 3 files changed, 30 insertions(+), 284 deletions(-) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index b3ae6717..6fed3108 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -2,9 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 //! `AppState` — the terminal-agnostic state of the ppvm TUI. Owns an optional -//! [`PPVM`] plus the command buffer, status line, program listing, and REPL -//! log. `dispatch` runs one command string; `handle_key` edits the buffer and -//! submits on Enter. Nothing here touches a terminal or runs a loop. +//! [`PPVM`] plus a [`LineEditor`], status line, program listing, and REPL log. +//! `dispatch` runs one command string; `handle_key` routes app-level keys +//! (submit, quit) and delegates editing to the [`LineEditor`]. Nothing here +//! touches a terminal or runs a loop. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use eyre::{Result, eyre}; @@ -17,6 +18,7 @@ use ratatui::widgets::Clear; use crate::codeview::CodeView; use crate::command::{Command, parse_command}; +use crate::editor::LineEditor; use crate::widgets::{CommandLine, HelpOverlay, ProgramView, RecordView, StateView}; /// Terminal-agnostic state for the ppvm TUI. @@ -37,16 +39,8 @@ pub struct AppState { paused: bool, /// True once the loaded program has run to Return/Halt. finished: bool, - /// The command-line buffer. - input: String, - /// Char index of the edit cursor within `input` (0..=input char count). - cursor: usize, - /// Submitted command lines, oldest first (for Up/Down recall). - history: Vec, - /// Position within `history` while recalling; `None` = editing the live line. - history_pos: Option, - /// The live line stashed when entering history, restored on the way back. - draft: String, + /// The command line: buffer, edit cursor, and history. + editor: LineEditor, /// The status/error line. status: String, /// Whether the help overlay is currently shown. @@ -72,11 +66,7 @@ impl AppState { has_program: false, paused: false, finished: false, - input: String::new(), - cursor: 0, - history: Vec::new(), - history_pos: None, - draft: String::new(), + editor: LineEditor::new(), status: String::new(), show_help: false, should_exit: false, @@ -299,7 +289,8 @@ impl AppState { // ─── key handling ──────────────────────────────────────────────────── - /// Apply one key event. Returns whether it was consumed. + /// Apply one key event. Returns whether it was consumed. App-level keys + /// (submit, quit) are handled here; the rest go to the line editor. pub fn handle_key(&mut self, key: KeyEvent) -> bool { if key.kind != KeyEventKind::Press { return false; @@ -309,173 +300,45 @@ impl AppState { self.submit(); true } - // Ctrl-C: clear a non-empty buffer, else quit (shell-like). + // Ctrl-C / Esc: clear a non-empty buffer, else quit (shell-like). KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if self.input.is_empty() { - self.should_exit = true; - } else { - self.clear_input(); - } - true - } - KeyCode::Char(c) => { - self.insert_char(c); - true - } - KeyCode::Backspace => { - self.backspace(); - true - } - KeyCode::Delete => { - self.delete(); - true - } - KeyCode::Left => { - self.cursor = self.cursor.saturating_sub(1); - true - } - KeyCode::Right => { - self.cursor = (self.cursor + 1).min(self.input_len()); - true - } - KeyCode::Home => { - self.cursor = 0; - true - } - KeyCode::End => { - self.cursor = self.input_len(); - true - } - KeyCode::Up => { - self.history_prev(); - true - } - KeyCode::Down => { - self.history_next(); + self.clear_or_quit(); true } KeyCode::Esc => { - if self.input.is_empty() { - self.should_exit = true; - } else { - self.clear_input(); - } + self.clear_or_quit(); true } - _ => false, + _ => self.editor.handle_key(key), } } - // ─── line editing & command history ────────────────────────────────── - - /// Char count of the current input line. - fn input_len(&self) -> usize { - self.input.chars().count() - } - - /// Byte offset of char index `i` within `input` (or the end of the string). - fn byte_index(&self, i: usize) -> usize { - self.input - .char_indices() - .nth(i) - .map(|(b, _)| b) - .unwrap_or(self.input.len()) - } - - /// Insert `c` at the cursor and step past it. - fn insert_char(&mut self, c: char) { - let b = self.byte_index(self.cursor); - self.input.insert(b, c); - self.cursor += 1; - } - - /// Delete the char before the cursor (Backspace). - fn backspace(&mut self) { - if self.cursor > 0 { - let b = self.byte_index(self.cursor - 1); - self.input.remove(b); - self.cursor -= 1; - } - } - - /// Delete the char at the cursor (Delete). - fn delete(&mut self) { - if self.cursor < self.input_len() { - let b = self.byte_index(self.cursor); - self.input.remove(b); + /// Clear a non-empty command line, or quit when it is already empty. + fn clear_or_quit(&mut self) { + if self.editor.is_empty() { + self.should_exit = true; + } else { + self.editor.clear(); } } - /// Clear the input line and reset the cursor. - fn clear_input(&mut self) { - self.input.clear(); - self.cursor = 0; - } - - /// Replace the input line, moving the cursor to its end. - fn set_input(&mut self, line: String) { - self.cursor = line.chars().count(); - self.input = line; - } - - /// Submit the current line: record it in history, then dispatch it. + /// Submit the current line: record it in history (via the editor), then + /// dispatch it. fn submit(&mut self) { - let line = std::mem::take(&mut self.input); - self.cursor = 0; - self.history_pos = None; - self.draft.clear(); - let trimmed = line.trim(); - // Skip blanks and consecutive duplicates, matching a shell's history. - if !trimmed.is_empty() && self.history.last().map(String::as_str) != Some(trimmed) { - self.history.push(trimmed.to_string()); - } + let line = self.editor.submit(); self.dispatch(&line); } - /// Recall an older history entry (Up), stashing the live line on first entry. - fn history_prev(&mut self) { - if self.history.is_empty() { - return; - } - let i = match self.history_pos { - None => { - self.draft = std::mem::take(&mut self.input); - self.history.len() - 1 - } - Some(0) => return, // already at the oldest - Some(i) => i - 1, - }; - self.history_pos = Some(i); - self.set_input(self.history[i].clone()); - } - - /// Move toward newer entries (Down); past the newest, restore the stashed - /// live line. - fn history_next(&mut self) { - match self.history_pos { - None => {} - Some(i) if i + 1 < self.history.len() => { - self.history_pos = Some(i + 1); - self.set_input(self.history[i + 1].clone()); - } - Some(_) => { - self.history_pos = None; - let draft = std::mem::take(&mut self.draft); - self.set_input(draft); - } - } - } - // ─── read-only accessors (used by the widgets in Task 6) ────────────── pub fn input(&self) -> &str { - &self.input + self.editor.input() } /// Char index of the edit cursor within the input line. Used to place the /// terminal cursor; a host embedding `CommandLine` uses this to position it. pub fn cursor(&self) -> usize { - self.cursor + self.editor.cursor() } pub fn status(&self) -> &str { @@ -557,7 +420,7 @@ impl AppState { // Place the terminal cursor in the input line, just after the prompt, // clamped to the command area so a long line can't run off-panel. let cmd = root[2]; - let col = cmd.x + CommandLine::PROMPT.len() as u16 + self.cursor as u16; + let col = cmd.x + CommandLine::PROMPT.len() as u16 + self.editor.cursor() as u16; let x = col.min(cmd.x + cmd.width.saturating_sub(1)); frame.set_cursor_position((x, cmd.y)); @@ -689,15 +552,6 @@ mod tests { assert!(app.input().is_empty(), "buffer should clear on submit"); } - #[test] - fn backspace_edits_the_buffer() { - let mut app = AppState::new(); - app.handle_key(key(KeyCode::Char('h'))); - app.handle_key(key(KeyCode::Char('i'))); - app.handle_key(key(KeyCode::Backspace)); - assert_eq!(app.input(), "h"); - } - #[test] fn ctrl_c_on_empty_buffer_exits() { let mut app = AppState::new(); @@ -832,105 +686,6 @@ mod tests { ); } - // ─── line editing & history ────────────────────────────────────────── - - /// Type each char, then press Enter (submitting to history + dispatch). - fn type_line(app: &mut AppState, line: &str) { - for c in line.chars() { - app.handle_key(key(KeyCode::Char(c))); - } - app.handle_key(key(KeyCode::Enter)); - } - - #[test] - fn typing_inserts_and_advances_the_cursor() { - let mut app = AppState::new(); - for c in "hz".chars() { - app.handle_key(key(KeyCode::Char(c))); - } - assert_eq!(app.input(), "hz"); - assert_eq!(app.cursor(), 2); - } - - #[test] - fn left_right_home_end_move_the_cursor() { - let mut app = AppState::new(); - for c in "abc".chars() { - app.handle_key(key(KeyCode::Char(c))); - } - assert_eq!(app.cursor(), 3); - app.handle_key(key(KeyCode::Left)); - assert_eq!(app.cursor(), 2); - app.handle_key(key(KeyCode::Home)); - assert_eq!(app.cursor(), 0); - app.handle_key(key(KeyCode::Left)); // saturates at 0 - assert_eq!(app.cursor(), 0); - app.handle_key(key(KeyCode::End)); - assert_eq!(app.cursor(), 3); - app.handle_key(key(KeyCode::Right)); // saturates at len - assert_eq!(app.cursor(), 3); - } - - #[test] - fn insert_and_delete_at_the_cursor() { - let mut app = AppState::new(); - for c in "ac".chars() { - app.handle_key(key(KeyCode::Char(c))); - } - app.handle_key(key(KeyCode::Left)); // cursor between 'a' and 'c' - app.handle_key(key(KeyCode::Char('b'))); - assert_eq!(app.input(), "abc"); - assert_eq!(app.cursor(), 2); - app.handle_key(key(KeyCode::Backspace)); // removes 'b' before cursor - assert_eq!(app.input(), "ac"); - assert_eq!(app.cursor(), 1); - app.handle_key(key(KeyCode::Delete)); // removes 'c' at cursor - assert_eq!(app.input(), "a"); - assert_eq!(app.cursor(), 1); - } - - #[test] - fn up_arrow_recalls_previous_commands() { - let mut app = AppState::new(); - type_line(&mut app, "device 1"); - type_line(&mut app, "x 0"); - app.handle_key(key(KeyCode::Up)); // newest first - assert_eq!(app.input(), "x 0"); - assert_eq!(app.cursor(), 3, "cursor lands at end of the recalled line"); - app.handle_key(key(KeyCode::Up)); - assert_eq!(app.input(), "device 1"); - app.handle_key(key(KeyCode::Up)); // already oldest, stays put - assert_eq!(app.input(), "device 1"); - } - - #[test] - fn down_arrow_returns_toward_the_live_line() { - let mut app = AppState::new(); - type_line(&mut app, "device 1"); - type_line(&mut app, "x 0"); - for c in "meas".chars() { - app.handle_key(key(KeyCode::Char(c))); - } - app.handle_key(key(KeyCode::Up)); // stash "meas", show "x 0" - app.handle_key(key(KeyCode::Up)); // "device 1" - app.handle_key(key(KeyCode::Down)); // "x 0" - assert_eq!(app.input(), "x 0"); - app.handle_key(key(KeyCode::Down)); // past newest -> restore draft - assert_eq!(app.input(), "meas"); - assert_eq!(app.cursor(), 4); - } - - #[test] - fn history_skips_consecutive_duplicates() { - let mut app = AppState::new(); - type_line(&mut app, "device 1"); - type_line(&mut app, "device 1"); // same command twice -> one entry - app.handle_key(key(KeyCode::Up)); - assert_eq!(app.input(), "device 1"); - app.handle_key(key(KeyCode::Up)); // only one entry -> stays - assert_eq!(app.input(), "device 1"); - } - #[test] fn render_places_the_terminal_cursor_after_the_prompt() { use crate::widgets::CommandLine; diff --git a/crates/ppvm-tui/src/lib.rs b/crates/ppvm-tui/src/lib.rs index 0878cb2b..cb659e30 100644 --- a/crates/ppvm-tui/src/lib.rs +++ b/crates/ppvm-tui/src/lib.rs @@ -8,6 +8,7 @@ pub mod app; pub mod codeview; pub mod command; +pub mod editor; pub mod widgets; pub use app::AppState; diff --git a/crates/ppvm-vihaco/src/composite.rs b/crates/ppvm-vihaco/src/composite.rs index dadb10c4..bb6b4433 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -435,23 +435,13 @@ impl PPVM { } instrs.push(PPVMInstruction::Circuit(inst)); - // Apply the op without disturbing a loaded program or its program - // counter: run the appended block, then truncate it and restore the pc. - // The tableau/measurement effects persist; the code + pc are left - // byte-for-byte unchanged, so a paused debugger resumes exactly where it - // was. (Also keeps a long REPL session's code vector from growing.) - // - // The operand stack is rolled back too. `circuit.measure`/`trace` push a - // result onto it for bytecode to consume, and a partially-applied gate - // may leave its consts behind on error; either way there is no consumer - // here, so a stray operand would desync a resumed program's stack and - // grow a REPL session's stack without bound. + // Run the appended block, then roll back pc, code, and operand stack (on + // every path, including error) so the injected op is transparent to a + // paused program — only its tableau/measurement effects persist. let saved_pc = self.loader.pc(); let saved_len = self.loader.module.code.len(); let saved_stack = self.cpu.stack_len(); let result = self.execute_single_instruction(&instrs); - // Restore the debugger's position on every path (including error): the - // appended ops are transient, and the pc/code/stack must be unchanged. self.loader.module.code.truncate(saved_len); *self.loader.pc_mut() = saved_pc; self.cpu.stack_mut().truncate(saved_stack); From 99f066a533729c58e6b07cfd4bf405ba7c7a3df2 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 2 Jul 2026 12:38:43 +0200 Subject: [PATCH 18/19] Add untracked file --- crates/ppvm-tui/src/editor.rs | 342 ++++++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 crates/ppvm-tui/src/editor.rs diff --git a/crates/ppvm-tui/src/editor.rs b/crates/ppvm-tui/src/editor.rs new file mode 100644 index 00000000..51804bb9 --- /dev/null +++ b/crates/ppvm-tui/src/editor.rs @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 The PPVM Authors +// SPDX-License-Identifier: Apache-2.0 + +//! A readline-style single-line editor with command history. Owns the command +//! buffer, edit cursor, and history; knows nothing about the PPVM or the +//! debugger. The host handles submit (Enter) and quit keys, then delegates the +//! remaining editing keys here via [`LineEditor::handle_key`]. + +use crossterm::event::{KeyCode, KeyEvent}; + +/// The command line's editable buffer plus its command history. +#[derive(Debug, Default)] +pub struct LineEditor { + /// The command-line buffer. + input: String, + /// Char index of the edit cursor within `input` (0..=input char count). + cursor: usize, + /// Submitted command lines, oldest first (for Up/Down recall). + history: Vec, + /// Position within `history` while recalling; `None` = editing the live line. + history_pos: Option, + /// The live line stashed when entering history, restored on the way back. + draft: String, +} + +impl LineEditor { + pub fn new() -> Self { + Self::default() + } + + // ─── read-only accessors ───────────────────────────────────────────── + + pub fn input(&self) -> &str { + &self.input + } + + /// Char index of the edit cursor within the input line. Used to place the + /// terminal cursor. + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn is_empty(&self) -> bool { + self.input.is_empty() + } + + /// Clear the input line and reset the cursor. + pub fn clear(&mut self) { + self.input.clear(); + self.cursor = 0; + } + + // ─── key handling ──────────────────────────────────────────────────── + + /// Apply one editing key (text, cursor movement, or history recall). + /// Returns whether it was consumed. Submit and quit keys are the host's + /// responsibility and are not handled here. + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char(c) => { + self.insert_char(c); + true + } + KeyCode::Backspace => { + self.backspace(); + true + } + KeyCode::Delete => { + self.delete(); + true + } + KeyCode::Left => { + self.cursor = self.cursor.saturating_sub(1); + true + } + KeyCode::Right => { + self.cursor = (self.cursor + 1).min(self.input_len()); + true + } + KeyCode::Home => { + self.cursor = 0; + true + } + KeyCode::End => { + self.cursor = self.input_len(); + true + } + KeyCode::Up => { + self.history_prev(); + true + } + KeyCode::Down => { + self.history_next(); + true + } + _ => false, + } + } + + /// Take the current line: record it in history and reset editor state, + /// returning the (untrimmed) line for the host to dispatch. + pub fn submit(&mut self) -> String { + let line = std::mem::take(&mut self.input); + self.cursor = 0; + self.history_pos = None; + self.draft.clear(); + let trimmed = line.trim(); + // Skip blanks and consecutive duplicates, matching a shell's history. + if !trimmed.is_empty() && self.history.last().map(String::as_str) != Some(trimmed) { + self.history.push(trimmed.to_string()); + } + line + } + + // ─── line editing ──────────────────────────────────────────────────── + + /// Char count of the current input line. + fn input_len(&self) -> usize { + self.input.chars().count() + } + + /// Byte offset of char index `i` within `input` (or the end of the string). + fn byte_index(&self, i: usize) -> usize { + self.input + .char_indices() + .nth(i) + .map(|(b, _)| b) + .unwrap_or(self.input.len()) + } + + /// Insert `c` at the cursor and step past it. + fn insert_char(&mut self, c: char) { + let b = self.byte_index(self.cursor); + self.input.insert(b, c); + self.cursor += 1; + } + + /// Delete the char before the cursor (Backspace). + fn backspace(&mut self) { + if self.cursor > 0 { + let b = self.byte_index(self.cursor - 1); + self.input.remove(b); + self.cursor -= 1; + } + } + + /// Delete the char at the cursor (Delete). + fn delete(&mut self) { + if self.cursor < self.input_len() { + let b = self.byte_index(self.cursor); + self.input.remove(b); + } + } + + /// Replace the input line, moving the cursor to its end. + fn set_input(&mut self, line: String) { + self.cursor = line.chars().count(); + self.input = line; + } + + // ─── command history ───────────────────────────────────────────────── + + /// Recall an older history entry (Up), stashing the live line on first entry. + fn history_prev(&mut self) { + if self.history.is_empty() { + return; + } + let i = match self.history_pos { + None => { + self.draft = std::mem::take(&mut self.input); + self.history.len() - 1 + } + Some(0) => return, // already at the oldest + Some(i) => i - 1, + }; + self.history_pos = Some(i); + self.set_input(self.history[i].clone()); + } + + /// Move toward newer entries (Down); past the newest, restore the stashed + /// live line. + fn history_next(&mut self) { + match self.history_pos { + None => {} + Some(i) if i + 1 < self.history.len() => { + self.history_pos = Some(i + 1); + self.set_input(self.history[i + 1].clone()); + } + Some(_) => { + self.history_pos = None; + let draft = std::mem::take(&mut self.draft); + self.set_input(draft); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyModifiers}; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Type each char, then submit (recording it in history). + fn type_line(ed: &mut LineEditor, line: &str) { + for c in line.chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + ed.submit(); + } + + #[test] + fn typing_inserts_and_advances_the_cursor() { + let mut ed = LineEditor::new(); + for c in "hz".chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(ed.input(), "hz"); + assert_eq!(ed.cursor(), 2); + } + + #[test] + fn backspace_edits_the_buffer() { + let mut ed = LineEditor::new(); + ed.handle_key(key(KeyCode::Char('h'))); + ed.handle_key(key(KeyCode::Char('i'))); + ed.handle_key(key(KeyCode::Backspace)); + assert_eq!(ed.input(), "h"); + } + + #[test] + fn left_right_home_end_move_the_cursor() { + let mut ed = LineEditor::new(); + for c in "abc".chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + assert_eq!(ed.cursor(), 3); + ed.handle_key(key(KeyCode::Left)); + assert_eq!(ed.cursor(), 2); + ed.handle_key(key(KeyCode::Home)); + assert_eq!(ed.cursor(), 0); + ed.handle_key(key(KeyCode::Left)); // saturates at 0 + assert_eq!(ed.cursor(), 0); + ed.handle_key(key(KeyCode::End)); + assert_eq!(ed.cursor(), 3); + ed.handle_key(key(KeyCode::Right)); // saturates at len + assert_eq!(ed.cursor(), 3); + } + + #[test] + fn insert_and_delete_at_the_cursor() { + let mut ed = LineEditor::new(); + for c in "ac".chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + ed.handle_key(key(KeyCode::Left)); // cursor between 'a' and 'c' + ed.handle_key(key(KeyCode::Char('b'))); + assert_eq!(ed.input(), "abc"); + assert_eq!(ed.cursor(), 2); + ed.handle_key(key(KeyCode::Backspace)); // removes 'b' before cursor + assert_eq!(ed.input(), "ac"); + assert_eq!(ed.cursor(), 1); + ed.handle_key(key(KeyCode::Delete)); // removes 'c' at cursor + assert_eq!(ed.input(), "a"); + assert_eq!(ed.cursor(), 1); + } + + #[test] + fn submit_records_history_and_clears_the_line() { + let mut ed = LineEditor::new(); + for c in "x 0".chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + let line = ed.submit(); + assert_eq!(line, "x 0", "submit returns the entered line"); + assert!(ed.is_empty(), "buffer clears on submit"); + assert_eq!(ed.cursor(), 0); + } + + #[test] + fn up_arrow_recalls_previous_commands() { + let mut ed = LineEditor::new(); + type_line(&mut ed, "device 1"); + type_line(&mut ed, "x 0"); + ed.handle_key(key(KeyCode::Up)); // newest first + assert_eq!(ed.input(), "x 0"); + assert_eq!(ed.cursor(), 3, "cursor lands at end of the recalled line"); + ed.handle_key(key(KeyCode::Up)); + assert_eq!(ed.input(), "device 1"); + ed.handle_key(key(KeyCode::Up)); // already oldest, stays put + assert_eq!(ed.input(), "device 1"); + } + + #[test] + fn down_arrow_returns_toward_the_live_line() { + let mut ed = LineEditor::new(); + type_line(&mut ed, "device 1"); + type_line(&mut ed, "x 0"); + for c in "meas".chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + ed.handle_key(key(KeyCode::Up)); // stash "meas", show "x 0" + ed.handle_key(key(KeyCode::Up)); // "device 1" + ed.handle_key(key(KeyCode::Down)); // "x 0" + assert_eq!(ed.input(), "x 0"); + ed.handle_key(key(KeyCode::Down)); // past newest -> restore draft + assert_eq!(ed.input(), "meas"); + assert_eq!(ed.cursor(), 4); + } + + #[test] + fn history_skips_consecutive_duplicates() { + let mut ed = LineEditor::new(); + type_line(&mut ed, "device 1"); + type_line(&mut ed, "device 1"); // same command twice -> one entry + ed.handle_key(key(KeyCode::Up)); + assert_eq!(ed.input(), "device 1"); + ed.handle_key(key(KeyCode::Up)); // only one entry -> stays + assert_eq!(ed.input(), "device 1"); + } + + #[test] + fn clear_empties_the_line() { + let mut ed = LineEditor::new(); + for c in "abc".chars() { + ed.handle_key(key(KeyCode::Char(c))); + } + ed.clear(); + assert!(ed.is_empty()); + assert_eq!(ed.cursor(), 0); + } + + #[test] + fn non_editing_keys_are_not_consumed() { + let mut ed = LineEditor::new(); + assert!(!ed.handle_key(key(KeyCode::Enter))); + assert!(!ed.handle_key(key(KeyCode::Esc))); + } +} From f806e10941873234cf3b4c1e3a43e60fe9d5ad6b Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 3 Jul 2026 09:56:41 +0200 Subject: [PATCH 19/19] Update bell --- crates/ppvm-vihaco/tests/bell.sst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ppvm-vihaco/tests/bell.sst b/crates/ppvm-vihaco/tests/bell.sst index 8b23d1f3..cdf76041 100644 --- a/crates/ppvm-vihaco/tests/bell.sst +++ b/crates/ppvm-vihaco/tests/bell.sst @@ -11,7 +11,7 @@ fn @main() { const.u64 0 circuit.measure - const.u64 0 + const.u64 1 circuit.measure ret