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:
parent
af281f1ed0
commit
25d358f96e
56 changed files with 4842 additions and 910 deletions
|
|
@ -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
515
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
|
||||||
7
GOALS.md
7
GOALS.md
|
|
@ -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
135
README.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
358
crates/miyabi-core/src/agent/core.rs
Normal file
358
crates/miyabi-core/src/agent/core.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
120
crates/miyabi-core/src/agent/events.rs
Normal file
120
crates/miyabi-core/src/agent/events.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
213
crates/miyabi-core/src/agent/executor.rs
Normal file
213
crates/miyabi-core/src/agent/executor.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/miyabi-core/src/agent/mod.rs
Normal file
12
crates/miyabi-core/src/agent/mod.rs
Normal 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};
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
388
crates/miyabi-core/src/config.rs
Normal file
388
crates/miyabi-core/src/config.rs
Normal 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]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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::*;
|
||||||
|
|
|
||||||
346
crates/miyabi-core/src/session.rs
Normal file
346
crates/miyabi-core/src/session.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(®ex_pattern).map_err(|e| {
|
let regex = Regex::new(®ex_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]
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
20
crates/miyabi-tui/src/app/event_loop.rs
Normal file
20
crates/miyabi-tui/src/app/event_loop.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/miyabi-tui/src/app/state.rs
Normal file
34
crates/miyabi-tui/src/app/state.rs
Normal 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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
28
crates/miyabi-tui/src/domain/actions.rs
Normal file
28
crates/miyabi-tui/src/domain/actions.rs
Normal 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,
|
||||||
|
}
|
||||||
7
crates/miyabi-tui/src/domain/mod.rs
Normal file
7
crates/miyabi-tui/src/domain/mod.rs
Normal 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};
|
||||||
19
crates/miyabi-tui/src/domain/models.rs
Normal file
19
crates/miyabi-tui/src/domain/models.rs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
crates/miyabi-tui/src/input/handler.rs
Normal file
14
crates/miyabi-tui/src/input/handler.rs
Normal 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)))
|
||||||
|
}
|
||||||
38
crates/miyabi-tui/src/input/keymap.rs
Normal file
38
crates/miyabi-tui/src/input/keymap.rs
Normal 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
|
||||||
|
}
|
||||||
7
crates/miyabi-tui/src/input/mod.rs
Normal file
7
crates/miyabi-tui/src/input/mod.rs
Normal 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};
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
"{}{}{}",
|
"{}{}{}",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
33
crates/miyabi-tui/src/ui/theme.rs
Normal file
33
crates/miyabi-tui/src/ui/theme.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/miyabi-tui/src/ui/widgets/diff.rs
Normal file
14
crates/miyabi-tui/src/ui/widgets/diff.rs
Normal 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.
|
||||||
|
}
|
||||||
30
crates/miyabi-tui/src/ui/widgets/history_list.rs
Normal file
30
crates/miyabi-tui/src/ui/widgets/history_list.rs
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/miyabi-tui/src/ui/widgets/markdown.rs
Normal file
15
crates/miyabi-tui/src/ui/widgets/markdown.rs
Normal 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.
|
||||||
|
}
|
||||||
7
crates/miyabi-tui/src/ui/widgets/mod.rs
Normal file
7
crates/miyabi-tui/src/ui/widgets/mod.rs
Normal 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};
|
||||||
5
crates/miyabi-tui/src/update/mod.rs
Normal file
5
crates/miyabi-tui/src/update/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
//! Update loop helpers.
|
||||||
|
|
||||||
|
pub mod reducer;
|
||||||
|
|
||||||
|
pub use reducer::reduce;
|
||||||
20
crates/miyabi-tui/src/update/reducer.rs
Normal file
20
crates/miyabi-tui/src/update/reducer.rs
Normal 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(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue