feat: Add config system, agent mode, and session management

- Add comprehensive Config system with TOML support (~/.miyabi/config.toml)
- Add Agent module for autonomous task execution with tool approval
- Add Session management for conversation persistence
- Extend CLI with new commands: init, sessions, agent, version
- Add CLI flags: --model, --max-tokens, --thinking, --config, --session
- Fix all clippy warnings (16 → 0)
- Improve code quality with collapsible pattern matching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2025-11-22 22:22:11 +09:00
parent af281f1ed0
commit 25d358f96e
56 changed files with 4842 additions and 910 deletions

View file

@ -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

515
Cargo.lock generated
View file

@ -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",
]

View file

@ -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

135
README.md
View file

@ -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)

View file

@ -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 }

View file

@ -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<String>,
/// Maximum tokens for responses (overrides config)
#[arg(long)]
max_tokens: Option<u32>,
/// Enable Extended Thinking (Claude 4.5+)
#[arg(long)]
thinking: bool,
/// Path to config file
#[arg(short, long)]
config: Option<PathBuf>,
/// Session ID to load on startup
#[arg(short, long)]
session: Option<String>,
#[command(subcommand)]
command: Option<Commands>,
}
@ -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<String>,
/// Export a session to JSON file
#[arg(short, long)]
export: Option<String>,
/// Export a session to Markdown file
#[arg(short, long)]
markdown: Option<String>,
},
/// 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<String>,
},
}
#[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()
}
}

View file

@ -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"

View file

@ -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<String>,
}
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<String>,
event_tx: Option<mpsc::Sender<AgentEvent>>,
}
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<AgentEvent>) -> 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::<Vec<_>>()
.join("\n")
}
/// Extract tool uses from response
fn extract_tool_uses(&self, content: &[ContentBlock]) -> Vec<ToolUse> {
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<AgentResult, AgentError> {
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");
}
}

View file

@ -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<Message>,
}
/// 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<crate::AnthropicError> for AgentError {
fn from(err: crate::AnthropicError) -> Self {
AgentError::ApiError(err.to_string())
}
}
impl From<serde_json::Error> for AgentError {
fn from(err: serde_json::Error) -> Self {
AgentError::SerializationError(err.to_string())
}
}
impl From<crate::ToolError> for AgentError {
fn from(err: crate::ToolError) -> Self {
AgentError::ToolExecutionFailed(err.to_string())
}
}

View file

@ -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<ToolOutput, ToolError>;
/// 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<T: ToolTrait> {
tool: T,
risk_level: RiskLevel,
}
impl<T: ToolTrait> ToolExecutorAdapter<T> {
pub fn new(tool: T, risk_level: RiskLevel) -> Self {
Self { tool, risk_level }
}
}
#[async_trait]
impl<T: ToolTrait + Send + Sync + 'static> ToolExecutor for ToolExecutorAdapter<T> {
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<ToolOutput, ToolError> {
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<String, Arc<dyn ToolExecutor>>,
}
impl ExecutorRegistry {
/// Create empty registry
pub fn new() -> Self {
Self {
executors: HashMap::new(),
}
}
/// Register a tool executor
pub fn register<T: ToolExecutor + 'static>(&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<Arc<dyn ToolExecutor>> {
self.executors.get(name).cloned()
}
/// Execute tool by name
pub async fn execute(&self, name: &str, input: Value) -> Result<ToolOutput, ToolError> {
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<ApiTool> {
self.executors.values().map(|e| e.definition()).collect()
}
/// Get tool names
pub fn tool_names(&self) -> Vec<String> {
self.executors.keys().cloned().collect()
}
/// Get risk level for a tool
pub fn risk_level(&self, name: &str) -> Option<RiskLevel> {
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(),
}
}
}

View file

@ -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};

View file

@ -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<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<bool>,
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<HeaderMap> {
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<StreamEvent> {
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 {

View file

@ -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<String>,
/// 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<String>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<Self> {
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<Self> {
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<PathBuf> {
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]"));
}
}

View file

@ -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(),

View file

@ -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::*;

View file

@ -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<Utc>,
/// Last updated timestamp
pub updated_at: DateTime<Utc>,
/// Model used
pub model: String,
/// Token usage
pub tokens_used: usize,
/// Session tags
pub tags: Vec<String>,
/// Conversation history
pub messages: Vec<Message>,
/// System prompt used
pub system_prompt: Option<String>,
}
impl Session {
/// Create a new session
pub fn new(title: impl Into<String>) -> 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<String>, title: impl Into<String>) -> 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<String>) -> Self {
self.model = model.into();
self
}
/// Set system prompt
pub fn system_prompt(mut self, prompt: impl Into<String>) -> 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<PathBuf>) -> 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<Session> {
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<Vec<Session>> {
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::<Session>(&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<Vec<SessionMetadata>> {
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<Utc>,
pub updated_at: DateTime<Utc>,
pub model: String,
pub tokens_used: usize,
pub message_count: usize,
pub preview: String,
pub tags: Vec<String>,
}
impl From<Session> 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);
}
}

View file

@ -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);
}

View file

@ -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<f64> {
let durations: Vec<u64> = self.records
.iter()
.filter_map(|r| r.duration_ms)
.collect();
let durations: Vec<u64> = 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<ToolRegistry>,
event_tx: mpsc::Sender<ExecutionEvent>,
) -> Self {
pub fn new(registry: Arc<ToolRegistry>, event_tx: mpsc::Sender<ExecutionEvent>) -> 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::<Vec<_>>()
@ -851,7 +881,10 @@ impl ToolExecutor {
}
/// Execute calls respecting dependencies (DAG execution)
pub async fn execute_dag(&self, calls: Vec<ToolCall>) -> HashMap<String, ToolResult<ToolOutput>> {
pub async fn execute_dag(
&self,
calls: Vec<ToolCall>,
) -> HashMap<String, ToolResult<ToolOutput>> {
let mut results: HashMap<String, ToolResult<ToolOutput>> = HashMap::new();
let mut completed: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut remaining: Vec<ToolCall> = 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]

View file

@ -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<ParameterDef> {
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(&regex_pattern).map_err(|e| {
ToolError::InvalidInput(format!("Invalid regex pattern: {}", e))
})?;
let regex = Regex::new(&regex_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::<Vec<_>>().join("\n");
let content = (1..=10)
.map(|i| format!("Line {}", i))
.collect::<Vec<_>>()
.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]

View file

@ -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<String>, content: impl Into<String>, call_id: impl Into<String>) -> Self {
pub fn tool(
name: impl Into<String>,
content: impl Into<String>,
call_id: impl Into<String>,
) -> Self {
Self {
role: Role::Tool,
content: content.into(),

View file

@ -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"]

View file

@ -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<Message>,
/// Whether currently streaming a response
is_streaming: bool,
/// Tool registry for executing tools
tool_registry: ToolRegistry,
/// Pending tools awaiting approval
pending_tools: Vec<PendingTool>,
/// Current session
session: Session,
/// Session storage for persistence
storage: SessionStorage,
/// System prompt for API requests
system_prompt: Option<String>,
/// Agent mode (autonomous execution)
agent_mode: bool,
/// API key for agent mode
api_key: Option<String>,
/// 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<B: ratatui::backend::Backend>(
&mut self,
terminal: &mut ratatui::Terminal<impl ratatui::backend::Backend>,
terminal: &mut ratatui::Terminal<B>,
) -> 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<B: ratatui::backend::Backend>(
&mut self,
message: String,
terminal: &mut ratatui::Terminal<B>,
) {
// 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::<AssistantMessageCell>() {
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>() {
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>() {
assistant_cell.content = format!("Error: {}", e);
assistant_cell.streaming = false;
if let Some(assistant_cell) =
(**cell).as_any_mut().downcast_mut::<AssistantMessageCell>()
{
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<B: ratatui::backend::Backend>(
&mut self,
message: String,
terminal: &mut ratatui::Terminal<B>,
) {
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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<B: ratatui::backend::Backend>(
&mut self,
request_id: &str,
approved: bool,
terminal: &mut ratatui::Terminal<B>,
) {
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<B: ratatui::backend::Backend>(
&mut self,
client: AnthropicClient,
terminal: &mut ratatui::Terminal<B>,
) {
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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::<AssistantMessageCell>()
{
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 {

View file

@ -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<AppAction> {
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,
}
}

View file

@ -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"))
}
}

View file

@ -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();

View file

@ -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<CursorPos>,
}
@ -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<Line> = self.rich_suggestions
let items: Vec<Line> = 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<Line> = self.suggestions
let items: Vec<Line> = 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<String>, category: SuggestionCategory, description: Option<String>) {
pub fn add_suggestion(
&mut self,
text: impl Into<String>,
category: SuggestionCategory,
description: Option<String>,
) {
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() {

View file

@ -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

View file

@ -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()

View file

@ -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]

View file

@ -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,
}

View file

@ -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};

View file

@ -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,
}

View file

@ -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);
}

View file

@ -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<Line<'static>> {
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!("{:<width$}", content_str, width = content_width);
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::raw(padded),
Span::styled("", Style::default().fg(Color::Cyan)),
]));
lines.push(wrapped_line);
}
}
// Bottom border
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(border, Style::default().fg(Color::Cyan)),
Span::styled("", Style::default().fg(Color::Cyan)),
]));
lines
}
@ -88,6 +69,10 @@ impl HistoryCell for UserMessageCell {
&self.timestamp
}
fn as_any(&self) -> &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<MarkdownStream>,
pub timestamp: String,
pub streaming: bool,
}
impl HistoryCell for AssistantMessageCell {
fn render(&self, width: u16) -> Vec<Line<'static>> {
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<Line<'static>> {
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<String>,
}
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<Line<'static>> {
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!("{:<width$}", content_str, width = content_width);
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(border_color)),
Span::styled(padded, Style::default().add_modifier(Modifier::DIM)),
Span::styled("", Style::default().fg(border_color)),
]));
lines.push(wrapped_line);
}
}
// Bottom border
lines.push(Line::from(vec![
Span::styled("", Style::default().fg(border_color)),
Span::styled(border, Style::default().fg(border_color)),
Span::styled("", Style::default().fg(border_color)),
]));
lines
}
@ -244,6 +293,10 @@ impl HistoryCell for ToolResultCell {
&self.timestamp
}
fn as_any(&self) -> &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
}

View file

@ -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<AppAction> {
let map = default_keymap();
map.get(&event)
.cloned()
.or(Some(AppAction::KeyPressed(event)))
}

View file

@ -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<KeyEvent, AppAction> {
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
}

View file

@ -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};

View file

@ -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};

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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();
}

View file

@ -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<usize> = self.headers
let mut widths: Vec<usize> = 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::<Vec<_>>()
.join("")
@ -174,7 +182,8 @@ impl TableState {
lines.push(Line::from(Span::styled(top_border, border_style)));
// Header row
let header_cells: Vec<String> = self.headers
let header_cells: Vec<String> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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"
);
}
}

View file

@ -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<String>, message: impl Into<String>) {
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<String>, message: impl Into<String>) {
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<String>, message: impl Into<String>) {
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));

View file

@ -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::<Vec<_>>().join("\n");
let content = (0..100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.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::<Vec<_>>().join("\n");
let content = (0..100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.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::<Vec<_>>().join("\n");
let content = (0..100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.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::<Vec<_>>().join("\n");
let content = (0..100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.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::<Vec<_>>().join("\n");
let content = (0..100)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.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");
}

View file

@ -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();

View file

@ -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)));
}

View file

@ -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!(
"{}{}{}",

View file

@ -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);
}

View file

@ -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)
}
}

View file

@ -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.
}

View file

@ -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<dyn HistoryCell>],
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.
}
}

View file

@ -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.
}

View file

@ -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};

View file

@ -0,0 +1,5 @@
//! Update loop helpers.
pub mod reducer;
pub use reducer::reduce;

View file

@ -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(_) => {}
}
}

View file

@ -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<String>,
/// 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<Line> = self.sidebar_items
let items: Vec<Line> = 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<Line> = lines
.into_iter()
.skip(start)
.take(visible_lines)
.collect();
let visible: Vec<Line> = 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 {