diff --git a/Cargo.lock b/Cargo.lock index 643a01d..edca5ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + [[package]] name = "async-compression" version = "0.4.42" @@ -115,6 +127,21 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[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.66" @@ -148,6 +175,20 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compression-codecs" version = "0.4.38" @@ -165,6 +206,17 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -183,6 +235,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -208,6 +269,72 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix 0.38.44", + "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 = "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", +] + +[[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", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "dirs" version = "5.0.1" @@ -246,6 +373,18 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -264,6 +403,12 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -319,6 +464,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -416,6 +567,15 @@ dependencies = [ "slab", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -602,9 +762,22 @@ dependencies = [ name = "harness-tui" version = "0.1.0" dependencies = [ + "anyhow", + "crossterm", + "dirs", + "futures", "harness-app", "harness-core", + "insta", + "pulldown-cmark", + "ratatui", + "serde_json", "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "tui-textarea", ] [[package]] @@ -616,15 +789,32 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashlink" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "http" version = "1.4.2" @@ -805,6 +995,12 @@ dependencies = [ "zerovec", ] +[[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" @@ -842,12 +1038,55 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -874,6 +1113,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -900,6 +1145,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -927,12 +1178,30 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.3" @@ -971,6 +1240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -985,6 +1255,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -1020,6 +1305,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1047,6 +1338,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" @@ -1065,6 +1362,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quinn" version = "0.11.11" @@ -1197,6 +1513,27 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1309,6 +1646,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1318,7 +1668,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1474,6 +1824,15 @@ dependencies = [ "serde", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -1486,6 +1845,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1536,12 +1916,52 @@ 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.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "2.0.118" @@ -1580,9 +2000,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -1626,6 +2046,45 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1763,6 +2222,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -1781,6 +2253,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1789,6 +2291,17 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui-textarea" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "ulid" version = "1.2.1" @@ -1800,12 +2313,47 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "untrusted" version = "0.9.0" @@ -1830,6 +2378,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1973,6 +2527,22 @@ dependencies = [ "rustls-pki-types", ] +[[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" @@ -1982,6 +2552,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-link" version = "0.2.1" diff --git a/crates/harness-app/src/lib.rs b/crates/harness-app/src/lib.rs index 0cefd5e..999bcb3 100644 --- a/crates/harness-app/src/lib.rs +++ b/crates/harness-app/src/lib.rs @@ -2,18 +2,25 @@ //! config, store, event bus, permission service, tool registry, provider registry — and //! exposes a small headless API (`run_prompt`) used by the `harness run -p` debug command. +use std::collections::hash_map::DefaultHasher; +use std::collections::HashMap; +use std::hash::Hasher; +use std::path::Path; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use harness_core::config::{self, Config, ConfigError}; use harness_core::engine::{run_session, RunConfig, StepContext}; -use harness_core::event::{EventBus, RunOutcome}; -use harness_core::permission::{spawn_auto_approve, PermissionService}; +use harness_core::event::{AppEvent, EventBus, RunOutcome}; +use harness_core::permission::{PermissionReply, PermissionService}; use harness_core::store::{Store, StoreError}; use harness_core::tool::ToolRegistry; -use harness_core::types::{Message, ModelRef, Part, PartBody, PartId, Session, SessionId}; +use harness_core::types::{ + Message, MessageId, ModelRef, Part, PartBody, PartId, Session, SessionId, +}; use harness_providers::{AnthropicProvider, ProviderRegistry}; +use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; /// Placeholder until M4's markdown agent registry lands (`assets/agents/orchestrator.md`). @@ -34,6 +41,8 @@ pub enum AppError { InvalidModelRef(String), #[error("no provider registered for {0:?} (missing API key?)")] UnknownProvider(String), + #[error("session {0} already has an active run")] + SessionRunning(SessionId), } fn now_ms() -> i64 { @@ -43,26 +52,64 @@ fn now_ms() -> i64 { .as_millis() as i64 } -pub struct App { - pub config: Config, - pub store: Store, - pub bus: EventBus, - pub permissions: Arc, - pub tools: ToolRegistry, - pub providers: ProviderRegistry, - pub cwd: PathBuf, - data_dir: PathBuf, +fn db_path(cwd: &Path) -> PathBuf { + let base = dirs::data_dir() + .unwrap_or_else(std::env::temp_dir) + .join("ai-harness") + .join("db"); + let cwd_str = cwd.to_string_lossy(); + let sanitized: String = cwd_str + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '_' }) + .collect(); + let mut hasher = DefaultHasher::new(); + hasher.write(cwd_str.as_bytes()); + let hash = hasher.finish(); + let hash_hex = format!("{:016x}", hash); + let slug = format!("{}_{}", sanitized, &hash_hex[..8]); + base.join(format!("{}.sqlite", slug)) } -impl App { - /// In-memory store, auto-approve permission frontend — the M1 headless configuration. - /// A persistent SQLite-backed store and a real TUI permission modal land in M2. +/// Non-blocking, multi-turn engine API used by the TUI. +#[derive(Clone)] +pub struct EngineHandle { + inner: Arc, +} + +struct EngineInner { + config: Config, + store: Store, + bus: EventBus, + permissions: Arc, + tools: ToolRegistry, + providers: ProviderRegistry, + cwd: PathBuf, + data_dir: PathBuf, + runs: Mutex>, +} + +struct RunHandle { + cancel: CancellationToken, +} + +impl EngineHandle { + /// Persistent SQLite-backed store. Used by the TUI and the default headless `App`. pub fn init(cwd: PathBuf) -> Result { + let path = db_path(&cwd); + let store = Store::open(&path)?; + Self::new(cwd, store) + } + + /// In-memory store — useful for tests and ephemeral sessions. + pub fn init_in_memory(cwd: PathBuf) -> Result { + let store = Store::open_in_memory()?; + Self::new(cwd, store) + } + + fn new(cwd: PathBuf, store: Store) -> Result { let config = config::load(&cwd)?; let bus = EventBus::new(); - let store = Store::open_in_memory()?; let permissions = Arc::new(PermissionService::new(bus.clone())); - spawn_auto_approve(bus.clone(), permissions.clone()); let mut tools = ToolRegistry::new(); harness_tools::register_builtins(&mut tools); @@ -82,64 +129,106 @@ impl App { .join("tool-output"); Ok(Self { - config, - store, - bus, - permissions, - tools, - providers, - cwd, - data_dir, + inner: Arc::new(EngineInner { + config, + store, + bus, + permissions, + tools, + providers, + cwd, + data_dir, + runs: Mutex::new(HashMap::new()), + }), }) } - /// Runs a single headless turn: creates a root session, appends `prompt` as the user - /// message, and drives the engine loop to completion. Returns the outcome plus the - /// session id so the caller can fetch the transcript via `final_text`. - pub async fn run_prompt( + pub fn bus(&self) -> EventBus { + self.inner.bus.clone() + } + + pub fn permissions(&self) -> Arc { + self.inner.permissions.clone() + } + + pub fn config(&self) -> Config { + self.inner.config.clone() + } + + pub async fn new_session(&self, agent: &str, model_ref: &str) -> Result { + let (provider_id, model_id) = model_ref + .split_once('/') + .ok_or_else(|| AppError::InvalidModelRef(model_ref.to_string()))?; + let model = ModelRef::new(provider_id, model_id); + let now = now_ms(); + let session = Session::new_root(agent, model, now); + let session_id = session.id.clone(); + self.inner.store.upsert_session(session.clone()).await?; + self.inner.bus.publish(AppEvent::SessionCreated { session }); + Ok(session_id) + } + + pub async fn prompt( &self, - prompt: String, + session_id: SessionId, + text: String, model_ref: &str, - ) -> Result<(RunOutcome, SessionId), AppError> { + ) -> Result<(), AppError> { let (provider_id, model_id) = model_ref .split_once('/') .ok_or_else(|| AppError::InvalidModelRef(model_ref.to_string()))?; let provider = self + .inner .providers .get(provider_id) .ok_or_else(|| AppError::UnknownProvider(provider_id.to_string()))?; let model = ModelRef::new(provider_id, model_id); - let now = now_ms(); - let session = Session::new_root("orchestrator", model.clone(), now); - let session_id = session.id.clone(); - self.store.upsert_session(session).await?; + { + let runs = self.inner.runs.lock().unwrap(); + if runs.contains_key(&session_id) { + return Err(AppError::SessionRunning(session_id)); + } + } + let now = now_ms(); let user_message = Message::new_user(session_id.clone(), now); - self.store.upsert_message(user_message.clone()).await?; - self.store - .upsert_part(Part { - id: PartId::new(), - message_id: user_message.id, - session_id: session_id.clone(), - idx: 0, - body: PartBody::Text { - text: prompt, - synthetic: false, - }, - }) + self.inner + .store + .upsert_message(user_message.clone()) .await?; + // The store actor does not emit events, so publish the user message and its part + // ourselves — otherwise the TUI (which builds its live transcript purely from bus + // events) never shows the prompt the user just typed until the session is reloaded. + self.inner.bus.publish(AppEvent::MessageCreated { + message: user_message.clone(), + }); + + let user_part = Part { + id: PartId::new(), + message_id: user_message.id.clone(), + session_id: session_id.clone(), + idx: 0, + body: PartBody::Text { + text, + synthetic: false, + }, + }; + self.inner.store.upsert_part(user_part.clone()).await?; + self.inner + .bus + .publish(AppEvent::PartUpdated { part: user_part }); let ctx = StepContext { - store: self.store.clone(), - bus: self.bus.clone(), - tools: self.tools.clone(), - permissions: self.permissions.clone(), - static_rules: self.config.permissions.clone(), + store: self.inner.store.clone(), + bus: self.inner.bus.clone(), + tools: self.inner.tools.clone(), + permissions: self.inner.permissions.clone(), + static_rules: self.inner.config.permissions.clone(), extra_rules: Arc::new(Mutex::new(Vec::new())), session_id: session_id.clone(), - cwd: self.cwd.clone(), - data_dir: self.data_dir.join(session_id.to_string()), + cwd: self.inner.cwd.clone(), + data_dir: self.inner.data_dir.join(session_id.to_string()), cancel: CancellationToken::new(), now, }; @@ -149,21 +238,79 @@ impl App { model, temperature: None, max_steps: DEFAULT_MAX_STEPS, - instructions: self.config.instructions.clone(), + instructions: self.inner.config.instructions.clone(), }; - let outcome = run_session(provider, ctx, &run_config, now_ms).await; - Ok((outcome, session_id)) + // Reserve the run slot *before* spawning. If we inserted after spawning, a run that + // finished quickly could remove its (not-yet-inserted) entry first, and our later + // insert would then strand the session as permanently "running". + let cancel = ctx.cancel.clone(); + { + let mut runs = self.inner.runs.lock().unwrap(); + if runs.contains_key(&session_id) { + return Err(AppError::SessionRunning(session_id)); + } + runs.insert( + session_id.clone(), + RunHandle { + cancel: cancel.clone(), + }, + ); + } + + self.inner.bus.publish(AppEvent::RunStarted { + session_id: session_id.clone(), + }); + + let inner = self.inner.clone(); + let spawn_session_id = session_id.clone(); + tokio::spawn(async move { + let outcome = run_session(provider, ctx, &run_config, now_ms).await; + inner.bus.publish(AppEvent::RunFinished { + session_id: spawn_session_id.clone(), + outcome: outcome.clone(), + }); + let mut runs = inner.runs.lock().unwrap(); + runs.remove(&spawn_session_id); + }); + + Ok(()) + } + + pub fn abort(&self, session_id: &SessionId) { + let runs = self.inner.runs.lock().unwrap(); + if let Some(run) = runs.get(session_id) { + run.cancel.cancel(); + } + } + + pub fn permission_reply(&self, id: &str, reply: PermissionReply) -> bool { + self.inner.permissions.reply(id, reply) + } + + pub async fn list_sessions(&self) -> Result, AppError> { + Ok(self.inner.store.sessions().await?) + } + + pub async fn session_messages(&self, session_id: SessionId) -> Result, AppError> { + Ok(self.inner.store.messages(session_id).await?) + } + + pub async fn message_parts(&self, message_id: MessageId) -> Result, AppError> { + Ok(self.inner.store.parts(message_id).await?) + } + + pub fn is_running(&self, session_id: &SessionId) -> bool { + let runs = self.inner.runs.lock().unwrap(); + runs.contains_key(session_id) } - /// Concatenates the `Text` parts of the last message in the session — the final - /// assistant reply for a `harness run` invocation to print. pub async fn final_text(&self, session_id: &SessionId) -> Result { - let messages = self.store.messages(session_id.clone()).await?; + let messages = self.inner.store.messages(session_id.clone()).await?; let Some(last) = messages.last() else { return Ok(String::new()); }; - let parts = self.store.parts(last.id.clone()).await?; + let parts = self.inner.store.parts(last.id.clone()).await?; let text = parts .iter() .filter_map(|p| match &p.body { @@ -176,6 +323,94 @@ impl App { } } +pub struct App { + engine: EngineHandle, + // Keeps the auto-approve task alive for the lifetime of the App. + _auto_approve_handle: Option>, +} + +impl App { + /// Persistent SQLite-backed store with an auto-approve permission frontend — the default + /// headless configuration used by `harness run -p`. + pub fn init(cwd: PathBuf) -> Result { + let engine = EngineHandle::init(cwd)?; + let _auto_approve_handle = Some(spawn_auto_approve_task(&engine)); + Ok(Self { + engine, + _auto_approve_handle, + }) + } + + /// In-memory store with an auto-approve permission frontend — useful for tests. + pub fn init_in_memory(cwd: PathBuf) -> Result { + let engine = EngineHandle::init_in_memory(cwd)?; + let _auto_approve_handle = Some(spawn_auto_approve_task(&engine)); + Ok(Self { + engine, + _auto_approve_handle, + }) + } + + pub fn engine(&self) -> &EngineHandle { + &self.engine + } + + pub fn config(&self) -> Config { + self.engine.config() + } + + /// Runs a single headless turn: creates a root session, appends `prompt` as the user + /// message, and drives the engine loop to completion. Returns the outcome plus the + /// session id so the caller can fetch the transcript via `final_text`. + pub async fn run_prompt( + &self, + prompt: String, + model_ref: &str, + ) -> Result<(RunOutcome, SessionId), AppError> { + let session_id = self.engine.new_session("orchestrator", model_ref).await?; + let mut rx = self.engine.bus().subscribe(); + // Subscribe before starting the run so we cannot miss the RunFinished event + // even for an instant (mock) provider. + self.engine + .prompt(session_id.clone(), prompt, model_ref) + .await?; + + while let Ok(event) = rx.recv().await { + if let AppEvent::RunFinished { + session_id: sid, + outcome, + } = event + { + if sid == session_id { + return Ok((outcome, session_id)); + } + } + } + + // Bus closed unexpectedly; report a stopped run so the caller can continue. + Ok((RunOutcome::Stopped, session_id)) + } + + /// Concatenates the `Text` parts of the last message in the session — the final + /// assistant reply for a `harness run` invocation to print. + pub async fn final_text(&self, session_id: &SessionId) -> Result { + self.engine.final_text(session_id).await + } +} + +fn spawn_auto_approve_task(engine: &EngineHandle) -> JoinHandle<()> { + let bus = engine.bus(); + let permissions = engine.permissions(); + tokio::spawn(async move { + let mut rx = bus.subscribe(); + while let Ok(event) = rx.recv().await { + if let AppEvent::PermissionAsked { request } = event { + permissions.reply(&request.id, PermissionReply::Once); + } + } + }) +} + #[cfg(test)] mod tests { use super::*; @@ -184,16 +419,16 @@ mod tests { async fn init_loads_defaults_with_no_providers_configured() { let dir = tempfile::tempdir().unwrap(); std::env::remove_var("ANTHROPIC_API_KEY"); - let app = App::init(dir.path().to_path_buf()).unwrap(); - assert!(app.providers.get("anthropic").is_none()); - assert_eq!(app.tools.all().len(), 6); + let app = App::init_in_memory(dir.path().to_path_buf()).unwrap(); + assert!(app.engine.inner.providers.get("anthropic").is_none()); + assert_eq!(app.engine.inner.tools.all().len(), 6); } #[tokio::test] async fn run_prompt_errors_on_unregistered_provider() { let dir = tempfile::tempdir().unwrap(); std::env::remove_var("ANTHROPIC_API_KEY"); - let app = App::init(dir.path().to_path_buf()).unwrap(); + let app = App::init_in_memory(dir.path().to_path_buf()).unwrap(); let err = app .run_prompt("hi".into(), "anthropic/claude-sonnet-4-5") .await @@ -204,7 +439,7 @@ mod tests { #[tokio::test] async fn run_prompt_errors_on_malformed_model_ref() { let dir = tempfile::tempdir().unwrap(); - let app = App::init(dir.path().to_path_buf()).unwrap(); + let app = App::init_in_memory(dir.path().to_path_buf()).unwrap(); let err = app.run_prompt("hi".into(), "no-slash").await.unwrap_err(); assert!(matches!(err, AppError::InvalidModelRef(_))); } @@ -212,8 +447,83 @@ mod tests { #[tokio::test] async fn final_text_is_empty_for_unknown_session() { let dir = tempfile::tempdir().unwrap(); - let app = App::init(dir.path().to_path_buf()).unwrap(); + let app = App::init_in_memory(dir.path().to_path_buf()).unwrap(); let text = app.final_text(&SessionId::new()).await.unwrap(); assert_eq!(text, ""); } + + #[tokio::test] + async fn engine_init_in_memory_creates_handle() { + let dir = tempfile::tempdir().unwrap(); + let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap(); + assert!(!engine.config().providers.contains_key("anthropic")); + } + + #[tokio::test] + async fn engine_new_session_persists_session() { + let dir = tempfile::tempdir().unwrap(); + let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap(); + let id = engine + .new_session("orchestrator", "anthropic/claude") + .await + .unwrap(); + let sessions = engine.list_sessions().await.unwrap(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].id, id); + assert_eq!(sessions[0].agent, "orchestrator"); + } + + #[tokio::test] + async fn engine_list_sessions_returns_persisted_sessions() { + let dir = tempfile::tempdir().unwrap(); + let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap(); + let id1 = engine.new_session("a", "anthropic/claude").await.unwrap(); + let id2 = engine.new_session("b", "anthropic/claude").await.unwrap(); + let sessions = engine.list_sessions().await.unwrap(); + assert_eq!(sessions.len(), 2); + assert!(sessions.iter().any(|s| s.id == id1)); + assert!(sessions.iter().any(|s| s.id == id2)); + } + + #[tokio::test] + async fn engine_prompt_errors_on_unregistered_provider() { + let dir = tempfile::tempdir().unwrap(); + std::env::remove_var("ANTHROPIC_API_KEY"); + let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap(); + let id = engine + .new_session("orchestrator", "anthropic/claude") + .await + .unwrap(); + let err = engine + .prompt(id, "hi".into(), "anthropic/claude") + .await + .unwrap_err(); + assert!(matches!(err, AppError::UnknownProvider(_))); + } + + #[tokio::test] + async fn engine_prompt_errors_on_malformed_model_ref() { + let dir = tempfile::tempdir().unwrap(); + let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap(); + let id = engine + .new_session("orchestrator", "anthropic/claude") + .await + .unwrap(); + let err = engine + .prompt(id, "hi".into(), "no-slash") + .await + .unwrap_err(); + assert!(matches!(err, AppError::InvalidModelRef(_))); + } + + #[tokio::test] + async fn engine_is_running_false_for_inactive_session() { + let dir = tempfile::tempdir().unwrap(); + let engine = EngineHandle::init_in_memory(dir.path().to_path_buf()).unwrap(); + let id = engine + .new_session("orchestrator", "anthropic/claude") + .await + .unwrap(); + assert!(!engine.is_running(&id)); + } } diff --git a/crates/harness-tui/Cargo.toml b/crates/harness-tui/Cargo.toml index e5d2bb2..48eab6f 100644 --- a/crates/harness-tui/Cargo.toml +++ b/crates/harness-tui/Cargo.toml @@ -12,6 +12,21 @@ path = "src/main.rs" harness-app = { workspace = true } harness-core = { workspace = true } tokio = { workspace = true } +ratatui = { workspace = true } +crossterm = { workspace = true } +tui-textarea = { workspace = true } +pulldown-cmark = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-appender = { workspace = true } +dirs = { workspace = true } +anyhow = { workspace = true } +serde_json = { workspace = true } +futures = { workspace = true } +tokio-util = { workspace = true } + +[dev-dependencies] +insta = "1" [lints] workspace = true diff --git a/crates/harness-tui/src/app.rs b/crates/harness-tui/src/app.rs new file mode 100644 index 0000000..8449bc3 --- /dev/null +++ b/crates/harness-tui/src/app.rs @@ -0,0 +1,106 @@ +use std::io; +use std::path::PathBuf; + +use crossterm::event::EventStream; +use futures::StreamExt; +use harness_app::EngineHandle; +use harness_core::event::AppEvent; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use tokio::sync::broadcast; +use tokio::time::{interval, Duration}; + +use crate::input::{apply_action, handle_event}; +use crate::render::render; +use crate::state::AppState; +use crate::terminal::TerminalGuard; + +const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-5"; +const DEFAULT_AGENT: &str = "orchestrator"; +const RENDER_TICK_MS: u64 = 33; + +pub struct App { + engine: EngineHandle, + state: AppState, + _terminal_guard: TerminalGuard, + terminal: Terminal>, + events: EventStream, + bus_rx: broadcast::Receiver, + render_interval: tokio::time::Interval, +} + +impl App { + pub async fn new(cwd: PathBuf) -> anyhow::Result { + let engine = EngineHandle::init(cwd)?; + let bus_rx = engine.bus().subscribe(); + + let config = engine.config(); + let model_ref = config + .model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL.to_string()); + let agent = DEFAULT_AGENT.to_string(); + + let mut state = AppState::new(model_ref, agent); + + // Create a default session so the user can start typing immediately. + match engine.new_session(&state.agent, &state.model_ref).await { + Ok(session_id) => { + state.session_id = Some(session_id); + } + Err(e) => { + tracing::warn!(error = %e, "failed to create default session"); + } + } + + let terminal_guard = TerminalGuard::enter()?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend)?; + let events = EventStream::new(); + let render_interval = interval(Duration::from_millis(RENDER_TICK_MS)); + + Ok(Self { + engine, + state, + _terminal_guard: terminal_guard, + terminal, + events, + bus_rx, + render_interval, + }) + } + + pub async fn run(&mut self) -> anyhow::Result<()> { + self.state.dirty = true; + while !self.state.quit { + tokio::select! { + biased; + + maybe_event = self.events.next() => { + match maybe_event { + Some(Ok(event)) => { + let action = handle_event(event, &mut self.state, &self.engine); + apply_action(action, &mut self.state, &self.engine).await; + } + Some(Err(e)) => { + tracing::error!(error = %e, "input error"); + } + None => break, + } + } + + Ok(event) = self.bus_rx.recv() => { + self.state.apply_event(event); + } + + _ = self.render_interval.tick() => { + if self.state.dirty { + self.terminal.draw(|frame| render(frame, &mut self.state))?; + self.state.dirty = false; + } + } + } + } + Ok(()) + } +} diff --git a/crates/harness-tui/src/input.rs b/crates/harness-tui/src/input.rs new file mode 100644 index 0000000..26ac273 --- /dev/null +++ b/crates/harness-tui/src/input.rs @@ -0,0 +1,262 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use harness_app::EngineHandle; +use harness_core::permission::PermissionReply; + +use crate::modal; +use crate::state::{AppState, ModalState}; + +/// Possible high-level actions produced by key input. +#[derive(Debug)] +pub enum InputAction { + None, + Submit { text: String }, + Abort, + Quit, + LoadSessions, + NewSession, + SetModel(String), + SetAgent(String), + CloseModal, + ScrollUp(u16), + ScrollDown(u16), + PermissionReply(PermissionReply), + SessionPickerUp, + SessionPickerDown, + SessionPickerSelect, +} + +/// Translate a crossterm event into an action and/or mutate `state` directly. +pub fn handle_event(event: Event, state: &mut AppState, _engine: &EngineHandle) -> InputAction { + match event { + Event::Key(key) => { + let action = handle_key(key, state); + // Any keypress can change the input buffer or cursor; force a redraw so typed + // characters appear immediately rather than only when some other event sets dirty. + state.dirty = true; + action + } + Event::Resize(_, _) => { + state.dirty = true; + InputAction::None + } + Event::Mouse(_) | Event::FocusGained | Event::FocusLost | Event::Paste(_) => { + InputAction::None + } + } +} + +fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction { + match &state.modal { + ModalState::Permission { .. } => handle_permission_key(key, state), + ModalState::SessionPicker { .. } => handle_session_picker_key(key, state), + ModalState::None => handle_normal_key(key, state), + } +} + +fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + InputAction::PermissionReply(PermissionReply::Once) + } + KeyCode::Char('a') | KeyCode::Char('A') => { + InputAction::PermissionReply(PermissionReply::Always) + } + KeyCode::Char('n') | KeyCode::Char('N') => { + InputAction::PermissionReply(PermissionReply::Reject) + } + // Esc rejects the pending request rather than merely hiding the modal — closing + // without a reply would leave the tool call blocked on its oneshot forever. + KeyCode::Esc => InputAction::PermissionReply(PermissionReply::Reject), + _ => InputAction::None, + } +} + +fn handle_session_picker_key(key: KeyEvent, _state: &mut AppState) -> InputAction { + match key.code { + KeyCode::Up => InputAction::SessionPickerUp, + KeyCode::Down => InputAction::SessionPickerDown, + KeyCode::Enter => InputAction::SessionPickerSelect, + KeyCode::Esc => InputAction::CloseModal, + _ => InputAction::None, + } +} + +fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + // Reset the double-Ctrl+C guard on any key that isn't another Ctrl+C. + if !(matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) && ctrl) { + state.ctrl_c_pressed = false; + } + + match key.code { + KeyCode::Enter => { + let text = state.input.lines().join("\n"); + state.input = tui_textarea::TextArea::default(); + state + .input + .set_cursor_line_style(ratatui::style::Style::default()); + if let Some(action) = parse_slash_command(&text) { + action + } else { + InputAction::Submit { text } + } + } + KeyCode::Char('c') if ctrl => { + if state.ctrl_c_pressed { + InputAction::Quit + } else { + state.ctrl_c_pressed = true; + state.dirty = true; + InputAction::None + } + } + KeyCode::Esc => { + if state.running { + InputAction::Abort + } else { + InputAction::None + } + } + KeyCode::Char('s') if ctrl => InputAction::LoadSessions, + KeyCode::Up => { + let (row, _) = state.input.cursor(); + if row == 0 { + InputAction::ScrollUp(3) + } else { + state.input.input(key); + InputAction::None + } + } + KeyCode::Down => { + let (row, _) = state.input.cursor(); + let last_line = state.input.lines().len().saturating_sub(1); + if row == last_line { + InputAction::ScrollDown(3) + } else { + state.input.input(key); + InputAction::None + } + } + _ => { + state.input.input(key); + InputAction::None + } + } +} + +fn parse_slash_command(text: &str) -> Option { + let trimmed = text.trim(); + if !trimmed.starts_with('/') { + return None; + } + let mut parts = trimmed.split_whitespace(); + let cmd = parts.next()?; + let rest: String = parts.collect::>().join(" "); + match cmd { + "/new" => Some(InputAction::NewSession), + "/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)), + "/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)), + "/sessions" => Some(InputAction::LoadSessions), + "/quit" => Some(InputAction::Quit), + _ => None, + } +} + +/// Apply an action that needs async engine calls. Non-async decisions are applied inline. +pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &EngineHandle) { + match action { + InputAction::Submit { text } => { + if let Some(session_id) = state.session_id.clone() { + if let Err(e) = engine.prompt(session_id, text, &state.model_ref).await { + tracing::error!(error = %e, "prompt failed"); + } + } + } + InputAction::Abort => { + if let Some(session_id) = state.session_id.clone() { + engine.abort(&session_id); + } + } + InputAction::LoadSessions => match engine.list_sessions().await { + Ok(sessions) => modal::open_session_picker(state, sessions), + Err(e) => tracing::error!(error = %e, "failed to list sessions"), + }, + InputAction::NewSession => match engine.new_session(&state.agent, &state.model_ref).await { + Ok(id) => { + state.session_id = Some(id); + state.messages.clear(); + state.scroll_offset = 0; + state.dirty = true; + } + Err(e) => tracing::error!(error = %e, "failed to create session"), + }, + InputAction::SetModel(model_ref) => { + state.model_ref = model_ref; + state.dirty = true; + } + InputAction::SetAgent(agent) => { + state.agent = agent; + state.dirty = true; + } + InputAction::Quit => state.quit = true, + InputAction::CloseModal => state.close_modal(), + InputAction::ScrollUp(n) => state.scroll_up(n), + InputAction::ScrollDown(n) => state.scroll_down(n), + InputAction::PermissionReply(reply) => { + modal::resolve_permission(state, engine, reply); + } + InputAction::SessionPickerUp => { + if let ModalState::SessionPicker { + selected, + sessions: _, + } = &mut state.modal + { + *selected = selected.saturating_sub(1); + } + state.dirty = true; + } + InputAction::SessionPickerDown => { + if let ModalState::SessionPicker { selected, sessions } = &mut state.modal { + if *selected + 1 < sessions.len() { + *selected += 1; + } + } + state.dirty = true; + } + InputAction::SessionPickerSelect => { + if let ModalState::SessionPicker { sessions, selected } = &state.modal { + if let Some(session) = sessions.get(*selected).cloned() { + if let Err(e) = modal::select_session(state, engine, session).await { + tracing::error!(error = %e, "failed to load session"); + } + } + } + } + InputAction::None => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::AppState; + use crossterm::event::KeyEvent; + + #[tokio::test] + async fn typing_marks_frame_dirty_and_updates_input() { + let engine = EngineHandle::init_in_memory(std::env::temp_dir()).unwrap(); + let mut state = AppState::new("anthropic/claude".into(), "orchestrator".into()); + state.dirty = false; + + let action = handle_event( + Event::Key(KeyEvent::from(KeyCode::Char('x'))), + &mut state, + &engine, + ); + + assert!(matches!(action, InputAction::None)); + assert!(state.dirty, "a keystroke must request a redraw"); + assert_eq!(state.input.lines().join("\n"), "x"); + } +} diff --git a/crates/harness-tui/src/main.rs b/crates/harness-tui/src/main.rs index 5a5feed..7b65de4 100644 --- a/crates/harness-tui/src/main.rs +++ b/crates/harness-tui/src/main.rs @@ -1,9 +1,19 @@ +mod app; +mod input; +mod markdown; +mod modal; +mod render; +mod state; +mod terminal; + +use std::path::PathBuf; + use harness_core::event::RunOutcome; const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4-5"; fn print_usage() { - eprintln!("usage: harness run -p \"\" [-m provider/model]"); + eprintln!("usage: harness [run -p \"\" [-m provider/model]] | [tui]"); } fn parse_run_args(args: &[String]) -> Option<(String, Option)> { @@ -26,7 +36,7 @@ fn parse_run_args(args: &[String]) -> Option<(String, Option)> { prompt.map(|p| (p, model)) } -async fn run(args: &[String]) -> i32 { +async fn run_headless(args: &[String]) -> i32 { let Some((prompt, model_arg)) = parse_run_args(args) else { print_usage(); return 2; @@ -49,7 +59,7 @@ async fn run(args: &[String]) -> i32 { }; let model_ref = model_arg - .or_else(|| app.config.model.clone()) + .or_else(|| app.config().model.clone()) .unwrap_or_else(|| DEFAULT_MODEL.to_string()); match app.run_prompt(prompt, &model_ref).await { @@ -73,14 +83,74 @@ async fn run(args: &[String]) -> i32 { } } +fn log_dir() -> PathBuf { + dirs::data_dir() + .unwrap_or_else(std::env::temp_dir) + .join("ai-harness") + .join("log") +} + +fn setup_tracing() -> anyhow::Result { + let log_dir = log_dir(); + std::fs::create_dir_all(&log_dir)?; + let file_appender = tracing_appender::rolling::daily(log_dir, "harness-tui.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + tracing_subscriber::fmt() + .with_writer(non_blocking) + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + Ok(guard) +} + +async fn run_tui() -> i32 { + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(e) => { + eprintln!("error: could not read cwd: {e}"); + return 1; + } + }; + + let _guard = match setup_tracing() { + Ok(guard) => guard, + Err(e) => { + eprintln!("error: could not initialize logging: {e}"); + return 1; + } + }; + + let mut app = match crate::app::App::new(cwd).await { + Ok(app) => app, + Err(e) => { + tracing::error!(error = %e, "failed to start TUI"); + eprintln!("error: {e}"); + return 1; + } + }; + + if let Err(e) = app.run().await { + tracing::error!(error = %e, "TUI error"); + eprintln!("error: {e}"); + return 1; + } + 0 +} + #[tokio::main(flavor = "current_thread")] async fn main() { let args: Vec = std::env::args().skip(1).collect(); - let exit_code = if args.first().map(String::as_str) == Some("run") { - run(&args[1..]).await - } else { - println!("harness {}", env!("CARGO_PKG_VERSION")); - 0 + let exit_code = match args.first().map(String::as_str) { + Some("run") => run_headless(&args[1..]).await, + Some("tui") | None => run_tui().await, + Some("help") | Some("--help") | Some("-h") => { + print_usage(); + 0 + } + Some(cmd) => { + eprintln!("unknown command: {cmd}"); + print_usage(); + 2 + } }; std::process::exit(exit_code); } diff --git a/crates/harness-tui/src/markdown.rs b/crates/harness-tui/src/markdown.rs new file mode 100644 index 0000000..2f9dfbe --- /dev/null +++ b/crates/harness-tui/src/markdown.rs @@ -0,0 +1,255 @@ +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Parser, Tag, TagEnd}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Render a markdown string into wrapped ratatui lines. +pub fn render_markdown(text: &str, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut current_text = String::new(); + + let mut in_bold = false; + let mut in_italic = false; + let mut in_code_block = false; + let mut code_block_language = String::new(); + let mut list_stack: Vec = Vec::new(); + + let flush = |current: &mut String, spans: &mut Vec>, bold, italic| { + if current.is_empty() { + return; + } + let style = base_style(bold, italic); + let span = Span::styled(std::mem::take(current), style); + spans.push(span); + }; + + for event in Parser::new(text) { + match event { + Event::Start(tag) => match tag { + Tag::Paragraph => { + if !lines.is_empty() && !current_text.is_empty() { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + wrap_spans(&mut lines, &mut current_spans, width); + } + } + Tag::Heading { level, .. } => { + let level_num = heading_level_from(level); + current_text.push_str(&"#".repeat(level_num as usize)); + current_text.push(' '); + } + Tag::Strong => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + in_bold = true; + } + Tag::Emphasis => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + in_italic = true; + } + Tag::List(start) => { + list_stack.push(start.unwrap_or(1)); + } + Tag::Item => { + let prefix = if let Some(n) = list_stack.last_mut() { + let p = format!("{n}. "); + *n += 1; + p + } else { + "• ".to_string() + }; + current_text.push_str(&prefix); + } + Tag::CodeBlock(lang) => { + in_code_block = true; + code_block_language = match lang { + CodeBlockKind::Fenced(name) => name.to_string(), + CodeBlockKind::Indented => String::new(), + }; + if !current_text.is_empty() || !current_spans.is_empty() { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + wrap_spans(&mut lines, &mut current_spans, width); + } + let border = if code_block_language.is_empty() { + "┌────".to_string() + } else { + format!("┌──── {code_block_language}") + }; + lines.push(Line::from(Span::styled(border, code_style()))); + } + _ => {} + }, + Event::End(tag_end) => match tag_end { + TagEnd::Heading(_) => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + let mut spans = std::mem::take(&mut current_spans); + for span in &mut spans { + span.style = span.style.add_modifier(Modifier::BOLD); + } + wrap_spans(&mut lines, &mut spans, width); + } + TagEnd::Paragraph => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + wrap_spans(&mut lines, &mut current_spans, width); + } + TagEnd::Strong => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + in_bold = false; + } + TagEnd::Emphasis => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + in_italic = false; + } + TagEnd::List(_) => { + list_stack.pop(); + } + TagEnd::CodeBlock => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + if !current_spans.is_empty() { + for line in wrap_spans_to_lines(&mut current_spans, width) { + lines.push(prefix_code_block_line(line)); + } + } + lines.push(Line::from(Span::styled("└────", code_style()))); + in_code_block = false; + code_block_language.clear(); + } + _ => {} + }, + Event::Text(t) => { + if in_code_block { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + current_spans.push(Span::styled(t.to_string(), code_style())); + } else { + current_text.push_str(&t); + } + } + Event::Code(c) => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + current_spans.push(Span::styled(c.to_string(), code_style())); + } + Event::Html(h) | Event::InlineHtml(h) => { + current_text.push_str(&h); + } + Event::SoftBreak | Event::HardBreak => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + wrap_spans(&mut lines, &mut current_spans, width); + } + Event::Rule => { + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + wrap_spans(&mut lines, &mut current_spans, width); + lines.push(Line::from("─".repeat(width as usize))); + } + _ => {} + } + } + + flush(&mut current_text, &mut current_spans, in_bold, in_italic); + if in_code_block { + for line in wrap_spans_to_lines(&mut current_spans, width) { + lines.push(prefix_code_block_line(line)); + } + lines.push(Line::from(Span::styled("└────", code_style()))); + } else { + wrap_spans(&mut lines, &mut current_spans, width); + } + + if lines.is_empty() { + lines.push(Line::default()); + } + lines +} + +fn heading_level_from(level: HeadingLevel) -> u8 { + match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + } +} + +fn base_style(bold: bool, italic: bool) -> Style { + let mut style = Style::new(); + if bold { + style = style.add_modifier(Modifier::BOLD); + } + if italic { + style = style.add_modifier(Modifier::ITALIC); + } + style +} + +fn code_style() -> Style { + Style::new().fg(Color::Yellow) +} + +fn prefix_code_block_line(line: Line<'static>) -> Line<'static> { + let mut spans = vec![Span::styled("│ ", code_style())]; + spans.extend(line.spans); + Line::from(spans) +} + +/// Flush accumulated spans into `lines`, wrapping to `width`. +fn wrap_spans(lines: &mut Vec>, spans: &mut Vec>, width: u16) { + if spans.is_empty() { + return; + } + for line in wrap_spans_to_lines(spans, width) { + lines.push(line); + } + spans.clear(); +} + +fn wrap_spans_to_lines(spans: &mut Vec>, width: u16) -> Vec> { + let width = width.max(1) as usize; + let mut out: Vec> = Vec::new(); + let mut current_line: Vec> = Vec::new(); + let mut current_width = 0usize; + + for span in spans.drain(..) { + for word in span.content.split(' ') { + let word_width = word.chars().count(); + let sep = if current_width == 0 { 0 } else { 1 }; + if current_width + sep + word_width > width && current_width > 0 { + out.push(Line::from(std::mem::take(&mut current_line))); + current_width = 0; + } + if current_width > 0 { + current_line.push(Span::styled(" ", span.style)); + current_width += 1; + } + current_line.push(Span::styled(word.to_string(), span.style)); + current_width += word_width; + } + } + + if !current_line.is_empty() { + out.push(Line::from(current_line)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_heading_and_bold() { + let lines = render_markdown("# Hello\n\n**bold** text", 40); + assert!(!lines.is_empty()); + let first: String = lines[0] + .spans + .iter() + .map(|s| s.content.to_string()) + .collect(); + assert!(first.contains("# Hello")); + } + + #[test] + fn wraps_long_paragraphs() { + let text = "a ".repeat(50); + let lines = render_markdown(&text, 20); + assert!(lines.len() > 1); + } +} diff --git a/crates/harness-tui/src/modal.rs b/crates/harness-tui/src/modal.rs new file mode 100644 index 0000000..bcda0c7 --- /dev/null +++ b/crates/harness-tui/src/modal.rs @@ -0,0 +1,38 @@ +//! Modal helpers for permission dialogs and the session picker. +//! +//! Rendering lives in `render.rs`; key handling lives in `input.rs`. This module +//! provides shared utilities for modal state transitions. + +use harness_app::EngineHandle; +use harness_core::permission::PermissionReply; +use harness_core::types::Session; + +use crate::state::{AppState, ModalState}; + +/// Resolve the current permission request with `reply`. +pub fn resolve_permission(state: &mut AppState, engine: &EngineHandle, reply: PermissionReply) { + state.handle_permission_reply(reply, engine); +} + +/// Open the session picker with the supplied sessions. +pub fn open_session_picker(state: &mut AppState, sessions: Vec) { + state.open_session_picker(sessions); +} + +/// Load a session's messages and parts into state. +pub async fn select_session( + state: &mut AppState, + engine: &EngineHandle, + session: Session, +) -> Result<(), harness_app::AppError> { + let session_id = session.id.clone(); + let messages = engine.session_messages(session_id).await?; + let mut all_parts = Vec::new(); + for message in &messages { + let parts = engine.message_parts(message.id.clone()).await?; + all_parts.extend(parts); + } + state.set_session(session, messages, all_parts); + state.modal = ModalState::None; + Ok(()) +} diff --git a/crates/harness-tui/src/render.rs b/crates/harness-tui/src/render.rs new file mode 100644 index 0000000..8cd3ffd --- /dev/null +++ b/crates/harness-tui/src/render.rs @@ -0,0 +1,497 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::markdown::render_markdown; +use crate::state::{AppState, ModalState, PartView, ToolStateView}; +use harness_core::types::Role; + +/// Render the full UI into the terminal frame. +pub fn render(frame: &mut Frame, state: &mut AppState) { + let area = frame.area(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + render_header(frame, state, chunks[0]); + render_chat(frame, state, chunks[1]); + render_input(frame, state, chunks[2]); + render_status(frame, state, chunks[3]); + + match &state.modal { + ModalState::Permission { requests } => { + render_permission_modal(frame, &requests[0], area); + } + ModalState::SessionPicker { sessions, selected } => { + render_session_picker(frame, sessions, *selected, area); + } + ModalState::None => {} + } +} + +fn render_header(frame: &mut Frame, state: &AppState, area: Rect) { + let title = if state.session_title.is_empty() { + "new session" + } else { + state.session_title.as_str() + }; + let text = format!("{} · {} · {}", title, state.agent, state.model_ref); + let paragraph = Paragraph::new(text) + .style(Style::new().add_modifier(Modifier::BOLD)) + .alignment(ratatui::layout::Alignment::Center); + frame.render_widget(paragraph, area); +} + +fn render_chat(frame: &mut Frame, state: &mut AppState, area: Rect) { + let effective_width = area.width.saturating_sub(4).max(1); + // Cached lines are wrapped to a specific width, so a resize must drop them. + if state.render_width != effective_width { + state.invalidate_caches(); + state.render_width = effective_width; + } + + let mut lines: Vec> = Vec::new(); + for message in &mut state.messages { + let role_style = match message.role { + Role::User => Style::new().fg(Color::Cyan), + Role::Assistant => Style::new(), + }; + let prefix = match message.role { + Role::User => "> ", + Role::Assistant => "", + }; + let mut first = true; + for part in &mut message.parts { + let part_lines = render_part(part, effective_width, role_style, prefix, first); + lines.extend(part_lines); + first = false; + } + lines.push(Line::default()); + } + + let paragraph = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" chat ")) + .scroll((state.scroll_offset, 0)); + frame.render_widget(paragraph, area); +} + +fn render_part( + part: &mut PartView, + effective_width: u16, + role_style: Style, + prefix: &'static str, + first: bool, +) -> Vec> { + match part { + PartView::Text { + text, cached_lines, .. + } => { + // Cache the prefix-free markdown render; the `> ` prefix is cheap to re-apply + // to the clone each frame and would otherwise poison a shared cache. + let base = cached_lines.get_or_insert_with(|| render_markdown(text, effective_width)); + let mut rendered = base.clone(); + if first && !prefix.is_empty() { + if let Some(first_line) = rendered.first_mut() { + let mut spans = vec![Span::styled(prefix, role_style)]; + spans.extend(first_line.spans.clone()); + *first_line = Line::from(spans); + } + } + rendered + } + PartView::Reasoning { text, .. } => { + let style = Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC); + render_markdown(text, effective_width) + .into_iter() + .map(|line| { + let mut spans = vec![Span::styled("🧠 ", style)]; + spans.extend(line.spans); + Line::from(spans) + }) + .collect() + } + PartView::Tool { + name, + state, + cached_lines, + .. + } => { + if let Some(cached) = cached_lines { + return cached.clone(); + } + let status = state.status_label(); + let icon = match state { + ToolStateView::Pending { .. } => "⏳", + ToolStateView::Running { .. } => "⚙", + ToolStateView::Completed { .. } => "✓", + ToolStateView::Error { .. } => "✗", + }; + let title = format!("{icon} {name} — {status}"); + let mut lines = vec![Line::from(title)]; + match state { + ToolStateView::Pending { partial_input } => { + lines.extend(render_markdown(partial_input, effective_width)); + } + ToolStateView::Completed { output, .. } => { + lines.extend(render_markdown(output, effective_width)); + } + ToolStateView::Error { error } => { + lines.extend(render_markdown(error, effective_width)); + } + ToolStateView::Running { .. } => {} + } + *cached_lines = Some(lines.clone()); + lines + } + PartView::StepFinish { .. } => vec![Line::from("─── step ───")], + } +} + +fn render_input(frame: &mut Frame, state: &mut AppState, area: Rect) { + let block = Block::default().borders(Borders::ALL).title(" input "); + state.input.set_block(block); + frame.render_widget(&state.input, area); +} + +fn render_status(frame: &mut Frame, state: &AppState, area: Rect) { + let spinner = if state.running { "⠋ " } else { "" }; + let status = if state.running { "running" } else { "idle" }; + let hints = if state.ctrl_c_pressed { + "Press Ctrl+C again to quit" + } else { + "Ctrl+S: sessions | Esc: abort | Ctrl+C: quit" + }; + let text = format!("{spinner}{status} · {hints}"); + let paragraph = Paragraph::new(text).style(Style::new().fg(Color::Gray)); + frame.render_widget(paragraph, area); +} + +fn render_permission_modal( + frame: &mut Frame, + request: &harness_core::event::PermissionRequest, + area: Rect, +) { + let popup = centered_rect(60, 60, area); + frame.render_widget(Clear, popup); + + let text = vec![ + Line::from("Permission required").style(Style::new().add_modifier(Modifier::BOLD)), + Line::default(), + Line::from(format!("permission: {}", request.permission)), + Line::from(format!("pattern: {}", request.pattern)), + Line::default(), + Line::from("y: allow once a: allow always n: reject"), + ]; + + let block = Block::default().borders(Borders::ALL).title(" permission "); + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); + frame.render_widget(paragraph, popup); +} + +fn render_session_picker( + frame: &mut Frame, + sessions: &[harness_core::types::Session], + selected: usize, + area: Rect, +) { + let popup = centered_rect(60, 60, area); + frame.render_widget(Clear, popup); + + let mut text: Vec> = + vec![Line::from("Select session").style(Style::new().add_modifier(Modifier::BOLD))]; + for (i, session) in sessions.iter().enumerate() { + let marker = if i == selected { "> " } else { " " }; + let line = format!( + "{}{} · {} · {}/{}", + marker, session.id, session.agent, session.model.provider_id, session.model.model_id + ); + let style = if i == selected { + Style::new().bg(Color::Blue).fg(Color::White) + } else { + Style::new() + }; + text.push(Line::from(Span::styled(line, style))); + } + + let block = Block::default().borders(Borders::ALL).title(" sessions "); + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, popup); +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +#[cfg(test)] +mod tests { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use super::*; + use crate::state::{AppState, MessageView, PartView, ToolStateView}; + use harness_core::event::PermissionRequest; + use harness_core::types::{MessageId, ModelRef, PartId, Role, Session, SessionId}; + + fn buffer_to_string(backend: &TestBackend) -> String { + let buffer = backend.buffer(); + let area = buffer.area; + let mut result = String::new(); + for y in 0..area.height { + for x in 0..area.width { + result.push_str(buffer[(x, y)].symbol()); + } + while result.ends_with(' ') { + result.pop(); + } + result.push('\n'); + } + result + } + + #[test] + fn snapshot_empty_session() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn snapshot_user_and_assistant_messages() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + state.session_id = Some(SessionId("ses_test_001".to_string())); + state.messages.push(MessageView { + id: MessageId("msg_test_001".to_string()), + role: Role::User, + parts: vec![PartView::Text { + id: PartId("prt_test_001".to_string()), + text: "Hello, can you read foo.txt?".to_string(), + cached_lines: None, + }], + }); + state.messages.push(MessageView { + id: MessageId("msg_test_002".to_string()), + role: Role::Assistant, + parts: vec![PartView::Text { + id: PartId("prt_test_002".to_string()), + text: "I'll read that file for you.\n\n```rust\nlet x = 42;\n```".to_string(), + cached_lines: None, + }], + }); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn snapshot_tool_card_completed() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + state.session_id = Some(SessionId("ses_test_001".to_string())); + state.messages.push(MessageView { + id: MessageId("msg_test_003".to_string()), + role: Role::Assistant, + parts: vec![PartView::Tool { + id: PartId("prt_test_003".to_string()), + name: "read".to_string(), + state: ToolStateView::Completed { + title: "read foo.txt".to_string(), + output: "file contents here".to_string(), + }, + cached_lines: None, + }], + }); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn snapshot_tool_card_running() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + state.session_id = Some(SessionId("ses_test_001".to_string())); + state.messages.push(MessageView { + id: MessageId("msg_test_004".to_string()), + role: Role::Assistant, + parts: vec![PartView::Tool { + id: PartId("prt_test_004".to_string()), + name: "read".to_string(), + state: ToolStateView::Running { + title: Some("read foo.txt".to_string()), + }, + cached_lines: None, + }], + }); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn snapshot_tool_card_error() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + state.session_id = Some(SessionId("ses_test_001".to_string())); + state.messages.push(MessageView { + id: MessageId("msg_test_005".to_string()), + role: Role::Assistant, + parts: vec![PartView::Tool { + id: PartId("prt_test_005".to_string()), + name: "read".to_string(), + state: ToolStateView::Error { + error: "file not found".to_string(), + }, + cached_lines: None, + }], + }); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn snapshot_permission_modal() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + let request = PermissionRequest { + id: "test".to_string(), + session_id: SessionId("ses_test_001".to_string()), + permission: "bash".to_string(), + pattern: "rm -rf /".to_string(), + always_pattern: "rm *".to_string(), + metadata: serde_json::Value::Null, + }; + state.modal = ModalState::Permission { + requests: vec![request], + }; + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn snapshot_session_picker_modal() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new( + "anthropic/claude-sonnet-4-5".to_string(), + "orchestrator".to_string(), + ); + let sessions = vec![ + Session { + id: SessionId("ses_test_001".to_string()), + parent_id: None, + depth: 0, + title: "Session One".to_string(), + agent: "orchestrator".to_string(), + model: ModelRef::new("anthropic", "claude-sonnet-4-5"), + usage: harness_core::types::TokenUsage::default(), + cost: 0.0, + extra_rules: Vec::new(), + created_at: 1, + updated_at: 1, + }, + Session { + id: SessionId("ses_test_002".to_string()), + parent_id: None, + depth: 0, + title: "Session Two".to_string(), + agent: "coder".to_string(), + model: ModelRef::new("openai", "gpt-4o"), + usage: harness_core::types::TokenUsage::default(), + cost: 0.0, + extra_rules: Vec::new(), + created_at: 2, + updated_at: 2, + }, + Session { + id: SessionId("ses_test_003".to_string()), + parent_id: None, + depth: 0, + title: "Session Three".to_string(), + agent: "reviewer".to_string(), + model: ModelRef::new("google", "gemini-2.5"), + usage: harness_core::types::TokenUsage::default(), + cost: 0.0, + extra_rules: Vec::new(), + created_at: 3, + updated_at: 3, + }, + ]; + state.modal = ModalState::SessionPicker { + sessions, + selected: 1, + }; + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + insta::assert_snapshot!(buffer_to_string(terminal.backend())); + } + + #[test] + fn render_empty_state_produces_frame() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new("anthropic/claude".to_string(), "orchestrator".to_string()); + terminal.draw(|frame| render(frame, &mut state)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + assert_eq!(buffer.area.width, 80); + assert_eq!(buffer.area.height, 24); + // Header should contain the agent/model line. + let header_row: String = buffer + .content + .chunks(80) + .next() + .unwrap() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(header_row.contains("orchestrator")); + assert!(header_row.contains("anthropic/claude")); + } +} diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap new file mode 100644 index 0000000..de758c7 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_empty_session.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 277 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap new file mode 100644 index 0000000..e83d418 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_permission_modal.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 397 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ ┌ permission ──────────────────────────────────┐ │ +│ │Permission required │ │ +│ │ │ │ +│ │permission: bash │ │ +│ │pattern: rm -rf / │ │ +│ │ │ │ +│ │y: allow once a: allow always n: reject │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap new file mode 100644 index 0000000..82c4456 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_session_picker_modal.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 449 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ ┌ sessions ────────────────────────────────────┐ │ +│ │Select session │ │ +│ │ ses_test_001 · orchestrator · │ │ +│ │anthropic/claude-sonnet-4-5 │ │ +│ │> ses_test_002 · coder · openai/gpt-4o │ │ +│ │ ses_test_003 · reviewer · google/gemini-2.5 │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap new file mode 100644 index 0000000..c35c01c --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_completed.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 330 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│✓ read — read foo.txt │ +│file contents here │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap new file mode 100644 index 0000000..24d9403 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_error.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 376 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│✗ read — error: file not found │ +│file not found │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap new file mode 100644 index 0000000..0b90d93 --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_tool_card_running.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 353 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│⚙ read — read foo.txt │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap new file mode 100644 index 0000000..f4cb1ee --- /dev/null +++ b/crates/harness-tui/src/snapshots/harness__render__tests__snapshot_user_and_assistant_messages.snap @@ -0,0 +1,29 @@ +--- +source: crates/harness-tui/src/render.rs +assertion_line: 306 +expression: buffer_to_string(terminal.backend()) +--- + new session · orchestrator · anthropic/claude-sonnet-4-5 +┌ chat ────────────────────────────────────────────────────────────────────────┐ +│> Hello, can you read foo.txt? │ +│ │ +│I'll read that file for you. │ +│┌──── rust │ +││ let x = 42; │ +│└──── │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ input ───────────────────────────────────────────────────────────────────────┐ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +idle · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit diff --git a/crates/harness-tui/src/state.rs b/crates/harness-tui/src/state.rs new file mode 100644 index 0000000..1b00b18 --- /dev/null +++ b/crates/harness-tui/src/state.rs @@ -0,0 +1,396 @@ +use harness_app::EngineHandle; +use harness_core::event::{AppEvent, PermissionRequest, RunOutcome}; +use harness_core::permission::PermissionReply; +use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState}; +use ratatui::text::Line; + +/// Cached view of a message in the chat viewport. +#[derive(Debug)] +pub struct MessageView { + pub id: harness_core::types::MessageId, + pub role: Role, + pub parts: Vec, +} + +/// Cached view of a part. The optional `cached_lines` field stores a pre-rendered markdown +/// representation; it is invalidated whenever the underlying text changes. +#[derive(Debug)] +pub enum PartView { + Text { + id: PartId, + text: String, + cached_lines: Option>>, + }, + Reasoning { + id: PartId, + text: String, + }, + Tool { + id: PartId, + name: String, + state: ToolStateView, + cached_lines: Option>>, + }, + StepFinish { + id: PartId, + }, +} + +/// Simplified tool state for rendering. +#[derive(Debug)] +pub enum ToolStateView { + Pending { partial_input: String }, + Running { title: Option }, + Completed { title: String, output: String }, + Error { error: String }, +} + +impl ToolStateView { + fn from_tool_state(state: &ToolState) -> Self { + match state { + ToolState::Pending { partial_input } => Self::Pending { + partial_input: partial_input.clone(), + }, + ToolState::Running { title, .. } => Self::Running { + title: title.clone(), + }, + ToolState::Completed { title, output, .. } => Self::Completed { + title: title.clone(), + output: output.clone(), + }, + ToolState::Error { error, .. } => Self::Error { + error: error.clone(), + }, + } + } + + pub fn status_label(&self) -> String { + match self { + Self::Pending { .. } => "pending".to_string(), + Self::Running { title } => title.clone().unwrap_or_else(|| "running".to_string()), + Self::Completed { title, .. } => title.clone(), + Self::Error { error } => format!("error: {error}"), + } + } +} + +/// Active modal overlay state. +#[derive(Debug, Default)] +pub enum ModalState { + #[default] + None, + Permission { + requests: Vec, + }, + SessionPicker { + sessions: Vec, + selected: usize, + }, +} + +/// All mutable UI state lives here. +#[derive(Debug)] +pub struct AppState { + pub session_id: Option, + pub session_title: String, + pub messages: Vec, + pub modal: ModalState, + pub input: tui_textarea::TextArea<'static>, + pub scroll_offset: u16, + pub running: bool, + pub model_ref: String, + pub agent: String, + pub quit: bool, + pub dirty: bool, + pub ctrl_c_pressed: bool, + /// Width the currently cached part lines were wrapped to; a change invalidates them. + pub render_width: u16, +} + +impl AppState { + pub fn new(model_ref: String, agent: String) -> Self { + let mut input = tui_textarea::TextArea::default(); + input.set_cursor_line_style(ratatui::style::Style::default()); + Self { + session_id: None, + session_title: String::new(), + messages: Vec::new(), + modal: ModalState::None, + input, + scroll_offset: 0, + running: false, + model_ref, + agent, + quit: false, + dirty: true, + ctrl_c_pressed: false, + render_width: 0, + } + } + + /// Drop every part's cached render (e.g. after a resize changes the wrap width). + pub fn invalidate_caches(&mut self) { + for message in &mut self.messages { + for part in &mut message.parts { + match part { + PartView::Text { cached_lines, .. } | PartView::Tool { cached_lines, .. } => { + *cached_lines = None + } + PartView::Reasoning { .. } | PartView::StepFinish { .. } => {} + } + } + } + } + + pub fn set_session(&mut self, session: Session, messages: Vec, parts: Vec) { + self.session_id = Some(session.id.clone()); + self.session_title = session.title; + self.agent = session.agent; + self.model_ref = format!("{}/{}", session.model.provider_id, session.model.model_id); + self.messages.clear(); + + let mut messages = messages; + messages.sort_by_key(|m| m.created_at); + for message in messages { + let message_parts: Vec = parts + .iter() + .filter(|p| p.message_id == message.id) + .cloned() + .collect(); + self.messages.push(message_view(message, message_parts)); + } + self.scroll_offset = 0; + self.dirty = true; + } + + pub fn apply_event(&mut self, event: AppEvent) { + match event { + AppEvent::SessionCreated { session } | AppEvent::SessionUpdated { session } => { + if self.session_id.as_ref() == Some(&session.id) { + self.session_title = session.title; + self.agent = session.agent; + self.model_ref = + format!("{}/{}", session.model.provider_id, session.model.model_id); + self.dirty = true; + } + } + AppEvent::MessageCreated { message } => { + if self.session_id.as_ref() == Some(&message.session_id) { + self.messages.push(message_view(message, Vec::new())); + self.dirty = true; + } + } + AppEvent::MessageUpdated { message } => { + if let Some(view) = self.messages.iter_mut().find(|m| m.id == message.id) { + view.role = message.role; + self.dirty = true; + } + } + AppEvent::PartUpdated { part } => { + if self.session_id.as_ref() == Some(&part.session_id) { + self.update_or_insert_part(part); + self.dirty = true; + } + } + AppEvent::PartDelta { + part_id, + message_id, + delta, + } => { + self.apply_delta(part_id, message_id, delta); + self.dirty = true; + } + AppEvent::RunStarted { session_id } => { + if self.session_id.as_ref() == Some(&session_id) { + self.running = true; + self.dirty = true; + } + } + AppEvent::RunFinished { + session_id, + outcome, + } => { + if self.session_id.as_ref() == Some(&session_id) { + self.running = false; + if matches!(outcome, RunOutcome::Errored { .. }) { + self.messages.push(MessageView { + id: harness_core::types::MessageId::new(), + role: Role::Assistant, + parts: vec![PartView::Text { + id: PartId::new(), + text: match outcome { + RunOutcome::Errored { message } => message, + _ => String::new(), + }, + cached_lines: None, + }], + }); + } + self.dirty = true; + } + } + AppEvent::PermissionAsked { request } => { + if let ModalState::Permission { requests } = &mut self.modal { + requests.push(request); + } else { + self.modal = ModalState::Permission { + requests: vec![request], + }; + } + self.dirty = true; + } + AppEvent::PermissionResolved { id } => { + if let ModalState::Permission { requests } = &mut self.modal { + requests.retain(|r| r.id != id); + if requests.is_empty() { + self.modal = ModalState::None; + } + } + self.dirty = true; + } + AppEvent::JobUpdated { .. } + | AppEvent::AuthPrompt { .. } + | AppEvent::ServerNotice { .. } => { + self.dirty = true; + } + } + } + + pub fn scroll_up(&mut self, n: u16) { + self.scroll_offset = self.scroll_offset.saturating_sub(n); + self.dirty = true; + } + + pub fn scroll_down(&mut self, n: u16) { + self.scroll_offset = self.scroll_offset.saturating_add(n); + self.dirty = true; + } + + pub fn handle_permission_reply(&mut self, reply: PermissionReply, engine: &EngineHandle) { + let request_id = if let ModalState::Permission { requests } = &self.modal { + requests.first().map(|r| r.id.clone()) + } else { + None + }; + + if let Some(id) = request_id { + engine.permission_reply(&id, reply); + if let ModalState::Permission { requests } = &mut self.modal { + requests.retain(|r| r.id != id); + if requests.is_empty() { + self.modal = ModalState::None; + } + } + self.dirty = true; + } + } + + pub fn open_session_picker(&mut self, sessions: Vec) { + self.modal = ModalState::SessionPicker { + sessions, + selected: 0, + }; + self.dirty = true; + } + + pub fn close_modal(&mut self) { + self.modal = ModalState::None; + self.dirty = true; + } + + fn update_or_insert_part(&mut self, part: Part) { + let target_id = part.id.clone(); + let Some(message_view) = self.messages.iter_mut().find(|m| m.id == part.message_id) else { + return; + }; + + let new_view = part_view(part); + if let Some(existing) = message_view + .parts + .iter_mut() + .find(|p| view_part_id(p) == target_id) + { + *existing = new_view; + } else { + message_view.parts.push(new_view); + } + } + + fn apply_delta( + &mut self, + target_id: PartId, + message_id: harness_core::types::MessageId, + delta: String, + ) { + let Some(message_view) = self.messages.iter_mut().find(|m| m.id == message_id) else { + return; + }; + + for part in &mut message_view.parts { + if view_part_id(part) == target_id { + match part { + PartView::Text { + text, cached_lines, .. + } => { + text.push_str(&delta); + *cached_lines = None; + } + PartView::Reasoning { text, .. } => { + text.push_str(&delta); + } + PartView::Tool { cached_lines, .. } => { + *cached_lines = None; + } + PartView::StepFinish { .. } => {} + } + return; + } + } + } +} + +fn message_view(message: Message, mut parts: Vec) -> MessageView { + parts.sort_by_key(|p| p.idx); + MessageView { + id: message.id, + role: message.role, + parts: parts.into_iter().map(part_view).collect(), + } +} + +fn part_view(part: Part) -> PartView { + match part.body { + PartBody::Text { text, .. } => PartView::Text { + id: part.id, + text, + cached_lines: None, + }, + PartBody::Reasoning { text, .. } => PartView::Reasoning { id: part.id, text }, + PartBody::Tool { name, state, .. } => PartView::Tool { + id: part.id, + name, + state: ToolStateView::from_tool_state(&state), + cached_lines: None, + }, + PartBody::StepStart | PartBody::StepFinish { .. } => PartView::StepFinish { id: part.id }, + PartBody::Subtask { description, .. } => PartView::Text { + id: part.id, + text: description, + cached_lines: None, + }, + PartBody::Compaction { summary, .. } => PartView::Text { + id: part.id, + text: format!("_Compaction summary: {summary}_"), + cached_lines: None, + }, + } +} + +fn view_part_id(part: &PartView) -> PartId { + match part { + PartView::Text { id, .. } + | PartView::Reasoning { id, .. } + | PartView::Tool { id, .. } + | PartView::StepFinish { id } => id.clone(), + } +} diff --git a/crates/harness-tui/src/terminal.rs b/crates/harness-tui/src/terminal.rs new file mode 100644 index 0000000..b023437 --- /dev/null +++ b/crates/harness-tui/src/terminal.rs @@ -0,0 +1,41 @@ +use std::io; +use std::panic; + +use crossterm::cursor::Show; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; + +/// Restores the terminal when dropped or on panic. +pub struct TerminalGuard; + +impl TerminalGuard { + /// Enables raw mode, enters the alternate screen, and installs a panic hook + /// that restores the terminal before printing panic info. + pub fn enter() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, Show)?; + + let original_hook = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + let _ = restore(); + original_hook(info); + })); + + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = restore(); + } +} + +fn restore() -> io::Result<()> { + disable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, LeaveAlternateScreen, Show) +}