diff --git a/.miyabi.yml b/.miyabi.yml index 61cbfa8..6a8691d 100644 --- a/.miyabi.yml +++ b/.miyabi.yml @@ -5,6 +5,12 @@ version: "0.1.0" # GitHub settings (use environment variables for sensitive data) # github_token: ${{ GITHUB_TOKEN }} +# LLM Configuration +llm: + provider: anthropic + model: claude-sonnet-4-20250514 + api_key: ${{ ANTHROPIC_API_KEY }} + # Agent settings agents: enabled: true diff --git a/Cargo.lock b/Cargo.lock index 3e5784f..2853c35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,26 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -138,6 +158,18 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -229,6 +261,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -327,6 +368,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.20.11" @@ -392,6 +439,37 @@ dependencies = [ "syn", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -443,12 +521,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -590,6 +703,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link", +] + [[package]] name = "getopts" version = "0.2.24" @@ -647,6 +770,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -922,6 +1056,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1013,6 +1161,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1120,6 +1278,7 @@ dependencies = [ "miyabi-core", "miyabi-tui", "ratatui", + "serde_json", "tokio", "tracing", "tracing-subscriber", @@ -1132,6 +1291,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "dirs", "futures", "glob", "regex", @@ -1139,8 +1299,9 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.17", "tokio", + "toml", "tracing", "uuid", ] @@ -1150,6 +1311,7 @@ name = "miyabi-tui" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "chrono", "crossterm 0.29.0", "futures", @@ -1161,12 +1323,22 @@ dependencies = [ "serde_json", "syntect", "textwrap", - "thiserror", + "thiserror 2.0.17", "tokio", "unicode-width 0.2.0", "uuid", ] +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1208,6 +1380,79 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1286,6 +1531,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1352,6 +1603,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1395,6 +1659,21 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -1449,6 +1728,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.2" @@ -1696,6 +1986,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1880,7 +2179,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "thiserror", + "thiserror 2.0.17", "walkdir", "yaml-rust", ] @@ -1930,13 +2229,33 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1959,6 +2278,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.44" @@ -2060,6 +2393,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" @@ -2388,6 +2762,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -2489,6 +2869,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2525,6 +2914,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2558,6 +2962,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2570,6 +2980,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2582,6 +2998,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2606,6 +3028,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2618,6 +3046,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2630,6 +3064,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2642,6 +3082,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2654,6 +3100,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -2666,6 +3121,23 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yaml-rust" version = "0.4.5" @@ -2698,6 +3170,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -2757,3 +3249,18 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/GOALS.md b/GOALS.md index 0840e02..8ed79e1 100644 --- a/GOALS.md +++ b/GOALS.md @@ -121,6 +121,13 @@ - [ ] System prompts - [ ] Tool definitions +### 6.3 Model Coverage & Options +- [ ] Add model presets for `claude-sonnet-4-5-20250929` and `claude-haiku-4-5-20251001` +- [ ] Surface model selection via config/CLI flag +- [ ] Update token/context limits for 4.5 models (context + max output) +- [ ] Handle new stop reason `model_context_window_exceeded` +- [ ] Toggle Extended Thinking (`thinking` param) with sensible defaults + --- ## Phase 7: Tool Execution diff --git a/README.md b/README.md index fa4740f..821d216 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,135 @@ # miyabi-cli-standalone -Autonomous development powered by Agentic OS + +Autonomous development powered by **Miyabi** - AI-driven development framework. + +## Getting Started + +### Prerequisites + +```bash +# Set environment variables +cp .env.example .env +# Edit .env and add your tokens +``` + +### Installation + +```bash +npm install +``` + +### Development + +```bash +npm run dev # Run development server +npm run build # Build project +npm test # Run tests +npm run typecheck # Check types +npm run lint # Lint code +``` + +## Project Structure + +``` +miyabi-cli-standalone/ +├── src/ # Source code +│ └── index.ts # Entry point +├── tests/ # Test files +│ └── example.test.ts +├── .claude/ # AI agent configuration +│ ├── agents/ # Agent definitions +│ └── commands/ # Custom commands +├── .github/ +│ ├── workflows/ # CI/CD automation +│ └── labels.yml # Label system (53 labels) +├── CLAUDE.md # AI context file +└── package.json +``` + +## Miyabi Framework + +This project uses **7 autonomous AI agents**: + +1. **CoordinatorAgent** - Task planning & orchestration +2. **IssueAgent** - Automatic issue analysis & labeling +3. **CodeGenAgent** - AI-powered code generation +4. **ReviewAgent** - Code quality validation (80+ score) +5. **PRAgent** - Automatic PR creation +6. **DeploymentAgent** - CI/CD deployment automation +7. **TestAgent** - Test execution & coverage + +### Workflow + +1. **Create Issue**: Describe what you want to build +2. **Agents Work**: AI agents analyze, implement, test +3. **Review PR**: Check generated pull request +4. **Merge**: Automatic deployment + +### Label System + +Issues transition through states automatically: + +- `📥 state:pending` - Waiting for agent assignment +- `🔍 state:analyzing` - Being analyzed +- `🏗️ state:implementing` - Code being written +- `👀 state:reviewing` - Under review +- `✅ state:done` - Completed & merged + +## Commands + +```bash +# Check project status +npx miyabi status + +# Watch for changes (real-time) +npx miyabi status --watch + +# Create new issue +gh issue create --title "Add feature" --body "Description" +``` + +## Configuration + +### Environment Variables + +Required variables (see `.env.example`): + +- `GITHUB_TOKEN` - GitHub personal access token +- `ANTHROPIC_API_KEY` - Claude API key (optional for local development) +- `REPOSITORY` - Format: `owner/repo` + +### GitHub Actions + +Workflows are pre-configured in `.github/workflows/`: + +- CI/CD pipeline +- Automated testing +- Deployment automation +- Agent execution triggers + +**Note**: Set repository secrets at: +`https://github.com/ShunsukeHayashi/miyabi-cli-standalone/settings/secrets/actions` + +Required secrets: +- `GITHUB_TOKEN` (auto-provided by GitHub Actions) +- `ANTHROPIC_API_KEY` (add manually for agent execution) + +## Documentation + +- **Miyabi Framework**: https://github.com/ShunsukeHayashi/Miyabi +- **NPM Package**: https://www.npmjs.com/package/miyabi +- **Label System**: See `.github/labels.yml` +- **Agent Operations**: See `CLAUDE.md` + +## Support + +- **Issues**: https://github.com/ShunsukeHayashi/Miyabi/issues +- **Discord**: [Coming soon] + +## License + +MIT + +--- + +✨ Generated by [Miyabi](https://github.com/ShunsukeHayashi/Miyabi) diff --git a/crates/miyabi-cli/Cargo.toml b/crates/miyabi-cli/Cargo.toml index 2e0a286..9f75826 100644 --- a/crates/miyabi-cli/Cargo.toml +++ b/crates/miyabi-cli/Cargo.toml @@ -31,6 +31,9 @@ tokio = { workspace = true } # Error Handling anyhow = { workspace = true } +# Serialization +serde_json = { workspace = true } + # Logging tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/miyabi-cli/src/main.rs b/crates/miyabi-cli/src/main.rs index 1cf7a81..5b21ae9 100644 --- a/crates/miyabi-cli/src/main.rs +++ b/crates/miyabi-cli/src/main.rs @@ -1,12 +1,33 @@ //! Miyabi CLI - Main entry point use clap::{Parser, Subcommand}; +use std::path::PathBuf; use tracing_subscriber::EnvFilter; #[derive(Parser)] #[command(name = "miyabi")] #[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)] struct Cli { + /// Model to use (overrides config) + #[arg(short, long)] + model: Option, + + /// Maximum tokens for responses (overrides config) + #[arg(long)] + max_tokens: Option, + + /// Enable Extended Thinking (Claude 4.5+) + #[arg(long)] + thinking: bool, + + /// Path to config file + #[arg(short, long)] + config: Option, + + /// Session ID to load on startup + #[arg(short, long)] + session: Option, + #[command(subcommand)] command: Option, } @@ -17,6 +38,39 @@ enum Commands { Tui, /// Show status Status, + /// Generate default config file + Init, + /// Manage sessions + Sessions { + /// Delete a session by ID + #[arg(short, long)] + delete: Option, + /// Export a session to JSON file + #[arg(short, long)] + export: Option, + /// Export a session to Markdown file + #[arg(short, long)] + markdown: Option, + }, + /// Show version and system information + Version, + /// Run agent with a prompt (autonomous execution) + Agent { + /// The prompt to execute + prompt: String, + /// Maximum iterations (default: 10) + #[arg(long, default_value = "10")] + max_iterations: usize, + /// Auto-approve all tool executions + #[arg(long)] + auto_approve: bool, + /// Output format: text or json + #[arg(long, default_value = "text")] + format: String, + /// System prompt for the agent + #[arg(long)] + system: Option, + }, } #[tokio::main] @@ -31,22 +85,51 @@ async fn main() -> anyhow::Result<()> { match cli.command { Some(Commands::Tui) | None => { // Run TUI - use miyabi_tui::App; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + }, }; + use miyabi_core::config::Config; + use miyabi_tui::App; use ratatui::prelude::*; use std::io; + // Load config (from custom path or default) + let mut config = if let Some(config_path) = &cli.config { + Config::load_from(config_path)? + } else { + Config::load().unwrap_or_default() + }; + + // Apply CLI overrides + if let Some(model) = &cli.model { + config.api.model = model.clone(); + } + if let Some(max_tokens) = cli.max_tokens { + config.api.max_tokens = max_tokens; + } + if cli.thinking { + config.api.thinking = true; + } + enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let mut app = App::new(); + let mut app = App::with_config(config); + + // Load session if specified + if let Some(session_id) = &cli.session { + if let Err(e) = app.load_session(session_id) { + eprintln!("Warning: Failed to load session {}: {}", session_id, e); + } + } + let res = app.run(&mut terminal).await; disable_raw_mode()?; @@ -63,8 +146,299 @@ async fn main() -> anyhow::Result<()> { } Some(Commands::Status) => { println!("Miyabi Status: Ready"); + println!( + "Config path: {:?}", + miyabi_core::config::Config::default_path() + ); + } + Some(Commands::Init) => { + use miyabi_core::config::Config; + let path = Config::generate_default()?; + println!("Generated default config at: {:?}", path); + } + Some(Commands::Sessions { + delete, + export, + markdown, + }) => { + use miyabi_core::anthropic::{ContentBlock, Role}; + use miyabi_core::config::Config; + use miyabi_core::session::SessionStorage; + + let config = Config::load().unwrap_or_default(); + let storage = SessionStorage::new(config.sessions_dir()); + + if let Some(id) = delete { + // Delete session + match storage.delete(&id) { + Ok(_) => println!("Deleted session: {}", id), + Err(e) => eprintln!("Failed to delete session {}: {}", id, e), + } + } else if let Some(id) = export { + // Export session to JSON + match storage.load(&id) { + Ok(session) => { + let filename = format!("{}.json", id); + let json = serde_json::to_string_pretty(&session)?; + std::fs::write(&filename, json)?; + println!("Exported session to: {}", filename); + } + Err(e) => eprintln!("Failed to load session {}: {}", id, e), + } + } else if let Some(id) = markdown { + // Export session to Markdown + match storage.load(&id) { + Ok(session) => { + let filename = format!("{}.md", id); + let mut md = String::new(); + + // Header + md.push_str(&format!("# Session: {}\n\n", session.title)); + md.push_str(&format!("**Model**: {}\n", session.model)); + md.push_str(&format!( + "**Date**: {}\n", + session.created_at.format("%Y-%m-%d %H:%M") + )); + md.push_str(&format!("**Tokens**: {}\n\n", session.tokens_used)); + md.push_str("---\n\n"); + + // Messages + for message in &session.messages { + let role = match message.role { + Role::User => "You", + Role::Assistant => "Assistant", + }; + + md.push_str(&format!("## {}\n\n", role)); + + for content in &message.content { + match content { + ContentBlock::Text { text } => { + md.push_str(text); + md.push_str("\n\n"); + } + ContentBlock::ToolUse { name, input, .. } => { + md.push_str(&format!( + "**Tool**: {}\n```json\n{}\n```\n\n", + name, input + )); + } + ContentBlock::ToolResult { content, .. } => { + md.push_str(&format!( + "**Result**:\n```\n{}\n```\n\n", + content + )); + } + } + } + } + + std::fs::write(&filename, md)?; + println!("Exported session to: {}", filename); + } + Err(e) => eprintln!("Failed to load session {}: {}", id, e), + } + } else { + // List all sessions + match storage.list() { + Ok(sessions) => { + if sessions.is_empty() { + println!("No sessions found."); + } else { + println!( + "{:<36} {:<20} {:<8} {:<10} Updated", + "ID", "Title", "Messages", "Tokens" + ); + println!("{}", "-".repeat(90)); + for session in sessions { + let updated = session.updated_at.format("%Y-%m-%d %H:%M"); + println!( + "{:<36} {:<20} {:<8} {:<10} {}", + session.id, + truncate_str(&session.title, 18), + session.messages.len(), + session.tokens_used, + updated + ); + } + } + } + Err(e) => eprintln!("Failed to list sessions: {}", e), + } + } + } + Some(Commands::Version) => { + use miyabi_core::config::Config; + + let config = Config::load().unwrap_or_default(); + + println!("Miyabi v{}", env!("CARGO_PKG_VERSION")); + println!(); + println!("Model: {}", config.api.model); + println!("Config: {}", Config::default_path().display()); + println!("Sessions: {}", config.sessions_dir().display()); + println!(); + println!( + "Platform: {} ({})", + std::env::consts::OS, + std::env::consts::ARCH + ); + } + Some(Commands::Agent { + prompt, + max_iterations, + auto_approve, + format, + system, + }) => { + use miyabi_core::{ + config::Config, Agent, AgentConfig, AgentEvent, AnthropicClient, ExecutorRegistry, + }; + use tokio::sync::mpsc; + + // Load config + let mut config = if let Some(config_path) = &cli.config { + Config::load_from(config_path)? + } else { + Config::load().unwrap_or_default() + }; + + // Apply CLI overrides + if let Some(model) = &cli.model { + config.api.model = model.clone(); + } + if let Some(max_tokens) = cli.max_tokens { + config.api.max_tokens = max_tokens; + } + if cli.thinking { + config.api.thinking = true; + } + + // Get API key + let api_key = config + .api + .api_key + .clone() + .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) + .ok_or_else(|| { + anyhow::anyhow!("No API key found. Set ANTHROPIC_API_KEY or add to config.") + })?; + + // Create client + let client = AnthropicClient::new(api_key)? + .with_model(&config.api.model) + .with_max_tokens(config.api.max_tokens) + .with_thinking(config.api.thinking); + + // Create executor registry with standard tools + let registry = ExecutorRegistry::with_standard_tools(); + + // Configure agent + let agent_config = AgentConfig { + max_iterations, + max_tokens_per_turn: config.api.max_tokens, + require_approval: !auto_approve, + auto_approve_patterns: if auto_approve { + vec![ + "read".to_string(), + "glob".to_string(), + "grep".to_string(), + "write".to_string(), + "edit".to_string(), + "bash".to_string(), + ] + } else { + vec!["read".to_string(), "glob".to_string(), "grep".to_string()] + }, + ..Default::default() + }; + + // Create agent + let mut agent = Agent::new(client, registry).with_config(agent_config); + + // Set system prompt + if let Some(sys) = system { + agent = agent.with_system_prompt(sys); + } else if let Some(sys) = config.api.system_prompt { + agent = agent.with_system_prompt(sys); + } + + // Create event channel for progress + let (tx, mut rx) = mpsc::channel(100); + let agent = agent.with_event_channel(tx); + + // Spawn agent execution + let agent_handle = tokio::spawn(async move { agent.run(&prompt).await }); + + // Process events + while let Some(event) = rx.recv().await { + match &event { + AgentEvent::Started { prompt } => { + if format != "json" { + eprintln!("🚀 Agent started with prompt: {}", truncate_str(prompt, 50)); + } + } + AgentEvent::Thinking { iteration } => { + if format != "json" { + eprintln!("💭 Iteration {}", iteration + 1); + } + } + AgentEvent::ToolDetected { name, .. } => { + if format != "json" { + eprintln!("🔧 Tool detected: {}", name); + } + } + AgentEvent::ToolExecuting { name, .. } => { + if format != "json" { + eprintln!("⚡ Executing: {}", name); + } + } + AgentEvent::ToolCompleted { name, .. } => { + if format != "json" { + eprintln!("✅ Completed: {}", name); + } + } + AgentEvent::ToolFailed { name, error } => { + if format != "json" { + eprintln!("❌ Failed {}: {}", name, error); + } + } + AgentEvent::Completed { result } => { + if format == "json" { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!("\n{}", result.output); + eprintln!( + "\n📊 Stats: {} iterations, {} tool calls, {} tokens", + result.iterations, result.tool_calls, result.total_tokens + ); + } + } + AgentEvent::Failed { error } => { + eprintln!("❌ Agent failed: {}", error); + } + _ => {} + } + } + + // Wait for agent to complete + match agent_handle.await? { + Ok(_) => {} + Err(e) => { + eprintln!("Agent error: {}", e); + std::process::exit(1); + } + } } } Ok(()) } + +fn truncate_str(s: &str, max_len: usize) -> String { + if s.len() > max_len { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } else { + s.to_string() + } +} diff --git a/crates/miyabi-core/Cargo.toml b/crates/miyabi-core/Cargo.toml index b01d311..b12c4e4 100644 --- a/crates/miyabi-core/Cargo.toml +++ b/crates/miyabi-core/Cargo.toml @@ -23,6 +23,8 @@ futures = { workspace = true } async-trait = { workspace = true } glob = { workspace = true } regex = { workspace = true } +dirs = "5" +toml = "0.8" [dev-dependencies] tempfile = "3" diff --git a/crates/miyabi-core/src/agent/core.rs b/crates/miyabi-core/src/agent/core.rs new file mode 100644 index 0000000..1b89c08 --- /dev/null +++ b/crates/miyabi-core/src/agent/core.rs @@ -0,0 +1,358 @@ +//! Agent struct and execution loop + +use std::time::Duration; + +use serde_json::Value; +use tokio::sync::mpsc; + +use crate::{AnthropicClient, ContentBlock, Message, Role, StopReason}; + +use super::{AgentError, AgentEvent, AgentResult, ExecutorRegistry}; + +/// Configuration for agent execution +#[derive(Debug, Clone)] +pub struct AgentConfig { + /// Maximum number of iterations (default: 10) + pub max_iterations: usize, + /// Maximum tokens per turn (default: 8192) + pub max_tokens_per_turn: u32, + /// Timeout per tool execution (default: 300s) + pub timeout_per_tool: Duration, + /// Whether to require approval for tools (default: true) + pub require_approval: bool, + /// Patterns for auto-approved tools (e.g., "read", "glob") + pub auto_approve_patterns: Vec, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + max_iterations: 10, + max_tokens_per_turn: 8192, + timeout_per_tool: Duration::from_secs(300), + require_approval: true, + auto_approve_patterns: vec!["read".to_string(), "glob".to_string(), "grep".to_string()], + } + } +} + +/// Autonomous agent for executing tasks with tools +pub struct Agent { + client: AnthropicClient, + executor_registry: ExecutorRegistry, + config: AgentConfig, + system_prompt: Option, + event_tx: Option>, +} + +impl Agent { + /// Create new agent with client and tools + pub fn new(client: AnthropicClient, executor_registry: ExecutorRegistry) -> Self { + Self { + client, + executor_registry, + config: AgentConfig::default(), + system_prompt: None, + event_tx: None, + } + } + + /// Set agent configuration + pub fn with_config(mut self, config: AgentConfig) -> Self { + self.config = config; + self + } + + /// Set system prompt + pub fn with_system_prompt(mut self, prompt: String) -> Self { + self.system_prompt = Some(prompt); + self + } + + /// Set event channel for progress updates + pub fn with_event_channel(mut self, tx: mpsc::Sender) -> Self { + self.event_tx = Some(tx); + self + } + + /// Emit event if channel is set + async fn emit_event(&self, event: AgentEvent) { + if let Some(tx) = &self.event_tx { + let _ = tx.send(event).await; + } + } + + /// Check if tool should be auto-approved + fn is_auto_approved(&self, tool_name: &str) -> bool { + if !self.config.require_approval { + return true; + } + self.config + .auto_approve_patterns + .iter() + .any(|pattern| tool_name.to_lowercase().contains(&pattern.to_lowercase())) + } + + /// Extract text content from response + fn extract_text(&self, content: &[ContentBlock]) -> String { + content + .iter() + .filter_map(|block| { + if let ContentBlock::Text { text } = block { + Some(text.clone()) + } else { + None + } + }) + .collect::>() + .join("\n") + } + + /// Extract tool uses from response + fn extract_tool_uses(&self, content: &[ContentBlock]) -> Vec { + content + .iter() + .filter_map(|block| { + if let ContentBlock::ToolUse { id, name, input } = block { + Some(ToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }) + } else { + None + } + }) + .collect() + } + + /// Main agent execution loop + pub async fn run(&self, prompt: &str) -> Result { + self.emit_event(AgentEvent::Started { + prompt: prompt.to_string(), + }) + .await; + + let mut messages = vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: prompt.to_string(), + }], + }]; + + let mut total_tokens = 0usize; + let mut tool_calls = 0usize; + + let tools = self.executor_registry.to_api_tools(); + + for iteration in 0..self.config.max_iterations { + self.emit_event(AgentEvent::Thinking { iteration }).await; + + // Call Claude API + let response = self + .client + .message( + messages.clone(), + self.system_prompt.clone(), + Some(tools.clone()), + None, + ) + .await?; + + // Track token usage + let input_tokens = response.usage.input_tokens as usize; + let output_tokens = response.usage.output_tokens as usize; + total_tokens += input_tokens + output_tokens; + + self.emit_event(AgentEvent::TokenUsage { + input: input_tokens, + output: output_tokens, + }) + .await; + + // Handle response based on stop reason + match response.stop_reason { + Some(StopReason::EndTurn) | Some(StopReason::StopSequence) => { + // Agent completed + let output = self.extract_text(&response.content); + let result = AgentResult { + output, + iterations: iteration + 1, + tool_calls, + total_tokens, + messages, + }; + self.emit_event(AgentEvent::Completed { + result: result.clone(), + }) + .await; + return Ok(result); + } + Some(StopReason::ToolUse) => { + // Extract tool uses + let tool_uses = self.extract_tool_uses(&response.content); + let mut results = Vec::new(); + + for tool_use in tool_uses { + tool_calls += 1; + + self.emit_event(AgentEvent::ToolDetected { + id: tool_use.id.clone(), + name: tool_use.name.clone(), + input: tool_use.input.clone(), + }) + .await; + + // Check approval + let needs_approval = !self.is_auto_approved(&tool_use.name) + && self.executor_registry.requires_approval(&tool_use.name); + + if needs_approval { + self.emit_event(AgentEvent::AwaitingApproval { + id: tool_use.id.clone(), + name: tool_use.name.clone(), + input: tool_use.input.clone(), + }) + .await; + // In non-interactive mode, auto-approve for now + // TODO: Add approval callback mechanism + } + + // Execute tool + self.emit_event(AgentEvent::ToolExecuting { + name: tool_use.name.clone(), + input: tool_use.input.clone(), + }) + .await; + + match self + .executor_registry + .execute(&tool_use.name, tool_use.input.clone()) + .await + { + Ok(output) => { + self.emit_event(AgentEvent::ToolCompleted { + name: tool_use.name.clone(), + output: output.clone(), + }) + .await; + + // Create tool result + let content = serde_json::to_string(&output.content) + .unwrap_or_else(|_| output.content.to_string()); + + results.push(ContentBlock::ToolResult { + tool_use_id: tool_use.id, + content, + }); + } + Err(e) => { + self.emit_event(AgentEvent::ToolFailed { + name: tool_use.name.clone(), + error: e.to_string(), + }) + .await; + + // Add error as tool result + results.push(ContentBlock::ToolResult { + tool_use_id: tool_use.id, + content: format!("Error: {}", e), + }); + } + } + } + + // Add assistant message with tool uses + messages.push(Message { + role: Role::Assistant, + content: response.content, + }); + + // Add tool results as user message + messages.push(Message { + role: Role::User, + content: results, + }); + } + Some(StopReason::MaxTokens) => { + return Err(AgentError::MaxTokensReached); + } + Some(StopReason::ModelContextWindowExceeded) => { + return Err(AgentError::ContextWindowExceeded); + } + None => { + // No stop reason, treat as end turn + let output = self.extract_text(&response.content); + let result = AgentResult { + output, + iterations: iteration + 1, + tool_calls, + total_tokens, + messages, + }; + self.emit_event(AgentEvent::Completed { + result: result.clone(), + }) + .await; + return Ok(result); + } + } + } + + Err(AgentError::MaxIterationsReached(self.config.max_iterations)) + } +} + +/// Internal tool use representation +#[derive(Debug, Clone)] +struct ToolUse { + id: String, + name: String, + input: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_agent_config_default() { + let config = AgentConfig::default(); + assert_eq!(config.max_iterations, 10); + assert_eq!(config.max_tokens_per_turn, 8192); + assert!(config.require_approval); + assert!(config.auto_approve_patterns.contains(&"read".to_string())); + } + + #[test] + fn test_is_auto_approved() { + let client = AnthropicClient::new("test-key".to_string()).unwrap(); + let registry = ExecutorRegistry::new(); + let agent = Agent::new(client, registry); + + assert!(agent.is_auto_approved("read")); + assert!(agent.is_auto_approved("glob")); + assert!(agent.is_auto_approved("grep")); + assert!(!agent.is_auto_approved("bash")); + assert!(!agent.is_auto_approved("write")); + } + + #[test] + fn test_extract_text() { + let client = AnthropicClient::new("test-key".to_string()).unwrap(); + let registry = ExecutorRegistry::new(); + let agent = Agent::new(client, registry); + + let content = vec![ + ContentBlock::Text { + text: "Hello".to_string(), + }, + ContentBlock::Text { + text: "World".to_string(), + }, + ]; + + let text = agent.extract_text(&content); + assert_eq!(text, "Hello\nWorld"); + } +} diff --git a/crates/miyabi-core/src/agent/events.rs b/crates/miyabi-core/src/agent/events.rs new file mode 100644 index 0000000..4eedf7b --- /dev/null +++ b/crates/miyabi-core/src/agent/events.rs @@ -0,0 +1,120 @@ +//! Agent events and results + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use crate::{Message, ToolOutput}; + +/// Events emitted during agent execution +#[derive(Debug, Clone)] +pub enum AgentEvent { + // Lifecycle events + /// Agent has started processing + Started { prompt: String }, + /// Agent is thinking (new iteration) + Thinking { iteration: usize }, + /// Agent completed successfully + Completed { result: AgentResult }, + /// Agent failed with error + Failed { error: String }, + + // Tool events + /// Tool use detected in response + ToolDetected { + id: String, + name: String, + input: Value, + }, + /// Waiting for user approval + AwaitingApproval { + id: String, + name: String, + input: Value, + }, + /// Tool was approved + ToolApproved { id: String }, + /// Tool was denied + ToolDenied { id: String, reason: String }, + /// Tool execution started + ToolExecuting { name: String, input: Value }, + /// Tool execution progress + ToolProgress { name: String, progress: f32 }, + /// Tool completed successfully + ToolCompleted { name: String, output: ToolOutput }, + /// Tool execution failed + ToolFailed { name: String, error: String }, + + // Streaming events + /// Text delta from streaming response + TextDelta { text: String }, + /// Token usage update + TokenUsage { input: usize, output: usize }, +} + +/// Result of agent execution +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentResult { + /// Final output text + pub output: String, + /// Number of iterations executed + pub iterations: usize, + /// Total tool calls made + pub tool_calls: usize, + /// Total tokens used + pub total_tokens: usize, + /// Final conversation state + pub messages: Vec, +} + +/// Agent execution errors +#[derive(Debug, Error)] +pub enum AgentError { + #[error("Maximum iterations ({0}) reached")] + MaxIterationsReached(usize), + + #[error("Maximum tokens reached")] + MaxTokensReached, + + #[error("Context window exceeded for this model")] + ContextWindowExceeded, + + #[error("Tool execution failed: {0}")] + ToolExecutionFailed(String), + + #[error("Tool not found: {0}")] + ToolNotFound(String), + + #[error("Tool denied by user: {0}")] + ToolDenied(String), + + #[error("API error: {0}")] + ApiError(String), + + #[error("Serialization error: {0}")] + SerializationError(String), + + #[error("Timeout after {0} seconds")] + Timeout(u64), + + #[error("Agent was cancelled")] + Cancelled, +} + +impl From for AgentError { + fn from(err: crate::AnthropicError) -> Self { + AgentError::ApiError(err.to_string()) + } +} + +impl From for AgentError { + fn from(err: serde_json::Error) -> Self { + AgentError::SerializationError(err.to_string()) + } +} + +impl From for AgentError { + fn from(err: crate::ToolError) -> Self { + AgentError::ToolExecutionFailed(err.to_string()) + } +} diff --git a/crates/miyabi-core/src/agent/executor.rs b/crates/miyabi-core/src/agent/executor.rs new file mode 100644 index 0000000..2a49a9e --- /dev/null +++ b/crates/miyabi-core/src/agent/executor.rs @@ -0,0 +1,213 @@ +//! Tool executor trait and registry + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::{ + anthropic::Tool as ApiTool, + tool::{Tool as ToolTrait, ToolError, ToolOutput}, + tools::{BashTool, EditTool, GlobTool, GrepTool, ReadTool, WriteTool}, +}; + +/// Risk level for tool execution +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum RiskLevel { + /// Read-only operations (Read, Glob, Grep) + Low, + /// File modification (Write, Edit) + Medium, + /// System execution (Bash) + High, + /// Destructive operations + Critical, +} + +impl RiskLevel { + /// Check if this risk level requires approval + pub fn requires_approval(&self) -> bool { + matches!(self, RiskLevel::High | RiskLevel::Critical) + } + + /// Get display name + pub fn as_str(&self) -> &'static str { + match self { + RiskLevel::Low => "Low", + RiskLevel::Medium => "Medium", + RiskLevel::High => "High", + RiskLevel::Critical => "Critical", + } + } +} + +/// Trait for executable tools with metadata +#[async_trait] +pub trait ToolExecutor: Send + Sync { + /// Tool name + fn name(&self) -> &str; + + /// Tool description + fn description(&self) -> &str; + + /// Tool definition for API + fn definition(&self) -> ApiTool; + + /// Risk level of this tool + fn risk_level(&self) -> RiskLevel { + RiskLevel::Medium + } + + /// Execute the tool with input + async fn execute(&self, input: Value) -> Result; + + /// Validate input before execution + fn validate(&self, _input: &Value) -> Result<(), ToolError> { + Ok(()) + } + + /// Timeout for this tool + fn timeout(&self) -> Duration { + Duration::from_secs(120) + } +} + +/// Adapter to wrap existing Tool trait implementations +pub struct ToolExecutorAdapter { + tool: T, + risk_level: RiskLevel, +} + +impl ToolExecutorAdapter { + pub fn new(tool: T, risk_level: RiskLevel) -> Self { + Self { tool, risk_level } + } +} + +#[async_trait] +impl ToolExecutor for ToolExecutorAdapter { + fn name(&self) -> &str { + self.tool.name() + } + + fn description(&self) -> &str { + self.tool.description() + } + + fn definition(&self) -> ApiTool { + ApiTool { + name: self.tool.name().to_string(), + description: self.tool.description().to_string(), + input_schema: self.tool.schema(), + } + } + + fn risk_level(&self) -> RiskLevel { + self.risk_level + } + + async fn execute(&self, input: Value) -> Result { + self.tool.execute(input).await + } + + fn validate(&self, input: &Value) -> Result<(), ToolError> { + self.tool.validate(input) + } +} + +/// Registry for tool executors +pub struct ExecutorRegistry { + executors: HashMap>, +} + +impl ExecutorRegistry { + /// Create empty registry + pub fn new() -> Self { + Self { + executors: HashMap::new(), + } + } + + /// Register a tool executor + pub fn register(&mut self, executor: T) { + let name = executor.name().to_string(); + self.executors.insert(name, Arc::new(executor)); + } + + /// Get executor by name + pub fn get(&self, name: &str) -> Option> { + self.executors.get(name).cloned() + } + + /// Execute tool by name + pub async fn execute(&self, name: &str, input: Value) -> Result { + let executor = self + .executors + .get(name) + .ok_or_else(|| ToolError::NotFound(name.to_string()))?; + + executor.validate(&input)?; + executor.execute(input).await + } + + /// Get all tool definitions for API + pub fn to_api_tools(&self) -> Vec { + self.executors.values().map(|e| e.definition()).collect() + } + + /// Get tool names + pub fn tool_names(&self) -> Vec { + self.executors.keys().cloned().collect() + } + + /// Get risk level for a tool + pub fn risk_level(&self, name: &str) -> Option { + self.executors.get(name).map(|e| e.risk_level()) + } + + /// Check if tool requires approval + pub fn requires_approval(&self, name: &str) -> bool { + self.executors + .get(name) + .map(|e| e.risk_level().requires_approval()) + .unwrap_or(true) + } + + /// Create registry with standard tools + pub fn with_standard_tools() -> Self { + let mut registry = Self::new(); + + // Read-only tools (Low risk) + registry.register(ToolExecutorAdapter::new(ReadTool::new(), RiskLevel::Low)); + registry.register(ToolExecutorAdapter::new(GlobTool::new(), RiskLevel::Low)); + registry.register(ToolExecutorAdapter::new(GrepTool::new(), RiskLevel::Low)); + + // File modification tools (Medium risk) + registry.register(ToolExecutorAdapter::new( + WriteTool::new(), + RiskLevel::Medium, + )); + registry.register(ToolExecutorAdapter::new(EditTool::new(), RiskLevel::Medium)); + + // System execution (High risk) + registry.register(ToolExecutorAdapter::new(BashTool::new(), RiskLevel::High)); + + registry + } +} + +impl Default for ExecutorRegistry { + fn default() -> Self { + Self::new() + } +} + +impl Clone for ExecutorRegistry { + fn clone(&self) -> Self { + Self { + executors: self.executors.clone(), + } + } +} diff --git a/crates/miyabi-core/src/agent/mod.rs b/crates/miyabi-core/src/agent/mod.rs new file mode 100644 index 0000000..be3f490 --- /dev/null +++ b/crates/miyabi-core/src/agent/mod.rs @@ -0,0 +1,12 @@ +//! Agent module for autonomous task execution +//! +//! This module provides the core Agent loop that enables autonomous +//! tool execution with Claude API, similar to Claude Agent SDK. + +mod core; +mod events; +mod executor; + +pub use core::{Agent, AgentConfig}; +pub use events::{AgentError, AgentEvent, AgentResult}; +pub use executor::{ExecutorRegistry, RiskLevel, ToolExecutor}; diff --git a/crates/miyabi-core/src/anthropic.rs b/crates/miyabi-core/src/anthropic.rs index e78c9e6..ee253bd 100644 --- a/crates/miyabi-core/src/anthropic.rs +++ b/crates/miyabi-core/src/anthropic.rs @@ -15,7 +15,7 @@ use tracing::{debug, error, warn}; const API_BASE_URL: &str = "https://api.anthropic.com"; /// Default model to use -pub const DEFAULT_MODEL: &str = "claude-sonnet-4-20250514"; +pub const DEFAULT_MODEL: &str = "claude-sonnet-4-5-20250929"; /// Maximum retry attempts for transient errors const MAX_RETRIES: u32 = 3; @@ -127,9 +127,18 @@ pub enum Role { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { - Text { text: String }, - ToolUse { id: String, name: String, input: serde_json::Value }, - ToolResult { tool_use_id: String, content: String }, + Text { + text: String, + }, + ToolUse { + id: String, + name: String, + input: serde_json::Value, + }, + ToolResult { + tool_use_id: String, + content: String, + }, } /// A message in a conversation @@ -177,6 +186,8 @@ pub struct MessagesRequest { pub tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, pub stream: bool, } @@ -188,6 +199,7 @@ pub enum StopReason { MaxTokens, StopSequence, ToolUse, + ModelContextWindowExceeded, } /// Usage statistics @@ -216,7 +228,10 @@ pub enum StreamEvent { /// Message started MessageStart { message: MessagesResponse }, /// Content block started - ContentBlockStart { index: usize, content_block: ContentBlock }, + ContentBlockStart { + index: usize, + content_block: ContentBlock, + }, /// Text delta in content ContentBlockDelta { index: usize, delta: TextDelta }, /// Content block finished @@ -252,6 +267,7 @@ pub struct AnthropicClient { api_key: String, model: String, max_tokens: u32, + thinking: bool, } impl AnthropicClient { @@ -274,6 +290,7 @@ impl AnthropicClient { api_key, model: DEFAULT_MODEL.to_string(), max_tokens: 4096, + thinking: false, }) } @@ -289,6 +306,12 @@ impl AnthropicClient { self } + /// Enable or disable extended thinking + pub fn with_thinking(mut self, thinking: bool) -> Self { + self.thinking = thinking; + self + } + /// Build request headers fn build_headers(&self) -> Result { let mut headers = HeaderMap::new(); @@ -298,10 +321,7 @@ impl AnthropicClient { HeaderValue::from_str(&self.api_key) .map_err(|_| AnthropicError::ConfigError("Invalid API key format".to_string()))?, ); - headers.insert( - "anthropic-version", - HeaderValue::from_static("2023-06-01"), - ); + headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); Ok(headers) } @@ -320,6 +340,7 @@ impl AnthropicClient { system, tools, temperature, + thinking: self.thinking.then_some(true), stream: false, }; @@ -356,7 +377,7 @@ impl AnthropicClient { } Err(last_error.unwrap_or(AnthropicError::StreamError( - "Max retries exceeded".to_string() + "Max retries exceeded".to_string(), ))) } @@ -441,6 +462,7 @@ impl AnthropicClient { system, tools, temperature, + thinking: self.thinking.then_some(true), stream: true, }; @@ -465,29 +487,35 @@ impl AnthropicClient { let stream = response.bytes_stream(); - Ok(Box::pin(stream.scan(String::new(), |buffer, chunk| { - let result = match chunk { - Ok(bytes) => { - buffer.push_str(&String::from_utf8_lossy(&bytes)); + Ok(Box::pin( + stream + .scan(String::new(), |buffer, chunk| { + let result = match chunk { + Ok(bytes) => { + buffer.push_str(&String::from_utf8_lossy(&bytes)); - let mut events = Vec::new(); + let mut events = Vec::new(); - // Parse SSE events from buffer - while let Some(event_end) = buffer.find("\n\n") { - let event_data = buffer[..event_end].to_string(); - *buffer = buffer[event_end + 2..].to_string(); + // Parse SSE events from buffer + while let Some(event_end) = buffer.find("\n\n") { + let event_data = buffer[..event_end].to_string(); + *buffer = buffer[event_end + 2..].to_string(); - if let Some(event) = parse_sse_event(&event_data) { - events.push(Ok(event)); + if let Some(event) = parse_sse_event(&event_data) { + events.push(Ok(event)); + } + } + + Some(futures::stream::iter(events)) } - } - - Some(futures::stream::iter(events)) - } - Err(e) => Some(futures::stream::iter(vec![Err(AnthropicError::NetworkError(e))])), - }; - async move { result } - }).flatten())) + Err(e) => Some(futures::stream::iter(vec![Err( + AnthropicError::NetworkError(e), + )])), + }; + async move { result } + }) + .flatten(), + )) } } @@ -510,14 +538,19 @@ fn parse_sse_event(event_data: &str) -> Option { match event_type.as_str() { "message_start" => { let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; - let message: MessagesResponse = serde_json::from_value(parsed.get("message")?.clone()).ok()?; + let message: MessagesResponse = + serde_json::from_value(parsed.get("message")?.clone()).ok()?; Some(StreamEvent::MessageStart { message }) } "content_block_start" => { let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; let index = parsed.get("index")?.as_u64()? as usize; - let content_block: ContentBlock = serde_json::from_value(parsed.get("content_block")?.clone()).ok()?; - Some(StreamEvent::ContentBlockStart { index, content_block }) + let content_block: ContentBlock = + serde_json::from_value(parsed.get("content_block")?.clone()).ok()?; + Some(StreamEvent::ContentBlockStart { + index, + content_block, + }) } "content_block_delta" => { let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; @@ -677,7 +710,9 @@ mod tests { let auth_err = AnthropicError::AuthError("invalid key".to_string()); assert!(auth_err.to_string().contains("invalid key")); - let rate_err = AnthropicError::RateLimited { retry_after_ms: 5000 }; + let rate_err = AnthropicError::RateLimited { + retry_after_ms: 5000, + }; assert!(rate_err.to_string().contains("5000")); let api_err = AnthropicError::ApiError { diff --git a/crates/miyabi-core/src/config.rs b/crates/miyabi-core/src/config.rs new file mode 100644 index 0000000..37ec903 --- /dev/null +++ b/crates/miyabi-core/src/config.rs @@ -0,0 +1,388 @@ +//! Configuration Management +//! +//! Handles loading and managing application configuration from files and environment. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +/// Main application configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +#[derive(Default)] +pub struct Config { + /// API configuration + pub api: ApiConfig, + /// UI configuration + pub ui: UiConfig, + /// Session configuration + pub session: SessionConfig, + /// Tool configuration + pub tools: ToolConfig, +} + + +/// API configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ApiConfig { + /// Anthropic API key (can also use ANTHROPIC_API_KEY env var) + pub api_key: Option, + /// Model to use + pub model: String, + /// Maximum tokens for responses + pub max_tokens: u32, + /// Enable extended thinking (Claude 4.5+) + pub thinking: bool, + /// System prompt + pub system_prompt: Option, + /// Request timeout in seconds + pub timeout_secs: u64, + /// Maximum retries for failed requests + pub max_retries: u32, +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + api_key: None, + model: "claude-sonnet-4-5-20250929".to_string(), + max_tokens: 8192, + thinking: false, + system_prompt: Some( + "You are a helpful AI assistant. Be concise and clear.".to_string(), + ), + timeout_secs: 120, + max_retries: 3, + } + } +} + +/// UI configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct UiConfig { + /// Show sidebar + pub show_sidebar: bool, + /// Show status bar + pub show_status_bar: bool, + /// Show breadcrumb + pub show_breadcrumb: bool, + /// Theme name + pub theme: String, + /// Enable vim mode in composer + pub vim_mode: bool, + /// Show line numbers in code blocks + pub show_line_numbers: bool, +} + +impl Default for UiConfig { + fn default() -> Self { + Self { + show_sidebar: false, + show_status_bar: true, + show_breadcrumb: true, + theme: "tokyo-night".to_string(), + vim_mode: false, + show_line_numbers: true, + } + } +} + +/// Session configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SessionConfig { + /// Directory for storing sessions + pub sessions_dir: Option, + /// Auto-save sessions + pub auto_save: bool, + /// Auto-save interval in seconds + pub auto_save_interval: u64, + /// Maximum sessions to keep + pub max_sessions: usize, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + sessions_dir: None, + auto_save: true, + auto_save_interval: 30, + max_sessions: 100, + } + } +} + +/// Tool configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ToolConfig { + /// Enable bash tool + pub enable_bash: bool, + /// Enable file tools (read, write, edit) + pub enable_file_tools: bool, + /// Enable search tools (glob, grep) + pub enable_search_tools: bool, + /// Auto-approve low-risk tools + pub auto_approve_low_risk: bool, + /// Working directory for tools + pub working_dir: Option, + /// Bash timeout in seconds + pub bash_timeout: u64, +} + +impl Default for ToolConfig { + fn default() -> Self { + Self { + enable_bash: true, + enable_file_tools: true, + enable_search_tools: true, + auto_approve_low_risk: false, + working_dir: None, + bash_timeout: 120, + } + } +} + +impl Config { + /// Load configuration from default location (~/.miyabi/config.toml) + pub fn load() -> anyhow::Result { + let config_path = Self::default_path(); + if config_path.exists() { + Self::load_from(&config_path) + } else { + Ok(Self::default()) + } + } + + /// Load configuration from a specific path + pub fn load_from(path: &PathBuf) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let mut config: Config = toml::from_str(&content)?; + + // Apply environment variable overrides + config.apply_env_overrides(); + + Ok(config) + } + + /// Get default configuration path + pub fn default_path() -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".miyabi").join("config.toml") + } + + /// Get default config directory + pub fn default_dir() -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".miyabi") + } + + /// Apply environment variable overrides + fn apply_env_overrides(&mut self) { + // API key from environment + if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") { + self.api.api_key = Some(key); + } + + // Model from environment + if let Ok(model) = std::env::var("MIYABI_MODEL") { + self.api.model = model; + } + + // Max tokens from environment + if let Ok(tokens) = std::env::var("MIYABI_MAX_TOKENS") { + if let Ok(n) = tokens.parse() { + self.api.max_tokens = n; + } + } + + // Thinking toggle from environment + if let Ok(thinking) = std::env::var("MIYABI_THINKING") { + let thinking = thinking.eq_ignore_ascii_case("true") || thinking == "1"; + self.api.thinking = thinking; + } + } + + /// Save configuration to default location + pub fn save(&self) -> anyhow::Result<()> { + let path = Self::default_path(); + self.save_to(&path) + } + + /// Save configuration to a specific path + pub fn save_to(&self, path: &PathBuf) -> anyhow::Result<()> { + // Ensure directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let content = toml::to_string_pretty(self)?; + fs::write(path, content)?; + Ok(()) + } + + /// Generate default configuration file + pub fn generate_default() -> anyhow::Result { + let config = Self::default(); + let path = Self::default_path(); + config.save_to(&path)?; + Ok(path) + } + + /// Get API key (from config or environment) + pub fn api_key(&self) -> Option<&str> { + self.api.api_key.as_deref() + } + + /// Get sessions directory + pub fn sessions_dir(&self) -> PathBuf { + self.session + .sessions_dir + .clone() + .unwrap_or_else(|| Self::default_dir().join("sessions")) + } + + /// Get working directory for tools + pub fn working_dir(&self) -> PathBuf { + self.tools + .working_dir + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.api.model, "claude-sonnet-4-5-20250929"); + assert_eq!(config.api.max_tokens, 8192); + assert!(!config.api.thinking); + assert!(config.ui.show_status_bar); + } + + #[test] + fn test_api_config_default() { + let api = ApiConfig::default(); + assert!(api.api_key.is_none()); + assert_eq!(api.timeout_secs, 120); + assert_eq!(api.max_retries, 3); + assert!(!api.thinking); + } + + #[test] + fn test_ui_config_default() { + let ui = UiConfig::default(); + assert!(!ui.show_sidebar); + assert!(ui.show_status_bar); + assert_eq!(ui.theme, "tokyo-night"); + } + + #[test] + fn test_session_config_default() { + let session = SessionConfig::default(); + assert!(session.auto_save); + assert_eq!(session.auto_save_interval, 30); + assert_eq!(session.max_sessions, 100); + } + + #[test] + fn test_tool_config_default() { + let tools = ToolConfig::default(); + assert!(tools.enable_bash); + assert!(tools.enable_file_tools); + assert!(!tools.auto_approve_low_risk); + } + + #[test] + fn test_save_load_config() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + + let config = Config::default(); + config.save_to(&path).unwrap(); + + let loaded = Config::load_from(&path).unwrap(); + assert_eq!(loaded.api.model, config.api.model); + assert_eq!(loaded.api.max_tokens, config.api.max_tokens); + assert_eq!(loaded.api.thinking, config.api.thinking); + } + + #[test] + fn test_config_with_custom_values() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + + let content = r#" +[api] +model = "claude-3-opus" +max_tokens = 4096 +thinking = true + +[ui] +theme = "dracula" +vim_mode = true + +[session] +auto_save = false +"#; + fs::write(&path, content).unwrap(); + + let config = Config::load_from(&path).unwrap(); + assert_eq!(config.api.model, "claude-3-opus"); + assert_eq!(config.api.max_tokens, 4096); + assert!(config.api.thinking); + assert_eq!(config.ui.theme, "dracula"); + assert!(config.ui.vim_mode); + assert!(!config.session.auto_save); + } + + #[test] + fn test_partial_config() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + + // Only specify some values + let content = r#" +[api] +model = "custom-model" +"#; + fs::write(&path, content).unwrap(); + + let config = Config::load_from(&path).unwrap(); + assert_eq!(config.api.model, "custom-model"); + // Defaults should be applied + assert_eq!(config.api.max_tokens, 8192); + assert!(config.ui.show_status_bar); + } + + #[test] + fn test_sessions_dir() { + let config = Config::default(); + let sessions_dir = config.sessions_dir(); + assert!(sessions_dir.to_string_lossy().contains("sessions")); + } + + #[test] + fn test_working_dir() { + let mut config = Config::default(); + config.tools.working_dir = Some(PathBuf::from("/tmp/test")); + assert_eq!(config.working_dir(), PathBuf::from("/tmp/test")); + } + + #[test] + fn test_toml_serialization() { + let config = Config::default(); + let toml_str = toml::to_string(&config).unwrap(); + assert!(toml_str.contains("[api]")); + assert!(toml_str.contains("[ui]")); + assert!(toml_str.contains("[session]")); + assert!(toml_str.contains("[tools]")); + } +} diff --git a/crates/miyabi-core/src/conversation.rs b/crates/miyabi-core/src/conversation.rs index 82c14c5..90e48e1 100644 --- a/crates/miyabi-core/src/conversation.rs +++ b/crates/miyabi-core/src/conversation.rs @@ -3,7 +3,7 @@ //! This module provides conversation management for maintaining context //! across multiple interactions with the Claude API. -use crate::anthropic::{Message, Role, ContentBlock}; +use crate::anthropic::{ContentBlock, Message, Role}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -407,8 +407,7 @@ mod tests { #[test] fn test_system_prompt() { - let conv = Conversation::new() - .with_system_prompt("You are a helpful assistant"); + let conv = Conversation::new().with_system_prompt("You are a helpful assistant"); assert_eq!( conv.get_system_prompt(), diff --git a/crates/miyabi-core/src/lib.rs b/crates/miyabi-core/src/lib.rs index 256b840..4faefcc 100644 --- a/crates/miyabi-core/src/lib.rs +++ b/crates/miyabi-core/src/lib.rs @@ -2,27 +2,45 @@ //! //! This crate provides core types and utilities shared across the Miyabi framework. -pub mod error; -pub mod types; +pub mod agent; pub mod anthropic; -pub mod tool; +pub mod config; pub mod conversation; -pub mod tools; +pub mod error; +pub mod session; pub mod token; +pub mod tool; +pub mod tools; +pub mod types; -pub use error::Error; -pub use types::*; +pub use agent::{ + Agent, AgentConfig, AgentError, AgentEvent, AgentResult, ExecutorRegistry, RiskLevel, + ToolExecutor, +}; pub use anthropic::{ - AnthropicClient, AnthropicError, Message, Role, ContentBlock, - MessagesRequest, MessagesResponse, StreamEvent, StopReason, Usage, - Tool as ApiTool, // Anthropic API tool definition format - RetryConfig, // Retry configuration for API requests -}; -pub use tool::{ - Tool as ToolTrait, ToolRegistry, ToolError, ToolOutput, ToolResult, ParameterDef, + AnthropicClient, + AnthropicError, + ContentBlock, + Message, + MessagesRequest, + MessagesResponse, + RetryConfig, // Retry configuration for API requests + Role, + StopReason, + StreamEvent, + Tool as ApiTool, // Anthropic API tool definition format + Usage, }; +pub use config::{ApiConfig, Config, SessionConfig, ToolConfig, UiConfig}; pub use conversation::{ - Conversation, ConversationMessage, ConversationManager, ConversationMetadata, ConversationError, + Conversation, ConversationError, ConversationManager, ConversationMessage, ConversationMetadata, }; -pub use tools::{ReadTool, WriteTool, EditTool, BashTool, GlobTool, GrepTool, create_file_tool_registry, create_standard_tool_registry}; -pub use token::{TokenCounter, TokenUsage, ContextManager, ContextUsage, ModelLimits}; +pub use error::Error; +pub use session::{Session, SessionMetadata, SessionStorage}; +pub use token::{ContextManager, ContextUsage, ModelLimits, TokenCounter, TokenUsage}; +pub use tool::{ParameterDef, Tool as ToolTrait, ToolError, ToolOutput, ToolRegistry, ToolResult}; +pub use tools::{ + create_file_tool_registry, create_standard_tool_registry, BashTool, EditTool, GlobTool, + GrepTool, ReadTool, WriteTool, +}; +pub use types::*; diff --git a/crates/miyabi-core/src/session.rs b/crates/miyabi-core/src/session.rs new file mode 100644 index 0000000..38d8f7a --- /dev/null +++ b/crates/miyabi-core/src/session.rs @@ -0,0 +1,346 @@ +//! Session Persistence +//! +//! Handles saving and loading conversation sessions to disk. + +use crate::anthropic::Message; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use uuid::Uuid; + +/// A complete session with metadata and conversation history +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + /// Unique session ID + pub id: String, + /// Session title + pub title: String, + /// Created timestamp + pub created_at: DateTime, + /// Last updated timestamp + pub updated_at: DateTime, + /// Model used + pub model: String, + /// Token usage + pub tokens_used: usize, + /// Session tags + pub tags: Vec, + /// Conversation history + pub messages: Vec, + /// System prompt used + pub system_prompt: Option, +} + +impl Session { + /// Create a new session + pub fn new(title: impl Into) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + title: title.into(), + created_at: now, + updated_at: now, + model: String::new(), + tokens_used: 0, + tags: Vec::new(), + messages: Vec::new(), + system_prompt: None, + } + } + + /// Create with a specific ID + pub fn with_id(id: impl Into, title: impl Into) -> Self { + let now = Utc::now(); + Self { + id: id.into(), + title: title.into(), + created_at: now, + updated_at: now, + model: String::new(), + tokens_used: 0, + tags: Vec::new(), + messages: Vec::new(), + system_prompt: None, + } + } + + /// Set model name + pub fn model(mut self, model: impl Into) -> Self { + self.model = model.into(); + self + } + + /// Set system prompt + pub fn system_prompt(mut self, prompt: impl Into) -> Self { + self.system_prompt = Some(prompt.into()); + self + } + + /// Add a message to the conversation + pub fn push_message(&mut self, message: Message) { + self.messages.push(message); + self.updated_at = Utc::now(); + } + + /// Update token usage + pub fn add_tokens(&mut self, tokens: usize) { + self.tokens_used += tokens; + self.updated_at = Utc::now(); + } + + /// Get message count + pub fn message_count(&self) -> usize { + self.messages.len() + } + + /// Get last message preview + pub fn preview(&self) -> String { + self.messages + .last() + .and_then(|m| { + m.content.first().and_then(|c| match c { + crate::anthropic::ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }) + }) + .unwrap_or_default() + .chars() + .take(100) + .collect() + } +} + +/// Session storage manager +#[derive(Debug, Clone)] +pub struct SessionStorage { + /// Directory for storing sessions + sessions_dir: PathBuf, +} + +impl SessionStorage { + /// Create a new session storage + pub fn new(sessions_dir: impl Into) -> Self { + Self { + sessions_dir: sessions_dir.into(), + } + } + + /// Create with default directory (~/.miyabi/sessions) + pub fn default_dir() -> Self { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + Self::new(home.join(".miyabi").join("sessions")) + } + + /// Ensure the sessions directory exists + pub fn ensure_dir(&self) -> std::io::Result<()> { + fs::create_dir_all(&self.sessions_dir) + } + + /// Get path for a session file + fn session_path(&self, id: &str) -> PathBuf { + self.sessions_dir.join(format!("{}.json", id)) + } + + /// Save a session to disk + pub fn save(&self, session: &Session) -> anyhow::Result<()> { + self.ensure_dir()?; + let path = self.session_path(&session.id); + let json = serde_json::to_string_pretty(session)?; + fs::write(path, json)?; + Ok(()) + } + + /// Load a session from disk + pub fn load(&self, id: &str) -> anyhow::Result { + let path = self.session_path(id); + let json = fs::read_to_string(path)?; + let session: Session = serde_json::from_str(&json)?; + Ok(session) + } + + /// Delete a session + pub fn delete(&self, id: &str) -> std::io::Result<()> { + let path = self.session_path(id); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) + } + + /// List all sessions + pub fn list(&self) -> anyhow::Result> { + self.ensure_dir()?; + let mut sessions = Vec::new(); + + for entry in fs::read_dir(&self.sessions_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(json) = fs::read_to_string(&path) { + if let Ok(session) = serde_json::from_str::(&json) { + sessions.push(session); + } + } + } + } + + // Sort by updated_at descending + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + + Ok(sessions) + } + + /// List session metadata only (for picker) + pub fn list_metadata(&self) -> anyhow::Result> { + let sessions = self.list()?; + Ok(sessions.into_iter().map(|s| s.into()).collect()) + } + + /// Check if a session exists + pub fn exists(&self, id: &str) -> bool { + self.session_path(id).exists() + } +} + +/// Lightweight session metadata for listing +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionMetadata { + pub id: String, + pub title: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub model: String, + pub tokens_used: usize, + pub message_count: usize, + pub preview: String, + pub tags: Vec, +} + +impl From for SessionMetadata { + fn from(session: Session) -> Self { + let preview = session.preview(); + let message_count = session.messages.len(); + Self { + id: session.id, + title: session.title, + created_at: session.created_at, + updated_at: session.updated_at, + model: session.model, + tokens_used: session.tokens_used, + message_count, + preview, + tags: session.tags, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_session_creation() { + let session = Session::new("Test Session"); + assert!(!session.id.is_empty()); + assert_eq!(session.title, "Test Session"); + assert_eq!(session.messages.len(), 0); + } + + #[test] + fn test_session_with_model() { + let session = Session::new("Test").model("claude-3-sonnet"); + assert_eq!(session.model, "claude-3-sonnet"); + } + + #[test] + fn test_session_push_message() { + let mut session = Session::new("Test"); + session.push_message(Message::user("Hello")); + assert_eq!(session.message_count(), 1); + } + + #[test] + fn test_session_preview() { + let mut session = Session::new("Test"); + session.push_message(Message::user("Hello, world!")); + assert_eq!(session.preview(), "Hello, world!"); + } + + #[test] + fn test_session_tokens() { + let mut session = Session::new("Test"); + session.add_tokens(100); + session.add_tokens(50); + assert_eq!(session.tokens_used, 150); + } + + #[test] + fn test_storage_save_load() { + let dir = TempDir::new().unwrap(); + let storage = SessionStorage::new(dir.path()); + + let mut session = Session::new("Test Session"); + session.push_message(Message::user("Hello")); + + // Save + storage.save(&session).unwrap(); + + // Load + let loaded = storage.load(&session.id).unwrap(); + assert_eq!(loaded.title, "Test Session"); + assert_eq!(loaded.message_count(), 1); + } + + #[test] + fn test_storage_list() { + let dir = TempDir::new().unwrap(); + let storage = SessionStorage::new(dir.path()); + + // Create multiple sessions + let session1 = Session::new("Session 1"); + let session2 = Session::new("Session 2"); + + storage.save(&session1).unwrap(); + storage.save(&session2).unwrap(); + + let sessions = storage.list().unwrap(); + assert_eq!(sessions.len(), 2); + } + + #[test] + fn test_storage_delete() { + let dir = TempDir::new().unwrap(); + let storage = SessionStorage::new(dir.path()); + + let session = Session::new("Test"); + storage.save(&session).unwrap(); + + assert!(storage.exists(&session.id)); + storage.delete(&session.id).unwrap(); + assert!(!storage.exists(&session.id)); + } + + #[test] + fn test_storage_list_empty() { + let dir = TempDir::new().unwrap(); + let storage = SessionStorage::new(dir.path()); + + let sessions = storage.list().unwrap(); + assert!(sessions.is_empty()); + } + + #[test] + fn test_metadata_conversion() { + let mut session = Session::new("Test"); + session.push_message(Message::user("Hello")); + session.tokens_used = 100; + + let metadata: SessionMetadata = session.into(); + assert_eq!(metadata.title, "Test"); + assert_eq!(metadata.message_count, 1); + assert_eq!(metadata.tokens_used, 100); + } +} diff --git a/crates/miyabi-core/src/token.rs b/crates/miyabi-core/src/token.rs index 7b634f6..81eb878 100644 --- a/crates/miyabi-core/src/token.rs +++ b/crates/miyabi-core/src/token.rs @@ -3,7 +3,7 @@ //! This module provides token estimation and context window management //! for Claude API conversations. -use crate::anthropic::{ContentBlock, Message}; +use crate::anthropic::{ContentBlock, Message, DEFAULT_MODEL}; use crate::conversation::{Conversation, ConversationMessage}; use serde::{Deserialize, Serialize}; @@ -17,6 +17,18 @@ pub struct ModelLimits { } impl ModelLimits { + /// Claude 4.5 Sonnet limits + pub const CLAUDE_4_5_SONNET: Self = Self { + context_window: 200_000, + max_output: 4_096, + }; + + /// Claude 4.5 Haiku limits + pub const CLAUDE_4_5_HAIKU: Self = Self { + context_window: 200_000, + max_output: 4_096, + }; + /// Claude 3 Opus limits pub const CLAUDE_3_OPUS: Self = Self { context_window: 200_000, @@ -37,9 +49,15 @@ impl ModelLimits { /// Get limits for a model name pub fn for_model(model: &str) -> Self { - if model.contains("opus") { + let lower = model.to_lowercase(); + + if lower.contains("sonnet-4-5") { + Self::CLAUDE_4_5_SONNET + } else if lower.contains("haiku-4-5") { + Self::CLAUDE_4_5_HAIKU + } else if lower.contains("opus") { Self::CLAUDE_3_OPUS - } else if model.contains("haiku") { + } else if lower.contains("haiku") { Self::CLAUDE_3_HAIKU } else { Self::CLAUDE_3_SONNET @@ -94,10 +112,7 @@ impl Default for TokenCounter { impl TokenCounter { /// Create a new token counter with default settings pub fn new() -> Self { - Self { - limits: ModelLimits::CLAUDE_3_SONNET, - chars_per_token: 4.0, - } + Self::with_model(DEFAULT_MODEL) } /// Create with specific model limits @@ -121,9 +136,7 @@ impl TokenCounter { let input_str = serde_json::to_string(input).unwrap_or_default(); self.estimate_text(name) + self.estimate_text(&input_str) + 20 } - ContentBlock::ToolResult { content, .. } => { - self.estimate_text(content) + 20 - } + ContentBlock::ToolResult { content, .. } => self.estimate_text(content) + 20, } } @@ -324,7 +337,7 @@ mod tests { // ~4 chars per token let tokens = counter.estimate_text("Hello, World!"); // 13 chars - assert!(tokens >= 3 && tokens <= 5); + assert!((3..=5).contains(&tokens)); } #[test] @@ -334,13 +347,18 @@ mod tests { let limits = ModelLimits::for_model("claude-3-sonnet"); assert_eq!(limits.context_window, 200_000); + + let limits = ModelLimits::for_model("claude-sonnet-4-5-20250929"); + assert_eq!(limits.context_window, 200_000); + + let limits = ModelLimits::for_model("claude-haiku-4-5-20251001"); + assert_eq!(limits.context_window, 200_000); } #[test] fn test_conversation_estimation() { let counter = TokenCounter::new(); - let mut conv = Conversation::new() - .with_system_prompt("You are a helpful assistant"); + let mut conv = Conversation::new().with_system_prompt("You are a helpful assistant"); conv.add_user_message("Hello"); conv.add_assistant_message("Hi there!"); @@ -352,8 +370,7 @@ mod tests { #[test] fn test_within_limits() { let counter = TokenCounter::new(); - let conv = Conversation::new() - .with_system_prompt("Test"); + let conv = Conversation::new().with_system_prompt("Test"); assert!(counter.within_limits(&conv)); } @@ -378,13 +395,20 @@ mod tests { // Add messages that will exceed the limit for i in 0..10 { // Each message is roughly 30+ tokens - conv.add_user_message(format!("This is message number {} with substantial content that uses many tokens", i)); + conv.add_user_message(format!( + "This is message number {} with substantial content that uses many tokens", + i + )); } let initial = conv.message_count(); let removed = manager.prune(&mut conv); - assert!(removed > 0, "Expected messages to be removed, tokens: {}", manager.estimate_tokens(&conv)); + assert!( + removed > 0, + "Expected messages to be removed, tokens: {}", + manager.estimate_tokens(&conv) + ); assert!(conv.message_count() < initial); } diff --git a/crates/miyabi-core/src/tool.rs b/crates/miyabi-core/src/tool.rs index 2d38bb7..460d1e7 100644 --- a/crates/miyabi-core/src/tool.rs +++ b/crates/miyabi-core/src/tool.rs @@ -267,7 +267,10 @@ pub trait Tool: Send + Sync { for param in params { let mut prop = serde_json::Map::new(); prop.insert("type".to_string(), Value::String(param.param_type.clone())); - prop.insert("description".to_string(), Value::String(param.description.clone())); + prop.insert( + "description".to_string(), + Value::String(param.description.clone()), + ); if let Some(default) = param.default { prop.insert("default".to_string(), default); @@ -633,7 +636,10 @@ impl ExecutionHistory { /// Get records for a tool pub fn by_tool(&self, tool_name: &str) -> Vec<&ExecutionRecord> { - self.records.iter().filter(|r| r.tool_name == tool_name).collect() + self.records + .iter() + .filter(|r| r.tool_name == tool_name) + .collect() } /// Total execution count @@ -643,20 +649,23 @@ impl ExecutionHistory { /// Success count pub fn success_count(&self) -> usize { - self.records.iter().filter(|r| r.status == ExecutionStatus::Completed).count() + self.records + .iter() + .filter(|r| r.status == ExecutionStatus::Completed) + .count() } /// Failure count pub fn failure_count(&self) -> usize { - self.records.iter().filter(|r| r.status == ExecutionStatus::Failed).count() + self.records + .iter() + .filter(|r| r.status == ExecutionStatus::Failed) + .count() } /// Average execution time (for completed executions) pub fn average_duration_ms(&self) -> Option { - let durations: Vec = self.records - .iter() - .filter_map(|r| r.duration_ms) - .collect(); + let durations: Vec = self.records.iter().filter_map(|r| r.duration_ms).collect(); if durations.is_empty() { None @@ -685,7 +694,11 @@ pub enum ExecutionEvent { /// Execution started Started { call_id: String }, /// Progress update - Progress { call_id: String, progress: f32, message: String }, + Progress { + call_id: String, + progress: f32, + message: String, + }, /// Output chunk (for streaming) OutputChunk { call_id: String, chunk: String }, /// Execution completed @@ -712,10 +725,7 @@ pub struct ToolExecutor { impl ToolExecutor { /// Create a new executor - pub fn new( - registry: Arc, - event_tx: mpsc::Sender, - ) -> Self { + pub fn new(registry: Arc, event_tx: mpsc::Sender) -> Self { Self { registry, history: Arc::new(RwLock::new(ExecutionHistory::new())), @@ -747,17 +757,23 @@ impl ToolExecutor { self.history.write().await.add(record); // Emit queued event - let _ = self.event_tx.send(ExecutionEvent::Queued { - call_id: call_id.clone(), - tool_name: tool_name.clone(), - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::Queued { + call_id: call_id.clone(), + tool_name: tool_name.clone(), + }) + .await; // Check approval requirement if call.requires_approval { - let _ = self.event_tx.send(ExecutionEvent::AwaitingApproval { - call_id: call_id.clone(), - tool_name: tool_name.clone(), - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::AwaitingApproval { + call_id: call_id.clone(), + tool_name: tool_name.clone(), + }) + .await; if let Some(record) = self.history.write().await.get_mut(&call_id) { record.status = ExecutionStatus::AwaitingApproval; @@ -765,9 +781,12 @@ impl ToolExecutor { // In a real implementation, we would wait for approval here // For now, we auto-approve - let _ = self.event_tx.send(ExecutionEvent::Approved { - call_id: call_id.clone(), - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::Approved { + call_id: call_id.clone(), + }) + .await; } // Mark as running @@ -775,15 +794,19 @@ impl ToolExecutor { record.start(); } - let _ = self.event_tx.send(ExecutionEvent::Started { - call_id: call_id.clone(), - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::Started { + call_id: call_id.clone(), + }) + .await; // Execute with timeout let result = tokio::time::timeout( std::time::Duration::from_millis(self.default_timeout_ms), - self.registry.execute(&call.name, call.input) - ).await; + self.registry.execute(&call.name, call.input), + ) + .await; match result { Ok(Ok(output)) => { @@ -792,10 +815,13 @@ impl ToolExecutor { record.complete(output.clone()); } - let _ = self.event_tx.send(ExecutionEvent::Completed { - call_id, - output: output.clone(), - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::Completed { + call_id, + output: output.clone(), + }) + .await; Ok(output) } @@ -806,10 +832,13 @@ impl ToolExecutor { record.fail(&error_msg); } - let _ = self.event_tx.send(ExecutionEvent::Failed { - call_id, - error: error_msg, - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::Failed { + call_id, + error: error_msg, + }) + .await; Err(e) } @@ -822,10 +851,13 @@ impl ToolExecutor { record.completed_at = Some(Utc::now()); } - let _ = self.event_tx.send(ExecutionEvent::Failed { - call_id, - error: error_msg.clone(), - }).await; + let _ = self + .event_tx + .send(ExecutionEvent::Failed { + call_id, + error: error_msg.clone(), + }) + .await; Err(ToolError::Timeout(self.default_timeout_ms)) } @@ -839,9 +871,7 @@ impl ToolExecutor { let results = stream::iter(calls) .map(|call| { let executor = self.clone_inner(); - async move { - executor.execute(call).await - } + async move { executor.execute(call).await } }) .buffer_unordered(self.max_concurrent) .collect::>() @@ -851,7 +881,10 @@ impl ToolExecutor { } /// Execute calls respecting dependencies (DAG execution) - pub async fn execute_dag(&self, calls: Vec) -> HashMap> { + pub async fn execute_dag( + &self, + calls: Vec, + ) -> HashMap> { let mut results: HashMap> = HashMap::new(); let mut completed: std::collections::HashSet = std::collections::HashSet::new(); let mut remaining: Vec = calls; @@ -860,9 +893,7 @@ impl ToolExecutor { // Find calls with satisfied dependencies let (ready, not_ready): (Vec<_>, Vec<_>) = remaining .into_iter() - .partition(|call| { - call.dependencies.iter().all(|dep| completed.contains(dep)) - }); + .partition(|call| call.dependencies.iter().all(|dep| completed.contains(dep))); if ready.is_empty() && !not_ready.is_empty() { // Circular dependency or missing dependency @@ -871,8 +902,8 @@ impl ToolExecutor { results.insert( call.id.clone(), Err(ToolError::ExecutionFailed( - "Unresolved dependencies".to_string() - )) + "Unresolved dependencies".to_string(), + )), ); } break; @@ -1130,7 +1161,10 @@ mod tests { assert_eq!(schema["type"], "object"); assert!(schema["properties"]["message"].is_object()); - assert!(schema["required"].as_array().unwrap().contains(&Value::String("message".to_string()))); + assert!(schema["required"] + .as_array() + .unwrap() + .contains(&Value::String("message".to_string()))); } #[tokio::test] @@ -1152,9 +1186,7 @@ mod tests { async fn test_tool_not_found() { let registry = ToolRegistry::new(); - let result = registry - .execute("missing", serde_json::json!({})) - .await; + let result = registry.execute("missing", serde_json::json!({})).await; assert!(matches!(result, Err(ToolError::NotFound(_)))); } @@ -1206,12 +1238,15 @@ mod tests { #[test] fn test_parameter_def_builders() { - let param = ParameterDef::required_string("test", "Test parameter") - .with_default("default_value"); + let param = + ParameterDef::required_string("test", "Test parameter").with_default("default_value"); assert_eq!(param.name, "test"); assert!(!param.required); // Setting default makes it optional - assert_eq!(param.default, Some(Value::String("default_value".to_string()))); + assert_eq!( + param.default, + Some(Value::String("default_value".to_string())) + ); } #[test] diff --git a/crates/miyabi-core/src/tools.rs b/crates/miyabi-core/src/tools.rs index bf73024..8ef66eb 100644 --- a/crates/miyabi-core/src/tools.rs +++ b/crates/miyabi-core/src/tools.rs @@ -54,9 +54,9 @@ impl ReadTool { }; // Security check: prevent path traversal - let canonical = resolved.canonicalize().map_err(|e| { - ToolError::ExecutionFailed(format!("Cannot resolve path: {}", e)) - })?; + let canonical = resolved + .canonicalize() + .map_err(|e| ToolError::ExecutionFailed(format!("Cannot resolve path: {}", e)))?; // Ensure path is within allowed boundaries if !canonical.starts_with(&self.base_dir) && !canonical.is_absolute() { @@ -107,17 +107,17 @@ impl Tool for ReadTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidInput("path is required".to_string()))?; - let offset = input - .get("offset") - .and_then(|v| v.as_u64()) - .unwrap_or(1) as usize; + let offset = input.get("offset").and_then(|v| v.as_u64()).unwrap_or(1) as usize; let limit = input .get("limit") .and_then(|v| v.as_u64()) .map(|v| v as usize); - debug!("Reading file: {} (offset: {}, limit: {:?})", path, offset, limit); + debug!( + "Reading file: {} (offset: {}, limit: {:?})", + path, offset, limit + ); let resolved = self.resolve_path(path)?; let content = std::fs::read_to_string(&resolved) @@ -339,10 +339,7 @@ impl Tool for EditTool { .and_then(|v| v.as_bool()) .unwrap_or(false); - debug!( - "Editing file: {} (replace_all: {})", - path, replace_all - ); + debug!("Editing file: {} (replace_all: {})", path, replace_all); let resolved = self.resolve_path(path)?; let content = std::fs::read_to_string(&resolved) @@ -432,7 +429,10 @@ impl BashTool { for pattern in dangerous { if command.contains(pattern) { - return Some(format!("Potentially dangerous command detected: {}", pattern)); + return Some(format!( + "Potentially dangerous command detected: {}", + pattern + )); } } None @@ -495,7 +495,10 @@ impl Tool for BashTool { .map(PathBuf::from) .unwrap_or_else(|| self.working_dir.clone()); - debug!("Executing bash command: {} (timeout: {}s)", command, timeout_secs); + debug!( + "Executing bash command: {} (timeout: {}s)", + command, timeout_secs + ); // Check for dangerous commands if let Some(warning) = self.check_dangerous(command) { @@ -612,7 +615,10 @@ impl Tool for GlobTool { fn parameters(&self) -> Vec { vec![ - ParameterDef::required_string("pattern", "Glob pattern to match (e.g., **/*.rs, src/**/*.ts)"), + ParameterDef::required_string( + "pattern", + "Glob pattern to match (e.g., **/*.rs, src/**/*.ts)", + ), ParameterDef::optional_string("path", "Base directory to search in"), ] } @@ -639,9 +645,8 @@ impl Tool for GlobTool { }; // Execute glob - let entries = glob(&full_pattern).map_err(|e| { - ToolError::InvalidInput(format!("Invalid glob pattern: {}", e)) - })?; + let entries = glob(&full_pattern) + .map_err(|e| ToolError::InvalidInput(format!("Invalid glob pattern: {}", e)))?; let mut matches = Vec::new(); for entry in entries { @@ -750,7 +755,10 @@ impl Tool for GrepTool { .and_then(|v| v.as_bool()) .unwrap_or(false); - debug!("Grep search: {} in {} (case_insensitive: {})", pattern, path, case_insensitive); + debug!( + "Grep search: {} in {} (case_insensitive: {})", + pattern, path, case_insensitive + ); // Build regex let regex_pattern = if case_insensitive { @@ -759,9 +767,8 @@ impl Tool for GrepTool { pattern.to_string() }; - let regex = Regex::new(®ex_pattern).map_err(|e| { - ToolError::InvalidInput(format!("Invalid regex pattern: {}", e)) - })?; + let regex = Regex::new(®ex_pattern) + .map_err(|e| ToolError::InvalidInput(format!("Invalid regex pattern: {}", e)))?; let search_path = if Path::new(path).is_absolute() { PathBuf::from(path) @@ -900,7 +907,10 @@ mod tests { #[tokio::test] async fn test_read_tool_offset_limit() { let dir = TempDir::new().unwrap(); - let content = (1..=10).map(|i| format!("Line {}", i)).collect::>().join("\n"); + let content = (1..=10) + .map(|i| format!("Line {}", i)) + .collect::>() + .join("\n"); create_temp_file(&dir, "test.txt", &content); let tool = ReadTool::with_base_dir(dir.path()); @@ -1180,7 +1190,10 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); assert!(output.success); - assert!(output.content["stdout"].as_str().unwrap().contains("Hello, World!")); + assert!(output.content["stdout"] + .as_str() + .unwrap() + .contains("Hello, World!")); } #[tokio::test] diff --git a/crates/miyabi-core/src/types.rs b/crates/miyabi-core/src/types.rs index 466854c..78b016b 100644 --- a/crates/miyabi-core/src/types.rs +++ b/crates/miyabi-core/src/types.rs @@ -1,7 +1,7 @@ //! Core types for Miyabi -use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; /// Message role in a conversation #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -56,7 +56,11 @@ impl ChatMessage { } } - pub fn tool(name: impl Into, content: impl Into, call_id: impl Into) -> Self { + pub fn tool( + name: impl Into, + content: impl Into, + call_id: impl Into, + ) -> Self { Self { role: Role::Tool, content: content.into(), diff --git a/crates/miyabi-tui/Cargo.toml b/crates/miyabi-tui/Cargo.toml index 1111ce8..cdbf450 100644 --- a/crates/miyabi-tui/Cargo.toml +++ b/crates/miyabi-tui/Cargo.toml @@ -20,6 +20,7 @@ path = "src/lib.rs" [dependencies] # Workspace dependencies miyabi-core = { path = "../miyabi-core" } +arboard = { version = "3", optional = true } # TUI Framework ratatui = { workspace = true } @@ -47,3 +48,6 @@ serde_json = { workspace = true } chrono = { workspace = true } once_cell = { workspace = true } uuid = { workspace = true } + +[features] +clipboard = ["arboard"] diff --git a/crates/miyabi-tui/src/app.rs b/crates/miyabi-tui/src/app.rs index 59febb9..9a3f482 100644 --- a/crates/miyabi-tui/src/app.rs +++ b/crates/miyabi-tui/src/app.rs @@ -1,13 +1,44 @@ //! Main TUI Application +pub mod event_loop; +pub mod state; + +use std::time::Instant; + use futures::StreamExt; +use crate::approval_overlay::{ApprovalRequest, RiskLevel}; use crate::event::{Event, EventHandler}; use crate::history_cell::{ - UserMessageCell, AssistantMessageCell, SystemMessageCell, SystemMessageType, + AssistantMessageCell, SystemMessageCell, SystemMessageType, ToolResultCell, UserMessageCell, }; use crate::views::{MainView, ViewAction}; -use miyabi_core::anthropic::{AnthropicClient, Message, StreamEvent}; +use miyabi_core::anthropic::{AnthropicClient, ContentBlock, Message, StreamEvent}; +use miyabi_core::config::Config; +use miyabi_core::session::{Session, SessionStorage}; +use miyabi_core::tool::ToolRegistry; +use miyabi_core::tools::create_standard_tool_registry; +use miyabi_core::{Agent, AgentConfig, AgentEvent, ExecutorRegistry}; +use tokio::sync::mpsc; + +const MODEL_PRESETS: &[&str] = &[ + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20251001", + "claude-sonnet-4-20250514", +]; +const THINKING_ON: &str = "thinking:on"; +const THINKING_OFF: &str = "thinking:off"; + +/// Pending tool request awaiting approval +#[derive(Debug, Clone)] +pub struct PendingTool { + /// Tool use ID from Claude + pub id: String, + /// Tool name + pub name: String, + /// Tool input + pub input: serde_json::Value, +} /// Main application state pub struct App { @@ -21,23 +52,55 @@ pub struct App { conversation: Vec, /// Whether currently streaming a response is_streaming: bool, + /// Tool registry for executing tools + tool_registry: ToolRegistry, + /// Pending tools awaiting approval + pending_tools: Vec, + /// Current session + session: Session, + /// Session storage for persistence + storage: SessionStorage, + /// System prompt for API requests + system_prompt: Option, + /// Agent mode (autonomous execution) + agent_mode: bool, + /// API key for agent mode + api_key: Option, + /// Model name for agent mode + model_name: String, + /// Max tokens for agent mode + max_tokens: u32, + /// Whether to request extended thinking + thinking: bool, } impl App { - /// Create a new app + /// Create a new app with default configuration pub fn new() -> Self { + let config = Config::load().unwrap_or_default(); + Self::with_config(config) + } + + /// Create a new app with specific configuration + pub fn with_config(config: Config) -> Self { let timestamp = chrono::Local::now().format("%H:%M").to_string(); - // Try to get API key from environment - let client = std::env::var("ANTHROPIC_API_KEY") - .ok() + // Create Anthropic client from config + let client = config + .api + .api_key + .as_ref() .and_then(|key| AnthropicClient::new(key).ok()) - .map(|c| c.with_max_tokens(8192)); + .map(|c| { + c.with_model(&config.api.model) + .with_max_tokens(config.api.max_tokens) + .with_thinking(config.api.thinking) + }); let welcome_message = if client.is_some() { "Welcome to Miyabi CLI! Type your message and press Enter. Press Ctrl+P for commands, F1 for help." } else { - "⚠ ANTHROPIC_API_KEY not set. Running in demo mode. Press Ctrl+P for commands." + "⚠ ANTHROPIC_API_KEY not set. Please set it in config or environment to use Claude API." }; let mut view = MainView::new(); @@ -46,13 +109,31 @@ impl App { view.push_message(Box::new(SystemMessageCell { content: welcome_message.to_string(), timestamp: timestamp.clone(), - message_type: if client.is_some() { SystemMessageType::Info } else { SystemMessageType::Warning }, + message_type: if client.is_some() { + SystemMessageType::Info + } else { + SystemMessageType::Warning + }, })); - // Set model name if client available - if client.is_some() { - view = view.with_model("claude-sonnet-4-20250514"); - } + // Get model name from config + let model_name = &config.api.model; + view = view.with_model(model_name); + + // Create session with model from config + let session = Session::new("New Session").model(model_name); + + // Create storage using config sessions directory + let storage = SessionStorage::new(config.sessions_dir()); + + // Get system prompt from config + let system_prompt = config.api.system_prompt.clone(); + + // Store config values for agent mode + let api_key = config.api.api_key.clone(); + let model_name = config.api.model.clone(); + let max_tokens = config.api.max_tokens; + let thinking = config.api.thinking; Self { should_quit: false, @@ -60,13 +141,63 @@ impl App { client, conversation: Vec::new(), is_streaming: false, + tool_registry: create_standard_tool_registry(), + pending_tools: Vec::new(), + session, + storage, + system_prompt, + agent_mode: false, + api_key, + model_name, + max_tokens, + thinking, } } + /// Toggle agent mode + pub fn toggle_agent_mode(&mut self) { + self.agent_mode = !self.agent_mode; + let mode_str = if self.agent_mode { "Agent" } else { "Chat" }; + self.view + .notifications + .info("Mode Changed", format!("Switched to {} mode", mode_str)); + + // Update view mode indicator + if self.agent_mode { + self.view.set_mode_indicator("🤖 AGENT"); + } else { + self.view.set_mode_indicator(""); + } + } + + /// Save current session to disk + pub fn save_session(&self) -> anyhow::Result<()> { + self.storage.save(&self.session) + } + + /// Load a session by ID + pub fn load_session(&mut self, id: &str) -> anyhow::Result<()> { + let session = self.storage.load(id)?; + self.conversation = session.messages.clone(); + self.view.tokens_used = session.tokens_used; + self.session = session; + Ok(()) + } + + /// Get session ID + pub fn session_id(&self) -> &str { + &self.session.id + } + + /// Get a mutable reference to the tool registry for registration + pub fn tool_registry_mut(&mut self) -> &mut ToolRegistry { + &mut self.tool_registry + } + /// Run the main app loop - pub async fn run( + pub async fn run( &mut self, - terminal: &mut ratatui::Terminal, + terminal: &mut ratatui::Terminal, ) -> anyhow::Result<()> { let mut events = EventHandler::new(100); @@ -78,12 +209,13 @@ impl App { Event::Key(key) => { let action = self.view.handle_key(key); match action { + ViewAction::None => {} ViewAction::Quit => { self.should_quit = true; } ViewAction::SendMessage(message) => { if !self.is_streaming { - self.send_message(message).await; + self.send_message(message, terminal).await; } } ViewAction::ExecuteCommand(cmd) => { @@ -94,7 +226,37 @@ impl App { self.is_streaming = false; self.view.set_streaming(false); } - _ => {} + ViewAction::Approve { + request_id, + approved, + } => { + self.handle_tool_approval(&request_id, approved, terminal) + .await; + } + ViewAction::ToggleSidebar => { + // Sidebar already toggled in views.rs handle_key() + // No additional action needed here + } + ViewAction::Notify(notification) => { + self.view.notifications.panel.push(notification); + } + ViewAction::Copy(text) => { + // TODO: Implement clipboard support + self.view + .notifications + .info("Copied", format!("{} chars", text.len())); + } + ViewAction::OpenFile(path) => { + // TODO: Implement file opening + self.view.notifications.info("Open File", &path); + } + ViewAction::ResumeSession(session_id) => { + // TODO: Implement session resume + self.view.notifications.info("Resume Session", &session_id); + } + ViewAction::ToggleAgentMode => { + self.toggle_agent_mode(); + } } } Event::Resize(_, _) => {} @@ -110,6 +272,33 @@ impl App { } } + // Auto-save session on exit if there are messages + if !self.conversation.is_empty() { + // Update session title from first user message if still default + if self.session.title == "New Session" { + if let Some(first_msg) = self.conversation.first() { + if let Some(ContentBlock::Text { text }) = first_msg.content.first() { + // Take first 50 chars as title + let title: String = text.chars().take(50).collect(); + self.session.title = if title.len() < text.len() { + format!("{}...", title) + } else { + title + }; + } + } + } + + // Update session with conversation + self.session.messages = self.conversation.clone(); + self.session.tokens_used = self.view.tokens_used; + + // Save session + if let Err(e) = self.save_session() { + eprintln!("Failed to save session: {}", e); + } + } + Ok(()) } @@ -122,12 +311,69 @@ impl App { self.conversation.clear(); } "help" => self.view.show_help(), + "model" => self.cycle_model(), + THINKING_ON => self.set_thinking(true), + THINKING_OFF => self.set_thinking(false), _ => {} } } + fn set_model(&mut self, model: &str) { + self.model_name = model.to_string(); + self.view.model_name = model.to_string(); + self.session.model = model.to_string(); + + if let Some(ref api_key) = self.api_key { + // Recreate client with new model if API key exists + self.client = AnthropicClient::new(api_key.clone()).ok().map(|c| { + c.with_model(model) + .with_max_tokens(self.max_tokens) + .with_thinking(self.thinking) + }); + } + } + + fn cycle_model(&mut self) { + let current_index = MODEL_PRESETS + .iter() + .position(|m| *m == self.model_name) + .unwrap_or(0); + let next_index = (current_index + 1) % MODEL_PRESETS.len(); + let next_model = MODEL_PRESETS[next_index]; + self.set_model(next_model); + self.view.notifications.info("Model changed", next_model); + } + + fn set_thinking(&mut self, enabled: bool) { + self.thinking = enabled; + if let Some(ref api_key) = self.api_key { + // Recreate client with updated thinking flag + self.client = AnthropicClient::new(api_key.clone()).ok().map(|c| { + c.with_model(&self.model_name) + .with_max_tokens(self.max_tokens) + .with_thinking(self.thinking) + }); + } + let status = if enabled { + "Extended Thinking ON" + } else { + "Extended Thinking OFF" + }; + self.view.notifications.info("Thinking", status); + } + /// Send a message - async fn send_message(&mut self, message: String) { + async fn send_message( + &mut self, + message: String, + terminal: &mut ratatui::Terminal, + ) { + // Use agent mode if enabled + if self.agent_mode { + self.send_message_agent(message, terminal).await; + return; + } + let timestamp = chrono::Local::now().format("%H:%M").to_string(); // Add user message to UI @@ -146,53 +392,109 @@ impl App { // Add streaming placeholder let cell_index = self.view.history.len(); - self.view.push_message(Box::new(AssistantMessageCell { - content: String::new(), - timestamp: timestamp.clone(), - streaming: true, - })); + self.view + .push_message(Box::new(AssistantMessageCell::new(timestamp.clone()))); // Start streaming - match client.message_stream( - self.conversation.clone(), - Some("You are a helpful AI assistant. Be concise and clear.".to_string()), - None, - None, - ).await { + match client + .message_stream( + self.conversation.clone(), + self.system_prompt.clone(), + None, + None, + ) + .await + { Ok(mut stream) => { - let mut response_text = String::new(); - while let Some(event) = stream.next().await { match event { + Ok(StreamEvent::ContentBlockStart { + content_block: ContentBlock::ToolUse { id, name, input }, + .. + }) => { + // Store pending tool + self.pending_tools.push(PendingTool { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + + // Determine risk level based on tool + let risk = if name.contains("write") + || name.contains("delete") + || name.contains("execute") + { + RiskLevel::High + } else if name.contains("read") || name.contains("search") { + RiskLevel::Low + } else { + RiskLevel::Medium + }; + + // Show approval overlay + let request = ApprovalRequest::new(id, &name) + .risk_level(risk) + .description(format!("Execute tool: {}", name)); + self.view.show_approval(request); + } + Ok(StreamEvent::ContentBlockStart { .. }) => { + // Other content block types - ignore + } Ok(StreamEvent::ContentBlockDelta { delta, .. }) => { - response_text.push_str(&delta.text); - // Update the cell content - if let Some(cell) = self.view.history.get_mut(cell_index) { - if let Some(assistant_cell) = (**cell).as_any_mut().downcast_mut::() { - assistant_cell.content = response_text.clone(); + // Push delta to the stream directly + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = + cell.as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&delta.text); } } + // Redraw terminal to show streaming content + let _ = terminal.draw(|f| self.view.render(f)); + } + Ok(StreamEvent::MessageDelta { usage, .. }) => { + // Track token usage + self.view.tokens_used += usage.output_tokens as usize; } Ok(StreamEvent::MessageStop) => { break; } Ok(StreamEvent::Error { error }) => { - response_text = format!("Error: {}", error); + // Show error notification + self.view.notifications.error("API Error", &error); + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = + cell.as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&format!("Error: {}", error)); + } + } break; } _ => {} } } - // Mark as done streaming - if let Some(cell) = self.view.history.get_mut(cell_index) { - if let Some(assistant_cell) = (**cell).as_any_mut().downcast_mut::() { - assistant_cell.streaming = false; - if response_text.is_empty() { - assistant_cell.content = "(No response)".to_string(); + // Mark as done streaming and get content for conversation history + let response_text = if let Some(cell) = self.view.history.get_mut(cell_index) { + if let Some(assistant_cell) = + (**cell).as_any_mut().downcast_mut::() + { + assistant_cell.set_complete(); + if assistant_cell.is_empty() { + assistant_cell.set_content("(No response)"); } + assistant_cell.content() + } else { + String::new() } - } + } else { + String::new() + }; // Add to conversation history if !response_text.is_empty() { @@ -200,11 +502,17 @@ impl App { } } Err(e) => { + // Show error notification + self.view + .notifications + .error("Connection Error", e.to_string()); // Replace with error message if let Some(cell) = self.view.history.get_mut(cell_index) { - if let Some(assistant_cell) = (**cell).as_any_mut().downcast_mut::() { - assistant_cell.content = format!("Error: {}", e); - assistant_cell.streaming = false; + if let Some(assistant_cell) = + (**cell).as_any_mut().downcast_mut::() + { + assistant_cell.set_content(&format!("Error: {}", e)); + assistant_cell.set_complete(); } } } @@ -215,16 +523,414 @@ impl App { } else { // Demo mode - no API key let response = format!("You said: {}\n\nThis is a **demo response** with `markdown` support!\n\nSet ANTHROPIC_API_KEY to enable real responses.", message); - self.view.push_message(Box::new(AssistantMessageCell { - content: response, - timestamp, - streaming: false, - })); + let mut cell = AssistantMessageCell::new(timestamp); + cell.set_content(&response); + cell.set_complete(); + self.view.push_message(Box::new(cell)); } // Auto-scroll to bottom self.view.history_scroll = self.view.max_scroll; } + + /// Send a message in agent mode + async fn send_message_agent( + &mut self, + message: String, + terminal: &mut ratatui::Terminal, + ) { + let timestamp = chrono::Local::now().format("%H:%M").to_string(); + + // Add user message to UI + self.view.push_message(Box::new(UserMessageCell { + content: message.clone(), + timestamp: timestamp.clone(), + })); + + // Check for API key + let Some(api_key) = self.api_key.clone() else { + self.view + .notifications + .error("Agent Error", "No API key available"); + return; + }; + + // Create client + let client = match AnthropicClient::new(api_key) { + Ok(c) => c + .with_model(&self.model_name) + .with_max_tokens(self.max_tokens) + .with_thinking(self.thinking), + Err(e) => { + self.view.notifications.error("Agent Error", e.to_string()); + return; + } + }; + + // Create executor registry + let registry = ExecutorRegistry::with_standard_tools(); + + // Configure agent + let agent_config = AgentConfig { + max_iterations: 10, + max_tokens_per_turn: self.max_tokens, + require_approval: false, // Auto-approve in TUI agent mode + auto_approve_patterns: vec![ + "read".to_string(), + "glob".to_string(), + "grep".to_string(), + "write".to_string(), + "edit".to_string(), + "bash".to_string(), + ], + ..Default::default() + }; + + // Create agent + let mut agent = Agent::new(client, registry).with_config(agent_config); + if let Some(sys) = &self.system_prompt { + agent = agent.with_system_prompt(sys.clone()); + } + + // Create event channel + let (tx, mut rx) = mpsc::channel(100); + let agent = agent.with_event_channel(tx); + + // Start streaming indicator + self.is_streaming = true; + self.view.set_streaming(true); + + // Add placeholder for response + let cell_index = self.view.history.len(); + self.view + .push_message(Box::new(AssistantMessageCell::new(timestamp.clone()))); + + // Spawn agent + let prompt = message.clone(); + let agent_handle = tokio::spawn(async move { agent.run(&prompt).await }); + + // Process events + while let Some(event) = rx.recv().await { + match event { + AgentEvent::Thinking { iteration } => { + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = cell + .as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell + .push_str(&format!("💭 Iteration {}...\n", iteration + 1)); + } + } + let _ = terminal.draw(|f| self.view.render(f)); + } + AgentEvent::ToolExecuting { name, .. } => { + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = cell + .as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&format!("⚡ Executing: {}\n", name)); + } + } + let _ = terminal.draw(|f| self.view.render(f)); + } + AgentEvent::ToolCompleted { name, .. } => { + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = cell + .as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&format!("✅ {}\n", name)); + } + } + let _ = terminal.draw(|f| self.view.render(f)); + } + AgentEvent::ToolFailed { name, error } => { + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = cell + .as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&format!("❌ {}: {}\n", name, error)); + } + } + let _ = terminal.draw(|f| self.view.render(f)); + } + AgentEvent::Completed { result } => { + if let Some(cell) = self.view.history.get_mut(cell_index) { + if let Some(assistant_cell) = + (**cell).as_any_mut().downcast_mut::() + { + assistant_cell.set_content(&format!( + "{}\n\n---\n📊 {} iterations, {} tool calls, {} tokens", + result.output, + result.iterations, + result.tool_calls, + result.total_tokens + )); + assistant_cell.set_complete(); + } + } + self.view.tokens_used += result.total_tokens; + break; + } + AgentEvent::Failed { error } => { + if let Some(cell) = self.view.history.get_mut(cell_index) { + if let Some(assistant_cell) = + (**cell).as_any_mut().downcast_mut::() + { + assistant_cell.set_content(&format!("❌ Agent failed: {}", error)); + assistant_cell.set_complete(); + } + } + self.view.notifications.error("Agent Error", &error); + break; + } + _ => {} + } + } + + // Wait for agent to complete + let _ = agent_handle.await; + + self.is_streaming = false; + self.view.set_streaming(false); + + // Auto-scroll to bottom + self.view.history_scroll = self.view.max_scroll; + } + + /// Handle tool approval/rejection + async fn handle_tool_approval( + &mut self, + request_id: &str, + approved: bool, + terminal: &mut ratatui::Terminal, + ) { + let timestamp = chrono::Local::now().format("%H:%M").to_string(); + + // Find the pending tool + let tool_index = self.pending_tools.iter().position(|t| t.id == request_id); + let Some(tool_index) = tool_index else { + return; + }; + + let tool = self.pending_tools.remove(tool_index); + + if approved { + // Execute the tool with timing + let start = Instant::now(); + let result = self + .tool_registry + .execute(&tool.name, tool.input.clone()) + .await; + let execution_time_ms = start.elapsed().as_millis() as u64; + + let (content, is_error) = match result { + Ok(output) => { + // Convert Value to String for display + let content_str = if let serde_json::Value::String(s) = output.content { + s + } else { + serde_json::to_string_pretty(&output.content).unwrap_or_default() + }; + (content_str, false) + } + Err(e) => (format!("Error: {}", e), true), + }; + + // Add tool result to UI + self.view.push_message(Box::new(ToolResultCell::new( + tool.name.clone(), + content.clone(), + timestamp.clone(), + execution_time_ms, + !is_error, + Some(&tool.input), + ))); + + // Add tool_result to conversation for Claude + let tool_result = ContentBlock::ToolResult { + tool_use_id: tool.id, + content, + }; + self.conversation.push(Message { + role: miyabi_core::anthropic::Role::User, + content: vec![tool_result], + }); + + // Continue the conversation with tool result + // This will trigger another API call with the tool result + if let Some(client) = &self.client { + self.continue_with_tool_result(client.clone(), terminal) + .await; + } + } else { + // Tool was rejected + let error_content = format!("Tool '{}' was rejected by user", tool.name); + + self.view.push_message(Box::new(ToolResultCell::new( + tool.name.clone(), + error_content.clone(), + timestamp.clone(), + 0, + false, + Some(&tool.input), + ))); + + // Send rejection as tool_result + let tool_result = ContentBlock::ToolResult { + tool_use_id: tool.id, + content: error_content, + }; + self.conversation.push(Message { + role: miyabi_core::anthropic::Role::User, + content: vec![tool_result], + }); + } + } + + /// Continue conversation after tool execution + async fn continue_with_tool_result( + &mut self, + client: AnthropicClient, + terminal: &mut ratatui::Terminal, + ) { + let timestamp = chrono::Local::now().format("%H:%M").to_string(); + + self.is_streaming = true; + self.view.set_streaming(true); + + // Add streaming placeholder + let cell_index = self.view.history.len(); + self.view + .push_message(Box::new(AssistantMessageCell::new(timestamp.clone()))); + + // Continue the conversation + match client + .message_stream( + self.conversation.clone(), + Some("You are a helpful AI assistant. Be concise and clear.".to_string()), + None, + None, + ) + .await + { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + match event { + Ok(StreamEvent::ContentBlockStart { + content_block: ContentBlock::ToolUse { id, name, input }, + .. + }) => { + self.pending_tools.push(PendingTool { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + + let risk = if name.contains("write") + || name.contains("delete") + || name.contains("execute") + { + RiskLevel::High + } else if name.contains("read") || name.contains("search") { + RiskLevel::Low + } else { + RiskLevel::Medium + }; + + let request = ApprovalRequest::new(id, &name) + .risk_level(risk) + .description(format!("Execute tool: {}", name)); + self.view.show_approval(request); + } + Ok(StreamEvent::ContentBlockStart { .. }) => { + // Other content block types - ignore + } + Ok(StreamEvent::ContentBlockDelta { delta, .. }) => { + // Push delta to the stream directly + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = + cell.as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&delta.text); + } + } + // Redraw terminal to show streaming content + let _ = terminal.draw(|f| self.view.render(f)); + } + Ok(StreamEvent::MessageDelta { usage, .. }) => { + self.view.tokens_used += usage.output_tokens as usize; + } + Ok(StreamEvent::MessageStop) => { + break; + } + Ok(StreamEvent::Error { error }) => { + self.view.notifications.error("API Error", &error); + if let Some(cell) = self.view.history.get(cell_index) { + if let Some(assistant_cell) = + cell.as_ref() + .as_any() + .downcast_ref::() + { + assistant_cell.push_str(&format!("Error: {}", error)); + } + } + break; + } + _ => {} + } + } + + // Mark as done streaming and get content for conversation history + let response_text = if let Some(cell) = self.view.history.get_mut(cell_index) { + if let Some(assistant_cell) = + (**cell).as_any_mut().downcast_mut::() + { + assistant_cell.set_complete(); + if assistant_cell.is_empty() { + assistant_cell.set_content("(No response)"); + } + assistant_cell.content() + } else { + String::new() + } + } else { + String::new() + }; + + // Add to conversation history + if !response_text.is_empty() { + self.conversation.push(Message::assistant(&response_text)); + } + } + Err(e) => { + self.view + .notifications + .error("Connection Error", e.to_string()); + if let Some(cell) = self.view.history.get_mut(cell_index) { + if let Some(assistant_cell) = + (**cell).as_any_mut().downcast_mut::() + { + assistant_cell.set_content(&format!("Error: {}", e)); + assistant_cell.set_complete(); + } + } + } + } + + self.is_streaming = false; + self.view.set_streaming(false); + self.view.history_scroll = self.view.max_scroll; + } } impl Default for App { diff --git a/crates/miyabi-tui/src/app/event_loop.rs b/crates/miyabi-tui/src/app/event_loop.rs new file mode 100644 index 0000000..9f9fe61 --- /dev/null +++ b/crates/miyabi-tui/src/app/event_loop.rs @@ -0,0 +1,20 @@ +//! Event loop helpers for the TUI. +//! +//! This module isolates Crossterm-driven events from application actions +//! so the run loop in `app.rs` can stay focused on orchestration. + +use crate::domain::actions::AppAction; +use crate::event::Event; + +/// Map a raw event into a high-level application action. +pub fn map_event(event: Event) -> Option { + match event { + Event::Key(key) => Some(AppAction::KeyPressed(key)), + Event::Resize(w, h) => Some(AppAction::Resize { + width: w, + height: h, + }), + Event::Tick => Some(AppAction::Tick), + Event::Mouse(_) => None, + } +} diff --git a/crates/miyabi-tui/src/app/state.rs b/crates/miyabi-tui/src/app/state.rs new file mode 100644 index 0000000..5e9a59c --- /dev/null +++ b/crates/miyabi-tui/src/app/state.rs @@ -0,0 +1,34 @@ +//! Shared application state container for the TUI. +//! +//! This module is a staging area for gradually moving state out of `app.rs` +//! into a testable structure. + +use miyabi_core::session::Session; + +/// High-level application state kept in the TUI loop. +#[derive(Debug, Clone)] +pub struct AppState { + /// Whether the UI should exit. + pub should_quit: bool, + /// Whether a streaming response is active. + pub is_streaming: bool, + /// Active session being displayed. + pub session: Session, +} + +impl AppState { + /// Create a new state with the given session. + pub fn new(session: Session) -> Self { + Self { + should_quit: false, + is_streaming: false, + session, + } + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new(Session::new("New Session")) + } +} diff --git a/crates/miyabi-tui/src/approval_overlay.rs b/crates/miyabi-tui/src/approval_overlay.rs index 6be5061..562496e 100644 --- a/crates/miyabi-tui/src/approval_overlay.rs +++ b/crates/miyabi-tui/src/approval_overlay.rs @@ -249,12 +249,19 @@ impl ApprovalOverlay { } // Navigation - KeyCode::Left | KeyCode::Char('h') | KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { + KeyCode::Left | KeyCode::Char('h') | KeyCode::Tab + if key.modifiers.contains(KeyModifiers::SHIFT) => + { self.selected = self.selected.saturating_sub(1); ApprovalAction::None } KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { - let max = if self.request.as_ref().map(|r| !r.details.is_empty()).unwrap_or(false) { + let max = if self + .request + .as_ref() + .map(|r| !r.details.is_empty()) + .unwrap_or(false) + { 2 } else { 1 @@ -324,10 +331,10 @@ impl ApprovalOverlay { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), // Risk indicator - Constraint::Length(3), // Title - Constraint::Min(4), // Content - Constraint::Length(3), // Buttons + Constraint::Length(2), // Risk indicator + Constraint::Length(3), // Title + Constraint::Min(4), // Content + Constraint::Length(3), // Buttons ]) .split(inner); @@ -392,9 +399,10 @@ impl ApprovalOverlay { // Arguments if !request.arguments.is_empty() { - lines.push(Line::from(vec![ - Span::styled("Command: ", Style::default().fg(Color::Rgb(86, 95, 137))), - ])); + lines.push(Line::from(vec![Span::styled( + "Command: ", + Style::default().fg(Color::Rgb(86, 95, 137)), + )])); // Wrap long arguments let arg_lines: Vec<&str> = request.arguments.lines().collect(); @@ -526,11 +534,14 @@ impl ApprovalBuilder { }; Self { - request: ApprovalRequest::new(uuid::Uuid::new_v4().to_string(), "Execute Shell Command") - .tool_name("bash") - .arguments(&cmd) - .risk_level(risk) - .description("The AI wants to run a shell command"), + request: ApprovalRequest::new( + uuid::Uuid::new_v4().to_string(), + "Execute Shell Command", + ) + .tool_name("bash") + .arguments(&cmd) + .risk_level(risk) + .description("The AI wants to run a shell command"), } } @@ -695,29 +706,25 @@ mod tests { #[test] fn test_request_description() { - let request = ApprovalRequest::new("1", "Title") - .description("Test description"); + let request = ApprovalRequest::new("1", "Title").description("Test description"); assert_eq!(request.description, "Test description"); } #[test] fn test_request_tool_name() { - let request = ApprovalRequest::new("1", "Title") - .tool_name("bash"); + let request = ApprovalRequest::new("1", "Title").tool_name("bash"); assert_eq!(request.tool_name, "bash"); } #[test] fn test_request_arguments() { - let request = ApprovalRequest::new("1", "Title") - .arguments("echo hello"); + let request = ApprovalRequest::new("1", "Title").arguments("echo hello"); assert_eq!(request.arguments, "echo hello"); } #[test] fn test_request_risk_level() { - let request = ApprovalRequest::new("1", "Title") - .risk_level(RiskLevel::Critical); + let request = ApprovalRequest::new("1", "Title").risk_level(RiskLevel::Critical); assert_eq!(request.risk_level, RiskLevel::Critical); } @@ -733,8 +740,8 @@ mod tests { #[test] fn test_request_details() { - let request = ApprovalRequest::new("1", "Title") - .details(vec!["A".to_string(), "B".to_string()]); + let request = + ApprovalRequest::new("1", "Title").details(vec!["A".to_string(), "B".to_string()]); assert_eq!(request.details.len(), 2); } @@ -834,8 +841,7 @@ mod tests { #[test] fn test_overlay_handle_key_toggle_details() { let mut overlay = ApprovalOverlay::new(); - let request = ApprovalRequest::new("1", "Test") - .add_detail("Detail"); + let request = ApprovalRequest::new("1", "Test").add_detail("Detail"); overlay.show(request); assert!(!overlay.current_request().unwrap().show_details); @@ -850,8 +856,7 @@ mod tests { #[test] fn test_overlay_handle_key_navigation() { let mut overlay = ApprovalOverlay::new(); - let request = ApprovalRequest::new("1", "Test") - .add_detail("Detail"); + let request = ApprovalRequest::new("1", "Test").add_detail("Detail"); overlay.show(request); // Initially selected = 0 (Approve) @@ -1043,7 +1048,7 @@ mod tests { let mut batch = BatchApproval::new(requests); batch.approve_current(); // Approve 1 - batch.reject_current(); // Reject 2 + batch.reject_current(); // Reject 2 batch.approve_current(); // Approve 3 let (approved, rejected) = batch.results(); diff --git a/crates/miyabi-tui/src/chat_composer.rs b/crates/miyabi-tui/src/chat_composer.rs index e0801cb..d249a55 100644 --- a/crates/miyabi-tui/src/chat_composer.rs +++ b/crates/miyabi-tui/src/chat_composer.rs @@ -41,8 +41,10 @@ pub enum VimMode { /// Keybinding style preference #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Default)] pub enum KeybindingStyle { /// Standard keybindings + #[default] Standard, /// Vim-style keybindings Vim, @@ -50,20 +52,12 @@ pub enum KeybindingStyle { Emacs, } -impl Default for KeybindingStyle { - fn default() -> Self { - KeybindingStyle::Standard - } -} /// Edit operation for undo/redo #[derive(Debug, Clone)] pub enum EditOperation { /// Insert text at position - Insert { - pos: CursorPos, - text: String, - }, + Insert { pos: CursorPos, text: String }, /// Delete text range Delete { start: CursorPos, @@ -276,7 +270,7 @@ pub struct VimRegisters { /// Named registers (a-z) named: [String; 26], /// Small delete register - small_delete: String, + _small_delete: String, /// Numbered registers (0-9) numbered: [String; 10], /// Last search register @@ -421,7 +415,7 @@ pub struct ChatComposer { /// Auto-indent on newline auto_indent: bool, /// Bracket matching - bracket_matching: bool, + _bracket_matching: bool, /// Last matched bracket position matched_bracket: Option, } @@ -456,7 +450,7 @@ impl ChatComposer { show_line_numbers: false, highlight_current_line: true, auto_indent: true, - bracket_matching: true, + _bracket_matching: true, matched_bracket: None, } } @@ -736,7 +730,8 @@ impl ChatComposer { if self.show_suggestions { match key.code { KeyCode::Tab | KeyCode::Down => { - self.suggestion_index = (self.suggestion_index + 1) % self.suggestions.len().max(1); + self.suggestion_index = + (self.suggestion_index + 1) % self.suggestions.len().max(1); return ComposerAction::None; } KeyCode::Up => { @@ -839,9 +834,7 @@ impl ChatComposer { } ComposerAction::None } - KeyCode::Esc => { - ComposerAction::Cancel - } + KeyCode::Esc => ComposerAction::Cancel, _ => ComposerAction::None, } } @@ -958,8 +951,10 @@ impl ChatComposer { fn backspace(&mut self) { if self.cursor.col > 0 { // Compute byte indices before mutable borrow - let byte_idx = self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col - 1); - let next_byte_idx = self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col); + let byte_idx = + self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col - 1); + let next_byte_idx = + self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col); self.lines[self.cursor.line].replace_range(byte_idx..next_byte_idx, ""); self.cursor.col -= 1; } else if self.cursor.line > 0 { @@ -983,7 +978,8 @@ impl ChatComposer { if self.cursor.col < char_count { // Compute byte indices before mutable borrow let byte_idx = self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col); - let next_byte_idx = self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col + 1); + let next_byte_idx = + self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col + 1); self.lines[self.cursor.line].replace_range(byte_idx..next_byte_idx, ""); } else if self.cursor.line < self.lines.len() - 1 { // Merge with next line @@ -1205,8 +1201,15 @@ impl ChatComposer { if let Some(cmd) = input.strip_prefix('/') { // Built-in commands let commands = vec![ - "help", "clear", "history", "exit", "quit", - "model", "temperature", "tools", "context", + "help", + "clear", + "history", + "exit", + "quit", + "model", + "temperature", + "tools", + "context", ]; self.suggestions = commands @@ -1248,7 +1251,10 @@ impl ChatComposer { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) - .title(Span::styled(title, Style::default().fg(Color::Rgb(192, 202, 245)))); + .title(Span::styled( + title, + Style::default().fg(Color::Rgb(192, 202, 245)), + )); let inner = block.inner(area); frame.render_widget(block, area); @@ -1268,7 +1274,10 @@ impl ChatComposer { let mode_indicator = self.get_mode_indicator(); lines.push(Line::from(vec![ Span::styled(mode_indicator, Style::default().fg(Color::Cyan)), - Span::styled(&self.placeholder, Style::default().fg(Color::Rgb(86, 95, 137))), + Span::styled( + &self.placeholder, + Style::default().fg(Color::Rgb(86, 95, 137)), + ), ])); } else if self.mode == InputMode::Search { // Show search prompt @@ -1278,19 +1287,22 @@ impl ChatComposer { "?" }; lines.push(Line::from(vec![ - Span::styled(search_prefix, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled( + search_prefix, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), Span::styled(&self.search.query, Style::default().fg(Color::White)), Span::styled("█", Style::default().fg(Color::Yellow)), ])); // Show match info if let Some(info) = self.search_info() { - lines.push(Line::from(vec![ - Span::styled( - format!(" {} matches", info), - Style::default().fg(Color::Rgb(86, 95, 137)), - ), - ])); + lines.push(Line::from(vec![Span::styled( + format!(" {} matches", info), + Style::default().fg(Color::Rgb(86, 95, 137)), + )])); } } else { for (i, line) in self.lines.iter().enumerate() { @@ -1332,8 +1344,7 @@ impl ChatComposer { } } - let paragraph = Paragraph::new(lines) - .scroll((self.scroll_offset as u16, 0)); + let paragraph = Paragraph::new(lines).scroll((self.scroll_offset as u16, 0)); frame.render_widget(paragraph, inner); // Render mode line at bottom @@ -1422,9 +1433,8 @@ impl ChatComposer { let char_style = self.get_char_style(line_idx, current_pos, chars[current_pos]); // Check if this is cursor position - let is_cursor = line_idx == self.cursor.line - && current_pos == self.cursor.col - && self.focused; + let is_cursor = + line_idx == self.cursor.line && current_pos == self.cursor.col && self.focused; let style = if is_cursor { Style::default().bg(Color::Cyan).fg(Color::Black) @@ -1469,43 +1479,37 @@ impl ChatComposer { return Style::default().bg(Color::Rgb(68, 71, 90)).fg(Color::Cyan); } } - if line_idx == self.cursor.line && col == self.cursor.col { - if matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>') { - return Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD); + if line_idx == self.cursor.line && col == self.cursor.col + && matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>') { + return Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); } - } // Basic syntax highlighting match ch { // Brackets - '(' | ')' | '[' | ']' | '{' | '}' => { - Style::default().fg(Color::Rgb(189, 147, 249)) - } + '(' | ')' | '[' | ']' | '{' | '}' => Style::default().fg(Color::Rgb(189, 147, 249)), // Operators '+' | '-' | '*' | '/' | '=' | '<' | '>' | '!' | '&' | '|' | '^' | '%' => { Style::default().fg(Color::Rgb(255, 121, 198)) } // Punctuation - '.' | ',' | ':' | ';' | '@' | '#' => { - Style::default().fg(Color::Rgb(139, 233, 253)) - } + '.' | ',' | ':' | ';' | '@' | '#' => Style::default().fg(Color::Rgb(139, 233, 253)), // Quotes - '"' | '\'' | '`' => { - Style::default().fg(Color::Rgb(241, 250, 140)) - } + '"' | '\'' | '`' => Style::default().fg(Color::Rgb(241, 250, 140)), // Numbers - c if c.is_ascii_digit() => { - Style::default().fg(Color::Rgb(189, 147, 249)) - } + c if c.is_ascii_digit() => Style::default().fg(Color::Rgb(189, 147, 249)), // Default - _ => Style::default().fg(Color::Rgb(248, 248, 242)) + _ => Style::default().fg(Color::Rgb(248, 248, 242)), } } /// Check if position is in selection fn is_in_selection(&self, line: usize, col: usize, sel: &Selection) -> bool { let (start, end) = if sel.start.line < sel.end.line - || (sel.start.line == sel.end.line && sel.start.col <= sel.end.col) { + || (sel.start.line == sel.end.line && sel.start.col <= sel.end.col) + { (sel.start, sel.end) } else { (sel.end, sel.start) @@ -1549,11 +1553,7 @@ impl ChatComposer { }; // Position info - let pos_info = format!( - "{}:{} ", - self.cursor.line + 1, - self.cursor.col + 1 - ); + let pos_info = format!("{}:{} ", self.cursor.line + 1, self.cursor.col + 1); // Command buffer display let cmd_display = if !self.vim_command_buffer.is_empty() { @@ -1565,7 +1565,10 @@ impl ChatComposer { }; let status = Line::from(vec![ - Span::styled(mode_text, Style::default().fg(mode_color).add_modifier(Modifier::BOLD)), + Span::styled( + mode_text, + Style::default().fg(mode_color).add_modifier(Modifier::BOLD), + ), Span::styled(cmd_display, Style::default().fg(Color::Yellow)), Span::raw(" "), Span::styled(pos_info, Style::default().fg(Color::Rgb(86, 95, 137))), @@ -1577,7 +1580,9 @@ impl ChatComposer { /// Render rich suggestions with descriptions fn render_rich_suggestions(&self, frame: &mut Frame, area: Rect) { let popup_height = (self.rich_suggestions.len() + 2).min(12) as u16; - let popup_width = self.rich_suggestions.iter() + let popup_width = self + .rich_suggestions + .iter() .map(|s| { let desc_len = s.description.as_ref().map(|d| d.len()).unwrap_or(0); s.text.len() + desc_len + 10 @@ -1595,7 +1600,8 @@ impl ChatComposer { frame.render_widget(Clear, popup_area); - let items: Vec = self.rich_suggestions + let items: Vec = self + .rich_suggestions .iter() .enumerate() .map(|(i, s)| { @@ -1620,8 +1626,17 @@ impl ChatComposer { Span::styled(icon, base_style.fg(Color::Cyan)), Span::styled( &s.text, - base_style.fg(if is_selected { Color::White } else { Color::Rgb(248, 248, 242) }) - .add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() }), + base_style + .fg(if is_selected { + Color::White + } else { + Color::Rgb(248, 248, 242) + }) + .add_modifier(if is_selected { + Modifier::BOLD + } else { + Modifier::empty() + }), ), ]; @@ -1637,13 +1652,12 @@ impl ChatComposer { .collect(); let title = format!(" Suggestions ({}) ", self.rich_suggestions.len()); - let popup = Paragraph::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(98, 114, 164))) - .title(Span::styled(title, Style::default().fg(Color::Cyan))), - ); + let popup = Paragraph::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(98, 114, 164))) + .title(Span::styled(title, Style::default().fg(Color::Cyan))), + ); frame.render_widget(popup, popup_area); } @@ -1651,11 +1665,14 @@ impl ChatComposer { /// Render suggestions popup fn render_suggestions(&self, frame: &mut Frame, area: Rect) { let popup_height = (self.suggestions.len() + 2).min(10) as u16; - let popup_width = self.suggestions.iter() + let popup_width = self + .suggestions + .iter() .map(|s| display_width(s)) .max() .unwrap_or(20) - .max(20) as u16 + 4; + .max(20) as u16 + + 4; let popup_area = Rect { x: area.x + 2, @@ -1666,12 +1683,15 @@ impl ChatComposer { frame.render_widget(Clear, popup_area); - let items: Vec = self.suggestions + let items: Vec = self + .suggestions .iter() .enumerate() .map(|(i, s)| { let style = if i == self.suggestion_index { - Style::default().bg(Color::Rgb(86, 95, 137)).fg(Color::White) + Style::default() + .bg(Color::Rgb(86, 95, 137)) + .fg(Color::White) } else { Style::default().fg(Color::Rgb(192, 202, 245)) }; @@ -1679,13 +1699,12 @@ impl ChatComposer { }) .collect(); - let popup = Paragraph::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) - .title(" Commands "), - ); + let popup = Paragraph::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Commands "), + ); frame.render_widget(popup, popup_area); } @@ -1743,7 +1762,9 @@ impl ChatComposer { } } } - EditOperation::Replace { start, old_text, .. } => { + EditOperation::Replace { + start, old_text, .. + } => { // Undo replace by replacing with old text self.cursor = *start; self.set_input(old_text); @@ -1773,12 +1794,15 @@ impl ChatComposer { EditOperation::Delete { start, end, .. } => { self.cursor = *start; // Delete from start to end - while self.cursor.line < end.line || - (self.cursor.line == end.line && self.cursor.col < end.col) { + while self.cursor.line < end.line + || (self.cursor.line == end.line && self.cursor.col < end.col) + { self.delete(); } } - EditOperation::Replace { start, new_text, .. } => { + EditOperation::Replace { + start, new_text, .. + } => { self.cursor = *start; self.set_input(new_text); } @@ -1829,8 +1853,9 @@ impl ChatComposer { /// Get selected text fn get_selected_text(&self, sel: &Selection) -> String { - let (start, end) = if sel.start.line < sel.end.line || - (sel.start.line == sel.end.line && sel.start.col <= sel.end.col) { + let (start, end) = if sel.start.line < sel.end.line + || (sel.start.line == sel.end.line && sel.start.col <= sel.end.col) + { (sel.start, sel.end) } else { (sel.end, sel.start) @@ -1862,8 +1887,9 @@ impl ChatComposer { /// Delete selected text fn delete_selection(&mut self, sel: &Selection) { - let (start, end) = if sel.start.line < sel.end.line || - (sel.start.line == sel.end.line && sel.start.col <= sel.end.col) { + let (start, end) = if sel.start.line < sel.end.line + || (sel.start.line == sel.end.line && sel.start.col <= sel.end.col) + { (sel.start, sel.end) } else { (sel.end, sel.start) @@ -1943,10 +1969,13 @@ impl ChatComposer { KeyCode::Char('V') => { self.vim_mode = VimMode::VisualLine; self.selection = Some(Selection { - start: CursorPos { line: self.cursor.line, col: 0 }, + start: CursorPos { + line: self.cursor.line, + col: 0, + }, end: CursorPos { line: self.cursor.line, - col: self.current_line().chars().count() + col: self.current_line().chars().count(), }, }); } @@ -1991,9 +2020,7 @@ impl ChatComposer { KeyCode::Char('^') => { // First non-whitespace let line = self.current_line(); - self.cursor.col = line.chars() - .position(|c| !c.is_whitespace()) - .unwrap_or(0); + self.cursor.col = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0); } KeyCode::Char('g') => { // gg - go to beginning @@ -2205,8 +2232,14 @@ impl ChatComposer { let col_end = col_start + query.chars().count(); self.search.matches.push(( - CursorPos { line: line_idx, col: col_start }, - CursorPos { line: line_idx, col: col_end }, + CursorPos { + line: line_idx, + col: col_start, + }, + CursorPos { + line: line_idx, + col: col_end, + }, )); start += pos + 1; @@ -2347,7 +2380,12 @@ impl ChatComposer { } /// Add rich suggestion - pub fn add_suggestion(&mut self, text: impl Into, category: SuggestionCategory, description: Option) { + pub fn add_suggestion( + &mut self, + text: impl Into, + category: SuggestionCategory, + description: Option, + ) { self.rich_suggestions.push(Suggestion { text: text.into(), description, @@ -2364,15 +2402,14 @@ impl ChatComposer { } /// Get auto-indent string for new line + #[allow(dead_code)] fn get_auto_indent(&self) -> String { if !self.auto_indent { return String::new(); } let line = &self.lines[self.cursor.line]; - let indent: String = line.chars() - .take_while(|c| c.is_whitespace()) - .collect(); + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); // Check for additional indent after { or : if let Some(last_char) = line.trim_end().chars().last() { diff --git a/crates/miyabi-tui/src/command_popup.rs b/crates/miyabi-tui/src/command_popup.rs index 64736b5..f54bf6e 100644 --- a/crates/miyabi-tui/src/command_popup.rs +++ b/crates/miyabi-tui/src/command_popup.rs @@ -145,7 +145,9 @@ impl CommandPopup { /// Get selected command pub fn selected_command(&self) -> Option<&Command> { - self.filtered.get(self.selected).map(|&idx| &self.commands[idx]) + self.filtered + .get(self.selected) + .map(|&idx| &self.commands[idx]) } /// Handle key event @@ -331,7 +333,9 @@ impl CommandPopup { let block = Block::default() .title(Span::styled( format!(" {} ", self.title), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -364,7 +368,10 @@ impl CommandPopup { let content = if self.query.is_empty() { Line::from(vec![ Span::styled("› ", Style::default().fg(Color::Cyan)), - Span::styled(&self.placeholder, Style::default().fg(Color::Rgb(86, 95, 137))), + Span::styled( + &self.placeholder, + Style::default().fg(Color::Rgb(86, 95, 137)), + ), ]) } else { Line::from(vec![ @@ -470,6 +477,12 @@ impl CommandPopup { Command::new("model", "Change Model") .description("Select AI model") .category("Settings"), + Command::new("thinking:on", "Extended Thinking On") + .description("Enable Claude Extended Thinking") + .category("Settings"), + Command::new("thinking:off", "Extended Thinking Off") + .description("Disable Claude Extended Thinking") + .category("Settings"), Command::new("temperature", "Temperature") .description("Adjust response creativity") .category("Settings"), @@ -690,7 +703,8 @@ mod tests { #[test] fn test_popup_handle_key_enter_disabled() { - let mut popup = CommandPopup::new().commands(vec![Command::new("test", "Test").enabled(false)]); + let mut popup = + CommandPopup::new().commands(vec![Command::new("test", "Test").enabled(false)]); popup.show(); let action = popup.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); @@ -883,9 +897,7 @@ mod tests { #[test] fn test_popup_filtering_empty() { - let mut popup = CommandPopup::new().commands(vec![ - Command::new("test", "Test"), - ]); + let mut popup = CommandPopup::new().commands(vec![Command::new("test", "Test")]); popup.show(); // Search for something that doesn't exist diff --git a/crates/miyabi-tui/src/diff_render.rs b/crates/miyabi-tui/src/diff_render.rs index 0952c24..a684ca2 100644 --- a/crates/miyabi-tui/src/diff_render.rs +++ b/crates/miyabi-tui/src/diff_render.rs @@ -100,8 +100,16 @@ impl DiffRender { // Parse file paths let parts: Vec<&str> = line.split_whitespace().collect(); - let old_path = parts.get(2).unwrap_or(&"").trim_start_matches("a/").to_string(); - let new_path = parts.get(3).unwrap_or(&"").trim_start_matches("b/").to_string(); + let old_path = parts + .get(2) + .unwrap_or(&"") + .trim_start_matches("a/") + .to_string(); + let new_path = parts + .get(3) + .unwrap_or(&"") + .trim_start_matches("b/") + .to_string(); current_file = Some(FileDiff { old_path, @@ -126,7 +134,9 @@ impl DiffRender { } // Parse hunk header - if let Some((old_start, old_count, new_start, new_count, header)) = parse_hunk_header(line) { + if let Some((old_start, old_count, new_start, new_count, header)) = + parse_hunk_header(line) + { old_line_num = old_start; new_line_num = new_start; @@ -202,7 +212,8 @@ impl DiffRender { /// Get total number of lines pub fn line_count(&self) -> usize { - self.files.iter() + self.files + .iter() .flat_map(|f| &f.hunks) .map(|h| h.lines.len()) .sum() @@ -247,22 +258,10 @@ impl DiffRender { /// Render a single diff line fn render_line(&self, diff_line: &DiffLine) -> Line<'static> { let (prefix, style) = match diff_line.line_type { - DiffLineType::Addition => ( - "+", - Style::default().fg(Color::Green), - ), - DiffLineType::Deletion => ( - "-", - Style::default().fg(Color::Red), - ), - DiffLineType::Context => ( - " ", - Style::default(), - ), - DiffLineType::HunkHeader => ( - "", - Style::default().fg(Color::Cyan), - ), + DiffLineType::Addition => ("+", Style::default().fg(Color::Green)), + DiffLineType::Deletion => ("-", Style::default().fg(Color::Red)), + DiffLineType::Context => (" ", Style::default()), + DiffLineType::HunkHeader => ("", Style::default().fg(Color::Cyan)), DiffLineType::FileHeader => ( "", Style::default() diff --git a/crates/miyabi-tui/src/diff_viewer.rs b/crates/miyabi-tui/src/diff_viewer.rs index 104749e..0eb8b44 100644 --- a/crates/miyabi-tui/src/diff_viewer.rs +++ b/crates/miyabi-tui/src/diff_viewer.rs @@ -3,7 +3,7 @@ //! This module provides an enhanced diff visualization with proper colors, //! line numbers, and indicators for a professional git diff display. -use crate::diff_render::{DiffRender, DiffLine, DiffLineType}; +use crate::diff_render::{DiffLine, DiffLineType, DiffRender}; use crate::markdown_stream::ScrollState; use crate::syntax::{normalize_language, SyntaxHighlighter}; use ratatui::{ @@ -585,10 +585,7 @@ mod tests { DiffViewer::extract_extension("app.js"), Some("js".to_string()) ); - assert_eq!( - DiffViewer::extract_extension("no_extension"), - None - ); + assert_eq!(DiffViewer::extract_extension("no_extension"), None); assert_eq!( DiffViewer::extract_extension("/path/to/file.py"), Some("py".to_string()) @@ -743,7 +740,7 @@ mod tests { viewer.scroll_to_bottom(); let percentage = viewer.scroll_percentage(); - assert!(percentage >= 0.0 && percentage <= 1.0); + assert!((0.0..=1.0).contains(&percentage)); } #[test] diff --git a/crates/miyabi-tui/src/domain/actions.rs b/crates/miyabi-tui/src/domain/actions.rs new file mode 100644 index 0000000..70c1f63 --- /dev/null +++ b/crates/miyabi-tui/src/domain/actions.rs @@ -0,0 +1,28 @@ +//! Application-level actions produced from input handling. + +use crossterm::event::KeyEvent; + +/// High-level actions the TUI can perform. +#[derive(Debug, Clone)] +pub enum AppAction { + /// Exit the application loop. + Quit, + /// Send a chat message. + SendMessage { text: String }, + /// Execute a command string. + ExecuteCommand { command: String }, + /// Approve or reject a pending tool use. + ApproveTool { id: String, approved: bool }, + /// Cancel current streaming response. + CancelStreaming, + /// Toggle agent/chat mode. + ToggleAgentMode, + /// Toggle sidebar visibility. + ToggleSidebar, + /// Generic key press for downstream handlers. + KeyPressed(KeyEvent), + /// Terminal resize. + Resize { width: u16, height: u16 }, + /// Periodic tick for animations. + Tick, +} diff --git a/crates/miyabi-tui/src/domain/mod.rs b/crates/miyabi-tui/src/domain/mod.rs new file mode 100644 index 0000000..8759f79 --- /dev/null +++ b/crates/miyabi-tui/src/domain/mod.rs @@ -0,0 +1,7 @@ +//! Domain types shared across the TUI layers. + +pub mod actions; +pub mod models; + +pub use actions::AppAction; +pub use models::{ConversationEntry, SessionSummary}; diff --git a/crates/miyabi-tui/src/domain/models.rs b/crates/miyabi-tui/src/domain/models.rs new file mode 100644 index 0000000..e33c754 --- /dev/null +++ b/crates/miyabi-tui/src/domain/models.rs @@ -0,0 +1,19 @@ +//! Domain structs used by the TUI layer. + +use miyabi_core::anthropic::Message; + +/// Summary of a session for list views. +#[derive(Debug, Clone)] +pub struct SessionSummary { + pub id: String, + pub title: String, + pub updated_at: String, + pub tokens_used: usize, +} + +/// Minimal representation of a message for UI rendering. +#[derive(Debug, Clone)] +pub struct ConversationEntry { + pub role: String, + pub message: Message, +} diff --git a/crates/miyabi-tui/src/help.rs b/crates/miyabi-tui/src/help.rs index 6843726..2fd9c84 100644 --- a/crates/miyabi-tui/src/help.rs +++ b/crates/miyabi-tui/src/help.rs @@ -218,7 +218,8 @@ impl HelpViewer { HelpAction::None } KeyCode::Tab => { - self.selected_category = (self.selected_category + 1) % self.categories.len().max(1); + self.selected_category = + (self.selected_category + 1) % self.categories.len().max(1); self.selected_binding = 0; self.update_filtered(); HelpAction::None @@ -343,7 +344,9 @@ impl HelpViewer { let block = Block::default() .title(Span::styled( format!(" {} ", self.title), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -355,10 +358,10 @@ impl HelpViewer { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Tabs - Constraint::Length(3), // Search - Constraint::Min(1), // Content - Constraint::Length(1), // Status + Constraint::Length(3), // Tabs + Constraint::Length(3), // Search + Constraint::Min(1), // Content + Constraint::Length(1), // Status ]) .split(inner); @@ -531,10 +534,7 @@ impl HelpViewer { " a: Show all ", Style::default().fg(Color::Rgb(86, 95, 137)), ), - Span::styled( - " q: Close ", - Style::default().fg(Color::Rgb(86, 95, 137)), - ), + Span::styled(" q: Close ", Style::default().fg(Color::Rgb(86, 95, 137))), ]); let paragraph = Paragraph::new(status); @@ -700,7 +700,9 @@ impl CheatSheet { let block = Block::default() .title(Span::styled( format!(" {} ", self.title), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -867,10 +869,7 @@ impl QuickRef { .iter() .map(|(key, desc)| { Line::from(vec![ - Span::styled( - format!("{:>8}", key), - Style::default().fg(Color::Cyan), - ), + Span::styled(format!("{:>8}", key), Style::default().fg(Color::Cyan)), Span::raw(" "), Span::styled(desc, Style::default().fg(Color::Rgb(192, 202, 245))), ]) @@ -957,10 +956,8 @@ mod tests { #[test] fn test_viewer_categories() { - let viewer = HelpViewer::new().categories(vec![ - HelpCategory::new("Cat1"), - HelpCategory::new("Cat2"), - ]); + let viewer = HelpViewer::new() + .categories(vec![HelpCategory::new("Cat1"), HelpCategory::new("Cat2")]); assert_eq!(viewer.categories.len(), 2); } @@ -989,7 +986,7 @@ mod tests { #[test] fn test_viewer_show_resets_state() { let mut viewer = HelpViewer::new().categories(vec![ - HelpCategory::new("Cat").binding(KeyBinding::new("a", "Action")), + HelpCategory::new("Cat").binding(KeyBinding::new("a", "Action")) ]); viewer.show(); viewer.selected_binding = 5; @@ -1085,13 +1082,12 @@ mod tests { #[test] fn test_viewer_handle_key_navigation() { - let mut viewer = HelpViewer::new().categories(vec![ - HelpCategory::new("Cat").bindings(vec![ + let mut viewer = + HelpViewer::new().categories(vec![HelpCategory::new("Cat").bindings(vec![ KeyBinding::new("a", "Action A"), KeyBinding::new("b", "Action B"), KeyBinding::new("c", "Action C"), - ]), - ]); + ])]); viewer.show(); viewer.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())); @@ -1109,13 +1105,12 @@ mod tests { #[test] fn test_viewer_handle_key_home_end() { - let mut viewer = HelpViewer::new().categories(vec![ - HelpCategory::new("Cat").bindings(vec![ + let mut viewer = + HelpViewer::new().categories(vec![HelpCategory::new("Cat").bindings(vec![ KeyBinding::new("a", "A"), KeyBinding::new("b", "B"), KeyBinding::new("c", "C"), - ]), - ]); + ])]); viewer.show(); viewer.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::empty())); @@ -1177,13 +1172,12 @@ mod tests { #[test] fn test_viewer_filtering() { - let mut viewer = HelpViewer::new().categories(vec![ - HelpCategory::new("Cat").bindings(vec![ + let mut viewer = + HelpViewer::new().categories(vec![HelpCategory::new("Cat").bindings(vec![ KeyBinding::new("a", "Alpha"), KeyBinding::new("b", "Beta"), KeyBinding::new("c", "Copy"), - ]), - ]); + ])]); viewer.show(); assert_eq!(viewer.filtered.len(), 3); @@ -1214,9 +1208,7 @@ mod tests { #[test] fn test_cheat_section_item() { - let section = CheatSection::new("Nav") - .item("j", "Down") - .item("k", "Up"); + let section = CheatSection::new("Nav").item("j", "Down").item("k", "Up"); assert_eq!(section.items.len(), 2); } @@ -1253,9 +1245,7 @@ mod tests { #[test] fn test_quickref_item() { - let qr = QuickRef::new() - .item("q", "Quit") - .item("?", "Help"); + let qr = QuickRef::new().item("q", "Quit").item("?", "Help"); assert_eq!(qr.items.len(), 2); } diff --git a/crates/miyabi-tui/src/history_cell.rs b/crates/miyabi-tui/src/history_cell.rs index 46d1a70..183f740 100644 --- a/crates/miyabi-tui/src/history_cell.rs +++ b/crates/miyabi-tui/src/history_cell.rs @@ -8,13 +8,14 @@ //! - Dim: Secondary, timestamps use std::any::Any; +use std::sync::Mutex; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, }; -use crate::markdown_render::MarkdownRenderer; +use crate::markdown_stream::MarkdownStream; use crate::wrapping::wrap_text; /// Trait for renderable history items @@ -24,6 +25,7 @@ pub trait HistoryCell: Send + Sync { fn is_streaming(&self) -> bool { false } + fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; } @@ -36,51 +38,30 @@ pub struct UserMessageCell { impl HistoryCell for UserMessageCell { fn render(&self, width: u16) -> Vec> { let mut lines = Vec::new(); - let inner_width = (width as usize).saturating_sub(6).min(70); - let border = "─".repeat(inner_width); + let content_width = (width as usize).saturating_sub(4); - // Top border + // Header line with role and timestamp lines.push(Line::from(vec![ - Span::styled("┌", Style::default().fg(Color::Cyan)), - Span::styled(border.clone(), Style::default().fg(Color::Cyan)), - Span::styled("┐", Style::default().fg(Color::Cyan)), - ])); - - // Header - lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::Cyan)), - Span::styled("You", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), Span::styled( - format!("{:>width$}", self.timestamp, width = inner_width - 4), - Style::default().add_modifier(Modifier::DIM) + "You ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + self.timestamp.clone(), + Style::default().add_modifier(Modifier::DIM), ), - Span::styled(" │", Style::default().fg(Color::Cyan)), ])); // Content with proper text wrapping - let content_width = inner_width.saturating_sub(2); for line in self.content.lines() { let wrapped = wrap_text(line, content_width); for wrapped_line in wrapped { - let content_str: String = wrapped_line.spans.iter() - .map(|s| s.content.as_ref()) - .collect(); - let padded = format!("{: &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -95,70 +80,101 @@ impl HistoryCell for UserMessageCell { /// Assistant message cell - Magenta accented card with markdown pub struct AssistantMessageCell { - pub content: String, + stream: Mutex, pub timestamp: String, pub streaming: bool, } -impl HistoryCell for AssistantMessageCell { - fn render(&self, width: u16) -> Vec> { - let mut lines = Vec::new(); - let inner_width = (width as usize).saturating_sub(6).min(70); - let border = "─".repeat(inner_width); +impl AssistantMessageCell { + /// Create a new assistant message cell + pub fn new(timestamp: String) -> Self { + Self { + stream: Mutex::new(MarkdownStream::new()), + timestamp, + streaming: true, + } + } - // Top border - lines.push(Line::from(vec![ - Span::styled("┌", Style::default().fg(Color::Magenta)), - Span::styled(border.clone(), Style::default().fg(Color::Magenta)), - Span::styled("┐", Style::default().fg(Color::Magenta)), - ])); + /// Push content to the stream + pub fn push_str(&self, s: &str) { + if let Ok(mut stream) = self.stream.lock() { + stream.push_str(s); + } + } + + /// Mark streaming as complete + pub fn set_complete(&mut self) { + self.streaming = false; + if let Ok(mut stream) = self.stream.lock() { + stream.complete(); + } + } + + /// Get the current content as string + pub fn content(&self) -> String { + self.stream + .lock() + .map(|s| s.content().to_string()) + .unwrap_or_default() + } + + /// Set content directly (for non-streaming messages) + pub fn set_content(&self, content: &str) { + if let Ok(mut stream) = self.stream.lock() { + stream.push_str(content); + } + } + + /// Check if content is empty + pub fn is_empty(&self) -> bool { + self.stream + .lock() + .map(|s| s.content().is_empty()) + .unwrap_or(true) + } +} + +impl HistoryCell for AssistantMessageCell { + fn render(&self, _width: u16) -> Vec> { + let mut lines = Vec::new(); // Header with streaming indicator - let header_text = if self.streaming { "Assistant ●" } else { "Assistant" }; - let header_style = Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD); + let header_text = if self.streaming { + "Assistant ●" + } else { + "Assistant" + }; + let header_style = Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD); lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::Magenta)), Span::styled(header_text, header_style), + Span::styled(" ", Style::default()), Span::styled( - format!("{:>width$}", self.timestamp, width = inner_width - header_text.len() - 1), - Style::default().add_modifier(Modifier::DIM) + self.timestamp.clone(), + Style::default().add_modifier(Modifier::DIM), ), - Span::styled(" │", Style::default().fg(Color::Magenta)), ])); - // Markdown rendered content - let renderer = MarkdownRenderer::new(); - let md_lines = renderer.render(&self.content); + // Markdown rendered content using MarkdownStream + let md_lines = if let Ok(mut stream) = self.stream.lock() { + stream.render() + } else { + Vec::new() + }; if md_lines.is_empty() && self.streaming { - lines.push(Line::from(vec![ - Span::styled("│ ", Style::default().fg(Color::Magenta)), - Span::styled("...", Style::default().add_modifier(Modifier::DIM)), - Span::styled( - format!("{:>width$}", "", width = inner_width - 5), - Style::default() - ), - Span::styled(" │", Style::default().fg(Color::Magenta)), - ])); + lines.push(Line::from(Span::styled( + "...", + Style::default().add_modifier(Modifier::DIM), + ))); } else { for md_line in md_lines { - let mut content_spans = vec![ - Span::styled("│ ", Style::default().fg(Color::Magenta)), - ]; - content_spans.extend(md_line.spans); - content_spans.push(Span::styled(" │", Style::default().fg(Color::Magenta))); - lines.push(Line::from(content_spans)); + lines.push(md_line); } } - // Bottom border - lines.push(Line::from(vec![ - Span::styled("└", Style::default().fg(Color::Magenta)), - Span::styled(border, Style::default().fg(Color::Magenta)), - Span::styled("┘", Style::default().fg(Color::Magenta)), - ])); - lines } @@ -170,6 +186,10 @@ impl HistoryCell for AssistantMessageCell { self.streaming } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -182,61 +202,90 @@ pub struct ToolResultCell { pub timestamp: String, pub execution_time_ms: u64, pub success: bool, + /// Optional truncated input preview for debugging + pub input_preview: Option, +} + +impl ToolResultCell { + /// Create a new tool result cell with input preview + pub fn new( + tool_name: String, + content: String, + timestamp: String, + execution_time_ms: u64, + success: bool, + input: Option<&serde_json::Value>, + ) -> Self { + let input_preview = input.map(|v| { + let s = serde_json::to_string(v).unwrap_or_default(); + if s.len() > 100 { + format!("{}...", &s[..97]) + } else { + s + } + }); + + Self { + tool_name, + content, + timestamp, + execution_time_ms, + success, + input_preview, + } + } } impl HistoryCell for ToolResultCell { fn render(&self, width: u16) -> Vec> { let mut lines = Vec::new(); - let inner_width = (width as usize).saturating_sub(8).min(68); - let border = "═".repeat(inner_width); - let border_color = if self.success { Color::Green } else { Color::Red }; - - // Top border (double line for tool) - lines.push(Line::from(vec![ - Span::styled(" ╔", Style::default().fg(border_color)), - Span::styled(border.clone(), Style::default().fg(border_color)), - Span::styled("╗", Style::default().fg(border_color)), - ])); + let content_width = (width as usize).saturating_sub(4); + let status_color = if self.success { + Color::Green + } else { + Color::Red + }; // Header with status icon let icon = if self.success { "✔" } else { "✗" }; let time_str = format!("{}ms", self.execution_time_ms); lines.push(Line::from(vec![ - Span::styled(" ║ ", Style::default().fg(border_color)), - Span::styled(format!("{} ", icon), Style::default().fg(border_color)), - Span::styled(self.tool_name.clone(), Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(format!("{} ", icon), Style::default().fg(status_color)), Span::styled( - format!("{:>width$}", time_str, width = inner_width - self.tool_name.len() - 4), - Style::default().add_modifier(Modifier::DIM) + self.tool_name.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}", time_str), + Style::default().add_modifier(Modifier::DIM), ), - Span::styled(" ║", Style::default().fg(border_color)), ])); + // Input preview (if available) + if let Some(input) = &self.input_preview { + let preview = format!("→ {}", input); + let truncated = if preview.len() > content_width { + format!("{}...", &preview[..content_width.saturating_sub(3)]) + } else { + preview + }; + lines.push(Line::from(Span::styled( + truncated, + Style::default().fg(Color::DarkGray), + ))); + } + // Content with proper text wrapping - let content_width = inner_width.saturating_sub(2); for line in self.content.lines() { let wrapped = wrap_text(line, content_width); for wrapped_line in wrapped { - let content_str: String = wrapped_line.spans.iter() - .map(|s| s.content.as_ref()) - .collect(); - let padded = format!("{: &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } @@ -273,18 +326,23 @@ impl HistoryCell for SystemMessageCell { SystemMessageType::Success => ("✔", Color::Green), }; - vec![ - Line::from(vec![ - Span::styled(format!("{} ", icon), Style::default().fg(color)), - Span::styled(self.content.clone(), Style::default().add_modifier(Modifier::DIM)), - ]), - ] + vec![Line::from(vec![ + Span::styled(format!("{} ", icon), Style::default().fg(color)), + Span::styled( + self.content.clone(), + Style::default().add_modifier(Modifier::DIM), + ), + ])] } fn timestamp(&self) -> &str { &self.timestamp } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { self } diff --git a/crates/miyabi-tui/src/input/handler.rs b/crates/miyabi-tui/src/input/handler.rs new file mode 100644 index 0000000..012498d --- /dev/null +++ b/crates/miyabi-tui/src/input/handler.rs @@ -0,0 +1,14 @@ +//! Maps key events to application actions. + +use crossterm::event::KeyEvent; + +use crate::domain::actions::AppAction; +use crate::input::keymap::default_keymap; + +/// Convert a key event into a high-level action using the default keymap. +pub fn handle_key_event(event: KeyEvent) -> Option { + let map = default_keymap(); + map.get(&event) + .cloned() + .or(Some(AppAction::KeyPressed(event))) +} diff --git a/crates/miyabi-tui/src/input/keymap.rs b/crates/miyabi-tui/src/input/keymap.rs new file mode 100644 index 0000000..18e977e --- /dev/null +++ b/crates/miyabi-tui/src/input/keymap.rs @@ -0,0 +1,38 @@ +//! Key binding definitions for the TUI. + +use std::collections::HashMap; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::domain::actions::AppAction; + +/// Individual key binding entry. +#[derive(Debug, Clone)] +pub struct KeyBinding { + pub key: KeyEvent, + pub action: AppAction, +} + +/// Default keymap mapping keys to actions. +pub fn default_keymap() -> HashMap { + let mut map = HashMap::new(); + + map.insert( + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), + AppAction::Quit, + ); + map.insert( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + AppAction::CancelStreaming, + ); + map.insert( + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), + AppAction::ToggleSidebar, + ); + map.insert( + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT), + AppAction::ToggleAgentMode, + ); + + map +} diff --git a/crates/miyabi-tui/src/input/mod.rs b/crates/miyabi-tui/src/input/mod.rs new file mode 100644 index 0000000..661498e --- /dev/null +++ b/crates/miyabi-tui/src/input/mod.rs @@ -0,0 +1,7 @@ +//! Input handling layer. + +pub mod handler; +pub mod keymap; + +pub use handler::handle_key_event; +pub use keymap::{default_keymap, KeyBinding}; diff --git a/crates/miyabi-tui/src/lib.rs b/crates/miyabi-tui/src/lib.rs index 974ddd0..33f30b3 100644 --- a/crates/miyabi-tui/src/lib.rs +++ b/crates/miyabi-tui/src/lib.rs @@ -4,45 +4,74 @@ //! functional design with proper text wrapping and markdown rendering. pub mod app; -pub mod event; -pub mod wrapping; -pub mod history_cell; -pub mod markdown_render; -pub mod markdown_stream; +pub mod approval_overlay; +pub mod chat_composer; +pub mod command_popup; pub mod diff_render; pub mod diff_viewer; -pub mod markdown_parser; -pub mod syntax; -pub mod chat_composer; -pub mod textarea; -pub mod command_popup; -pub mod approval_overlay; -pub mod resume_picker; -pub mod pager_overlay; -pub mod shimmer; -pub mod ui; +pub mod domain; +pub mod event; pub mod help; +pub mod history_cell; +pub mod input; +pub mod markdown_parser; +pub mod markdown_render; +pub mod markdown_stream; pub mod notification; +pub mod pager_overlay; +pub mod resume_picker; +pub mod shimmer; +pub mod syntax; +pub mod textarea; +pub mod ui; +pub mod update; pub mod views; +pub mod wrapping; pub use app::App; +pub use approval_overlay::{ + ApprovalAction, ApprovalBuilder, ApprovalOverlay, ApprovalRequest, BatchApproval, RiskLevel, +}; +pub use chat_composer::{ChatComposer, ComposerAction, CursorPos, InputMode}; +pub use command_popup::{ + Command, CommandBuilder, CommandCategory, CommandPopup, CommandPopupAction, +}; +pub use diff_render::{DiffHunk, DiffLine, DiffLineType, DiffRender, FileDiff}; +pub use diff_viewer::{ + render_diff, render_diff_minimal, DiffColors, DiffViewer, DiffViewerOptions, +}; +pub use domain::{AppAction, ConversationEntry, SessionSummary}; pub use event::{Event, EventHandler}; -pub use wrapping::{word_wrap_line, wrap_text, display_width, WrapOptions}; -pub use history_cell::{HistoryCell, UserMessageCell, AssistantMessageCell, ToolResultCell, SystemMessageCell}; -pub use markdown_render::{MarkdownRenderer, MarkdownStyles}; -pub use markdown_stream::{MarkdownStream, StreamState, StreamBuffer, ScrollState, CursorPosition}; -pub use diff_render::{DiffRender, DiffLine, DiffLineType, DiffHunk, FileDiff}; -pub use diff_viewer::{DiffViewer, DiffViewerOptions, DiffColors, render_diff, render_diff_minimal}; +pub use help::{ + CheatSection, CheatSheet, HelpAction, HelpCategory, HelpViewer, KeyBinding, QuickRef, +}; +pub use history_cell::{ + AssistantMessageCell, HistoryCell, SystemMessageCell, ToolResultCell, UserMessageCell, +}; +pub use input::{default_keymap, handle_key_event, KeyBinding as InputKeyBinding}; pub use markdown_parser::MarkdownParser; -pub use syntax::{SyntaxHighlighter, highlight_code, render_code_block, normalize_language}; -pub use chat_composer::{ChatComposer, ComposerAction, InputMode, CursorPos}; -pub use textarea::{TextArea, TextAreaConfig, TextAreaAction, TextCursor, TextRange}; -pub use command_popup::{CommandPopup, CommandPopupAction, Command, CommandBuilder, CommandCategory}; -pub use approval_overlay::{ApprovalOverlay, ApprovalAction, ApprovalRequest, ApprovalBuilder, RiskLevel, BatchApproval}; -pub use resume_picker::{ResumePicker, ResumePickerAction, SessionEntry, SessionSortOrder, SessionManager}; -pub use pager_overlay::{PagerOverlay, PagerAction, PagerContent, PagerBuilder}; -pub use shimmer::{ShimmerState, ShimmerEffect, SkeletonLoader, Spinner, SpinnerStyle, ProgressBar, TypingIndicator, Countdown, LoadingState, LoadingOverlay}; -pub use ui::{colors, styles, layout, Modal, Toast, ToastType, ToastManager, Breadcrumb, StatusBar, StatusItem, Badge, Divider, EmptyState, KeyHint, KeyHints}; -pub use help::{HelpViewer, HelpAction, HelpCategory, KeyBinding, CheatSheet, CheatSection, QuickRef}; -pub use notification::{NotificationCenter, NotificationPanel, NotificationPanelAction, Notification, NotificationPriority, NotificationAction, Banner, Alert, AlertType, AlertButton, AlertAction}; -pub use views::{MainView, ViewAction, ViewBuilder, FocusArea, ActiveOverlay, AppMode, LayoutConfig}; +pub use markdown_render::{MarkdownRenderer, MarkdownStyles}; +pub use markdown_stream::{CursorPosition, MarkdownStream, ScrollState, StreamBuffer, StreamState}; +pub use notification::{ + Alert, AlertAction, AlertButton, AlertType, Banner, Notification, NotificationAction, + NotificationCenter, NotificationPanel, NotificationPanelAction, NotificationPriority, +}; +pub use pager_overlay::{PagerAction, PagerBuilder, PagerContent, PagerOverlay}; +pub use resume_picker::{ + ResumePicker, ResumePickerAction, SessionEntry, SessionManager, SessionSortOrder, +}; +pub use shimmer::{ + Countdown, LoadingOverlay, LoadingState, ProgressBar, ShimmerEffect, ShimmerState, + SkeletonLoader, Spinner, SpinnerStyle, TypingIndicator, +}; +pub use syntax::{highlight_code, normalize_language, render_code_block, SyntaxHighlighter}; +pub use textarea::{TextArea, TextAreaAction, TextAreaConfig, TextCursor, TextRange}; +pub use ui::{ + colors, layout, styles, Badge, Breadcrumb, Divider, EmptyState, KeyHint, KeyHints, Modal, + StatusBar, StatusItem, Toast, ToastManager, ToastType, +}; +pub use update::reduce; +pub use views::{ + ActiveOverlay, AppMode, FocusArea, LayoutConfig, MainView, ViewAction, ViewBuilder, +}; +pub use wrapping::{display_width, word_wrap_line, wrap_text, WrapOptions}; diff --git a/crates/miyabi-tui/src/main.rs b/crates/miyabi-tui/src/main.rs index 4e2759f..f3d39c8 100644 --- a/crates/miyabi-tui/src/main.rs +++ b/crates/miyabi-tui/src/main.rs @@ -2,12 +2,12 @@ //! //! A premium terminal interface following OpenAI Codex patterns. -use miyabi_tui::App; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; +use miyabi_tui::App; use ratatui::prelude::*; use std::io; diff --git a/crates/miyabi-tui/src/markdown_parser.rs b/crates/miyabi-tui/src/markdown_parser.rs index 4278e47..554ca2b 100644 --- a/crates/miyabi-tui/src/markdown_parser.rs +++ b/crates/miyabi-tui/src/markdown_parser.rs @@ -3,7 +3,7 @@ //! This module provides incremental parsing of markdown content using pulldown-cmark, //! with caching for efficient re-rendering during streaming. -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, CodeBlockKind}; +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, @@ -205,10 +205,8 @@ impl EventRenderer { } else { format!("{}- ", indent) }; - self.current_spans.push(Span::styled( - marker, - Style::default().fg(Color::Yellow), - )); + self.current_spans + .push(Span::styled(marker, Style::default().fg(Color::Yellow))); } Tag::Emphasis => { self.push_style(Style::default().add_modifier(Modifier::ITALIC)); @@ -220,14 +218,16 @@ impl EventRenderer { self.push_style(Style::default().add_modifier(Modifier::CROSSED_OUT)); } Tag::BlockQuote(_) => { - self.current_spans.push(Span::styled( - "│ ", - Style::default().fg(Color::DarkGray), - )); + self.current_spans + .push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); self.push_style(Style::default().fg(Color::Gray)); } Tag::Link { .. } => { - self.push_style(Style::default().fg(Color::Blue).add_modifier(Modifier::UNDERLINED)); + self.push_style( + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::UNDERLINED), + ); } _ => {} } @@ -280,7 +280,8 @@ impl EventRenderer { } } else { let style = self.current_style(); - self.current_spans.push(Span::styled(text.to_string(), style)); + self.current_spans + .push(Span::styled(text.to_string(), style)); } } diff --git a/crates/miyabi-tui/src/markdown_render.rs b/crates/miyabi-tui/src/markdown_render.rs index 2a975f4..c1baaf3 100644 --- a/crates/miyabi-tui/src/markdown_render.rs +++ b/crates/miyabi-tui/src/markdown_render.rs @@ -133,7 +133,10 @@ impl MarkdownRenderer { if let Some(stripped) = trimmed.strip_prefix("> ") { return Line::from(vec![ Span::styled(" > ", self.styles.blockquote), - Span::styled(stripped.to_string(), Style::default().fg(Color::Rgb(192, 202, 245))), + Span::styled( + stripped.to_string(), + Style::default().fg(Color::Rgb(192, 202, 245)), + ), ]); } @@ -174,7 +177,11 @@ impl MarkdownRenderer { if !current.is_empty() { spans.push(Span::styled( current.clone(), - if in_code { self.styles.code } else { Style::default().fg(Color::Rgb(192, 202, 245)) }, + if in_code { + self.styles.code + } else { + Style::default().fg(Color::Rgb(192, 202, 245)) + }, )); current.clear(); } diff --git a/crates/miyabi-tui/src/markdown_stream.rs b/crates/miyabi-tui/src/markdown_stream.rs index 1930e30..10c72ae 100644 --- a/crates/miyabi-tui/src/markdown_stream.rs +++ b/crates/miyabi-tui/src/markdown_stream.rs @@ -11,28 +11,28 @@ //! - Link and image handling //! - Blockquotes with visual indicators +use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, }; -use pulldown_cmark::{Parser, Event, Tag, TagEnd, CodeBlockKind, Options}; use unicode_width::UnicodeWidthStr; /// Color palette - Tokyo Night theme mod colors { use ratatui::style::Color; - pub const HEADING_1: Color = Color::Rgb(122, 162, 247); // Blue - pub const HEADING_2: Color = Color::Rgb(125, 207, 255); // Cyan - pub const HEADING_3: Color = Color::Rgb(187, 154, 247); // Purple - pub const CODE_BG: Color = Color::Rgb(36, 40, 59); // Dark background - pub const CODE_FG: Color = Color::Rgb(169, 177, 214); // Light gray - pub const LINK: Color = Color::Rgb(125, 207, 255); // Cyan - pub const EMPHASIS: Color = Color::Rgb(224, 175, 104); // Yellow - pub const STRONG: Color = Color::Rgb(247, 118, 142); // Red/Pink - pub const BLOCKQUOTE: Color = Color::Rgb(115, 218, 202); // Teal - pub const LIST_MARKER: Color = Color::Rgb(158, 206, 106); // Green - pub const TABLE_BORDER: Color = Color::Rgb(86, 95, 137); // Dim + pub const HEADING_1: Color = Color::Rgb(122, 162, 247); // Blue + pub const HEADING_2: Color = Color::Rgb(125, 207, 255); // Cyan + pub const HEADING_3: Color = Color::Rgb(187, 154, 247); // Purple + pub const CODE_BG: Color = Color::Rgb(36, 40, 59); // Dark background + pub const CODE_FG: Color = Color::Rgb(169, 177, 214); // Light gray + pub const LINK: Color = Color::Rgb(125, 207, 255); // Cyan + pub const EMPHASIS: Color = Color::Rgb(224, 175, 104); // Yellow + pub const STRONG: Color = Color::Rgb(247, 118, 142); // Red/Pink + pub const BLOCKQUOTE: Color = Color::Rgb(115, 218, 202); // Teal + pub const LIST_MARKER: Color = Color::Rgb(158, 206, 106); // Green + pub const TABLE_BORDER: Color = Color::Rgb(86, 95, 137); // Dim pub const STRIKETHROUGH: Color = Color::Rgb(169, 177, 214); // Gray pub const HORIZONTAL_RULE: Color = Color::Rgb(86, 95, 137); // Dim } @@ -64,7 +64,9 @@ impl InlineStyle { style = style.add_modifier(Modifier::ITALIC).fg(colors::EMPHASIS); } if self.strikethrough { - style = style.add_modifier(Modifier::CROSSED_OUT).fg(colors::STRIKETHROUGH); + style = style + .add_modifier(Modifier::CROSSED_OUT) + .fg(colors::STRIKETHROUGH); } if self.code { style = style.bg(colors::CODE_BG).fg(colors::CODE_FG); @@ -115,7 +117,8 @@ pub struct TableState { impl TableState { /// Finish current cell pub fn finish_cell(&mut self) { - let alignment = self.alignments + let alignment = self + .alignments .get(self.current_row.len()) .copied() .unwrap_or_default(); @@ -147,7 +150,8 @@ impl TableState { let mut lines = Vec::new(); // Calculate column widths - let mut widths: Vec = self.headers + let mut widths: Vec = self + .headers .iter() .map(|c| c.content.width().max(3)) .collect(); @@ -162,11 +166,15 @@ impl TableState { // Render header let border_style = Style::default().fg(colors::TABLE_BORDER); - let header_style = Style::default().fg(colors::HEADING_2).add_modifier(Modifier::BOLD); + let header_style = Style::default() + .fg(colors::HEADING_2) + .add_modifier(Modifier::BOLD); // Top border - let top_border = format!("┌{}┐", - widths.iter() + let top_border = format!( + "┌{}┐", + widths + .iter() .map(|w| "─".repeat(*w + 2)) .collect::>() .join("┬") @@ -174,7 +182,8 @@ impl TableState { lines.push(Line::from(Span::styled(top_border, border_style))); // Header row - let header_cells: Vec = self.headers + let header_cells: Vec = self + .headers .iter() .enumerate() .map(|(i, cell)| { @@ -194,8 +203,10 @@ impl TableState { lines.push(Line::from(header_spans)); // Header/body separator - let separator = format!("├{}┤", - widths.iter() + let separator = format!( + "├{}┤", + widths + .iter() .map(|w| "─".repeat(*w + 2)) .collect::>() .join("┼") @@ -229,8 +240,10 @@ impl TableState { } // Bottom border - let bottom_border = format!("└{}┘", - widths.iter() + let bottom_border = format!( + "└{}┘", + widths + .iter() .map(|w| "─".repeat(*w + 2)) .collect::>() .join("┴") @@ -276,7 +289,8 @@ impl ParseContext { } let style = self.inline_style.to_style(); - self.current_spans.push(Span::styled(text.to_string(), style)); + self.current_spans + .push(Span::styled(text.to_string(), style)); } /// Finish current line @@ -285,20 +299,22 @@ impl ParseContext { // Add blockquote prefix if needed if self.in_blockquote { let prefix = "│ ".repeat(self.blockquote_depth); - let mut spans = vec![ - Span::styled(prefix, Style::default().fg(colors::BLOCKQUOTE)) - ]; + let mut spans = vec![Span::styled( + prefix, + Style::default().fg(colors::BLOCKQUOTE), + )]; spans.extend(std::mem::take(&mut self.current_spans)); self.lines.push(Line::from(spans)); } else { - self.lines.push(Line::from(std::mem::take(&mut self.current_spans))); + self.lines + .push(Line::from(std::mem::take(&mut self.current_spans))); } } else if self.in_blockquote { // Empty blockquote line let prefix = "│ ".repeat(self.blockquote_depth); self.lines.push(Line::from(Span::styled( prefix, - Style::default().fg(colors::BLOCKQUOTE) + Style::default().fg(colors::BLOCKQUOTE), ))); } } @@ -312,11 +328,19 @@ impl ParseContext { /// Get list marker for current depth pub fn get_list_marker(&self) -> String { if let Some(num) = self.list_number { - format!("{}{}. ", " ".repeat(self.list_depth.saturating_sub(1)), num) + format!( + "{}{}. ", + " ".repeat(self.list_depth.saturating_sub(1)), + num + ) } else { let markers = ['•', '◦', '▪', '▸']; let marker = markers[(self.list_depth.saturating_sub(1)) % markers.len()]; - format!("{}{} ", " ".repeat(self.list_depth.saturating_sub(1)), marker) + format!( + "{}{} ", + " ".repeat(self.list_depth.saturating_sub(1)), + marker + ) } } } @@ -560,10 +584,7 @@ impl MarkdownStream { let content = self.buffer.content(); let line_count = content.lines().count(); let last_line_len = content.lines().last().map(|l| l.len()).unwrap_or(0); - self.cursor = CursorPosition::new( - line_count.saturating_sub(1), - last_line_len, - ); + self.cursor = CursorPosition::new(line_count.saturating_sub(1), last_line_len); } /// Mark stream as complete @@ -685,9 +706,8 @@ impl MarkdownStream { } // Configure pulldown-cmark options - let options = Options::ENABLE_TABLES - | Options::ENABLE_STRIKETHROUGH - | Options::ENABLE_TASKLISTS; + let options = + Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; let parser = Parser::new_ext(&content, options); let mut ctx = ParseContext::default(); @@ -700,15 +720,15 @@ impl MarkdownStream { Tag::Heading { level, .. } => { ctx.finish_line(); let style = match level { - pulldown_cmark::HeadingLevel::H1 => { - Style::default().fg(colors::HEADING_1).add_modifier(Modifier::BOLD) - } - pulldown_cmark::HeadingLevel::H2 => { - Style::default().fg(colors::HEADING_2).add_modifier(Modifier::BOLD) - } - pulldown_cmark::HeadingLevel::H3 => { - Style::default().fg(colors::HEADING_3).add_modifier(Modifier::BOLD) - } + pulldown_cmark::HeadingLevel::H1 => Style::default() + .fg(colors::HEADING_1) + .add_modifier(Modifier::BOLD), + pulldown_cmark::HeadingLevel::H2 => Style::default() + .fg(colors::HEADING_2) + .add_modifier(Modifier::BOLD), + pulldown_cmark::HeadingLevel::H3 => Style::default() + .fg(colors::HEADING_3) + .add_modifier(Modifier::BOLD), _ => Style::default().add_modifier(Modifier::BOLD), }; let prefix = match level { @@ -719,7 +739,8 @@ impl MarkdownStream { pulldown_cmark::HeadingLevel::H5 => "##### ", pulldown_cmark::HeadingLevel::H6 => "###### ", }; - ctx.current_spans.push(Span::styled(prefix.to_string(), style)); + ctx.current_spans + .push(Span::styled(prefix.to_string(), style)); } Tag::Paragraph => { ctx.finish_line(); @@ -767,16 +788,18 @@ impl MarkdownStream { } Tag::Table(alignments) => { ctx.finish_line(); - let mut table = TableState::default(); - table.alignments = alignments - .into_iter() - .map(|a| match a { - pulldown_cmark::Alignment::Left => Alignment::Left, - pulldown_cmark::Alignment::Center => Alignment::Center, - pulldown_cmark::Alignment::Right => Alignment::Right, - pulldown_cmark::Alignment::None => Alignment::Left, - }) - .collect(); + let table = TableState { + alignments: alignments + .into_iter() + .map(|a| match a { + pulldown_cmark::Alignment::Left => Alignment::Left, + pulldown_cmark::Alignment::Center => Alignment::Center, + pulldown_cmark::Alignment::Right => Alignment::Right, + pulldown_cmark::Alignment::None => Alignment::Left, + }) + .collect(), + ..Default::default() + }; ctx.table = Some(table); } Tag::TableHead => { @@ -801,7 +824,9 @@ impl MarkdownStream { Tag::Image { dest_url, .. } => { ctx.current_spans.push(Span::styled( format!("[image: {}]", dest_url), - Style::default().fg(colors::LINK).add_modifier(Modifier::DIM), + Style::default() + .fg(colors::LINK) + .add_modifier(Modifier::DIM), )); } _ => {} @@ -881,7 +906,9 @@ impl MarkdownStream { if let Some(url) = ctx.inline_style.link_url.take() { ctx.current_spans.push(Span::styled( format!(" ({})", url), - Style::default().fg(colors::TABLE_BORDER).add_modifier(Modifier::DIM), + Style::default() + .fg(colors::TABLE_BORDER) + .add_modifier(Modifier::DIM), )); } } @@ -968,25 +995,121 @@ fn highlight_code_line(line: &str, _language: &str) -> Line<'static> { let mut string_char = '"'; let keywords = [ - "fn", "let", "mut", "const", "pub", "mod", "use", "struct", "enum", "impl", - "trait", "where", "if", "else", "match", "for", "while", "loop", "return", - "break", "continue", "async", "await", "self", "Self", "true", "false", - "Some", "None", "Ok", "Err", "type", "static", "extern", "crate", "super", + "fn", + "let", + "mut", + "const", + "pub", + "mod", + "use", + "struct", + "enum", + "impl", + "trait", + "where", + "if", + "else", + "match", + "for", + "while", + "loop", + "return", + "break", + "continue", + "async", + "await", + "self", + "Self", + "true", + "false", + "Some", + "None", + "Ok", + "Err", + "type", + "static", + "extern", + "crate", + "super", // TypeScript/JavaScript - "function", "class", "interface", "extends", "import", "export", "default", - "new", "this", "try", "catch", "throw", "finally", "typeof", "instanceof", + "function", + "class", + "interface", + "extends", + "import", + "export", + "default", + "new", + "this", + "try", + "catch", + "throw", + "finally", + "typeof", + "instanceof", // Python - "def", "class", "import", "from", "as", "pass", "raise", "with", "yield", - "lambda", "global", "nonlocal", "assert", "del", "in", "is", "not", "and", "or", + "def", + "class", + "import", + "from", + "as", + "pass", + "raise", + "with", + "yield", + "lambda", + "global", + "nonlocal", + "assert", + "del", + "in", + "is", + "not", + "and", + "or", ]; let types = [ - "String", "Vec", "Option", "Result", "Box", "Rc", "Arc", "HashMap", "HashSet", - "bool", "char", "str", "i8", "i16", "i32", "i64", "i128", "isize", - "u8", "u16", "u32", "u64", "u128", "usize", "f32", "f64", + "String", + "Vec", + "Option", + "Result", + "Box", + "Rc", + "Arc", + "HashMap", + "HashSet", + "bool", + "char", + "str", + "i8", + "i16", + "i32", + "i64", + "i128", + "isize", + "u8", + "u16", + "u32", + "u64", + "u128", + "usize", + "f32", + "f64", // TypeScript - "number", "string", "boolean", "void", "null", "undefined", "any", "never", - "Array", "Promise", "Map", "Set", "Object", + "number", + "string", + "boolean", + "void", + "null", + "undefined", + "any", + "never", + "Array", + "Promise", + "Map", + "Set", + "Object", ]; for ch in line.chars() { @@ -1024,9 +1147,7 @@ fn highlight_code_line(line: &str, _language: &str) -> Line<'static> { '+' | '-' | '*' | '/' | '=' | '<' | '>' | '!' | '&' | '|' | '^' | '%' => { Style::default().fg(Color::Rgb(255, 121, 198)) // Pink } - ':' | ';' | ',' | '.' => { - Style::default().fg(colors::TABLE_BORDER) - } + ':' | ';' | ',' | '.' => Style::default().fg(colors::TABLE_BORDER), '#' => { Style::default().fg(Color::Rgb(255, 184, 108)) // Orange (attributes) } @@ -1053,10 +1174,14 @@ fn highlight_code_line(line: &str, _language: &str) -> Line<'static> { fn get_word_style(word: &str, keywords: &[&str], types: &[&str]) -> Style { if keywords.contains(&word) { Style::default().fg(Color::Rgb(255, 121, 198)) // Pink - keywords - } else if types.contains(&word) { - Style::default().fg(Color::Rgb(139, 233, 253)) // Cyan - types - } else if word.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) { - Style::default().fg(Color::Rgb(139, 233, 253)) // Cyan - types (PascalCase) + } else if types.contains(&word) + || word + .chars() + .next() + .map(|c| c.is_uppercase()) + .unwrap_or(false) + { + Style::default().fg(Color::Rgb(139, 233, 253)) // Cyan - types or PascalCase } else if word.chars().all(|c| c.is_ascii_digit() || c == '.') { Style::default().fg(Color::Rgb(189, 147, 249)) // Purple - numbers } else { @@ -1205,7 +1330,11 @@ mod tests { // Markdown renders paragraphs with blank lines between them let line_count = stream.line_count(); - assert!(line_count >= 5, "Expected at least 5 lines, got {}", line_count); + assert!( + line_count >= 5, + "Expected at least 5 lines, got {}", + line_count + ); assert!(stream.scroll().can_scroll_down()); stream.scroll_down(2); @@ -1234,13 +1363,20 @@ mod tests { // Markdown renders paragraphs (at least 5 lines with blank lines) let initial_count = lines.len(); - assert!(initial_count >= 5, "Expected at least 5 lines, got {}", initial_count); + assert!( + initial_count >= 5, + "Expected at least 5 lines, got {}", + initial_count + ); // Add more content stream.push_str("\n\nLine 6"); let lines = stream.render(); // Should have more lines now - assert!(lines.len() > initial_count, "Expected more lines after adding content"); + assert!( + lines.len() > initial_count, + "Expected more lines after adding content" + ); } } diff --git a/crates/miyabi-tui/src/notification.rs b/crates/miyabi-tui/src/notification.rs index 30263eb..7aee942 100644 --- a/crates/miyabi-tui/src/notification.rs +++ b/crates/miyabi-tui/src/notification.rs @@ -189,7 +189,10 @@ pub enum NotificationPanelAction { /// Dismiss all notifications DismissAll, /// Execute action on notification - ExecuteAction { notification_id: String, action_id: String }, + ExecuteAction { + notification_id: String, + action_id: String, + }, /// Mark all as read MarkAllRead, } @@ -424,26 +427,21 @@ impl NotificationPanel { let header = format!( "{} {} {} - {}", - read_indicator, - icon, - notification.title, - age + read_indicator, icon, notification.title, age ); - let mut lines = vec![ - Line::from(Span::styled( - header, - Style::default() - .fg(notification.priority.color()) - .add_modifier(if is_selected { - Modifier::BOLD - } else if notification.read { - Modifier::DIM - } else { - Modifier::empty() - }), - )), - ]; + let mut lines = vec![Line::from(Span::styled( + header, + Style::default() + .fg(notification.priority.color()) + .add_modifier(if is_selected { + Modifier::BOLD + } else if notification.read { + Modifier::DIM + } else { + Modifier::empty() + }), + ))]; // Add message (truncated) let msg = if notification.message.len() > 60 { @@ -453,13 +451,13 @@ impl NotificationPanel { }; lines.push(Line::from(Span::styled( msg, - Style::default().fg(colors::FG).add_modifier( - if notification.read { + Style::default() + .fg(colors::FG) + .add_modifier(if notification.read { Modifier::DIM } else { Modifier::empty() - }, - ), + }), ))); // Add actions if selected @@ -485,8 +483,7 @@ impl NotificationPanel { }) .collect(); - let list = List::new(items) - .highlight_style(Style::default().bg(colors::SELECTION)); + let list = List::new(items).highlight_style(Style::default().bg(colors::SELECTION)); // Render with state ratatui::widgets::StatefulWidget::render(list, inner, buf, &mut self.list_state); @@ -601,7 +598,10 @@ impl Banner { let filled = (progress * progress_area.width as f64) as u16; for x in progress_area.x..progress_area.x + filled { - if let Some(cell) = buf.cell_mut(Position { x, y: progress_area.y }) { + if let Some(cell) = buf.cell_mut(Position { + x, + y: progress_area.y, + }) { cell.set_bg(colors::GREEN); } } @@ -918,26 +918,17 @@ impl NotificationCenter { /// Convenience method for info notification pub fn info(&mut self, title: impl Into, message: impl Into) { - self.notify( - Notification::new(title, message) - .with_priority(NotificationPriority::Low) - ); + self.notify(Notification::new(title, message).with_priority(NotificationPriority::Low)); } /// Convenience method for success notification pub fn success(&mut self, title: impl Into, message: impl Into) { - self.notify( - Notification::new(title, message) - .with_priority(NotificationPriority::Normal) - ); + self.notify(Notification::new(title, message).with_priority(NotificationPriority::Normal)); } /// Convenience method for warning notification pub fn warning(&mut self, title: impl Into, message: impl Into) { - self.notify( - Notification::new(title, message) - .with_priority(NotificationPriority::High) - ); + self.notify(Notification::new(title, message).with_priority(NotificationPriority::High)); } /// Convenience method for error notification @@ -945,7 +936,7 @@ impl NotificationCenter { self.notify( Notification::new(title, message) .with_priority(NotificationPriority::Critical) - .with_duration(None) // Errors persist until dismissed + .with_duration(None), // Errors persist until dismissed ); } } @@ -991,26 +982,24 @@ mod tests { #[test] fn test_notification_with_priority() { - let notification = Notification::new("Title", "Message") - .with_priority(NotificationPriority::Critical); + let notification = + Notification::new("Title", "Message").with_priority(NotificationPriority::Critical); assert_eq!(notification.priority, NotificationPriority::Critical); } #[test] fn test_notification_with_source() { - let notification = Notification::new("Title", "Message") - .with_source("system"); + let notification = Notification::new("Title", "Message").with_source("system"); assert_eq!(notification.source, Some("system".to_string())); } #[test] fn test_notification_with_duration() { - let notification = Notification::new("Title", "Message") - .with_duration(Some(Duration::from_secs(10))); + let notification = + Notification::new("Title", "Message").with_duration(Some(Duration::from_secs(10))); assert_eq!(notification.duration, Some(Duration::from_secs(10))); - let persistent = Notification::new("Title", "Message") - .with_duration(None); + let persistent = Notification::new("Title", "Message").with_duration(None); assert_eq!(persistent.duration, None); } @@ -1025,14 +1014,13 @@ mod tests { #[test] fn test_notification_is_expired() { // Short duration notification - let notification = Notification::new("Title", "Message") - .with_duration(Some(Duration::from_millis(1))); + let notification = + Notification::new("Title", "Message").with_duration(Some(Duration::from_millis(1))); std::thread::sleep(Duration::from_millis(5)); assert!(notification.is_expired()); // Persistent notification never expires - let persistent = Notification::new("Title", "Message") - .with_duration(None); + let persistent = Notification::new("Title", "Message").with_duration(None); assert!(!persistent.is_expired()); } @@ -1138,8 +1126,7 @@ mod tests { fn test_panel_cleanup_expired() { let mut panel = NotificationPanel::new(); panel.push( - Notification::new("Test", "Message") - .with_duration(Some(Duration::from_millis(1))) + Notification::new("Test", "Message").with_duration(Some(Duration::from_millis(1))), ); std::thread::sleep(Duration::from_millis(5)); @@ -1409,9 +1396,8 @@ mod tests { #[test] fn test_alert_handle_key_enter() { - let mut alert = Alert::new("Title", "Message").with_buttons(vec![ - AlertButton::new("ok", "OK"), - ]); + let mut alert = + Alert::new("Title", "Message").with_buttons(vec![AlertButton::new("ok", "OK")]); let action = alert.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); assert!(matches!(action, AlertAction::ButtonPressed(ref id) if id == "ok")); @@ -1466,8 +1452,7 @@ mod tests { let mut center = NotificationCenter::new(); center.show_banner(Banner::new("Test").with_duration(Duration::from_millis(1))); center.notify( - Notification::new("Test", "Message") - .with_duration(Some(Duration::from_millis(1))) + Notification::new("Test", "Message").with_duration(Some(Duration::from_millis(1))), ); std::thread::sleep(Duration::from_millis(5)); diff --git a/crates/miyabi-tui/src/pager_overlay.rs b/crates/miyabi-tui/src/pager_overlay.rs index d978eb4..0209472 100644 --- a/crates/miyabi-tui/src/pager_overlay.rs +++ b/crates/miyabi-tui/src/pager_overlay.rs @@ -12,7 +12,9 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, + widgets::{ + Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, + }, Frame, }; @@ -328,7 +330,10 @@ impl PagerOverlay { /// Scroll down fn scroll_down(&mut self, lines: usize) { - let max_scroll = self.content.line_count().saturating_sub(self.viewport_height); + let max_scroll = self + .content + .line_count() + .saturating_sub(self.viewport_height); self.scroll = (self.scroll + lines).min(max_scroll); } @@ -339,7 +344,10 @@ impl PagerOverlay { /// Scroll to end fn scroll_to_end(&mut self) { - let max_scroll = self.content.line_count().saturating_sub(self.viewport_height); + let max_scroll = self + .content + .line_count() + .saturating_sub(self.viewport_height); self.scroll = max_scroll; } @@ -364,7 +372,9 @@ impl PagerOverlay { let mut start = 0; while let Some(pos) = line_lower[start..].find(&query) { let absolute_pos = start + pos; - search.matches.push((line_idx, absolute_pos, absolute_pos + query.len())); + search + .matches + .push((line_idx, absolute_pos, absolute_pos + query.len())); start = absolute_pos + 1; } } @@ -424,7 +434,9 @@ impl PagerOverlay { let block = Block::default() .title(Span::styled( format!(" {} ", self.title), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -564,11 +576,7 @@ impl PagerOverlay { // Search mode if self.search_mode { - let query = self - .search - .as_ref() - .map(|s| s.query.as_str()) - .unwrap_or(""); + let query = self.search.as_ref().map(|s| s.query.as_str()).unwrap_or(""); let direction = self .search .as_ref() @@ -603,7 +611,11 @@ impl PagerOverlay { if let Some(search) = &self.search { if !search.matches.is_empty() { spans.push(Span::styled( - format!(" Match {}/{} ", search.current_match + 1, search.matches.len()), + format!( + " Match {}/{} ", + search.current_match + 1, + search.matches.len() + ), Style::default().fg(Color::Yellow), )); } @@ -723,10 +735,7 @@ mod tests { #[test] fn test_pager_content_styled() { - let lines = vec![ - Line::from("Line 1"), - Line::from("Line 2"), - ]; + let lines = vec![Line::from("Line 1"), Line::from("Line 2")]; let content = PagerContent::Styled(lines); assert_eq!(content.raw(), ""); assert_eq!(content.line_count(), 2); @@ -742,8 +751,7 @@ mod tests { #[test] fn test_pager_content_builder() { - let pager = PagerOverlay::new() - .content(PagerContent::Plain("test".to_string())); + let pager = PagerOverlay::new().content(PagerContent::Plain("test".to_string())); assert_eq!(pager.content.raw(), "test"); } @@ -835,7 +843,10 @@ mod tests { #[test] fn test_pager_handle_key_scroll_down() { let mut pager = PagerOverlay::new(); - let content = (0..100).map(|i| format!("line {}", i)).collect::>().join("\n"); + let content = (0..100) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); pager.show_text(content); pager.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty())); @@ -848,7 +859,10 @@ mod tests { #[test] fn test_pager_handle_key_scroll_up() { let mut pager = PagerOverlay::new(); - let content = (0..100).map(|i| format!("line {}", i)).collect::>().join("\n"); + let content = (0..100) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); pager.show_text(content); pager.scroll = 5; @@ -862,7 +876,10 @@ mod tests { #[test] fn test_pager_handle_key_page_down() { let mut pager = PagerOverlay::new(); - let content = (0..100).map(|i| format!("line {}", i)).collect::>().join("\n"); + let content = (0..100) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); pager.show_text(content); pager.viewport_height = 10; @@ -873,7 +890,10 @@ mod tests { #[test] fn test_pager_handle_key_page_up() { let mut pager = PagerOverlay::new(); - let content = (0..100).map(|i| format!("line {}", i)).collect::>().join("\n"); + let content = (0..100) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); pager.show_text(content); pager.viewport_height = 10; pager.scroll = 20; @@ -899,7 +919,10 @@ mod tests { #[test] fn test_pager_handle_key_end() { let mut pager = PagerOverlay::new(); - let content = (0..100).map(|i| format!("line {}", i)).collect::>().join("\n"); + let content = (0..100) + .map(|i| format!("line {}", i)) + .collect::>() + .join("\n"); pager.show_text(content); pager.viewport_height = 10; @@ -1060,9 +1083,7 @@ mod tests { #[test] fn test_pager_builder_title() { - let pager = PagerBuilder::help("content") - .title("Custom Title") - .build(); + let pager = PagerBuilder::help("content").title("Custom Title").build(); assert_eq!(pager.title, "Custom Title"); } diff --git a/crates/miyabi-tui/src/resume_picker.rs b/crates/miyabi-tui/src/resume_picker.rs index 2fc50a6..cb6203c 100644 --- a/crates/miyabi-tui/src/resume_picker.rs +++ b/crates/miyabi-tui/src/resume_picker.rs @@ -228,7 +228,9 @@ impl ResumePicker { /// Get selected session pub fn selected_session(&self) -> Option<&SessionEntry> { - self.filtered.get(self.selected).map(|&idx| &self.sessions[idx]) + self.filtered + .get(self.selected) + .map(|&idx| &self.sessions[idx]) } /// Set sort order @@ -303,7 +305,9 @@ impl ResumePicker { } ResumePickerAction::None } - KeyCode::Delete | KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Delete | KeyCode::Char('x') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { if let Some(session) = self.selected_session() { let id = session.id.clone(); ResumePickerAction::Delete(id) @@ -379,18 +383,16 @@ impl ResumePicker { /// Sort sessions fn sort_sessions(&mut self) { // Pinned items always first - self.sessions.sort_by(|a, b| { - match (a.pinned, b.pinned) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - _ => match self.sort_order { - SessionSortOrder::RecentFirst => b.updated_at.cmp(&a.updated_at), - SessionSortOrder::OldestFirst => a.updated_at.cmp(&b.updated_at), - SessionSortOrder::Alphabetical => a.title.cmp(&b.title), - SessionSortOrder::MessageCount => b.message_count.cmp(&a.message_count), - SessionSortOrder::TokenUsage => b.tokens_used.cmp(&a.tokens_used), - }, - } + self.sessions.sort_by(|a, b| match (a.pinned, b.pinned) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => match self.sort_order { + SessionSortOrder::RecentFirst => b.updated_at.cmp(&a.updated_at), + SessionSortOrder::OldestFirst => a.updated_at.cmp(&b.updated_at), + SessionSortOrder::Alphabetical => a.title.cmp(&b.title), + SessionSortOrder::MessageCount => b.message_count.cmp(&a.message_count), + SessionSortOrder::TokenUsage => b.tokens_used.cmp(&a.tokens_used), + }, }); } @@ -408,7 +410,10 @@ impl ResumePicker { .filter(|(_, session)| { session.title.to_lowercase().contains(&query) || session.preview.to_lowercase().contains(&query) - || session.tags.iter().any(|t| t.to_lowercase().contains(&query)) + || session + .tags + .iter() + .any(|t| t.to_lowercase().contains(&query)) }) .map(|(i, _)| i) .collect(); @@ -444,7 +449,9 @@ impl ResumePicker { let block = Block::default() .title(Span::styled( format!(" {} ", self.title), - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)); @@ -646,7 +653,11 @@ impl ResumePicker { lines.push(Line::from(vec![ Span::styled("Created: ", Style::default().fg(Color::Rgb(86, 95, 137))), Span::styled( - session.created_at.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string(), + session + .created_at + .with_timezone(&Local) + .format("%Y-%m-%d %H:%M") + .to_string(), Style::default().fg(Color::Rgb(169, 177, 214)), ), ])); @@ -835,7 +846,8 @@ mod tests { #[test] fn test_session_entry_tags() { - let entry = SessionEntry::new("id1", "Test").tags(vec!["rust".to_string(), "cli".to_string()]); + let entry = + SessionEntry::new("id1", "Test").tags(vec!["rust".to_string(), "cli".to_string()]); assert_eq!(entry.tags.len(), 2); assert_eq!(entry.tags[0], "rust"); } @@ -952,8 +964,10 @@ mod tests { fn test_picker_selected_session() { let now = Utc::now(); let sessions = vec![ - SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(2), now - Duration::hours(2)), - SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(1), now - Duration::hours(1)), + SessionEntry::new("1", "Session 1") + .timestamps(now - Duration::hours(2), now - Duration::hours(2)), + SessionEntry::new("2", "Session 2") + .timestamps(now - Duration::hours(1), now - Duration::hours(1)), ]; let mut picker = ResumePicker::new().sessions(sessions); picker.show(); @@ -1025,9 +1039,12 @@ mod tests { fn test_picker_handle_key_navigation() { let now = Utc::now(); let sessions = vec![ - SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(3), now - Duration::hours(3)), - SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(2), now - Duration::hours(2)), - SessionEntry::new("3", "Session 3").timestamps(now - Duration::hours(1), now - Duration::hours(1)), + SessionEntry::new("1", "Session 1") + .timestamps(now - Duration::hours(3), now - Duration::hours(3)), + SessionEntry::new("2", "Session 2") + .timestamps(now - Duration::hours(2), now - Duration::hours(2)), + SessionEntry::new("3", "Session 3") + .timestamps(now - Duration::hours(1), now - Duration::hours(1)), ]; let mut picker = ResumePicker::new().sessions(sessions); picker.show(); @@ -1045,8 +1062,10 @@ mod tests { fn test_picker_handle_key_tab() { let now = Utc::now(); let sessions = vec![ - SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(2), now - Duration::hours(2)), - SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(1), now - Duration::hours(1)), + SessionEntry::new("1", "Session 1") + .timestamps(now - Duration::hours(2), now - Duration::hours(2)), + SessionEntry::new("2", "Session 2") + .timestamps(now - Duration::hours(1), now - Duration::hours(1)), ]; let mut picker = ResumePicker::new().sessions(sessions); picker.show(); @@ -1060,9 +1079,12 @@ mod tests { fn test_picker_handle_key_home_end() { let now = Utc::now(); let sessions = vec![ - SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(3), now - Duration::hours(3)), - SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(2), now - Duration::hours(2)), - SessionEntry::new("3", "Session 3").timestamps(now - Duration::hours(1), now - Duration::hours(1)), + SessionEntry::new("1", "Session 1") + .timestamps(now - Duration::hours(3), now - Duration::hours(3)), + SessionEntry::new("2", "Session 2") + .timestamps(now - Duration::hours(2), now - Duration::hours(2)), + SessionEntry::new("3", "Session 3") + .timestamps(now - Duration::hours(1), now - Duration::hours(1)), ]; let mut picker = ResumePicker::new().sessions(sessions); picker.show(); diff --git a/crates/miyabi-tui/src/shimmer.rs b/crates/miyabi-tui/src/shimmer.rs index a8ed6db..d4fc5d7 100644 --- a/crates/miyabi-tui/src/shimmer.rs +++ b/crates/miyabi-tui/src/shimmer.rs @@ -206,10 +206,7 @@ impl SkeletonLoader { // Pulse effect: entire bar pulses let intensity = (progress * std::f64::consts::PI * 2.0).sin() * 0.5 + 0.5; let color = blend_colors(self.base_color, self.highlight_color, intensity); - vec![Span::styled( - "█".repeat(width), - Style::default().fg(color), - )] + vec![Span::styled("█".repeat(width), Style::default().fg(color))] } ShimmerEffect::Gradient => { // Gradient sweep @@ -234,10 +231,7 @@ impl SkeletonLoader { self.highlight_color, (progress * std::f64::consts::PI).sin(), ); - vec![Span::styled( - "─".repeat(width), - Style::default().fg(color), - )] + vec![Span::styled("─".repeat(width), Style::default().fg(color))] } }; @@ -636,7 +630,13 @@ fn blend_colors(a: Color, b: Color, t: f64) -> Color { let b = (b1 as f64 * (1.0 - t) + b2 as f64 * t) as u8; Color::Rgb(r, g, b) } - _ => if t < 0.5 { a } else { b }, + _ => { + if t < 0.5 { + a + } else { + b + } + } } } @@ -658,7 +658,10 @@ pub enum LoadingState { impl LoadingState { /// Check if loading pub fn is_loading(&self) -> bool { - matches!(self, LoadingState::Loading(_) | LoadingState::Progress { .. }) + matches!( + self, + LoadingState::Loading(_) | LoadingState::Progress { .. } + ) } /// Check if complete @@ -818,7 +821,7 @@ mod tests { fn test_shimmer_state_progress() { let state = ShimmerState::new(); let progress = state.progress(); - assert!(progress >= 0.0 && progress <= 1.0); + assert!((0.0..=1.0).contains(&progress)); } #[test] @@ -1036,11 +1039,7 @@ mod tests { #[test] fn test_blend_colors_rgb() { - let result = blend_colors( - Color::Rgb(0, 0, 0), - Color::Rgb(255, 255, 255), - 0.5, - ); + let result = blend_colors(Color::Rgb(0, 0, 0), Color::Rgb(255, 255, 255), 0.5); assert!(matches!(result, Color::Rgb(127, 127, 127))); } diff --git a/crates/miyabi-tui/src/textarea.rs b/crates/miyabi-tui/src/textarea.rs index 0fa5004..5d886b4 100644 --- a/crates/miyabi-tui/src/textarea.rs +++ b/crates/miyabi-tui/src/textarea.rs @@ -48,7 +48,10 @@ impl TextRange { if start <= end { Self { start, end } } else { - Self { start: end, end: start } + Self { + start: end, + end: start, + } } } @@ -762,7 +765,11 @@ impl TextArea { )); self.cursor = self.offset_to_pos(*pos + text.len()); } - EditOp::Replace { pos, old_text, new_text } => { + EditOp::Replace { + pos, + old_text, + new_text, + } => { let full_text = self.get_text(); self.set_text(&format!( "{}{}{}", @@ -801,7 +808,11 @@ impl TextArea { )); self.cursor = cursor; } - EditOp::Replace { pos, old_text, new_text } => { + EditOp::Replace { + pos, + old_text, + new_text, + } => { let full_text = self.get_text(); self.set_text(&format!( "{}{}{}", diff --git a/crates/miyabi-tui/src/ui.rs b/crates/miyabi-tui/src/ui.rs index 57ff1f8..decd230 100644 --- a/crates/miyabi-tui/src/ui.rs +++ b/crates/miyabi-tui/src/ui.rs @@ -2,6 +2,9 @@ //! //! Shared UI components and utilities for the TUI. +pub mod theme; +pub mod widgets; + use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -43,8 +46,8 @@ pub mod colors { /// Common UI styles pub mod styles { - use ratatui::style::{Modifier, Style}; use super::colors; + use ratatui::style::{Modifier, Style}; /// Default text style pub fn default() -> Style { @@ -313,7 +316,9 @@ impl Modal { let block = Block::default() .title(Span::styled( format!(" {} ", self.title), - Style::default().fg(colors::CYAN).add_modifier(Modifier::BOLD), + Style::default() + .fg(colors::CYAN) + .add_modifier(Modifier::BOLD), )) .borders(Borders::ALL) .border_style(Style::default().fg(colors::BORDER_FOCUS)); @@ -832,12 +837,12 @@ impl EmptyState { /// Render the empty state pub fn render(&self, frame: &mut Frame, area: Rect) { let mut lines = vec![ - Line::from(Span::styled(&self.icon, Style::default().fg(colors::FG_GUTTER))), - Line::from(""), Line::from(Span::styled( - &self.title, - styles::bold(), + &self.icon, + Style::default().fg(colors::FG_GUTTER), )), + Line::from(""), + Line::from(Span::styled(&self.title, styles::bold())), ]; if !self.description.is_empty() { @@ -1164,8 +1169,8 @@ mod tests { #[test] fn test_modal_navigation() { - let mut modal = Modal::new("Test") - .buttons(vec!["A".to_string(), "B".to_string(), "C".to_string()]); + let mut modal = + Modal::new("Test").buttons(vec!["A".to_string(), "B".to_string(), "C".to_string()]); assert_eq!(modal.selected(), 0); modal.next(); @@ -1463,9 +1468,7 @@ mod tests { #[test] fn test_key_hints_hint() { - let hints = KeyHints::new() - .hint("Esc", "Close") - .hint("Enter", "Submit"); + let hints = KeyHints::new().hint("Esc", "Close").hint("Enter", "Submit"); assert_eq!(hints.hints.len(), 2); } diff --git a/crates/miyabi-tui/src/ui/theme.rs b/crates/miyabi-tui/src/ui/theme.rs new file mode 100644 index 0000000..3f4c003 --- /dev/null +++ b/crates/miyabi-tui/src/ui/theme.rs @@ -0,0 +1,33 @@ +//! Theme container for sharing palette and typography choices. + +use ratatui::style::{Modifier, Style}; + +use crate::ui::colors; + +/// Centralized theme values used across widgets. +#[derive(Debug, Clone, Copy)] +pub struct Theme; + +impl Theme { + pub fn primary() -> Style { + Style::default().fg(colors::CYAN) + } + + pub fn muted() -> Style { + Style::default().fg(colors::FG_GUTTER) + } + + pub fn accent() -> Style { + Style::default() + .fg(colors::MAGENTA) + .add_modifier(Modifier::BOLD) + } + + pub fn danger() -> Style { + Style::default().fg(colors::RED) + } + + pub fn success() -> Style { + Style::default().fg(colors::GREEN) + } +} diff --git a/crates/miyabi-tui/src/ui/widgets/diff.rs b/crates/miyabi-tui/src/ui/widgets/diff.rs new file mode 100644 index 0000000..206f5cf --- /dev/null +++ b/crates/miyabi-tui/src/ui/widgets/diff.rs @@ -0,0 +1,14 @@ +//! Diff viewer widget placeholder. +//! +//! This will eventually wrap `diff_render.rs` for reuse across overlays. + +use ratatui::layout::Rect; +use ratatui::Frame; + +use crate::diff_render::FileDiff; + +/// Render a diff within the given area. +pub fn render_diff_widget(frame: &mut Frame, area: Rect, diff: &FileDiff) { + let _ = (frame, area, diff); + // TODO: integrate syntax highlighting and line numbers. +} diff --git a/crates/miyabi-tui/src/ui/widgets/history_list.rs b/crates/miyabi-tui/src/ui/widgets/history_list.rs new file mode 100644 index 0000000..26d1866 --- /dev/null +++ b/crates/miyabi-tui/src/ui/widgets/history_list.rs @@ -0,0 +1,30 @@ +//! History list widget placeholder. +//! +//! Intended to wrap `HistoryCell` rendering with consistent padding and theming. + +use ratatui::{ + layout::Rect, + widgets::{Block, Borders}, + Frame, +}; + +use crate::history_cell::HistoryCell; + +/// Properties required to render the history list. +pub struct HistoryListProps<'a> { + pub items: &'a [Box], + pub scroll: u16, +} + +/// Render conversation history as a list. +pub struct HistoryList; + +impl HistoryList { + pub fn render(frame: &mut Frame, area: Rect, props: HistoryListProps<'_>) { + let block = Block::default().borders(Borders::ALL); + frame.render_widget(block, area); + + let _ = props; + // TODO: integrate with virtualized list + markdown rendering. + } +} diff --git a/crates/miyabi-tui/src/ui/widgets/markdown.rs b/crates/miyabi-tui/src/ui/widgets/markdown.rs new file mode 100644 index 0000000..d2ceb8f --- /dev/null +++ b/crates/miyabi-tui/src/ui/widgets/markdown.rs @@ -0,0 +1,15 @@ +//! Streaming markdown widget placeholder. +//! +//! This file is the landing zone for `markdown_stream` integration to keep +//! rendering logic out of `history_cell.rs`. + +use ratatui::layout::Rect; +use ratatui::Frame; + +use crate::markdown_stream::MarkdownStream; + +/// Render a markdown stream into the provided frame area. +pub fn render_stream(frame: &mut Frame, area: Rect, stream: &MarkdownStream) { + let _ = (frame, area, stream); + // TODO: reuse MarkdownRenderer and add code block scroll support. +} diff --git a/crates/miyabi-tui/src/ui/widgets/mod.rs b/crates/miyabi-tui/src/ui/widgets/mod.rs new file mode 100644 index 0000000..ebccc98 --- /dev/null +++ b/crates/miyabi-tui/src/ui/widgets/mod.rs @@ -0,0 +1,7 @@ +//! Collection of reusable widgets for the TUI. + +pub mod diff; +pub mod history_list; +pub mod markdown; + +pub use history_list::{HistoryList, HistoryListProps}; diff --git a/crates/miyabi-tui/src/update/mod.rs b/crates/miyabi-tui/src/update/mod.rs new file mode 100644 index 0000000..5fe681d --- /dev/null +++ b/crates/miyabi-tui/src/update/mod.rs @@ -0,0 +1,5 @@ +//! Update loop helpers. + +pub mod reducer; + +pub use reducer::reduce; diff --git a/crates/miyabi-tui/src/update/reducer.rs b/crates/miyabi-tui/src/update/reducer.rs new file mode 100644 index 0000000..158da2c --- /dev/null +++ b/crates/miyabi-tui/src/update/reducer.rs @@ -0,0 +1,20 @@ +//! Pure update logic for `AppState`. + +use crate::app::state::AppState; +use crate::domain::actions::AppAction; + +/// Apply an action to the application state. +pub fn reduce(state: &mut AppState, action: &AppAction) { + match action { + AppAction::Quit => state.should_quit = true, + AppAction::CancelStreaming => state.is_streaming = false, + AppAction::Resize { .. } => {} + AppAction::Tick => {} + AppAction::ToggleSidebar => {} + AppAction::ToggleAgentMode => {} + AppAction::SendMessage { .. } => {} + AppAction::ExecuteCommand { .. } => {} + AppAction::ApproveTool { .. } => {} + AppAction::KeyPressed(_) => {} + } +} diff --git a/crates/miyabi-tui/src/views.rs b/crates/miyabi-tui/src/views.rs index d31e8d3..ea665ad 100644 --- a/crates/miyabi-tui/src/views.rs +++ b/crates/miyabi-tui/src/views.rs @@ -5,6 +5,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ prelude::*, + style::Modifier, widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, }; use std::time::Instant; @@ -16,11 +17,12 @@ use crate::{ help::{HelpAction, HelpViewer}, history_cell::HistoryCell, notification::{Alert, AlertAction, Notification, NotificationCenter, NotificationPanelAction}, - pager_overlay::{PagerAction, PagerOverlay, PagerContent}, + pager_overlay::{PagerAction, PagerContent, PagerOverlay}, resume_picker::{ResumePicker, ResumePickerAction, SessionEntry}, shimmer::Spinner, ui::{colors, Breadcrumb, StatusBar, StatusItem}, }; +use miyabi_core::anthropic::DEFAULT_MODEL; /// Focus area in the main view #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -92,6 +94,8 @@ pub enum ViewAction { OpenFile(String), /// Copy to clipboard Copy(String), + /// Toggle agent mode + ToggleAgentMode, } /// Layout configuration @@ -118,7 +122,7 @@ impl Default for LayoutConfig { sidebar_width: 25, show_status_bar: true, show_breadcrumb: true, - input_height: 3, + input_height: 5, min_history_height: 10, } } @@ -142,6 +146,8 @@ pub struct MainView { pub history_scroll: usize, /// Maximum scroll position pub max_scroll: usize, + /// Auto-follow history (stick to latest messages unless user scrolls) + pub history_follow_latest: bool, /// Notification center pub notifications: NotificationCenter, /// Command popup @@ -170,6 +176,8 @@ pub struct MainView { pub sidebar_items: Vec, /// Selected sidebar item pub sidebar_selected: usize, + /// Mode indicator (e.g., "🤖 AGENT") + pub mode_indicator: String, } impl Default for MainView { @@ -190,9 +198,10 @@ impl MainView { history: Vec::new(), history_scroll: 0, max_scroll: 0, + history_follow_latest: true, notifications: NotificationCenter::new(), - command_popup: CommandPopup::new(), - help_viewer: HelpViewer::new(), + command_popup: CommandPopup::new().with_default_commands(), + help_viewer: HelpViewer::with_defaults(), approval_overlay: ApprovalOverlay::new(), pager_overlay: PagerOverlay::new(), session_picker: ResumePicker::new(), @@ -201,10 +210,11 @@ impl MainView { .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| "~".to_string()), session_name: "New Session".to_string(), - model_name: "claude-sonnet-4-20250514".to_string(), + model_name: DEFAULT_MODEL.to_string(), tokens_used: 0, last_activity: Instant::now(), sidebar_items: Vec::new(), + mode_indicator: String::new(), sidebar_selected: 0, } } @@ -236,6 +246,7 @@ impl MainView { /// Show help viewer pub fn show_help(&mut self) { self.overlay = ActiveOverlay::Help; + self.help_viewer.show(); } /// Show approval dialog @@ -304,6 +315,11 @@ impl MainView { }; } + /// Set mode indicator text + pub fn set_mode_indicator(&mut self, indicator: &str) { + self.mode_indicator = indicator.to_string(); + } + /// Handle keyboard input pub fn handle_key(&mut self, key: KeyEvent) -> ViewAction { self.last_activity = Instant::now(); @@ -342,6 +358,10 @@ impl MainView { self.show_notifications(); return ViewAction::None; } + // Toggle agent mode + (KeyModifiers::CONTROL, KeyCode::Char('a')) => { + return ViewAction::ToggleAgentMode; + } // Escape (KeyModifiers::NONE, KeyCode::Esc) => { if self.mode == AppMode::Streaming { @@ -362,85 +382,70 @@ impl MainView { /// Handle overlay keyboard input fn handle_overlay_key(&mut self, key: KeyEvent) -> ViewAction { match self.overlay { - ActiveOverlay::CommandPalette => { - match self.command_popup.handle_key(key) { - CommandPopupAction::Execute(cmd) => { - self.close_overlay(); - return ViewAction::ExecuteCommand(cmd); - } - CommandPopupAction::Cancel => { - self.close_overlay(); - } - _ => {} + ActiveOverlay::CommandPalette => match self.command_popup.handle_key(key) { + CommandPopupAction::Execute(cmd) => { + self.close_overlay(); + return ViewAction::ExecuteCommand(cmd); } - } - ActiveOverlay::Help => { - match self.help_viewer.handle_key(key) { - HelpAction::Close => { - self.close_overlay(); - } - _ => {} + CommandPopupAction::Cancel => { + self.close_overlay(); } - } - ActiveOverlay::Approval => { - match self.approval_overlay.handle_key(key) { - ApprovalAction::Approve(id) | ApprovalAction::ApproveAll(id) => { - self.close_overlay(); - return ViewAction::Approve { - request_id: id, - approved: true, - }; - } - ApprovalAction::Reject(id) => { - self.close_overlay(); - return ViewAction::Approve { - request_id: id, - approved: false, - }; - } - _ => {} + _ => {} + }, + ActiveOverlay::Help => if self.help_viewer.handle_key(key) == HelpAction::Close { + self.close_overlay(); + }, + ActiveOverlay::Approval => match self.approval_overlay.handle_key(key) { + ApprovalAction::Approve(id) | ApprovalAction::ApproveAll(id) => { + self.close_overlay(); + return ViewAction::Approve { + request_id: id, + approved: true, + }; } - } - ActiveOverlay::Pager => { - match self.pager_overlay.handle_key(key) { - PagerAction::Close => { - self.close_overlay(); - } - PagerAction::Copy(content) => { - return ViewAction::Copy(content); - } - _ => {} + ApprovalAction::Reject(id) => { + self.close_overlay(); + return ViewAction::Approve { + request_id: id, + approved: false, + }; } - } - ActiveOverlay::SessionPicker => { - match self.session_picker.handle_key(key) { - ResumePickerAction::Select(session_id) => { - self.close_overlay(); - return ViewAction::ResumeSession(session_id); - } - ResumePickerAction::Cancel => { - self.close_overlay(); - } - _ => {} + _ => {} + }, + ActiveOverlay::Pager => match self.pager_overlay.handle_key(key) { + PagerAction::Close => { + self.close_overlay(); } - } - ActiveOverlay::Notifications => { - match self.notifications.panel.handle_key(key) { - NotificationPanelAction::Close => { - self.close_overlay(); - } - NotificationPanelAction::Dismiss(id) => { - self.notifications.panel.dismiss(&id); - } - NotificationPanelAction::DismissAll => { - self.notifications.panel.dismiss_all(); - } - NotificationPanelAction::MarkAllRead => { - self.notifications.panel.mark_all_read(); - } - _ => {} + PagerAction::Copy(content) => { + return ViewAction::Copy(content); } - } + _ => {} + }, + ActiveOverlay::SessionPicker => match self.session_picker.handle_key(key) { + ResumePickerAction::Select(session_id) => { + self.close_overlay(); + return ViewAction::ResumeSession(session_id); + } + ResumePickerAction::Cancel => { + self.close_overlay(); + } + _ => {} + }, + ActiveOverlay::Notifications => match self.notifications.panel.handle_key(key) { + NotificationPanelAction::Close => { + self.close_overlay(); + } + NotificationPanelAction::Dismiss(id) => { + self.notifications.panel.dismiss(&id); + } + NotificationPanelAction::DismissAll => { + self.notifications.panel.dismiss_all(); + } + NotificationPanelAction::MarkAllRead => { + self.notifications.panel.mark_all_read(); + } + _ => {} + }, ActiveOverlay::Alert => { if let Some(ref mut alert) = self.notifications.alert { match alert.handle_key(key) { @@ -486,32 +491,43 @@ impl MainView { /// Handle history navigation keys fn handle_history_key(&mut self, key: KeyEvent) -> ViewAction { + let mut moved = false; match key.code { KeyCode::Up | KeyCode::Char('k') => { self.history_scroll = self.history_scroll.saturating_sub(1); + moved = true; } KeyCode::Down | KeyCode::Char('j') => { if self.history_scroll < self.max_scroll { self.history_scroll += 1; + moved = true; } } KeyCode::PageUp => { self.history_scroll = self.history_scroll.saturating_sub(10); + moved = true; } KeyCode::PageDown => { self.history_scroll = (self.history_scroll + 10).min(self.max_scroll); + moved = true; } KeyCode::Home | KeyCode::Char('g') => { self.history_scroll = 0; + moved = true; } KeyCode::End | KeyCode::Char('G') => { self.history_scroll = self.max_scroll; + moved = true; } KeyCode::Tab | KeyCode::Char('i') => { self.focus = FocusArea::Chat; } _ => {} } + if moved { + // Disable auto-follow when the user scrolls away; re-enable when they return to the bottom. + self.history_follow_latest = self.history_scroll >= self.max_scroll; + } ViewAction::None } @@ -633,7 +649,8 @@ impl MainView { frame.render_widget(block, area); // Render sidebar items - let items: Vec = self.sidebar_items + let items: Vec = self + .sidebar_items .iter() .enumerate() .map(|(i, item)| { @@ -660,13 +677,13 @@ impl MainView { /// Render message history fn render_history(&mut self, frame: &mut Frame, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(if self.focus == FocusArea::History { + let block = Block::default().borders(Borders::ALL).border_style( + if self.focus == FocusArea::History { Style::default().fg(colors::CYAN) } else { Style::default().fg(colors::BORDER) - }); + }, + ); let inner = block.inner(area); frame.render_widget(block, area); @@ -704,13 +721,13 @@ impl MainView { let visible_lines = inner.height as usize; self.max_scroll = total_lines.saturating_sub(visible_lines); + if self.history_follow_latest { + self.history_scroll = self.max_scroll; + } + // Apply scroll let start = self.history_scroll.min(self.max_scroll); - let visible: Vec = lines - .into_iter() - .skip(start) - .take(visible_lines) - .collect(); + let visible: Vec = lines.into_iter().skip(start).take(visible_lines).collect(); let paragraph = Paragraph::new(visible); frame.render_widget(paragraph, inner); @@ -721,13 +738,8 @@ impl MainView { .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("↑")) .end_symbol(Some("↓")); - let mut scrollbar_state = ScrollbarState::new(total_lines) - .position(start); - frame.render_stateful_widget( - scrollbar, - area, - &mut scrollbar_state, - ); + let mut scrollbar_state = ScrollbarState::new(total_lines).position(start); + frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state); } } @@ -736,7 +748,8 @@ impl MainView { let is_focused = self.focus == FocusArea::Chat; // Update focused state for the composer - self.chat.set_focused(is_focused && self.mode == AppMode::Normal); + self.chat + .set_focused(is_focused && self.mode == AppMode::Normal); // Use ChatComposer's built-in render which handles cursor display self.chat.render(frame, area); @@ -744,11 +757,20 @@ impl MainView { /// Render status bar fn render_status_bar(&self, frame: &mut Frame, area: Rect) { - let mut status_bar = StatusBar::new() - .left( - StatusItem::new(self.model_name.clone()) - .style(Style::default().fg(colors::CYAN)), + let mut status_bar = StatusBar::new().left( + StatusItem::new(self.model_name.clone()).style(Style::default().fg(colors::CYAN)), + ); + + // Mode indicator (e.g., AGENT) + if !self.mode_indicator.is_empty() { + status_bar = status_bar.left( + StatusItem::new(self.mode_indicator.clone()).style( + Style::default() + .fg(colors::MAGENTA) + .add_modifier(Modifier::BOLD), + ), ); + } // Token count if self.tokens_used > 0 { @@ -762,8 +784,7 @@ impl MainView { let unread = self.notifications.unread_count(); if unread > 0 { status_bar = status_bar.left( - StatusItem::new(format!("{}N", unread)) - .style(Style::default().fg(colors::YELLOW)), + StatusItem::new(format!("{}N", unread)).style(Style::default().fg(colors::YELLOW)), ); } @@ -774,15 +795,14 @@ impl MainView { AppMode::WaitingApproval => "APPROVAL", AppMode::Loading => "LOADING", }; - status_bar = status_bar.right( - StatusItem::new(mode_str) - .style(Style::default().fg(match self.mode { - AppMode::Normal => colors::GREEN, - AppMode::Streaming => colors::YELLOW, - AppMode::WaitingApproval => colors::ORANGE, - AppMode::Loading => colors::CYAN, - })), - ); + status_bar = status_bar.right(StatusItem::new(mode_str).style(Style::default().fg( + match self.mode { + AppMode::Normal => colors::GREEN, + AppMode::Streaming => colors::YELLOW, + AppMode::WaitingApproval => colors::ORANGE, + AppMode::Loading => colors::CYAN, + }, + ))); status_bar.render(frame, area); } @@ -812,7 +832,9 @@ impl MainView { } ActiveOverlay::Notifications => { let popup_area = centered_rect(60, 70, area); - self.notifications.panel.render(popup_area, frame.buffer_mut()); + self.notifications + .panel + .render(popup_area, frame.buffer_mut()); } ActiveOverlay::Alert => { if let Some(ref alert) = self.notifications.alert {