From 762b7510b31ed763e62bff4e0d1196a743b6bd2f Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 26 Mar 2026 13:34:50 -0400 Subject: [PATCH 1/2] feat: redaction tui --- Cargo.lock | 1093 +++++++++++++++++++++++++- Cargo.toml | 5 + crates/toolpath-cli/Cargo.toml | 1 + crates/toolpath-cli/src/cmd_tui.rs | 40 + crates/toolpath-cli/src/main.rs | 13 + crates/toolpath-tui/Cargo.toml | 16 + crates/toolpath-tui/src/app.rs | 1070 ++++++++++++++++++++++++++ crates/toolpath-tui/src/lib.rs | 76 ++ crates/toolpath-tui/src/model.rs | 1136 ++++++++++++++++++++++++++++ crates/toolpath-tui/src/redact.rs | 665 ++++++++++++++++ crates/toolpath-tui/src/render.rs | 586 ++++++++++++++ examples/path-05-complex-dag.json | 252 ++++++ 12 files changed, 4927 insertions(+), 26 deletions(-) create mode 100644 crates/toolpath-cli/src/cmd_tui.rs create mode 100644 crates/toolpath-tui/Cargo.toml create mode 100644 crates/toolpath-tui/src/app.rs create mode 100644 crates/toolpath-tui/src/lib.rs create mode 100644 crates/toolpath-tui/src/model.rs create mode 100644 crates/toolpath-tui/src/redact.rs create mode 100644 crates/toolpath-tui/src/render.rs create mode 100644 examples/path-05-complex-dag.json diff --git a/Cargo.lock b/Cargo.lock index e4c79e3..1fa8ef1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -91,6 +97,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -109,6 +124,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "1.3.2" @@ -121,6 +151,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -138,12 +177,27 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[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.55" @@ -162,6 +216,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -207,7 +267,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -222,6 +282,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -234,6 +308,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "core-foundation" version = "0.9.4" @@ -260,12 +343,149 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "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 = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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 2.0.115", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[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", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.115", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -274,9 +494,24 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -308,12 +543,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "filetime" version = "0.2.27" @@ -331,6 +596,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "float-cmp" version = "0.10.0" @@ -352,6 +629,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -434,6 +717,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -510,7 +803,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -518,6 +811,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -525,6 +823,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.0" @@ -754,6 +1058,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -787,6 +1097,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 = "inotify" version = "0.10.2" @@ -819,6 +1138,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "instant" version = "0.1.13" @@ -850,6 +1182,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -876,6 +1217,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -896,6 +1248,18 @@ dependencies = [ "libc", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -959,6 +1323,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -971,6 +1344,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -986,18 +1365,58 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -1028,8 +1447,31 @@ dependencies = [ ] [[package]] -name = "normalize-line-endings" -version = "0.3.0" +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" @@ -1061,6 +1503,23 @@ dependencies = [ "instant", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1070,6 +1529,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1105,7 +1573,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -1132,6 +1600,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1161,6 +1638,101 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1179,6 +1751,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1188,6 +1766,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1234,7 +1818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.115", ] [[package]] @@ -1261,6 +1845,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.2" @@ -1268,7 +1861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1278,9 +1871,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1290,6 +1889,91 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1393,6 +2077,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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 = "1.1.3" @@ -1531,7 +2224,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -1559,12 +2252,44 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" 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" @@ -1581,6 +2306,12 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -1609,18 +2340,56 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.115" @@ -1649,7 +2418,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -1686,19 +2455,91 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1709,9 +2550,41 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", ] +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinystr" version = "0.8.2" @@ -1747,7 +2620,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -1801,7 +2674,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "toolpath", "toolpath-convo", @@ -1818,7 +2691,7 @@ dependencies = [ "git2", "insta", "predicates", - "rand", + "rand 0.9.2", "serde", "serde_json", "similar", @@ -1829,6 +2702,7 @@ dependencies = [ "toolpath-git", "toolpath-github", "toolpath-md", + "toolpath-tui", ] [[package]] @@ -1838,7 +2712,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1879,6 +2753,17 @@ dependencies = [ "toolpath", ] +[[package]] +name = "toolpath-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "ratatui", + "serde_json", + "toolpath", +] + [[package]] name = "tower" version = "0.5.3" @@ -1949,12 +2834,47 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-segmentation" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1991,12 +2911,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "atomic", + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2095,7 +3042,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.115", "wasm-bindgen-shared", ] @@ -2152,6 +3099,94 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2161,6 +3196,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2182,7 +3223,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -2193,7 +3234,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -2426,7 +3467,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.115", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2442,7 +3483,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.115", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2509,7 +3550,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", "synstructure", ] @@ -2530,7 +3571,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -2550,7 +3591,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", "synstructure", ] @@ -2590,7 +3631,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f2b9691..7cbbb22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/toolpath-claude", "crates/toolpath-dot", "crates/toolpath-md", + "crates/toolpath-tui", "crates/toolpath-cli", ] resolver = "2" @@ -23,6 +24,10 @@ toolpath-claude = { version = "0.6.2", path = "crates/toolpath-claude", default- toolpath-github = { version = "0.2.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.1.2", path = "crates/toolpath-dot" } toolpath-md = { version = "0.2.0", path = "crates/toolpath-md" } +toolpath-tui = { version = "0.1.0", path = "crates/toolpath-tui" } + +crossterm = "0.29" +ratatui = "0.30" reqwest = { version = "0.12", features = ["blocking", "json"] } serde = { version = "1.0", features = ["derive"] } diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index d98aa37..2a5d952 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] toolpath = { workspace = true } +toolpath-tui = { workspace = true } toolpath-git = { workspace = true } toolpath-dot = { workspace = true } toolpath-md = { workspace = true } diff --git a/crates/toolpath-cli/src/cmd_tui.rs b/crates/toolpath-cli/src/cmd_tui.rs new file mode 100644 index 0000000..14a4a99 --- /dev/null +++ b/crates/toolpath-cli/src/cmd_tui.rs @@ -0,0 +1,40 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use toolpath::v1; +use toolpath_tui::{TuiConfig, TuiMode}; + +pub fn run_view(input: PathBuf) -> Result<()> { + run_tui(input, TuiMode::View) +} + +pub fn run_redact(input: PathBuf) -> Result<()> { + run_tui(input, TuiMode::Redact) +} + +fn run_tui(input: PathBuf, mode: TuiMode) -> Result<()> { + let json = if input.to_str() == Some("-") { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else { + std::fs::read_to_string(&input).with_context(|| format!("reading {}", input.display()))? + }; + + let doc = v1::Document::from_json(&json).context("parsing Toolpath document")?; + let path = match doc { + v1::Document::Path(p) => p, + _ => anyhow::bail!("view/redact requires a Path document"), + }; + + let config = TuiConfig { + app_name: "path".to_string(), + }; + + let result = toolpath_tui::run(path, mode, config)?; + if let Some(json) = result { + println!("{json}"); + } + Ok(()) +} diff --git a/crates/toolpath-cli/src/main.rs b/crates/toolpath-cli/src/main.rs index fb6ef38..dc9e283 100644 --- a/crates/toolpath-cli/src/main.rs +++ b/crates/toolpath-cli/src/main.rs @@ -5,6 +5,7 @@ mod cmd_merge; mod cmd_query; mod cmd_render; mod cmd_track; +mod cmd_tui; mod cmd_validate; use anyhow::Result; @@ -64,6 +65,16 @@ enum Commands { #[command(subcommand)] op: cmd_track::TrackOp, }, + /// View a Toolpath Path document interactively + View { + /// Input file (use - for stdin) + input: PathBuf, + }, + /// Interactively redact a Toolpath Path document + Redact { + /// Input file (use - for stdin) + input: PathBuf, + }, /// Validate a Toolpath document Validate { /// Input file @@ -84,6 +95,8 @@ fn main() -> Result<()> { Commands::Render { format } => cmd_render::run(format), Commands::Merge { inputs, title } => cmd_merge::run(inputs, title, cli.pretty), Commands::Track { op } => cmd_track::run(op, cli.pretty), + Commands::View { input } => cmd_tui::run_view(input), + Commands::Redact { input } => cmd_tui::run_redact(input), Commands::Validate { input } => cmd_validate::run(input), Commands::Haiku => { cmd_haiku::run(); diff --git a/crates/toolpath-tui/Cargo.toml b/crates/toolpath-tui/Cargo.toml new file mode 100644 index 0000000..c17f6a9 --- /dev/null +++ b/crates/toolpath-tui/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "toolpath-tui" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository = "https://github.com/empathic/toolpath" +description = "Interactive TUI for viewing and redacting Toolpath documents" +keywords = ["provenance", "toolpath", "tui", "redaction"] +categories = ["command-line-utilities", "visualization"] + +[dependencies] +toolpath = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } +crossterm = { workspace = true } +ratatui = { workspace = true } diff --git a/crates/toolpath-tui/src/app.rs b/crates/toolpath-tui/src/app.rs new file mode 100644 index 0000000..47b4434 --- /dev/null +++ b/crates/toolpath-tui/src/app.rs @@ -0,0 +1,1070 @@ +//! Main application state and event loop for the trace TUI. + +use std::time::Instant; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use ratatui::Terminal; +use ratatui::backend::Backend; + +use crate::TuiConfig; +use crate::model::{self, GraphLine, Mode, SelectionMode, StepEntry, TextRedaction}; +use crate::redact; +use crate::render; +use toolpath::v1; + +/// Tracks an in-progress mouse drag for text selection. +#[derive(Clone)] +struct MouseDrag { + step_index: usize, + json_pointer: String, + start_offset: usize, +} + +/// Undo entry. +#[allow(dead_code)] +enum UndoAction { + ToggleInclude { index: usize, was_included: bool }, + RangeExclude { indices: Vec }, + RangeInclude { indices: Vec }, + IncludeAll { previously_excluded: Vec }, + TextRedact { step_index: usize }, +} + +/// The main trace TUI application. +pub struct TraceTuiApp { + /// Configuration (app name, etc.). + pub config: TuiConfig, + /// The original path (never mutated). + pub path: v1::Path, + /// Display entries. + pub entries: Vec, + /// DAG graph layout. + pub graph_layout: Vec, + /// Cursor position (step index). + pub cursor: usize, + /// Sub-line within the current step: 0 = summary line, 1..N = field lines. + pub sub_line: usize, + /// Scroll offset (in display lines). + pub scroll: usize, + /// Selection mode. + pub selection: SelectionMode, + /// UI mode. + pub mode: Mode, + /// Whether redaction features are enabled. + pub redact_mode: bool, + /// Flash message and timestamp. + pub flash: Option<(String, Instant)>, + /// Character position within the current field value (when sub_line > 0). + pub text_col: usize, + /// Visible height of the step list area (updated each render). + pub visible_height: usize, + /// Visible width of the step list area (updated each render). + pub visible_width: usize, + /// In-progress mouse drag state. + mouse_drag: Option, + /// Undo stack. + undo_stack: Vec, + /// Whether the user exported. + exported: Option, +} + +impl TraceTuiApp { + pub fn new(path: v1::Path, redact_mode: bool, config: TuiConfig) -> Self { + let entries = model::build_entries(&path.steps); + let graph_layout = model::compute_graph_layout(&path.steps, &path.path.head); + Self { + config, + path, + entries, + graph_layout, + cursor: 0, + sub_line: 0, + scroll: 0, + selection: SelectionMode::Normal, + mode: Mode::Normal, + redact_mode, + flash: None, + text_col: 0, + visible_height: 24, + visible_width: 120, + mouse_drag: None, + undo_stack: Vec::new(), + exported: None, + } + } + + /// Run the event loop. Returns the exported JSON string if the user chose to export. + pub fn run(&mut self, terminal: &mut Terminal) -> Result> + where + B::Error: Send + Sync + 'static, + { + loop { + terminal.draw(|frame| render::render(&mut *self, frame))?; + + // Clear expired flash messages. + if let Some((_, ts)) = &self.flash { + if ts.elapsed().as_secs() >= 3 { + self.flash = None; + } + } + + if event::poll(std::time::Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) => { + if self.handle_key(key) { + return Ok(self.exported.take()); + } + } + Event::Mouse(mouse) => { + self.handle_mouse(mouse); + } + _ => {} + } + } + } + } + + /// Handle a key event. Returns true if the app should exit. + fn handle_key(&mut self, key: KeyEvent) -> bool { + match &self.mode { + Mode::Help => { + self.mode = Mode::Normal; + return false; + } + Mode::Search { .. } => { + return self.handle_search_key(key); + } + Mode::Confirm => { + return self.handle_confirm_key(key); + } + Mode::Normal => {} + } + + // Handle visual char mode separately — it intercepts most keys. + if let SelectionMode::Visual { .. } = &self.selection { + return self.handle_visual_char_key(key); + } + + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let multiplier = if shift { 5 } else { 1 }; + + // V (visual line) — redact mode only. + let is_v_upper = key.code == KeyCode::Char('V'); + let is_v_shifted = key.code == KeyCode::Char('v') && shift; + if (is_v_upper || is_v_shifted) && self.redact_mode { + self.selection = SelectionMode::VisualLine { + anchor: self.cursor, + cursor: self.cursor, + }; + return false; + } + + match key.code { + // Quit. + KeyCode::Char('q') | KeyCode::Esc => { + if matches!(self.selection, SelectionMode::VisualLine { .. }) { + self.selection = SelectionMode::Normal; + return false; + } + return true; + } + + // Navigation. + KeyCode::Char('j') | KeyCode::Down => self.move_cursor(multiplier as isize), + KeyCode::Char('k') | KeyCode::Up => self.move_cursor(-(multiplier as isize)), + KeyCode::Char('J') => self.move_cursor(5), + KeyCode::Char('K') => self.move_cursor(-5), + KeyCode::Char('g') => { + self.cursor = 0; + self.sub_line = 0; + self.text_col = 0; + self.ensure_cursor_visible(); + } + KeyCode::Char('G') => { + self.cursor = self.entries.len().saturating_sub(1); + self.sub_line = 0; + self.text_col = 0; + self.ensure_cursor_visible(); + } + + // h/l: text cursor when inside expanded content, expand/collapse on summary line. + KeyCode::Char('h') | KeyCode::Left if self.sub_line > 0 => { + self.text_col = self.text_col.saturating_sub(multiplier); + } + KeyCode::Char('l') | KeyCode::Right if self.sub_line > 0 => { + if let Some(len) = self.current_field_value_len() { + self.text_col = (self.text_col + multiplier).min(len); + } + } + KeyCode::Char('l') | KeyCode::Right | KeyCode::Enter => { + // On summary line: expand. + if let Some(entry) = self.entries.get_mut(self.cursor) { + entry.expanded = true; + } + } + KeyCode::Char('h') | KeyCode::Left | KeyCode::Backspace => { + // On summary line: collapse. + if let Some(entry) = self.entries.get_mut(self.cursor) { + entry.expanded = false; + } + self.sub_line = 0; + self.text_col = 0; + } + + // Visual char mode — v (lowercase, no shift), redact mode only. + KeyCode::Char('v') if self.redact_mode => { + self.enter_visual_char_mode(); + } + + // Help. + KeyCode::Char('?') => { + self.mode = Mode::Help; + } + + // Search. + KeyCode::Char('/') => { + self.mode = Mode::Search { + query: String::new(), + cursor: 0, + matches: Vec::new(), + }; + } + KeyCode::Char('n') => self.next_search_match(false), + KeyCode::Char('N') => self.next_search_match(true), + + // Redaction action keys — redact mode only. + _ if self.redact_mode => self.handle_redact_key(key), + _ => {} + } + false + } + + fn handle_redact_key(&mut self, key: KeyEvent) { + match key.code { + // Toggle include/exclude. + KeyCode::Char(' ') => { + match &self.selection { + SelectionMode::Normal => { + self.toggle_include(self.cursor); + } + SelectionMode::VisualLine { anchor, cursor } => { + let lo = (*anchor).min(*cursor); + let hi = (*anchor).max(*cursor); + let indices: Vec = (lo..=hi).collect(); + // Toggle: if any are included, exclude all; otherwise include all. + let any_included = indices.iter().any(|&i| self.entries[i].included); + for &i in &indices { + self.entries[i].included = !any_included; + } + if any_included { + self.undo_stack.push(UndoAction::RangeExclude { indices }); + } else { + self.undo_stack.push(UndoAction::RangeInclude { indices }); + } + self.selection = SelectionMode::Normal; + } + _ => {} + } + } + + // Exclude. + KeyCode::Char('d') => self.exclude_action(), + + // Include. + KeyCode::Char('i') => self.include_action(), + + // Exclude everything except selection (visual line). + KeyCode::Char('D') if matches!(self.selection, SelectionMode::VisualLine { .. }) => { + if let SelectionMode::VisualLine { anchor, cursor } = &self.selection { + let lo = (*anchor).min(*cursor); + let hi = (*anchor).max(*cursor); + let mut excluded = Vec::new(); + for i in 0..self.entries.len() { + if i < lo || i > hi { + if self.entries[i].included { + self.entries[i].included = false; + excluded.push(i); + } + } + } + self.undo_stack + .push(UndoAction::RangeExclude { indices: excluded }); + self.selection = SelectionMode::Normal; + self.flash("Kept only selected range"); + } + } + + // Exclude to end. + KeyCode::Char('D') => { + let mut excluded = Vec::new(); + for i in self.cursor..self.entries.len() { + if self.entries[i].included { + self.entries[i].included = false; + excluded.push(i); + } + } + self.undo_stack + .push(UndoAction::RangeExclude { indices: excluded }); + self.flash("Excluded to end"); + } + + // Exclude to beginning. + KeyCode::Char('B') => { + let mut excluded = Vec::new(); + for i in 0..=self.cursor { + if self.entries[i].included { + self.entries[i].included = false; + excluded.push(i); + } + } + self.undo_stack + .push(UndoAction::RangeExclude { indices: excluded }); + self.flash("Excluded to beginning"); + } + + // Include all. + KeyCode::Char('a') => { + let previously_excluded: Vec = self + .entries + .iter() + .enumerate() + .filter(|(_, e)| !e.included) + .map(|(i, _)| i) + .collect(); + for entry in &mut self.entries { + entry.included = true; + } + self.undo_stack.push(UndoAction::IncludeAll { + previously_excluded, + }); + self.flash("Included all steps"); + } + + // Undo. + KeyCode::Char('u') => self.undo(), + + // Export. + KeyCode::Char('e') => { + self.mode = Mode::Confirm; + } + + _ => {} + } + } + + fn handle_confirm_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Enter => { + let doc = redact::build_redacted_document(&self.path, &self.entries); + match doc.to_json_pretty() { + Ok(json) => { + self.exported = Some(json); + return true; + } + Err(e) => { + self.flash(&format!("Export error: {e}")); + self.mode = Mode::Normal; + } + } + } + KeyCode::Esc | KeyCode::Char('q') => { + self.mode = Mode::Normal; + } + _ => {} + } + false + } + + fn handle_search_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Enter => { + // Finalize search, jump to first match. + if let Mode::Search { matches, .. } = &self.mode { + if let Some(&first) = matches.first() { + self.cursor = first; + self.ensure_cursor_visible(); + } + } + self.mode = Mode::Normal; + } + KeyCode::Esc => { + self.mode = Mode::Normal; + } + KeyCode::Backspace => { + if let Mode::Search { query, .. } = &mut self.mode { + query.pop(); + } + self.update_search_matches(); + } + KeyCode::Char(c) => { + if let Mode::Search { query, .. } = &mut self.mode { + query.push(c); + } + self.update_search_matches(); + } + _ => {} + } + false + } + + fn update_search_matches(&mut self) { + if let Mode::Search { query, matches, .. } = &mut self.mode { + let q = query.to_lowercase(); + *matches = self + .entries + .iter() + .enumerate() + .filter(|(_, e)| { + e.summary.to_lowercase().contains(&q) + || e.actor_label.to_lowercase().contains(&q) + || e.step.step.actor.to_lowercase().contains(&q) + }) + .map(|(i, _)| i) + .collect(); + } + } + + fn next_search_match(&mut self, reverse: bool) { + let matches = match &self.mode { + Mode::Search { matches, .. } => matches.clone(), + _ => { + // Use last search matches if available. + return; + } + }; + if matches.is_empty() { + return; + } + if reverse { + let prev = matches.iter().rev().find(|&&i| i < self.cursor); + self.cursor = prev.copied().unwrap_or(*matches.last().unwrap()); + } else { + let next = matches.iter().find(|&&i| i > self.cursor); + self.cursor = next.copied().unwrap_or(matches[0]); + } + self.ensure_cursor_visible(); + } + + fn handle_mouse(&mut self, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollUp => self.move_cursor(-3), + MouseEventKind::ScrollDown => self.move_cursor(3), + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + let row = mouse.row as usize; + let col = mouse.column as usize; + if row < 1 { + return; + } + let display_line = self.scroll + row - 1; + let shift = mouse.modifiers.contains(KeyModifiers::SHIFT); + + if let Some((step_idx, sub)) = self.display_line_to_cursor(display_line) { + self.cursor = step_idx; + self.sub_line = sub; + + // If clicking on a field line, start text drag. + if sub > 0 { + if let Some(fl) = self.get_field_line_at(step_idx, sub) { + if let Some(ptr) = fl.json_pointer.clone() { + let char_offset = self.col_to_char_offset(col, &fl); + self.mouse_drag = Some(MouseDrag { + step_index: step_idx, + json_pointer: ptr.clone(), + start_offset: char_offset, + }); + self.selection = SelectionMode::Visual { + step_index: step_idx, + json_pointer: ptr, + anchor: char_offset, + cursor: char_offset, + }; + return; + } + } + } + + // Step-level click. + if shift { + match &self.selection { + SelectionMode::VisualLine { anchor, .. } => { + self.selection = SelectionMode::VisualLine { + anchor: *anchor, + cursor: step_idx, + }; + } + _ => { + self.selection = SelectionMode::VisualLine { + anchor: step_idx, + cursor: step_idx, + }; + } + } + } else { + self.selection = SelectionMode::Normal; + } + } + } + MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { + if let Some(ref drag) = self.mouse_drag.clone() { + let row = mouse.row as usize; + let col = mouse.column as usize; + if row < 1 { + return; + } + let display_line = self.scroll + row - 1; + if let Some((step_idx, sub)) = self.display_line_to_cursor(display_line) { + if step_idx == drag.step_index && sub > 0 { + if let Some(fl) = self.get_field_line_at(step_idx, sub) { + if fl.json_pointer.as_deref() == Some(&drag.json_pointer) { + let char_offset = self.col_to_char_offset(col, &fl); + self.selection = SelectionMode::Visual { + step_index: drag.step_index, + json_pointer: drag.json_pointer.clone(), + anchor: drag.start_offset, + cursor: char_offset, + }; + } + } + } + } + } + } + MouseEventKind::Up(crossterm::event::MouseButton::Left) => { + self.mouse_drag = None; + } + _ => {} + } + } + + /// Get the FieldLine at a given (step_index, sub_line). sub_line must be >= 1. + fn get_field_line_at(&self, step_idx: usize, sub_line: usize) -> Option { + if sub_line == 0 { + return None; + } + let field_lines = + model::build_field_lines(&self.entries[step_idx].step, self.field_wrap_width()); + field_lines.into_iter().nth(sub_line - 1) + } + + /// Convert a terminal column to a char offset within a field line's value. + fn col_to_char_offset(&self, col: usize, fl: &model::FieldLine) -> usize { + let indent_len: usize = 9; // matches render indent + // fl.text is like " key: value" or " value_line". + // The value starts at the label end. + let label_end = fl.text.find(": ").map(|p| p + 2).unwrap_or(0); + let value_col_start = indent_len + label_end; + if col > value_col_start { + fl.value_offset + (col - value_col_start).min(fl.value_len) + } else { + fl.value_offset + } + } + + /// Move cursor by `delta` display lines (positive = down, negative = up). + fn move_cursor(&mut self, delta: isize) { + let abs = delta.unsigned_abs(); + let down = delta > 0; + for _ in 0..abs { + if down { + self.move_cursor_down(); + } else { + self.move_cursor_up(); + } + } + + // Update visual line cursor if in visual line mode. + if let SelectionMode::VisualLine { cursor, .. } = &mut self.selection { + *cursor = self.cursor; + } + + self.ensure_cursor_visible(); + } + + fn move_cursor_down(&mut self) { + let entry = &self.entries[self.cursor]; + if entry.expanded { + let n = self.field_line_count(self.cursor); + if self.sub_line < n { + self.sub_line += 1; + self.text_col = 0; + return; + } + } + // Move to next step. + if self.cursor + 1 < self.entries.len() { + self.cursor += 1; + self.sub_line = 0; + self.text_col = 0; + } + } + + fn move_cursor_up(&mut self) { + if self.sub_line > 0 { + self.sub_line -= 1; + self.text_col = 0; + return; + } + // Move to previous step. + if self.cursor > 0 { + self.cursor -= 1; + let entry = &self.entries[self.cursor]; + if entry.expanded { + self.sub_line = self.field_line_count(self.cursor); + } else { + self.sub_line = 0; + } + self.text_col = 0; + } + } + + /// Length of the field value at the current cursor position, if on a field line. + fn current_field_value_len(&self) -> Option { + if self.sub_line == 0 { + return None; + } + let fl = self.get_field_line_at(self.cursor, self.sub_line)?; + let ptr = fl.json_pointer.as_ref()?; + self.get_field_value(&self.cursor, ptr).map(|v| v.len()) + } + + /// Max width for value text in field lines (accounts for indent + label space). + fn field_wrap_width(&self) -> usize { + self.visible_width.saturating_sub(20).max(40) // indent(9) + label(~10) + margin + } + + /// Number of field lines for a step (0 if not expanded). + fn field_line_count(&self, step_idx: usize) -> usize { + model::build_field_lines(&self.entries[step_idx].step, self.field_wrap_width()).len() + } + + fn ensure_cursor_visible(&mut self) { + let cursor_line = self.cursor_display_line(); + if cursor_line < self.scroll { + self.scroll = cursor_line; + } else if cursor_line >= self.scroll + self.visible_height { + self.scroll = cursor_line.saturating_sub(self.visible_height.saturating_sub(1)); + } + } + + /// Compute the display line index for the current cursor position. + fn cursor_display_line(&self) -> usize { + let mut line = 0; + for (i, entry) in self.entries.iter().enumerate() { + if i == self.cursor { + return line + self.sub_line; + } + line += 1; // summary line + if entry.expanded { + line += self.field_line_count(i); + } + } + line + } + + /// Map a display line to (step_index, sub_line). + fn display_line_to_cursor(&self, target: usize) -> Option<(usize, usize)> { + let mut line = 0; + for (i, entry) in self.entries.iter().enumerate() { + if line == target { + return Some((i, 0)); + } + line += 1; + if entry.expanded { + let n = self.field_line_count(i); + for sub in 0..n { + if line == target { + return Some((i, sub + 1)); + } + line += 1; + } + } + } + None + } + + fn toggle_include(&mut self, idx: usize) { + if idx < self.entries.len() { + let was_included = self.entries[idx].included; + self.entries[idx].included = !was_included; + self.undo_stack.push(UndoAction::ToggleInclude { + index: idx, + was_included, + }); + } + } + + fn exclude_action(&mut self) { + match &self.selection { + SelectionMode::VisualLine { anchor, cursor } => { + let lo = (*anchor).min(*cursor); + let hi = (*anchor).max(*cursor); + let indices: Vec = (lo..=hi).filter(|&i| self.entries[i].included).collect(); + for &i in &indices { + self.entries[i].included = false; + } + self.undo_stack.push(UndoAction::RangeExclude { indices }); + self.selection = SelectionMode::Normal; + } + SelectionMode::Normal => { + if self.entries[self.cursor].included { + self.toggle_include(self.cursor); + } + } + _ => {} + } + } + + fn include_action(&mut self) { + match &self.selection { + SelectionMode::VisualLine { anchor, cursor } => { + let lo = (*anchor).min(*cursor); + let hi = (*anchor).max(*cursor); + let indices: Vec = + (lo..=hi).filter(|&i| !self.entries[i].included).collect(); + for &i in &indices { + self.entries[i].included = true; + } + self.undo_stack.push(UndoAction::RangeInclude { indices }); + self.selection = SelectionMode::Normal; + } + SelectionMode::Normal => { + if !self.entries[self.cursor].included { + self.toggle_include(self.cursor); + } + } + _ => {} + } + } + + /// Enter visual char mode on the field under the cursor (or first field if on summary). + /// Auto-expands the step if collapsed. + fn enter_visual_char_mode(&mut self) { + let idx = self.cursor; + if idx >= self.entries.len() { + return; + } + // Auto-expand if collapsed. + if !self.entries[idx].expanded { + self.entries[idx].expanded = true; + self.sub_line = 1; + self.text_col = 0; + } + let field_lines = + model::build_field_lines(&self.entries[idx].step, self.field_wrap_width()); + + // If sub_line > 0, try to use that specific field line. + let target_fl = if self.sub_line > 0 && self.sub_line <= field_lines.len() { + let fl = &field_lines[self.sub_line - 1]; + if fl.json_pointer.is_some() { + Some(fl) + } else { + // On a structural label — find the next field with a pointer. + field_lines[self.sub_line..] + .iter() + .find(|fl| fl.json_pointer.is_some()) + } + } else { + // On summary line — use first string field. + field_lines.iter().find(|fl| fl.json_pointer.is_some()) + }; + + if let Some(fl) = target_fl { + let ptr = fl.json_pointer.as_ref().unwrap().clone(); + self.selection = SelectionMode::Visual { + step_index: idx, + json_pointer: ptr, + anchor: self.text_col, + cursor: self.text_col, + }; + } else { + self.flash("No editable fields"); + } + } + + /// Handle keys while in visual char mode. All movement extends selection. + fn handle_visual_char_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + // Exit visual mode, preserve text_col from cursor position. + if let SelectionMode::Visual { cursor, .. } = &self.selection { + self.text_col = *cursor; + } + self.selection = SelectionMode::Normal; + } + // Move cursor within the value — always extends selection. + KeyCode::Char('l') | KeyCode::Right => { + self.visual_char_move(1); + } + KeyCode::Char('h') | KeyCode::Left => { + self.visual_char_move(-1); + } + // Move to next/prev field. + KeyCode::Char('j') | KeyCode::Down => { + self.visual_char_next_field(1); + } + KeyCode::Char('k') | KeyCode::Up => { + self.visual_char_next_field(-1); + } + // Word-wise movement. + KeyCode::Char('w') => { + self.visual_char_word_forward(); + } + KeyCode::Char('b') => { + self.visual_char_word_backward(); + } + // Select all in current field. + KeyCode::Char('A') => { + self.visual_char_select_all(); + } + // Home / End. + KeyCode::Char('0') | KeyCode::Home => { + self.visual_char_set_cursor(0); + } + KeyCode::Char('$') | KeyCode::End => { + if let Some(len) = self.visual_char_field_len() { + self.visual_char_set_cursor(len); + } + } + // Apply redaction. + KeyCode::Char('d') | KeyCode::Char('x') => { + self.apply_visual_redaction(); + } + _ => {} + } + false + } + + /// Move visual mode cursor by delta. Anchor stays fixed (always extends). + fn visual_char_move(&mut self, delta: isize) { + if let SelectionMode::Visual { + step_index, + json_pointer, + anchor, + cursor, + } = self.selection.clone() + { + let len = self.visual_char_field_len_for(&step_index, &json_pointer); + let new_cursor = (cursor as isize + delta).max(0).min(len as isize) as usize; + self.selection = SelectionMode::Visual { + step_index, + json_pointer, + anchor, + cursor: new_cursor, + }; + } + } + + /// Set visual mode cursor to a specific position. Anchor stays fixed. + fn visual_char_set_cursor(&mut self, pos: usize) { + if let SelectionMode::Visual { + step_index, + json_pointer, + anchor, + .. + } = self.selection.clone() + { + self.selection = SelectionMode::Visual { + step_index, + json_pointer, + anchor, + cursor: pos, + }; + } + } + + fn visual_char_select_all(&mut self) { + if let SelectionMode::Visual { + step_index, + json_pointer, + .. + } = self.selection.clone() + { + let len = self.visual_char_field_len_for(&step_index, &json_pointer); + self.selection = SelectionMode::Visual { + step_index, + json_pointer, + anchor: 0, + cursor: len, + }; + } + } + + fn visual_char_word_forward(&mut self) { + if let SelectionMode::Visual { + step_index, + ref json_pointer, + cursor, + .. + } = self.selection.clone() + { + if let Some(value) = self.get_field_value(&step_index, &json_pointer) { + let chars: Vec = value.chars().collect(); + let mut pos = cursor; + while pos < chars.len() && !chars[pos].is_whitespace() { + pos += 1; + } + while pos < chars.len() && chars[pos].is_whitespace() { + pos += 1; + } + self.visual_char_set_cursor(pos); + } + } + } + + fn visual_char_word_backward(&mut self) { + if let SelectionMode::Visual { + step_index, + ref json_pointer, + cursor, + .. + } = self.selection.clone() + { + if let Some(value) = self.get_field_value(&step_index, &json_pointer) { + let chars: Vec = value.chars().collect(); + let mut pos = cursor; + while pos > 0 && chars[pos.saturating_sub(1)].is_whitespace() { + pos -= 1; + } + while pos > 0 && !chars[pos.saturating_sub(1)].is_whitespace() { + pos -= 1; + } + self.visual_char_set_cursor(pos); + } + } + } + + fn visual_char_next_field(&mut self, direction: isize) { + if let SelectionMode::Visual { + step_index, + ref json_pointer, + .. + } = self.selection.clone() + { + let field_lines = + model::build_field_lines(&self.entries[step_index].step, self.field_wrap_width()); + let string_fields: Vec<&model::FieldLine> = field_lines + .iter() + .filter(|fl| fl.json_pointer.is_some() && fl.value_offset == 0) + .collect(); + + let current_idx = string_fields + .iter() + .position(|fl| fl.json_pointer.as_deref() == Some(&json_pointer)); + + if let Some(idx) = current_idx { + let new_idx = (idx as isize + direction) + .max(0) + .min(string_fields.len() as isize - 1) as usize; + if let Some(new_ptr) = &string_fields[new_idx].json_pointer { + self.selection = SelectionMode::Visual { + step_index, + json_pointer: new_ptr.clone(), + anchor: 0, + cursor: 0, + }; + } + } + } + } + + fn visual_char_field_len(&self) -> Option { + if let SelectionMode::Visual { + step_index, + ref json_pointer, + .. + } = self.selection + { + Some(self.visual_char_field_len_for(&step_index, json_pointer)) + } else { + None + } + } + + fn visual_char_field_len_for(&self, step_index: &usize, json_pointer: &str) -> usize { + self.get_field_value(step_index, json_pointer) + .map(|v| v.len()) + .unwrap_or(0) + } + + fn get_field_value(&self, step_index: &usize, json_pointer: &str) -> Option { + let entry = self.entries.get(*step_index)?; + let value = serde_json::to_value(&entry.step).ok()?; + let target = value.pointer(json_pointer)?; + target.as_str().map(|s| s.to_string()) + } + + fn apply_visual_redaction(&mut self) { + if let SelectionMode::Visual { + step_index, + ref json_pointer, + anchor, + cursor, + } = self.selection.clone() + { + // Selection is inclusive: [min, max]. Convert to exclusive [start, end) for storage. + let sel_start = anchor.min(cursor); + let sel_end = anchor.max(cursor) + 1; // +1: inclusive to exclusive + + // Get the original text for undo. + let original = match self.get_field_value(&step_index, &json_pointer) { + Some(v) if sel_end <= v.len() => v[sel_start..sel_end].to_string(), + _ => return, + }; + + self.entries[step_index] + .text_redactions + .push(TextRedaction { + json_pointer: json_pointer.clone(), + start: sel_start, + end: sel_end, + original, + }); + self.undo_stack.push(UndoAction::TextRedact { step_index }); + self.selection = SelectionMode::Normal; + + let count = sel_end - sel_start; + self.flash(&format!("Redacted {count} chars")); + } + } + + fn undo(&mut self) { + if let Some(action) = self.undo_stack.pop() { + match action { + UndoAction::ToggleInclude { + index, + was_included, + } => { + self.entries[index].included = was_included; + } + UndoAction::RangeExclude { indices } => { + for i in indices { + self.entries[i].included = true; + } + } + UndoAction::RangeInclude { indices } => { + for i in indices { + self.entries[i].included = false; + } + } + UndoAction::IncludeAll { + previously_excluded, + } => { + for i in previously_excluded { + self.entries[i].included = false; + } + } + UndoAction::TextRedact { step_index } => { + if let Some(entry) = self.entries.get_mut(step_index) { + entry.text_redactions.pop(); + } + } + } + self.flash("Undone"); + } + } + + fn flash(&mut self, msg: &str) { + self.flash = Some((msg.to_string(), Instant::now())); + } +} diff --git a/crates/toolpath-tui/src/lib.rs b/crates/toolpath-tui/src/lib.rs new file mode 100644 index 0000000..02d80ac --- /dev/null +++ b/crates/toolpath-tui/src/lib.rs @@ -0,0 +1,76 @@ +//! Interactive TUI for viewing and redacting Toolpath Path documents. +//! +//! Provides a ratatui-based terminal UI for browsing toolpath traces with: +//! - Navigable step list with DAG graph visualization +//! - Expandable/collapsible step detail view +//! - Visual line mode for excluding steps +//! - Visual character mode for redacting text within fields +//! - Export to stdout with redacted steps collapsed into placeholders +//! +//! Uses stderr for TUI rendering so stdout stays clean for piped JSON output. + +pub mod app; +pub mod model; +pub mod redact; +pub mod render; + +use anyhow::Result; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use toolpath::v1; + +/// TUI mode. +pub enum TuiMode { + /// Read-only viewer. + View, + /// Interactive redaction. + Redact, +} + +/// Configuration for the TUI title bar. +pub struct TuiConfig { + /// Application name shown in the title (e.g. "clash trace", "path"). + pub app_name: String, +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + app_name: "toolpath".to_string(), + } + } +} + +/// Launch the trace TUI. +/// +/// Returns `Ok(Some(json))` if the user exported a redacted trace, `Ok(None)` if they quit. +pub fn run(path: v1::Path, mode: TuiMode, config: TuiConfig) -> Result> { + let redact_mode = matches!(mode, TuiMode::Redact); + let mut tui_app = app::TraceTuiApp::new(path, redact_mode, config); + + // Setup terminal on stderr so stdout stays clean for piped output. + enable_raw_mode()?; + let mut stderr = std::io::stderr(); + execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stderr); + let mut terminal = Terminal::new(backend)?; + + // Run the app. + let result = tui_app.run(&mut terminal); + + // Restore terminal. + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} diff --git a/crates/toolpath-tui/src/model.rs b/crates/toolpath-tui/src/model.rs new file mode 100644 index 0000000..0e84bfd --- /dev/null +++ b/crates/toolpath-tui/src/model.rs @@ -0,0 +1,1136 @@ +//! Data types and summary generation for the trace TUI. + +use std::collections::{HashMap, HashSet}; + +use toolpath::v1::{self, query}; + +/// Display-oriented wrapper around a toolpath Step. +pub struct StepEntry { + pub index: usize, + pub step: v1::Step, + /// One-line collapsed display. + pub summary: String, + /// Actor label: "user", "assistant", "policy", or the raw actor string. + pub actor_label: String, + /// Whether this step is included in output (default: true). + pub included: bool, + /// Whether the step detail is expanded. + pub expanded: bool, + /// Per-field text redactions within this step. + pub text_redactions: Vec, +} + +/// A text redaction within a step's JSON. +pub struct TextRedaction { + /// JSON Pointer (RFC 6901) to the string value within the step JSON. + pub json_pointer: String, + /// Byte offset start within the string value. + pub start: usize, + /// Byte offset end within the string value. + pub end: usize, + /// The original text that was replaced (for undo). + pub original: String, +} + +/// Selection mode for the TUI. +#[derive(Clone)] +pub enum SelectionMode { + Normal, + /// Visual line mode — selects whole steps. Range is min..=max of anchor,cursor. + VisualLine { + anchor: usize, + cursor: usize, + }, + /// Visual character mode — selects text within an expanded step's value. + Visual { + step_index: usize, + /// JSON Pointer (RFC 6901) to the string value being edited. + json_pointer: String, + /// Selection start (char offset in rendered value). + anchor: usize, + /// Current cursor position. + cursor: usize, + }, +} + +/// UI mode. +pub enum Mode { + Normal, + Help, + Search { + query: String, + cursor: usize, + matches: Vec, + }, + Confirm, +} + +/// A single line of the expanded field view, mapped back to a JSON Pointer. +#[derive(Clone, Debug)] +pub struct FieldLine { + /// JSON Pointer to the string value this line belongs to, or None for structural labels. + pub json_pointer: Option, + /// The display text for this line. + pub text: String, + /// Character offset within the full string value where this line starts. + /// Only meaningful when json_pointer is Some. + pub value_offset: usize, + /// Length of the value content on this line (excluding label prefix). + pub value_len: usize, +} + +/// Build a flat list of FieldLines from a step's JSON for the expanded view. +/// `wrap_width` is the max character width for value content before wrapping. +pub fn build_field_lines(step: &v1::Step, wrap_width: usize) -> Vec { + let value = match serde_json::to_value(step) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + let mut lines = Vec::new(); + walk_value(&value, "", "", wrap_width, &mut lines); + lines +} + +fn walk_value( + value: &serde_json::Value, + pointer: &str, + label: &str, + wrap_width: usize, + lines: &mut Vec, +) { + match value { + serde_json::Value::Object(map) => { + if !label.is_empty() { + lines.push(FieldLine { + json_pointer: None, + text: format!("{label}:"), + value_offset: 0, + value_len: 0, + }); + } + // Sort keys for deterministic ordering. + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for key in keys { + let val = &map[key]; + let child_pointer = format!("{pointer}/{}", escape_json_pointer(key)); + let child_label = if label.is_empty() { + key.clone() + } else { + format!(" {key}") + }; + walk_value(val, &child_pointer, &child_label, wrap_width, lines); + } + } + serde_json::Value::Array(arr) => { + if !label.is_empty() { + lines.push(FieldLine { + json_pointer: None, + text: format!("{label}:"), + value_offset: 0, + value_len: 0, + }); + } + for (i, val) in arr.iter().enumerate() { + let child_pointer = format!("{pointer}/{i}"); + let child_label = format!(" [{i}]"); + walk_value(val, &child_pointer, &child_label, wrap_width, lines); + } + } + serde_json::Value::String(s) => { + // Split into lines first (handles actual newlines in the string). + let s_lines: Vec<&str> = s.lines().collect(); + let is_multiline = s_lines.len() > 1; + + // Check if any line needs wrapping. + let label_overhead = label.len() + 2; // ": " + let first_line_width = wrap_width.saturating_sub(label_overhead); + let continuation_width = wrap_width.saturating_sub(4); // " " indent + let needs_wrap = if is_multiline { + true + } else { + s.len() > first_line_width + }; + + if !needs_wrap { + // Fits on one line with label. + let display = s.replace('\t', " "); + let value_len = display.len(); + lines.push(FieldLine { + json_pointer: Some(pointer.to_string()), + text: format!("{label}: {display}"), + value_offset: 0, + value_len, + }); + } else { + // Show label with |, then wrapped content lines. + lines.push(FieldLine { + json_pointer: None, + text: format!("{label}: |"), + value_offset: 0, + value_len: 0, + }); + let mut offset = 0; + for (line_idx, line) in s_lines.iter().enumerate() { + let display = line.replace('\t', " "); + // Wrap this line if it's too long. + if display.len() <= continuation_width { + let value_len = display.len(); + lines.push(FieldLine { + json_pointer: Some(pointer.to_string()), + text: format!(" {display}"), + value_offset: offset, + value_len, + }); + } else { + // Wrap at continuation_width. + let mut chunk_start = 0; + while chunk_start < display.len() { + let chunk_end = (chunk_start + continuation_width).min(display.len()); + let chunk = &display[chunk_start..chunk_end]; + lines.push(FieldLine { + json_pointer: Some(pointer.to_string()), + text: format!(" {chunk}"), + value_offset: offset + chunk_start, + value_len: chunk.len(), + }); + chunk_start = chunk_end; + } + } + offset += line.len(); + if line_idx < s_lines.len() - 1 { + offset += 1; // +1 for the newline between lines + } + } + } + } + serde_json::Value::Number(n) => { + let display = n.to_string(); + lines.push(FieldLine { + json_pointer: Some(pointer.to_string()), + text: format!("{label}: {display}"), + value_offset: 0, + value_len: display.len(), + }); + } + serde_json::Value::Bool(b) => { + let display = b.to_string(); + lines.push(FieldLine { + json_pointer: Some(pointer.to_string()), + text: format!("{label}: {display}"), + value_offset: 0, + value_len: display.len(), + }); + } + serde_json::Value::Null => { + lines.push(FieldLine { + json_pointer: Some(pointer.to_string()), + text: format!("{label}: null"), + value_offset: 0, + value_len: 4, + }); + } + } +} + +fn escape_json_pointer(s: &str) -> String { + s.replace('~', "~0").replace('/', "~1") +} + +/// Characters for a single row's DAG graph column. +pub struct GraphLine { + /// The characters to render for this row's graph column. + pub segments: Vec, +} + +/// A single segment in a graph line. +pub struct GraphSegment { + pub ch: &'static str, + pub is_dead_end: bool, +} + +/// Build step entries from a path's steps. +pub fn build_entries(steps: &[v1::Step]) -> Vec { + steps + .iter() + .enumerate() + .map(|(i, step)| { + let actor_label = actor_label(&step.step.actor); + let summary = build_summary(step); + StepEntry { + index: i, + step: step.clone(), + summary, + actor_label, + included: true, + expanded: false, + text_redactions: Vec::new(), + } + }) + .collect() +} + +/// Derive a short actor label from the actor string. +fn actor_label(actor: &str) -> String { + if actor.starts_with("human:") { + "user".to_string() + } else if actor.starts_with("agent:clash-policy") { + "policy".to_string() + } else if actor.starts_with("agent:") { + "assistant".to_string() + } else if actor.starts_with("tool:") { + actor.strip_prefix("tool:").unwrap_or(actor).to_string() + } else { + actor.to_string() + } +} + +/// Build a one-line summary from a step. +fn build_summary(step: &v1::Step) -> String { + let actor = &step.step.actor; + + // Try to extract text from conversation-style structural changes. + if actor.starts_with("human:") + || actor.starts_with("agent:claude") + || actor.starts_with("agent:cc") + { + // Look for conversation.append structural changes. + for change in step.change.values() { + if let Some(ref structural) = change.structural { + if structural.change_type == "conversation.append" { + return build_conversation_summary(structural, actor); + } + } + } + // Fallback: try raw text from any change. + for change in step.change.values() { + if let Some(ref raw) = change.raw { + return truncate_first_line(raw, 80); + } + } + } + + // Policy steps. + if actor == "agent:clash-policy" { + for change in step.change.values() { + if let Some(ref structural) = change.structural { + if structural.change_type == "policy_evaluation" { + let effect = structural + .extra + .get("effect") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let tool = structural + .extra + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + return format!("{effect} {tool}").trim().to_string(); + } + } + } + } + + // Fallback. + if let Some(ref meta) = step.meta { + if let Some(ref intent) = meta.intent { + return truncate_first_line(intent, 80); + } + } + format!("{} — {}", actor, step.step.id) +} + +/// Build summary from a conversation.append structural change. +fn build_conversation_summary(structural: &v1::StructuralChange, actor: &str) -> String { + let text = structural + .extra + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let tool_uses: Vec<&str> = structural + .extra + .get("tool_uses") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + if !tool_uses.is_empty() && actor.starts_with("agent:") { + let tools = format!("[{}]", tool_uses.join(", ")); + let text_part = truncate_first_line(text, 80 - tools.len().min(60)); + if text_part.is_empty() { + tools + } else { + format!("{tools} {text_part}") + } + } else { + truncate_first_line(text, 80) + } +} + +fn truncate_first_line(s: &str, max: usize) -> String { + let first_line = s.lines().next().unwrap_or("").trim(); + if first_line.len() <= max { + first_line.to_string() + } else { + let truncated: String = first_line.chars().take(max.saturating_sub(1)).collect(); + format!("{truncated}…") + } +} + +/// Compute the DAG graph layout for a list of steps. +/// +/// Returns one `GraphLine` per step with multi-column segments showing the DAG structure. +/// Main path stays in column 0; dead-end branches get offset columns. +pub fn compute_graph_layout(steps: &[v1::Step], head_id: &str) -> Vec { + if steps.is_empty() { + return Vec::new(); + } + + let ancestor_ids = query::ancestors(steps, head_id); + + // Build children map: parent_id -> vec of child step indices. + let mut children_of: HashMap<&str, Vec> = HashMap::new(); + for (i, step) in steps.iter().enumerate() { + for parent in &step.step.parents { + children_of.entry(parent.as_str()).or_default().push(i); + } + } + let has_children: HashSet<&str> = children_of.keys().copied().collect(); + + // Column assignment + rendering in a single pass. + // Freed columns are reused by subsequent branches. + let mut col_of: HashMap<&str, usize> = HashMap::new(); + let mut active: HashSet = HashSet::new(); + let mut free_cols: Vec = Vec::new(); // available for reuse + let mut next_col: usize = 1; + let mut result = Vec::with_capacity(steps.len()); + + // Root gets column 0. + if let Some(first) = steps.first() { + col_of.insert(&first.step.id, 0); + } + + for step in steps { + let id = step.step.id.as_str(); + + // Ensure this step has a column assigned. + if !col_of.contains_key(id) { + let parent_col = step + .step + .parents + .first() + .and_then(|p| col_of.get(p.as_str()).copied()) + .unwrap_or(0); + col_of.insert(id, parent_col); + } + + let my_col = col_of[id]; + let is_on_main = ancestor_ids.contains(id); + let is_dead = !is_on_main; + let is_leaf = !has_children.contains(id); + let has_branch = children_of.get(id).map_or(false, |c| c.len() > 1); + + // If this step merges AND branches, pre-free merged columns so + // branch children can reuse them immediately. + if step.step.parents.len() > 1 && children_of.get(id).map_or(false, |k| k.len() > 1) { + for parent in &step.step.parents { + let parent_col = col_of.get(parent.as_str()).copied().unwrap_or(0); + if parent_col != my_col && !free_cols.contains(&parent_col) { + free_cols.push(parent_col); + } + } + } + + // Assign columns to children — reuse freed columns. + if let Some(kids) = children_of.get(id) { + if kids.len() == 1 { + let kid_id = steps[kids[0]].step.id.as_str(); + col_of.entry(kid_id).or_insert(my_col); + } else { + let main_child_idx = kids + .iter() + .find(|&&i| ancestor_ids.contains(&steps[i].step.id)); + for &kid_idx in kids { + let kid_id = steps[kid_idx].step.id.as_str(); + if Some(&kid_idx) == main_child_idx { + col_of.entry(kid_id).or_insert(my_col); + } else if !col_of.contains_key(kid_id) { + // Reuse a freed column or allocate a new one. + let col = if let Some(c) = free_cols.pop() { + c + } else { + let c = next_col; + next_col += 1; + c + }; + col_of.insert(kid_id, col); + } + } + } + } + + // This step's column becomes active. + active.insert(my_col); + + // Columns being merged into this step (will be deactivated after this row). + let merging_cols: HashSet = if step.step.parents.len() > 1 { + step.step + .parents + .iter() + .filter_map(|p| col_of.get(p.as_str()).copied()) + .filter(|&c| c != my_col) + .collect() + } else { + HashSet::new() + }; + + // For branches, find new child columns so we can draw horizontal connectors. + let branch_targets: Vec = if has_branch { + let mut targets: Vec = children_of + .get(id) + .into_iter() + .flatten() + .map(|&kid_idx| col_of[steps[kid_idx].step.id.as_str()]) + .filter(|&c| c != my_col) + .collect(); + targets.sort(); + targets.dedup(); + targets + } else { + Vec::new() + }; + let rightmost_branch = branch_targets.last().copied(); + let branch_target_set: HashSet = branch_targets.iter().copied().collect(); + + // For merges, find the rightmost merging column for horizontal connectors. + let rightmost_merge = if !merging_cols.is_empty() { + merging_cols.iter().copied().max() + } else { + None + }; + + // Max column to render (include branch/merge targets not yet active). + let max_col = active + .iter() + .copied() + .chain(branch_targets.iter().copied()) + .chain(merging_cols.iter().copied()) + .max() + .unwrap_or(0); + + // Solid │ for the step's column, dotted ┆ for pass-through columns. + let mut segments = Vec::new(); + for col in 0..=max_col { + let ch = if col == my_col { + // This step's own column — solid line. + if has_branch { + "├" + } else if is_leaf && is_dead { + "╵" + } else if step.step.parents.len() > 1 { + "├" + } else { + "│" + } + } else if let Some(rb) = rightmost_branch { + // Branch row: horizontal connectors from my_col to rightmost branch. + if col > my_col && col <= rb { + if branch_target_set.contains(&col) && merging_cols.contains(&col) { + // Merge+split reuse: column absorbed and reused for new branch. + if col == rb { "┤" } else { "┼" } + } else if branch_target_set.contains(&col) { + if col == rb { "╮" } else { "┬" } + } else if active.contains(&col) && merging_cols.contains(&col) { + "┼" // merge+split: column being merged AND branch crosses it + } else if active.contains(&col) { + "│" // vertical passes through, not a junction + } else { + "─" + } + } else if merging_cols.contains(&col) { + " " + } else if active.contains(&col) { + "┆" + } else { + " " + } + } else if let Some(rm) = rightmost_merge { + // Merge row: horizontal connectors from my_col to rightmost merge. + if col > my_col && col <= rm { + if merging_cols.contains(&col) { + if col == rm { "╯" } else { "┴" } + } else if active.contains(&col) { + "┼" + } else { + "─" + } + } else if merging_cols.contains(&col) { + " " + } else if active.contains(&col) { + "┆" + } else { + " " + } + } else if merging_cols.contains(&col) { + " " + } else if active.contains(&col) { + "┆" // pass-through: dotted + } else { + " " + }; + + segments.push(GraphSegment { + ch, + is_dead_end: col == my_col && is_dead && is_leaf, + }); + } + + result.push(GraphLine { segments }); + + // If this step branches, activate all child columns immediately + // so continuation lines appear between branch point and child. + if has_branch { + if let Some(kids) = children_of.get(id) { + for &kid_idx in kids { + let kid_col = col_of[steps[kid_idx].step.id.as_str()]; + active.insert(kid_col); + } + } + } + + // If this step merges (multiple parents), deactivate and free the merged columns. + // Don't deactivate columns reused as branch targets (merge+split reuse). + if step.step.parents.len() > 1 { + let branch_child_cols: HashSet = if has_branch { + children_of + .get(id) + .into_iter() + .flatten() + .map(|&kid_idx| col_of[steps[kid_idx].step.id.as_str()]) + .collect() + } else { + HashSet::new() + }; + for parent in &step.step.parents { + let parent_col = col_of.get(parent.as_str()).copied().unwrap_or(0); + if parent_col != my_col && !branch_child_cols.contains(&parent_col) { + active.remove(&parent_col); + if parent_col != 0 && !free_cols.contains(&parent_col) { + free_cols.push(parent_col); + } + } + } + } + + // If this step is a dead-end leaf, deactivate and free its column. + if is_leaf { + active.remove(&my_col); + if my_col != 0 { + free_cols.push(my_col); + } + } + } + + // Pad all rows to the same width so columns align vertically. + let max_width = result.iter().map(|gl| gl.segments.len()).max().unwrap_or(0); + for gl in &mut result { + while gl.segments.len() < max_width { + gl.segments.push(GraphSegment { + ch: " ", + is_dead_end: false, + }); + } + } + + result +} + +/// Render a graph layout as a vector of strings (for testing). +/// Trailing spaces are trimmed. +#[cfg(test)] +fn graph_to_strings(layout: &[GraphLine]) -> Vec { + layout + .iter() + .map(|gl| { + gl.segments + .iter() + .map(|s| s.ch) + .collect::() + .trim_end() + .to_string() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use toolpath::v1::{ArtifactChange, Step, StructuralChange}; + + fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step { + let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z"); + for p in parents { + step = step.with_parent(*p); + } + step + } + + fn make_conversation_step( + id: &str, + actor: &str, + parents: &[&str], + text: &str, + tool_uses: &[&str], + ) -> Step { + let mut step = make_step(id, actor, parents); + let mut extra = HashMap::new(); + extra.insert("text".to_string(), serde_json::json!(text)); + extra.insert("role".to_string(), serde_json::json!("user")); + if !tool_uses.is_empty() { + extra.insert("tool_uses".to_string(), serde_json::json!(tool_uses)); + } + step.change.insert( + "claude://session".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.append".to_string(), + extra, + }), + }, + ); + step + } + + fn make_policy_step(id: &str, parents: &[&str], effect: &str, tool_name: &str) -> Step { + let mut step = make_step(id, "agent:clash-policy", parents); + let mut extra = HashMap::new(); + extra.insert("effect".to_string(), serde_json::json!(effect)); + extra.insert("tool_name".to_string(), serde_json::json!(tool_name)); + step.change.insert( + "clash://policy/evaluations".to_string(), + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "policy_evaluation".to_string(), + extra, + }), + }, + ); + step + } + + // ── Summary generation tests ────────────────────────────────────── + + #[test] + fn summary_from_user_message() { + let step = + make_conversation_step("s1", "human:user", &[], "Fix the bug in auth handler", &[]); + let summary = build_summary(&step); + assert_eq!(summary, "Fix the bug in auth handler"); + } + + #[test] + fn summary_from_assistant_with_tools() { + let step = make_conversation_step( + "s2", + "agent:claude-code", + &["s1"], + "Looking at the file", + &["Read", "Edit", "Bash"], + ); + let summary = build_summary(&step); + assert!(summary.starts_with("[Read, Edit, Bash]")); + assert!(summary.contains("Looking at the file")); + } + + #[test] + fn summary_from_assistant_message_only() { + let step = make_conversation_step( + "s2", + "agent:claude-code", + &["s1"], + "I'll fix the authentication handler", + &[], + ); + let summary = build_summary(&step); + assert_eq!(summary, "I'll fix the authentication handler"); + } + + #[test] + fn summary_from_policy_step() { + let step = make_policy_step("s3", &["s2"], "allow", "Read"); + let summary = build_summary(&step); + assert_eq!(summary, "allow Read"); + } + + #[test] + fn summary_fallback() { + let step = make_step("s1", "ci:github-actions", &[]); + let summary = build_summary(&step); + assert_eq!(summary, "ci:github-actions — s1"); + } + + #[test] + fn summary_truncation() { + let long_text = "a".repeat(200); + let step = make_conversation_step("s1", "human:user", &[], &long_text, &[]); + let summary = build_summary(&step); + // 79 'a' chars + '…' (3 bytes in UTF-8) = 82 bytes, but 80 chars. + assert!(summary.chars().count() <= 80); + assert!(summary.ends_with('…')); + } + + // ═══════════════════════════════════════════════════════════════════ + // DAG graph layout — full visual tests + // ═══════════════════════════════════════════════════════════════════ + // + // Each test shows the expected graph as an ASCII art comment, then + // asserts the full graph output. Character reference: + // + // │ solid — this step belongs to this column + // ┆ dotted — another column passing through + // ├ branch/merge source — horizontal connector goes right + // ╮ branch target (rightmost) + // ╯ merge source (rightmost) + // ┬ branch intermediate target + // ┴ merge intermediate source + // ┤ merge+split reuse — column absorbed and immediately reused for new branch + // ┼ merge+multi-split — column being merged AND multiple branches cross it + // ─ horizontal over inactive gap + // ╵ dead end — leaf not on main path + + #[test] + fn graph_linear() { + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["b"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "c")); + assert_eq!( + g, + vec![ + "│", // a + "│", // b + "│", // c + ] + ); + } + + #[test] + fn graph_two_way_split_with_dead_end() { + // a splits → b (main), c (dead end) + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["b"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "d")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "│┆", // b: solid col 0, pass-through col 1 + "┆╵", // c: pass-through col 0, dead end col 1 + "│", // d + ] + ); + } + + #[test] + fn graph_three_way_split() { + // a splits → b, c (both dead ends), d (main) + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["a"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "d")); + assert_eq!( + g, + vec![ + "├┬╮", // a: ├(col0) ┬(col1 intermediate) ╮(col2) + "┆╵┆", // b: dead end col 1 + "┆ ╵", // c: dead end col 2 (col 1 freed) + "│", // d + ] + ); + } + + #[test] + fn graph_dead_end_multi_step_branch() { + // a → b → c (dead branch), a → d (main) + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["b"]), + make_step("d", "x", &["a"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "d")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "┆│", // b: dead branch, solid col 1 + "┆╵", // c: dead end col 1 + "│", // d: main col 0 + ] + ); + } + + #[test] + fn graph_diamond_split_then_merge() { + // a splits → b, c; both merge at d + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["b", "c"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "d")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "│┆", // b: solid col 0 + "┆│", // c: solid col 1 + "├╯", // d: merge — ╯ absorbs col 1 + ] + ); + } + + #[test] + fn graph_three_way_merge() { + // a splits 3 ways → b, c, e; all merge at f + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("e", "x", &["a"]), + make_step("f", "x", &["b", "c", "e"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "f")); + assert_eq!( + g, + vec![ + "├┬╮", // a: 3-way split + "│┆┆", // b: solid col 0 + "┆│┆", // c: solid col 1 + "┆┆│", // e: solid col 2 + "├┴╯", // f: 3-way merge — ┴ intermediate, ╯ rightmost + ] + ); + } + + #[test] + fn graph_cross_branch_over_active() { + // a→b(main)+d1(dead). b→c(main)+d2(dead), branch crosses col 1. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("d1", "x", &["a"]), + make_step("c", "x", &["b"]), + make_step("d2", "x", &["b"]), + make_step("e", "x", &["c"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "e")); + assert_eq!( + g, + vec![ + "├╮", // a: split → col 1 + "├│╮", // b: split → col 2, │ passes through col 1 + "┆╵┆", // d1: dead end col 1 + "│ ┆", // c: solid col 0, gap (col 1 freed) + "┆ ╵", // d2: dead end col 2 + "│", // e + ] + ); + } + + #[test] + fn graph_merge_and_split_simultaneous() { + // d merges (parents b,c) AND splits (children e,f). + // Single split reuses the merged column → ┤ instead of new column. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["b", "c"]), + make_step("e", "x", &["d"]), + make_step("f", "x", &["d"]), + make_step("g", "x", &["e"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "g")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "│┆", // b + "┆│", // c + "├┤", // d: merge+split — ┤ = col 1 absorbed and reused + "│┆", // e + "┆╵", // f: dead end col 1 (reused) + "│", // g + ] + ); + } + + #[test] + fn graph_long_parallel_branches() { + // Two branches interleave for 3 steps then merge. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b1", "x", &["a"]), + make_step("c1", "x", &["a"]), + make_step("b2", "x", &["b1"]), + make_step("c2", "x", &["c1"]), + make_step("b3", "x", &["b2"]), + make_step("c3", "x", &["c2"]), + make_step("m", "x", &["b3", "c3"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "m")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "│┆", // b1: solid col 0, dotted col 1 + "┆│", // c1: dotted col 0, solid col 1 + "│┆", // b2 + "┆│", // c2 + "│┆", // b3 + "┆│", // c3 + "├╯", // m: merge + ] + ); + } + + #[test] + fn graph_column_reuse_after_dead_end() { + // d1 dies before c branches → col 1 reused. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("d1", "x", &["a"]), + make_step("c", "x", &["b"]), + make_step("d2", "x", &["c"]), + make_step("d3", "x", &["c"]), + make_step("e", "x", &["d2"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "e")); + assert_eq!( + g, + vec![ + "├╮", // a: split → col 1 + "│┆", // b + "┆╵", // d1: dead end, col 1 freed + "├╮", // c: split reuses col 1 + "│┆", // d2 + "┆╵", // d3: dead end col 1 (reused) + "│", // e + ] + ); + } + + #[test] + fn graph_column_reuse_after_merge() { + // Merge frees col 1, next branch reuses it. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["b", "c"]), + make_step("e", "x", &["d"]), + make_step("f", "x", &["e"]), + make_step("h", "x", &["e"]), + make_step("g", "x", &["f"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "g")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "│┆", // b + "┆│", // c + "├╯", // d: merge, col 1 freed + "├╮", // e: split, col 1 reused + "│┆", // f + "┆╵", // h: dead end col 1 + "│", // g + ] + ); + } + + #[test] + fn graph_double_diamond() { + // Two consecutive diamonds with column reuse. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["b", "c"]), + make_step("mid", "x", &["d"]), + make_step("e", "x", &["mid"]), + make_step("f", "x", &["mid"]), + make_step("g", "x", &["e", "f"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "g")); + assert_eq!( + g, + vec![ + "├╮", // a: split + "│┆", // b + "┆│", // c + "├╯", // d: merge + "├╮", // mid: split (col 1 reused) + "│┆", // e + "┆│", // f + "├╯", // g: merge + ] + ); + } + + #[test] + fn graph_merge_with_parallel_dead_end() { + // 3-way split: b+c merge at e, d is dead end. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["a"]), + make_step("e", "x", &["b", "c"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "e")); + assert_eq!( + g, + vec![ + "├┬╮", // a: 3-way split + "│┆┆", // b: solid col 0 + "┆│┆", // c: solid col 1 + "┆┆╵", // d: dead end col 2 + "├╯", // e: merge cols 0+1 + ] + ); + } + + #[test] + fn graph_nested_split() { + // a→b(main)+c(branch). c itself splits to d+e. + let steps = vec![ + make_step("a", "x", &[]), + make_step("b", "x", &["a"]), + make_step("c", "x", &["a"]), + make_step("d", "x", &["c"]), + make_step("e", "x", &["c"]), + make_step("f", "x", &["b"]), + ]; + let g = graph_to_strings(&compute_graph_layout(&steps, "f")); + let c_row = &g[2]; + assert!(c_row.contains('├'), "c should branch: {c_row}"); + assert!(c_row.contains('╮'), "c should have branch target: {c_row}"); + } +} diff --git a/crates/toolpath-tui/src/redact.rs b/crates/toolpath-tui/src/redact.rs new file mode 100644 index 0000000..a0f5272 --- /dev/null +++ b/crates/toolpath-tui/src/redact.rs @@ -0,0 +1,665 @@ +//! Pure redaction logic: given step entries with redaction state, produce a redacted Document. + +use std::collections::HashMap; + +use toolpath::v1; + +use crate::model::StepEntry; + +/// Build a redacted toolpath Document from the original path and the entries with redaction state. +pub fn build_redacted_document(original: &v1::Path, entries: &[StepEntry]) -> v1::Document { + let mut output_steps: Vec = Vec::new(); + let mut id_remap: HashMap = HashMap::new(); + + let mut i = 0; + while i < entries.len() { + if entries[i].included { + let mut step = entries[i].step.clone(); + apply_text_redactions(&mut step, &entries[i].text_redactions); + id_remap.insert(step.step.id.clone(), step.step.id.clone()); + output_steps.push(step); + i += 1; + } else { + // Group consecutive excluded entries. + let group_start = i; + while i < entries.len() && !entries[i].included { + i += 1; + } + let group = &entries[group_start..i]; + let count = group.len(); + + let first = &group[0].step; + let placeholder_id = format!("redacted-{}", first.step.id); + + // Map all excluded step IDs to the placeholder. + for entry in group { + id_remap.insert(entry.step.step.id.clone(), placeholder_id.clone()); + } + + let placeholder = build_placeholder_step(&placeholder_id, first, count); + output_steps.push(placeholder); + } + } + + // Rewrite parent references. + for step in &mut output_steps { + step.step.parents = step + .step + .parents + .iter() + .map(|p| id_remap.get(p).cloned().unwrap_or_else(|| p.clone())) + .collect(); + // Deduplicate parents (multiple excluded parents may map to same placeholder). + step.step.parents.dedup(); + } + + // Update head. + let head = output_steps + .last() + .map(|s| s.step.id.clone()) + .unwrap_or_else(|| original.path.head.clone()); + + // Build path meta with "(redacted)" suffix. + let meta = original.meta.clone().map(|mut m| { + let title = m.title.unwrap_or_default(); + m.title = Some(format!("{title} (redacted)")); + m + }); + + let path = v1::Path { + path: v1::PathIdentity { + id: original.path.id.clone(), + base: original.path.base.clone(), + head, + }, + steps: output_steps, + meta, + }; + + v1::Document::Path(path) +} + +fn build_placeholder_step(id: &str, first_excluded: &v1::Step, count: usize) -> v1::Step { + let mut change = HashMap::new(); + let mut extra = HashMap::new(); + extra.insert("count".to_string(), serde_json::json!(count)); + change.insert( + "clash://redaction".to_string(), + v1::ArtifactChange { + raw: None, + structural: Some(v1::StructuralChange { + change_type: "redaction".to_string(), + extra, + }), + }, + ); + + v1::Step { + step: v1::StepIdentity { + id: id.to_string(), + parents: first_excluded.step.parents.clone(), + actor: "tool:redact".to_string(), + timestamp: first_excluded.step.timestamp.clone(), + }, + change, + meta: Some(v1::StepMeta { + intent: Some(format!("[redacted] ({count} steps redacted)")), + ..Default::default() + }), + } +} + +/// Apply text redactions to a step by working on its JSON representation. +fn apply_text_redactions(step: &mut v1::Step, redactions: &[crate::model::TextRedaction]) { + if redactions.is_empty() { + return; + } + + let mut value = match serde_json::to_value(&*step) { + Ok(v) => v, + Err(_) => return, + }; + + // Group redactions by json_pointer and sort each group by offset descending. + let mut by_pointer: HashMap<&str, Vec<&crate::model::TextRedaction>> = HashMap::new(); + for r in redactions { + by_pointer + .entry(r.json_pointer.as_str()) + .or_default() + .push(r); + } + + for (pointer, group) in by_pointer { + // Sort by start offset ascending, then merge overlapping/adjacent ranges. + let mut ranges: Vec<(usize, usize)> = group.iter().map(|r| (r.start, r.end)).collect(); + ranges.sort(); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (s, e) in ranges { + if let Some(last) = merged.last_mut() { + if s <= last.1 { + // Overlapping or adjacent — merge. + last.1 = last.1.max(e); + continue; + } + } + merged.push((s, e)); + } + + // Apply from the end so earlier offsets remain valid. + merged.reverse(); + + if let Some(target) = value.pointer_mut(pointer) { + if let Some(s) = target.as_str().map(|s| s.to_string()) { + let mut result = s; + for (start, end) in &merged { + if *start <= result.len() && *end <= result.len() && start <= end { + let char_count = end - start; + let replacement = format!("[REDACTED({char_count})]"); + result.replace_range(*start..*end, &replacement); + } + } + *target = serde_json::Value::String(result); + } + } + } + + if let Ok(modified) = serde_json::from_value(value) { + *step = modified; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::TextRedaction; + use std::collections::HashMap; + use toolpath::v1; + + fn make_step(id: &str, actor: &str, parents: &[&str]) -> v1::Step { + let mut step = v1::Step::new(id, actor, "2026-01-29T10:00:00Z"); + for p in parents { + step = step.with_parent(*p); + } + step = step.with_raw_change("file.rs", "@@ test diff @@"); + step + } + + fn make_entry(step: v1::Step, included: bool) -> StepEntry { + StepEntry { + index: 0, + summary: String::new(), + actor_label: String::new(), + step, + included, + expanded: false, + text_redactions: Vec::new(), + } + } + + fn make_path(steps: Vec) -> v1::Path { + let head = steps.last().map(|s| s.step.id.clone()).unwrap_or_default(); + v1::Path { + path: v1::PathIdentity { + id: "test-path".to_string(), + base: None, + head, + }, + steps, + meta: Some(v1::PathMeta { + title: Some("Test session".to_string()), + ..Default::default() + }), + } + } + + #[test] + fn no_redactions() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + ]; + let path = make_path(steps.clone()); + let entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 2); + assert_eq!(p.steps[0].step.id, "s1"); + assert_eq!(p.steps[1].step.id, "s2"); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn exclude_single_step() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + make_step("s3", "human:user", &["s2"]), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[1].included = false; + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 3); + assert_eq!(p.steps[0].step.id, "s1"); + assert_eq!(p.steps[1].step.id, "redacted-s2"); + assert_eq!(p.steps[1].step.actor, "tool:redact"); + assert_eq!(p.steps[2].step.id, "s3"); + // s3's parent should point to the placeholder. + assert_eq!(p.steps[2].step.parents, vec!["redacted-s2"]); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn exclude_consecutive_steps() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + make_step("s3", "agent:claude", &["s2"]), + make_step("s4", "agent:claude", &["s3"]), + make_step("s5", "human:user", &["s4"]), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[1].included = false; + entries[2].included = false; + entries[3].included = false; + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 3); // s1, placeholder, s5 + assert_eq!(p.steps[1].step.id, "redacted-s2"); + // Check placeholder intent. + let intent = p.steps[1].meta.as_ref().unwrap().intent.as_ref().unwrap(); + assert_eq!(intent, "[redacted] (3 steps redacted)"); + // s5's parent should be the placeholder. + assert_eq!(p.steps[2].step.parents, vec!["redacted-s2"]); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn exclude_non_consecutive() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + make_step("s3", "human:user", &["s2"]), + make_step("s4", "agent:claude", &["s3"]), + make_step("s5", "human:user", &["s4"]), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[1].included = false; // s2 + entries[3].included = false; // s4 + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 5); // s1, placeholder1, s3, placeholder2, s5 + assert_eq!(p.steps[1].step.id, "redacted-s2"); + assert_eq!(p.steps[3].step.id, "redacted-s4"); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn parent_rewriting_simple() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + make_step("s3", "human:user", &["s2"]), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[1].included = false; + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + // s3's parent was s2, should now point to placeholder. + assert_eq!(p.steps[2].step.parents, vec!["redacted-s2"]); + // Placeholder's parent should be s1 (the original parent of s2). + assert_eq!(p.steps[1].step.parents, vec!["s1"]); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn head_rewriting() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + make_step("s3", "human:user", &["s2"]), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[2].included = false; // exclude the last step + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.path.head, "redacted-s3"); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn text_redaction_basic() { + let mut step = make_step("s1", "human:user", &[]); + step = step.with_intent("Fix the secret API key abc123 in config"); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 25, + end: 31, + original: "abc123".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + let intent = p.steps[0].meta.as_ref().unwrap().intent.as_ref().unwrap(); + assert!(intent.contains("[REDACTED(6)]")); + assert!(!intent.contains("abc123")); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn text_redaction_multiple_in_same_field() { + let mut step = make_step("s1", "human:user", &[]); + step = step.with_intent("key=SECRET1 and token=SECRET2"); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 4, + end: 11, + original: "SECRET1".to_string(), + }); + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 22, + end: 29, + original: "SECRET2".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + let intent = p.steps[0].meta.as_ref().unwrap().intent.as_ref().unwrap(); + assert!(intent.contains("[REDACTED(7)]")); + assert!(!intent.contains("SECRET1")); + assert!(!intent.contains("SECRET2")); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn text_redaction_in_diff() { + let step = v1::Step::new("s1", "human:user", "2026-01-29T10:00:00Z").with_raw_change( + "file.rs", + "--- a\n+++ b\n@@ -1 +1 @@\n-secret_key\n+new_line", + ); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + entry.text_redactions.push(TextRedaction { + json_pointer: "/change/file.rs/raw".to_string(), + start: 30, + end: 40, + original: "secret_key".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + let raw = p.steps[0].change["file.rs"].raw.as_ref().unwrap(); + assert!(raw.contains("[REDACTED(10)]")); + assert!(!raw.contains("secret_key")); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn text_redaction_deep_field() { + let mut step = make_step("s1", "human:user", &[]); + // Add a structural change with extra fields. + let mut extra = HashMap::new(); + extra.insert( + "text".to_string(), + serde_json::json!("my secret password here"), + ); + step.change.insert( + "conversation".to_string(), + v1::ArtifactChange { + raw: None, + structural: Some(v1::StructuralChange { + change_type: "conversation.append".to_string(), + extra, + }), + }, + ); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + entry.text_redactions.push(TextRedaction { + json_pointer: "/change/conversation/structural/text".to_string(), + start: 10, + end: 18, + original: "password".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + let structural = p.steps[0].change["conversation"] + .structural + .as_ref() + .unwrap(); + let text = structural.extra["text"].as_str().unwrap(); + assert!(text.contains("[REDACTED(8)]")); + assert!(!text.contains("password")); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn text_redaction_preserves_other_fields() { + let step = v1::Step::new("s1", "human:user", "2026-01-29T10:00:00Z") + .with_raw_change("a.rs", "diff-a") + .with_raw_change("b.rs", "diff-b") + .with_intent("keep this intent"); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + entry.text_redactions.push(TextRedaction { + json_pointer: "/change/a.rs/raw".to_string(), + start: 0, + end: 6, + original: "diff-a".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + // a.rs should be redacted. + let raw_a = p.steps[0].change["a.rs"].raw.as_ref().unwrap(); + assert!(raw_a.contains("[REDACTED(6)]")); + // b.rs should be untouched. + let raw_b = p.steps[0].change["b.rs"].raw.as_ref().unwrap(); + assert_eq!(raw_b, "diff-b"); + // Intent should be untouched. + let intent = p.steps[0].meta.as_ref().unwrap().intent.as_ref().unwrap(); + assert_eq!(intent, "keep this intent"); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn combined_step_and_text_redaction() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + v1::Step::new("s3", "human:user", "2026-01-29T10:00:00Z") + .with_parent("s2") + .with_raw_change("file.rs", "contains SECRET here"), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[1].included = false; // exclude s2 + entries[2].text_redactions.push(TextRedaction { + json_pointer: "/change/file.rs/raw".to_string(), + start: 9, + end: 15, + original: "SECRET".to_string(), + }); + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 3); // s1, placeholder, s3 + assert_eq!(p.steps[1].step.actor, "tool:redact"); + let raw = p.steps[2].change["file.rs"].raw.as_ref().unwrap(); + assert!(raw.contains("[REDACTED(6)]")); + assert!(!raw.contains("SECRET")); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn all_steps_excluded() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + ]; + let path = make_path(steps.clone()); + let entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, false)).collect(); + + let doc = build_redacted_document(&path, &entries); + match doc { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 1); + assert_eq!(p.steps[0].step.id, "redacted-s1"); + assert_eq!(p.path.head, "redacted-s1"); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn overlapping_redactions_merge() { + let mut step = make_step("s1", "human:user", &[]); + step = step.with_intent("the SECRET_KEY_VALUE is here"); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + // Two overlapping redactions: "SECRET_KEY" (4..14) and "KEY_VALUE" (11..20) + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 4, + end: 14, + original: "SECRET_KEY".to_string(), + }); + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 11, + end: 20, + original: "KEY_VALUE".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + let intent = p.steps[0].meta.as_ref().unwrap().intent.as_ref().unwrap(); + // Should merge into one [REDACTED(16)] covering chars 4..20. + assert!(intent.contains("[REDACTED(16)]")); + assert!(!intent.contains("SECRET")); + assert!(!intent.contains("KEY")); + assert!(!intent.contains("VALUE")); + // Should only have ONE redaction marker, not two. + assert_eq!(intent.matches("[REDACTED").count(), 1); + } + _ => panic!("expected Path"), + } + } + + #[test] + fn adjacent_redactions_merge() { + let mut step = make_step("s1", "human:user", &[]); + step = step.with_intent("AABBCC"); + let path = make_path(vec![step.clone()]); + + let mut entry = make_entry(step, true); + // Adjacent: "AA" (0..2) and "BB" (2..4) + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 0, + end: 2, + original: "AA".to_string(), + }); + entry.text_redactions.push(TextRedaction { + json_pointer: "/meta/intent".to_string(), + start: 2, + end: 4, + original: "BB".to_string(), + }); + + let doc = build_redacted_document(&path, &[entry]); + match doc { + v1::Document::Path(p) => { + let intent = p.steps[0].meta.as_ref().unwrap().intent.as_ref().unwrap(); + // Should merge into one [REDACTED(4)] covering chars 0..4. + assert!(intent.contains("[REDACTED(4)]")); + assert_eq!(intent.matches("[REDACTED").count(), 1); + assert!(intent.contains("CC")); // "CC" should remain + } + _ => panic!("expected Path"), + } + } + + #[test] + fn roundtrip_serialization() { + let steps = vec![ + make_step("s1", "human:user", &[]), + make_step("s2", "agent:claude", &["s1"]), + make_step("s3", "human:user", &["s2"]), + ]; + let path = make_path(steps.clone()); + let mut entries: Vec<_> = steps.into_iter().map(|s| make_entry(s, true)).collect(); + entries[1].included = false; + + let doc = build_redacted_document(&path, &entries); + let json = doc.to_json().unwrap(); + let parsed = v1::Document::from_json(&json).unwrap(); + match parsed { + v1::Document::Path(p) => { + assert_eq!(p.steps.len(), 3); + assert_eq!(p.steps[1].step.actor, "tool:redact"); + } + _ => panic!("expected Path"), + } + } +} diff --git a/crates/toolpath-tui/src/render.rs b/crates/toolpath-tui/src/render.rs new file mode 100644 index 0000000..9207b96 --- /dev/null +++ b/crates/toolpath-tui/src/render.rs @@ -0,0 +1,586 @@ +//! Rendering logic for the trace TUI. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; + +use crate::app::TraceTuiApp; +use crate::model::{self, Mode, SelectionMode}; + +/// Render the full trace TUI. +pub fn render(app: &mut TraceTuiApp, frame: &mut Frame) { + let area = frame.area(); + + let chunks = Layout::vertical([ + Constraint::Length(1), // title + Constraint::Min(3), // step list + Constraint::Length(1), // status bar + ]) + .split(area); + + app.visible_height = chunks[1].height as usize; + app.visible_width = chunks[1].width as usize; + render_title(app, frame, chunks[0]); + render_step_list(app, frame, chunks[1]); + render_status_bar(app, frame, chunks[2]); + + if matches!(app.mode, Mode::Help) { + render_help_overlay(app, frame, area); + } + if matches!(app.mode, Mode::Confirm) { + render_confirm_overlay(app, frame, area); + } + if matches!(app.mode, Mode::Search { .. }) { + render_search_bar(app, frame, chunks[2]); + } +} + +fn render_title(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { + let included = app.entries.iter().filter(|e| e.included).count(); + let total = app.entries.len(); + let app_name = &app.config.app_name; + let session_id = &app.path.path.id; + let title = if app.redact_mode { + format!(" {app_name} redact — {session_id} — {total} steps ({included} included)") + } else { + format!(" {app_name} view — {session_id} — {total} steps") + }; + let line = Line::from(Span::styled( + title, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )); + frame.render_widget(Paragraph::new(line), area); +} + +/// Shared highlight bg for hover, visual line, and visual char selection. +const HIGHLIGHT_BG: Color = Color::Rgb(55, 55, 70); + +fn render_step_list(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { + let visible_height = area.height as usize; + let term_width = area.width as usize; + if visible_height == 0 || app.entries.is_empty() { + return; + } + + // Collect all visible lines (including expanded content). + let mut lines: Vec = Vec::new(); + let mut step_line_map: Vec<(usize, bool)> = Vec::new(); // (entry_index, is_summary_line) + + for (i, entry) in app.entries.iter().enumerate() { + // Build the summary line. + let is_cursor = i == app.cursor && app.sub_line == 0; + let is_in_visual_range = match &app.selection { + SelectionMode::VisualLine { anchor, cursor } => { + let lo = (*anchor).min(*cursor); + let hi = (*anchor).max(*cursor); + i >= lo && i <= hi + } + _ => false, + }; + + let mut spans = Vec::new(); + + // Cursor / visual-line marker. + if is_cursor { + spans.push(Span::styled( + " > ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + } else if is_in_visual_range { + spans.push(Span::styled(" > ", Style::default().fg(Color::Cyan))); + } else { + spans.push(Span::raw(" ")); + } + + // Redaction gutter. + if !entry.included { + spans.push(Span::styled(" - ", Style::default().fg(Color::Red))); + } else { + spans.push(Span::raw(" ")); + } + + // DAG graph column. + if let Some(graph_line) = app.graph_layout.get(i) { + for seg in &graph_line.segments { + let style = if seg.ch == "┆" { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Cyan) + }; + spans.push(Span::styled(seg.ch, style)); + } + spans.push(Span::raw(" ")); + } + + // Actor label. + let actor_style = match entry.actor_label.as_str() { + "user" => Style::default().fg(Color::Green), + "assistant" => Style::default().fg(Color::Blue), + "policy" => Style::default().fg(Color::Magenta), + _ => Style::default().fg(Color::White), + }; + let dimmed = !entry.included; + let actor_style = if dimmed { + actor_style.add_modifier(Modifier::DIM) + } else { + actor_style + }; + spans.push(Span::styled( + format!("{:<10}", entry.actor_label), + actor_style, + )); + + // Summary. + let summary_style = if dimmed { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + spans.push(Span::styled(&entry.summary, summary_style)); + + // Timestamp. + let ts = &entry.step.step.timestamp; + let ts_short = ts + .find('T') + .map(|i| &ts[i + 1..]) + .unwrap_or(ts) + .trim_end_matches('Z'); + spans.push(Span::styled( + format!(" {ts_short}"), + Style::default().fg(Color::DarkGray), + )); + + // Pad to terminal width so highlight covers the full row. + let content_len: usize = spans.iter().map(|s| s.content.len()).sum(); + if content_len < term_width { + spans.push(Span::raw(" ".repeat(term_width - content_len))); + } + + let mut line = Line::from(spans); + if is_in_visual_range || is_cursor { + line = line.style(Style::default().bg(HIGHLIGHT_BG)); + } + + lines.push(line); + step_line_map.push((i, true)); + + // Expanded content — field-oriented view with json_pointer tracking. + if entry.expanded { + let wrap_width = term_width.saturating_sub(20).max(40); + let field_lines = model::build_field_lines(&entry.step, wrap_width); + let indent = " "; // aligns with gutter + cursor marker + + // Collect redaction ranges for this step, grouped by json_pointer. + let redactions = &entry.text_redactions; + + for (fl_idx, fl) in field_lines.iter().enumerate() { + let is_field_cursor = i == app.cursor && app.sub_line == fl_idx + 1; + let is_visual_target = matches!( + &app.selection, + SelectionMode::Visual { step_index, json_pointer, .. } + if *step_index == i && fl.json_pointer.as_deref() == Some(json_pointer.as_str()) + ); + + let base_style = if fl.json_pointer.is_some() { + if dimmed { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Gray) + } + } else { + if dimmed { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::DIM) + } + }; + + // Get selection range if this line is the visual target. + let selection = if is_visual_target && fl.json_pointer.is_some() { + if let SelectionMode::Visual { anchor, cursor, .. } = &app.selection { + Some((*anchor, *cursor)) + } else { + None + } + } else { + None + }; + + // Cursor col — show block cursor on the cursor line OR on + // the visual mode cursor position. + let cursor_col = if is_field_cursor && selection.is_none() { + Some(app.text_col) + } else if let Some((_, c)) = selection { + Some(c) // show cursor at the moving end of visual selection + } else { + None + }; + + let spans = + build_field_spans(fl, indent, redactions, base_style, cursor_col, selection); + let mut line = Line::from(spans); + if is_field_cursor && (fl.json_pointer.is_none() || fl.value_len == 0) { + line = line.style(Style::default().bg(HIGHLIGHT_BG)); + } + lines.push(line); + step_line_map.push((i, false)); + } + } + } + + // Apply scroll offset. + let total_lines = lines.len(); + let scroll = app.scroll.min(total_lines.saturating_sub(visible_height)); + + let visible_lines: Vec = lines + .into_iter() + .skip(scroll) + .take(visible_height) + .collect(); + + let paragraph = Paragraph::new(visible_lines); + frame.render_widget(paragraph, area); +} + +fn render_status_bar(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { + // Mode indicator with vim-style colored background. + let (mode_text, mode_style) = match (&app.mode, &app.selection) { + (Mode::Search { .. }, _) => ( + " SEARCH ", + Style::default().fg(Color::Black).bg(Color::Green), + ), + (Mode::Help, _) => (" HELP ", Style::default().fg(Color::Black).bg(Color::Cyan)), + (Mode::Confirm, _) => ( + " CONFIRM ", + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + (_, SelectionMode::VisualLine { .. }) => ( + " VISUAL LINE ", + Style::default().fg(Color::Black).bg(Color::Magenta), + ), + (_, SelectionMode::Visual { .. }) => ( + " VISUAL ", + Style::default().fg(Color::Black).bg(Color::Magenta), + ), + _ => ( + " NORMAL ", + Style::default().fg(Color::Black).bg(Color::White), + ), + }; + + let position_text = match &app.selection { + SelectionMode::Visual { anchor, cursor, .. } => { + let sel_len = (*anchor).max(*cursor) - (*anchor).min(*cursor) + 1; + format!(" col:{cursor} sel:{sel_len}") + } + _ if app.sub_line > 0 => format!(" col:{}", app.text_col), + _ => String::new(), + }; + + let hints = if app.redact_mode { + "Space:toggle V:visual-line v:visual e:export ?:help q:quit" + } else { + "Enter:expand /:search ?:help q:quit" + }; + + let flash = app + .flash + .as_ref() + .map(|(msg, _)| format!(" {msg}")) + .unwrap_or_default(); + + let bar_style = Style::default().fg(Color::Black).bg(Color::White); + let rest = format!("{position_text} {hints}{flash}"); + + let line = Line::from(vec![ + Span::styled(mode_text, mode_style), + Span::styled(rest, bar_style), + ]); + frame.render_widget(Paragraph::new(line), area); +} + +fn render_help_overlay(_app: &TraceTuiApp, frame: &mut Frame, area: Rect) { + let help_text = vec![ + "Navigation:", + " j/↓ Move down", + " k/↑ Move up", + " J/K Move by 5", + " g/G Top / Bottom", + " Enter Expand step", + " Backspace Collapse step", + " h/l ←/→ Move within text", + "", + "Redaction:", + " Space Toggle include/exclude", + " V Visual line mode", + " v Visual char mode (expanded)", + " d Exclude (or redact in visual)", + " i Include", + " D Keep only selection (visual line)", + " Shift+D Exclude to end", + " Shift+B Exclude to beginning", + " a Include all (reset)", + " u Undo", + "", + "Actions:", + " e Export", + " / Search", + " n/N Next/prev match", + " q/Esc Quit", + " ? Toggle help", + ]; + + let width = 50.min(area.width.saturating_sub(4)); + let height = (help_text.len() as u16 + 2).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let popup_area = Rect::new(x, y, width, height); + + let lines: Vec = help_text + .iter() + .map(|&s| Line::from(Span::raw(s))) + .collect(); + + let block = Block::default() + .title(" Help ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .style(Style::default().bg(Color::Black).fg(Color::White)); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(Clear, popup_area); + frame.render_widget(paragraph, popup_area); +} + +fn render_confirm_overlay(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { + let included = app.entries.iter().filter(|e| e.included).count(); + let excluded = app.entries.len() - included; + let text_redactions: usize = app.entries.iter().map(|e| e.text_redactions.len()).sum(); + + let lines = vec![ + Line::from(""), + Line::from(format!(" Steps included: {included}")), + Line::from(format!(" Steps excluded: {excluded}")), + Line::from(format!(" Text redactions: {text_redactions}")), + Line::from(""), + Line::from(" Press Enter to export, Esc to cancel"), + ]; + + let width = 45.min(area.width.saturating_sub(4)); + let height = (lines.len() as u16 + 2).min(area.height.saturating_sub(4)); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let popup_area = Rect::new(x, y, width, height); + + let block = Block::default() + .title(" Export Redacted Trace ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .style(Style::default().bg(Color::Black).fg(Color::White)); + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(Clear, popup_area); + frame.render_widget(paragraph, popup_area); +} + +/// Build spans for a field line with redaction highlights, block cursor, and selection. +/// +/// Handles all three overlapping visual concerns in one pass: +/// - Text redactions: red + strikethrough +/// - Visual selection: HIGHLIGHT_BG +/// - Block cursor: white-on-black +/// +/// Priority: cursor > selected+redacted > selected > redacted > normal +fn build_field_spans<'a>( + fl: &model::FieldLine, + indent: &str, + redactions: &[model::TextRedaction], + base_style: Style, + cursor_col: Option, + selection: Option<(usize, usize)>, // (anchor, cursor) in value coords +) -> Vec> { + let full = format!("{indent}{}", fl.text); + let redact_style = Style::default() + .fg(Color::Red) + .add_modifier(Modifier::CROSSED_OUT); + let sel_style = Style::default().bg(HIGHLIGHT_BG); + let sel_redact_style = Style::default() + .fg(Color::Red) + .bg(HIGHLIGHT_BG) + .add_modifier(Modifier::CROSSED_OUT); + let cursor_style = Style::default().bg(Color::White).fg(Color::Black); + + let ptr = match &fl.json_pointer { + Some(p) => p, + None => return vec![Span::styled(full, base_style)], + }; + if fl.value_len == 0 { + return vec![Span::styled(full, base_style)]; + } + + let value_start_in_display = full.len().saturating_sub(fl.value_len); + let line_value_end = fl.value_offset + fl.value_len; + + // Redaction ranges clipped to this line. + let mut ranges: Vec<(usize, usize)> = Vec::new(); + for r in redactions { + if r.json_pointer != *ptr { + continue; + } + let r_start = r.start.max(fl.value_offset); + let r_end = r.end.min(line_value_end); + if r_start < r_end { + ranges.push((r_start - fl.value_offset, r_end - fl.value_offset)); + } + } + ranges.sort(); + let mut merged_redactions: Vec<(usize, usize)> = Vec::new(); + for (s, e) in ranges { + if let Some(last) = merged_redactions.last_mut() { + if s <= last.1 { + last.1 = last.1.max(e); + continue; + } + } + merged_redactions.push((s, e)); + } + + // Selection range clipped to this line (in line-local value coords). + let sel_range = selection.map(|(a, c)| { + let s = a.min(c); + let e = a.max(c); + let ls = s.max(fl.value_offset).saturating_sub(fl.value_offset); + let le = e.min(line_value_end).saturating_sub(fl.value_offset); + (ls, le) + }); + + // Cursor position in display coords. + let cursor_display = cursor_col.map(|col| { + let col_in_line = col.saturating_sub(fl.value_offset); + value_start_in_display + col_in_line.min(fl.value_len) + }); + + // Walk through display string, determine style at each position. + let mut spans: Vec> = Vec::new(); + let mut pos = 0; + while pos < full.len() { + let style = char_style( + pos, + value_start_in_display, + fl.value_len, + &merged_redactions, + sel_range, + cursor_display, + base_style, + redact_style, + sel_style, + sel_redact_style, + cursor_style, + ); + + let start = pos; + pos += 1; + while pos < full.len() { + let next_style = char_style( + pos, + value_start_in_display, + fl.value_len, + &merged_redactions, + sel_range, + cursor_display, + base_style, + redact_style, + sel_style, + sel_redact_style, + cursor_style, + ); + if next_style != style { + break; + } + pos += 1; + } + spans.push(Span::styled(full[start..pos].to_string(), style)); + } + + // Cursor at end of value. + if let Some(cp) = cursor_display { + if cp >= full.len() { + spans.push(Span::styled(" ".to_string(), cursor_style)); + } + } + + if spans.is_empty() { + vec![Span::styled(full, base_style)] + } else { + spans + } +} + +/// Determine the style for a single character position. +#[allow(clippy::too_many_arguments)] +fn char_style( + pos: usize, + value_start: usize, + value_len: usize, + redactions: &[(usize, usize)], + sel_range: Option<(usize, usize)>, + cursor_display: Option, + base: Style, + redact: Style, + sel: Style, + sel_redact: Style, + cursor: Style, +) -> Style { + if cursor_display == Some(pos) { + return cursor; + } + let in_value = pos >= value_start && pos < value_start + value_len; + if !in_value { + return base; + } + let vpos = pos - value_start; + let is_redacted = redactions.iter().any(|(s, e)| vpos >= *s && vpos < *e); + let is_selected = sel_range.map_or(false, |(s, e)| vpos >= s && vpos <= e); + + match (is_selected, is_redacted) { + (true, true) => sel_redact, + (true, false) => sel, + (false, true) => redact, + (false, false) => base, + } +} + +fn render_search_bar(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { + if let Mode::Search { + query, + cursor: _, + matches, + } = &app.mode + { + let match_info = if matches.is_empty() { + "no matches".to_string() + } else { + format!("{} matches", matches.len()) + }; + let text = format!(" /{query} ({match_info})"); + let line = Line::from(Span::styled( + text, + Style::default().fg(Color::White).bg(Color::DarkGray), + )); + frame.render_widget(Paragraph::new(line), area); + } +} diff --git a/examples/path-05-complex-dag.json b/examples/path-05-complex-dag.json new file mode 100644 index 0000000..37459c9 --- /dev/null +++ b/examples/path-05-complex-dag.json @@ -0,0 +1,252 @@ +{ + "Path": { + "path": { + "id": "path-payments-v2", + "base": { + "uri": "github:acme/payments", + "ref": "main", + "commit": "f4e3d2c1b0a9" + }, + "head": "s24" + }, + + "steps": [ + { + "step": { "id": "s01", "actor": "human:priya", "timestamp": "2026-04-01T09:00:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "user", "text": "Rewrite the payments module. We need Stripe and PayPal support, webhook handling, idempotency keys, and a retry queue." } } + }, + "meta": { "intent": "Kick off payments v2 rewrite" } + }, + { + "step": { "id": "s02", "parents": ["s01"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:01:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "I'll define the core trait first, then build Stripe and PayPal in parallel.", "tool_uses": ["Write", "Write"] } }, + "src/payments/mod.rs": { "raw": "@@ -0,0 +1,6 @@\n+pub mod provider;\n+pub mod stripe;\n+pub mod paypal;\n+pub mod webhook;\n+pub mod retry;\n+pub mod idempotency;" }, + "src/payments/provider.rs": { "raw": "@@ -0,0 +1,15 @@\n+#[async_trait::async_trait]\n+pub trait PaymentProvider: Send + Sync {\n+ async fn charge(&self, amount: u64, currency: &str, source: &str) -> Result;\n+ async fn refund(&self, charge_id: &str) -> Result<(), PaymentError>;\n+}\n+pub struct Charge { pub id: String, pub amount: u64, pub status: ChargeStatus }\n+pub enum ChargeStatus { Pending, Succeeded, Failed, Refunded }\n+pub enum PaymentError { Provider(String), Timeout }" } + }, + "meta": { "intent": "Define PaymentProvider trait and core types" } + }, + + { + "step": { "id": "s03", "parents": ["s02"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:03:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Implementing Stripe provider with reqwest.", "tool_uses": ["Write"] } }, + "src/payments/stripe.rs": { "raw": "@@ -0,0 +1,30 @@\n+pub struct StripeProvider { client: reqwest::Client, api_key: String }\n+impl StripeProvider { pub fn new(api_key: String) -> Self { Self { client: reqwest::Client::new(), api_key } } }\n+#[async_trait::async_trait]\n+impl PaymentProvider for StripeProvider { ... }" } + }, + "meta": { "intent": "Implement Stripe provider" } + }, + { + "step": { "id": "s04", "parents": ["s02"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:04:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Implementing PayPal provider with OAuth2 token exchange.", "tool_uses": ["Write"] } }, + "src/payments/paypal.rs": { "raw": "@@ -0,0 +1,35 @@\n+pub struct PayPalProvider { client: reqwest::Client, client_id: String, secret: String, sandbox: bool }\n+impl PayPalProvider { pub fn new(client_id: String, secret: String, sandbox: bool) -> Self { ... } }\n+#[async_trait::async_trait]\n+impl PaymentProvider for PayPalProvider { ... }" } + }, + "meta": { "intent": "Implement PayPal provider" } + }, + + { + "step": { "id": "s05", "parents": ["s03"], "actor": "tool:rustfmt/1.7.0", "timestamp": "2026-04-01T09:03:30Z" }, + "change": { "src/payments/stripe.rs": { "raw": "@@ formatting changes @@" } }, + "meta": { "intent": "Format Stripe provider" } + }, + { + "step": { "id": "s06", "parents": ["s04"], "actor": "tool:clippy/0.1.84", "timestamp": "2026-04-01T09:04:30Z" }, + "change": { "src/payments/paypal.rs": { "structural": { "type": "lint.fix", "rule": "clippy::needless_borrow" } } }, + "meta": { "intent": "Fix clippy warning in PayPal provider" } + }, + + { + "step": { "id": "s07", "parents": ["s05", "s06"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:06:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Both providers done. Now building webhook handlers for each.", "tool_uses": ["Write"] } }, + "src/payments/webhook.rs": { "raw": "@@ -0,0 +1,12 @@\n+pub trait WebhookHandler: Send + Sync {\n+ fn verify_signature(&self, payload: &[u8], signature: &str) -> bool;\n+ fn parse_event(&self, payload: &[u8]) -> Result;\n+}\n+pub struct WebhookEvent { pub event_type: String, pub charge_id: Option }\n+pub struct WebhookError(pub String);" } + }, + "meta": { "intent": "MERGE Stripe+PayPal; define webhook trait" } + }, + + { + "step": { "id": "s08a", "parents": ["s07"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:08:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Trying manual HMAC-SHA256 for Stripe webhook verification.", "tool_uses": ["Write"] } }, + "src/payments/stripe_webhook.rs": { "raw": "@@ -0,0 +1,10 @@\n+use hmac::{Hmac, Mac};\n+// Abandoned: Stripe's signature header format is more complex than plain HMAC" } + }, + "meta": { "intent": "Manual HMAC webhook verification (abandoned)" } + }, + { + "step": { "id": "s08b", "parents": ["s07"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:09:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Using stripe crate's built-in Webhook::construct_event.", "tool_uses": ["Write"] } }, + "src/payments/stripe_webhook.rs": { "raw": "@@ -0,0 +1,20 @@\n+use stripe::Webhook;\n+pub struct StripeWebhookHandler { signing_secret: String }\n+impl WebhookHandler for StripeWebhookHandler { ... }" } + }, + "meta": { "intent": "Stripe webhook via stripe crate" } + }, + { + "step": { "id": "s09", "parents": ["s07"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:10:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Building PayPal webhook handler with certificate-based verification.", "tool_uses": ["Write"] } }, + "src/payments/paypal_webhook.rs": { "raw": "@@ -0,0 +1,20 @@\n+pub struct PayPalWebhookHandler { webhook_id: String }\n+impl WebhookHandler for PayPalWebhookHandler { ... }" } + }, + "meta": { "intent": "PayPal webhook handler" } + }, + + { + "step": { "id": "s10", "parents": ["s08b", "s09"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:12:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Both webhook handlers done. Building the webhook router that dispatches to the right handler based on the request path.", "tool_uses": ["Write"] } }, + "src/payments/webhook_router.rs": { "raw": "@@ -0,0 +1,25 @@\n+pub struct WebhookRouter { handlers: HashMap> }\n+impl WebhookRouter {\n+ pub fn new() -> Self { Self { handlers: HashMap::new() } }\n+ pub fn register(&mut self, path: &str, handler: Box) { ... }\n+ pub async fn dispatch(&self, path: &str, payload: &[u8], sig: &str) -> Result { ... }\n+}" } + }, + "meta": { "intent": "MERGE Stripe+PayPal webhooks; build router" } + }, + + { + "step": { "id": "s11", "parents": ["s10"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:14:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Now the infrastructure layer. Building idempotency wrapper and retry queue in parallel.", "tool_uses": ["Write"] } }, + "src/payments/idempotency.rs": { "raw": "@@ -0,0 +1,30 @@\n+pub struct IdempotentProvider { inner: P, cache: RwLock> }\n+impl PaymentProvider for IdempotentProvider

{ ... }" } + }, + "meta": { "intent": "Idempotency wrapper" } + }, + { + "step": { "id": "s12a", "parents": ["s10"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:15:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Trying tokio mpsc channels for the retry queue.", "tool_uses": ["Write"] } }, + "src/payments/retry.rs": { "raw": "@@ -0,0 +1,15 @@\n+use tokio::sync::mpsc;\n+// Channel-based: doesn't survive restarts, abandoned" } + }, + "meta": { "intent": "Retry queue via tokio channels (abandoned: not durable)" } + }, + { + "step": { "id": "s12b", "parents": ["s10"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:16:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "PostgreSQL-backed retry queue with advisory locks.", "tool_uses": ["Write", "Write"] } }, + "src/payments/retry.rs": { "raw": "@@ -0,0 +1,40 @@\n+pub struct RetryQueue { pool: PgPool, max_retries: u32 }\n+impl RetryQueue {\n+ pub async fn enqueue(&self, req: &ChargeRequest) -> Result<(), sqlx::Error> { ... }\n+ pub async fn process_pending(&self) -> Result { ... }\n+}" }, + "migrations/001_retry_queue.sql": { "raw": "@@ -0,0 +1,8 @@\n+CREATE TABLE payment_retry_queue (\n+ id BIGSERIAL PRIMARY KEY,\n+ payload JSONB NOT NULL,\n+ attempts INT NOT NULL DEFAULT 0,\n+ next_at TIMESTAMPTZ NOT NULL DEFAULT now()\n+);" } + }, + "meta": { "intent": "PostgreSQL retry queue with advisory locks" } + }, + { + "step": { "id": "s13", "parents": ["s12b"], "actor": "tool:sqlx-cli/0.7.4", "timestamp": "2026-04-01T09:16:30Z" }, + "change": { "migrations/001_retry_queue.sql": { "structural": { "type": "migration.verify", "status": "valid" } } }, + "meta": { "intent": "Validate migration" } + }, + + { + "step": { "id": "s14", "parents": ["s11", "s13"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:18:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Idempotency and retry queue both ready. Composing them into PaymentService.", "tool_uses": ["Write", "Edit"] } }, + "src/payments/service.rs": { "raw": "@@ -0,0 +1,35 @@\n+use tracing::{instrument, info, warn};\n+pub struct PaymentService { provider: Box, retry_queue: RetryQueue, webhook_router: WebhookRouter }\n+impl PaymentService {\n+ #[instrument(skip(self))]\n+ pub async fn process_charge(&self, amount: u64, currency: &str, source: &str) -> Result { ... }\n+ pub async fn handle_webhook(&self, path: &str, payload: &[u8], sig: &str) -> Result<(), PaymentError> { ... }\n+}" }, + "src/payments/mod.rs": { "raw": "@@ -4,0 +5,3 @@\n+pub mod service;\n+pub mod stripe_webhook;\n+pub mod paypal_webhook;\n+pub mod webhook_router;" } + }, + "meta": { "intent": "MERGE idempotency+retry; build PaymentService" } + }, + + { + "step": { "id": "s15", "parents": ["s14"], "actor": "human:priya", "timestamp": "2026-04-01T09:20:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "user", "text": "Looks great. Write comprehensive tests — unit tests for each provider and integration tests for the full flow." } } + }, + "meta": { "intent": "Request test suite" } + }, + + { + "step": { "id": "s16", "parents": ["s15"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:22:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Writing Stripe unit tests with mock HTTP responses.", "tool_uses": ["Write"] } }, + "tests/stripe_test.rs": { "raw": "@@ -0,0 +1,40 @@\n+// Mock Stripe API, test charge/refund/error paths" } + }, + "meta": { "intent": "Stripe unit tests" } + }, + { + "step": { "id": "s17", "parents": ["s15"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:23:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Writing PayPal unit tests and webhook verification tests.", "tool_uses": ["Write"] } }, + "tests/paypal_test.rs": { "raw": "@@ -0,0 +1,35 @@\n+// Mock PayPal API, test OAuth token exchange, charge, webhook verification" } + }, + "meta": { "intent": "PayPal unit tests" } + }, + { + "step": { "id": "s18a", "parents": ["s15"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:24:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Trying testcontainers for integration tests with real Postgres.", "tool_uses": ["Write", "Bash"] } }, + "tests/integration_test.rs": { "raw": "@@ -0,0 +1,10 @@\n+use testcontainers::clients::Cli;\n+// Abandoned: adds 30s startup, flaky in CI" } + }, + "meta": { "intent": "Integration tests with testcontainers (abandoned: too slow)" } + }, + { + "step": { "id": "s18b", "parents": ["s15"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:25:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "End-to-end tests with mock providers and in-memory SQLite for the retry queue.", "tool_uses": ["Write"] } }, + "tests/e2e_test.rs": { "raw": "@@ -0,0 +1,50 @@\n+// Full lifecycle: charge -> webhook -> retry\n+// Uses mock providers and SQLite instead of Postgres for speed" } + }, + "meta": { "intent": "E2E tests with mocks and SQLite" } + }, + + { + "step": { "id": "s19", "parents": ["s16", "s17", "s18b"], "actor": "tool:cargo-test", "timestamp": "2026-04-01T09:27:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "test.run", "passed": 18, "failed": 2, "suite": "tests/" } } + }, + "meta": { "intent": "Test run: 18 passed, 2 failed (webhook sig)" } + }, + + { + "step": { "id": "s20", "parents": ["s19"], "actor": "agent:claude-opus-4-6/payments-v2", "timestamp": "2026-04-01T09:29:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "conversation.append", "role": "assistant", "text": "Fixed webhook signature comparison — was comparing raw bytes vs hex.", "tool_uses": ["Edit", "Edit"] } }, + "src/payments/stripe_webhook.rs": { "raw": "@@ -16,1 +16,1 @@\n-raw bytes comparison\n+hex-encoded comparison" }, + "src/payments/paypal_webhook.rs": { "raw": "@@ -15,1 +15,3 @@\n-simple check\n+certificate-based verification" } + }, + "meta": { "intent": "Fix webhook signature tests" } + }, + { + "step": { "id": "s21", "parents": ["s20"], "actor": "tool:cargo-test", "timestamp": "2026-04-01T09:30:00Z" }, + "change": { + "claude://payments-v2": { "structural": { "type": "test.run", "passed": 20, "failed": 0, "suite": "tests/" } } + }, + "meta": { "intent": "All 20 tests pass" } + }, + { + "step": { "id": "s22", "parents": ["s21"], "actor": "tool:rustfmt/1.7.0", "timestamp": "2026-04-01T09:30:30Z" }, + "change": { + "src/payments/service.rs": { "raw": "@@ formatting @@" }, + "src/payments/provider.rs": { "raw": "@@ formatting @@" } + }, + "meta": { "intent": "Final formatting pass" } + }, + { + "step": { "id": "s23", "parents": ["s22"], "actor": "tool:clippy/0.1.84", "timestamp": "2026-04-01T09:31:00Z" }, + "change": { + "src/payments/idempotency.rs": { "structural": { "type": "lint.fix", "rule": "clippy::manual_map" } }, + "src/payments/retry.rs": { "structural": { "type": "lint.fix", "rule": "clippy::redundant_closure" } } + }, + "meta": { "intent": "Final clippy fixes" } + }, + { + "step": { "id": "s24", "parents": ["s23"], "actor": "human:priya", "timestamp": "2026-04-01T09:35:00Z" }, + "change": { + "src/lib.rs": { "raw": "@@ -0,0 +1,2 @@\n+pub mod payments;\n+pub use payments::service::PaymentService;" }, + "README.md": { "raw": "@@ -0,0 +1,10 @@\n+# Payments v2\n+Stripe + PayPal with webhooks, idempotency, and retry" } + }, + "meta": { "intent": "Finalize public API and README" } + } + ], + + "meta": { + "title": "Payments v2: Stripe + PayPal with webhooks, idempotency, and retry", + "source": "agent://claude-code/payments-v2", + "intent": "Complete rewrite of payments module with dual-provider support, webhook handling, idempotency keys, and a durable retry queue", + "refs": [ + {"rel": "issue", "href": "https://github.com/acme/payments/issues/89"}, + {"rel": "pr", "href": "https://github.com/acme/payments/pull/102"} + ], + "actors": { + "human:priya": { "name": "Priya Sharma", "identities": [{"system": "github", "id": "priyash"}] }, + "agent:claude-opus-4-6/payments-v2": { "name": "Claude Code", "provider": "Anthropic", "model": "claude-opus-4-6" }, + "tool:rustfmt/1.7.0": { "name": "rustfmt" }, + "tool:clippy/0.1.84": { "name": "Clippy" }, + "tool:cargo-test": { "name": "cargo test" }, + "tool:sqlx-cli/0.7.4": { "name": "sqlx migrate" } + } + } + } +} From c42107ddfdb52ccfacd0910f878939db29897cf2 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 30 Mar 2026 10:49:52 -0400 Subject: [PATCH 2/2] various fixes in redaction tui --- crates/toolpath-claude/src/derive.rs | 332 ++++++++++++++++----------- crates/toolpath-tui/src/app.rs | 179 +++++++++------ crates/toolpath-tui/src/model.rs | 81 ++++--- crates/toolpath-tui/src/redact.rs | 39 ++-- crates/toolpath-tui/src/render.rs | 80 ++++--- 5 files changed, 419 insertions(+), 292 deletions(-) diff --git a/crates/toolpath-claude/src/derive.rs b/crates/toolpath-claude/src/derive.rs index d7b0d91..8297e7f 100644 --- a/crates/toolpath-claude/src/derive.rs +++ b/crates/toolpath-claude/src/derive.rs @@ -5,7 +5,7 @@ //! operation. File mutations from tool use (Write, Edit) appear as sibling //! artifacts in the same step's `change` map. -use crate::types::{ContentPart, Conversation, MessageContent, MessageRole}; +use crate::types::{ContentPart, Conversation, ConversationEntry, MessageContent, MessageRole}; use serde_json::json; use std::collections::HashMap; use toolpath::v1::{ @@ -22,150 +22,214 @@ pub struct DeriveConfig { pub include_thinking: bool, } -/// Derive a single Toolpath Path from a Claude conversation. -/// -/// The conversation is modeled as an artifact at `claude://`. -/// Each user or assistant turn produces a step whose `change` map contains -/// a `conversation.append` structural change on that artifact, plus any -/// file-level artifacts touched by tool use. -pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { - let session_short = safe_prefix(&conversation.session_id, 8); - let convo_artifact = format!("claude://{}", conversation.session_id); - - let mut steps = Vec::new(); - let mut last_step_id: Option = None; - let mut actors: HashMap = HashMap::new(); +/// The result of deriving a single step from a conversation entry. +pub struct DerivedStep { + /// The toolpath step. + pub step: Step, + /// The actor key (e.g. `"human:user"` or `"agent:claude-opus-4-6"`). + pub actor_key: String, + /// The actor definition for this step's actor. + pub actor_def: ActorDefinition, +} - for entry in &conversation.entries { - if entry.uuid.is_empty() { - continue; - } +/// Derive a single Toolpath [`Step`] from a Claude conversation entry. +/// +/// Returns `None` for entries that should be skipped (empty UUID, no message, +/// system messages, or entries with no text/tool-use/file-change content). +/// +/// The caller provides `session_id` (used to build the `claude://` +/// artifact key) and `parents` (the parent step IDs for DAG construction). +/// +/// # Example +/// +/// ``` +/// use toolpath_claude::derive::{derive_step, DeriveConfig}; +/// use toolpath_claude::types::{ConversationEntry, Message, MessageContent, MessageRole}; +/// +/// let entry = ConversationEntry { +/// uuid: "abc-123".into(), +/// timestamp: "2024-01-01T00:00:00Z".into(), +/// entry_type: "user".into(), +/// message: Some(Message { +/// role: MessageRole::User, +/// content: Some(MessageContent::Text("Fix the bug".into())), +/// model: None, id: None, message_type: None, +/// stop_reason: None, stop_sequence: None, usage: None, +/// }), +/// parent_uuid: None, is_sidechain: false, session_id: None, +/// cwd: None, git_branch: None, version: None, user_type: None, +/// request_id: None, tool_use_result: None, snapshot: None, +/// message_id: None, extra: Default::default(), +/// }; +/// +/// let config = DeriveConfig::default(); +/// let result = derive_step(&entry, "session-42", vec![], &config); +/// assert!(result.is_some()); +/// +/// let derived = result.unwrap(); +/// assert_eq!(derived.step.step.actor, "human:user"); +/// assert!(derived.step.change.contains_key("claude://session-42")); +/// ``` +pub fn derive_step( + entry: &ConversationEntry, + session_id: &str, + parents: Vec, + config: &DeriveConfig, +) -> Option { + if entry.uuid.is_empty() { + return None; + } - let message = match &entry.message { - Some(m) => m, - None => continue, - }; + let message = entry.message.as_ref()?; - let (actor, role_str) = match message.role { - MessageRole::User => { - actors - .entry("human:user".to_string()) - .or_insert_with(|| ActorDefinition { - name: Some("User".to_string()), - ..Default::default() - }); - ("human:user".to_string(), "user") - } - MessageRole::Assistant => { - let (actor_key, model_str) = if let Some(model) = &message.model { - (format!("agent:{}", model), model.clone()) - } else { - ("agent:claude-code".to_string(), "claude-code".to_string()) - }; - actors.entry(actor_key.clone()).or_insert_with(|| { - let mut identities = vec![Identity { - system: "anthropic".to_string(), - id: model_str.clone(), - }]; - if let Some(version) = &entry.version { - identities.push(Identity { - system: "claude-code".to_string(), - id: version.clone(), - }); - } - ActorDefinition { - name: Some("Claude Code".to_string()), - provider: Some("anthropic".to_string()), - model: Some(model_str), - identities, - ..Default::default() - } + let (actor_key, actor_def, role_str) = match message.role { + MessageRole::User => ( + "human:user".to_string(), + ActorDefinition { + name: Some("User".to_string()), + ..Default::default() + }, + "user", + ), + MessageRole::Assistant => { + let (key, model_str) = if let Some(model) = &message.model { + (format!("agent:{}", model), model.clone()) + } else { + ("agent:claude-code".to_string(), "claude-code".to_string()) + }; + let mut identities = vec![Identity { + system: "anthropic".to_string(), + id: model_str.clone(), + }]; + if let Some(version) = &entry.version { + identities.push(Identity { + system: "claude-code".to_string(), + id: version.clone(), }); - (actor_key, "assistant") } - MessageRole::System => continue, - }; - - // Collect conversation text and file changes from this turn - let mut file_changes: HashMap = HashMap::new(); - let mut text_parts: Vec = Vec::new(); - let mut tool_uses: Vec = Vec::new(); - - match &message.content { - Some(MessageContent::Parts(parts)) => { - for part in parts { - match part { - ContentPart::Text { text } => { - if !text.trim().is_empty() { - text_parts.push(text.clone()); - } + let def = ActorDefinition { + name: Some("Claude Code".to_string()), + provider: Some("anthropic".to_string()), + model: Some(model_str), + identities, + ..Default::default() + }; + (key, def, "assistant") + } + MessageRole::System => return None, + }; + + // Collect conversation text and file changes from this turn + let mut file_changes: HashMap = HashMap::new(); + let mut text_parts: Vec = Vec::new(); + let mut tool_uses: Vec = Vec::new(); + + match &message.content { + Some(MessageContent::Parts(parts)) => { + for part in parts { + match part { + ContentPart::Text { text } => { + if !text.trim().is_empty() { + text_parts.push(text.clone()); } - ContentPart::Thinking { thinking, .. } => { - if config.include_thinking && !thinking.trim().is_empty() { - text_parts.push(format!("[thinking] {}", thinking)); - } + } + ContentPart::Thinking { thinking, .. } => { + if config.include_thinking && !thinking.trim().is_empty() { + text_parts.push(format!("[thinking] {}", thinking)); } - ContentPart::ToolUse { name, input, .. } => { - tool_uses.push(name.clone()); - if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) - { - match name.as_str() { - "Write" | "Edit" => { - file_changes.insert( - file_path.to_string(), - ArtifactChange { - raw: None, - structural: None, - }, - ); - } - _ => {} + } + ContentPart::ToolUse { name, input, .. } => { + tool_uses.push(name.clone()); + if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) { + match name.as_str() { + "Write" | "Edit" => { + file_changes.insert( + file_path.to_string(), + ArtifactChange { + raw: None, + structural: None, + }, + ); } + _ => {} } } - _ => {} } + _ => {} } } - Some(MessageContent::Text(text)) => { - if !text.trim().is_empty() { - text_parts.push(text.clone()); - } + } + Some(MessageContent::Text(text)) => { + if !text.trim().is_empty() { + text_parts.push(text.clone()); } - None => {} } + None => {} + } - // Skip entries with no conversation content and no file changes - if text_parts.is_empty() && tool_uses.is_empty() && file_changes.is_empty() { - continue; - } + // Skip entries with no conversation content and no file changes + if text_parts.is_empty() && tool_uses.is_empty() && file_changes.is_empty() { + return None; + } - // Build the conversation artifact change - let mut convo_extra = HashMap::new(); - convo_extra.insert("role".to_string(), json!(role_str)); - if !text_parts.is_empty() { - let combined = text_parts.join("\n\n"); - convo_extra.insert("text".to_string(), json!(truncate(&combined, 2000))); - } - if !tool_uses.is_empty() { - convo_extra.insert("tool_uses".to_string(), json!(tool_uses.clone())); - } + // Build the conversation artifact change + let convo_artifact = format!("claude://{}", session_id); + let mut convo_extra = HashMap::new(); + convo_extra.insert("role".to_string(), json!(role_str)); + if !text_parts.is_empty() { + let combined = text_parts.join("\n\n"); + convo_extra.insert("text".to_string(), json!(truncate(&combined, 2000))); + } + if !tool_uses.is_empty() { + convo_extra.insert("tool_uses".to_string(), json!(tool_uses.clone())); + } - let convo_change = ArtifactChange { - raw: None, - structural: Some(StructuralChange { - change_type: "conversation.append".to_string(), - extra: convo_extra, - }), - }; + let convo_change = ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.append".to_string(), + extra: convo_extra, + }), + }; - let mut changes = HashMap::new(); - changes.insert(convo_artifact.clone(), convo_change); - changes.extend(file_changes); + let mut changes = HashMap::new(); + changes.insert(convo_artifact, convo_change); + changes.extend(file_changes); + + let step_id = format!("step-{}", safe_prefix(&entry.uuid, 8)); + + let step = Step { + step: StepIdentity { + id: step_id, + parents, + actor: actor_key.clone(), + timestamp: entry.timestamp.clone(), + }, + change: changes, + meta: None, + }; + + Some(DerivedStep { + step, + actor_key, + actor_def, + }) +} - // Build step — no meta.intent; the conversation content already - // lives in the structural change and adding it again is redundant. - let step_id = format!("step-{}", safe_prefix(&entry.uuid, 8)); +/// Derive a single Toolpath Path from a Claude conversation. +/// +/// The conversation is modeled as an artifact at `claude://`. +/// Each user or assistant turn produces a step whose `change` map contains +/// a `conversation.append` structural change on that artifact, plus any +/// file-level artifacts touched by tool use. +pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { + let session_short = safe_prefix(&conversation.session_id, 8); + + let mut steps = Vec::new(); + let mut last_step_id: Option = None; + let mut actors: HashMap = HashMap::new(); + + for entry in &conversation.entries { let parents = if entry.is_sidechain { entry .parent_uuid @@ -176,21 +240,17 @@ pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path { last_step_id.iter().cloned().collect() }; - let step = Step { - step: StepIdentity { - id: step_id.clone(), - parents, - actor, - timestamp: entry.timestamp.clone(), - }, - change: changes, - meta: None, + let derived = match derive_step(entry, &conversation.session_id, parents, config) { + Some(d) => d, + None => continue, }; + actors.entry(derived.actor_key).or_insert(derived.actor_def); + if !entry.is_sidechain { - last_step_id = Some(step_id); + last_step_id = Some(derived.step.step.id.clone()); } - steps.push(step); + steps.push(derived.step); } let head = last_step_id.unwrap_or_else(|| "empty".to_string()); diff --git a/crates/toolpath-tui/src/app.rs b/crates/toolpath-tui/src/app.rs index 47b4434..89edc22 100644 --- a/crates/toolpath-tui/src/app.rs +++ b/crates/toolpath-tui/src/app.rs @@ -22,7 +22,6 @@ struct MouseDrag { } /// Undo entry. -#[allow(dead_code)] enum UndoAction { ToggleInclude { index: usize, was_included: bool }, RangeExclude { indices: Vec }, @@ -65,6 +64,8 @@ pub struct TraceTuiApp { mouse_drag: Option, /// Undo stack. undo_stack: Vec, + /// Last search matches (persisted after exiting search mode for n/N navigation). + last_search_matches: Vec, /// Whether the user exported. exported: Option, } @@ -90,6 +91,7 @@ impl TraceTuiApp { visible_width: 120, mouse_drag: None, undo_stack: Vec::new(), + last_search_matches: Vec::new(), exported: None, } } @@ -103,10 +105,10 @@ impl TraceTuiApp { terminal.draw(|frame| render::render(&mut *self, frame))?; // Clear expired flash messages. - if let Some((_, ts)) = &self.flash { - if ts.elapsed().as_secs() >= 3 { - self.flash = None; - } + if let Some((_, ts)) = &self.flash + && ts.elapsed().as_secs() >= 3 + { + self.flash = None; } if event::poll(std::time::Duration::from_millis(100))? { @@ -281,11 +283,9 @@ impl TraceTuiApp { let hi = (*anchor).max(*cursor); let mut excluded = Vec::new(); for i in 0..self.entries.len() { - if i < lo || i > hi { - if self.entries[i].included { - self.entries[i].included = false; - excluded.push(i); - } + if (i < lo || i > hi) && self.entries[i].included { + self.entries[i].included = false; + excluded.push(i); } } self.undo_stack @@ -379,8 +379,9 @@ impl TraceTuiApp { fn handle_search_key(&mut self, key: KeyEvent) -> bool { match key.code { KeyCode::Enter => { - // Finalize search, jump to first match. + // Finalize search, jump to first match, persist matches for n/N. if let Mode::Search { matches, .. } = &self.mode { + self.last_search_matches = matches.clone(); if let Some(&first) = matches.first() { self.cursor = first; self.ensure_cursor_visible(); @@ -389,6 +390,10 @@ impl TraceTuiApp { self.mode = Mode::Normal; } KeyCode::Esc => { + // Persist matches even on Esc so n/N still works. + if let Mode::Search { matches, .. } = &self.mode { + self.last_search_matches = matches.clone(); + } self.mode = Mode::Normal; } KeyCode::Backspace => { @@ -428,10 +433,7 @@ impl TraceTuiApp { fn next_search_match(&mut self, reverse: bool) { let matches = match &self.mode { Mode::Search { matches, .. } => matches.clone(), - _ => { - // Use last search matches if available. - return; - } + _ => self.last_search_matches.clone(), }; if matches.is_empty() { return; @@ -464,24 +466,23 @@ impl TraceTuiApp { self.sub_line = sub; // If clicking on a field line, start text drag. - if sub > 0 { - if let Some(fl) = self.get_field_line_at(step_idx, sub) { - if let Some(ptr) = fl.json_pointer.clone() { - let char_offset = self.col_to_char_offset(col, &fl); - self.mouse_drag = Some(MouseDrag { - step_index: step_idx, - json_pointer: ptr.clone(), - start_offset: char_offset, - }); - self.selection = SelectionMode::Visual { - step_index: step_idx, - json_pointer: ptr, - anchor: char_offset, - cursor: char_offset, - }; - return; - } - } + if sub > 0 + && let Some(fl) = self.get_field_line_at(step_idx, sub) + && let Some(ptr) = fl.json_pointer.clone() + { + let char_offset = self.col_to_char_offset(col, &fl); + self.mouse_drag = Some(MouseDrag { + step_index: step_idx, + json_pointer: ptr.clone(), + start_offset: char_offset, + }); + self.selection = SelectionMode::Visual { + step_index: step_idx, + json_pointer: ptr, + anchor: char_offset, + cursor: char_offset, + }; + return; } // Step-level click. @@ -513,20 +514,19 @@ impl TraceTuiApp { return; } let display_line = self.scroll + row - 1; - if let Some((step_idx, sub)) = self.display_line_to_cursor(display_line) { - if step_idx == drag.step_index && sub > 0 { - if let Some(fl) = self.get_field_line_at(step_idx, sub) { - if fl.json_pointer.as_deref() == Some(&drag.json_pointer) { - let char_offset = self.col_to_char_offset(col, &fl); - self.selection = SelectionMode::Visual { - step_index: drag.step_index, - json_pointer: drag.json_pointer.clone(), - anchor: drag.start_offset, - cursor: char_offset, - }; - } - } - } + if let Some((step_idx, sub)) = self.display_line_to_cursor(display_line) + && step_idx == drag.step_index + && sub > 0 + && let Some(fl) = self.get_field_line_at(step_idx, sub) + && fl.json_pointer.as_deref() == Some(&drag.json_pointer) + { + let char_offset = self.col_to_char_offset(col, &fl); + self.selection = SelectionMode::Visual { + step_index: drag.step_index, + json_pointer: drag.json_pointer.clone(), + anchor: drag.start_offset, + cursor: char_offset, + }; } } } @@ -836,6 +836,7 @@ impl TraceTuiApp { } /// Move visual mode cursor by delta. Anchor stays fixed (always extends). + /// Skips newline characters so the cursor doesn't land on invisible positions. fn visual_char_move(&mut self, delta: isize) { if let SelectionMode::Visual { step_index, @@ -844,8 +845,23 @@ impl TraceTuiApp { cursor, } = self.selection.clone() { - let len = self.visual_char_field_len_for(&step_index, &json_pointer); - let new_cursor = (cursor as isize + delta).max(0).min(len as isize) as usize; + let value = match self.get_field_value(&step_index, &json_pointer) { + Some(v) => v, + None => return, + }; + let len = value.len(); + let mut new_cursor = (cursor as isize + delta).max(0).min(len as isize) as usize; + // Skip over newline characters. + let bytes = value.as_bytes(); + if delta > 0 { + while new_cursor < len && bytes[new_cursor] == b'\n' { + new_cursor += 1; + } + } else if delta < 0 { + while new_cursor > 0 && bytes[new_cursor] == b'\n' { + new_cursor -= 1; + } + } self.selection = SelectionMode::Visual { step_index, json_pointer, @@ -897,18 +913,24 @@ impl TraceTuiApp { cursor, .. } = self.selection.clone() + && let Some(value) = self.get_field_value(&step_index, json_pointer) { - if let Some(value) = self.get_field_value(&step_index, &json_pointer) { - let chars: Vec = value.chars().collect(); - let mut pos = cursor; - while pos < chars.len() && !chars[pos].is_whitespace() { - pos += 1; - } - while pos < chars.len() && chars[pos].is_whitespace() { + // Work in byte offsets to stay consistent with the rest of the system. + let bytes = value.as_bytes(); + let mut pos = cursor; + // Skip non-whitespace. + while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() { + pos += 1; + // Advance past any multi-byte char. + while pos < bytes.len() && !value.is_char_boundary(pos) { pos += 1; } - self.visual_char_set_cursor(pos); } + // Skip whitespace. + while pos < bytes.len() && bytes[pos].is_ascii_whitespace() { + pos += 1; + } + self.visual_char_set_cursor(pos); } } @@ -919,48 +941,59 @@ impl TraceTuiApp { cursor, .. } = self.selection.clone() + && let Some(value) = self.get_field_value(&step_index, json_pointer) { - if let Some(value) = self.get_field_value(&step_index, &json_pointer) { - let chars: Vec = value.chars().collect(); - let mut pos = cursor; - while pos > 0 && chars[pos.saturating_sub(1)].is_whitespace() { - pos -= 1; - } - while pos > 0 && !chars[pos.saturating_sub(1)].is_whitespace() { + // Work in byte offsets to stay consistent with the rest of the system. + let bytes = value.as_bytes(); + let mut pos = cursor; + // Skip whitespace backward. + while pos > 0 && bytes[pos - 1].is_ascii_whitespace() { + pos -= 1; + } + // Skip non-whitespace backward. + while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() { + pos -= 1; + // Retreat to char boundary. + while pos > 0 && !value.is_char_boundary(pos) { pos -= 1; } - self.visual_char_set_cursor(pos); } + self.visual_char_set_cursor(pos); } } + /// Move visual cursor to the next/prev field. Left/right wraps within a field; + /// j/k jumps between distinct fields. fn visual_char_next_field(&mut self, direction: isize) { if let SelectionMode::Visual { step_index, ref json_pointer, + anchor, .. } = self.selection.clone() { let field_lines = model::build_field_lines(&self.entries[step_index].step, self.field_wrap_width()); - let string_fields: Vec<&model::FieldLine> = field_lines + + // Distinct fields only (first line of each field). + let fields: Vec<&model::FieldLine> = field_lines .iter() .filter(|fl| fl.json_pointer.is_some() && fl.value_offset == 0) .collect(); - let current_idx = string_fields + let current_idx = fields .iter() - .position(|fl| fl.json_pointer.as_deref() == Some(&json_pointer)); + .position(|fl| fl.json_pointer.as_deref() == Some(json_pointer)); if let Some(idx) = current_idx { let new_idx = (idx as isize + direction) .max(0) - .min(string_fields.len() as isize - 1) as usize; - if let Some(new_ptr) = &string_fields[new_idx].json_pointer { + .min(fields.len() as isize - 1) as usize; + if let Some(new_ptr) = &fields[new_idx].json_pointer { self.selection = SelectionMode::Visual { step_index, json_pointer: new_ptr.clone(), - anchor: 0, + anchor, cursor: 0, }; } @@ -987,7 +1020,7 @@ impl TraceTuiApp { .unwrap_or(0) } - fn get_field_value(&self, step_index: &usize, json_pointer: &str) -> Option { + pub fn get_field_value(&self, step_index: &usize, json_pointer: &str) -> Option { let entry = self.entries.get(*step_index)?; let value = serde_json::to_value(&entry.step).ok()?; let target = value.pointer(json_pointer)?; @@ -1007,7 +1040,7 @@ impl TraceTuiApp { let sel_end = anchor.max(cursor) + 1; // +1: inclusive to exclusive // Get the original text for undo. - let original = match self.get_field_value(&step_index, &json_pointer) { + let original = match self.get_field_value(&step_index, json_pointer) { Some(v) if sel_end <= v.len() => v[sel_start..sel_end].to_string(), _ => return, }; diff --git a/crates/toolpath-tui/src/model.rs b/crates/toolpath-tui/src/model.rs index 0e84bfd..505c429 100644 --- a/crates/toolpath-tui/src/model.rs +++ b/crates/toolpath-tui/src/model.rs @@ -183,10 +183,24 @@ fn walk_value( value_len, }); } else { - // Wrap at continuation_width. + // Wrap at continuation_width, respecting char boundaries. let mut chunk_start = 0; while chunk_start < display.len() { - let chunk_end = (chunk_start + continuation_width).min(display.len()); + let target = (chunk_start + continuation_width).min(display.len()); + // Find a valid char boundary at or before the target. + let mut chunk_end = target; + while chunk_end > chunk_start && !display.is_char_boundary(chunk_end) { + chunk_end -= 1; + } + if chunk_end == chunk_start { + // Safety: advance to the next char boundary. + chunk_end = target; + while chunk_end < display.len() + && !display.is_char_boundary(chunk_end) + { + chunk_end += 1; + } + } let chunk = &display[chunk_start..chunk_end]; lines.push(FieldLine { json_pointer: Some(pointer.to_string()), @@ -296,10 +310,10 @@ fn build_summary(step: &v1::Step) -> String { { // Look for conversation.append structural changes. for change in step.change.values() { - if let Some(ref structural) = change.structural { - if structural.change_type == "conversation.append" { - return build_conversation_summary(structural, actor); - } + if let Some(ref structural) = change.structural + && structural.change_type == "conversation.append" + { + return build_conversation_summary(structural, actor); } } // Fallback: try raw text from any change. @@ -313,29 +327,29 @@ fn build_summary(step: &v1::Step) -> String { // Policy steps. if actor == "agent:clash-policy" { for change in step.change.values() { - if let Some(ref structural) = change.structural { - if structural.change_type == "policy_evaluation" { - let effect = structural - .extra - .get("effect") - .and_then(|v| v.as_str()) - .unwrap_or("?"); - let tool = structural - .extra - .get("tool_name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - return format!("{effect} {tool}").trim().to_string(); - } + if let Some(ref structural) = change.structural + && structural.change_type == "policy_evaluation" + { + let effect = structural + .extra + .get("effect") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let tool = structural + .extra + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + return format!("{effect} {tool}").trim().to_string(); } } } // Fallback. - if let Some(ref meta) = step.meta { - if let Some(ref intent) = meta.intent { - return truncate_first_line(intent, 80); - } + if let Some(ref meta) = step.meta + && let Some(ref intent) = meta.intent + { + return truncate_first_line(intent, 80); } format!("{} — {}", actor, step.step.id) } @@ -370,7 +384,8 @@ fn build_conversation_summary(structural: &v1::StructuralChange, actor: &str) -> fn truncate_first_line(s: &str, max: usize) -> String { let first_line = s.lines().next().unwrap_or("").trim(); - if first_line.len() <= max { + let char_count = first_line.chars().count(); + if char_count <= max { first_line.to_string() } else { let truncated: String = first_line.chars().take(max.saturating_sub(1)).collect(); @@ -429,11 +444,11 @@ pub fn compute_graph_layout(steps: &[v1::Step], head_id: &str) -> Vec let is_on_main = ancestor_ids.contains(id); let is_dead = !is_on_main; let is_leaf = !has_children.contains(id); - let has_branch = children_of.get(id).map_or(false, |c| c.len() > 1); + let has_branch = children_of.get(id).is_some_and(|c| c.len() > 1); // If this step merges AND branches, pre-free merged columns so // branch children can reuse them immediately. - if step.step.parents.len() > 1 && children_of.get(id).map_or(false, |k| k.len() > 1) { + if step.step.parents.len() > 1 && children_of.get(id).is_some_and(|k| k.len() > 1) { for parent in &step.step.parents { let parent_col = col_of.get(parent.as_str()).copied().unwrap_or(0); if parent_col != my_col && !free_cols.contains(&parent_col) { @@ -590,12 +605,10 @@ pub fn compute_graph_layout(steps: &[v1::Step], head_id: &str) -> Vec // If this step branches, activate all child columns immediately // so continuation lines appear between branch point and child. - if has_branch { - if let Some(kids) = children_of.get(id) { - for &kid_idx in kids { - let kid_col = col_of[steps[kid_idx].step.id.as_str()]; - active.insert(kid_col); - } + if has_branch && let Some(kids) = children_of.get(id) { + for &kid_idx in kids { + let kid_col = col_of[steps[kid_idx].step.id.as_str()]; + active.insert(kid_col); } } @@ -710,7 +723,7 @@ mod tests { extra.insert("effect".to_string(), serde_json::json!(effect)); extra.insert("tool_name".to_string(), serde_json::json!(tool_name)); step.change.insert( - "clash://policy/evaluations".to_string(), + "toolpath://policy/evaluations".to_string(), ArtifactChange { raw: None, structural: Some(StructuralChange { diff --git a/crates/toolpath-tui/src/redact.rs b/crates/toolpath-tui/src/redact.rs index a0f5272..d1c7d72 100644 --- a/crates/toolpath-tui/src/redact.rs +++ b/crates/toolpath-tui/src/redact.rs @@ -1,6 +1,6 @@ //! Pure redaction logic: given step entries with redaction state, produce a redacted Document. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use toolpath::v1; @@ -50,7 +50,8 @@ pub fn build_redacted_document(original: &v1::Path, entries: &[StepEntry]) -> v1 .map(|p| id_remap.get(p).cloned().unwrap_or_else(|| p.clone())) .collect(); // Deduplicate parents (multiple excluded parents may map to same placeholder). - step.step.parents.dedup(); + let mut seen = HashSet::new(); + step.step.parents.retain(|p| seen.insert(p.clone())); } // Update head. @@ -84,7 +85,7 @@ fn build_placeholder_step(id: &str, first_excluded: &v1::Step, count: usize) -> let mut extra = HashMap::new(); extra.insert("count".to_string(), serde_json::json!(count)); change.insert( - "clash://redaction".to_string(), + "toolpath://redaction".to_string(), v1::ArtifactChange { raw: None, structural: Some(v1::StructuralChange { @@ -135,12 +136,12 @@ fn apply_text_redactions(step: &mut v1::Step, redactions: &[crate::model::TextRe ranges.sort(); let mut merged: Vec<(usize, usize)> = Vec::new(); for (s, e) in ranges { - if let Some(last) = merged.last_mut() { - if s <= last.1 { - // Overlapping or adjacent — merge. - last.1 = last.1.max(e); - continue; - } + if let Some(last) = merged.last_mut() + && s <= last.1 + { + // Overlapping or adjacent — merge. + last.1 = last.1.max(e); + continue; } merged.push((s, e)); } @@ -148,18 +149,18 @@ fn apply_text_redactions(step: &mut v1::Step, redactions: &[crate::model::TextRe // Apply from the end so earlier offsets remain valid. merged.reverse(); - if let Some(target) = value.pointer_mut(pointer) { - if let Some(s) = target.as_str().map(|s| s.to_string()) { - let mut result = s; - for (start, end) in &merged { - if *start <= result.len() && *end <= result.len() && start <= end { - let char_count = end - start; - let replacement = format!("[REDACTED({char_count})]"); - result.replace_range(*start..*end, &replacement); - } + if let Some(target) = value.pointer_mut(pointer) + && let Some(s) = target.as_str().map(|s| s.to_string()) + { + let mut result = s; + for (start, end) in &merged { + if *start <= result.len() && *end <= result.len() && start <= end { + let char_count = end - start; + let replacement = format!("[REDACTED({char_count})]"); + result.replace_range(*start..*end, &replacement); } - *target = serde_json::Value::String(result); } + *target = serde_json::Value::String(result); } } diff --git a/crates/toolpath-tui/src/render.rs b/crates/toolpath-tui/src/render.rs index 9207b96..b696a55 100644 --- a/crates/toolpath-tui/src/render.rs +++ b/crates/toolpath-tui/src/render.rs @@ -157,7 +157,7 @@ fn render_step_list(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { )); // Pad to terminal width so highlight covers the full row. - let content_len: usize = spans.iter().map(|s| s.content.len()).sum(); + let content_len: usize = spans.iter().map(|s| s.width()).sum(); if content_len < term_width { spans.push(Span::raw(" ".repeat(term_width - content_len))); } @@ -214,12 +214,12 @@ fn render_step_list(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { None }; - // Cursor col — show block cursor on the cursor line OR on - // the visual mode cursor position. + // Pass cursor position unconditionally — build_field_spans + // handles checking if the cursor falls within this line's range. let cursor_col = if is_field_cursor && selection.is_none() { Some(app.text_col) } else if let Some((_, c)) = selection { - Some(c) // show cursor at the moving end of visual selection + Some(c) } else { None }; @@ -277,9 +277,18 @@ fn render_status_bar(app: &TraceTuiApp, frame: &mut Frame, area: Rect) { }; let position_text = match &app.selection { - SelectionMode::Visual { anchor, cursor, .. } => { + SelectionMode::Visual { + anchor, + cursor, + json_pointer, + step_index, + } => { let sel_len = (*anchor).max(*cursor) - (*anchor).min(*cursor) + 1; - format!(" col:{cursor} sel:{sel_len}") + let flen = app + .get_field_value(step_index, json_pointer) + .map(|v| v.len()) + .unwrap_or(0); + format!(" cur:{cursor}/{flen} anc:{anchor} sel:{sel_len} ptr:{json_pointer}") } _ if app.sub_line > 0 => format!(" col:{}", app.text_col), _ => String::new(), @@ -450,11 +459,11 @@ fn build_field_spans<'a>( ranges.sort(); let mut merged_redactions: Vec<(usize, usize)> = Vec::new(); for (s, e) in ranges { - if let Some(last) = merged_redactions.last_mut() { - if s <= last.1 { - last.1 = last.1.max(e); - continue; - } + if let Some(last) = merged_redactions.last_mut() + && s <= last.1 + { + last.1 = last.1.max(e); + continue; } merged_redactions.push((s, e)); } @@ -468,18 +477,23 @@ fn build_field_spans<'a>( (ls, le) }); - // Cursor position in display coords. - let cursor_display = cursor_col.map(|col| { - let col_in_line = col.saturating_sub(fl.value_offset); - value_start_in_display + col_in_line.min(fl.value_len) + // Cursor position in display coords — only if cursor falls within this line's range. + let cursor_display = cursor_col.and_then(|col| { + if col < fl.value_offset || col > fl.value_offset + fl.value_len { + return None; // cursor is on a different line + } + let col_in_line = col - fl.value_offset; + Some(value_start_in_display + col_in_line) }); - // Walk through display string, determine style at each position. + // Walk through display string by char (not byte) to avoid splitting multi-byte chars. + let char_indices: Vec<(usize, char)> = full.char_indices().collect(); let mut spans: Vec> = Vec::new(); - let mut pos = 0; - while pos < full.len() { + let mut i = 0; + while i < char_indices.len() { + let (byte_pos, _) = char_indices[i]; let style = char_style( - pos, + byte_pos, value_start_in_display, fl.value_len, &merged_redactions, @@ -492,11 +506,12 @@ fn build_field_spans<'a>( cursor_style, ); - let start = pos; - pos += 1; - while pos < full.len() { + let start_byte = byte_pos; + i += 1; + while i < char_indices.len() { + let (next_byte, _) = char_indices[i]; let next_style = char_style( - pos, + next_byte, value_start_in_display, fl.value_len, &merged_redactions, @@ -511,16 +526,21 @@ fn build_field_spans<'a>( if next_style != style { break; } - pos += 1; + i += 1; } - spans.push(Span::styled(full[start..pos].to_string(), style)); + let end_byte = if i < char_indices.len() { + char_indices[i].0 + } else { + full.len() + }; + spans.push(Span::styled(full[start_byte..end_byte].to_string(), style)); } // Cursor at end of value. - if let Some(cp) = cursor_display { - if cp >= full.len() { - spans.push(Span::styled(" ".to_string(), cursor_style)); - } + if let Some(cp) = cursor_display + && cp >= full.len() + { + spans.push(Span::styled(" ".to_string(), cursor_style)); } if spans.is_empty() { @@ -554,7 +574,7 @@ fn char_style( } let vpos = pos - value_start; let is_redacted = redactions.iter().any(|(s, e)| vpos >= *s && vpos < *e); - let is_selected = sel_range.map_or(false, |(s, e)| vpos >= s && vpos <= e); + let is_selected = sel_range.is_some_and(|(s, e)| vpos >= s && vpos <= e); match (is_selected, is_redacted) { (true, true) => sel_redact,