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 settings (use environment variables for sensitive data)
# github_token: ${{ GITHUB_TOKEN }} # github_token: ${{ GITHUB_TOKEN }}
# LLM Configuration
llm:
provider: anthropic
model: claude-sonnet-4-20250514
api_key: ${{ ANTHROPIC_API_KEY }}
# Agent settings # Agent settings
agents: agents:
enabled: true 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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@ -138,6 +158,18 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 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]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.0" version = "1.11.0"
@ -229,6 +261,15 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" 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]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.4" version = "1.0.4"
@ -327,6 +368,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -392,6 +439,37 @@ dependencies = [
"syn", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -443,12 +521,47 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.5" version = "0.1.5"
@ -590,6 +703,16 @@ dependencies = [
"slab", "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]] [[package]]
name = "getopts" name = "getopts"
version = "0.2.24" version = "0.2.24"
@ -647,6 +770,17 @@ dependencies = [
"tracing", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -922,6 +1056,20 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.12.1" version = "2.12.1"
@ -1013,6 +1161,16 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" 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]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
version = "0.5.6" version = "0.5.6"
@ -1120,6 +1278,7 @@ dependencies = [
"miyabi-core", "miyabi-core",
"miyabi-tui", "miyabi-tui",
"ratatui", "ratatui",
"serde_json",
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@ -1132,6 +1291,7 @@ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"chrono", "chrono",
"dirs",
"futures", "futures",
"glob", "glob",
"regex", "regex",
@ -1139,8 +1299,9 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
"thiserror", "thiserror 2.0.17",
"tokio", "tokio",
"toml",
"tracing", "tracing",
"uuid", "uuid",
] ]
@ -1150,6 +1311,7 @@ name = "miyabi-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard",
"chrono", "chrono",
"crossterm 0.29.0", "crossterm 0.29.0",
"futures", "futures",
@ -1161,12 +1323,22 @@ dependencies = [
"serde_json", "serde_json",
"syntect", "syntect",
"textwrap", "textwrap",
"thiserror", "thiserror 2.0.17",
"tokio", "tokio",
"unicode-width 0.2.0", "unicode-width 0.2.0",
"uuid", "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]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@ -1208,6 +1380,79 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -1286,6 +1531,12 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@ -1352,6 +1603,19 @@ dependencies = [
"time", "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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@ -1395,6 +1659,21 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" 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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@ -1449,6 +1728,17 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "regex" name = "regex"
version = "1.12.2" version = "1.12.2"
@ -1696,6 +1986,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@ -1880,7 +2179,7 @@ dependencies = [
"serde", "serde",
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"thiserror", "thiserror 2.0.17",
"walkdir", "walkdir",
"yaml-rust", "yaml-rust",
] ]
@ -1930,13 +2229,33 @@ dependencies = [
"unicode-width 0.2.0", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.17" version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [ 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]] [[package]]
@ -1959,6 +2278,20 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.44"
@ -2060,6 +2393,47 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.2" version = "0.5.2"
@ -2388,6 +2762,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@ -2489,6 +2869,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@ -2525,6 +2914,21 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@ -2558,6 +2962,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@ -2570,6 +2980,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@ -2582,6 +2998,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@ -2606,6 +3028,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@ -2618,6 +3046,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@ -2630,6 +3064,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@ -2642,6 +3082,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@ -2654,6 +3100,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.46.0" version = "0.46.0"
@ -2666,6 +3121,23 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 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]] [[package]]
name = "yaml-rust" name = "yaml-rust"
version = "0.4.5" version = "0.4.5"
@ -2698,6 +3170,26 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.6" version = "0.1.6"
@ -2757,3 +3249,18 @@ dependencies = [
"quote", "quote",
"syn", "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 - [ ] System prompts
- [ ] Tool definitions - [ ] 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 ## Phase 7: Tool Execution

135
README.md
View file

@ -1,2 +1,135 @@
# miyabi-cli-standalone # 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 # Error Handling
anyhow = { workspace = true } anyhow = { workspace = true }
# Serialization
serde_json = { workspace = true }
# Logging # Logging
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }

View file

@ -1,12 +1,33 @@
//! Miyabi CLI - Main entry point //! Miyabi CLI - Main entry point
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "miyabi")] #[command(name = "miyabi")]
#[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)] #[command(author, version, about = "Miyabi - Autonomous AI Development Framework", long_about = None)]
struct Cli { 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(subcommand)]
command: Option<Commands>, command: Option<Commands>,
} }
@ -17,6 +38,39 @@ enum Commands {
Tui, Tui,
/// Show status /// Show status
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] #[tokio::main]
@ -31,22 +85,51 @@ async fn main() -> anyhow::Result<()> {
match cli.command { match cli.command {
Some(Commands::Tui) | None => { Some(Commands::Tui) | None => {
// Run TUI // Run TUI
use miyabi_tui::App;
use crossterm::{ use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture}, event::{DisableMouseCapture, EnableMouseCapture},
execute, 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 ratatui::prelude::*;
use std::io; 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()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; 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; let res = app.run(&mut terminal).await;
disable_raw_mode()?; disable_raw_mode()?;
@ -63,8 +146,299 @@ async fn main() -> anyhow::Result<()> {
} }
Some(Commands::Status) => { Some(Commands::Status) => {
println!("Miyabi Status: Ready"); 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(()) 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 } async-trait = { workspace = true }
glob = { workspace = true } glob = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
dirs = "5"
toml = "0.8"
[dev-dependencies] [dev-dependencies]
tempfile = "3" 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"; const API_BASE_URL: &str = "https://api.anthropic.com";
/// Default model to use /// 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 /// Maximum retry attempts for transient errors
const MAX_RETRIES: u32 = 3; const MAX_RETRIES: u32 = 3;
@ -127,9 +127,18 @@ pub enum Role {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock { pub enum ContentBlock {
Text { text: String }, Text {
ToolUse { id: String, name: String, input: serde_json::Value }, text: String,
ToolResult { tool_use_id: String, content: String }, },
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: String,
},
} }
/// A message in a conversation /// A message in a conversation
@ -177,6 +186,8 @@ pub struct MessagesRequest {
pub tools: Option<Vec<Tool>>, pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>, pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<bool>,
pub stream: bool, pub stream: bool,
} }
@ -188,6 +199,7 @@ pub enum StopReason {
MaxTokens, MaxTokens,
StopSequence, StopSequence,
ToolUse, ToolUse,
ModelContextWindowExceeded,
} }
/// Usage statistics /// Usage statistics
@ -216,7 +228,10 @@ pub enum StreamEvent {
/// Message started /// Message started
MessageStart { message: MessagesResponse }, MessageStart { message: MessagesResponse },
/// Content block started /// Content block started
ContentBlockStart { index: usize, content_block: ContentBlock }, ContentBlockStart {
index: usize,
content_block: ContentBlock,
},
/// Text delta in content /// Text delta in content
ContentBlockDelta { index: usize, delta: TextDelta }, ContentBlockDelta { index: usize, delta: TextDelta },
/// Content block finished /// Content block finished
@ -252,6 +267,7 @@ pub struct AnthropicClient {
api_key: String, api_key: String,
model: String, model: String,
max_tokens: u32, max_tokens: u32,
thinking: bool,
} }
impl AnthropicClient { impl AnthropicClient {
@ -274,6 +290,7 @@ impl AnthropicClient {
api_key, api_key,
model: DEFAULT_MODEL.to_string(), model: DEFAULT_MODEL.to_string(),
max_tokens: 4096, max_tokens: 4096,
thinking: false,
}) })
} }
@ -289,6 +306,12 @@ impl AnthropicClient {
self self
} }
/// Enable or disable extended thinking
pub fn with_thinking(mut self, thinking: bool) -> Self {
self.thinking = thinking;
self
}
/// Build request headers /// Build request headers
fn build_headers(&self) -> Result<HeaderMap> { fn build_headers(&self) -> Result<HeaderMap> {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@ -298,10 +321,7 @@ impl AnthropicClient {
HeaderValue::from_str(&self.api_key) HeaderValue::from_str(&self.api_key)
.map_err(|_| AnthropicError::ConfigError("Invalid API key format".to_string()))?, .map_err(|_| AnthropicError::ConfigError("Invalid API key format".to_string()))?,
); );
headers.insert( headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01"));
"anthropic-version",
HeaderValue::from_static("2023-06-01"),
);
Ok(headers) Ok(headers)
} }
@ -320,6 +340,7 @@ impl AnthropicClient {
system, system,
tools, tools,
temperature, temperature,
thinking: self.thinking.then_some(true),
stream: false, stream: false,
}; };
@ -356,7 +377,7 @@ impl AnthropicClient {
} }
Err(last_error.unwrap_or(AnthropicError::StreamError( Err(last_error.unwrap_or(AnthropicError::StreamError(
"Max retries exceeded".to_string() "Max retries exceeded".to_string(),
))) )))
} }
@ -441,6 +462,7 @@ impl AnthropicClient {
system, system,
tools, tools,
temperature, temperature,
thinking: self.thinking.then_some(true),
stream: true, stream: true,
}; };
@ -465,29 +487,35 @@ impl AnthropicClient {
let stream = response.bytes_stream(); let stream = response.bytes_stream();
Ok(Box::pin(stream.scan(String::new(), |buffer, chunk| { Ok(Box::pin(
let result = match chunk { stream
Ok(bytes) => { .scan(String::new(), |buffer, chunk| {
buffer.push_str(&String::from_utf8_lossy(&bytes)); 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 // Parse SSE events from buffer
while let Some(event_end) = buffer.find("\n\n") { while let Some(event_end) = buffer.find("\n\n") {
let event_data = buffer[..event_end].to_string(); let event_data = buffer[..event_end].to_string();
*buffer = buffer[event_end + 2..].to_string(); *buffer = buffer[event_end + 2..].to_string();
if let Some(event) = parse_sse_event(&event_data) { if let Some(event) = parse_sse_event(&event_data) {
events.push(Ok(event)); events.push(Ok(event));
}
}
Some(futures::stream::iter(events))
} }
} Err(e) => Some(futures::stream::iter(vec![Err(
AnthropicError::NetworkError(e),
Some(futures::stream::iter(events)) )])),
} };
Err(e) => Some(futures::stream::iter(vec![Err(AnthropicError::NetworkError(e))])), async move { result }
}; })
async move { result } .flatten(),
}).flatten())) ))
} }
} }
@ -510,14 +538,19 @@ fn parse_sse_event(event_data: &str) -> Option<StreamEvent> {
match event_type.as_str() { match event_type.as_str() {
"message_start" => { "message_start" => {
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; 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 }) Some(StreamEvent::MessageStart { message })
} }
"content_block_start" => { "content_block_start" => {
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; let parsed: serde_json::Value = serde_json::from_str(&data).ok()?;
let index = parsed.get("index")?.as_u64()? as usize; let index = parsed.get("index")?.as_u64()? as usize;
let content_block: ContentBlock = serde_json::from_value(parsed.get("content_block")?.clone()).ok()?; let content_block: ContentBlock =
Some(StreamEvent::ContentBlockStart { index, content_block }) serde_json::from_value(parsed.get("content_block")?.clone()).ok()?;
Some(StreamEvent::ContentBlockStart {
index,
content_block,
})
} }
"content_block_delta" => { "content_block_delta" => {
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?; 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()); let auth_err = AnthropicError::AuthError("invalid key".to_string());
assert!(auth_err.to_string().contains("invalid key")); 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")); assert!(rate_err.to_string().contains("5000"));
let api_err = AnthropicError::ApiError { 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 //! This module provides conversation management for maintaining context
//! across multiple interactions with the Claude API. //! across multiple interactions with the Claude API.
use crate::anthropic::{Message, Role, ContentBlock}; use crate::anthropic::{ContentBlock, Message, Role};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
@ -407,8 +407,7 @@ mod tests {
#[test] #[test]
fn test_system_prompt() { fn test_system_prompt() {
let conv = Conversation::new() let conv = Conversation::new().with_system_prompt("You are a helpful assistant");
.with_system_prompt("You are a helpful assistant");
assert_eq!( assert_eq!(
conv.get_system_prompt(), conv.get_system_prompt(),

View file

@ -2,27 +2,45 @@
//! //!
//! This crate provides core types and utilities shared across the Miyabi framework. //! This crate provides core types and utilities shared across the Miyabi framework.
pub mod error; pub mod agent;
pub mod types;
pub mod anthropic; pub mod anthropic;
pub mod tool; pub mod config;
pub mod conversation; pub mod conversation;
pub mod tools; pub mod error;
pub mod session;
pub mod token; pub mod token;
pub mod tool;
pub mod tools;
pub mod types;
pub use error::Error; pub use agent::{
pub use types::*; Agent, AgentConfig, AgentError, AgentEvent, AgentResult, ExecutorRegistry, RiskLevel,
ToolExecutor,
};
pub use anthropic::{ pub use anthropic::{
AnthropicClient, AnthropicError, Message, Role, ContentBlock, AnthropicClient,
MessagesRequest, MessagesResponse, StreamEvent, StopReason, Usage, AnthropicError,
Tool as ApiTool, // Anthropic API tool definition format ContentBlock,
RetryConfig, // Retry configuration for API requests Message,
}; MessagesRequest,
pub use tool::{ MessagesResponse,
Tool as ToolTrait, ToolRegistry, ToolError, ToolOutput, ToolResult, ParameterDef, 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::{ 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 error::Error;
pub use token::{TokenCounter, TokenUsage, ContextManager, ContextUsage, ModelLimits}; 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 //! This module provides token estimation and context window management
//! for Claude API conversations. //! for Claude API conversations.
use crate::anthropic::{ContentBlock, Message}; use crate::anthropic::{ContentBlock, Message, DEFAULT_MODEL};
use crate::conversation::{Conversation, ConversationMessage}; use crate::conversation::{Conversation, ConversationMessage};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -17,6 +17,18 @@ pub struct ModelLimits {
} }
impl 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 /// Claude 3 Opus limits
pub const CLAUDE_3_OPUS: Self = Self { pub const CLAUDE_3_OPUS: Self = Self {
context_window: 200_000, context_window: 200_000,
@ -37,9 +49,15 @@ impl ModelLimits {
/// Get limits for a model name /// Get limits for a model name
pub fn for_model(model: &str) -> Self { 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 Self::CLAUDE_3_OPUS
} else if model.contains("haiku") { } else if lower.contains("haiku") {
Self::CLAUDE_3_HAIKU Self::CLAUDE_3_HAIKU
} else { } else {
Self::CLAUDE_3_SONNET Self::CLAUDE_3_SONNET
@ -94,10 +112,7 @@ impl Default for TokenCounter {
impl TokenCounter { impl TokenCounter {
/// Create a new token counter with default settings /// Create a new token counter with default settings
pub fn new() -> Self { pub fn new() -> Self {
Self { Self::with_model(DEFAULT_MODEL)
limits: ModelLimits::CLAUDE_3_SONNET,
chars_per_token: 4.0,
}
} }
/// Create with specific model limits /// Create with specific model limits
@ -121,9 +136,7 @@ impl TokenCounter {
let input_str = serde_json::to_string(input).unwrap_or_default(); let input_str = serde_json::to_string(input).unwrap_or_default();
self.estimate_text(name) + self.estimate_text(&input_str) + 20 self.estimate_text(name) + self.estimate_text(&input_str) + 20
} }
ContentBlock::ToolResult { content, .. } => { ContentBlock::ToolResult { content, .. } => self.estimate_text(content) + 20,
self.estimate_text(content) + 20
}
} }
} }
@ -324,7 +337,7 @@ mod tests {
// ~4 chars per token // ~4 chars per token
let tokens = counter.estimate_text("Hello, World!"); // 13 chars let tokens = counter.estimate_text("Hello, World!"); // 13 chars
assert!(tokens >= 3 && tokens <= 5); assert!((3..=5).contains(&tokens));
} }
#[test] #[test]
@ -334,13 +347,18 @@ mod tests {
let limits = ModelLimits::for_model("claude-3-sonnet"); let limits = ModelLimits::for_model("claude-3-sonnet");
assert_eq!(limits.context_window, 200_000); 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] #[test]
fn test_conversation_estimation() { fn test_conversation_estimation() {
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let mut conv = Conversation::new() let mut conv = Conversation::new().with_system_prompt("You are a helpful assistant");
.with_system_prompt("You are a helpful assistant");
conv.add_user_message("Hello"); conv.add_user_message("Hello");
conv.add_assistant_message("Hi there!"); conv.add_assistant_message("Hi there!");
@ -352,8 +370,7 @@ mod tests {
#[test] #[test]
fn test_within_limits() { fn test_within_limits() {
let counter = TokenCounter::new(); let counter = TokenCounter::new();
let conv = Conversation::new() let conv = Conversation::new().with_system_prompt("Test");
.with_system_prompt("Test");
assert!(counter.within_limits(&conv)); assert!(counter.within_limits(&conv));
} }
@ -378,13 +395,20 @@ mod tests {
// Add messages that will exceed the limit // Add messages that will exceed the limit
for i in 0..10 { for i in 0..10 {
// Each message is roughly 30+ tokens // 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 initial = conv.message_count();
let removed = manager.prune(&mut conv); 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); assert!(conv.message_count() < initial);
} }

View file

@ -267,7 +267,10 @@ pub trait Tool: Send + Sync {
for param in params { for param in params {
let mut prop = serde_json::Map::new(); let mut prop = serde_json::Map::new();
prop.insert("type".to_string(), Value::String(param.param_type.clone())); 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 { if let Some(default) = param.default {
prop.insert("default".to_string(), default); prop.insert("default".to_string(), default);
@ -633,7 +636,10 @@ impl ExecutionHistory {
/// Get records for a tool /// Get records for a tool
pub fn by_tool(&self, tool_name: &str) -> Vec<&ExecutionRecord> { 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 /// Total execution count
@ -643,20 +649,23 @@ impl ExecutionHistory {
/// Success count /// Success count
pub fn success_count(&self) -> usize { 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 /// Failure count
pub fn failure_count(&self) -> usize { 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) /// Average execution time (for completed executions)
pub fn average_duration_ms(&self) -> Option<f64> { pub fn average_duration_ms(&self) -> Option<f64> {
let durations: Vec<u64> = self.records let durations: Vec<u64> = self.records.iter().filter_map(|r| r.duration_ms).collect();
.iter()
.filter_map(|r| r.duration_ms)
.collect();
if durations.is_empty() { if durations.is_empty() {
None None
@ -685,7 +694,11 @@ pub enum ExecutionEvent {
/// Execution started /// Execution started
Started { call_id: String }, Started { call_id: String },
/// Progress update /// Progress update
Progress { call_id: String, progress: f32, message: String }, Progress {
call_id: String,
progress: f32,
message: String,
},
/// Output chunk (for streaming) /// Output chunk (for streaming)
OutputChunk { call_id: String, chunk: String }, OutputChunk { call_id: String, chunk: String },
/// Execution completed /// Execution completed
@ -712,10 +725,7 @@ pub struct ToolExecutor {
impl ToolExecutor { impl ToolExecutor {
/// Create a new executor /// Create a new executor
pub fn new( pub fn new(registry: Arc<ToolRegistry>, event_tx: mpsc::Sender<ExecutionEvent>) -> Self {
registry: Arc<ToolRegistry>,
event_tx: mpsc::Sender<ExecutionEvent>,
) -> Self {
Self { Self {
registry, registry,
history: Arc::new(RwLock::new(ExecutionHistory::new())), history: Arc::new(RwLock::new(ExecutionHistory::new())),
@ -747,17 +757,23 @@ impl ToolExecutor {
self.history.write().await.add(record); self.history.write().await.add(record);
// Emit queued event // Emit queued event
let _ = self.event_tx.send(ExecutionEvent::Queued { let _ = self
call_id: call_id.clone(), .event_tx
tool_name: tool_name.clone(), .send(ExecutionEvent::Queued {
}).await; call_id: call_id.clone(),
tool_name: tool_name.clone(),
})
.await;
// Check approval requirement // Check approval requirement
if call.requires_approval { if call.requires_approval {
let _ = self.event_tx.send(ExecutionEvent::AwaitingApproval { let _ = self
call_id: call_id.clone(), .event_tx
tool_name: tool_name.clone(), .send(ExecutionEvent::AwaitingApproval {
}).await; call_id: call_id.clone(),
tool_name: tool_name.clone(),
})
.await;
if let Some(record) = self.history.write().await.get_mut(&call_id) { if let Some(record) = self.history.write().await.get_mut(&call_id) {
record.status = ExecutionStatus::AwaitingApproval; record.status = ExecutionStatus::AwaitingApproval;
@ -765,9 +781,12 @@ impl ToolExecutor {
// In a real implementation, we would wait for approval here // In a real implementation, we would wait for approval here
// For now, we auto-approve // For now, we auto-approve
let _ = self.event_tx.send(ExecutionEvent::Approved { let _ = self
call_id: call_id.clone(), .event_tx
}).await; .send(ExecutionEvent::Approved {
call_id: call_id.clone(),
})
.await;
} }
// Mark as running // Mark as running
@ -775,15 +794,19 @@ impl ToolExecutor {
record.start(); record.start();
} }
let _ = self.event_tx.send(ExecutionEvent::Started { let _ = self
call_id: call_id.clone(), .event_tx
}).await; .send(ExecutionEvent::Started {
call_id: call_id.clone(),
})
.await;
// Execute with timeout // Execute with timeout
let result = tokio::time::timeout( let result = tokio::time::timeout(
std::time::Duration::from_millis(self.default_timeout_ms), std::time::Duration::from_millis(self.default_timeout_ms),
self.registry.execute(&call.name, call.input) self.registry.execute(&call.name, call.input),
).await; )
.await;
match result { match result {
Ok(Ok(output)) => { Ok(Ok(output)) => {
@ -792,10 +815,13 @@ impl ToolExecutor {
record.complete(output.clone()); record.complete(output.clone());
} }
let _ = self.event_tx.send(ExecutionEvent::Completed { let _ = self
call_id, .event_tx
output: output.clone(), .send(ExecutionEvent::Completed {
}).await; call_id,
output: output.clone(),
})
.await;
Ok(output) Ok(output)
} }
@ -806,10 +832,13 @@ impl ToolExecutor {
record.fail(&error_msg); record.fail(&error_msg);
} }
let _ = self.event_tx.send(ExecutionEvent::Failed { let _ = self
call_id, .event_tx
error: error_msg, .send(ExecutionEvent::Failed {
}).await; call_id,
error: error_msg,
})
.await;
Err(e) Err(e)
} }
@ -822,10 +851,13 @@ impl ToolExecutor {
record.completed_at = Some(Utc::now()); record.completed_at = Some(Utc::now());
} }
let _ = self.event_tx.send(ExecutionEvent::Failed { let _ = self
call_id, .event_tx
error: error_msg.clone(), .send(ExecutionEvent::Failed {
}).await; call_id,
error: error_msg.clone(),
})
.await;
Err(ToolError::Timeout(self.default_timeout_ms)) Err(ToolError::Timeout(self.default_timeout_ms))
} }
@ -839,9 +871,7 @@ impl ToolExecutor {
let results = stream::iter(calls) let results = stream::iter(calls)
.map(|call| { .map(|call| {
let executor = self.clone_inner(); let executor = self.clone_inner();
async move { async move { executor.execute(call).await }
executor.execute(call).await
}
}) })
.buffer_unordered(self.max_concurrent) .buffer_unordered(self.max_concurrent)
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -851,7 +881,10 @@ impl ToolExecutor {
} }
/// Execute calls respecting dependencies (DAG execution) /// 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 results: HashMap<String, ToolResult<ToolOutput>> = HashMap::new();
let mut completed: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut completed: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut remaining: Vec<ToolCall> = calls; let mut remaining: Vec<ToolCall> = calls;
@ -860,9 +893,7 @@ impl ToolExecutor {
// Find calls with satisfied dependencies // Find calls with satisfied dependencies
let (ready, not_ready): (Vec<_>, Vec<_>) = remaining let (ready, not_ready): (Vec<_>, Vec<_>) = remaining
.into_iter() .into_iter()
.partition(|call| { .partition(|call| call.dependencies.iter().all(|dep| completed.contains(dep)));
call.dependencies.iter().all(|dep| completed.contains(dep))
});
if ready.is_empty() && !not_ready.is_empty() { if ready.is_empty() && !not_ready.is_empty() {
// Circular dependency or missing dependency // Circular dependency or missing dependency
@ -871,8 +902,8 @@ impl ToolExecutor {
results.insert( results.insert(
call.id.clone(), call.id.clone(),
Err(ToolError::ExecutionFailed( Err(ToolError::ExecutionFailed(
"Unresolved dependencies".to_string() "Unresolved dependencies".to_string(),
)) )),
); );
} }
break; break;
@ -1130,7 +1161,10 @@ mod tests {
assert_eq!(schema["type"], "object"); assert_eq!(schema["type"], "object");
assert!(schema["properties"]["message"].is_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] #[tokio::test]
@ -1152,9 +1186,7 @@ mod tests {
async fn test_tool_not_found() { async fn test_tool_not_found() {
let registry = ToolRegistry::new(); let registry = ToolRegistry::new();
let result = registry let result = registry.execute("missing", serde_json::json!({})).await;
.execute("missing", serde_json::json!({}))
.await;
assert!(matches!(result, Err(ToolError::NotFound(_)))); assert!(matches!(result, Err(ToolError::NotFound(_))));
} }
@ -1206,12 +1238,15 @@ mod tests {
#[test] #[test]
fn test_parameter_def_builders() { fn test_parameter_def_builders() {
let param = ParameterDef::required_string("test", "Test parameter") let param =
.with_default("default_value"); ParameterDef::required_string("test", "Test parameter").with_default("default_value");
assert_eq!(param.name, "test"); assert_eq!(param.name, "test");
assert!(!param.required); // Setting default makes it optional 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] #[test]

View file

@ -54,9 +54,9 @@ impl ReadTool {
}; };
// Security check: prevent path traversal // Security check: prevent path traversal
let canonical = resolved.canonicalize().map_err(|e| { let canonical = resolved
ToolError::ExecutionFailed(format!("Cannot resolve path: {}", e)) .canonicalize()
})?; .map_err(|e| ToolError::ExecutionFailed(format!("Cannot resolve path: {}", e)))?;
// Ensure path is within allowed boundaries // Ensure path is within allowed boundaries
if !canonical.starts_with(&self.base_dir) && !canonical.is_absolute() { if !canonical.starts_with(&self.base_dir) && !canonical.is_absolute() {
@ -107,17 +107,17 @@ impl Tool for ReadTool {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidInput("path is required".to_string()))?; .ok_or_else(|| ToolError::InvalidInput("path is required".to_string()))?;
let offset = input let offset = input.get("offset").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
.get("offset")
.and_then(|v| v.as_u64())
.unwrap_or(1) as usize;
let limit = input let limit = input
.get("limit") .get("limit")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.map(|v| v as usize); .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 resolved = self.resolve_path(path)?;
let content = std::fs::read_to_string(&resolved) let content = std::fs::read_to_string(&resolved)
@ -339,10 +339,7 @@ impl Tool for EditTool {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
debug!( debug!("Editing file: {} (replace_all: {})", path, replace_all);
"Editing file: {} (replace_all: {})",
path, replace_all
);
let resolved = self.resolve_path(path)?; let resolved = self.resolve_path(path)?;
let content = std::fs::read_to_string(&resolved) let content = std::fs::read_to_string(&resolved)
@ -432,7 +429,10 @@ impl BashTool {
for pattern in dangerous { for pattern in dangerous {
if command.contains(pattern) { if command.contains(pattern) {
return Some(format!("Potentially dangerous command detected: {}", pattern)); return Some(format!(
"Potentially dangerous command detected: {}",
pattern
));
} }
} }
None None
@ -495,7 +495,10 @@ impl Tool for BashTool {
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| self.working_dir.clone()); .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 // Check for dangerous commands
if let Some(warning) = self.check_dangerous(command) { if let Some(warning) = self.check_dangerous(command) {
@ -612,7 +615,10 @@ impl Tool for GlobTool {
fn parameters(&self) -> Vec<ParameterDef> { fn parameters(&self) -> Vec<ParameterDef> {
vec![ vec![
ParameterDef::required_string("pattern", "Glob pattern to match (e.g., **/*.rs, src/**/*.ts)"), ParameterDef::required_string(
"pattern",
"Glob pattern to match (e.g., **/*.rs, src/**/*.ts)",
),
ParameterDef::optional_string("path", "Base directory to search in"), ParameterDef::optional_string("path", "Base directory to search in"),
] ]
} }
@ -639,9 +645,8 @@ impl Tool for GlobTool {
}; };
// Execute glob // Execute glob
let entries = glob(&full_pattern).map_err(|e| { let entries = glob(&full_pattern)
ToolError::InvalidInput(format!("Invalid glob pattern: {}", e)) .map_err(|e| ToolError::InvalidInput(format!("Invalid glob pattern: {}", e)))?;
})?;
let mut matches = Vec::new(); let mut matches = Vec::new();
for entry in entries { for entry in entries {
@ -750,7 +755,10 @@ impl Tool for GrepTool {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .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 // Build regex
let regex_pattern = if case_insensitive { let regex_pattern = if case_insensitive {
@ -759,9 +767,8 @@ impl Tool for GrepTool {
pattern.to_string() pattern.to_string()
}; };
let regex = Regex::new(&regex_pattern).map_err(|e| { let regex = Regex::new(&regex_pattern)
ToolError::InvalidInput(format!("Invalid regex pattern: {}", e)) .map_err(|e| ToolError::InvalidInput(format!("Invalid regex pattern: {}", e)))?;
})?;
let search_path = if Path::new(path).is_absolute() { let search_path = if Path::new(path).is_absolute() {
PathBuf::from(path) PathBuf::from(path)
@ -900,7 +907,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_read_tool_offset_limit() { async fn test_read_tool_offset_limit() {
let dir = TempDir::new().unwrap(); 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); create_temp_file(&dir, "test.txt", &content);
let tool = ReadTool::with_base_dir(dir.path()); let tool = ReadTool::with_base_dir(dir.path());
@ -1180,7 +1190,10 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
let output = result.unwrap(); let output = result.unwrap();
assert!(output.success); 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] #[tokio::test]

View file

@ -1,7 +1,7 @@
//! Core types for Miyabi //! Core types for Miyabi
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Message role in a conversation /// Message role in a conversation
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[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 { Self {
role: Role::Tool, role: Role::Tool,
content: content.into(), content: content.into(),

View file

@ -20,6 +20,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
# Workspace dependencies # Workspace dependencies
miyabi-core = { path = "../miyabi-core" } miyabi-core = { path = "../miyabi-core" }
arboard = { version = "3", optional = true }
# TUI Framework # TUI Framework
ratatui = { workspace = true } ratatui = { workspace = true }
@ -47,3 +48,6 @@ serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
once_cell = { workspace = true } once_cell = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
[features]
clipboard = ["arboard"]

View file

@ -1,13 +1,44 @@
//! Main TUI Application //! Main TUI Application
pub mod event_loop;
pub mod state;
use std::time::Instant;
use futures::StreamExt; use futures::StreamExt;
use crate::approval_overlay::{ApprovalRequest, RiskLevel};
use crate::event::{Event, EventHandler}; use crate::event::{Event, EventHandler};
use crate::history_cell::{ use crate::history_cell::{
UserMessageCell, AssistantMessageCell, SystemMessageCell, SystemMessageType, AssistantMessageCell, SystemMessageCell, SystemMessageType, ToolResultCell, UserMessageCell,
}; };
use crate::views::{MainView, ViewAction}; 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 /// Main application state
pub struct App { pub struct App {
@ -21,23 +52,55 @@ pub struct App {
conversation: Vec<Message>, conversation: Vec<Message>,
/// Whether currently streaming a response /// Whether currently streaming a response
is_streaming: bool, 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 { impl App {
/// Create a new app /// Create a new app with default configuration
pub fn new() -> Self { 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(); let timestamp = chrono::Local::now().format("%H:%M").to_string();
// Try to get API key from environment // Create Anthropic client from config
let client = std::env::var("ANTHROPIC_API_KEY") let client = config
.ok() .api
.api_key
.as_ref()
.and_then(|key| AnthropicClient::new(key).ok()) .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() { 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." "Welcome to Miyabi CLI! Type your message and press Enter. Press Ctrl+P for commands, F1 for help."
} else { } 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(); let mut view = MainView::new();
@ -46,13 +109,31 @@ impl App {
view.push_message(Box::new(SystemMessageCell { view.push_message(Box::new(SystemMessageCell {
content: welcome_message.to_string(), content: welcome_message.to_string(),
timestamp: timestamp.clone(), 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 // Get model name from config
if client.is_some() { let model_name = &config.api.model;
view = view.with_model("claude-sonnet-4-20250514"); 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 { Self {
should_quit: false, should_quit: false,
@ -60,13 +141,63 @@ impl App {
client, client,
conversation: Vec::new(), conversation: Vec::new(),
is_streaming: false, 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 /// Run the main app loop
pub async fn run( pub async fn run<B: ratatui::backend::Backend>(
&mut self, &mut self,
terminal: &mut ratatui::Terminal<impl ratatui::backend::Backend>, terminal: &mut ratatui::Terminal<B>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut events = EventHandler::new(100); let mut events = EventHandler::new(100);
@ -78,12 +209,13 @@ impl App {
Event::Key(key) => { Event::Key(key) => {
let action = self.view.handle_key(key); let action = self.view.handle_key(key);
match action { match action {
ViewAction::None => {}
ViewAction::Quit => { ViewAction::Quit => {
self.should_quit = true; self.should_quit = true;
} }
ViewAction::SendMessage(message) => { ViewAction::SendMessage(message) => {
if !self.is_streaming { if !self.is_streaming {
self.send_message(message).await; self.send_message(message, terminal).await;
} }
} }
ViewAction::ExecuteCommand(cmd) => { ViewAction::ExecuteCommand(cmd) => {
@ -94,7 +226,37 @@ impl App {
self.is_streaming = false; self.is_streaming = false;
self.view.set_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(_, _) => {} 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(()) Ok(())
} }
@ -122,12 +311,69 @@ impl App {
self.conversation.clear(); self.conversation.clear();
} }
"help" => self.view.show_help(), "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 /// 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(); let timestamp = chrono::Local::now().format("%H:%M").to_string();
// Add user message to UI // Add user message to UI
@ -146,53 +392,109 @@ impl App {
// Add streaming placeholder // Add streaming placeholder
let cell_index = self.view.history.len(); let cell_index = self.view.history.len();
self.view.push_message(Box::new(AssistantMessageCell { self.view
content: String::new(), .push_message(Box::new(AssistantMessageCell::new(timestamp.clone())));
timestamp: timestamp.clone(),
streaming: true,
}));
// Start streaming // Start streaming
match client.message_stream( match client
self.conversation.clone(), .message_stream(
Some("You are a helpful AI assistant. Be concise and clear.".to_string()), self.conversation.clone(),
None, self.system_prompt.clone(),
None, None,
).await { None,
)
.await
{
Ok(mut stream) => { Ok(mut stream) => {
let mut response_text = String::new();
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
match event { 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, .. }) => { Ok(StreamEvent::ContentBlockDelta { delta, .. }) => {
response_text.push_str(&delta.text); // Push delta to the stream directly
// Update the cell content if let Some(cell) = self.view.history.get(cell_index) {
if let Some(cell) = self.view.history.get_mut(cell_index) { if let Some(assistant_cell) =
if let Some(assistant_cell) = (**cell).as_any_mut().downcast_mut::<AssistantMessageCell>() { cell.as_ref()
assistant_cell.content = response_text.clone(); .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) => { Ok(StreamEvent::MessageStop) => {
break; break;
} }
Ok(StreamEvent::Error { error }) => { 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; break;
} }
_ => {} _ => {}
} }
} }
// Mark as done streaming // Mark as done streaming and get content for conversation history
if let Some(cell) = self.view.history.get_mut(cell_index) { 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>() { if let Some(assistant_cell) =
assistant_cell.streaming = false; (**cell).as_any_mut().downcast_mut::<AssistantMessageCell>()
if response_text.is_empty() { {
assistant_cell.content = "(No response)".to_string(); 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 // Add to conversation history
if !response_text.is_empty() { if !response_text.is_empty() {
@ -200,11 +502,17 @@ impl App {
} }
} }
Err(e) => { Err(e) => {
// Show error notification
self.view
.notifications
.error("Connection Error", e.to_string());
// Replace with error message // Replace with error message
if let Some(cell) = self.view.history.get_mut(cell_index) { if let Some(cell) = self.view.history.get_mut(cell_index) {
if let Some(assistant_cell) = (**cell).as_any_mut().downcast_mut::<AssistantMessageCell>() { if let Some(assistant_cell) =
assistant_cell.content = format!("Error: {}", e); (**cell).as_any_mut().downcast_mut::<AssistantMessageCell>()
assistant_cell.streaming = false; {
assistant_cell.set_content(&format!("Error: {}", e));
assistant_cell.set_complete();
} }
} }
} }
@ -215,16 +523,414 @@ impl App {
} else { } else {
// Demo mode - no API key // 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); 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 { let mut cell = AssistantMessageCell::new(timestamp);
content: response, cell.set_content(&response);
timestamp, cell.set_complete();
streaming: false, self.view.push_message(Box::new(cell));
}));
} }
// Auto-scroll to bottom // Auto-scroll to bottom
self.view.history_scroll = self.view.max_scroll; 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 { 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 // 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); self.selected = self.selected.saturating_sub(1);
ApprovalAction::None ApprovalAction::None
} }
KeyCode::Right | KeyCode::Char('l') | KeyCode::Tab => { 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 2
} else { } else {
1 1
@ -324,10 +331,10 @@ impl ApprovalOverlay {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(2), // Risk indicator Constraint::Length(2), // Risk indicator
Constraint::Length(3), // Title Constraint::Length(3), // Title
Constraint::Min(4), // Content Constraint::Min(4), // Content
Constraint::Length(3), // Buttons Constraint::Length(3), // Buttons
]) ])
.split(inner); .split(inner);
@ -392,9 +399,10 @@ impl ApprovalOverlay {
// Arguments // Arguments
if !request.arguments.is_empty() { if !request.arguments.is_empty() {
lines.push(Line::from(vec![ lines.push(Line::from(vec![Span::styled(
Span::styled("Command: ", Style::default().fg(Color::Rgb(86, 95, 137))), "Command: ",
])); Style::default().fg(Color::Rgb(86, 95, 137)),
)]));
// Wrap long arguments // Wrap long arguments
let arg_lines: Vec<&str> = request.arguments.lines().collect(); let arg_lines: Vec<&str> = request.arguments.lines().collect();
@ -526,11 +534,14 @@ impl ApprovalBuilder {
}; };
Self { Self {
request: ApprovalRequest::new(uuid::Uuid::new_v4().to_string(), "Execute Shell Command") request: ApprovalRequest::new(
.tool_name("bash") uuid::Uuid::new_v4().to_string(),
.arguments(&cmd) "Execute Shell Command",
.risk_level(risk) )
.description("The AI wants to run a 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] #[test]
fn test_request_description() { fn test_request_description() {
let request = ApprovalRequest::new("1", "Title") let request = ApprovalRequest::new("1", "Title").description("Test description");
.description("Test description");
assert_eq!(request.description, "Test description"); assert_eq!(request.description, "Test description");
} }
#[test] #[test]
fn test_request_tool_name() { fn test_request_tool_name() {
let request = ApprovalRequest::new("1", "Title") let request = ApprovalRequest::new("1", "Title").tool_name("bash");
.tool_name("bash");
assert_eq!(request.tool_name, "bash"); assert_eq!(request.tool_name, "bash");
} }
#[test] #[test]
fn test_request_arguments() { fn test_request_arguments() {
let request = ApprovalRequest::new("1", "Title") let request = ApprovalRequest::new("1", "Title").arguments("echo hello");
.arguments("echo hello");
assert_eq!(request.arguments, "echo hello"); assert_eq!(request.arguments, "echo hello");
} }
#[test] #[test]
fn test_request_risk_level() { fn test_request_risk_level() {
let request = ApprovalRequest::new("1", "Title") let request = ApprovalRequest::new("1", "Title").risk_level(RiskLevel::Critical);
.risk_level(RiskLevel::Critical);
assert_eq!(request.risk_level, RiskLevel::Critical); assert_eq!(request.risk_level, RiskLevel::Critical);
} }
@ -733,8 +740,8 @@ mod tests {
#[test] #[test]
fn test_request_details() { fn test_request_details() {
let request = ApprovalRequest::new("1", "Title") let request =
.details(vec!["A".to_string(), "B".to_string()]); ApprovalRequest::new("1", "Title").details(vec!["A".to_string(), "B".to_string()]);
assert_eq!(request.details.len(), 2); assert_eq!(request.details.len(), 2);
} }
@ -834,8 +841,7 @@ mod tests {
#[test] #[test]
fn test_overlay_handle_key_toggle_details() { fn test_overlay_handle_key_toggle_details() {
let mut overlay = ApprovalOverlay::new(); let mut overlay = ApprovalOverlay::new();
let request = ApprovalRequest::new("1", "Test") let request = ApprovalRequest::new("1", "Test").add_detail("Detail");
.add_detail("Detail");
overlay.show(request); overlay.show(request);
assert!(!overlay.current_request().unwrap().show_details); assert!(!overlay.current_request().unwrap().show_details);
@ -850,8 +856,7 @@ mod tests {
#[test] #[test]
fn test_overlay_handle_key_navigation() { fn test_overlay_handle_key_navigation() {
let mut overlay = ApprovalOverlay::new(); let mut overlay = ApprovalOverlay::new();
let request = ApprovalRequest::new("1", "Test") let request = ApprovalRequest::new("1", "Test").add_detail("Detail");
.add_detail("Detail");
overlay.show(request); overlay.show(request);
// Initially selected = 0 (Approve) // Initially selected = 0 (Approve)
@ -1043,7 +1048,7 @@ mod tests {
let mut batch = BatchApproval::new(requests); let mut batch = BatchApproval::new(requests);
batch.approve_current(); // Approve 1 batch.approve_current(); // Approve 1
batch.reject_current(); // Reject 2 batch.reject_current(); // Reject 2
batch.approve_current(); // Approve 3 batch.approve_current(); // Approve 3
let (approved, rejected) = batch.results(); let (approved, rejected) = batch.results();

View file

@ -41,8 +41,10 @@ pub enum VimMode {
/// Keybinding style preference /// Keybinding style preference
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Default)]
pub enum KeybindingStyle { pub enum KeybindingStyle {
/// Standard keybindings /// Standard keybindings
#[default]
Standard, Standard,
/// Vim-style keybindings /// Vim-style keybindings
Vim, Vim,
@ -50,20 +52,12 @@ pub enum KeybindingStyle {
Emacs, Emacs,
} }
impl Default for KeybindingStyle {
fn default() -> Self {
KeybindingStyle::Standard
}
}
/// Edit operation for undo/redo /// Edit operation for undo/redo
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum EditOperation { pub enum EditOperation {
/// Insert text at position /// Insert text at position
Insert { Insert { pos: CursorPos, text: String },
pos: CursorPos,
text: String,
},
/// Delete text range /// Delete text range
Delete { Delete {
start: CursorPos, start: CursorPos,
@ -276,7 +270,7 @@ pub struct VimRegisters {
/// Named registers (a-z) /// Named registers (a-z)
named: [String; 26], named: [String; 26],
/// Small delete register /// Small delete register
small_delete: String, _small_delete: String,
/// Numbered registers (0-9) /// Numbered registers (0-9)
numbered: [String; 10], numbered: [String; 10],
/// Last search register /// Last search register
@ -421,7 +415,7 @@ pub struct ChatComposer {
/// Auto-indent on newline /// Auto-indent on newline
auto_indent: bool, auto_indent: bool,
/// Bracket matching /// Bracket matching
bracket_matching: bool, _bracket_matching: bool,
/// Last matched bracket position /// Last matched bracket position
matched_bracket: Option<CursorPos>, matched_bracket: Option<CursorPos>,
} }
@ -456,7 +450,7 @@ impl ChatComposer {
show_line_numbers: false, show_line_numbers: false,
highlight_current_line: true, highlight_current_line: true,
auto_indent: true, auto_indent: true,
bracket_matching: true, _bracket_matching: true,
matched_bracket: None, matched_bracket: None,
} }
} }
@ -736,7 +730,8 @@ impl ChatComposer {
if self.show_suggestions { if self.show_suggestions {
match key.code { match key.code {
KeyCode::Tab | KeyCode::Down => { 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; return ComposerAction::None;
} }
KeyCode::Up => { KeyCode::Up => {
@ -839,9 +834,7 @@ impl ChatComposer {
} }
ComposerAction::None ComposerAction::None
} }
KeyCode::Esc => { KeyCode::Esc => ComposerAction::Cancel,
ComposerAction::Cancel
}
_ => ComposerAction::None, _ => ComposerAction::None,
} }
} }
@ -958,8 +951,10 @@ impl ChatComposer {
fn backspace(&mut self) { fn backspace(&mut self) {
if self.cursor.col > 0 { if self.cursor.col > 0 {
// Compute byte indices before mutable borrow // Compute byte indices before mutable borrow
let byte_idx = self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col - 1); let byte_idx =
let next_byte_idx = self.char_to_byte_index(&self.lines[self.cursor.line], self.cursor.col); 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.lines[self.cursor.line].replace_range(byte_idx..next_byte_idx, "");
self.cursor.col -= 1; self.cursor.col -= 1;
} else if self.cursor.line > 0 { } else if self.cursor.line > 0 {
@ -983,7 +978,8 @@ impl ChatComposer {
if self.cursor.col < char_count { if self.cursor.col < char_count {
// Compute byte indices before mutable borrow // Compute byte indices before mutable borrow
let 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);
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, ""); self.lines[self.cursor.line].replace_range(byte_idx..next_byte_idx, "");
} else if self.cursor.line < self.lines.len() - 1 { } else if self.cursor.line < self.lines.len() - 1 {
// Merge with next line // Merge with next line
@ -1205,8 +1201,15 @@ impl ChatComposer {
if let Some(cmd) = input.strip_prefix('/') { if let Some(cmd) = input.strip_prefix('/') {
// Built-in commands // Built-in commands
let commands = vec![ let commands = vec![
"help", "clear", "history", "exit", "quit", "help",
"model", "temperature", "tools", "context", "clear",
"history",
"exit",
"quit",
"model",
"temperature",
"tools",
"context",
]; ];
self.suggestions = commands self.suggestions = commands
@ -1248,7 +1251,10 @@ impl ChatComposer {
let block = Block::default() let block = Block::default()
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(border_color)) .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); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
@ -1268,7 +1274,10 @@ impl ChatComposer {
let mode_indicator = self.get_mode_indicator(); let mode_indicator = self.get_mode_indicator();
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(mode_indicator, Style::default().fg(Color::Cyan)), 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 { } else if self.mode == InputMode::Search {
// Show search prompt // Show search prompt
@ -1278,19 +1287,22 @@ impl ChatComposer {
"?" "?"
}; };
lines.push(Line::from(vec![ 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(&self.search.query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)), Span::styled("", Style::default().fg(Color::Yellow)),
])); ]));
// Show match info // Show match info
if let Some(info) = self.search_info() { if let Some(info) = self.search_info() {
lines.push(Line::from(vec![ lines.push(Line::from(vec![Span::styled(
Span::styled( format!(" {} matches", info),
format!(" {} matches", info), Style::default().fg(Color::Rgb(86, 95, 137)),
Style::default().fg(Color::Rgb(86, 95, 137)), )]));
),
]));
} }
} else { } else {
for (i, line) in self.lines.iter().enumerate() { for (i, line) in self.lines.iter().enumerate() {
@ -1332,8 +1344,7 @@ impl ChatComposer {
} }
} }
let paragraph = Paragraph::new(lines) let paragraph = Paragraph::new(lines).scroll((self.scroll_offset as u16, 0));
.scroll((self.scroll_offset as u16, 0));
frame.render_widget(paragraph, inner); frame.render_widget(paragraph, inner);
// Render mode line at bottom // 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]); let char_style = self.get_char_style(line_idx, current_pos, chars[current_pos]);
// Check if this is cursor position // Check if this is cursor position
let is_cursor = line_idx == self.cursor.line let is_cursor =
&& current_pos == self.cursor.col line_idx == self.cursor.line && current_pos == self.cursor.col && self.focused;
&& self.focused;
let style = if is_cursor { let style = if is_cursor {
Style::default().bg(Color::Cyan).fg(Color::Black) 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); return Style::default().bg(Color::Rgb(68, 71, 90)).fg(Color::Cyan);
} }
} }
if line_idx == self.cursor.line && col == self.cursor.col { if line_idx == self.cursor.line && col == self.cursor.col
if matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>') { && matches!(ch, '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>') {
return Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD); return Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
} }
}
// Basic syntax highlighting // Basic syntax highlighting
match ch { match ch {
// Brackets // Brackets
'(' | ')' | '[' | ']' | '{' | '}' => { '(' | ')' | '[' | ']' | '{' | '}' => Style::default().fg(Color::Rgb(189, 147, 249)),
Style::default().fg(Color::Rgb(189, 147, 249))
}
// Operators // Operators
'+' | '-' | '*' | '/' | '=' | '<' | '>' | '!' | '&' | '|' | '^' | '%' => { '+' | '-' | '*' | '/' | '=' | '<' | '>' | '!' | '&' | '|' | '^' | '%' => {
Style::default().fg(Color::Rgb(255, 121, 198)) Style::default().fg(Color::Rgb(255, 121, 198))
} }
// Punctuation // Punctuation
'.' | ',' | ':' | ';' | '@' | '#' => { '.' | ',' | ':' | ';' | '@' | '#' => Style::default().fg(Color::Rgb(139, 233, 253)),
Style::default().fg(Color::Rgb(139, 233, 253))
}
// Quotes // Quotes
'"' | '\'' | '`' => { '"' | '\'' | '`' => Style::default().fg(Color::Rgb(241, 250, 140)),
Style::default().fg(Color::Rgb(241, 250, 140))
}
// Numbers // Numbers
c if c.is_ascii_digit() => { c if c.is_ascii_digit() => Style::default().fg(Color::Rgb(189, 147, 249)),
Style::default().fg(Color::Rgb(189, 147, 249))
}
// Default // Default
_ => Style::default().fg(Color::Rgb(248, 248, 242)) _ => Style::default().fg(Color::Rgb(248, 248, 242)),
} }
} }
/// Check if position is in selection /// Check if position is in selection
fn is_in_selection(&self, line: usize, col: usize, sel: &Selection) -> bool { fn is_in_selection(&self, line: usize, col: usize, sel: &Selection) -> bool {
let (start, end) = if sel.start.line < sel.end.line 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) (sel.start, sel.end)
} else { } else {
(sel.end, sel.start) (sel.end, sel.start)
@ -1549,11 +1553,7 @@ impl ChatComposer {
}; };
// Position info // Position info
let pos_info = format!( let pos_info = format!("{}:{} ", self.cursor.line + 1, self.cursor.col + 1);
"{}:{} ",
self.cursor.line + 1,
self.cursor.col + 1
);
// Command buffer display // Command buffer display
let cmd_display = if !self.vim_command_buffer.is_empty() { let cmd_display = if !self.vim_command_buffer.is_empty() {
@ -1565,7 +1565,10 @@ impl ChatComposer {
}; };
let status = Line::from(vec![ 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::styled(cmd_display, Style::default().fg(Color::Yellow)),
Span::raw(" "), Span::raw(" "),
Span::styled(pos_info, Style::default().fg(Color::Rgb(86, 95, 137))), Span::styled(pos_info, Style::default().fg(Color::Rgb(86, 95, 137))),
@ -1577,7 +1580,9 @@ impl ChatComposer {
/// Render rich suggestions with descriptions /// Render rich suggestions with descriptions
fn render_rich_suggestions(&self, frame: &mut Frame, area: Rect) { fn render_rich_suggestions(&self, frame: &mut Frame, area: Rect) {
let popup_height = (self.rich_suggestions.len() + 2).min(12) as u16; 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| { .map(|s| {
let desc_len = s.description.as_ref().map(|d| d.len()).unwrap_or(0); let desc_len = s.description.as_ref().map(|d| d.len()).unwrap_or(0);
s.text.len() + desc_len + 10 s.text.len() + desc_len + 10
@ -1595,7 +1600,8 @@ impl ChatComposer {
frame.render_widget(Clear, popup_area); frame.render_widget(Clear, popup_area);
let items: Vec<Line> = self.rich_suggestions let items: Vec<Line> = self
.rich_suggestions
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, s)| { .map(|(i, s)| {
@ -1620,8 +1626,17 @@ impl ChatComposer {
Span::styled(icon, base_style.fg(Color::Cyan)), Span::styled(icon, base_style.fg(Color::Cyan)),
Span::styled( Span::styled(
&s.text, &s.text,
base_style.fg(if is_selected { Color::White } else { Color::Rgb(248, 248, 242) }) base_style
.add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() }), .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(); .collect();
let title = format!(" Suggestions ({}) ", self.rich_suggestions.len()); let title = format!(" Suggestions ({}) ", self.rich_suggestions.len());
let popup = Paragraph::new(items) let popup = Paragraph::new(items).block(
.block( Block::default()
Block::default() .borders(Borders::ALL)
.borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(98, 114, 164)))
.border_style(Style::default().fg(Color::Rgb(98, 114, 164))) .title(Span::styled(title, Style::default().fg(Color::Cyan))),
.title(Span::styled(title, Style::default().fg(Color::Cyan))), );
);
frame.render_widget(popup, popup_area); frame.render_widget(popup, popup_area);
} }
@ -1651,11 +1665,14 @@ impl ChatComposer {
/// Render suggestions popup /// Render suggestions popup
fn render_suggestions(&self, frame: &mut Frame, area: Rect) { fn render_suggestions(&self, frame: &mut Frame, area: Rect) {
let popup_height = (self.suggestions.len() + 2).min(10) as u16; 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)) .map(|s| display_width(s))
.max() .max()
.unwrap_or(20) .unwrap_or(20)
.max(20) as u16 + 4; .max(20) as u16
+ 4;
let popup_area = Rect { let popup_area = Rect {
x: area.x + 2, x: area.x + 2,
@ -1666,12 +1683,15 @@ impl ChatComposer {
frame.render_widget(Clear, popup_area); frame.render_widget(Clear, popup_area);
let items: Vec<Line> = self.suggestions let items: Vec<Line> = self
.suggestions
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, s)| { .map(|(i, s)| {
let style = if i == self.suggestion_index { 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 { } else {
Style::default().fg(Color::Rgb(192, 202, 245)) Style::default().fg(Color::Rgb(192, 202, 245))
}; };
@ -1679,13 +1699,12 @@ impl ChatComposer {
}) })
.collect(); .collect();
let popup = Paragraph::new(items) let popup = Paragraph::new(items).block(
.block( Block::default()
Block::default() .borders(Borders::ALL)
.borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(Color::Cyan)) .title(" Commands "),
.title(" Commands "), );
);
frame.render_widget(popup, popup_area); 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 // Undo replace by replacing with old text
self.cursor = *start; self.cursor = *start;
self.set_input(old_text); self.set_input(old_text);
@ -1773,12 +1794,15 @@ impl ChatComposer {
EditOperation::Delete { start, end, .. } => { EditOperation::Delete { start, end, .. } => {
self.cursor = *start; self.cursor = *start;
// Delete from start to end // Delete from start to end
while self.cursor.line < end.line || while self.cursor.line < end.line
(self.cursor.line == end.line && self.cursor.col < end.col) { || (self.cursor.line == end.line && self.cursor.col < end.col)
{
self.delete(); self.delete();
} }
} }
EditOperation::Replace { start, new_text, .. } => { EditOperation::Replace {
start, new_text, ..
} => {
self.cursor = *start; self.cursor = *start;
self.set_input(new_text); self.set_input(new_text);
} }
@ -1829,8 +1853,9 @@ impl ChatComposer {
/// Get selected text /// Get selected text
fn get_selected_text(&self, sel: &Selection) -> String { fn get_selected_text(&self, sel: &Selection) -> String {
let (start, end) = if sel.start.line < sel.end.line || 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) (sel.start, sel.end)
} else { } else {
(sel.end, sel.start) (sel.end, sel.start)
@ -1862,8 +1887,9 @@ impl ChatComposer {
/// Delete selected text /// Delete selected text
fn delete_selection(&mut self, sel: &Selection) { fn delete_selection(&mut self, sel: &Selection) {
let (start, end) = if sel.start.line < sel.end.line || 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) (sel.start, sel.end)
} else { } else {
(sel.end, sel.start) (sel.end, sel.start)
@ -1943,10 +1969,13 @@ impl ChatComposer {
KeyCode::Char('V') => { KeyCode::Char('V') => {
self.vim_mode = VimMode::VisualLine; self.vim_mode = VimMode::VisualLine;
self.selection = Some(Selection { self.selection = Some(Selection {
start: CursorPos { line: self.cursor.line, col: 0 }, start: CursorPos {
line: self.cursor.line,
col: 0,
},
end: CursorPos { end: CursorPos {
line: self.cursor.line, line: self.cursor.line,
col: self.current_line().chars().count() col: self.current_line().chars().count(),
}, },
}); });
} }
@ -1991,9 +2020,7 @@ impl ChatComposer {
KeyCode::Char('^') => { KeyCode::Char('^') => {
// First non-whitespace // First non-whitespace
let line = self.current_line(); let line = self.current_line();
self.cursor.col = line.chars() self.cursor.col = line.chars().position(|c| !c.is_whitespace()).unwrap_or(0);
.position(|c| !c.is_whitespace())
.unwrap_or(0);
} }
KeyCode::Char('g') => { KeyCode::Char('g') => {
// gg - go to beginning // gg - go to beginning
@ -2205,8 +2232,14 @@ impl ChatComposer {
let col_end = col_start + query.chars().count(); let col_end = col_start + query.chars().count();
self.search.matches.push(( self.search.matches.push((
CursorPos { line: line_idx, col: col_start }, CursorPos {
CursorPos { line: line_idx, col: col_end }, line: line_idx,
col: col_start,
},
CursorPos {
line: line_idx,
col: col_end,
},
)); ));
start += pos + 1; start += pos + 1;
@ -2347,7 +2380,12 @@ impl ChatComposer {
} }
/// Add rich suggestion /// 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 { self.rich_suggestions.push(Suggestion {
text: text.into(), text: text.into(),
description, description,
@ -2364,15 +2402,14 @@ impl ChatComposer {
} }
/// Get auto-indent string for new line /// Get auto-indent string for new line
#[allow(dead_code)]
fn get_auto_indent(&self) -> String { fn get_auto_indent(&self) -> String {
if !self.auto_indent { if !self.auto_indent {
return String::new(); return String::new();
} }
let line = &self.lines[self.cursor.line]; let line = &self.lines[self.cursor.line];
let indent: String = line.chars() let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
.take_while(|c| c.is_whitespace())
.collect();
// Check for additional indent after { or : // Check for additional indent after { or :
if let Some(last_char) = line.trim_end().chars().last() { if let Some(last_char) = line.trim_end().chars().last() {

View file

@ -145,7 +145,9 @@ impl CommandPopup {
/// Get selected command /// Get selected command
pub fn selected_command(&self) -> Option<&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 /// Handle key event
@ -331,7 +333,9 @@ impl CommandPopup {
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
format!(" {} ", self.title), format!(" {} ", self.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -364,7 +368,10 @@ impl CommandPopup {
let content = if self.query.is_empty() { let content = if self.query.is_empty() {
Line::from(vec![ Line::from(vec![
Span::styled(" ", Style::default().fg(Color::Cyan)), 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 { } else {
Line::from(vec![ Line::from(vec![
@ -470,6 +477,12 @@ impl CommandPopup {
Command::new("model", "Change Model") Command::new("model", "Change Model")
.description("Select AI model") .description("Select AI model")
.category("Settings"), .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") Command::new("temperature", "Temperature")
.description("Adjust response creativity") .description("Adjust response creativity")
.category("Settings"), .category("Settings"),
@ -690,7 +703,8 @@ mod tests {
#[test] #[test]
fn test_popup_handle_key_enter_disabled() { 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(); popup.show();
let action = popup.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); let action = popup.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
@ -883,9 +897,7 @@ mod tests {
#[test] #[test]
fn test_popup_filtering_empty() { fn test_popup_filtering_empty() {
let mut popup = CommandPopup::new().commands(vec![ let mut popup = CommandPopup::new().commands(vec![Command::new("test", "Test")]);
Command::new("test", "Test"),
]);
popup.show(); popup.show();
// Search for something that doesn't exist // Search for something that doesn't exist

View file

@ -100,8 +100,16 @@ impl DiffRender {
// Parse file paths // Parse file paths
let parts: Vec<&str> = line.split_whitespace().collect(); let parts: Vec<&str> = line.split_whitespace().collect();
let old_path = parts.get(2).unwrap_or(&"").trim_start_matches("a/").to_string(); let old_path = parts
let new_path = parts.get(3).unwrap_or(&"").trim_start_matches("b/").to_string(); .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 { current_file = Some(FileDiff {
old_path, old_path,
@ -126,7 +134,9 @@ impl DiffRender {
} }
// Parse hunk header // 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; old_line_num = old_start;
new_line_num = new_start; new_line_num = new_start;
@ -202,7 +212,8 @@ impl DiffRender {
/// Get total number of lines /// Get total number of lines
pub fn line_count(&self) -> usize { pub fn line_count(&self) -> usize {
self.files.iter() self.files
.iter()
.flat_map(|f| &f.hunks) .flat_map(|f| &f.hunks)
.map(|h| h.lines.len()) .map(|h| h.lines.len())
.sum() .sum()
@ -247,22 +258,10 @@ impl DiffRender {
/// Render a single diff line /// Render a single diff line
fn render_line(&self, diff_line: &DiffLine) -> Line<'static> { fn render_line(&self, diff_line: &DiffLine) -> Line<'static> {
let (prefix, style) = match diff_line.line_type { let (prefix, style) = match diff_line.line_type {
DiffLineType::Addition => ( DiffLineType::Addition => ("+", Style::default().fg(Color::Green)),
"+", DiffLineType::Deletion => ("-", Style::default().fg(Color::Red)),
Style::default().fg(Color::Green), DiffLineType::Context => (" ", Style::default()),
), DiffLineType::HunkHeader => ("", Style::default().fg(Color::Cyan)),
DiffLineType::Deletion => (
"-",
Style::default().fg(Color::Red),
),
DiffLineType::Context => (
" ",
Style::default(),
),
DiffLineType::HunkHeader => (
"",
Style::default().fg(Color::Cyan),
),
DiffLineType::FileHeader => ( DiffLineType::FileHeader => (
"", "",
Style::default() Style::default()

View file

@ -3,7 +3,7 @@
//! This module provides an enhanced diff visualization with proper colors, //! This module provides an enhanced diff visualization with proper colors,
//! line numbers, and indicators for a professional git diff display. //! 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::markdown_stream::ScrollState;
use crate::syntax::{normalize_language, SyntaxHighlighter}; use crate::syntax::{normalize_language, SyntaxHighlighter};
use ratatui::{ use ratatui::{
@ -585,10 +585,7 @@ mod tests {
DiffViewer::extract_extension("app.js"), DiffViewer::extract_extension("app.js"),
Some("js".to_string()) Some("js".to_string())
); );
assert_eq!( assert_eq!(DiffViewer::extract_extension("no_extension"), None);
DiffViewer::extract_extension("no_extension"),
None
);
assert_eq!( assert_eq!(
DiffViewer::extract_extension("/path/to/file.py"), DiffViewer::extract_extension("/path/to/file.py"),
Some("py".to_string()) Some("py".to_string())
@ -743,7 +740,7 @@ mod tests {
viewer.scroll_to_bottom(); viewer.scroll_to_bottom();
let percentage = viewer.scroll_percentage(); let percentage = viewer.scroll_percentage();
assert!(percentage >= 0.0 && percentage <= 1.0); assert!((0.0..=1.0).contains(&percentage));
} }
#[test] #[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 HelpAction::None
} }
KeyCode::Tab => { 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.selected_binding = 0;
self.update_filtered(); self.update_filtered();
HelpAction::None HelpAction::None
@ -343,7 +344,9 @@ impl HelpViewer {
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
format!(" {} ", self.title), format!(" {} ", self.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -355,10 +358,10 @@ impl HelpViewer {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), // Tabs Constraint::Length(3), // Tabs
Constraint::Length(3), // Search Constraint::Length(3), // Search
Constraint::Min(1), // Content Constraint::Min(1), // Content
Constraint::Length(1), // Status Constraint::Length(1), // Status
]) ])
.split(inner); .split(inner);
@ -531,10 +534,7 @@ impl HelpViewer {
" a: Show all ", " a: Show all ",
Style::default().fg(Color::Rgb(86, 95, 137)), Style::default().fg(Color::Rgb(86, 95, 137)),
), ),
Span::styled( Span::styled(" q: Close ", Style::default().fg(Color::Rgb(86, 95, 137))),
" q: Close ",
Style::default().fg(Color::Rgb(86, 95, 137)),
),
]); ]);
let paragraph = Paragraph::new(status); let paragraph = Paragraph::new(status);
@ -700,7 +700,9 @@ impl CheatSheet {
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
format!(" {} ", self.title), format!(" {} ", self.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -867,10 +869,7 @@ impl QuickRef {
.iter() .iter()
.map(|(key, desc)| { .map(|(key, desc)| {
Line::from(vec![ Line::from(vec![
Span::styled( Span::styled(format!("{:>8}", key), Style::default().fg(Color::Cyan)),
format!("{:>8}", key),
Style::default().fg(Color::Cyan),
),
Span::raw(" "), Span::raw(" "),
Span::styled(desc, Style::default().fg(Color::Rgb(192, 202, 245))), Span::styled(desc, Style::default().fg(Color::Rgb(192, 202, 245))),
]) ])
@ -957,10 +956,8 @@ mod tests {
#[test] #[test]
fn test_viewer_categories() { fn test_viewer_categories() {
let viewer = HelpViewer::new().categories(vec![ let viewer = HelpViewer::new()
HelpCategory::new("Cat1"), .categories(vec![HelpCategory::new("Cat1"), HelpCategory::new("Cat2")]);
HelpCategory::new("Cat2"),
]);
assert_eq!(viewer.categories.len(), 2); assert_eq!(viewer.categories.len(), 2);
} }
@ -989,7 +986,7 @@ mod tests {
#[test] #[test]
fn test_viewer_show_resets_state() { fn test_viewer_show_resets_state() {
let mut viewer = HelpViewer::new().categories(vec![ 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.show();
viewer.selected_binding = 5; viewer.selected_binding = 5;
@ -1085,13 +1082,12 @@ mod tests {
#[test] #[test]
fn test_viewer_handle_key_navigation() { fn test_viewer_handle_key_navigation() {
let mut viewer = HelpViewer::new().categories(vec![ let mut viewer =
HelpCategory::new("Cat").bindings(vec![ HelpViewer::new().categories(vec![HelpCategory::new("Cat").bindings(vec![
KeyBinding::new("a", "Action A"), KeyBinding::new("a", "Action A"),
KeyBinding::new("b", "Action B"), KeyBinding::new("b", "Action B"),
KeyBinding::new("c", "Action C"), KeyBinding::new("c", "Action C"),
]), ])]);
]);
viewer.show(); viewer.show();
viewer.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())); viewer.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::empty()));
@ -1109,13 +1105,12 @@ mod tests {
#[test] #[test]
fn test_viewer_handle_key_home_end() { fn test_viewer_handle_key_home_end() {
let mut viewer = HelpViewer::new().categories(vec![ let mut viewer =
HelpCategory::new("Cat").bindings(vec![ HelpViewer::new().categories(vec![HelpCategory::new("Cat").bindings(vec![
KeyBinding::new("a", "A"), KeyBinding::new("a", "A"),
KeyBinding::new("b", "B"), KeyBinding::new("b", "B"),
KeyBinding::new("c", "C"), KeyBinding::new("c", "C"),
]), ])]);
]);
viewer.show(); viewer.show();
viewer.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::empty())); viewer.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::empty()));
@ -1177,13 +1172,12 @@ mod tests {
#[test] #[test]
fn test_viewer_filtering() { fn test_viewer_filtering() {
let mut viewer = HelpViewer::new().categories(vec![ let mut viewer =
HelpCategory::new("Cat").bindings(vec![ HelpViewer::new().categories(vec![HelpCategory::new("Cat").bindings(vec![
KeyBinding::new("a", "Alpha"), KeyBinding::new("a", "Alpha"),
KeyBinding::new("b", "Beta"), KeyBinding::new("b", "Beta"),
KeyBinding::new("c", "Copy"), KeyBinding::new("c", "Copy"),
]), ])]);
]);
viewer.show(); viewer.show();
assert_eq!(viewer.filtered.len(), 3); assert_eq!(viewer.filtered.len(), 3);
@ -1214,9 +1208,7 @@ mod tests {
#[test] #[test]
fn test_cheat_section_item() { fn test_cheat_section_item() {
let section = CheatSection::new("Nav") let section = CheatSection::new("Nav").item("j", "Down").item("k", "Up");
.item("j", "Down")
.item("k", "Up");
assert_eq!(section.items.len(), 2); assert_eq!(section.items.len(), 2);
} }
@ -1253,9 +1245,7 @@ mod tests {
#[test] #[test]
fn test_quickref_item() { fn test_quickref_item() {
let qr = QuickRef::new() let qr = QuickRef::new().item("q", "Quit").item("?", "Help");
.item("q", "Quit")
.item("?", "Help");
assert_eq!(qr.items.len(), 2); assert_eq!(qr.items.len(), 2);
} }

View file

@ -8,13 +8,14 @@
//! - Dim: Secondary, timestamps //! - Dim: Secondary, timestamps
use std::any::Any; use std::any::Any;
use std::sync::Mutex;
use ratatui::{ use ratatui::{
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
}; };
use crate::markdown_render::MarkdownRenderer; use crate::markdown_stream::MarkdownStream;
use crate::wrapping::wrap_text; use crate::wrapping::wrap_text;
/// Trait for renderable history items /// Trait for renderable history items
@ -24,6 +25,7 @@ pub trait HistoryCell: Send + Sync {
fn is_streaming(&self) -> bool { fn is_streaming(&self) -> bool {
false false
} }
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any;
} }
@ -36,51 +38,30 @@ pub struct UserMessageCell {
impl HistoryCell for UserMessageCell { impl HistoryCell for UserMessageCell {
fn render(&self, width: u16) -> Vec<Line<'static>> { fn render(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let inner_width = (width as usize).saturating_sub(6).min(70); let content_width = (width as usize).saturating_sub(4);
let border = "".repeat(inner_width);
// Top border // Header line with role and timestamp
lines.push(Line::from(vec![ 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( Span::styled(
format!("{:>width$}", self.timestamp, width = inner_width - 4), "You ",
Style::default().add_modifier(Modifier::DIM) 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 // Content with proper text wrapping
let content_width = inner_width.saturating_sub(2);
for line in self.content.lines() { for line in self.content.lines() {
let wrapped = wrap_text(line, content_width); let wrapped = wrap_text(line, content_width);
for wrapped_line in wrapped { for wrapped_line in wrapped {
let content_str: String = wrapped_line.spans.iter() lines.push(wrapped_line);
.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)),
]));
} }
} }
// 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 lines
} }
@ -88,6 +69,10 @@ impl HistoryCell for UserMessageCell {
&self.timestamp &self.timestamp
} }
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any { fn as_any_mut(&mut self) -> &mut dyn Any {
self self
} }
@ -95,70 +80,101 @@ impl HistoryCell for UserMessageCell {
/// Assistant message cell - Magenta accented card with markdown /// Assistant message cell - Magenta accented card with markdown
pub struct AssistantMessageCell { pub struct AssistantMessageCell {
pub content: String, stream: Mutex<MarkdownStream>,
pub timestamp: String, pub timestamp: String,
pub streaming: bool, pub streaming: bool,
} }
impl HistoryCell for AssistantMessageCell { impl AssistantMessageCell {
fn render(&self, width: u16) -> Vec<Line<'static>> { /// Create a new assistant message cell
let mut lines = Vec::new(); pub fn new(timestamp: String) -> Self {
let inner_width = (width as usize).saturating_sub(6).min(70); Self {
let border = "".repeat(inner_width); stream: Mutex::new(MarkdownStream::new()),
timestamp,
streaming: true,
}
}
// Top border /// Push content to the stream
lines.push(Line::from(vec![ pub fn push_str(&self, s: &str) {
Span::styled("", Style::default().fg(Color::Magenta)), if let Ok(mut stream) = self.stream.lock() {
Span::styled(border.clone(), Style::default().fg(Color::Magenta)), stream.push_str(s);
Span::styled("", Style::default().fg(Color::Magenta)), }
])); }
/// 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 // Header with streaming indicator
let header_text = if self.streaming { "Assistant ●" } else { "Assistant" }; let header_text = if self.streaming {
let header_style = Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD); "Assistant ●"
} else {
"Assistant"
};
let header_style = Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(header_text, header_style), Span::styled(header_text, header_style),
Span::styled(" ", Style::default()),
Span::styled( Span::styled(
format!("{:>width$}", self.timestamp, width = inner_width - header_text.len() - 1), self.timestamp.clone(),
Style::default().add_modifier(Modifier::DIM) Style::default().add_modifier(Modifier::DIM),
), ),
Span::styled("", Style::default().fg(Color::Magenta)),
])); ]));
// Markdown rendered content // Markdown rendered content using MarkdownStream
let renderer = MarkdownRenderer::new(); let md_lines = if let Ok(mut stream) = self.stream.lock() {
let md_lines = renderer.render(&self.content); stream.render()
} else {
Vec::new()
};
if md_lines.is_empty() && self.streaming { if md_lines.is_empty() && self.streaming {
lines.push(Line::from(vec![ lines.push(Line::from(Span::styled(
Span::styled("", Style::default().fg(Color::Magenta)), "...",
Span::styled("...", Style::default().add_modifier(Modifier::DIM)), Style::default().add_modifier(Modifier::DIM),
Span::styled( )));
format!("{:>width$}", "", width = inner_width - 5),
Style::default()
),
Span::styled("", Style::default().fg(Color::Magenta)),
]));
} else { } else {
for md_line in md_lines { for md_line in md_lines {
let mut content_spans = vec![ lines.push(md_line);
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));
} }
} }
// 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 lines
} }
@ -170,6 +186,10 @@ impl HistoryCell for AssistantMessageCell {
self.streaming self.streaming
} }
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any { fn as_any_mut(&mut self) -> &mut dyn Any {
self self
} }
@ -182,61 +202,90 @@ pub struct ToolResultCell {
pub timestamp: String, pub timestamp: String,
pub execution_time_ms: u64, pub execution_time_ms: u64,
pub success: bool, 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 { impl HistoryCell for ToolResultCell {
fn render(&self, width: u16) -> Vec<Line<'static>> { fn render(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
let inner_width = (width as usize).saturating_sub(8).min(68); let content_width = (width as usize).saturating_sub(4);
let border = "".repeat(inner_width); let status_color = if self.success {
let border_color = if self.success { Color::Green } else { Color::Red }; Color::Green
} else {
// Top border (double line for tool) Color::Red
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)),
]));
// Header with status icon // Header with status icon
let icon = if self.success { "" } else { "" }; let icon = if self.success { "" } else { "" };
let time_str = format!("{}ms", self.execution_time_ms); let time_str = format!("{}ms", self.execution_time_ms);
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled("", Style::default().fg(border_color)), Span::styled(format!("{} ", icon), Style::default().fg(status_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( Span::styled(
format!("{:>width$}", time_str, width = inner_width - self.tool_name.len() - 4), self.tool_name.clone(),
Style::default().add_modifier(Modifier::DIM) 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 // Content with proper text wrapping
let content_width = inner_width.saturating_sub(2);
for line in self.content.lines() { for line in self.content.lines() {
let wrapped = wrap_text(line, content_width); let wrapped = wrap_text(line, content_width);
for wrapped_line in wrapped { for wrapped_line in wrapped {
let content_str: String = wrapped_line.spans.iter() lines.push(wrapped_line);
.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)),
]));
} }
} }
// 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 lines
} }
@ -244,6 +293,10 @@ impl HistoryCell for ToolResultCell {
&self.timestamp &self.timestamp
} }
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any { fn as_any_mut(&mut self) -> &mut dyn Any {
self self
} }
@ -273,18 +326,23 @@ impl HistoryCell for SystemMessageCell {
SystemMessageType::Success => ("", Color::Green), SystemMessageType::Success => ("", Color::Green),
}; };
vec![ vec![Line::from(vec![
Line::from(vec![ Span::styled(format!("{} ", icon), Style::default().fg(color)),
Span::styled(format!("{} ", icon), Style::default().fg(color)), Span::styled(
Span::styled(self.content.clone(), Style::default().add_modifier(Modifier::DIM)), self.content.clone(),
]), Style::default().add_modifier(Modifier::DIM),
] ),
])]
} }
fn timestamp(&self) -> &str { fn timestamp(&self) -> &str {
&self.timestamp &self.timestamp
} }
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any { fn as_any_mut(&mut self) -> &mut dyn Any {
self 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. //! functional design with proper text wrapping and markdown rendering.
pub mod app; pub mod app;
pub mod event; pub mod approval_overlay;
pub mod wrapping; pub mod chat_composer;
pub mod history_cell; pub mod command_popup;
pub mod markdown_render;
pub mod markdown_stream;
pub mod diff_render; pub mod diff_render;
pub mod diff_viewer; pub mod diff_viewer;
pub mod markdown_parser; pub mod domain;
pub mod syntax; pub mod event;
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 help; 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 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 views;
pub mod wrapping;
pub use app::App; 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 event::{Event, EventHandler};
pub use wrapping::{word_wrap_line, wrap_text, display_width, WrapOptions}; pub use help::{
pub use history_cell::{HistoryCell, UserMessageCell, AssistantMessageCell, ToolResultCell, SystemMessageCell}; CheatSection, CheatSheet, HelpAction, HelpCategory, HelpViewer, KeyBinding, QuickRef,
pub use markdown_render::{MarkdownRenderer, MarkdownStyles}; };
pub use markdown_stream::{MarkdownStream, StreamState, StreamBuffer, ScrollState, CursorPosition}; pub use history_cell::{
pub use diff_render::{DiffRender, DiffLine, DiffLineType, DiffHunk, FileDiff}; AssistantMessageCell, HistoryCell, SystemMessageCell, ToolResultCell, UserMessageCell,
pub use diff_viewer::{DiffViewer, DiffViewerOptions, DiffColors, render_diff, render_diff_minimal}; };
pub use input::{default_keymap, handle_key_event, KeyBinding as InputKeyBinding};
pub use markdown_parser::MarkdownParser; pub use markdown_parser::MarkdownParser;
pub use syntax::{SyntaxHighlighter, highlight_code, render_code_block, normalize_language}; pub use markdown_render::{MarkdownRenderer, MarkdownStyles};
pub use chat_composer::{ChatComposer, ComposerAction, InputMode, CursorPos}; pub use markdown_stream::{CursorPosition, MarkdownStream, ScrollState, StreamBuffer, StreamState};
pub use textarea::{TextArea, TextAreaConfig, TextAreaAction, TextCursor, TextRange}; pub use notification::{
pub use command_popup::{CommandPopup, CommandPopupAction, Command, CommandBuilder, CommandCategory}; Alert, AlertAction, AlertButton, AlertType, Banner, Notification, NotificationAction,
pub use approval_overlay::{ApprovalOverlay, ApprovalAction, ApprovalRequest, ApprovalBuilder, RiskLevel, BatchApproval}; NotificationCenter, NotificationPanel, NotificationPanelAction, NotificationPriority,
pub use resume_picker::{ResumePicker, ResumePickerAction, SessionEntry, SessionSortOrder, SessionManager}; };
pub use pager_overlay::{PagerOverlay, PagerAction, PagerContent, PagerBuilder}; pub use pager_overlay::{PagerAction, PagerBuilder, PagerContent, PagerOverlay};
pub use shimmer::{ShimmerState, ShimmerEffect, SkeletonLoader, Spinner, SpinnerStyle, ProgressBar, TypingIndicator, Countdown, LoadingState, LoadingOverlay}; pub use resume_picker::{
pub use ui::{colors, styles, layout, Modal, Toast, ToastType, ToastManager, Breadcrumb, StatusBar, StatusItem, Badge, Divider, EmptyState, KeyHint, KeyHints}; ResumePicker, ResumePickerAction, SessionEntry, SessionManager, SessionSortOrder,
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 shimmer::{
pub use views::{MainView, ViewAction, ViewBuilder, FocusArea, ActiveOverlay, AppMode, LayoutConfig}; 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. //! A premium terminal interface following OpenAI Codex patterns.
use miyabi_tui::App;
use crossterm::{ use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture}, event::{DisableMouseCapture, EnableMouseCapture},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}; };
use miyabi_tui::App;
use ratatui::prelude::*; use ratatui::prelude::*;
use std::io; use std::io;

View file

@ -3,7 +3,7 @@
//! This module provides incremental parsing of markdown content using pulldown-cmark, //! This module provides incremental parsing of markdown content using pulldown-cmark,
//! with caching for efficient re-rendering during streaming. //! 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::{ use ratatui::{
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
@ -205,10 +205,8 @@ impl EventRenderer {
} else { } else {
format!("{}- ", indent) format!("{}- ", indent)
}; };
self.current_spans.push(Span::styled( self.current_spans
marker, .push(Span::styled(marker, Style::default().fg(Color::Yellow)));
Style::default().fg(Color::Yellow),
));
} }
Tag::Emphasis => { Tag::Emphasis => {
self.push_style(Style::default().add_modifier(Modifier::ITALIC)); 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)); self.push_style(Style::default().add_modifier(Modifier::CROSSED_OUT));
} }
Tag::BlockQuote(_) => { Tag::BlockQuote(_) => {
self.current_spans.push(Span::styled( self.current_spans
"", .push(Span::styled("", Style::default().fg(Color::DarkGray)));
Style::default().fg(Color::DarkGray),
));
self.push_style(Style::default().fg(Color::Gray)); self.push_style(Style::default().fg(Color::Gray));
} }
Tag::Link { .. } => { 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 { } else {
let style = self.current_style(); 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("> ") { if let Some(stripped) = trimmed.strip_prefix("> ") {
return Line::from(vec![ return Line::from(vec![
Span::styled(" > ", self.styles.blockquote), 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() { if !current.is_empty() {
spans.push(Span::styled( spans.push(Span::styled(
current.clone(), 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(); current.clear();
} }

View file

@ -11,28 +11,28 @@
//! - Link and image handling //! - Link and image handling
//! - Blockquotes with visual indicators //! - Blockquotes with visual indicators
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use ratatui::{ use ratatui::{
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
}; };
use pulldown_cmark::{Parser, Event, Tag, TagEnd, CodeBlockKind, Options};
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
/// Color palette - Tokyo Night theme /// Color palette - Tokyo Night theme
mod colors { mod colors {
use ratatui::style::Color; use ratatui::style::Color;
pub const HEADING_1: Color = Color::Rgb(122, 162, 247); // Blue 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_2: Color = Color::Rgb(125, 207, 255); // Cyan
pub const HEADING_3: Color = Color::Rgb(187, 154, 247); // Purple 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_BG: Color = Color::Rgb(36, 40, 59); // Dark background
pub const CODE_FG: Color = Color::Rgb(169, 177, 214); // Light gray pub const CODE_FG: Color = Color::Rgb(169, 177, 214); // Light gray
pub const LINK: Color = Color::Rgb(125, 207, 255); // Cyan pub const LINK: Color = Color::Rgb(125, 207, 255); // Cyan
pub const EMPHASIS: Color = Color::Rgb(224, 175, 104); // Yellow pub const EMPHASIS: Color = Color::Rgb(224, 175, 104); // Yellow
pub const STRONG: Color = Color::Rgb(247, 118, 142); // Red/Pink pub const STRONG: Color = Color::Rgb(247, 118, 142); // Red/Pink
pub const BLOCKQUOTE: Color = Color::Rgb(115, 218, 202); // Teal pub const BLOCKQUOTE: Color = Color::Rgb(115, 218, 202); // Teal
pub const LIST_MARKER: Color = Color::Rgb(158, 206, 106); // Green pub const LIST_MARKER: Color = Color::Rgb(158, 206, 106); // Green
pub const TABLE_BORDER: Color = Color::Rgb(86, 95, 137); // Dim pub const TABLE_BORDER: Color = Color::Rgb(86, 95, 137); // Dim
pub const STRIKETHROUGH: Color = Color::Rgb(169, 177, 214); // Gray pub const STRIKETHROUGH: Color = Color::Rgb(169, 177, 214); // Gray
pub const HORIZONTAL_RULE: Color = Color::Rgb(86, 95, 137); // Dim 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); style = style.add_modifier(Modifier::ITALIC).fg(colors::EMPHASIS);
} }
if self.strikethrough { 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 { if self.code {
style = style.bg(colors::CODE_BG).fg(colors::CODE_FG); style = style.bg(colors::CODE_BG).fg(colors::CODE_FG);
@ -115,7 +117,8 @@ pub struct TableState {
impl TableState { impl TableState {
/// Finish current cell /// Finish current cell
pub fn finish_cell(&mut self) { pub fn finish_cell(&mut self) {
let alignment = self.alignments let alignment = self
.alignments
.get(self.current_row.len()) .get(self.current_row.len())
.copied() .copied()
.unwrap_or_default(); .unwrap_or_default();
@ -147,7 +150,8 @@ impl TableState {
let mut lines = Vec::new(); let mut lines = Vec::new();
// Calculate column widths // Calculate column widths
let mut widths: Vec<usize> = self.headers let mut widths: Vec<usize> = self
.headers
.iter() .iter()
.map(|c| c.content.width().max(3)) .map(|c| c.content.width().max(3))
.collect(); .collect();
@ -162,11 +166,15 @@ impl TableState {
// Render header // Render header
let border_style = Style::default().fg(colors::TABLE_BORDER); 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 // Top border
let top_border = format!("{}", let top_border = format!(
widths.iter() "┌{}┐",
widths
.iter()
.map(|w| "".repeat(*w + 2)) .map(|w| "".repeat(*w + 2))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("") .join("")
@ -174,7 +182,8 @@ impl TableState {
lines.push(Line::from(Span::styled(top_border, border_style))); lines.push(Line::from(Span::styled(top_border, border_style)));
// Header row // Header row
let header_cells: Vec<String> = self.headers let header_cells: Vec<String> = self
.headers
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, cell)| { .map(|(i, cell)| {
@ -194,8 +203,10 @@ impl TableState {
lines.push(Line::from(header_spans)); lines.push(Line::from(header_spans));
// Header/body separator // Header/body separator
let separator = format!("{}", let separator = format!(
widths.iter() "├{}┤",
widths
.iter()
.map(|w| "".repeat(*w + 2)) .map(|w| "".repeat(*w + 2))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("") .join("")
@ -229,8 +240,10 @@ impl TableState {
} }
// Bottom border // Bottom border
let bottom_border = format!("{}", let bottom_border = format!(
widths.iter() "└{}┘",
widths
.iter()
.map(|w| "".repeat(*w + 2)) .map(|w| "".repeat(*w + 2))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("") .join("")
@ -276,7 +289,8 @@ impl ParseContext {
} }
let style = self.inline_style.to_style(); 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 /// Finish current line
@ -285,20 +299,22 @@ impl ParseContext {
// Add blockquote prefix if needed // Add blockquote prefix if needed
if self.in_blockquote { if self.in_blockquote {
let prefix = "".repeat(self.blockquote_depth); let prefix = "".repeat(self.blockquote_depth);
let mut spans = vec![ let mut spans = vec![Span::styled(
Span::styled(prefix, Style::default().fg(colors::BLOCKQUOTE)) prefix,
]; Style::default().fg(colors::BLOCKQUOTE),
)];
spans.extend(std::mem::take(&mut self.current_spans)); spans.extend(std::mem::take(&mut self.current_spans));
self.lines.push(Line::from(spans)); self.lines.push(Line::from(spans));
} else { } 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 { } else if self.in_blockquote {
// Empty blockquote line // Empty blockquote line
let prefix = "".repeat(self.blockquote_depth); let prefix = "".repeat(self.blockquote_depth);
self.lines.push(Line::from(Span::styled( self.lines.push(Line::from(Span::styled(
prefix, prefix,
Style::default().fg(colors::BLOCKQUOTE) Style::default().fg(colors::BLOCKQUOTE),
))); )));
} }
} }
@ -312,11 +328,19 @@ impl ParseContext {
/// Get list marker for current depth /// Get list marker for current depth
pub fn get_list_marker(&self) -> String { pub fn get_list_marker(&self) -> String {
if let Some(num) = self.list_number { 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 { } else {
let markers = ['•', '◦', '▪', '▸']; let markers = ['•', '◦', '▪', '▸'];
let marker = markers[(self.list_depth.saturating_sub(1)) % markers.len()]; 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 content = self.buffer.content();
let line_count = content.lines().count(); let line_count = content.lines().count();
let last_line_len = content.lines().last().map(|l| l.len()).unwrap_or(0); let last_line_len = content.lines().last().map(|l| l.len()).unwrap_or(0);
self.cursor = CursorPosition::new( self.cursor = CursorPosition::new(line_count.saturating_sub(1), last_line_len);
line_count.saturating_sub(1),
last_line_len,
);
} }
/// Mark stream as complete /// Mark stream as complete
@ -685,9 +706,8 @@ impl MarkdownStream {
} }
// Configure pulldown-cmark options // Configure pulldown-cmark options
let options = Options::ENABLE_TABLES let options =
| Options::ENABLE_STRIKETHROUGH Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
| Options::ENABLE_TASKLISTS;
let parser = Parser::new_ext(&content, options); let parser = Parser::new_ext(&content, options);
let mut ctx = ParseContext::default(); let mut ctx = ParseContext::default();
@ -700,15 +720,15 @@ impl MarkdownStream {
Tag::Heading { level, .. } => { Tag::Heading { level, .. } => {
ctx.finish_line(); ctx.finish_line();
let style = match level { let style = match level {
pulldown_cmark::HeadingLevel::H1 => { pulldown_cmark::HeadingLevel::H1 => Style::default()
Style::default().fg(colors::HEADING_1).add_modifier(Modifier::BOLD) .fg(colors::HEADING_1)
} .add_modifier(Modifier::BOLD),
pulldown_cmark::HeadingLevel::H2 => { pulldown_cmark::HeadingLevel::H2 => Style::default()
Style::default().fg(colors::HEADING_2).add_modifier(Modifier::BOLD) .fg(colors::HEADING_2)
} .add_modifier(Modifier::BOLD),
pulldown_cmark::HeadingLevel::H3 => { pulldown_cmark::HeadingLevel::H3 => Style::default()
Style::default().fg(colors::HEADING_3).add_modifier(Modifier::BOLD) .fg(colors::HEADING_3)
} .add_modifier(Modifier::BOLD),
_ => Style::default().add_modifier(Modifier::BOLD), _ => Style::default().add_modifier(Modifier::BOLD),
}; };
let prefix = match level { let prefix = match level {
@ -719,7 +739,8 @@ impl MarkdownStream {
pulldown_cmark::HeadingLevel::H5 => "##### ", pulldown_cmark::HeadingLevel::H5 => "##### ",
pulldown_cmark::HeadingLevel::H6 => "###### ", 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 => { Tag::Paragraph => {
ctx.finish_line(); ctx.finish_line();
@ -767,16 +788,18 @@ impl MarkdownStream {
} }
Tag::Table(alignments) => { Tag::Table(alignments) => {
ctx.finish_line(); ctx.finish_line();
let mut table = TableState::default(); let table = TableState {
table.alignments = alignments alignments: alignments
.into_iter() .into_iter()
.map(|a| match a { .map(|a| match a {
pulldown_cmark::Alignment::Left => Alignment::Left, pulldown_cmark::Alignment::Left => Alignment::Left,
pulldown_cmark::Alignment::Center => Alignment::Center, pulldown_cmark::Alignment::Center => Alignment::Center,
pulldown_cmark::Alignment::Right => Alignment::Right, pulldown_cmark::Alignment::Right => Alignment::Right,
pulldown_cmark::Alignment::None => Alignment::Left, pulldown_cmark::Alignment::None => Alignment::Left,
}) })
.collect(); .collect(),
..Default::default()
};
ctx.table = Some(table); ctx.table = Some(table);
} }
Tag::TableHead => { Tag::TableHead => {
@ -801,7 +824,9 @@ impl MarkdownStream {
Tag::Image { dest_url, .. } => { Tag::Image { dest_url, .. } => {
ctx.current_spans.push(Span::styled( ctx.current_spans.push(Span::styled(
format!("[image: {}]", dest_url), 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() { if let Some(url) = ctx.inline_style.link_url.take() {
ctx.current_spans.push(Span::styled( ctx.current_spans.push(Span::styled(
format!(" ({})", url), 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 mut string_char = '"';
let keywords = [ let keywords = [
"fn", "let", "mut", "const", "pub", "mod", "use", "struct", "enum", "impl", "fn",
"trait", "where", "if", "else", "match", "for", "while", "loop", "return", "let",
"break", "continue", "async", "await", "self", "Self", "true", "false", "mut",
"Some", "None", "Ok", "Err", "type", "static", "extern", "crate", "super", "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 // TypeScript/JavaScript
"function", "class", "interface", "extends", "import", "export", "default", "function",
"new", "this", "try", "catch", "throw", "finally", "typeof", "instanceof", "class",
"interface",
"extends",
"import",
"export",
"default",
"new",
"this",
"try",
"catch",
"throw",
"finally",
"typeof",
"instanceof",
// Python // Python
"def", "class", "import", "from", "as", "pass", "raise", "with", "yield", "def",
"lambda", "global", "nonlocal", "assert", "del", "in", "is", "not", "and", "or", "class",
"import",
"from",
"as",
"pass",
"raise",
"with",
"yield",
"lambda",
"global",
"nonlocal",
"assert",
"del",
"in",
"is",
"not",
"and",
"or",
]; ];
let types = [ let types = [
"String", "Vec", "Option", "Result", "Box", "Rc", "Arc", "HashMap", "HashSet", "String",
"bool", "char", "str", "i8", "i16", "i32", "i64", "i128", "isize", "Vec",
"u8", "u16", "u32", "u64", "u128", "usize", "f32", "f64", "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 // TypeScript
"number", "string", "boolean", "void", "null", "undefined", "any", "never", "number",
"Array", "Promise", "Map", "Set", "Object", "string",
"boolean",
"void",
"null",
"undefined",
"any",
"never",
"Array",
"Promise",
"Map",
"Set",
"Object",
]; ];
for ch in line.chars() { 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(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) 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 { fn get_word_style(word: &str, keywords: &[&str], types: &[&str]) -> Style {
if keywords.contains(&word) { if keywords.contains(&word) {
Style::default().fg(Color::Rgb(255, 121, 198)) // Pink - keywords Style::default().fg(Color::Rgb(255, 121, 198)) // Pink - keywords
} else if types.contains(&word) { } else if types.contains(&word)
Style::default().fg(Color::Rgb(139, 233, 253)) // Cyan - types || word
} else if word.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) { .chars()
Style::default().fg(Color::Rgb(139, 233, 253)) // Cyan - types (PascalCase) .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 == '.') { } else if word.chars().all(|c| c.is_ascii_digit() || c == '.') {
Style::default().fg(Color::Rgb(189, 147, 249)) // Purple - numbers Style::default().fg(Color::Rgb(189, 147, 249)) // Purple - numbers
} else { } else {
@ -1205,7 +1330,11 @@ mod tests {
// Markdown renders paragraphs with blank lines between them // Markdown renders paragraphs with blank lines between them
let line_count = stream.line_count(); 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()); assert!(stream.scroll().can_scroll_down());
stream.scroll_down(2); stream.scroll_down(2);
@ -1234,13 +1363,20 @@ mod tests {
// Markdown renders paragraphs (at least 5 lines with blank lines) // Markdown renders paragraphs (at least 5 lines with blank lines)
let initial_count = lines.len(); 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 // Add more content
stream.push_str("\n\nLine 6"); stream.push_str("\n\nLine 6");
let lines = stream.render(); let lines = stream.render();
// Should have more lines now // 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 /// Dismiss all notifications
DismissAll, DismissAll,
/// Execute action on notification /// Execute action on notification
ExecuteAction { notification_id: String, action_id: String }, ExecuteAction {
notification_id: String,
action_id: String,
},
/// Mark all as read /// Mark all as read
MarkAllRead, MarkAllRead,
} }
@ -424,26 +427,21 @@ impl NotificationPanel {
let header = format!( let header = format!(
"{} {} {} - {}", "{} {} {} - {}",
read_indicator, read_indicator, icon, notification.title, age
icon,
notification.title,
age
); );
let mut lines = vec![ let mut lines = vec![Line::from(Span::styled(
Line::from(Span::styled( header,
header, Style::default()
Style::default() .fg(notification.priority.color())
.fg(notification.priority.color()) .add_modifier(if is_selected {
.add_modifier(if is_selected { Modifier::BOLD
Modifier::BOLD } else if notification.read {
} else if notification.read { Modifier::DIM
Modifier::DIM } else {
} else { Modifier::empty()
Modifier::empty() }),
}), ))];
)),
];
// Add message (truncated) // Add message (truncated)
let msg = if notification.message.len() > 60 { let msg = if notification.message.len() > 60 {
@ -453,13 +451,13 @@ impl NotificationPanel {
}; };
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
msg, msg,
Style::default().fg(colors::FG).add_modifier( Style::default()
if notification.read { .fg(colors::FG)
.add_modifier(if notification.read {
Modifier::DIM Modifier::DIM
} else { } else {
Modifier::empty() Modifier::empty()
}, }),
),
))); )));
// Add actions if selected // Add actions if selected
@ -485,8 +483,7 @@ impl NotificationPanel {
}) })
.collect(); .collect();
let list = List::new(items) let list = List::new(items).highlight_style(Style::default().bg(colors::SELECTION));
.highlight_style(Style::default().bg(colors::SELECTION));
// Render with state // Render with state
ratatui::widgets::StatefulWidget::render(list, inner, buf, &mut self.list_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; let filled = (progress * progress_area.width as f64) as u16;
for x in progress_area.x..progress_area.x + filled { 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); cell.set_bg(colors::GREEN);
} }
} }
@ -918,26 +918,17 @@ impl NotificationCenter {
/// Convenience method for info notification /// Convenience method for info notification
pub fn info(&mut self, title: impl Into<String>, message: impl Into<String>) { pub fn info(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.notify( self.notify(Notification::new(title, message).with_priority(NotificationPriority::Low));
Notification::new(title, message)
.with_priority(NotificationPriority::Low)
);
} }
/// Convenience method for success notification /// Convenience method for success notification
pub fn success(&mut self, title: impl Into<String>, message: impl Into<String>) { pub fn success(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.notify( self.notify(Notification::new(title, message).with_priority(NotificationPriority::Normal));
Notification::new(title, message)
.with_priority(NotificationPriority::Normal)
);
} }
/// Convenience method for warning notification /// Convenience method for warning notification
pub fn warning(&mut self, title: impl Into<String>, message: impl Into<String>) { pub fn warning(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.notify( self.notify(Notification::new(title, message).with_priority(NotificationPriority::High));
Notification::new(title, message)
.with_priority(NotificationPriority::High)
);
} }
/// Convenience method for error notification /// Convenience method for error notification
@ -945,7 +936,7 @@ impl NotificationCenter {
self.notify( self.notify(
Notification::new(title, message) Notification::new(title, message)
.with_priority(NotificationPriority::Critical) .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] #[test]
fn test_notification_with_priority() { fn test_notification_with_priority() {
let notification = Notification::new("Title", "Message") let notification =
.with_priority(NotificationPriority::Critical); Notification::new("Title", "Message").with_priority(NotificationPriority::Critical);
assert_eq!(notification.priority, NotificationPriority::Critical); assert_eq!(notification.priority, NotificationPriority::Critical);
} }
#[test] #[test]
fn test_notification_with_source() { fn test_notification_with_source() {
let notification = Notification::new("Title", "Message") let notification = Notification::new("Title", "Message").with_source("system");
.with_source("system");
assert_eq!(notification.source, Some("system".to_string())); assert_eq!(notification.source, Some("system".to_string()));
} }
#[test] #[test]
fn test_notification_with_duration() { fn test_notification_with_duration() {
let notification = Notification::new("Title", "Message") let notification =
.with_duration(Some(Duration::from_secs(10))); Notification::new("Title", "Message").with_duration(Some(Duration::from_secs(10)));
assert_eq!(notification.duration, Some(Duration::from_secs(10))); assert_eq!(notification.duration, Some(Duration::from_secs(10)));
let persistent = Notification::new("Title", "Message") let persistent = Notification::new("Title", "Message").with_duration(None);
.with_duration(None);
assert_eq!(persistent.duration, None); assert_eq!(persistent.duration, None);
} }
@ -1025,14 +1014,13 @@ mod tests {
#[test] #[test]
fn test_notification_is_expired() { fn test_notification_is_expired() {
// Short duration notification // Short duration notification
let notification = Notification::new("Title", "Message") let notification =
.with_duration(Some(Duration::from_millis(1))); Notification::new("Title", "Message").with_duration(Some(Duration::from_millis(1)));
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));
assert!(notification.is_expired()); assert!(notification.is_expired());
// Persistent notification never expires // Persistent notification never expires
let persistent = Notification::new("Title", "Message") let persistent = Notification::new("Title", "Message").with_duration(None);
.with_duration(None);
assert!(!persistent.is_expired()); assert!(!persistent.is_expired());
} }
@ -1138,8 +1126,7 @@ mod tests {
fn test_panel_cleanup_expired() { fn test_panel_cleanup_expired() {
let mut panel = NotificationPanel::new(); let mut panel = NotificationPanel::new();
panel.push( panel.push(
Notification::new("Test", "Message") Notification::new("Test", "Message").with_duration(Some(Duration::from_millis(1))),
.with_duration(Some(Duration::from_millis(1)))
); );
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));
@ -1409,9 +1396,8 @@ mod tests {
#[test] #[test]
fn test_alert_handle_key_enter() { fn test_alert_handle_key_enter() {
let mut alert = Alert::new("Title", "Message").with_buttons(vec![ let mut alert =
AlertButton::new("ok", "OK"), Alert::new("Title", "Message").with_buttons(vec![AlertButton::new("ok", "OK")]);
]);
let action = alert.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); let action = alert.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
assert!(matches!(action, AlertAction::ButtonPressed(ref id) if id == "ok")); assert!(matches!(action, AlertAction::ButtonPressed(ref id) if id == "ok"));
@ -1466,8 +1452,7 @@ mod tests {
let mut center = NotificationCenter::new(); let mut center = NotificationCenter::new();
center.show_banner(Banner::new("Test").with_duration(Duration::from_millis(1))); center.show_banner(Banner::new("Test").with_duration(Duration::from_millis(1)));
center.notify( center.notify(
Notification::new("Test", "Message") Notification::new("Test", "Message").with_duration(Some(Duration::from_millis(1))),
.with_duration(Some(Duration::from_millis(1)))
); );
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));

View file

@ -12,7 +12,9 @@ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, widgets::{
Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
},
Frame, Frame,
}; };
@ -328,7 +330,10 @@ impl PagerOverlay {
/// Scroll down /// Scroll down
fn scroll_down(&mut self, lines: usize) { 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); self.scroll = (self.scroll + lines).min(max_scroll);
} }
@ -339,7 +344,10 @@ impl PagerOverlay {
/// Scroll to end /// Scroll to end
fn scroll_to_end(&mut self) { 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; self.scroll = max_scroll;
} }
@ -364,7 +372,9 @@ impl PagerOverlay {
let mut start = 0; let mut start = 0;
while let Some(pos) = line_lower[start..].find(&query) { while let Some(pos) = line_lower[start..].find(&query) {
let absolute_pos = start + pos; 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; start = absolute_pos + 1;
} }
} }
@ -424,7 +434,9 @@ impl PagerOverlay {
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
format!(" {} ", self.title), format!(" {} ", self.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -564,11 +576,7 @@ impl PagerOverlay {
// Search mode // Search mode
if self.search_mode { if self.search_mode {
let query = self let query = self.search.as_ref().map(|s| s.query.as_str()).unwrap_or("");
.search
.as_ref()
.map(|s| s.query.as_str())
.unwrap_or("");
let direction = self let direction = self
.search .search
.as_ref() .as_ref()
@ -603,7 +611,11 @@ impl PagerOverlay {
if let Some(search) = &self.search { if let Some(search) = &self.search {
if !search.matches.is_empty() { if !search.matches.is_empty() {
spans.push(Span::styled( 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), Style::default().fg(Color::Yellow),
)); ));
} }
@ -723,10 +735,7 @@ mod tests {
#[test] #[test]
fn test_pager_content_styled() { fn test_pager_content_styled() {
let lines = vec![ let lines = vec![Line::from("Line 1"), Line::from("Line 2")];
Line::from("Line 1"),
Line::from("Line 2"),
];
let content = PagerContent::Styled(lines); let content = PagerContent::Styled(lines);
assert_eq!(content.raw(), ""); assert_eq!(content.raw(), "");
assert_eq!(content.line_count(), 2); assert_eq!(content.line_count(), 2);
@ -742,8 +751,7 @@ mod tests {
#[test] #[test]
fn test_pager_content_builder() { fn test_pager_content_builder() {
let pager = PagerOverlay::new() let pager = PagerOverlay::new().content(PagerContent::Plain("test".to_string()));
.content(PagerContent::Plain("test".to_string()));
assert_eq!(pager.content.raw(), "test"); assert_eq!(pager.content.raw(), "test");
} }
@ -835,7 +843,10 @@ mod tests {
#[test] #[test]
fn test_pager_handle_key_scroll_down() { fn test_pager_handle_key_scroll_down() {
let mut pager = PagerOverlay::new(); 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.show_text(content);
pager.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty())); pager.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()));
@ -848,7 +859,10 @@ mod tests {
#[test] #[test]
fn test_pager_handle_key_scroll_up() { fn test_pager_handle_key_scroll_up() {
let mut pager = PagerOverlay::new(); 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.show_text(content);
pager.scroll = 5; pager.scroll = 5;
@ -862,7 +876,10 @@ mod tests {
#[test] #[test]
fn test_pager_handle_key_page_down() { fn test_pager_handle_key_page_down() {
let mut pager = PagerOverlay::new(); 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.show_text(content);
pager.viewport_height = 10; pager.viewport_height = 10;
@ -873,7 +890,10 @@ mod tests {
#[test] #[test]
fn test_pager_handle_key_page_up() { fn test_pager_handle_key_page_up() {
let mut pager = PagerOverlay::new(); 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.show_text(content);
pager.viewport_height = 10; pager.viewport_height = 10;
pager.scroll = 20; pager.scroll = 20;
@ -899,7 +919,10 @@ mod tests {
#[test] #[test]
fn test_pager_handle_key_end() { fn test_pager_handle_key_end() {
let mut pager = PagerOverlay::new(); 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.show_text(content);
pager.viewport_height = 10; pager.viewport_height = 10;
@ -1060,9 +1083,7 @@ mod tests {
#[test] #[test]
fn test_pager_builder_title() { fn test_pager_builder_title() {
let pager = PagerBuilder::help("content") let pager = PagerBuilder::help("content").title("Custom Title").build();
.title("Custom Title")
.build();
assert_eq!(pager.title, "Custom Title"); assert_eq!(pager.title, "Custom Title");
} }

View file

@ -228,7 +228,9 @@ impl ResumePicker {
/// Get selected session /// Get selected session
pub fn selected_session(&self) -> Option<&SessionEntry> { 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 /// Set sort order
@ -303,7 +305,9 @@ impl ResumePicker {
} }
ResumePickerAction::None 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() { if let Some(session) = self.selected_session() {
let id = session.id.clone(); let id = session.id.clone();
ResumePickerAction::Delete(id) ResumePickerAction::Delete(id)
@ -379,18 +383,16 @@ impl ResumePicker {
/// Sort sessions /// Sort sessions
fn sort_sessions(&mut self) { fn sort_sessions(&mut self) {
// Pinned items always first // Pinned items always first
self.sessions.sort_by(|a, b| { self.sessions.sort_by(|a, b| match (a.pinned, b.pinned) {
match (a.pinned, b.pinned) { (true, false) => std::cmp::Ordering::Less,
(true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Greater, _ => match self.sort_order {
_ => match self.sort_order { SessionSortOrder::RecentFirst => b.updated_at.cmp(&a.updated_at),
SessionSortOrder::RecentFirst => b.updated_at.cmp(&a.updated_at), SessionSortOrder::OldestFirst => a.updated_at.cmp(&b.updated_at),
SessionSortOrder::OldestFirst => a.updated_at.cmp(&b.updated_at), SessionSortOrder::Alphabetical => a.title.cmp(&b.title),
SessionSortOrder::Alphabetical => a.title.cmp(&b.title), SessionSortOrder::MessageCount => b.message_count.cmp(&a.message_count),
SessionSortOrder::MessageCount => b.message_count.cmp(&a.message_count), SessionSortOrder::TokenUsage => b.tokens_used.cmp(&a.tokens_used),
SessionSortOrder::TokenUsage => b.tokens_used.cmp(&a.tokens_used), },
},
}
}); });
} }
@ -408,7 +410,10 @@ impl ResumePicker {
.filter(|(_, session)| { .filter(|(_, session)| {
session.title.to_lowercase().contains(&query) session.title.to_lowercase().contains(&query)
|| session.preview.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) .map(|(i, _)| i)
.collect(); .collect();
@ -444,7 +449,9 @@ impl ResumePicker {
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
format!(" {} ", self.title), format!(" {} ", self.title),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)); .border_style(Style::default().fg(Color::Cyan));
@ -646,7 +653,11 @@ impl ResumePicker {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled("Created: ", Style::default().fg(Color::Rgb(86, 95, 137))), Span::styled("Created: ", Style::default().fg(Color::Rgb(86, 95, 137))),
Span::styled( 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)), Style::default().fg(Color::Rgb(169, 177, 214)),
), ),
])); ]));
@ -835,7 +846,8 @@ mod tests {
#[test] #[test]
fn test_session_entry_tags() { 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.len(), 2);
assert_eq!(entry.tags[0], "rust"); assert_eq!(entry.tags[0], "rust");
} }
@ -952,8 +964,10 @@ mod tests {
fn test_picker_selected_session() { fn test_picker_selected_session() {
let now = Utc::now(); let now = Utc::now();
let sessions = vec![ let sessions = vec![
SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(2), now - Duration::hours(2)), SessionEntry::new("1", "Session 1")
SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(1), now - Duration::hours(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); let mut picker = ResumePicker::new().sessions(sessions);
picker.show(); picker.show();
@ -1025,9 +1039,12 @@ mod tests {
fn test_picker_handle_key_navigation() { fn test_picker_handle_key_navigation() {
let now = Utc::now(); let now = Utc::now();
let sessions = vec![ let sessions = vec![
SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(3), now - Duration::hours(3)), SessionEntry::new("1", "Session 1")
SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(2), now - Duration::hours(2)), .timestamps(now - Duration::hours(3), now - Duration::hours(3)),
SessionEntry::new("3", "Session 3").timestamps(now - Duration::hours(1), now - Duration::hours(1)), 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); let mut picker = ResumePicker::new().sessions(sessions);
picker.show(); picker.show();
@ -1045,8 +1062,10 @@ mod tests {
fn test_picker_handle_key_tab() { fn test_picker_handle_key_tab() {
let now = Utc::now(); let now = Utc::now();
let sessions = vec![ let sessions = vec![
SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(2), now - Duration::hours(2)), SessionEntry::new("1", "Session 1")
SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(1), now - Duration::hours(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); let mut picker = ResumePicker::new().sessions(sessions);
picker.show(); picker.show();
@ -1060,9 +1079,12 @@ mod tests {
fn test_picker_handle_key_home_end() { fn test_picker_handle_key_home_end() {
let now = Utc::now(); let now = Utc::now();
let sessions = vec![ let sessions = vec![
SessionEntry::new("1", "Session 1").timestamps(now - Duration::hours(3), now - Duration::hours(3)), SessionEntry::new("1", "Session 1")
SessionEntry::new("2", "Session 2").timestamps(now - Duration::hours(2), now - Duration::hours(2)), .timestamps(now - Duration::hours(3), now - Duration::hours(3)),
SessionEntry::new("3", "Session 3").timestamps(now - Duration::hours(1), now - Duration::hours(1)), 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); let mut picker = ResumePicker::new().sessions(sessions);
picker.show(); picker.show();

View file

@ -206,10 +206,7 @@ impl SkeletonLoader {
// Pulse effect: entire bar pulses // Pulse effect: entire bar pulses
let intensity = (progress * std::f64::consts::PI * 2.0).sin() * 0.5 + 0.5; 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); let color = blend_colors(self.base_color, self.highlight_color, intensity);
vec![Span::styled( vec![Span::styled("".repeat(width), Style::default().fg(color))]
"".repeat(width),
Style::default().fg(color),
)]
} }
ShimmerEffect::Gradient => { ShimmerEffect::Gradient => {
// Gradient sweep // Gradient sweep
@ -234,10 +231,7 @@ impl SkeletonLoader {
self.highlight_color, self.highlight_color,
(progress * std::f64::consts::PI).sin(), (progress * std::f64::consts::PI).sin(),
); );
vec![Span::styled( vec![Span::styled("".repeat(width), Style::default().fg(color))]
"".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; let b = (b1 as f64 * (1.0 - t) + b2 as f64 * t) as u8;
Color::Rgb(r, g, b) 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 { impl LoadingState {
/// Check if loading /// Check if loading
pub fn is_loading(&self) -> bool { pub fn is_loading(&self) -> bool {
matches!(self, LoadingState::Loading(_) | LoadingState::Progress { .. }) matches!(
self,
LoadingState::Loading(_) | LoadingState::Progress { .. }
)
} }
/// Check if complete /// Check if complete
@ -818,7 +821,7 @@ mod tests {
fn test_shimmer_state_progress() { fn test_shimmer_state_progress() {
let state = ShimmerState::new(); let state = ShimmerState::new();
let progress = state.progress(); let progress = state.progress();
assert!(progress >= 0.0 && progress <= 1.0); assert!((0.0..=1.0).contains(&progress));
} }
#[test] #[test]
@ -1036,11 +1039,7 @@ mod tests {
#[test] #[test]
fn test_blend_colors_rgb() { fn test_blend_colors_rgb() {
let result = blend_colors( let result = blend_colors(Color::Rgb(0, 0, 0), Color::Rgb(255, 255, 255), 0.5);
Color::Rgb(0, 0, 0),
Color::Rgb(255, 255, 255),
0.5,
);
assert!(matches!(result, Color::Rgb(127, 127, 127))); assert!(matches!(result, Color::Rgb(127, 127, 127)));
} }

View file

@ -48,7 +48,10 @@ impl TextRange {
if start <= end { if start <= end {
Self { start, end } Self { start, end }
} else { } 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()); 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(); let full_text = self.get_text();
self.set_text(&format!( self.set_text(&format!(
"{}{}{}", "{}{}{}",
@ -801,7 +808,11 @@ impl TextArea {
)); ));
self.cursor = cursor; self.cursor = cursor;
} }
EditOp::Replace { pos, old_text, new_text } => { EditOp::Replace {
pos,
old_text,
new_text,
} => {
let full_text = self.get_text(); let full_text = self.get_text();
self.set_text(&format!( self.set_text(&format!(
"{}{}{}", "{}{}{}",

View file

@ -2,6 +2,9 @@
//! //!
//! Shared UI components and utilities for the TUI. //! Shared UI components and utilities for the TUI.
pub mod theme;
pub mod widgets;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@ -43,8 +46,8 @@ pub mod colors {
/// Common UI styles /// Common UI styles
pub mod styles { pub mod styles {
use ratatui::style::{Modifier, Style};
use super::colors; use super::colors;
use ratatui::style::{Modifier, Style};
/// Default text style /// Default text style
pub fn default() -> Style { pub fn default() -> Style {
@ -313,7 +316,9 @@ impl Modal {
let block = Block::default() let block = Block::default()
.title(Span::styled( .title(Span::styled(
format!(" {} ", self.title), format!(" {} ", self.title),
Style::default().fg(colors::CYAN).add_modifier(Modifier::BOLD), Style::default()
.fg(colors::CYAN)
.add_modifier(Modifier::BOLD),
)) ))
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(colors::BORDER_FOCUS)); .border_style(Style::default().fg(colors::BORDER_FOCUS));
@ -832,12 +837,12 @@ impl EmptyState {
/// Render the empty state /// Render the empty state
pub fn render(&self, frame: &mut Frame, area: Rect) { pub fn render(&self, frame: &mut Frame, area: Rect) {
let mut lines = vec![ let mut lines = vec![
Line::from(Span::styled(&self.icon, Style::default().fg(colors::FG_GUTTER))),
Line::from(""),
Line::from(Span::styled( Line::from(Span::styled(
&self.title, &self.icon,
styles::bold(), Style::default().fg(colors::FG_GUTTER),
)), )),
Line::from(""),
Line::from(Span::styled(&self.title, styles::bold())),
]; ];
if !self.description.is_empty() { if !self.description.is_empty() {
@ -1164,8 +1169,8 @@ mod tests {
#[test] #[test]
fn test_modal_navigation() { fn test_modal_navigation() {
let mut modal = Modal::new("Test") let mut modal =
.buttons(vec!["A".to_string(), "B".to_string(), "C".to_string()]); Modal::new("Test").buttons(vec!["A".to_string(), "B".to_string(), "C".to_string()]);
assert_eq!(modal.selected(), 0); assert_eq!(modal.selected(), 0);
modal.next(); modal.next();
@ -1463,9 +1468,7 @@ mod tests {
#[test] #[test]
fn test_key_hints_hint() { fn test_key_hints_hint() {
let hints = KeyHints::new() let hints = KeyHints::new().hint("Esc", "Close").hint("Enter", "Submit");
.hint("Esc", "Close")
.hint("Enter", "Submit");
assert_eq!(hints.hints.len(), 2); 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
style::Modifier,
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
}; };
use std::time::Instant; use std::time::Instant;
@ -16,11 +17,12 @@ use crate::{
help::{HelpAction, HelpViewer}, help::{HelpAction, HelpViewer},
history_cell::HistoryCell, history_cell::HistoryCell,
notification::{Alert, AlertAction, Notification, NotificationCenter, NotificationPanelAction}, notification::{Alert, AlertAction, Notification, NotificationCenter, NotificationPanelAction},
pager_overlay::{PagerAction, PagerOverlay, PagerContent}, pager_overlay::{PagerAction, PagerContent, PagerOverlay},
resume_picker::{ResumePicker, ResumePickerAction, SessionEntry}, resume_picker::{ResumePicker, ResumePickerAction, SessionEntry},
shimmer::Spinner, shimmer::Spinner,
ui::{colors, Breadcrumb, StatusBar, StatusItem}, ui::{colors, Breadcrumb, StatusBar, StatusItem},
}; };
use miyabi_core::anthropic::DEFAULT_MODEL;
/// Focus area in the main view /// Focus area in the main view
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -92,6 +94,8 @@ pub enum ViewAction {
OpenFile(String), OpenFile(String),
/// Copy to clipboard /// Copy to clipboard
Copy(String), Copy(String),
/// Toggle agent mode
ToggleAgentMode,
} }
/// Layout configuration /// Layout configuration
@ -118,7 +122,7 @@ impl Default for LayoutConfig {
sidebar_width: 25, sidebar_width: 25,
show_status_bar: true, show_status_bar: true,
show_breadcrumb: true, show_breadcrumb: true,
input_height: 3, input_height: 5,
min_history_height: 10, min_history_height: 10,
} }
} }
@ -142,6 +146,8 @@ pub struct MainView {
pub history_scroll: usize, pub history_scroll: usize,
/// Maximum scroll position /// Maximum scroll position
pub max_scroll: usize, pub max_scroll: usize,
/// Auto-follow history (stick to latest messages unless user scrolls)
pub history_follow_latest: bool,
/// Notification center /// Notification center
pub notifications: NotificationCenter, pub notifications: NotificationCenter,
/// Command popup /// Command popup
@ -170,6 +176,8 @@ pub struct MainView {
pub sidebar_items: Vec<String>, pub sidebar_items: Vec<String>,
/// Selected sidebar item /// Selected sidebar item
pub sidebar_selected: usize, pub sidebar_selected: usize,
/// Mode indicator (e.g., "🤖 AGENT")
pub mode_indicator: String,
} }
impl Default for MainView { impl Default for MainView {
@ -190,9 +198,10 @@ impl MainView {
history: Vec::new(), history: Vec::new(),
history_scroll: 0, history_scroll: 0,
max_scroll: 0, max_scroll: 0,
history_follow_latest: true,
notifications: NotificationCenter::new(), notifications: NotificationCenter::new(),
command_popup: CommandPopup::new(), command_popup: CommandPopup::new().with_default_commands(),
help_viewer: HelpViewer::new(), help_viewer: HelpViewer::with_defaults(),
approval_overlay: ApprovalOverlay::new(), approval_overlay: ApprovalOverlay::new(),
pager_overlay: PagerOverlay::new(), pager_overlay: PagerOverlay::new(),
session_picker: ResumePicker::new(), session_picker: ResumePicker::new(),
@ -201,10 +210,11 @@ impl MainView {
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "~".to_string()), .unwrap_or_else(|_| "~".to_string()),
session_name: "New Session".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, tokens_used: 0,
last_activity: Instant::now(), last_activity: Instant::now(),
sidebar_items: Vec::new(), sidebar_items: Vec::new(),
mode_indicator: String::new(),
sidebar_selected: 0, sidebar_selected: 0,
} }
} }
@ -236,6 +246,7 @@ impl MainView {
/// Show help viewer /// Show help viewer
pub fn show_help(&mut self) { pub fn show_help(&mut self) {
self.overlay = ActiveOverlay::Help; self.overlay = ActiveOverlay::Help;
self.help_viewer.show();
} }
/// Show approval dialog /// 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 /// Handle keyboard input
pub fn handle_key(&mut self, key: KeyEvent) -> ViewAction { pub fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
self.last_activity = Instant::now(); self.last_activity = Instant::now();
@ -342,6 +358,10 @@ impl MainView {
self.show_notifications(); self.show_notifications();
return ViewAction::None; return ViewAction::None;
} }
// Toggle agent mode
(KeyModifiers::CONTROL, KeyCode::Char('a')) => {
return ViewAction::ToggleAgentMode;
}
// Escape // Escape
(KeyModifiers::NONE, KeyCode::Esc) => { (KeyModifiers::NONE, KeyCode::Esc) => {
if self.mode == AppMode::Streaming { if self.mode == AppMode::Streaming {
@ -362,85 +382,70 @@ impl MainView {
/// Handle overlay keyboard input /// Handle overlay keyboard input
fn handle_overlay_key(&mut self, key: KeyEvent) -> ViewAction { fn handle_overlay_key(&mut self, key: KeyEvent) -> ViewAction {
match self.overlay { match self.overlay {
ActiveOverlay::CommandPalette => { ActiveOverlay::CommandPalette => match self.command_popup.handle_key(key) {
match self.command_popup.handle_key(key) { CommandPopupAction::Execute(cmd) => {
CommandPopupAction::Execute(cmd) => { self.close_overlay();
self.close_overlay(); return ViewAction::ExecuteCommand(cmd);
return ViewAction::ExecuteCommand(cmd);
}
CommandPopupAction::Cancel => {
self.close_overlay();
}
_ => {}
} }
} CommandPopupAction::Cancel => {
ActiveOverlay::Help => { self.close_overlay();
match self.help_viewer.handle_key(key) {
HelpAction::Close => {
self.close_overlay();
}
_ => {}
} }
} _ => {}
ActiveOverlay::Approval => { },
match self.approval_overlay.handle_key(key) { ActiveOverlay::Help => if self.help_viewer.handle_key(key) == HelpAction::Close {
ApprovalAction::Approve(id) | ApprovalAction::ApproveAll(id) => { self.close_overlay();
self.close_overlay(); },
return ViewAction::Approve { ActiveOverlay::Approval => match self.approval_overlay.handle_key(key) {
request_id: id, ApprovalAction::Approve(id) | ApprovalAction::ApproveAll(id) => {
approved: true, self.close_overlay();
}; return ViewAction::Approve {
} request_id: id,
ApprovalAction::Reject(id) => { approved: true,
self.close_overlay(); };
return ViewAction::Approve {
request_id: id,
approved: false,
};
}
_ => {}
} }
} ApprovalAction::Reject(id) => {
ActiveOverlay::Pager => { self.close_overlay();
match self.pager_overlay.handle_key(key) { return ViewAction::Approve {
PagerAction::Close => { request_id: id,
self.close_overlay(); approved: false,
} };
PagerAction::Copy(content) => {
return ViewAction::Copy(content);
}
_ => {}
} }
} _ => {}
ActiveOverlay::SessionPicker => { },
match self.session_picker.handle_key(key) { ActiveOverlay::Pager => match self.pager_overlay.handle_key(key) {
ResumePickerAction::Select(session_id) => { PagerAction::Close => {
self.close_overlay(); self.close_overlay();
return ViewAction::ResumeSession(session_id);
}
ResumePickerAction::Cancel => {
self.close_overlay();
}
_ => {}
} }
} PagerAction::Copy(content) => {
ActiveOverlay::Notifications => { return ViewAction::Copy(content);
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::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 => { ActiveOverlay::Alert => {
if let Some(ref mut alert) = self.notifications.alert { if let Some(ref mut alert) = self.notifications.alert {
match alert.handle_key(key) { match alert.handle_key(key) {
@ -486,32 +491,43 @@ impl MainView {
/// Handle history navigation keys /// Handle history navigation keys
fn handle_history_key(&mut self, key: KeyEvent) -> ViewAction { fn handle_history_key(&mut self, key: KeyEvent) -> ViewAction {
let mut moved = false;
match key.code { match key.code {
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
self.history_scroll = self.history_scroll.saturating_sub(1); self.history_scroll = self.history_scroll.saturating_sub(1);
moved = true;
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
if self.history_scroll < self.max_scroll { if self.history_scroll < self.max_scroll {
self.history_scroll += 1; self.history_scroll += 1;
moved = true;
} }
} }
KeyCode::PageUp => { KeyCode::PageUp => {
self.history_scroll = self.history_scroll.saturating_sub(10); self.history_scroll = self.history_scroll.saturating_sub(10);
moved = true;
} }
KeyCode::PageDown => { KeyCode::PageDown => {
self.history_scroll = (self.history_scroll + 10).min(self.max_scroll); self.history_scroll = (self.history_scroll + 10).min(self.max_scroll);
moved = true;
} }
KeyCode::Home | KeyCode::Char('g') => { KeyCode::Home | KeyCode::Char('g') => {
self.history_scroll = 0; self.history_scroll = 0;
moved = true;
} }
KeyCode::End | KeyCode::Char('G') => { KeyCode::End | KeyCode::Char('G') => {
self.history_scroll = self.max_scroll; self.history_scroll = self.max_scroll;
moved = true;
} }
KeyCode::Tab | KeyCode::Char('i') => { KeyCode::Tab | KeyCode::Char('i') => {
self.focus = FocusArea::Chat; 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 ViewAction::None
} }
@ -633,7 +649,8 @@ impl MainView {
frame.render_widget(block, area); frame.render_widget(block, area);
// Render sidebar items // Render sidebar items
let items: Vec<Line> = self.sidebar_items let items: Vec<Line> = self
.sidebar_items
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, item)| { .map(|(i, item)| {
@ -660,13 +677,13 @@ impl MainView {
/// Render message history /// Render message history
fn render_history(&mut self, frame: &mut Frame, area: Rect) { fn render_history(&mut self, frame: &mut Frame, area: Rect) {
let block = Block::default() let block = Block::default().borders(Borders::ALL).border_style(
.borders(Borders::ALL) if self.focus == FocusArea::History {
.border_style(if self.focus == FocusArea::History {
Style::default().fg(colors::CYAN) Style::default().fg(colors::CYAN)
} else { } else {
Style::default().fg(colors::BORDER) Style::default().fg(colors::BORDER)
}); },
);
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
@ -704,13 +721,13 @@ impl MainView {
let visible_lines = inner.height as usize; let visible_lines = inner.height as usize;
self.max_scroll = total_lines.saturating_sub(visible_lines); self.max_scroll = total_lines.saturating_sub(visible_lines);
if self.history_follow_latest {
self.history_scroll = self.max_scroll;
}
// Apply scroll // Apply scroll
let start = self.history_scroll.min(self.max_scroll); let start = self.history_scroll.min(self.max_scroll);
let visible: Vec<Line> = lines let visible: Vec<Line> = lines.into_iter().skip(start).take(visible_lines).collect();
.into_iter()
.skip(start)
.take(visible_lines)
.collect();
let paragraph = Paragraph::new(visible); let paragraph = Paragraph::new(visible);
frame.render_widget(paragraph, inner); frame.render_widget(paragraph, inner);
@ -721,13 +738,8 @@ impl MainView {
.orientation(ScrollbarOrientation::VerticalRight) .orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("")) .begin_symbol(Some(""))
.end_symbol(Some("")); .end_symbol(Some(""));
let mut scrollbar_state = ScrollbarState::new(total_lines) let mut scrollbar_state = ScrollbarState::new(total_lines).position(start);
.position(start); frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
frame.render_stateful_widget(
scrollbar,
area,
&mut scrollbar_state,
);
} }
} }
@ -736,7 +748,8 @@ impl MainView {
let is_focused = self.focus == FocusArea::Chat; let is_focused = self.focus == FocusArea::Chat;
// Update focused state for the composer // 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 // Use ChatComposer's built-in render which handles cursor display
self.chat.render(frame, area); self.chat.render(frame, area);
@ -744,11 +757,20 @@ impl MainView {
/// Render status bar /// Render status bar
fn render_status_bar(&self, frame: &mut Frame, area: Rect) { fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let mut status_bar = StatusBar::new() let mut status_bar = StatusBar::new().left(
.left( StatusItem::new(self.model_name.clone()).style(Style::default().fg(colors::CYAN)),
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 // Token count
if self.tokens_used > 0 { if self.tokens_used > 0 {
@ -762,8 +784,7 @@ impl MainView {
let unread = self.notifications.unread_count(); let unread = self.notifications.unread_count();
if unread > 0 { if unread > 0 {
status_bar = status_bar.left( status_bar = status_bar.left(
StatusItem::new(format!("{}N", unread)) StatusItem::new(format!("{}N", unread)).style(Style::default().fg(colors::YELLOW)),
.style(Style::default().fg(colors::YELLOW)),
); );
} }
@ -774,15 +795,14 @@ impl MainView {
AppMode::WaitingApproval => "APPROVAL", AppMode::WaitingApproval => "APPROVAL",
AppMode::Loading => "LOADING", AppMode::Loading => "LOADING",
}; };
status_bar = status_bar.right( status_bar = status_bar.right(StatusItem::new(mode_str).style(Style::default().fg(
StatusItem::new(mode_str) match self.mode {
.style(Style::default().fg(match self.mode { AppMode::Normal => colors::GREEN,
AppMode::Normal => colors::GREEN, AppMode::Streaming => colors::YELLOW,
AppMode::Streaming => colors::YELLOW, AppMode::WaitingApproval => colors::ORANGE,
AppMode::WaitingApproval => colors::ORANGE, AppMode::Loading => colors::CYAN,
AppMode::Loading => colors::CYAN, },
})), )));
);
status_bar.render(frame, area); status_bar.render(frame, area);
} }
@ -812,7 +832,9 @@ impl MainView {
} }
ActiveOverlay::Notifications => { ActiveOverlay::Notifications => {
let popup_area = centered_rect(60, 70, area); 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 => { ActiveOverlay::Alert => {
if let Some(ref alert) = self.notifications.alert { if let Some(ref alert) = self.notifications.alert {