From efc48274c00d43cdaeb14e68ad392dc3a8762093 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:49:41 +0200 Subject: [PATCH 01/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 f95fb924..4ce7b56e 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -402,7 +402,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> { @@ -1230,6 +1241,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 2be65bfacf56a55b0e29f4fec9f5751cea781371 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:53:51 +0200 Subject: [PATCH 02/19] feat(ppvm-tui): scaffold crate with CodeView line list Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 370 ++++++++++++++++++++++++++++++-- Cargo.toml | 1 + crates/ppvm-tui/Cargo.toml | 15 ++ crates/ppvm-tui/src/codeview.rs | 70 ++++++ crates/ppvm-tui/src/lib.rs | 8 + 5 files changed, 452 insertions(+), 12 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 0041ea5e..6dad12ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36f5e3dca4e09a6f340a61a0e9c7b61e030c69fc27bf29d73218f7e5e3b7638f" dependencies = [ - "unicode-width", + "unicode-width 0.1.14", "yansi", ] @@ -220,7 +220,7 @@ version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d82020dadcb845a345591863adb65d74fa8dc5c18a0b6d408470e13b7adc7005" dependencies = [ - "darling", + "darling 0.21.3", "ident_case", "prettyplease", "proc-macro2", @@ -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.61" @@ -393,7 +408,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -411,6 +426,20 @@ dependencies = [ "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]] name = "console" version = "0.15.11" @@ -432,6 +461,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" @@ -534,6 +572,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.11.1", + "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.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.2", + "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" @@ -546,8 +627,18 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -564,13 +655,37 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] @@ -621,6 +736,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.15.0" @@ -730,7 +876,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -827,6 +973,15 @@ dependencies = [ "serde_core", ] +[[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.43.2" @@ -839,6 +994,19 @@ dependencies = [ "similar", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "inventory" version = "0.3.22" @@ -921,9 +1089,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libmimalloc-sys" @@ -934,12 +1102,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.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[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.13" @@ -956,6 +1136,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.7.6" @@ -971,6 +1160,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 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -1081,6 +1282,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.11" @@ -1312,6 +1523,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" @@ -1521,6 +1742,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.11.1", + "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.11.0" @@ -1596,6 +1838,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.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.2" @@ -1605,7 +1869,7 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -1703,6 +1967,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" @@ -1728,6 +2023,12 @@ dependencies = [ "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" @@ -1743,6 +2044,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" @@ -1775,7 +2098,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -1836,12 +2159,29 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1919,7 +2259,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", @@ -1971,6 +2311,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 = "wasi" version = "0.14.7+wasi-0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 60e8ae5d..35cb8102 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,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. + "crates/ppvm-tui", "skills/ppvm-usage/examples/rust", ] 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 524d206acda688bea54b198b15ca39bb669d805c Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 10:57:18 +0200 Subject: [PATCH 03/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 1106972f6f3417a01436b7a5eef0d4b65795df0c Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:01:26 +0200 Subject: [PATCH 04/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 4452f0fe07cca31d5f3a921537cf4b6537a35899 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:04:44 +0200 Subject: [PATCH 05/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 47c7fd31167afb859107ec4f09e3c281f389f55b Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:08:53 +0200 Subject: [PATCH 06/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 16b27108bee423c24a03f2453961ad59f776231d Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:13:43 +0200 Subject: [PATCH 07/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 67b7d9372371999df1f1f25b0f6a263115739255 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:18:58 +0200 Subject: [PATCH 08/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 6dad12ce..13946dec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1377,8 +1377,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 846880ae5ed27a280c9c8c1005f4fabff70b026c Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:28:30 +0200 Subject: [PATCH 09/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 4ce7b56e..69f9734e 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -410,10 +410,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> { @@ -1280,6 +1282,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 bc806438b329ba38e5939b805ba6b1daed564003 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 11:53:10 +0200 Subject: [PATCH 10/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 5091ace1ed482414fdb4e44e638affd23d79f90d Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 12:02:43 +0200 Subject: [PATCH 11/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 bf9f29f1c8cf7ded47e027be0d97d0eeeff65315 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 15:38:52 +0200 Subject: [PATCH 12/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 69f9734e..bd8dbed8 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -408,13 +408,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 } @@ -1262,6 +1270,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], &[])?; @@ -1273,6 +1282,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) {} @@ -1298,6 +1312,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. @@ -1311,6 +1326,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 0cf22fddf150dcdc8ee47fe90ed83741de31dcfa Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 16:06:56 +0200 Subject: [PATCH 13/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 | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0ddc59f..7a68a12b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,11 +70,12 @@ 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 and forcing the rayon feature) 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 → rayon, ahash) are pruned automatically by the `cfg(not(target_arch = + # 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. wasm-build: @@ -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 52a3f22baad2b2fd70f113cd4c8fd47f481183d1 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 1 Jul 2026 18:56:54 +0200 Subject: [PATCH 14/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 bd8dbed8..c8632a2e 100644 --- a/crates/ppvm-vihaco/src/composite.rs +++ b/crates/ppvm-vihaco/src/composite.rs @@ -403,23 +403,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 0bc1e90fb44e8f7a16303e53a193d5bcb1e1de3f Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Thu, 2 Jul 2026 12:38:43 +0200 Subject: [PATCH 15/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 fa4945249fec98e4541ff5c82f3aa7013f29c349 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Fri, 3 Jul 2026 09:56:41 +0200 Subject: [PATCH 16/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 From 206c4695611ba30dcaec549de352cb12cf91ac2b Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 16:05:01 +0200 Subject: [PATCH 17/19] fix(ppvm-cli): create TerminalGuard before entering the alternate screen The guard that restores the terminal on drop was created only after EnterAlternateScreen and Terminal::new. If either errored, the `?` early-return left raw mode enabled with no guard, wedging the user's terminal. Move the guard to immediately after enable_raw_mode() so every later error path restores the terminal. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-cli/src/tui.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ppvm-cli/src/tui.rs b/crates/ppvm-cli/src/tui.rs index c8c2b5ec..6a2955c2 100644 --- a/crates/ppvm-cli/src/tui.rs +++ b/crates/ppvm-cli/src/tui.rs @@ -35,9 +35,11 @@ pub fn run(file: Option<&str>) -> Result<()> { None => AppState::new(), }; + // Guard immediately after raw mode is on, so any later setup error + // (EnterAlternateScreen, Terminal::new) still restores the terminal. enable_raw_mode()?; - execute!(io::stdout(), EnterAlternateScreen)?; let _guard = TerminalGuard; + execute!(io::stdout(), EnterAlternateScreen)?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; terminal.clear()?; From 1c1a45ae291bc3a465582d7bba8eccef0cba5ac4 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 16:05:13 +0200 Subject: [PATCH 18/19] fix(ppvm-tui): clear paused on finish and reject trailing command args Two REPL/debugger correctness fixes from review: - apply_outcome now sets `paused` for every step outcome (cleared on Continue and Return/Halt, set on Breakpoint), so the UI no longer shows debugger hints after a program finishes; the footer hint also special-cases the finished state to point at :reset instead of stepping. - :load and device now reject trailing tokens (e.g. `:load a b`, `device 2 extra`) instead of silently ignoring them, matching how gate commands enforce arity. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ppvm-tui/src/app.rs | 33 +++++++++++++++++++++++++++++++-- crates/ppvm-tui/src/command.rs | 24 +++++++++++++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index 6fed3108..a9ede3bd 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -226,12 +226,16 @@ impl AppState { /// 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::Continue => { + self.paused = false; + self.set_status(""); + } StepOutcome::Breakpoint => { self.paused = true; self.set_status("-- breakpoint hit --"); } StepOutcome::Return | StepOutcome::Halt => { + self.paused = false; self.finished = true; self.set_status("program finished"); } @@ -379,7 +383,9 @@ impl AppState { /// A contextual footer hint. pub fn hint(&self) -> &'static str { - if self.has_program && self.paused { + if self.has_program && self.finished { + ":reset=run again · :load · :help · :q=quit" + } else if self.has_program && self.paused { "Enter=step :c=continue :reset :help :q=quit" } else if self.machine.is_some() { "type a gate, or :load :help :q=quit" @@ -627,6 +633,29 @@ mod tests { assert_eq!(app.measurement_bits(), "0"); } + #[test] + fn finishing_clears_paused() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); // pause at breakpoint + assert!(app.paused()); + app.dispatch(":c"); // run to Return + assert!(!app.paused(), "paused must clear once the program finishes"); + } + + #[test] + fn finished_hint_does_not_suggest_stepping() { + let mut app = AppState::new(); + app.load_source(BP_PROGRAM).unwrap(); + app.dispatch(":c"); // pause at breakpoint + app.dispatch(":c"); // run to Return + assert!( + !app.hint().contains("step") && !app.hint().contains("continue"), + "finished-program hint should not suggest stepping, got: {}", + app.hint() + ); + } + #[test] fn empty_line_steps_and_advances_cursor() { let mut app = AppState::new(); diff --git a/crates/ppvm-tui/src/command.rs b/crates/ppvm-tui/src/command.rs index 917bd0a8..0cea9ba1 100644 --- a/crates/ppvm-tui/src/command.rs +++ b/crates/ppvm-tui/src/command.rs @@ -103,6 +103,9 @@ pub fn parse_command(line: &str) -> Result { "help" | "h" | "?" => Ok(Command::Help), "load" => { let path = it.next().ok_or_else(|| eyre!(":load needs a file path"))?; + if it.next().is_some() { + bail!(":load takes a single file path"); + } Ok(Command::Load(path.to_string())) } other => bail!("unknown command :{other}"), @@ -115,11 +118,10 @@ pub fn parse_command(line: &str) -> Result { 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")?; + if args.len() != 1 { + bail!("device takes a single qubit count, got {}", args.len()); + } + let n = args[0].parse::().wrap_err("invalid qubit count")?; return Ok(Command::Device(n)); } @@ -230,4 +232,16 @@ mod tests { assert!(parse_command("x").is_err()); assert!(parse_command("cnot 0").is_err()); } + + #[test] + fn load_rejects_trailing_tokens() { + assert!(parse_command(":load a.sst").is_ok()); + assert!(parse_command(":load a.sst junk").is_err()); + } + + #[test] + fn device_rejects_trailing_tokens() { + assert!(parse_command("device 2").is_ok()); + assert!(parse_command("device 2 extra").is_err()); + } } From c0c8336d143d4b7f12f0a8243b4f607d4ca0a2b9 Mon Sep 17 00:00:00 2001 From: David Plankensteiner Date: Wed, 8 Jul 2026 16:17:57 +0200 Subject: [PATCH 19/19] refactor(ppvm-tui): use ratatui's re-exported crossterm, drop direct dep ppvm-cli and ppvm-tui pinned crossterm 0.29.0 directly while ratatui 0.29 pulls 0.28.1, so the build carried two crossterm versions (bloat + KeyEvent types that don't unify across ratatui apps). Route all crossterm access through `ratatui::crossterm` and drop the direct dependency from both crates. No 0.29-only APIs were used, so this is a pure de-duplication; Cargo.lock now resolves a single crossterm 0.28.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 79 +---------------------------------- crates/ppvm-cli/Cargo.toml | 1 - crates/ppvm-cli/src/tui.rs | 10 ++--- crates/ppvm-tui/Cargo.toml | 4 +- crates/ppvm-tui/src/app.rs | 2 +- crates/ppvm-tui/src/editor.rs | 4 +- 6 files changed, 13 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13946dec..55b321d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,15 +461,6 @@ 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" @@ -588,24 +579,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix 1.1.2", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -736,37 +709,6 @@ 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.15.0" @@ -1114,12 +1056,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" -[[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.13" @@ -1377,7 +1313,6 @@ name = "ppvm-cli" version = "0.1.0" dependencies = [ "clap", - "crossterm 0.29.0", "eyre", "ppvm-tui", "ppvm-vihaco", @@ -1530,7 +1465,6 @@ dependencies = [ name = "ppvm-tui" version = "0.1.0" dependencies = [ - "crossterm 0.29.0", "eyre", "ppvm-vihaco", "ratatui", @@ -1754,7 +1688,7 @@ dependencies = [ "bitflags 2.11.1", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools 0.13.0", @@ -1841,15 +1775,6 @@ 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" @@ -2262,7 +2187,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f11ac1123518d4bec265847a1a12e406c1b54f4ef2478db87f4e6986e26b918" dependencies = [ - "convert_case 0.6.0", + "convert_case", "proc-macro2", "quote", "syn", diff --git a/crates/ppvm-cli/Cargo.toml b/crates/ppvm-cli/Cargo.toml index 4108ca28..38290e61 100644 --- a/crates/ppvm-cli/Cargo.toml +++ b/crates/ppvm-cli/Cargo.toml @@ -5,7 +5,6 @@ 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"] } diff --git a/crates/ppvm-cli/src/tui.rs b/crates/ppvm-cli/src/tui.rs index 6a2955c2..b853f5c5 100644 --- a/crates/ppvm-cli/src/tui.rs +++ b/crates/ppvm-cli/src/tui.rs @@ -7,15 +7,15 @@ 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; +use ratatui::crossterm::event::{self, Event}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; /// Restores the terminal on drop — including when the app panics mid-loop. struct TerminalGuard; diff --git a/crates/ppvm-tui/Cargo.toml b/crates/ppvm-tui/Cargo.toml index e4df123b..22a4ffc0 100644 --- a/crates/ppvm-tui/Cargo.toml +++ b/crates/ppvm-tui/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] eyre = "0.6.12" +# crossterm comes through ratatui's re-export (`ratatui::crossterm`), so we +# don't declare it directly — that keeps a single crossterm version in the tree +# and unifies event types with any host ratatui app. ratatui = "0.29.0" -crossterm = "0.29.0" ppvm-vihaco = { version = "0.1.0", path = "../ppvm-vihaco" } diff --git a/crates/ppvm-tui/src/app.rs b/crates/ppvm-tui/src/app.rs index a9ede3bd..01473eba 100644 --- a/crates/ppvm-tui/src/app.rs +++ b/crates/ppvm-tui/src/app.rs @@ -7,12 +7,12 @@ //! (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}; 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::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::widgets::Clear; diff --git a/crates/ppvm-tui/src/editor.rs b/crates/ppvm-tui/src/editor.rs index 51804bb9..59c192f2 100644 --- a/crates/ppvm-tui/src/editor.rs +++ b/crates/ppvm-tui/src/editor.rs @@ -6,7 +6,7 @@ //! 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}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; /// The command line's editable buffer plus its command history. #[derive(Debug, Default)] @@ -198,7 +198,7 @@ impl LineEditor { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyCode, KeyModifiers}; + use ratatui::crossterm::event::{KeyCode, KeyModifiers}; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE)