Compare commits
269 Commits
c4a6bb1c0f
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 49531864f1 | |||
| 84fa08ab45 | |||
| f97bd44f05 | |||
| 9bc865b8fa | |||
| af1a61a468 | |||
| f610ed312c | |||
| 159f2d3330 | |||
| f5d465b5a9 | |||
| 37a36dd36d | |||
| 6601654b3a | |||
| 8d1d7ba184 | |||
| e3eb8c1b02 | |||
| b6ad2f09a5 | |||
| 0db472aaa5 | |||
| 1d7c584b55 | |||
| 4f0b91adda | |||
| 5c82a0e632 | |||
| 7a8a99b604 | |||
| 1e7c7cd65d | |||
| 6acb1dc091 | |||
| aed3879844 | |||
| a8fc9bbc54 | |||
| 2bccb11e53 | |||
| 352bbb76f2 | |||
| c54962b0e0 | |||
| 68e3993234 | |||
| 9648ddd69d | |||
| 7c66dfd36b | |||
| b0e65e4041 | |||
| abbda81659 | |||
| 62237bcd3b | |||
| 6486dd9b43 | |||
| 914f30c8e0 | |||
| b555256d21 | |||
| f1f1f88181 | |||
| a764fd6b75 | |||
| 2273817f5f | |||
| 997007439e | |||
| 41bf788a51 | |||
| f7aac0785b | |||
| 611547afa5 | |||
| f39c7a75f2 | |||
| 2e8efdaa12 | |||
| 495f63f0d8 | |||
| efc72c5ceb | |||
| b4a4a38fec | |||
| ffe899a3c0 | |||
| 94c89cea05 | |||
| b02a41c8e0 | |||
| f5a5724823 | |||
| fbb6681cd2 | |||
| 5b0774958a | |||
| 4a07b97eab | |||
| 10c8e2baae | |||
| 09c8c9d83e | |||
| 5caf502009 | |||
| 04a7085007 | |||
| 6022aeb2b0 | |||
| e77e33ce2f | |||
| f87e5d2796 | |||
| 3c436fda54 | |||
| 173403379f | |||
| 688d1fe58a | |||
| b1b95a4560 | |||
| a024a764d6 | |||
| 686526bbd4 | |||
| 5134462deb | |||
| d7ddc365ec | |||
| 6108b9e3d1 | |||
| a6cf8585ef | |||
| baf833427a | |||
| d21945dbc0 | |||
| 7f39bf1eca | |||
| dcda8216dc | |||
| ff49e7ce93 | |||
| b63d26f0cd | |||
| 64fd3206a2 | |||
| 2a651ebd7b | |||
| 491fd049b0 | |||
| c9e2f9bae6 | |||
| 7b87459a72 | |||
| 4935a64a13 | |||
| a84c8a425d | |||
| 16c0e71147 | |||
| 0728262a9e | |||
| 7aa80fb0a4 | |||
| 28b6eb0a9a | |||
| 353c0a8239 | |||
| 44b07c8e27 | |||
| 76e59c2d0e | |||
| c92e07b866 | |||
| 9aa8722ec3 | |||
| 7daa4f4ebe | |||
| a788b8941e | |||
| 16bc534837 | |||
| eef0e3dea0 | |||
| 5d9ecec82c | |||
| 6980640324 | |||
| a0868a9b49 | |||
| 877ece07be | |||
| f6a3f235df | |||
| a4f7a45e56 | |||
| 94ef08db6b | |||
| 57942219a8 | |||
| 03244e8d24 | |||
| d7066d7d37 | |||
| 124db19e68 | |||
| e89da02d49 | |||
| cf0a8f21d5 | |||
| 2d45406982 | |||
| f592840d39 | |||
| 9090bddf68 | |||
| 4981a63224 | |||
| 1238bbe000 | |||
| f29f306692 | |||
| 9024e2b914 | |||
| 6849d5ef12 | |||
| 3c6e689de9 | |||
| 1994367a2e | |||
| c3a92a092b | |||
| 6a94373c4f | |||
| 83280f68cc | |||
| 21759898fb | |||
| 02df6d893c | |||
| 8f9d601fdc | |||
| 40e42c8918 | |||
| 6e12bb3acb | |||
| 16b6f24e3e | |||
| 25628d1d58 | |||
| e813736b47 | |||
| 7e2c6ea037 | |||
| 3f6d7d56f6 | |||
| bbb94367e1 | |||
| 79fdafce97 | |||
| 24671f5f2a | |||
| e0b14a42f2 | |||
| 3e8788dd44 | |||
| 38a4c55eaa | |||
| c7b7fe98ec | |||
| 4820a6706f | |||
| 3308b483f7 | |||
| 4ce4ac0b0e | |||
| 3722840d2c | |||
| 02f25b7bec | |||
| d86888704f | |||
| de6b6e20a5 | |||
| 1e8a5e08ed | |||
| 218ebbf32f | |||
| c49e7f4b22 | |||
| 9588c8c562 | |||
| 1948ac1284 | |||
| 3f92b7d963 | |||
| 5553e61dbf | |||
| 7f987737f9 | |||
| 5182f86133 | |||
| a50099ad74 | |||
| 20ba5523ee | |||
| 0b2b3701dc | |||
| 438b05b8a3 | |||
| e2a31b192f | |||
| b827d3d047 | |||
| 9c0cf274a3 | |||
| 85ae319690 | |||
| 449f133a1f | |||
| 2f6b03ef65 | |||
| d4030dc598 | |||
| 3271697f6b | |||
| cbfef5a5df | |||
| 52efd5f341 | |||
| 200cdbc4bd | |||
| 8525819ab4 | |||
| bcd52d526c | |||
| 7effade1d3 | |||
| dc0fee2ee3 | |||
| ea04a25ed6 | |||
| 282dcdce88 | |||
| b49f58bc16 | |||
| cdc425ae93 | |||
| 3525cb3949 | |||
| 9d85420bf6 | |||
| 641c95131f | |||
| 708c626176 | |||
| 5210e196f2 | |||
| 30c375b6c5 | |||
| baf49b1e69 | |||
| 96e0436d43 | |||
| 498e6e61b6 | |||
| 99064b6c41 | |||
| ee58b0ac32 | |||
| 990f93d467 | |||
| 44a00619b5 | |||
| 6923ee439f | |||
| c997b19b53 | |||
| c9daf68fea | |||
| ba9d083088 | |||
| 825dfc0722 | |||
| 3e4eacd1d3 | |||
| 23253219a3 | |||
| cc2b85a86d | |||
| 58dd6f3efa | |||
| c81d0f1593 | |||
| ae0dd3fc51 | |||
| 80dffa9f41 | |||
| ab0ae4fe04 | |||
| d31e068277 | |||
| 690f5c7056 | |||
| 0da8a3f193 | |||
| 15f81d9728 | |||
| b80db89391 | |||
| f413a63c5a | |||
| 33ad3797a1 | |||
| 55e6b0583d | |||
| ae9c3af096 | |||
| 0bd560b408 | |||
| 083b621b7d | |||
| d2a193e5c1 | |||
| acbfe47a4b | |||
| 60c859b3ab | |||
| 82078afd6d | |||
| 7851af14a9 | |||
| c2f5ccea3b | |||
| fab63d224b | |||
| 15e5c1206b | |||
| 38aba1a6bb | |||
| d0d3079df5 | |||
| 56de1170ee | |||
| 952e4819fe | |||
| 5ac0d152cb | |||
| 40c44470e8 | |||
| 5c37df1b22 | |||
| 5e81185df3 | |||
| 7534c9ef8d | |||
| 9545a4b3ad | |||
| e94df2c48a | |||
| cdf95002fc | |||
| 4c066bf2da | |||
| e57844e742 | |||
| 33d11ae223 | |||
| 05e90d3e2b | |||
| fe414d49e6 | |||
| d002d35bde | |||
| c9c3d17db0 | |||
| a909455f97 | |||
| 67381b02db | |||
| 235f84fa19 | |||
| 9c777c8429 | |||
| 0b17a0f4c8 | |||
| 2eabe55fe6 | |||
| 4d7ad2c330 | |||
| 13af046eff | |||
| 5b202fed4f | |||
| 979347bf53 | |||
| 76b55ccff5 | |||
| f0e162d551 | |||
| 6c4571804f | |||
| a0cdcfdf6c | |||
| 96e2482782 | |||
| 6a3f44f911 | |||
| e0e5a2a83d | |||
| 23e86591d1 | |||
| b60a317788 | |||
| 2788e8b7e2 | |||
| 7c186882dc | |||
| bdda669d4d | |||
| 108070db4b | |||
| 08ba04e99f | |||
| e58032deae | |||
| 5c59539120 | |||
| c725bb1ce6 |
@@ -1,20 +0,0 @@
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "x86_64-linux-gnu-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||
|
||||
[target.armv7-unknown-linux-gnueabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
|
||||
[target.armv7-unknown-linux-musleabihf]
|
||||
linker = "arm-linux-gnueabihf-gcc"
|
||||
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
34
.gitignore
vendored
34
.gitignore
vendored
@@ -1,9 +1,12 @@
|
||||
### Custom
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
|
||||
### Rust template
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
dev/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
@@ -15,17 +18,10 @@ Cargo.lock
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
.idea/
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
@@ -56,14 +52,15 @@ Cargo.lock
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
.idea/artifacts
|
||||
.idea/compiler.xml
|
||||
.idea/jarRepositories.xml
|
||||
.idea/modules.xml
|
||||
.idea/*.iml
|
||||
.idea/modules
|
||||
*.iml
|
||||
*.ipr
|
||||
.idea
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
@@ -101,3 +98,8 @@ fabric.properties
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
### rust-analyzer template
|
||||
# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules)
|
||||
rust-project.json
|
||||
|
||||
|
||||
|
||||
1
.tmp
Submodule
1
.tmp
Submodule
Submodule .tmp added at 4928f2cdca
109
.woodpecker.yml
109
.woodpecker.yml
@@ -1,109 +0,0 @@
|
||||
when:
|
||||
event: tag
|
||||
tag: v*
|
||||
|
||||
variables:
|
||||
- &rust_image 'rust:1.83'
|
||||
|
||||
matrix:
|
||||
include:
|
||||
# Linux
|
||||
- TARGET: x86_64-unknown-linux-gnu
|
||||
ARTIFACT: owlen-linux-x86_64-gnu
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: x86_64-unknown-linux-musl
|
||||
ARTIFACT: owlen-linux-x86_64-musl
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: aarch64-unknown-linux-gnu
|
||||
ARTIFACT: owlen-linux-aarch64-gnu
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: aarch64-unknown-linux-musl
|
||||
ARTIFACT: owlen-linux-aarch64-musl
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: armv7-unknown-linux-gnueabihf
|
||||
ARTIFACT: owlen-linux-armv7-gnu
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
- TARGET: armv7-unknown-linux-musleabihf
|
||||
ARTIFACT: owlen-linux-armv7-musl
|
||||
PLATFORM: linux
|
||||
EXT: ""
|
||||
# Windows
|
||||
- TARGET: x86_64-pc-windows-gnu
|
||||
ARTIFACT: owlen-windows-x86_64
|
||||
PLATFORM: windows
|
||||
EXT: ".exe"
|
||||
|
||||
steps:
|
||||
- name: install-deps
|
||||
image: *rust_image
|
||||
commands:
|
||||
- apt-get update
|
||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 zip
|
||||
|
||||
- name: build
|
||||
image: *rust_image
|
||||
commands:
|
||||
- rustup target add ${TARGET}
|
||||
- |
|
||||
case "${TARGET}" in
|
||||
aarch64-unknown-linux-gnu)
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
||||
;;
|
||||
aarch64-unknown-linux-musl)
|
||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
||||
export CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc
|
||||
;;
|
||||
armv7-unknown-linux-gnueabihf)
|
||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
||||
;;
|
||||
armv7-unknown-linux-musleabihf)
|
||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
||||
export CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc
|
||||
;;
|
||||
x86_64-pc-windows-gnu)
|
||||
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
|
||||
;;
|
||||
esac
|
||||
- cargo build --release --all-features --target ${TARGET}
|
||||
|
||||
- name: package
|
||||
image: *rust_image
|
||||
commands:
|
||||
- mkdir -p dist
|
||||
- |
|
||||
if [ "${PLATFORM}" = "windows" ]; then
|
||||
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
|
||||
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
|
||||
cd dist
|
||||
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
|
||||
cd ..
|
||||
mv dist/${ARTIFACT}.zip .
|
||||
sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256
|
||||
else
|
||||
cp target/${TARGET}/release/owlen dist/owlen
|
||||
cp target/${TARGET}/release/owlen-code dist/owlen-code
|
||||
cd dist
|
||||
tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
||||
cd ..
|
||||
mv dist/${ARTIFACT}.tar.gz .
|
||||
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||
fi
|
||||
|
||||
- name: release
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: gitea_token
|
||||
base_url: https://somegit.dev
|
||||
files:
|
||||
- ${ARTIFACT}.tar.gz
|
||||
- ${ARTIFACT}.tar.gz.sha256
|
||||
- ${ARTIFACT}.zip
|
||||
- ${ARTIFACT}.zip.sha256
|
||||
title: Release ${CI_COMMIT_TAG}
|
||||
note: "Release ${CI_COMMIT_TAG}"
|
||||
86
Cargo.toml
86
Cargo.toml
@@ -1,64 +1,34 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/owlen-core",
|
||||
"crates/owlen-tui",
|
||||
"crates/owlen-cli",
|
||||
"crates/owlen-ollama",
|
||||
"crates/app/cli",
|
||||
"crates/app/ui",
|
||||
"crates/core/agent",
|
||||
"crates/llm/core",
|
||||
"crates/llm/anthropic",
|
||||
"crates/llm/ollama",
|
||||
"crates/llm/openai",
|
||||
"crates/platform/auth",
|
||||
"crates/platform/config",
|
||||
"crates/platform/credentials",
|
||||
"crates/platform/hooks",
|
||||
"crates/platform/permissions",
|
||||
"crates/platform/plugins",
|
||||
"crates/tools/ask",
|
||||
"crates/tools/bash",
|
||||
"crates/tools/fs",
|
||||
"crates/tools/notebook",
|
||||
"crates/tools/plan",
|
||||
"crates/tools/skill",
|
||||
"crates/tools/slash",
|
||||
"crates/tools/task",
|
||||
"crates/tools/todo",
|
||||
"crates/tools/web",
|
||||
"crates/integration/mcp-client",
|
||||
"crates/integration/jsonrpc",
|
||||
]
|
||||
exclude = []
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.5"
|
||||
edition = "2021"
|
||||
authors = ["Owlibou"]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0"
|
||||
repository = "https://somegit.dev/Owlibou/owlen"
|
||||
homepage = "https://somegit.dev/Owlibou/owlen"
|
||||
keywords = ["llm", "tui", "cli", "ollama", "chat"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async runtime and utilities
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
# TUI framework
|
||||
ratatui = "0.28"
|
||||
crossterm = "0.28"
|
||||
tui-textarea = "0.6"
|
||||
|
||||
# HTTP client and JSON handling
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Utilities
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Configuration
|
||||
toml = "0.8"
|
||||
shellexpand = "3.1"
|
||||
|
||||
# Database
|
||||
sled = "0.34"
|
||||
|
||||
# For better text handling
|
||||
textwrap = "0.16"
|
||||
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
# CLI framework
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
|
||||
# Dev dependencies
|
||||
tempfile = "3.8"
|
||||
tokio-test = "0.4"
|
||||
|
||||
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
rust-version = "1.91"
|
||||
|
||||
662
LICENSE
662
LICENSE
@@ -1,662 +0,0 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
311
PERMISSION_SYSTEM.md
Normal file
311
PERMISSION_SYSTEM.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Permission System - TUI Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The TUI now has a fully functional interactive permission system that allows users to grant, deny, or permanently allow tool executions through an elegant popup interface.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Interactive Permission Popup
|
||||
|
||||
**Location:** `crates/app/ui/src/components/permission_popup.rs`
|
||||
|
||||
- **Visual Design:**
|
||||
- Centered modal popup with themed border
|
||||
- Tool name highlighted with icon (⚡)
|
||||
- Context information (file path, command, etc.)
|
||||
- Four selectable options with icons
|
||||
- Keyboard shortcuts and navigation hints
|
||||
|
||||
- **Options Available:**
|
||||
- ✓ `[a]` **Allow once** - Execute this one time
|
||||
- ✓✓ `[A]` **Always allow** - Add permanent rule to permission manager
|
||||
- ✗ `[d]` **Deny** - Refuse this operation
|
||||
- ? `[?]` **Explain** - Show what this operation does
|
||||
|
||||
- **Navigation:**
|
||||
- Arrow keys (↑/↓) to select options
|
||||
- Enter to confirm selection
|
||||
- Keyboard shortcuts (a/A/d/?) for quick selection
|
||||
- Esc to deny and close
|
||||
|
||||
### 2. Permission Flow Integration
|
||||
|
||||
**Location:** `crates/app/ui/src/app.rs`
|
||||
|
||||
#### New Components:
|
||||
|
||||
1. **PendingToolCall struct:**
|
||||
```rust
|
||||
struct PendingToolCall {
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
perm_tool: PermTool,
|
||||
context: Option<String>,
|
||||
}
|
||||
```
|
||||
Stores information about tool awaiting permission.
|
||||
|
||||
2. **TuiApp fields:**
|
||||
- `pending_tool: Option<PendingToolCall>` - Current pending tool
|
||||
- `permission_tx: Option<oneshot::Sender<bool>>` - Channel to signal decision
|
||||
|
||||
3. **execute_tool_with_permission() method:**
|
||||
```rust
|
||||
async fn execute_tool_with_permission(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
arguments: &Value,
|
||||
) -> Result<String>
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. Maps tool name to PermTool enum (Read, Write, Edit, Bash, etc.)
|
||||
2. Extracts context (file path, command, etc.)
|
||||
3. Checks permission via PermissionManager
|
||||
4. If `Allow` → Execute immediately
|
||||
5. If `Deny` → Return error
|
||||
6. If `Ask` → Show popup and wait for user decision
|
||||
|
||||
**Async Wait Mechanism:**
|
||||
- Creates oneshot channel for permission response
|
||||
- Shows permission popup
|
||||
- Awaits channel response (with 5-minute timeout)
|
||||
- Event loop continues processing keyboard events
|
||||
- When user responds, channel signals and execution resumes
|
||||
|
||||
### 3. Permission Decision Handling
|
||||
|
||||
**Location:** `crates/app/ui/src/app.rs:184-254`
|
||||
|
||||
When user makes a choice in the popup:
|
||||
|
||||
- **Allow Once:**
|
||||
- Signals permission granted (sends `true` through channel)
|
||||
- Tool executes once
|
||||
- No persistent changes
|
||||
|
||||
- **Always Allow:**
|
||||
- Adds new rule to PermissionManager
|
||||
- Rule format: `perms.add_rule(tool, context, Action::Allow)`
|
||||
- Example: Always allow reading from `src/` directory
|
||||
- Signals permission granted
|
||||
- All future matching operations auto-approved
|
||||
|
||||
- **Deny:**
|
||||
- Signals permission denied (sends `false`)
|
||||
- Tool execution fails with error
|
||||
- Error shown in chat
|
||||
|
||||
- **Explain:**
|
||||
- Shows explanation of what the tool does
|
||||
- Popup remains open for user to choose again
|
||||
- Tool-specific explanations:
|
||||
- `read` → "read a file from disk"
|
||||
- `write` → "write or overwrite a file"
|
||||
- `edit` → "modify an existing file"
|
||||
- `bash` → "execute a shell command"
|
||||
- `grep` → "search for patterns in files"
|
||||
- `glob` → "list files matching a pattern"
|
||||
|
||||
### 4. Agent Loop Integration
|
||||
|
||||
**Location:** `crates/app/ui/src/app.rs:488`
|
||||
|
||||
Changed from:
|
||||
```rust
|
||||
match execute_tool(tool_name, arguments, &self.perms).await {
|
||||
```
|
||||
|
||||
To:
|
||||
```rust
|
||||
match self.execute_tool_with_permission(tool_name, arguments).await {
|
||||
```
|
||||
|
||||
This ensures all tool calls in the streaming agent loop go through the permission system.
|
||||
|
||||
## Architecture Details
|
||||
|
||||
### Async Concurrency Model
|
||||
|
||||
The implementation uses Rust's async/await with tokio to handle the permission flow without blocking the UI:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Event Loop │
|
||||
│ (continuously running at 60 FPS) │
|
||||
│ │
|
||||
│ while running { │
|
||||
│ terminal.draw(...) ← Always responsive │
|
||||
│ if let Ok(event) = event_rx.try_recv() { │
|
||||
│ handle_event(event).await │
|
||||
│ } │
|
||||
│ tokio::sleep(16ms).await ← Yields to runtime │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Keyboard Event Listener │
|
||||
│ (separate tokio task) │
|
||||
│ │
|
||||
│ loop { │
|
||||
│ event = event_stream.next().await │
|
||||
│ event_tx.send(Input(key)) │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↕
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Permission Request Flow │
|
||||
│ │
|
||||
│ 1. Tool needs permission (PermissionDecision::Ask) │
|
||||
│ 2. Create oneshot channel (tx, rx) │
|
||||
│ 3. Show popup, store tx │
|
||||
│ 4. await rx ← Yields to event loop │
|
||||
│ 5. Event loop continues, handles keyboard │
|
||||
│ 6. User presses 'a' → handle_event processes │
|
||||
│ 7. tx.send(true) signals channel │
|
||||
│ 8. rx.await completes, returns true │
|
||||
│ 9. Tool executes with permission │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Insight
|
||||
|
||||
The implementation works because:
|
||||
1. **Awaiting is non-blocking:** When we `await rx`, we yield control to the tokio runtime
|
||||
2. **Event loop continues:** The outer event loop continues to run its iterations
|
||||
3. **Keyboard events processed:** The separate event listener task continues reading keyboard
|
||||
4. **Channel signals resume:** When user responds, the channel completes and we resume
|
||||
|
||||
This creates a smooth UX where the UI remains responsive while waiting for permission.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: First-time File Write
|
||||
|
||||
```
|
||||
User: "Create a new file hello.txt with 'Hello World'"
|
||||
|
||||
Agent: [Calls write tool]
|
||||
|
||||
┌───────────────────────────────────────┐
|
||||
│ 🔒 Permission Required │
|
||||
├───────────────────────────────────────┤
|
||||
│ ⚡ Tool: write │
|
||||
│ 📝 Context: │
|
||||
│ hello.txt │
|
||||
├───────────────────────────────────────┤
|
||||
│ ▶ ✓ [a] Allow once │
|
||||
│ ✓✓ [A] Always allow │
|
||||
│ ✗ [d] Deny │
|
||||
│ ? [?] Explain │
|
||||
│ │
|
||||
│ ↑↓ Navigate Enter to select Esc... │
|
||||
└───────────────────────────────────────┘
|
||||
|
||||
User presses 'a' → File created once
|
||||
```
|
||||
|
||||
### Example 2: Always Allow Bash in Current Directory
|
||||
|
||||
```
|
||||
User: "Run npm test"
|
||||
|
||||
Agent: [Calls bash tool]
|
||||
|
||||
[Permission popup shows with context: "npm test"]
|
||||
|
||||
User presses 'A' → Rule added: bash("npm test*") → Allow
|
||||
|
||||
Future: User: "Run npm test:unit"
|
||||
Agent: [Executes immediately, no popup]
|
||||
```
|
||||
|
||||
### Example 3: Explanation Request
|
||||
|
||||
```
|
||||
User: "Read my secrets.env file"
|
||||
|
||||
[Permission popup appears]
|
||||
|
||||
User presses '?' →
|
||||
|
||||
System: "Tool 'read' requires permission. This operation
|
||||
will read a file from disk."
|
||||
|
||||
[Popup remains open]
|
||||
|
||||
User presses 'd' → Permission denied
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Build status: ✅ All tests pass
|
||||
```bash
|
||||
cargo build --workspace # Success
|
||||
cargo test --workspace --lib # 28 tests passed
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The permission system respects three modes from `PermissionManager`:
|
||||
|
||||
1. **Plan Mode** (default):
|
||||
- Read operations (read, grep, glob) → Auto-allowed
|
||||
- Write operations (write, edit) → Ask
|
||||
- System operations (bash) → Ask
|
||||
|
||||
2. **AcceptEdits Mode**:
|
||||
- Read operations → Auto-allowed
|
||||
- Write operations → Auto-allowed
|
||||
- System operations (bash) → Ask
|
||||
|
||||
3. **Code Mode**:
|
||||
- All operations → Auto-allowed
|
||||
- No popups shown
|
||||
|
||||
User can override mode with CLI flag: `--mode code`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
|
||||
1. **Permission History:**
|
||||
- Show recently granted/denied permissions
|
||||
- `/permissions` command to view active rules
|
||||
|
||||
2. **Temporary Rules:**
|
||||
- "Allow for this session" option
|
||||
- Rules expire when TUI closes
|
||||
|
||||
3. **Pattern-based Rules:**
|
||||
- "Always allow reading from `src/` directory"
|
||||
- "Always allow bash commands starting with `npm`"
|
||||
|
||||
4. **Visual Feedback:**
|
||||
- Show indicator when permission auto-granted by rule
|
||||
- Different styling for policy-denied vs user-denied
|
||||
|
||||
5. **Rule Management:**
|
||||
- `/clear-rules` command
|
||||
- Edit/remove specific rules interactively
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `crates/app/ui/src/app.rs` - Main permission flow logic
|
||||
- `crates/app/ui/src/events.rs` - Removed unused event type
|
||||
- `crates/app/ui/src/components/permission_popup.rs` - Pre-existing, now fully integrated
|
||||
|
||||
## Summary
|
||||
|
||||
The TUI permission system is now fully functional, providing:
|
||||
- ✅ Interactive permission popups with keyboard navigation
|
||||
- ✅ Four permission options (allow once, always, deny, explain)
|
||||
- ✅ Runtime permission rule updates
|
||||
- ✅ Async flow that keeps UI responsive
|
||||
- ✅ Integration with existing permission manager
|
||||
- ✅ Tool-specific context and explanations
|
||||
- ✅ Timeout handling (5 minutes)
|
||||
- ✅ All tests passing
|
||||
|
||||
Users can now safely interact with the AI agent while maintaining control over potentially dangerous operations.
|
||||
44
PKGBUILD
44
PKGBUILD
@@ -1,44 +0,0 @@
|
||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||
pkgname=owlen
|
||||
pkgver=0.1.4
|
||||
pkgrel=1
|
||||
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
||||
arch=('x86_64')
|
||||
url="https://somegit.dev/Owlibou/owlen"
|
||||
license=('AGPL-3.0-or-later')
|
||||
depends=('gcc-libs')
|
||||
makedepends=('cargo' 'git')
|
||||
options=(!lto) # avoid LTO-linked ring symbol drop with lld
|
||||
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||
sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec')
|
||||
|
||||
prepare() {
|
||||
cd $pkgname
|
||||
cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd $pkgname
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||
export CARGO_PROFILE_RELEASE_LTO=false
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --frozen --release --all-features
|
||||
}
|
||||
|
||||
check() {
|
||||
cd $pkgname
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||
cargo test --frozen --all-features
|
||||
}
|
||||
|
||||
package() {
|
||||
cd $pkgname
|
||||
|
||||
# Install binaries
|
||||
install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen"
|
||||
install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code"
|
||||
|
||||
# Install documentation
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
}
|
||||
|
||||
363
PLUGIN_HOOKS_INTEGRATION.md
Normal file
363
PLUGIN_HOOKS_INTEGRATION.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Plugin Hooks Integration
|
||||
|
||||
This document describes how the plugin system integrates with the hook system to allow plugins to define lifecycle hooks.
|
||||
|
||||
## Overview
|
||||
|
||||
Plugins can now define hooks in a `hooks/hooks.json` file that will be automatically registered with the `HookManager` during application startup. This allows plugins to:
|
||||
|
||||
- Intercept and validate tool calls before execution (PreToolUse)
|
||||
- React to tool execution results (PostToolUse)
|
||||
- Run code at session boundaries (SessionStart, SessionEnd)
|
||||
- Process user input (UserPromptSubmit)
|
||||
- Handle context compaction (PreCompact)
|
||||
|
||||
## Plugin Hook Configuration
|
||||
|
||||
Plugins define hooks in `hooks/hooks.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Validation and logging hooks for the plugin",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validate.py",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/bash_guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "echo 'Tool executed' >> ${CLAUDE_PLUGIN_ROOT}/logs/tool.log && exit 0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hook Configuration Schema
|
||||
|
||||
- **description** (optional): A human-readable description of what the hooks do
|
||||
- **hooks**: A map of event names to hook matchers
|
||||
- **PreToolUse**: Hooks that run before a tool is executed
|
||||
- **PostToolUse**: Hooks that run after a tool is executed
|
||||
- **SessionStart**: Hooks that run when a session starts
|
||||
- **SessionEnd**: Hooks that run when a session ends
|
||||
- **UserPromptSubmit**: Hooks that run when the user submits a prompt
|
||||
- **PreCompact**: Hooks that run before context compaction
|
||||
|
||||
### Hook Matcher
|
||||
|
||||
Each hook matcher contains:
|
||||
|
||||
- **matcher** (optional): A regex pattern to match against tool names (for PreToolUse events)
|
||||
- Example: `"Edit|Write"` matches both Edit and Write tools
|
||||
- Example: `".*"` matches all tools
|
||||
- If not specified, the hook applies to all tools
|
||||
- **hooks**: An array of hook definitions
|
||||
|
||||
### Hook Definition
|
||||
|
||||
Each hook definition contains:
|
||||
|
||||
- **type**: The hook type (`"command"` or `"prompt"`)
|
||||
- **command**: The shell command to execute (for command-type hooks)
|
||||
- Can use `${CLAUDE_PLUGIN_ROOT}` which is replaced with the plugin's base path
|
||||
- **prompt** (future): An LLM prompt for AI-based validation
|
||||
- **timeout** (optional): Timeout in milliseconds (default: no timeout)
|
||||
|
||||
## Variable Substitution
|
||||
|
||||
The following variables are automatically substituted in hook commands:
|
||||
|
||||
- **${CLAUDE_PLUGIN_ROOT}**: The absolute path to the plugin directory
|
||||
- Example: `~/.config/owlen/plugins/my-plugin`
|
||||
- Useful for referencing scripts within the plugin
|
||||
|
||||
## Hook Execution Behavior
|
||||
|
||||
### Exit Codes
|
||||
|
||||
Hooks communicate their decision via exit codes:
|
||||
|
||||
- **0**: Allow the operation to proceed
|
||||
- **2**: Deny the operation (blocks the tool call)
|
||||
- **Other**: Error (operation fails with error message)
|
||||
|
||||
### Input/Output
|
||||
|
||||
Hooks receive JSON input via stdin containing the event data:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "preToolUse",
|
||||
"tool": "Edit",
|
||||
"args": {
|
||||
"path": "/path/to/file.txt",
|
||||
"old_string": "foo",
|
||||
"new_string": "bar"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern Matching
|
||||
|
||||
For PreToolUse hooks, the `matcher` field is treated as a regex pattern:
|
||||
|
||||
- `"Edit|Write"` - Matches Edit OR Write tools
|
||||
- `"Bash"` - Matches only Bash tool
|
||||
- `".*"` - Matches all tools
|
||||
- No matcher - Applies to all tools
|
||||
|
||||
### Multiple Hooks
|
||||
|
||||
- Multiple plugins can define hooks for the same event
|
||||
- All matching hooks are executed in sequence
|
||||
- If any hook denies (exit code 2), the operation is blocked
|
||||
- File-based hooks in `.owlen/hooks/` are executed first, then plugin hooks
|
||||
|
||||
## Integration Architecture
|
||||
|
||||
### Loading Process
|
||||
|
||||
1. **Application Startup** (`main.rs`):
|
||||
```rust
|
||||
// Create hook manager
|
||||
let mut hook_mgr = HookManager::new(".");
|
||||
|
||||
// Register plugin hooks
|
||||
for plugin in app_context.plugin_manager.plugins() {
|
||||
if let Ok(Some(hooks_config)) = plugin.load_hooks_config() {
|
||||
for (event, command, pattern, timeout) in plugin.register_hooks_with_manager(&hooks_config) {
|
||||
hook_mgr.register_hook(event, command, pattern, timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Plugin Hook Loading** (`plugins/src/lib.rs`):
|
||||
- `Plugin::load_hooks_config()` reads and parses `hooks/hooks.json`
|
||||
- `Plugin::register_hooks_with_manager()` processes the config and performs variable substitution
|
||||
|
||||
3. **Hook Registration** (`hooks/src/lib.rs`):
|
||||
- `HookManager::register_hook()` stores hooks internally
|
||||
- `HookManager::execute()` filters and executes matching hooks
|
||||
|
||||
### Execution Flow
|
||||
|
||||
```
|
||||
Tool Call Request
|
||||
↓
|
||||
Permission Check
|
||||
↓
|
||||
HookManager::execute(PreToolUse)
|
||||
↓
|
||||
Check file-based hook (.owlen/hooks/PreToolUse)
|
||||
↓
|
||||
Filter plugin hooks by event and pattern
|
||||
↓
|
||||
Execute each matching hook
|
||||
↓
|
||||
If any hook denies → Block operation
|
||||
↓
|
||||
If all allow → Execute tool
|
||||
↓
|
||||
HookManager::execute(PostToolUse)
|
||||
```
|
||||
|
||||
## Example: Validation Hook
|
||||
|
||||
Create a plugin with a validation hook:
|
||||
|
||||
**Directory structure:**
|
||||
```
|
||||
~/.config/owlen/plugins/validation/
|
||||
├── plugin.json
|
||||
└── hooks/
|
||||
├── hooks.json
|
||||
└── validate.py
|
||||
```
|
||||
|
||||
**plugin.json:**
|
||||
```json
|
||||
{
|
||||
"name": "validation",
|
||||
"version": "1.0.0",
|
||||
"description": "Validation hooks for file operations"
|
||||
}
|
||||
```
|
||||
|
||||
**hooks/hooks.json:**
|
||||
```json
|
||||
{
|
||||
"description": "Validate file operations",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/validate.py",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**hooks/validate.py:**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Read event from stdin
|
||||
event = json.load(sys.stdin)
|
||||
|
||||
tool = event.get('tool')
|
||||
args = event.get('args', {})
|
||||
path = args.get('path', '')
|
||||
|
||||
# Deny operations on system files
|
||||
if path.startswith('/etc/') or path.startswith('/sys/'):
|
||||
print(f"Blocked: Cannot modify system file {path}", file=sys.stderr)
|
||||
sys.exit(2) # Deny
|
||||
|
||||
# Allow all other operations
|
||||
sys.exit(0) # Allow
|
||||
```
|
||||
|
||||
**Make executable:**
|
||||
```bash
|
||||
chmod +x ~/.config/owlen/plugins/validation/hooks/validate.py
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Test hook registration and execution:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_plugin_hooks() -> Result<()> {
|
||||
let mut hook_mgr = HookManager::new(".");
|
||||
|
||||
hook_mgr.register_hook(
|
||||
"PreToolUse".to_string(),
|
||||
"echo 'validated' && exit 0".to_string(),
|
||||
Some("Edit|Write".to_string()),
|
||||
Some(5000),
|
||||
);
|
||||
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Edit".to_string(),
|
||||
args: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test the full plugin loading and hook execution:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_plugin_hooks_integration() -> Result<()> {
|
||||
// Create plugin with hooks
|
||||
let plugin_dir = create_test_plugin_with_hooks()?;
|
||||
|
||||
// Load plugin
|
||||
let mut plugin_manager = PluginManager::with_dirs(vec![plugin_dir]);
|
||||
plugin_manager.load_all()?;
|
||||
|
||||
// Register hooks
|
||||
let mut hook_mgr = HookManager::new(".");
|
||||
for plugin in plugin_manager.plugins() {
|
||||
if let Ok(Some(config)) = plugin.load_hooks_config() {
|
||||
for (event, cmd, pattern, timeout) in plugin.register_hooks_with_manager(&config) {
|
||||
hook_mgr.register_hook(event, cmd, pattern, timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test hook execution
|
||||
let event = HookEvent::PreToolUse {
|
||||
tool: "Edit".to_string(),
|
||||
args: serde_json::json!({}),
|
||||
};
|
||||
|
||||
let result = hook_mgr.execute(&event, Some(5000)).await?;
|
||||
assert_eq!(result, HookResult::Allow);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Modified Crates
|
||||
|
||||
1. **plugins** (`crates/platform/plugins/src/lib.rs`):
|
||||
- Added `PluginHooksConfig`, `HookMatcher`, `HookDefinition` structs
|
||||
- Added `Plugin::load_hooks_config()` method
|
||||
- Added `Plugin::register_hooks_with_manager()` method
|
||||
|
||||
2. **hooks** (`crates/platform/hooks/src/lib.rs`):
|
||||
- Refactored to store registered hooks internally
|
||||
- Added `HookManager::register_hook()` method
|
||||
- Updated `HookManager::execute()` to handle both file-based and registered hooks
|
||||
- Added pattern matching support using regex
|
||||
- Added `regex` dependency
|
||||
|
||||
3. **owlen** (`crates/app/cli/src/main.rs`):
|
||||
- Integrated plugin hook loading during startup
|
||||
- Registered plugin hooks with HookManager
|
||||
|
||||
### Dependencies Added
|
||||
|
||||
- **hooks/Cargo.toml**: Added `regex = "1.10"`
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Modularity**: Hooks can be packaged with plugins and distributed independently
|
||||
2. **Reusability**: Plugins can be shared across projects
|
||||
3. **Flexibility**: Each plugin can define multiple hooks with different patterns
|
||||
4. **Compatibility**: Works alongside existing file-based hooks in `.owlen/hooks/`
|
||||
5. **Variable Substitution**: `${CLAUDE_PLUGIN_ROOT}` makes scripts portable
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Prompt-based hooks**: Use LLM for validation instead of shell commands
|
||||
2. **Hook priorities**: Control execution order of hooks
|
||||
3. **Hook metadata**: Description, author, version for each hook
|
||||
4. **Hook debugging**: Better error messages and logging
|
||||
5. **Async hooks**: Support for long-running hooks that don't block
|
||||
307
README.md
307
README.md
@@ -1,307 +0,0 @@
|
||||
# OWLEN
|
||||
|
||||
> Terminal-native assistant for running local language models with a comfortable TUI.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Alpha Status
|
||||
|
||||
- This project is currently in **alpha** (v0.1.5) and under active development.
|
||||
- Core features are functional but expect occasional bugs and missing polish.
|
||||
- Breaking changes may occur between releases as we refine the API.
|
||||
- Feedback, bug reports, and contributions are very welcome!
|
||||
|
||||
## What Is OWLEN?
|
||||
|
||||
OWLEN is a Rust-powered, terminal-first interface for interacting with local large
|
||||
language models. It provides a responsive chat workflow that runs against
|
||||
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
|
||||
and seamless session management—all without leaving your terminal.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Initial Layout
|
||||

|
||||
|
||||
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory including:
|
||||
- Full chat conversations (`chat_view.png`)
|
||||
- Help menu (`help.png`)
|
||||
- Model selection (`model_select.png`)
|
||||
- Visual selection mode (`select_mode.png`)
|
||||
|
||||
## Features
|
||||
|
||||
### Chat Client (`owlen`)
|
||||
- **Vim-style Navigation** - Normal, editing, visual, and command modes
|
||||
- **Streaming Responses** - Real-time token streaming from Ollama
|
||||
- **Multi-Panel Interface** - Separate panels for chat, thinking content, and input
|
||||
- **Advanced Text Editing** - Multi-line input with `tui-textarea`, history navigation
|
||||
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
||||
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
||||
- **Model Management** - Interactive model and provider selection (press `m`)
|
||||
- **Session Persistence** - Save and load conversations to/from disk
|
||||
- **AI-Generated Descriptions** - Automatic short summaries for saved sessions
|
||||
- **Session Management** - Start new conversations, clear history, browse saved sessions
|
||||
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
||||
- **Bracketed Paste** - Safe paste handling for multi-line content
|
||||
|
||||
### Code Client (`owlen-code`) [Experimental]
|
||||
- All chat client features
|
||||
- Optimized system prompt for programming assistance
|
||||
- Foundation for future code-specific features
|
||||
|
||||
### Core Infrastructure
|
||||
- **Modular Architecture** - Separated core logic, TUI components, and providers
|
||||
- **Provider System** - Extensible provider trait (currently: Ollama)
|
||||
- **Session Controller** - Unified conversation and state management
|
||||
- **Configuration Management** - TOML-based config with sensible defaults
|
||||
- **Message Formatting** - Markdown rendering, thinking content extraction
|
||||
- **Async Runtime** - Built on Tokio for efficient streaming
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Rust 1.75+ and Cargo (`rustup` recommended)
|
||||
- A running Ollama instance with at least one model pulled
|
||||
(defaults to `http://localhost:11434`)
|
||||
- A terminal that supports 256 colors
|
||||
|
||||
### Clone and Build
|
||||
|
||||
```bash
|
||||
git clone https://somegit.dev/Owlibou/owlen.git
|
||||
cd owlen
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Run the Chat Client
|
||||
|
||||
Make sure Ollama is running, then launch:
|
||||
|
||||
```bash
|
||||
./target/release/owlen
|
||||
# or during development:
|
||||
cargo run --bin owlen
|
||||
```
|
||||
|
||||
### (Optional) Try the Code Client
|
||||
|
||||
The coding-focused TUI is experimental:
|
||||
|
||||
```bash
|
||||
cargo build --release --bin owlen-code --features code-client
|
||||
./target/release/owlen-code
|
||||
```
|
||||
|
||||
## Using the TUI
|
||||
|
||||
### Mode System (Vim-inspired)
|
||||
|
||||
**Normal Mode** (default):
|
||||
- `i` / `Enter` - Enter editing mode
|
||||
- `a` - Append (move right and enter editing mode)
|
||||
- `A` - Append at end of line
|
||||
- `I` - Insert at start of line
|
||||
- `o` - Insert new line below
|
||||
- `O` - Insert new line above
|
||||
- `v` - Enter visual mode (text selection)
|
||||
- `:` - Enter command mode
|
||||
- `h/j/k/l` - Navigate left/down/up/right
|
||||
- `w/b/e` - Word navigation
|
||||
- `0/$` - Jump to line start/end
|
||||
- `gg` - Jump to top
|
||||
- `G` - Jump to bottom
|
||||
- `Ctrl-d/u` - Half-page scroll
|
||||
- `Ctrl-f/b` - Full-page scroll
|
||||
- `Tab` - Cycle focus between panels
|
||||
- `p` - Paste from clipboard
|
||||
- `dd` - Clear input buffer
|
||||
- `q` - Quit
|
||||
|
||||
**Editing Mode**:
|
||||
- `Esc` - Return to normal mode
|
||||
- `Enter` - Send message and return to normal mode
|
||||
- `Ctrl-J` / `Shift-Enter` - Insert newline
|
||||
- `Ctrl-↑/↓` - Navigate input history
|
||||
- Paste events handled automatically
|
||||
|
||||
**Visual Mode**:
|
||||
- `j/k/h/l` - Extend selection
|
||||
- `w/b/e` - Word-based selection
|
||||
- `y` - Yank (copy) selection
|
||||
- `d` - Cut selection (Input panel only)
|
||||
- `Esc` - Cancel selection
|
||||
|
||||
**Command Mode**:
|
||||
- `:q` / `:quit` - Quit application
|
||||
- `:c` / `:clear` - Clear conversation
|
||||
- `:m` / `:model` - Open model selector
|
||||
- `:n` / `:new` - Start new conversation
|
||||
- `:h` / `:help` - Show help
|
||||
- `:save [name]` / `:w [name]` - Save current conversation
|
||||
- `:load` / `:open` - Browse and load saved sessions
|
||||
- `:sessions` / `:ls` - List saved sessions
|
||||
|
||||
**Session Browser** (accessed via `:load` or `:sessions`):
|
||||
- `j` / `k` / `↑` / `↓` - Navigate sessions
|
||||
- `Enter` - Load selected session
|
||||
- `d` - Delete selected session
|
||||
- `Esc` - Close browser
|
||||
|
||||
### Panel Management
|
||||
- Three panels: Chat, Thinking, and Input
|
||||
- `Tab` / `Shift-Tab` - Cycle focus forward/backward
|
||||
- Focused panel receives scroll and navigation commands
|
||||
- Thinking panel appears when extended reasoning is available
|
||||
|
||||
## Configuration
|
||||
|
||||
OWLEN stores configuration in `~/.config/owlen/config.toml`. The file is created
|
||||
on first run and can be edited to customize behavior:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
default_model = "llama3.2:latest"
|
||||
default_provider = "ollama"
|
||||
enable_streaming = true
|
||||
project_context_file = "OWLEN.md"
|
||||
|
||||
[providers.ollama]
|
||||
provider_type = "ollama"
|
||||
base_url = "http://localhost:11434"
|
||||
timeout = 300
|
||||
```
|
||||
|
||||
### Storage Settings
|
||||
|
||||
Sessions are saved to platform-specific directories by default:
|
||||
- **Linux**: `~/.local/share/owlen/sessions`
|
||||
- **Windows**: `%APPDATA%\owlen\sessions`
|
||||
- **macOS**: `~/Library/Application Support/owlen/sessions`
|
||||
|
||||
You can customize this in your config:
|
||||
|
||||
```toml
|
||||
[storage]
|
||||
# conversation_dir = "~/custom/path" # Optional: override default location
|
||||
max_saved_sessions = 25
|
||||
generate_descriptions = true # AI-generated summaries for saved sessions
|
||||
```
|
||||
|
||||
Configuration is automatically saved when you change models or providers.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
owlen/
|
||||
├── crates/
|
||||
│ ├── owlen-core/ # Core types, session management, shared UI components
|
||||
│ ├── owlen-ollama/ # Ollama provider implementation
|
||||
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
|
||||
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
|
||||
├── LICENSE # AGPL-3.0 License
|
||||
├── Cargo.toml # Workspace configuration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Architecture Highlights
|
||||
- **owlen-core**: Provider-agnostic core with session controller, UI primitives (AutoScroll, InputMode, FocusedPanel), and shared utilities
|
||||
- **owlen-tui**: Ratatui-based UI implementation with vim-style modal editing
|
||||
- **Separation of Concerns**: Clean boundaries between business logic, presentation, and provider implementations
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Debug build
|
||||
cargo build
|
||||
|
||||
# Release build
|
||||
cargo build --release
|
||||
|
||||
# Build with all features
|
||||
cargo build --all-features
|
||||
|
||||
# Run tests
|
||||
cargo test
|
||||
|
||||
# Check code
|
||||
cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
### Development Notes
|
||||
- Standard Rust workflows apply (`cargo fmt`, `cargo clippy`, `cargo test`)
|
||||
- Codebase uses async Rust (`tokio`) for event handling and streaming
|
||||
- Configuration is cached in `~/.config/owlen` (wipe to reset)
|
||||
- UI components are extensively tested in `owlen-core/src/ui.rs`
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed ✓
|
||||
- [x] Streaming responses with real-time display
|
||||
- [x] Autoscroll and viewport management
|
||||
- [x] Push user message before loading LLM response
|
||||
- [x] Thinking mode support with dedicated panel
|
||||
- [x] Vim-style modal editing (Normal, Visual, Command modes)
|
||||
- [x] Multi-panel focus management
|
||||
- [x] Text selection and clipboard functionality
|
||||
- [x] Comprehensive keyboard navigation
|
||||
- [x] Bracketed paste support
|
||||
|
||||
### In Progress
|
||||
- [x] Session persistence (save/load conversations)
|
||||
- [ ] Theming options and color customization
|
||||
- [ ] Enhanced configuration UX (in-app settings)
|
||||
- [ ] Conversation export (Markdown, JSON, plain text)
|
||||
|
||||
### Planned
|
||||
- [ ] Code Client Enhancement
|
||||
- [ ] In-project code navigation
|
||||
- [ ] Syntax highlighting for code blocks
|
||||
- [ ] File tree browser integration
|
||||
- [ ] Project-aware context management
|
||||
- [ ] Code snippets and templates
|
||||
- [ ] Additional LLM Providers
|
||||
- [ ] OpenAI API support
|
||||
- [ ] Anthropic Claude support
|
||||
- [ ] Local model providers (llama.cpp, etc.)
|
||||
- [ ] Advanced Features
|
||||
- [ ] Conversation search and filtering
|
||||
- [ ] Multi-session management
|
||||
- [ ] Export conversations (Markdown, JSON)
|
||||
- [ ] Custom keybindings
|
||||
- [ ] Plugin system
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Here's how to get started:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes and add tests
|
||||
4. Run `cargo fmt` and `cargo clippy`
|
||||
5. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
Please open an issue first for significant changes to discuss the approach.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
- [ratatui](https://ratatui.rs/) - Terminal UI framework
|
||||
- [crossterm](https://github.com/crossterm-rs/crossterm) - Cross-platform terminal manipulation
|
||||
- [tokio](https://tokio.rs/) - Async runtime
|
||||
- [Ollama](https://ollama.com/) - Local LLM runtime
|
||||
|
||||
---
|
||||
|
||||
**Status**: Alpha v0.1.0 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
||||
262
TODO.md
Normal file
262
TODO.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# Owlen Project Improvement Roadmap
|
||||
|
||||
Generated from codebase analysis on 2025-11-01
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
**Grade:** A (90/100)
|
||||
**Status:** Production-ready with minor enhancements needed
|
||||
**Architecture:** Excellent domain-driven design with clean separation of concerns
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Critical Issues (Do First)
|
||||
|
||||
- [x] **Fix Integration Test Failure** (`crates/app/cli/tests/chat_stream.rs`) ✅ **COMPLETED**
|
||||
- Fixed mock server to accept requests with tools parameter
|
||||
- Test now passes successfully
|
||||
- Location: `crates/app/cli/tests/chat_stream.rs`
|
||||
|
||||
- [x] **Remove Side Effects from Library Code** (`crates/core/agent/src/lib.rs:348-349`) ✅ **COMPLETED**
|
||||
- Replaced `println!` with `tracing` crate
|
||||
- Added `tracing = "0.1"` dependency to `agent-core`
|
||||
- Changed to structured logging: `tracing::debug!` for tool calls, `tracing::warn!` for errors
|
||||
- Users can now control verbosity and route logs appropriately
|
||||
- Location: `crates/core/agent/src/lib.rs:348, 352, 361`
|
||||
|
||||
---
|
||||
|
||||
## 🟡 High-Priority Improvements
|
||||
|
||||
### Permission System ✅
|
||||
|
||||
- [x] **Implement Proper Permission Selection in TUI** ✅ **COMPLETED**
|
||||
- Added interactive permission popup with keyboard navigation
|
||||
- Implemented "Allow once", "Always allow", "Deny", and "Explain" options
|
||||
- Integrated permission requests into agent loop with async channels
|
||||
- Added runtime permission rule updates for "Always allow"
|
||||
- Permission popups pause execution and wait for user input
|
||||
- Location: `crates/app/ui/src/app.rs`, `crates/app/ui/src/components/permission_popup.rs`
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] **Add User-Facing README.md**
|
||||
- Quick start guide
|
||||
- Installation instructions
|
||||
- Usage examples
|
||||
- Feature overview
|
||||
- Links to detailed docs
|
||||
- Priority: HIGH
|
||||
|
||||
- [ ] **Add Architecture Documentation**
|
||||
- Crate dependency graph diagram
|
||||
- Agent loop flow diagram
|
||||
- Permission system flow diagram
|
||||
- Plugin/hook integration points diagram
|
||||
- Priority: MEDIUM
|
||||
|
||||
### Feature Integration
|
||||
|
||||
- [ ] **Integrate Plugin System**
|
||||
- Wire plugin loading into `crates/app/cli/src/main.rs`
|
||||
- Load plugins at startup
|
||||
- Test with example plugins
|
||||
- Priority: HIGH
|
||||
|
||||
- [ ] **Integrate MCP Client into Agent**
|
||||
- Add MCP tools to agent's tool registry
|
||||
- Enable external tool servers (databases, APIs, etc.)
|
||||
- Document MCP server setup
|
||||
- Priority: HIGH
|
||||
|
||||
- [ ] **Implement Real Web Search Provider**
|
||||
- Add provider for DuckDuckGo, Brave Search, or SearXNG
|
||||
- Make the web tool functional
|
||||
- Add configuration for provider selection
|
||||
- Priority: MEDIUM
|
||||
|
||||
### Error Handling & Reliability
|
||||
|
||||
- [ ] **Add Retry Logic for Transient Failures**
|
||||
- Exponential backoff for Ollama API calls
|
||||
- Configurable retry policies (max attempts, timeout)
|
||||
- Handle network failures gracefully
|
||||
- Priority: MEDIUM
|
||||
|
||||
- [ ] **Enhance Error Messages**
|
||||
- Add actionable suggestions for common errors
|
||||
- Example: "Ollama not running? Try: `ollama serve`"
|
||||
- Example: "Model not found? Try: `ollama pull qwen3:8b`"
|
||||
- Priority: MEDIUM
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Medium-Priority Enhancements
|
||||
|
||||
### Testing
|
||||
|
||||
- [ ] **Add UI Component Testing**
|
||||
- Snapshot tests for TUI components
|
||||
- Integration tests for user interactions
|
||||
- Use `ratatui` testing utilities
|
||||
- Priority: MEDIUM
|
||||
|
||||
- [ ] **Add More Edge Case Tests**
|
||||
- Glob patterns with special characters
|
||||
- Edit operations with Unicode
|
||||
- Very large file handling
|
||||
- Concurrent tool execution
|
||||
- Priority: MEDIUM
|
||||
|
||||
- [ ] **Code Coverage Reporting**
|
||||
- Integrate `tarpaulin` or `cargo-llvm-cov`
|
||||
- Set minimum coverage thresholds (aim for 80%+)
|
||||
- Track coverage trends over time
|
||||
- Priority: LOW
|
||||
|
||||
### Documentation
|
||||
|
||||
- [ ] **Module-Level Documentation**
|
||||
- Add `//!` docs to key modules
|
||||
- Explain design decisions and patterns
|
||||
- Document internal APIs
|
||||
- Priority: MEDIUM
|
||||
|
||||
- [ ] **Create Examples Directory**
|
||||
- Simple CLI usage examples
|
||||
- Custom plugin development guide
|
||||
- Hook script examples
|
||||
- MCP server integration examples
|
||||
- Configuration templates
|
||||
- Priority: MEDIUM
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **Fix Dead Code Warning** (`ui/src/app.rs:38`)
|
||||
- Either use `settings` field or remove it
|
||||
- Remove `#[allow(dead_code)]`
|
||||
- Priority: LOW
|
||||
|
||||
- [ ] **Improve Error Recovery**
|
||||
- Checkpoint auto-save on crashes
|
||||
- Graceful degradation when tools fail
|
||||
- Better handling of partial tool results
|
||||
- Priority: MEDIUM
|
||||
|
||||
---
|
||||
|
||||
## 🔵 Low-Priority Nice-to-Haves
|
||||
|
||||
### Project Infrastructure
|
||||
|
||||
- [ ] **CI/CD Pipeline (GitHub Actions)**
|
||||
- Automated testing on push
|
||||
- Clippy linting
|
||||
- Format checking with `rustfmt`
|
||||
- Security audits with `cargo-audit`
|
||||
- Cross-platform builds (Linux, macOS, Windows)
|
||||
- Priority: LOW
|
||||
|
||||
- [ ] **Performance Benchmarking**
|
||||
- Add benchmark suite using `criterion` crate
|
||||
- Track performance for glob, grep, large file ops
|
||||
- Track agent loop iteration performance
|
||||
- Priority: LOW
|
||||
|
||||
### Code Organization
|
||||
|
||||
- [ ] **Extract Reusable Crates**
|
||||
- Publish `mcp-client` as standalone library
|
||||
- Publish `llm-ollama` as standalone library
|
||||
- Enable reuse by other projects
|
||||
- Consider publishing to crates.io
|
||||
- Priority: LOW
|
||||
|
||||
---
|
||||
|
||||
## 💡 Feature Enhancement Ideas
|
||||
|
||||
### Session Management
|
||||
|
||||
- [ ] **Session Persistence**
|
||||
- Auto-save sessions across restarts
|
||||
- Resume previous conversations
|
||||
- Session history browser in TUI
|
||||
- Export/import session transcripts
|
||||
|
||||
### Multi-Provider Support
|
||||
|
||||
- [ ] **Multi-Model Support**
|
||||
- Support Anthropic Claude API
|
||||
- Support OpenAI API
|
||||
- Provider abstraction layer
|
||||
- Fallback chains for reliability
|
||||
|
||||
### Enhanced Permissions
|
||||
|
||||
- [ ] **Advanced Permission System**
|
||||
- Time-based permissions (expire after N minutes)
|
||||
- Scope-based permissions (allow within specific directories)
|
||||
- Permission profiles (dev, prod, strict)
|
||||
- Team permission policies
|
||||
|
||||
### Collaboration
|
||||
|
||||
- [ ] **Collaborative Features**
|
||||
- Export sessions as shareable transcripts
|
||||
- Import/export checkpoints
|
||||
- Shared permission policies for teams
|
||||
- Session replay functionality
|
||||
|
||||
### Observability
|
||||
|
||||
- [ ] **Enhanced Observability**
|
||||
- Token usage tracking per tool call
|
||||
- Cost estimation dashboard
|
||||
- Performance metrics export (JSON/CSV)
|
||||
- OpenTelemetry integration
|
||||
- Real-time stats in TUI
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Wins (Can Be Done Today)
|
||||
|
||||
- [ ] Add `README.md` to repository root
|
||||
- [ ] Fix dead code warning in `ui/src/app.rs:38`
|
||||
- [ ] Add `tracing` crate and replace `println!` calls
|
||||
- [ ] Create `.github/workflows/ci.yml` for basic CI
|
||||
- [ ] Add module-level docs to `agent-core` and `config-agent`
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Long-Term Vision
|
||||
|
||||
- **Plugin Marketplace:** Curated registry of community plugins
|
||||
- **Interactive Tutorial:** Built-in tutorial mode for new users
|
||||
- **VS Code Extension:** Editor integration for inline assistance
|
||||
- **Collaborative Agents:** Multi-agent workflows with role assignment
|
||||
- **Knowledge Base Integration:** RAG capabilities for project-specific knowledge
|
||||
- **Web Dashboard:** Browser-based interface for session management
|
||||
- **Cloud Sync:** Sync configs and sessions across devices
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Test Status:** 28+ tests, most passing. 1 integration test failure (mock server issue)
|
||||
- **Test Coverage:** Strong coverage for core functionality (permissions, checkpoints, hooks)
|
||||
- **Architecture:** Clean domain-driven workspace with 15 crates across 4 domains
|
||||
- **Code Quality:** Excellent error handling, consistent patterns, minimal technical debt
|
||||
- **Innovation Highlights:** Checkpoint/rewind system, three-tiered permissions, shell-based hooks
|
||||
|
||||
---
|
||||
|
||||
## Priority Legend
|
||||
|
||||
- **HIGH:** Should be done soon, blocks other features or affects quality
|
||||
- **MEDIUM:** Important but not urgent, improves user experience
|
||||
- **LOW:** Nice to have, can be deferred
|
||||
|
||||
---
|
||||
|
||||
Last Updated: 2025-11-01
|
||||
8
conductor/archive/stabilize_core_20251226/metadata.json
Normal file
8
conductor/archive/stabilize_core_20251226/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "stabilize_core_20251226",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2025-12-26T10:00:00Z",
|
||||
"updated_at": "2025-12-26T10:00:00Z",
|
||||
"description": "Establish a comprehensive test suite for the core agent logic and ensure basic documentation for all crates."
|
||||
}
|
||||
17
conductor/archive/stabilize_core_20251226/plan.md
Normal file
17
conductor/archive/stabilize_core_20251226/plan.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Plan: Core Stabilization & Documentation
|
||||
|
||||
## Phase 1: Core Agent & Platform Testing [checkpoint: 495f63f]
|
||||
|
||||
- [x] Task: Write unit tests for `crates/core/agent/src/lib.rs` f5a5724
|
||||
- [x] Task: Write unit tests for `crates/platform/permissions/src/lib.rs` 94c89ce
|
||||
- [x] Task: Write unit tests for `crates/platform/config/src/lib.rs` b4a4a38
|
||||
- [x] Task: Conductor - User Manual Verification 'Core Agent & Platform Testing' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Documentation Audit & Standardization [checkpoint: 6486dd9]
|
||||
|
||||
- [x] Task: Create/Update README.md for all crates in `crates/app/` f39c7a7
|
||||
- [x] Task: Create/Update README.md for all crates in `crates/llm/` f7aac07
|
||||
- [x] Task: Create/Update README.md for all crates in `crates/platform/` f9970074
|
||||
- [x] Task: Create/Update README.md for all crates in `crates/tools/` a764fd6b
|
||||
- [x] Task: Add doc-comments to all public functions in `crates/core/agent/` b555256d
|
||||
- [x] Task: Conductor - User Manual Verification 'Documentation Audit & Standardization' (Protocol in workflow.md)
|
||||
14
conductor/archive/stabilize_core_20251226/spec.md
Normal file
14
conductor/archive/stabilize_core_20251226/spec.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Specification: Core Stabilization & Documentation
|
||||
|
||||
## Goal
|
||||
To increase the reliability and maintainability of the Owlen codebase by establishing a robust testing foundation and comprehensive documentation for all existing crates.
|
||||
|
||||
## Scope
|
||||
- **Crates Covered:** All crates in the workspace (`app/*`, `core/*`, `llm/*`, `platform/*`, `tools/*`, `integration/*`).
|
||||
- **Testing:** Unit tests for core logic, focusing on the `core/agent` and `platform/*` crates.
|
||||
- **Documentation:** `README.md` for each crate and doc-comments for all public APIs.
|
||||
|
||||
## Deliverables
|
||||
- Increased test coverage (targeting >80%) for core crates.
|
||||
- Comprehensive API documentation.
|
||||
- Standardized `README.md` files for all crates.
|
||||
28
conductor/code_styleguides/markdown.md
Normal file
28
conductor/code_styleguides/markdown.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Markdown Style Guide
|
||||
|
||||
## General
|
||||
- **Line Wrapping:** Wrap lines at 80-100 characters for readability, unless it breaks a link or code block.
|
||||
- **Headers:**
|
||||
- Use ATX-style headers (`#`, `##`, etc.).
|
||||
- Leave one blank line before and after headers.
|
||||
- **Lists:**
|
||||
- Use hyphens (`-`) for unordered lists.
|
||||
- Use numbers (`1.`, `2.`) for ordered lists.
|
||||
- Indent nested lists by 2 or 4 spaces.
|
||||
|
||||
## Code Blocks
|
||||
- **Fenced Code Blocks:** Use triple backticks (```) for code blocks.
|
||||
- **Language Tags:** Always specify the language for syntax highlighting (e.g., ```rust).
|
||||
|
||||
## Links & Images
|
||||
- **Reference Links:** Prefer inline links `[text](url)` for short links.
|
||||
- **Images:** Provide descriptive alt text for accessibility: ``.
|
||||
|
||||
## Emphasis
|
||||
- **Bold:** Use double asterisks (`**text**`).
|
||||
- **Italic:** Use single asterisks (`*text*`) or underscores (`_text_`).
|
||||
- **Code:** Use single backticks (`` `text` ``) for inline code.
|
||||
|
||||
## Structure
|
||||
- **Table of Contents:** Include a TOC for long documents.
|
||||
- **Sections:** Use logical hierarchy for sections and subsections.
|
||||
37
conductor/code_styleguides/rust.md
Normal file
37
conductor/code_styleguides/rust.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Rust Style Guide
|
||||
|
||||
## General
|
||||
- **Formatting:** Always use `rustfmt` with default settings.
|
||||
- **Linting:** Use `clippy` to catch common mistakes and improve code quality. Address all warnings.
|
||||
- **Edition:** Use the latest stable edition (currently 2024).
|
||||
|
||||
## Naming Conventions
|
||||
- **Crates:** `snake_case` (e.g., `my_crate`)
|
||||
- **Modules:** `snake_case` (e.g., `my_module`)
|
||||
- **Types (Structs, Enums, Traits):** `UpperCamelCase` (e.g., `MyStruct`)
|
||||
- **Functions & Methods:** `snake_case` (e.g., `my_function`)
|
||||
- **Variables:** `snake_case` (e.g., `my_variable`)
|
||||
- **Constants:** `SCREAMING_SNAKE_CASE` (e.g., `MAX_SIZE`)
|
||||
- **Generics:** `UpperCamelCase`, usually single letters (e.g., `T`, `U`) or descriptive names (e.g., `Input`, `Output`).
|
||||
|
||||
## Code Structure
|
||||
- **Imports:** Group imports by crate. Use `std` first, then external crates, then internal modules.
|
||||
- **Visibility:** Minimizing visibility is preferred. Use `pub(crate)` or `pub(super)` where appropriate.
|
||||
- **Error Handling:**
|
||||
- Prefer `Result<T, E>` over `panic!`.
|
||||
- Use the `?` operator for error propagation.
|
||||
- Use `anyhow` for application-level error handling and `thiserror` for library-level errors.
|
||||
|
||||
## Documentation
|
||||
- **Public API:** Document all public items using `///` comments.
|
||||
- **Module Level:** Include module-level documentation using `//!` at the top of the file.
|
||||
- **Examples:** Include examples in documentation where helpful.
|
||||
|
||||
## Testing
|
||||
- **Unit Tests:** Place unit tests in a `tests` module within the same file, annotated with `#[cfg(test)]`.
|
||||
- **Integration Tests:** Place integration tests in the `tests/` directory at the crate root.
|
||||
|
||||
## Idioms
|
||||
- **Pattern Matching:** Use pattern matching (`match`, `if let`) extensively.
|
||||
- **Ownership & Borrowing:** Follow Rust's ownership rules strictly. Avoid `clone()` unless necessary.
|
||||
- **Iterators:** Prefer iterators and combinators (`map`, `filter`, `fold`) over explicit loops.
|
||||
22
conductor/product-guidelines.md
Normal file
22
conductor/product-guidelines.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Product Guidelines
|
||||
|
||||
## Prose Style
|
||||
- **Professional & Direct:** Use a professional tone that is helpful but concise.
|
||||
- **Clarity over Verbosity:** Explain complex actions briefly without being overly chatty.
|
||||
- **Technical Accuracy:** Use precise terminology when referring to code or system operations.
|
||||
|
||||
## Technical Excellence
|
||||
- **Idiomatic Rust:** Follow standard Rust conventions (`rustfmt`, `clippy`).
|
||||
- **Safety First:** Prioritize memory safety and robust error handling. Use `Result` and `Option` effectively.
|
||||
- **Performance:** Ensure that file operations and LLM interactions are as efficient as possible.
|
||||
- **Modularity:** Maintain high cohesion and low coupling between crates.
|
||||
|
||||
## Security & Privacy
|
||||
- **Minimal Privilege:** Only request permissions for necessary operations.
|
||||
- **Secret Handling:** Never log or store API keys or sensitive data in plain text.
|
||||
- **Transparency:** Clearly inform the user before executing potentially destructive commands.
|
||||
|
||||
## UI/UX Design (CLI/TUI)
|
||||
- **Unix Philosophy:** Support piping and non-interactive modes where applicable.
|
||||
- **Visual Feedback:** Use progress indicators for long-running tasks.
|
||||
- **Accessibility:** Ensure the TUI is readable and navigable with standard terminal settings.
|
||||
21
conductor/product.md
Normal file
21
conductor/product.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Product Guide
|
||||
|
||||
## Initial Concept
|
||||
A robust, modular, and high-performance AI coding assistant for the terminal, built in Rust. It aims to streamline software development workflows by integrating directly with the codebase and version control systems.
|
||||
|
||||
## Target Audience
|
||||
- **Software Developers:** The primary users who need assistance with coding tasks, refactoring, and understanding complex codebases.
|
||||
- **DevOps Engineers:** For handling infrastructure-as-code and script automation via natural language.
|
||||
|
||||
## Core Value Proposition
|
||||
- **Performance & Safety:** Leverages Rust's memory safety and speed for a responsive CLI experience.
|
||||
- **Model Agnostic:** Supports multiple LLM backends (Anthropic, OpenAI, Ollama) giving users flexibility.
|
||||
- **Deep Integration:** Deeply integrates with the local development environment (filesystem, git, shell).
|
||||
- **Extensibility:** A plugin architecture to extend functionality with custom commands and agents.
|
||||
|
||||
## Key Features
|
||||
- **Natural Language Interface:** Interact with the agent using plain English to perform complex tasks.
|
||||
- **Codebase Awareness:** The agent can read and analyze the project structure and file contents to provide context-aware assistance.
|
||||
- **Multi-LLM Support:** Configurable providers including Anthropic, OpenAI, and local models via Ollama.
|
||||
- **Rich TUI:** A text-based user interface for a better user experience in the terminal.
|
||||
- **Tool Ecosystem:** A suite of built-in tools for file manipulation, shell execution, and more.
|
||||
1
conductor/setup_state.json
Normal file
1
conductor/setup_state.json
Normal file
@@ -0,0 +1 @@
|
||||
{"last_successful_step": "3.3_initial_track_generated"}
|
||||
28
conductor/tech-stack.md
Normal file
28
conductor/tech-stack.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Tech Stack
|
||||
|
||||
## Core Technologies
|
||||
- **Language:** Rust (Edition 2024, v1.91+)
|
||||
- **Architecture:** Modular Workspace with specialized crates for UI, Core Agent, LLM Providers, Platform Services, and Tools.
|
||||
- **Dependency Management:** Cargo (Rust's package manager).
|
||||
|
||||
## Crates & Components
|
||||
- **Frontends:**
|
||||
- `crates/app/cli`: Command-line interface.
|
||||
- `crates/app/ui`: Terminal User Interface (TUI).
|
||||
- **Core Agent:**
|
||||
- `crates/core/agent`: The primary orchestration engine for the AI agent.
|
||||
- **LLM Providers:**
|
||||
- `crates/llm/anthropic`: Integration with Anthropic's Claude models.
|
||||
- `crates/llm/openai`: Integration with OpenAI's models.
|
||||
- `crates/llm/ollama`: Integration with local models via Ollama.
|
||||
- **Platform Services:**
|
||||
- `crates/platform/auth`: Authentication logic.
|
||||
- `crates/platform/config`: Configuration management.
|
||||
- `crates/platform/credentials`: Secure credential storage and retrieval.
|
||||
- `crates/platform/hooks`: Plugin hook system.
|
||||
- `crates/platform/permissions`: Permission and safety system.
|
||||
- `crates/platform/plugins`: Plugin management.
|
||||
- **Functional Tools:**
|
||||
- Specialized tools in `crates/tools/` for `bash` execution, `fs` (filesystem) operations, `web` search/fetch, `notebook` interaction, `plan` management, and more.
|
||||
- **Integration:**
|
||||
- `crates/integration/mcp-client`: Client for the Model Context Protocol (MCP).
|
||||
5
conductor/tracks.md
Normal file
5
conductor/tracks.md
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
---
|
||||
|
||||
## [~] Track: Owlen Evolution - From TUI to Agentic Workstation
|
||||
*Link: [./conductor/tracks/owlen_evolution_20251226/](./conductor/tracks/owlen_evolution_20251226/)*
|
||||
8
conductor/tracks/owlen_evolution_20251226/metadata.json
Normal file
8
conductor/tracks/owlen_evolution_20251226/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "owlen_evolution_20251226",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2025-12-26T12:00:00Z",
|
||||
"updated_at": "2025-12-26T12:00:00Z",
|
||||
"description": "Transform Owlen from a blocking TUI into a non-blocking, agentic workstation."
|
||||
}
|
||||
55
conductor/tracks/owlen_evolution_20251226/plan.md
Normal file
55
conductor/tracks/owlen_evolution_20251226/plan.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Implementation Plan - Owlen Evolution
|
||||
|
||||
## Phase 1: Asynchronous Foundation (The "Non-Blocking" Fix) [checkpoint: aed3879]
|
||||
- [x] Task: Create `Message` enum and Central Message Hub using `tokio::sync::mpsc` b0e65e4
|
||||
- Define the `Message` enum to handle UI events, Agent responses, and System notifications.
|
||||
- Create the channel infrastructure in `main.rs`.
|
||||
- [x] Task: Refactor `main.rs` into UI and Engine Loops 9648ddd
|
||||
- Separate the TUI rendering loop into its own async task or thread.
|
||||
- Create the Engine loop (Tokio task) to handle `Message` processing.
|
||||
- [x] Task: Switch LLM interaction to Streaming-only c54962b
|
||||
- Update the LLM client to use streaming responses.
|
||||
- Ensure the UI updates incrementally as tokens arrive.
|
||||
- [x] Task: Introduce `Arc<Mutex<AppState>>` for shared state 2bccb11
|
||||
- Create `AppState` struct to hold conversation history, current mode, etc.
|
||||
- Share it between UI and Engine loops safely.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Asynchronous Foundation (The "Non-Blocking" Fix)' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: The Agentic Orchestrator (Middleware) [checkpoint: 8d1d7ba]
|
||||
- [x] Task: Create `AgentManager` struct 1e7c7cd
|
||||
- Implement the struct to manage the agent's lifecycle and state.
|
||||
- [x] Task: Implement the "Reasoning Loop" (Thought -> Action -> Observation) 5c82a0e
|
||||
- Build the loop logic to process user input, generate thoughts/actions, and handle results.
|
||||
- Implement Sliding Window context management.
|
||||
- Implement Context Summarization logic.
|
||||
- [x] Task: Develop Tool Registry System 1d7c584
|
||||
- Define the `Tool` trait.
|
||||
- Implement a registry to store and retrieve available tools.
|
||||
- Implement logic to inject tool JSON schemas into the System Prompt.
|
||||
- [x] Task: Add Sub-Agent Support b6ad2f0
|
||||
- Allow the Orchestrator to spawn sub-tasks if needed (basic implementation).
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: The Agentic Orchestrator (Middleware)' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Permission & Mode Engine
|
||||
- [x] Task: Define `AppMode` enum 37a36dd
|
||||
- Add `Normal`, `Plan`, `AcceptAll` variants.
|
||||
- Integrate `AppMode` into `AppState`.
|
||||
- [x] Task: Implement Permission Interceptor 159f2d3
|
||||
- Create logic to intercept tool calls based on `AppMode`.
|
||||
- Implement the pause/resume mechanism for the Agent Loop.
|
||||
- [x] Task: Implement UI for Permission Requests af1a61a
|
||||
- Add a "Status Bar Prompt" component to the TUI.
|
||||
- Connect the prompt to the Permission Interceptor via the Message Hub.
|
||||
- [ ] Task: Build "Plan Mode" logic
|
||||
- Implement the specific logic for batching actions in Plan Mode.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 3: Permission & Mode Engine' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Extension & Integration (The "Gemini-CLI" Layer)
|
||||
- [ ] Task: Create Dynamic Extension Loader
|
||||
- Implement scanning of `~/.config/owlen/plugins/`.
|
||||
- Implement reading of `config.yaml` for tool configuration.
|
||||
- [ ] Task: Implement JSON-RPC Interface
|
||||
- Create a standard interface for communicating with external tool binaries/scripts.
|
||||
- [ ] Task: Integrate External Tools into Registry
|
||||
- Map external tools to the `Tool` trait using the JSON-RPC bridge.
|
||||
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Extension & Integration (The "Gemini-CLI" Layer)' (Protocol in workflow.md)
|
||||
45
conductor/tracks/owlen_evolution_20251226/spec.md
Normal file
45
conductor/tracks/owlen_evolution_20251226/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Specification: Owlen Evolution – From TUI to Agentic Workstation
|
||||
|
||||
## Overview
|
||||
Transform Owlen from a blocking TUI into a non-blocking, agentic workstation. This involves decoupling the UI from the LLM logic, implementing a reasoning loop (Thought-Action-Observation), a robust permission system, and an extensible plugin architecture.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### 1. Asynchronous Foundation
|
||||
- **Non-blocking UI:** Separate the Ratatui UI rendering loop from the LLM/Engine logic using `tokio::sync::mpsc`.
|
||||
- **Streaming Response:** LLM interactions must be streaming-only to prevent UI freezes during long generations.
|
||||
- **Central Message Hub:** Use a many-to-one or many-to-many communication pattern (via `mpsc`) to synchronize state between the background engine and the UI.
|
||||
|
||||
### 2. Agentic Orchestrator
|
||||
- **Reasoning Loop:** Implement a core loop that follows the "Thought -> Action -> Observation" pattern.
|
||||
- **Context Management:**
|
||||
- **Sliding Window:** Maintain only the most recent N interactions in the immediate context.
|
||||
- **Summarization:** Use a secondary LLM call to periodically summarize older parts of the conversation to retain long-term state without hitting token limits.
|
||||
- **Tool Registry:** Define a `Tool` trait and a system to automatically inject tool definitions (JSON Schema) into system prompts.
|
||||
|
||||
### 3. Permission & Mode Engine
|
||||
- **Modes:** Support `Normal` (default), `Plan` (propose all actions first), and `AcceptAll` (autonomous execution).
|
||||
- **Permission Interceptor:** A mechanism to pause the agent loop when a tool requires approval.
|
||||
- **UI Integration:** Requests for permissions must be displayed in the **Status Bar** to avoid obscuring the main conversation.
|
||||
|
||||
### 4. Extension & Integration
|
||||
- **Hybrid Extension Loader:**
|
||||
- Scan a default directory (e.g., `~/.config/owlen/plugins/`) for executable extensions.
|
||||
- Allow manual configuration and overrides via `config.yaml`.
|
||||
- **JSON-RPC Interface:** Use a standardized JSON-RPC interface for communication with external extensions.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Performance:** UI must remain responsive (60 FPS target for rendering) even during heavy LLM tasks.
|
||||
- **Safety:** Explicit user consent required for "Write" or "Execute" operations by default.
|
||||
- **Maintainability:** Clear separation of concerns between `crates/app/ui` and `crates/core/agent`.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] TUI remains responsive (can scroll/exit) while the model is "thinking" or "generating".
|
||||
- [ ] Agent can successfully call a registered tool and process its output.
|
||||
- [ ] User can approve or deny a tool execution via a status bar prompt.
|
||||
- [ ] Context window manages long conversations via summarization without crashing.
|
||||
- [ ] External scripts can be loaded as tools and invoked by the agent.
|
||||
|
||||
## Out of Scope
|
||||
- Implementation of complex multi-agent swarms (keeping to a single orchestrator with sub-task capability).
|
||||
- Web-based UI (staying purely within TUI).
|
||||
333
conductor/workflow.md
Normal file
333
conductor/workflow.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Project Workflow
|
||||
|
||||
## Guiding Principles
|
||||
|
||||
1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
|
||||
2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
|
||||
3. **Test-Driven Development:** Write unit tests before implementing functionality
|
||||
4. **High Code Coverage:** Aim for >80% code coverage for all modules
|
||||
5. **User Experience First:** Every decision should prioritize user experience
|
||||
6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
|
||||
|
||||
## Task Workflow
|
||||
|
||||
All tasks follow a strict lifecycle:
|
||||
|
||||
### Standard Task Workflow
|
||||
|
||||
1. **Select Task:** Choose the next available task from `plan.md` in sequential order
|
||||
|
||||
2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
|
||||
|
||||
3. **Write Failing Tests (Red Phase):**
|
||||
- Create a new test file for the feature or bug fix.
|
||||
- Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
|
||||
- **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
|
||||
|
||||
4. **Implement to Pass Tests (Green Phase):**
|
||||
- Write the minimum amount of application code necessary to make the failing tests pass.
|
||||
- Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
|
||||
|
||||
5. **Refactor (Optional but Recommended):**
|
||||
- With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
|
||||
- Rerun tests to ensure they still pass after refactoring.
|
||||
|
||||
6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
|
||||
```bash
|
||||
pytest --cov=app --cov-report=html
|
||||
```
|
||||
Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
|
||||
|
||||
7. **Document Deviations:** If implementation differs from tech stack:
|
||||
- **STOP** implementation
|
||||
- Update `tech-stack.md` with new design
|
||||
- Add dated note explaining the change
|
||||
- Resume implementation
|
||||
|
||||
8. **Commit Code Changes:**
|
||||
- Stage all code changes related to the task.
|
||||
- Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
|
||||
- Perform the commit.
|
||||
|
||||
9. **Attach Task Summary with Git Notes:**
|
||||
- **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
|
||||
- **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
|
||||
- **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
|
||||
```bash
|
||||
# The note content from the previous step is passed via the -m flag.
|
||||
git notes add -m "<note content>" <commit_hash>
|
||||
```
|
||||
|
||||
10. **Get and Record Task Commit SHA:**
|
||||
- **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
|
||||
- **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
11. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
|
||||
|
||||
### Phase Completion Verification and Checkpointing Protocol
|
||||
|
||||
**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
|
||||
|
||||
1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
|
||||
|
||||
2. **Ensure Test Coverage for Phase Changes:**
|
||||
- **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
|
||||
- **Step 2.2: List Changed Files:** Execute `git diff --name-only <previous_checkpoint_sha> HEAD` to get a precise list of all files modified during this phase.
|
||||
- **Step 2.3: Verify and Create Tests:** For each file in the list:
|
||||
- **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
|
||||
- For each remaining code file, verify a corresponding test file exists.
|
||||
- If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
|
||||
|
||||
3. **Execute Automated Tests with Proactive Debugging:**
|
||||
- Before execution, you **must** announce the exact shell command you will use to run the tests.
|
||||
- **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
|
||||
- Execute the announced command.
|
||||
- If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
|
||||
|
||||
4. **Propose a Detailed, Actionable Manual Verification Plan:**
|
||||
- **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
|
||||
- You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
|
||||
- The plan you present to the user **must** follow this format:
|
||||
|
||||
**For a Frontend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Start the development server with the command:** `npm run dev`
|
||||
2. **Open your browser to:** `http://localhost:3000`
|
||||
3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
|
||||
```
|
||||
|
||||
**For a Backend Change:**
|
||||
```
|
||||
The automated tests have passed. For manual verification, please follow these steps:
|
||||
|
||||
**Manual Verification Steps:**
|
||||
1. **Ensure the server is running.**
|
||||
2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
|
||||
3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
|
||||
```
|
||||
|
||||
5. **Await Explicit User Feedback:**
|
||||
- After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
|
||||
- **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
|
||||
|
||||
6. **Create Checkpoint Commit:**
|
||||
- Stage all changes. If no changes occurred in this step, proceed with an empty commit.
|
||||
- Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
|
||||
|
||||
7. **Attach Auditable Verification Report using Git Notes:**
|
||||
- **Step 8.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
|
||||
- **Step 8.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
|
||||
|
||||
8. **Get and Record Phase Checkpoint SHA:**
|
||||
- **Step 7.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
|
||||
- **Step 7.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: <sha>]`.
|
||||
- **Step 7.3: Write Plan:** Write the updated content back to `plan.md`.
|
||||
|
||||
9. **Commit Plan Update:**
|
||||
- **Action:** Stage the modified `plan.md` file.
|
||||
- **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '<PHASE NAME>' as complete`.
|
||||
|
||||
10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
|
||||
|
||||
### Quality Gates
|
||||
|
||||
Before marking any task complete, verify:
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Code coverage meets requirements (>80%)
|
||||
- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
|
||||
- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
|
||||
- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
|
||||
- [ ] No linting or static analysis errors (using the project's configured tools)
|
||||
- [ ] Works correctly on mobile (if applicable)
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] No security vulnerabilities introduced
|
||||
|
||||
## Development Commands
|
||||
|
||||
**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
|
||||
# e.g., for a Node.js project: npm install
|
||||
# e.g., for a Go project: go mod tidy
|
||||
```
|
||||
|
||||
### Daily Development
|
||||
```bash
|
||||
# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
|
||||
# e.g., for a Node.js project: npm run dev, npm test, npm run lint
|
||||
# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
|
||||
```
|
||||
|
||||
### Before Committing
|
||||
```bash
|
||||
# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
|
||||
# e.g., for a Node.js project: npm run check
|
||||
# e.g., for a Go project: make check (if a Makefile exists)
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Testing
|
||||
- Every module must have corresponding tests.
|
||||
- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
|
||||
- Mock external dependencies.
|
||||
- Test both success and failure cases.
|
||||
|
||||
### Integration Testing
|
||||
- Test complete user flows
|
||||
- Verify database transactions
|
||||
- Test authentication and authorization
|
||||
- Check form submissions
|
||||
|
||||
### Mobile Testing
|
||||
- Test on actual iPhone when possible
|
||||
- Use Safari developer tools
|
||||
- Test touch interactions
|
||||
- Verify responsive layouts
|
||||
- Check performance on 3G/4G
|
||||
|
||||
## Code Review Process
|
||||
|
||||
### Self-Review Checklist
|
||||
Before requesting review:
|
||||
|
||||
1. **Functionality**
|
||||
- Feature works as specified
|
||||
- Edge cases handled
|
||||
- Error messages are user-friendly
|
||||
|
||||
2. **Code Quality**
|
||||
- Follows style guide
|
||||
- DRY principle applied
|
||||
- Clear variable/function names
|
||||
- Appropriate comments
|
||||
|
||||
3. **Testing**
|
||||
- Unit tests comprehensive
|
||||
- Integration tests pass
|
||||
- Coverage adequate (>80%)
|
||||
|
||||
4. **Security**
|
||||
- No hardcoded secrets
|
||||
- Input validation present
|
||||
- SQL injection prevented
|
||||
- XSS protection in place
|
||||
|
||||
5. **Performance**
|
||||
- Database queries optimized
|
||||
- Images optimized
|
||||
- Caching implemented where needed
|
||||
|
||||
6. **Mobile Experience**
|
||||
- Touch targets adequate (44x44px)
|
||||
- Text readable without zooming
|
||||
- Performance acceptable on mobile
|
||||
- Interactions feel native
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
### Message Format
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation only
|
||||
- `style`: Formatting, missing semicolons, etc.
|
||||
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||
- `test`: Adding missing tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```bash
|
||||
git commit -m "feat(auth): Add remember me functionality"
|
||||
git commit -m "fix(posts): Correct excerpt generation for short posts"
|
||||
git commit -m "test(comments): Add tests for emoji reaction limits"
|
||||
git commit -m "style(mobile): Improve button touch targets"
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A task is complete when:
|
||||
|
||||
1. All code implemented to specification
|
||||
2. Unit tests written and passing
|
||||
3. Code coverage meets project requirements
|
||||
4. Documentation complete (if applicable)
|
||||
5. Code passes all configured linting and static analysis checks
|
||||
6. Works beautifully on mobile (if applicable)
|
||||
7. Implementation notes added to `plan.md`
|
||||
8. Changes committed with proper message
|
||||
9. Git note with task summary attached to the commit
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Critical Bug in Production
|
||||
1. Create hotfix branch from main
|
||||
2. Write failing test for bug
|
||||
3. Implement minimal fix
|
||||
4. Test thoroughly including mobile
|
||||
5. Deploy immediately
|
||||
6. Document in plan.md
|
||||
|
||||
### Data Loss
|
||||
1. Stop all write operations
|
||||
2. Restore from latest backup
|
||||
3. Verify data integrity
|
||||
4. Document incident
|
||||
5. Update backup procedures
|
||||
|
||||
### Security Breach
|
||||
1. Rotate all secrets immediately
|
||||
2. Review access logs
|
||||
3. Patch vulnerability
|
||||
4. Notify affected users (if any)
|
||||
5. Document and update security procedures
|
||||
|
||||
## Deployment Workflow
|
||||
|
||||
### Pre-Deployment Checklist
|
||||
- [ ] All tests passing
|
||||
- [ ] Coverage >80%
|
||||
- [ ] No linting errors
|
||||
- [ ] Mobile testing complete
|
||||
- [ ] Environment variables configured
|
||||
- [ ] Database migrations ready
|
||||
- [ ] Backup created
|
||||
|
||||
### Deployment Steps
|
||||
1. Merge feature branch to main
|
||||
2. Tag release with version
|
||||
3. Push to deployment service
|
||||
4. Run database migrations
|
||||
5. Verify deployment
|
||||
6. Test critical paths
|
||||
7. Monitor for errors
|
||||
|
||||
### Post-Deployment
|
||||
1. Monitor analytics
|
||||
2. Check error logs
|
||||
3. Gather user feedback
|
||||
4. Plan next iteration
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
- Review workflow weekly
|
||||
- Update based on pain points
|
||||
- Document lessons learned
|
||||
- Optimize for user happiness
|
||||
- Keep things simple and maintainable
|
||||
22
crates/app/cli/.gitignore
vendored
Normal file
22
crates/app/cli/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/target
|
||||
### Rust template
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
### rust-analyzer template
|
||||
# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules)
|
||||
rust-project.json
|
||||
|
||||
|
||||
41
crates/app/cli/Cargo.toml
Normal file
41
crates/app/cli/Cargo.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "owlen"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
agent-core = { path = "../../core/agent" }
|
||||
llm-core = { path = "../../llm/core" }
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
llm-anthropic = { path = "../../llm/anthropic" }
|
||||
llm-openai = { path = "../../llm/openai" }
|
||||
tools-fs = { path = "../../tools/fs" }
|
||||
tools-bash = { path = "../../tools/bash" }
|
||||
tools-slash = { path = "../../tools/slash" }
|
||||
tools-plan = { path = "../../tools/plan" }
|
||||
auth-manager = { path = "../../platform/auth" }
|
||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
hooks = { path = "../../platform/hooks" }
|
||||
plugins = { path = "../../platform/plugins" }
|
||||
ui = { path = "../ui" }
|
||||
atty = "0.2"
|
||||
futures-util = "0.3.31"
|
||||
rpassword = "7"
|
||||
open = "5"
|
||||
futures = "0.3.31"
|
||||
async-trait = "0.1.89"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2.0"
|
||||
predicates = "3.1"
|
||||
httpmock = "0.7"
|
||||
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
|
||||
tempfile = "3.23.0"
|
||||
29
crates/app/cli/README.md
Normal file
29
crates/app/cli/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Owlen CLI
|
||||
|
||||
The command-line interface for the Owlen AI agent.
|
||||
|
||||
## Features
|
||||
- **Interactive Chat:** Communicate with the AI agent directly from your terminal.
|
||||
- **Tool Integration:** Built-in support for filesystem operations, bash execution, and more.
|
||||
- **Provider Management:** Easily switch between different LLM providers (Ollama, Anthropic, OpenAI).
|
||||
- **Session Management:** Persist conversation history and resume previous sessions.
|
||||
- **Secure Authentication:** Managed authentication flows for major AI providers.
|
||||
|
||||
## Usage
|
||||
|
||||
### Direct Invocation
|
||||
```bash
|
||||
# Start an interactive chat session
|
||||
owlen
|
||||
|
||||
# Ask a single question
|
||||
owlen "How do I list files in Rust?"
|
||||
```
|
||||
|
||||
### Commands
|
||||
- `owlen config`: View or modify agent configuration.
|
||||
- `owlen login <provider>`: Authenticate with a specific LLM provider.
|
||||
- `owlen session`: Manage chat sessions.
|
||||
|
||||
## Configuration
|
||||
Owlen uses a global configuration file located at `~/.config/owlen/config.toml`. You can also provide project-specific settings via an `.owlen.toml` file in your project root.
|
||||
584
crates/app/cli/src/agent_manager.rs
Normal file
584
crates/app/cli/src/agent_manager.rs
Normal file
@@ -0,0 +1,584 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use agent_core::state::{AppState, AppMode};
|
||||
use agent_core::{PlanStep, AccumulatedPlanStatus};
|
||||
use llm_core::{LlmProvider, ChatMessage, ChatOptions};
|
||||
use color_eyre::eyre::Result;
|
||||
use agent_core::messages::{Message, AgentResponse};
|
||||
use futures::StreamExt;
|
||||
use ui::ProviderManager;
|
||||
|
||||
/// Client source - either a fixed client or dynamic via ProviderManager
|
||||
enum ClientSource {
|
||||
/// Fixed client (legacy mode)
|
||||
Fixed(Arc<dyn LlmProvider>),
|
||||
/// Dynamic via ProviderManager (supports provider/model switching)
|
||||
Dynamic(Arc<Mutex<ProviderManager>>),
|
||||
}
|
||||
|
||||
/// Manages the lifecycle and state of the agent
|
||||
pub struct AgentManager {
|
||||
client_source: ClientSource,
|
||||
state: Arc<Mutex<AppState>>,
|
||||
tx_ui: Option<mpsc::Sender<Message>>,
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
/// Create a new AgentManager with a fixed client (legacy mode)
|
||||
pub fn new(client: Arc<dyn LlmProvider>, state: Arc<Mutex<AppState>>) -> Self {
|
||||
Self {
|
||||
client_source: ClientSource::Fixed(client),
|
||||
state,
|
||||
tx_ui: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new AgentManager with a dynamic ProviderManager
|
||||
pub fn with_provider_manager(
|
||||
provider_manager: Arc<Mutex<ProviderManager>>,
|
||||
state: Arc<Mutex<AppState>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client_source: ClientSource::Dynamic(provider_manager),
|
||||
state,
|
||||
tx_ui: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the UI message sender
|
||||
pub fn with_ui_sender(mut self, tx: mpsc::Sender<Message>) -> Self {
|
||||
self.tx_ui = Some(tx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the current LLM client (resolves dynamic provider if needed)
|
||||
async fn get_client(&self) -> Result<Arc<dyn LlmProvider>> {
|
||||
match &self.client_source {
|
||||
ClientSource::Fixed(client) => Ok(Arc::clone(client)),
|
||||
ClientSource::Dynamic(manager) => {
|
||||
let mut guard = manager.lock().await;
|
||||
guard.get_provider()
|
||||
.map_err(|e| color_eyre::eyre::eyre!("Failed to get provider: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current model name
|
||||
async fn get_model(&self) -> String {
|
||||
match &self.client_source {
|
||||
ClientSource::Fixed(client) => client.model().to_string(),
|
||||
ClientSource::Dynamic(manager) => {
|
||||
let guard = manager.lock().await;
|
||||
guard.current_model().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the shared state
|
||||
#[allow(dead_code)]
|
||||
pub fn state(&self) -> &Arc<Mutex<AppState>> {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Check if a tool execution is permitted
|
||||
pub async fn check_permission(&self, tool: &str, context: Option<&str>) -> Result<bool> {
|
||||
let mode = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.mode
|
||||
};
|
||||
|
||||
match mode {
|
||||
AppMode::AcceptAll => Ok(true),
|
||||
AppMode::Normal | AppMode::Plan => {
|
||||
// Request permission from UI
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::PermissionRequest {
|
||||
tool: tool.to_string(),
|
||||
context: context.map(|s| s.to_string()),
|
||||
})).await;
|
||||
|
||||
// Wait for result
|
||||
let notify = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.permission_notify.clone()
|
||||
};
|
||||
|
||||
notify.notified().await;
|
||||
|
||||
let result = {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.last_permission_result.take().unwrap_or(false)
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
// No UI sender, default to deny for safety
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a sub-agent for a specific task
|
||||
pub async fn spawn_sub_agent(&self, _task: &str) -> Result<String> {
|
||||
// Basic placeholder implementation for Sub-Agent support
|
||||
Ok("Sub-agent task completed (mock)".to_string())
|
||||
}
|
||||
|
||||
/// Execute approved steps from the accumulated plan
|
||||
async fn execute_approved_plan_steps(&self) -> Result<()> {
|
||||
// Take the approval and apply it to the plan
|
||||
let approval = {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.take_plan_approval()
|
||||
};
|
||||
|
||||
if let Some(approval) = approval {
|
||||
// Apply approval to plan
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
approval.apply_to(plan);
|
||||
plan.start_execution();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the approved steps
|
||||
let approved_steps: Vec<_> = {
|
||||
let guard = self.state.lock().await;
|
||||
if let Some(plan) = guard.current_plan() {
|
||||
plan.approved_steps()
|
||||
.into_iter()
|
||||
.map(|s| (s.id.clone(), s.tool.clone(), s.args.clone()))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let total_steps = approved_steps.len();
|
||||
let mut executed = 0;
|
||||
let mut skipped = 0;
|
||||
|
||||
// Execute each approved step
|
||||
for (index, (id, tool_name, arguments)) in approved_steps.into_iter().enumerate() {
|
||||
// Notify UI of execution progress
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::PlanExecuting {
|
||||
step_id: id.clone(),
|
||||
step_index: index,
|
||||
total_steps,
|
||||
})).await;
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
let dummy_perms = permissions::PermissionManager::new(permissions::Mode::Code);
|
||||
let ctx = agent_core::ToolContext::new();
|
||||
|
||||
match agent_core::execute_tool(&tool_name, &arguments, &dummy_perms, &ctx).await {
|
||||
Ok(result) => {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::tool_result(&id, result));
|
||||
executed += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::tool_result(&id, format!("Error: {}", e)));
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark plan as complete and notify UI
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.complete();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::PlanExecutionComplete {
|
||||
executed,
|
||||
skipped,
|
||||
})).await;
|
||||
}
|
||||
|
||||
// Clear the plan
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.clear_plan();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a single tool call (used by engine for plan step execution)
|
||||
pub async fn execute_single_tool(&self, tool_name: &str, args: &serde_json::Value) -> Result<String> {
|
||||
let dummy_perms = permissions::PermissionManager::new(permissions::Mode::Code);
|
||||
let ctx = agent_core::ToolContext::new();
|
||||
|
||||
let result = agent_core::execute_tool(tool_name, args, &dummy_perms, &ctx).await?;
|
||||
|
||||
// Notify UI of tool execution
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::ToolCall {
|
||||
name: tool_name.to_string(),
|
||||
args: args.to_string(),
|
||||
})).await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute the full reasoning loop until a final response is reached
|
||||
pub async fn run(&self, input: &str) -> Result<()> {
|
||||
let tools = agent_core::get_tool_definitions();
|
||||
|
||||
// 1. Add user message to history
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::user(input.to_string()));
|
||||
}
|
||||
|
||||
let max_iterations = 10;
|
||||
let mut iteration = 0;
|
||||
|
||||
loop {
|
||||
iteration += 1;
|
||||
if iteration > max_iterations {
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Error("Max iterations reached".to_string()))).await;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 2. Prepare context
|
||||
let messages = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.messages.clone()
|
||||
};
|
||||
|
||||
// 3. Get current client (supports dynamic provider switching)
|
||||
let client = match self.get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Error(e.to_string()))).await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let options = ChatOptions::new(client.model());
|
||||
|
||||
// 4. Call LLM with streaming
|
||||
let stream_result = client.chat_stream(&messages, &options, Some(&tools)).await;
|
||||
let mut stream = match stream_result {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Error(e.to_string()))).await;
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let mut response_content = String::new();
|
||||
let mut tool_calls_builder = agent_core::ToolCallsBuilder::new();
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Error(e.to_string()))).await;
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(content) = &chunk.content {
|
||||
response_content.push_str(content);
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Token(content.clone()))).await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(deltas) = &chunk.tool_calls {
|
||||
tool_calls_builder.add_deltas(deltas);
|
||||
}
|
||||
}
|
||||
|
||||
drop(stream);
|
||||
|
||||
let tool_calls = tool_calls_builder.build();
|
||||
|
||||
// Add assistant message to history
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage {
|
||||
role: llm_core::Role::Assistant,
|
||||
content: if response_content.is_empty() { None } else { Some(response_content.clone()) },
|
||||
tool_calls: if tool_calls.is_empty() { None } else { Some(tool_calls.clone()) },
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mode = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.mode
|
||||
};
|
||||
|
||||
// Check if LLM finished (no tool calls)
|
||||
if tool_calls.is_empty() {
|
||||
// Check if we have an accumulated plan with steps
|
||||
let has_plan_steps = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.accumulated_plan.as_ref().map(|p| !p.steps.is_empty()).unwrap_or(false)
|
||||
};
|
||||
|
||||
if mode == AppMode::Plan && has_plan_steps {
|
||||
// In Plan mode WITH steps: finalize and wait for user approval
|
||||
let (total_steps, status) = {
|
||||
let mut guard = self.state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.finalize();
|
||||
(plan.steps.len(), plan.status.clone())
|
||||
} else {
|
||||
(0, AccumulatedPlanStatus::Completed)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::PlanComplete {
|
||||
total_steps,
|
||||
status,
|
||||
})).await;
|
||||
}
|
||||
|
||||
// Wait for user approval
|
||||
let notify = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.plan_notify.clone()
|
||||
};
|
||||
notify.notified().await;
|
||||
|
||||
// Execute approved steps
|
||||
self.execute_approved_plan_steps().await?;
|
||||
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Complete)).await;
|
||||
}
|
||||
} else {
|
||||
// Normal mode OR Plan mode with no steps: just complete
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::Complete)).await;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle Plan mode: accumulate steps instead of executing
|
||||
if mode == AppMode::Plan {
|
||||
// Ensure we have an active plan
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
if guard.accumulated_plan.is_none() {
|
||||
guard.start_plan();
|
||||
}
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.next_turn();
|
||||
}
|
||||
}
|
||||
|
||||
// Add each tool call as a plan step
|
||||
for call in &tool_calls {
|
||||
let step = PlanStep::new(
|
||||
call.id.clone(),
|
||||
iteration,
|
||||
call.function.name.clone(),
|
||||
call.function.arguments.clone(),
|
||||
).with_rationale(response_content.clone());
|
||||
|
||||
// Add to accumulated plan
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.add_step(step.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Notify UI of new step
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::PlanStepAdded(step))).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Feed mock results back to LLM so it can continue reasoning
|
||||
for call in &tool_calls {
|
||||
let mock_result = format!(
|
||||
"[Plan Mode] Step '{}' recorded for approval. Continue proposing steps or stop to finalize.",
|
||||
call.function.name
|
||||
);
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::tool_result(&call.id, mock_result));
|
||||
}
|
||||
|
||||
// Continue loop - agent will propose more steps or complete
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normal/AcceptAll mode: Execute tools immediately
|
||||
for call in tool_calls {
|
||||
let tool_name = call.function.name.clone();
|
||||
let arguments = call.function.arguments.clone();
|
||||
|
||||
if let Some(tx) = &self.tx_ui {
|
||||
let _ = tx.send(Message::AgentResponse(AgentResponse::ToolCall {
|
||||
name: tool_name.clone(),
|
||||
args: arguments.to_string(),
|
||||
})).await;
|
||||
}
|
||||
|
||||
// Check permission
|
||||
let context = match tool_name.as_str() {
|
||||
"read" | "write" | "edit" => arguments.get("path").and_then(|v| v.as_str()),
|
||||
"bash" => arguments.get("command").and_then(|v| v.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let allowed = self.check_permission(&tool_name, context).await?;
|
||||
|
||||
if allowed {
|
||||
// Execute tool
|
||||
// We need a dummy PermissionManager that always allows because we already checked
|
||||
let dummy_perms = permissions::PermissionManager::new(permissions::Mode::Code);
|
||||
let ctx = agent_core::ToolContext::new(); // TODO: Use real context
|
||||
|
||||
match agent_core::execute_tool(&tool_name, &arguments, &dummy_perms, &ctx).await {
|
||||
Ok(result) => {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::tool_result(&call.id, result));
|
||||
}
|
||||
Err(e) => {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::tool_result(&call.id, format!("Error: {}", e)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::tool_result(&call.id, "Permission denied by user".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute the reasoning loop: User Input -> LLM -> Thought/Action -> Result -> LLM
|
||||
pub async fn step(&self, input: &str) -> Result<String> {
|
||||
// 1. Add user message to history
|
||||
{
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::user(input.to_string()));
|
||||
}
|
||||
|
||||
// 2. Prepare context
|
||||
let messages = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.messages.clone()
|
||||
};
|
||||
|
||||
// 3. Get current client
|
||||
let client = self.get_client().await?;
|
||||
let options = ChatOptions::new(client.model());
|
||||
let response = client.chat(&messages, &options, None).await?;
|
||||
|
||||
// 4. Process response
|
||||
if let Some(content) = response.content {
|
||||
let mut guard = self.state.lock().await;
|
||||
guard.add_message(ChatMessage::assistant(content.clone()));
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_core::{Tool, ChunkStream, ChatResponse};
|
||||
use async_trait::async_trait;
|
||||
use agent_core::messages::UserAction;
|
||||
|
||||
struct MockProvider;
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
fn name(&self) -> &str { "mock" }
|
||||
fn model(&self) -> &str { "mock" }
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_options: &ChatOptions,
|
||||
_tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, llm_core::LlmError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn chat(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_options: &ChatOptions,
|
||||
_tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, llm_core::LlmError> {
|
||||
Ok(ChatResponse {
|
||||
content: Some("Mock Response".to_string()),
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_reasoning_loop_basic() {
|
||||
let client = Arc::new(MockProvider);
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
let manager = AgentManager::new(client, state);
|
||||
|
||||
let response = manager.step("Hello").await.unwrap();
|
||||
assert_eq!(response, "Mock Response");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sub_agent_spawn() {
|
||||
let client = Arc::new(MockProvider);
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
let manager = AgentManager::new(client, state);
|
||||
|
||||
let result = manager.spawn_sub_agent("Do something").await.unwrap();
|
||||
assert_eq!(result, "Sub-agent task completed (mock)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_permission_request() {
|
||||
let client = Arc::new(MockProvider);
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
|
||||
let manager = AgentManager::new(client, state.clone()).with_ui_sender(tx);
|
||||
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
// Simulate UI receiving request and granting permission
|
||||
if let Some(Message::AgentResponse(AgentResponse::PermissionRequest { .. })) = rx.recv().await {
|
||||
let mut guard = state_clone.lock().await;
|
||||
guard.set_permission_result(true);
|
||||
}
|
||||
});
|
||||
|
||||
let allowed = manager.check_permission("bash", Some("ls")).await.unwrap();
|
||||
assert!(allowed);
|
||||
}
|
||||
}
|
||||
382
crates/app/cli/src/commands.rs
Normal file
382
crates/app/cli/src/commands.rs
Normal file
@@ -0,0 +1,382 @@
|
||||
//! Built-in commands for CLI and TUI
|
||||
//!
|
||||
//! Provides handlers for /help, /mcp, /hooks, /clear, and other built-in commands.
|
||||
|
||||
use ui::{CommandInfo, CommandOutput, OutputFormat, TreeNode, ListItem};
|
||||
use permissions::PermissionManager;
|
||||
use hooks::HookManager;
|
||||
use plugins::PluginManager;
|
||||
use agent_core::SessionStats;
|
||||
|
||||
/// Result of executing a built-in command
|
||||
pub enum CommandResult {
|
||||
/// Command produced output to display
|
||||
Output(CommandOutput),
|
||||
/// Command was handled but produced no output (e.g., /clear)
|
||||
Handled,
|
||||
/// Command was not recognized
|
||||
NotFound,
|
||||
/// Command needs to exit the session
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// Built-in command handler
|
||||
pub struct BuiltinCommands<'a> {
|
||||
plugin_manager: Option<&'a PluginManager>,
|
||||
hook_manager: Option<&'a HookManager>,
|
||||
permission_manager: Option<&'a PermissionManager>,
|
||||
stats: Option<&'a SessionStats>,
|
||||
}
|
||||
|
||||
impl<'a> BuiltinCommands<'a> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
plugin_manager: None,
|
||||
hook_manager: None,
|
||||
permission_manager: None,
|
||||
stats: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_plugins(mut self, pm: &'a PluginManager) -> Self {
|
||||
self.plugin_manager = Some(pm);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_hooks(mut self, hm: &'a HookManager) -> Self {
|
||||
self.hook_manager = Some(hm);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_permissions(mut self, perms: &'a PermissionManager) -> Self {
|
||||
self.permission_manager = Some(perms);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_stats(mut self, stats: &'a SessionStats) -> Self {
|
||||
self.stats = Some(stats);
|
||||
self
|
||||
}
|
||||
|
||||
/// Execute a built-in command
|
||||
pub fn execute(&self, command: &str) -> CommandResult {
|
||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||
let cmd = parts.first().map(|s| s.trim_start_matches('/'));
|
||||
|
||||
match cmd {
|
||||
Some("help") | Some("?") => CommandResult::Output(self.help()),
|
||||
Some("mcp") => CommandResult::Output(self.mcp()),
|
||||
Some("hooks") => CommandResult::Output(self.hooks()),
|
||||
Some("plugins") => CommandResult::Output(self.plugins()),
|
||||
Some("status") => CommandResult::Output(self.status()),
|
||||
Some("permissions") | Some("perms") => CommandResult::Output(self.permissions()),
|
||||
Some("clear") => CommandResult::Handled,
|
||||
Some("exit") | Some("quit") | Some("q") => CommandResult::Exit,
|
||||
_ => CommandResult::NotFound,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate help output
|
||||
fn help(&self) -> CommandOutput {
|
||||
let mut commands = vec![
|
||||
// Built-in commands
|
||||
CommandInfo::new("help", "Show available commands", "builtin"),
|
||||
CommandInfo::new("clear", "Clear the screen", "builtin"),
|
||||
CommandInfo::new("status", "Show session status", "builtin"),
|
||||
CommandInfo::new("permissions", "Show permission settings", "builtin"),
|
||||
CommandInfo::new("mcp", "List MCP servers and tools", "builtin"),
|
||||
CommandInfo::new("hooks", "Show loaded hooks", "builtin"),
|
||||
CommandInfo::new("plugins", "Show loaded plugins", "builtin"),
|
||||
CommandInfo::new("checkpoint", "Save session state", "builtin"),
|
||||
CommandInfo::new("checkpoints", "List saved checkpoints", "builtin"),
|
||||
CommandInfo::new("rewind", "Restore from checkpoint", "builtin"),
|
||||
CommandInfo::new("compact", "Compact conversation context", "builtin"),
|
||||
CommandInfo::new("exit", "Exit the session", "builtin"),
|
||||
];
|
||||
|
||||
// Add plugin commands
|
||||
if let Some(pm) = self.plugin_manager {
|
||||
for plugin in pm.plugins() {
|
||||
for cmd_name in plugin.all_command_names() {
|
||||
commands.push(CommandInfo::new(
|
||||
&cmd_name,
|
||||
&format!("Plugin command from {}", plugin.manifest.name),
|
||||
&format!("plugin:{}", plugin.manifest.name),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandOutput::help_table(&commands)
|
||||
}
|
||||
|
||||
/// Generate MCP servers output
|
||||
fn mcp(&self) -> CommandOutput {
|
||||
let mut servers: Vec<(String, Vec<String>)> = vec![];
|
||||
|
||||
// Get MCP servers from plugins
|
||||
if let Some(pm) = self.plugin_manager {
|
||||
for plugin in pm.plugins() {
|
||||
// Check for .mcp.json in plugin directory
|
||||
let mcp_path = plugin.base_path.join(".mcp.json");
|
||||
if mcp_path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&mcp_path) {
|
||||
if let Ok(config) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(mcpservers) = config.get("mcpServers").and_then(|v| v.as_object()) {
|
||||
for (name, _) in mcpservers {
|
||||
servers.push((
|
||||
format!("{} ({})", name, plugin.manifest.name),
|
||||
vec!["(connect to discover tools)".to_string()],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if servers.is_empty() {
|
||||
CommandOutput::new(OutputFormat::Text {
|
||||
content: "No MCP servers configured.\n\nAdd MCP servers in plugin .mcp.json files.".to_string(),
|
||||
})
|
||||
} else {
|
||||
CommandOutput::mcp_tree(&servers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate hooks output
|
||||
fn hooks(&self) -> CommandOutput {
|
||||
let mut hooks_list: Vec<(String, String, bool)> = vec![];
|
||||
|
||||
// Check for file-based hooks in .owlen/hooks/
|
||||
let hook_events = ["PreToolUse", "PostToolUse", "SessionStart", "SessionEnd",
|
||||
"UserPromptSubmit", "PreCompact", "Stop", "SubagentStop"];
|
||||
|
||||
for event in hook_events {
|
||||
let path = format!(".owlen/hooks/{}", event);
|
||||
let exists = std::path::Path::new(&path).exists();
|
||||
if exists {
|
||||
hooks_list.push((event.to_string(), path, true));
|
||||
}
|
||||
}
|
||||
|
||||
// Get hooks from plugins
|
||||
if let Some(pm) = self.plugin_manager {
|
||||
for plugin in pm.plugins() {
|
||||
if let Some(hooks_config) = plugin.load_hooks_config().ok().flatten() {
|
||||
// hooks_config.hooks is HashMap<String, Vec<HookMatcher>>
|
||||
for (event_name, matchers) in &hooks_config.hooks {
|
||||
for matcher in matchers {
|
||||
for hook_def in &matcher.hooks {
|
||||
let cmd = hook_def.command.as_deref()
|
||||
.or(hook_def.prompt.as_deref())
|
||||
.unwrap_or("(no command)");
|
||||
hooks_list.push((
|
||||
event_name.clone(),
|
||||
format!("{}: {}", plugin.manifest.name, cmd),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hooks_list.is_empty() {
|
||||
CommandOutput::new(OutputFormat::Text {
|
||||
content: "No hooks configured.\n\nAdd hooks in .owlen/hooks/ or plugin hooks.json files.".to_string(),
|
||||
})
|
||||
} else {
|
||||
CommandOutput::hooks_list(&hooks_list)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate plugins output
|
||||
fn plugins(&self) -> CommandOutput {
|
||||
if let Some(pm) = self.plugin_manager {
|
||||
let plugins = pm.plugins();
|
||||
if plugins.is_empty() {
|
||||
return CommandOutput::new(OutputFormat::Text {
|
||||
content: "No plugins loaded.\n\nPlace plugins in:\n - ~/.config/owlen/plugins (user)\n - .owlen/plugins (project)".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Build tree of plugins and their components
|
||||
let children: Vec<TreeNode> = plugins.iter().map(|p| {
|
||||
let mut plugin_children = vec![];
|
||||
|
||||
let commands = p.all_command_names();
|
||||
if !commands.is_empty() {
|
||||
plugin_children.push(TreeNode::new("Commands").with_children(
|
||||
commands.iter().map(|c| TreeNode::new(format!("/{}", c))).collect()
|
||||
));
|
||||
}
|
||||
|
||||
let agents = p.all_agent_names();
|
||||
if !agents.is_empty() {
|
||||
plugin_children.push(TreeNode::new("Agents").with_children(
|
||||
agents.iter().map(|a| TreeNode::new(a)).collect()
|
||||
));
|
||||
}
|
||||
|
||||
let skills = p.all_skill_names();
|
||||
if !skills.is_empty() {
|
||||
plugin_children.push(TreeNode::new("Skills").with_children(
|
||||
skills.iter().map(|s| TreeNode::new(s)).collect()
|
||||
));
|
||||
}
|
||||
|
||||
TreeNode::new(format!("{} v{}", p.manifest.name, p.manifest.version))
|
||||
.with_children(plugin_children)
|
||||
}).collect();
|
||||
|
||||
CommandOutput::new(OutputFormat::Tree {
|
||||
root: TreeNode::new("Loaded Plugins").with_children(children),
|
||||
})
|
||||
} else {
|
||||
CommandOutput::new(OutputFormat::Text {
|
||||
content: "Plugin manager not available.".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate status output
|
||||
fn status(&self) -> CommandOutput {
|
||||
let mut items = vec![];
|
||||
|
||||
if let Some(stats) = self.stats {
|
||||
items.push(ListItem {
|
||||
text: format!("Messages: {}", stats.total_messages),
|
||||
marker: Some("📊".to_string()),
|
||||
style: None,
|
||||
});
|
||||
items.push(ListItem {
|
||||
text: format!("Tool Calls: {}", stats.total_tool_calls),
|
||||
marker: Some("🔧".to_string()),
|
||||
style: None,
|
||||
});
|
||||
items.push(ListItem {
|
||||
text: format!("Est. Tokens: ~{}", stats.estimated_tokens),
|
||||
marker: Some("📝".to_string()),
|
||||
style: None,
|
||||
});
|
||||
let uptime = stats.start_time.elapsed().unwrap_or_default();
|
||||
items.push(ListItem {
|
||||
text: format!("Uptime: {}", SessionStats::format_duration(uptime)),
|
||||
marker: Some("⏱️".to_string()),
|
||||
style: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(perms) = self.permission_manager {
|
||||
items.push(ListItem {
|
||||
text: format!("Mode: {:?}", perms.mode()),
|
||||
marker: Some("🔒".to_string()),
|
||||
style: None,
|
||||
});
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
CommandOutput::new(OutputFormat::Text {
|
||||
content: "Session status not available.".to_string(),
|
||||
})
|
||||
} else {
|
||||
CommandOutput::new(OutputFormat::List { items })
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate permissions output
|
||||
fn permissions(&self) -> CommandOutput {
|
||||
if let Some(perms) = self.permission_manager {
|
||||
let mode = perms.mode();
|
||||
let mode_str = format!("{:?}", mode);
|
||||
|
||||
let mut items = vec![
|
||||
ListItem {
|
||||
text: format!("Current Mode: {}", mode_str),
|
||||
marker: Some("🔒".to_string()),
|
||||
style: None,
|
||||
},
|
||||
];
|
||||
|
||||
// Add tool permissions summary
|
||||
let (read_status, write_status, bash_status) = match mode {
|
||||
permissions::Mode::Plan => ("✅ Allowed", "❓ Ask", "❓ Ask"),
|
||||
permissions::Mode::AcceptEdits => ("✅ Allowed", "✅ Allowed", "❓ Ask"),
|
||||
permissions::Mode::Code => ("✅ Allowed", "✅ Allowed", "✅ Allowed"),
|
||||
};
|
||||
|
||||
items.push(ListItem {
|
||||
text: format!("Read/Grep/Glob: {}", read_status),
|
||||
marker: None,
|
||||
style: None,
|
||||
});
|
||||
items.push(ListItem {
|
||||
text: format!("Write/Edit: {}", write_status),
|
||||
marker: None,
|
||||
style: None,
|
||||
});
|
||||
items.push(ListItem {
|
||||
text: format!("Bash: {}", bash_status),
|
||||
marker: None,
|
||||
style: None,
|
||||
});
|
||||
|
||||
CommandOutput::new(OutputFormat::List { items })
|
||||
} else {
|
||||
CommandOutput::new(OutputFormat::Text {
|
||||
content: "Permission manager not available.".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BuiltinCommands<'_> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_help_command() {
|
||||
let handler = BuiltinCommands::new();
|
||||
match handler.execute("/help") {
|
||||
CommandResult::Output(output) => {
|
||||
match output.format {
|
||||
OutputFormat::Table { headers, rows } => {
|
||||
assert!(!headers.is_empty());
|
||||
assert!(!rows.is_empty());
|
||||
}
|
||||
_ => panic!("Expected Table format"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected Output result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exit_command() {
|
||||
let handler = BuiltinCommands::new();
|
||||
assert!(matches!(handler.execute("/exit"), CommandResult::Exit));
|
||||
assert!(matches!(handler.execute("/quit"), CommandResult::Exit));
|
||||
assert!(matches!(handler.execute("/q"), CommandResult::Exit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear_command() {
|
||||
let handler = BuiltinCommands::new();
|
||||
assert!(matches!(handler.execute("/clear"), CommandResult::Handled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_command() {
|
||||
let handler = BuiltinCommands::new();
|
||||
assert!(matches!(handler.execute("/unknown"), CommandResult::NotFound));
|
||||
}
|
||||
}
|
||||
329
crates/app/cli/src/engine.rs
Normal file
329
crates/app/cli/src/engine.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
use agent_core::messages::{Message, UserAction, AgentResponse, SystemNotification};
|
||||
use agent_core::state::AppState;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use llm_core::LlmProvider;
|
||||
use ui::ProviderManager;
|
||||
use tools_plan::PlanManager;
|
||||
use crate::agent_manager::AgentManager;
|
||||
|
||||
/// The main background task that handles logic, API calls, and state updates.
|
||||
/// Uses a shared ProviderManager for dynamic provider/model switching.
|
||||
pub async fn run_engine_loop_dynamic(
|
||||
mut rx: mpsc::Receiver<Message>,
|
||||
tx_ui: mpsc::Sender<Message>,
|
||||
provider_manager: Arc<Mutex<ProviderManager>>,
|
||||
state: Arc<Mutex<AppState>>,
|
||||
) {
|
||||
let agent_manager = Arc::new(
|
||||
AgentManager::with_provider_manager(provider_manager, state.clone())
|
||||
.with_ui_sender(tx_ui.clone())
|
||||
);
|
||||
|
||||
// Plan manager for persistence
|
||||
let plan_manager = PlanManager::new(PathBuf::from("."));
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
Message::UserAction(UserAction::Input(text)) => {
|
||||
let agent_manager_clone = agent_manager.clone();
|
||||
let tx_clone = tx_ui.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = agent_manager_clone.run(&text).await {
|
||||
let _ = tx_clone.send(Message::AgentResponse(AgentResponse::Error(e.to_string()))).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
Message::UserAction(UserAction::PermissionResult(res)) => {
|
||||
let mut guard = state.lock().await;
|
||||
guard.set_permission_result(res);
|
||||
}
|
||||
Message::UserAction(UserAction::FinalizePlan) => {
|
||||
let mut guard = state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.finalize();
|
||||
let total_steps = plan.steps.len();
|
||||
let status = plan.status.clone();
|
||||
drop(guard);
|
||||
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanComplete {
|
||||
total_steps,
|
||||
status,
|
||||
})).await;
|
||||
}
|
||||
}
|
||||
Message::UserAction(UserAction::PlanApproval(approval)) => {
|
||||
let mut guard = state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
// Apply approval decisions
|
||||
approval.apply_to(plan);
|
||||
plan.start_execution();
|
||||
|
||||
// Get approved steps for execution
|
||||
let approved_steps: Vec<_> = plan.steps.iter()
|
||||
.filter(|s| s.is_approved())
|
||||
.cloned()
|
||||
.collect();
|
||||
let total = approved_steps.len();
|
||||
let skipped = plan.steps.iter().filter(|s| s.is_rejected()).count();
|
||||
|
||||
drop(guard);
|
||||
|
||||
// Execute approved steps
|
||||
for (idx, step) in approved_steps.iter().enumerate() {
|
||||
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanExecuting {
|
||||
step_id: step.id.clone(),
|
||||
step_index: idx,
|
||||
total_steps: total,
|
||||
})).await;
|
||||
|
||||
// Execute the tool
|
||||
let agent_manager_clone = agent_manager.clone();
|
||||
let tx_clone = tx_ui.clone();
|
||||
let step_clone = step.clone();
|
||||
|
||||
// Execute tool and send result
|
||||
if let Err(e) = agent_manager_clone.execute_single_tool(&step_clone.tool, &step_clone.args).await {
|
||||
let _ = tx_clone.send(Message::AgentResponse(AgentResponse::Error(
|
||||
format!("Step {} failed: {}", idx + 1, e)
|
||||
))).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark plan as completed
|
||||
let mut guard = state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.complete();
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::PlanExecutionComplete {
|
||||
executed: total,
|
||||
skipped,
|
||||
})).await;
|
||||
}
|
||||
}
|
||||
Message::UserAction(UserAction::SavePlan(name)) => {
|
||||
let guard = state.lock().await;
|
||||
if let Some(plan) = guard.current_plan() {
|
||||
let mut plan_to_save = plan.clone();
|
||||
plan_to_save.name = Some(name.clone());
|
||||
drop(guard);
|
||||
|
||||
match plan_manager.save_accumulated_plan(&plan_to_save).await {
|
||||
Ok(path) => {
|
||||
let _ = tx_ui.send(Message::System(SystemNotification::PlanSaved {
|
||||
id: plan_to_save.id.clone(),
|
||||
path: path.display().to_string(),
|
||||
})).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::Error(
|
||||
format!("Failed to save plan: {}", e)
|
||||
))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::UserAction(UserAction::LoadPlan(id)) => {
|
||||
match plan_manager.load_accumulated_plan(&id).await {
|
||||
Ok(plan) => {
|
||||
let name = plan.name.clone();
|
||||
let steps = plan.steps.len();
|
||||
let mut guard = state.lock().await;
|
||||
guard.accumulated_plan = Some(plan);
|
||||
drop(guard);
|
||||
|
||||
let _ = tx_ui.send(Message::System(SystemNotification::PlanLoaded {
|
||||
id,
|
||||
name,
|
||||
steps,
|
||||
})).await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx_ui.send(Message::AgentResponse(AgentResponse::Error(
|
||||
format!("Failed to load plan: {}", e)
|
||||
))).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::UserAction(UserAction::CancelPlan) => {
|
||||
let mut guard = state.lock().await;
|
||||
if let Some(plan) = guard.current_plan_mut() {
|
||||
plan.cancel();
|
||||
}
|
||||
guard.clear_plan();
|
||||
}
|
||||
Message::UserAction(UserAction::Exit) => {
|
||||
let mut guard = state.lock().await;
|
||||
guard.running = false;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy engine loop with fixed client (for backward compatibility)
|
||||
pub async fn run_engine_loop(
|
||||
mut rx: mpsc::Receiver<Message>,
|
||||
tx_ui: mpsc::Sender<Message>,
|
||||
client: Arc<dyn LlmProvider>,
|
||||
state: Arc<Mutex<AppState>>,
|
||||
) {
|
||||
let agent_manager = Arc::new(AgentManager::new(client, state.clone()).with_ui_sender(tx_ui.clone()));
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
Message::UserAction(UserAction::Input(text)) => {
|
||||
let agent_manager_clone = agent_manager.clone();
|
||||
let tx_clone = tx_ui.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = agent_manager_clone.run(&text).await {
|
||||
let _ = tx_clone.send(Message::AgentResponse(AgentResponse::Error(e.to_string()))).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
Message::UserAction(UserAction::PermissionResult(res)) => {
|
||||
let mut guard = state.lock().await;
|
||||
guard.set_permission_result(res);
|
||||
}
|
||||
Message::UserAction(UserAction::Exit) => {
|
||||
let mut guard = state.lock().await;
|
||||
guard.running = false;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_core::{LlmError, Tool, ChunkStream, StreamChunk, ChatMessage, ChatOptions};
|
||||
use async_trait::async_trait;
|
||||
use futures::stream;
|
||||
|
||||
struct MockProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockProvider {
|
||||
fn name(&self) -> &str { "mock" }
|
||||
fn model(&self) -> &str { "mock-model" }
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
_messages: &[ChatMessage],
|
||||
_options: &ChatOptions,
|
||||
_tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
let chunks = vec![
|
||||
Ok(StreamChunk { content: Some("Hello".to_string()), tool_calls: None, done: false, usage: None }),
|
||||
Ok(StreamChunk { content: Some(" World".to_string()), tool_calls: None, done: true, usage: None }),
|
||||
];
|
||||
Ok(Box::pin(stream::iter(chunks)))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_engine_streaming() {
|
||||
let (tx_in, rx_in) = mpsc::channel(1);
|
||||
let (tx_out, mut rx_out) = mpsc::channel(10);
|
||||
|
||||
let client = Arc::new(MockProvider);
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
|
||||
// Spawn the engine loop
|
||||
tokio::spawn(async move {
|
||||
run_engine_loop(rx_in, tx_out, client, state).await;
|
||||
});
|
||||
|
||||
// Send a message
|
||||
tx_in.send(Message::UserAction(UserAction::Input("Hi".to_string()))).await.unwrap();
|
||||
|
||||
// Verify streaming responses
|
||||
let mut tokens = Vec::new();
|
||||
while let Some(msg) = rx_out.recv().await {
|
||||
match msg {
|
||||
Message::AgentResponse(AgentResponse::Token(s)) => tokens.push(s),
|
||||
Message::AgentResponse(AgentResponse::Complete) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(tokens, vec!["Hello", " World"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_engine_permission_result() {
|
||||
let (tx_in, rx_in) = mpsc::channel(1);
|
||||
let (tx_out, _rx_out) = mpsc::channel(10);
|
||||
|
||||
let client = Arc::new(MockProvider);
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
let state_clone = state.clone();
|
||||
|
||||
// Spawn the engine loop
|
||||
tokio::spawn(async move {
|
||||
run_engine_loop(rx_in, tx_out, client, state_clone).await;
|
||||
});
|
||||
|
||||
// Send a PermissionResult
|
||||
tx_in.send(Message::UserAction(UserAction::PermissionResult(true))).await.unwrap();
|
||||
|
||||
// Give it a moment to process
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||
|
||||
let guard = state.lock().await;
|
||||
assert_eq!(guard.last_permission_result, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_engine_plan_mode() {
|
||||
use agent_core::state::AppMode;
|
||||
|
||||
let (tx_in, rx_in) = mpsc::channel(1);
|
||||
let (tx_out, mut rx_out) = mpsc::channel(10);
|
||||
|
||||
let client = Arc::new(MockProvider);
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
|
||||
// Set Plan mode
|
||||
{
|
||||
let mut guard = state.lock().await;
|
||||
guard.mode = AppMode::Plan;
|
||||
}
|
||||
|
||||
let state_clone = state.clone();
|
||||
|
||||
// Spawn the engine loop
|
||||
tokio::spawn(async move {
|
||||
run_engine_loop(rx_in, tx_out, client, state_clone).await;
|
||||
});
|
||||
|
||||
// Send a message
|
||||
tx_in.send(Message::UserAction(UserAction::Input("Hi".to_string()))).await.unwrap();
|
||||
|
||||
// Verify we get responses (tokens and complete)
|
||||
let mut received_tokens = false;
|
||||
let mut received_complete = false;
|
||||
let timeout = tokio::time::timeout(tokio::time::Duration::from_secs(2), async {
|
||||
while let Some(msg) = rx_out.recv().await {
|
||||
match msg {
|
||||
Message::AgentResponse(AgentResponse::Token(_)) => received_tokens = true,
|
||||
Message::AgentResponse(AgentResponse::Complete) => {
|
||||
received_complete = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}).await;
|
||||
|
||||
assert!(timeout.is_ok(), "Should receive responses within timeout");
|
||||
assert!(received_tokens, "Should receive tokens");
|
||||
assert!(received_complete, "Should receive complete signal");
|
||||
}
|
||||
}
|
||||
1160
crates/app/cli/src/main.rs
Normal file
1160
crates/app/cli/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
56
crates/app/cli/src/tool_registry.rs
Normal file
56
crates/app/cli/src/tool_registry.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use std::collections::HashMap;
|
||||
use serde_json::Value;
|
||||
use llm_core::Tool;
|
||||
|
||||
/// Registry for tools available to the agent
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<String, Tool>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a tool
|
||||
pub fn register(&mut self, tool: Tool) {
|
||||
self.tools.insert(tool.function.name.clone(), tool);
|
||||
}
|
||||
|
||||
/// Get a tool by name
|
||||
pub fn get(&self, name: &str) -> Option<&Tool> {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
/// Get all registered tools as a list (for LLM context)
|
||||
pub fn list_tools(&self) -> Vec<Tool> {
|
||||
self.tools.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_core::{ToolParameters, ToolFunction};
|
||||
|
||||
fn create_mock_tool(name: &str) -> Tool {
|
||||
Tool::function(
|
||||
name,
|
||||
"Description",
|
||||
ToolParameters::object(serde_json::json!({}), vec![])
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
let tool = create_mock_tool("test_tool");
|
||||
|
||||
registry.register(tool.clone());
|
||||
|
||||
assert!(registry.get("test_tool").is_some());
|
||||
assert_eq!(registry.list_tools().len(), 1);
|
||||
}
|
||||
}
|
||||
34
crates/app/cli/tests/chat_stream.rs
Normal file
34
crates/app/cli/tests/chat_stream.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use assert_cmd::Command;
|
||||
use httpmock::prelude::*;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_streams_ndjson() {
|
||||
let server = MockServer::start_async().await;
|
||||
|
||||
let response = concat!(
|
||||
r#"{"message":{"role":"assistant","content":"Hel"}}"#,"\n",
|
||||
r#"{"message":{"role":"assistant","content":"lo"}}"#,"\n",
|
||||
r#"{"done":true}"#,"\n",
|
||||
);
|
||||
|
||||
// The CLI includes tools in the request, so we need to match any request to /api/chat
|
||||
// instead of matching exact body (which includes tool definitions)
|
||||
let _m = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api/chat");
|
||||
then.status(200)
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.body(response);
|
||||
});
|
||||
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--ollama-url").arg(server.base_url())
|
||||
.arg("--model").arg("qwen2.5")
|
||||
.arg("--print")
|
||||
.arg("hello");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicates::str::contains("Hello").count(1).or(predicates::str::contains("Hel").and(predicates::str::contains("lo"))));
|
||||
}
|
||||
145
crates/app/cli/tests/headless.rs
Normal file
145
crates/app/cli/tests/headless.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use assert_cmd::Command;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn print_json_has_session_id_and_stats() {
|
||||
let mut cmd = Command::cargo_bin("owlen").unwrap();
|
||||
cmd.arg("--output-format")
|
||||
.arg("json")
|
||||
.arg("Say hello");
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
|
||||
|
||||
// Parse JSON output
|
||||
let json: Value = serde_json::from_str(&stdout).expect("Output should be valid JSON");
|
||||
|
||||
// Verify session_id exists
|
||||
assert!(json.get("session_id").is_some(), "JSON output should have session_id");
|
||||
let session_id = json["session_id"].as_str().unwrap();
|
||||
assert!(!session_id.is_empty(), "session_id should not be empty");
|
||||
|
||||
// Verify stats exist
|
||||
assert!(json.get("stats").is_some(), "JSON output should have stats");
|
||||
let stats = &json["stats"];
|
||||
|
||||
// Check for token counts
|
||||
assert!(stats.get("total_tokens").is_some(), "stats should have total_tokens");
|
||||
|
||||
// Check for messages
|
||||
assert!(json.get("messages").is_some(), "JSON output should have messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_sequence_is_well_formed() {
|
||||
let mut cmd = Command::cargo_bin("owlen").unwrap();
|
||||
cmd.arg("--output-format")
|
||||
.arg("stream-json")
|
||||
.arg("Say hello");
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
|
||||
|
||||
// Stream-JSON is NDJSON - each line should be valid JSON
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||
|
||||
assert!(!lines.is_empty(), "Stream-JSON should produce at least one event");
|
||||
|
||||
// Each line should be valid JSON
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let json: Value = serde_json::from_str(line)
|
||||
.expect(&format!("Line {} should be valid JSON: {}", i, line));
|
||||
|
||||
// Each event should have a type
|
||||
assert!(json.get("type").is_some(), "Event should have a type field");
|
||||
}
|
||||
|
||||
// First event should be session_start
|
||||
let first: Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(first["type"].as_str().unwrap(), "session_start");
|
||||
assert!(first.get("session_id").is_some());
|
||||
|
||||
// Last event should be session_end or complete
|
||||
let last: Value = serde_json::from_str(lines[lines.len() - 1]).unwrap();
|
||||
let last_type = last["type"].as_str().unwrap();
|
||||
assert!(
|
||||
last_type == "session_end" || last_type == "complete",
|
||||
"Last event should be session_end or complete, got: {}",
|
||||
last_type
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_format_is_default() {
|
||||
let mut cmd = Command::cargo_bin("owlen").unwrap();
|
||||
cmd.arg("Say hello");
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
|
||||
|
||||
// Text format should not be JSON
|
||||
assert!(serde_json::from_str::<Value>(&stdout).is_err(),
|
||||
"Default output should be text, not JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_format_with_tool_execution() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "hello world").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("owlen").unwrap();
|
||||
cmd.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("--output-format")
|
||||
.arg("json")
|
||||
.arg("read")
|
||||
.arg(file.to_str().unwrap());
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
|
||||
|
||||
let json: Value = serde_json::from_str(&stdout).expect("Output should be valid JSON");
|
||||
|
||||
// Should have result
|
||||
assert!(json.get("result").is_some());
|
||||
|
||||
// Should have tool info
|
||||
assert!(json.get("tool").is_some());
|
||||
assert_eq!(json["tool"].as_str().unwrap(), "Read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_includes_chunk_events() {
|
||||
let mut cmd = Command::cargo_bin("owlen").unwrap();
|
||||
cmd.arg("--output-format")
|
||||
.arg("stream-json")
|
||||
.arg("Say hello");
|
||||
|
||||
let output = cmd.assert().success();
|
||||
let stdout = String::from_utf8_lossy(&output.get_output().stdout);
|
||||
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||
|
||||
// Should have chunk events between session_start and session_end
|
||||
let chunk_events: Vec<&str> = lines.iter()
|
||||
.filter(|line| {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(line) {
|
||||
json["type"].as_str() == Some("chunk")
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
assert!(!chunk_events.is_empty(), "Should have at least one chunk event");
|
||||
|
||||
// Each chunk should have content
|
||||
for chunk_line in chunk_events {
|
||||
let chunk: Value = serde_json::from_str(chunk_line).unwrap();
|
||||
assert!(chunk.get("content").is_some(), "Chunk should have content");
|
||||
}
|
||||
}
|
||||
255
crates/app/cli/tests/permissions.rs
Normal file
255
crates/app/cli/tests/permissions.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use assert_cmd::Command;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn plan_mode_allows_read_operations() {
|
||||
// Create a temp file to read
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "hello world").unwrap();
|
||||
|
||||
// Read operation should work in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("read").arg(file.to_str().unwrap());
|
||||
cmd.assert().success().stdout("hello world\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_allows_glob_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("a.txt"), "test").unwrap();
|
||||
fs::write(dir.path().join("b.txt"), "test").unwrap();
|
||||
|
||||
let pattern = format!("{}/*.txt", dir.path().display());
|
||||
|
||||
// Glob operation should work in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("glob").arg(&pattern);
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_allows_grep_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("test.txt"), "hello world\nfoo bar").unwrap();
|
||||
|
||||
// Grep operation should work in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("grep").arg(dir.path().to_str().unwrap()).arg("hello");
|
||||
cmd.assert().success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_override_via_cli_flag() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "content").unwrap();
|
||||
|
||||
// Test with --mode code (should also allow read)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("read")
|
||||
.arg(file.to_str().unwrap());
|
||||
cmd.assert().success().stdout("content\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_blocks_write_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("new.txt");
|
||||
|
||||
// Write operation should be blocked in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("write").arg(file.to_str().unwrap()).arg("content");
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_blocks_edit_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "old content").unwrap();
|
||||
|
||||
// Edit operation should be blocked in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("edit")
|
||||
.arg(file.to_str().unwrap())
|
||||
.arg("old")
|
||||
.arg("new");
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_edits_mode_allows_write() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("new.txt");
|
||||
|
||||
// Write operation should work in acceptEdits mode
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("acceptEdits")
|
||||
.arg("write")
|
||||
.arg(file.to_str().unwrap())
|
||||
.arg("new content");
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify file was written
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "new content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_edits_mode_allows_edit() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "line 1\nline 2\nline 3").unwrap();
|
||||
|
||||
// Edit operation should work in acceptEdits mode
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("acceptEdits")
|
||||
.arg("edit")
|
||||
.arg(file.to_str().unwrap())
|
||||
.arg("line 2")
|
||||
.arg("modified line");
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify file was edited
|
||||
assert_eq!(
|
||||
fs::read_to_string(&file).unwrap(),
|
||||
"line 1\nmodified line\nline 3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_allows_all_operations() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
|
||||
// Write in code mode
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("write")
|
||||
.arg(file.to_str().unwrap())
|
||||
.arg("initial content");
|
||||
cmd.assert().success();
|
||||
|
||||
// Edit in code mode
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("edit")
|
||||
.arg(file.to_str().unwrap())
|
||||
.arg("initial")
|
||||
.arg("modified");
|
||||
cmd.assert().success();
|
||||
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "modified content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_mode_blocks_bash_operations() {
|
||||
// Bash operation should be blocked in plan mode (default)
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("bash").arg("echo hello");
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_allows_bash() {
|
||||
// Bash operation should work in code mode
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode").arg("code").arg("bash").arg("echo hello");
|
||||
cmd.assert().success().stdout("hello\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_command_timeout_works() {
|
||||
// Test that timeout works
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("bash")
|
||||
.arg("sleep 10")
|
||||
.arg("--timeout")
|
||||
.arg("1000");
|
||||
cmd.assert().failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_works() {
|
||||
// Create .owlen/commands directory in temp dir
|
||||
let dir = tempdir().unwrap();
|
||||
let commands_dir = dir.path().join(".owlen/commands");
|
||||
fs::create_dir_all(&commands_dir).unwrap();
|
||||
|
||||
// Create a test slash command
|
||||
let command_content = r#"---
|
||||
description: "Test command"
|
||||
---
|
||||
Hello from slash command!
|
||||
Args: $ARGUMENTS
|
||||
First: $1
|
||||
"#;
|
||||
let command_file = commands_dir.join("test.md");
|
||||
fs::write(&command_file, command_content).unwrap();
|
||||
|
||||
// Execute slash command with args from the temp directory
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.current_dir(dir.path())
|
||||
.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("slash")
|
||||
.arg("test")
|
||||
.arg("arg1");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicates::str::contains("Hello from slash command!"))
|
||||
.stdout(predicates::str::contains("Args: arg1"))
|
||||
.stdout(predicates::str::contains("First: arg1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_file_refs() {
|
||||
let dir = tempdir().unwrap();
|
||||
let commands_dir = dir.path().join(".owlen/commands");
|
||||
fs::create_dir_all(&commands_dir).unwrap();
|
||||
|
||||
// Create a file to reference
|
||||
let data_file = dir.path().join("data.txt");
|
||||
fs::write(&data_file, "Referenced content").unwrap();
|
||||
|
||||
// Create slash command with file reference
|
||||
let command_content = format!("File content: @{}", data_file.display());
|
||||
fs::write(commands_dir.join("reftest.md"), command_content).unwrap();
|
||||
|
||||
// Execute slash command
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.current_dir(dir.path())
|
||||
.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("slash")
|
||||
.arg("reftest");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicates::str::contains("Referenced content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_not_found() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
// Try to execute non-existent slash command
|
||||
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
|
||||
cmd.current_dir(dir.path())
|
||||
.arg("--mode")
|
||||
.arg("code")
|
||||
.arg("slash")
|
||||
.arg("nonexistent");
|
||||
|
||||
cmd.assert().failure();
|
||||
}
|
||||
31
crates/app/ui/Cargo.toml
Normal file
31
crates/app/ui/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "ui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
color-eyre = "0.6"
|
||||
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||
ratatui = "0.28"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
unicode-width = "0.2"
|
||||
textwrap = "0.16"
|
||||
syntect = { version = "5.0", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] }
|
||||
pulldown-cmark = "0.11"
|
||||
|
||||
# Internal dependencies
|
||||
agent-core = { path = "../../core/agent" }
|
||||
auth-manager = { path = "../../platform/auth" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
llm-core = { path = "../../llm/core" }
|
||||
llm-anthropic = { path = "../../llm/anthropic" }
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
llm-openai = { path = "../../llm/openai" }
|
||||
config-agent = { path = "../../platform/config" }
|
||||
tools-todo = { path = "../../tools/todo" }
|
||||
tools-plan = { path = "../../tools/plan" }
|
||||
18
crates/app/ui/README.md
Normal file
18
crates/app/ui/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Owlen UI
|
||||
|
||||
A Terminal User Interface (TUI) for the Owlen AI agent, built with Ratatui.
|
||||
|
||||
## Features
|
||||
- **Rich Text Rendering:** Markdown support with syntax highlighting for code blocks.
|
||||
- **Interactive Components:** Intuitive panels for chat, tool execution, and session status.
|
||||
- **Real-time Streaming:** Smooth display of agent output as it's generated.
|
||||
- **Task Visualization:** Dedicated view for tracking the agent's progress through a task list.
|
||||
|
||||
## Architecture
|
||||
The UI is built using an event-driven architecture integrated with the `agent-core` event stream. It leverages `ratatui` for terminal rendering and `crossterm` for event handling.
|
||||
|
||||
## Components
|
||||
- `ChatPanel`: Displays the conversation history.
|
||||
- `TaskPanel`: Shows the current implementation plan and task status.
|
||||
- `ToolPanel`: Visualizes active tool executions and their output.
|
||||
- `ModelPicker`: Allows selecting between available LLM providers and models.
|
||||
1927
crates/app/ui/src/app.rs
Normal file
1927
crates/app/ui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
226
crates/app/ui/src/completions.rs
Normal file
226
crates/app/ui/src/completions.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Command completion engine for the TUI
|
||||
//!
|
||||
//! Provides Tab-completion for slash commands, file paths, and tool names.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// A single completion suggestion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Completion {
|
||||
/// The text to insert
|
||||
pub text: String,
|
||||
/// Description of what this completion does
|
||||
pub description: String,
|
||||
/// Source of the completion (e.g., "builtin", "plugin:name")
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Information about a command for completion purposes
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandInfo {
|
||||
/// Command name (without leading /)
|
||||
pub name: String,
|
||||
/// Command description
|
||||
pub description: String,
|
||||
/// Source of the command
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
pub fn new(name: &str, description: &str, source: &str) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
source: source.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Completion engine for the TUI
|
||||
pub struct CompletionEngine {
|
||||
/// Available commands
|
||||
commands: Vec<CommandInfo>,
|
||||
}
|
||||
|
||||
impl Default for CompletionEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
commands: Self::builtin_commands(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get built-in commands
|
||||
fn builtin_commands() -> Vec<CommandInfo> {
|
||||
vec![
|
||||
CommandInfo::new("help", "Show available commands and help", "builtin"),
|
||||
CommandInfo::new("clear", "Clear the screen", "builtin"),
|
||||
CommandInfo::new("mcp", "List MCP servers and their tools", "builtin"),
|
||||
CommandInfo::new("hooks", "Show loaded hooks", "builtin"),
|
||||
CommandInfo::new("compact", "Compact conversation context", "builtin"),
|
||||
CommandInfo::new("mode", "Switch permission mode (plan/edit/code)", "builtin"),
|
||||
CommandInfo::new("provider", "Switch LLM provider", "builtin"),
|
||||
CommandInfo::new("model", "Switch LLM model", "builtin"),
|
||||
CommandInfo::new("checkpoint", "Create a checkpoint", "builtin"),
|
||||
CommandInfo::new("rewind", "Rewind to a checkpoint", "builtin"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Add commands from plugins
|
||||
pub fn add_plugin_commands(&mut self, plugin_name: &str, commands: Vec<CommandInfo>) {
|
||||
for mut cmd in commands {
|
||||
cmd.source = format!("plugin:{}", plugin_name);
|
||||
self.commands.push(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a single command
|
||||
pub fn add_command(&mut self, command: CommandInfo) {
|
||||
self.commands.push(command);
|
||||
}
|
||||
|
||||
/// Get completions for the given input
|
||||
pub fn complete(&self, input: &str) -> Vec<Completion> {
|
||||
if input.starts_with('/') {
|
||||
self.complete_command(&input[1..])
|
||||
} else if input.starts_with('@') {
|
||||
self.complete_file_path(&input[1..])
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete a slash command
|
||||
fn complete_command(&self, partial: &str) -> Vec<Completion> {
|
||||
let partial_lower = partial.to_lowercase();
|
||||
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|cmd| {
|
||||
// Match if name starts with partial, or contains partial (fuzzy)
|
||||
cmd.name.to_lowercase().starts_with(&partial_lower)
|
||||
|| (partial.len() >= 2 && cmd.name.to_lowercase().contains(&partial_lower))
|
||||
})
|
||||
.map(|cmd| Completion {
|
||||
text: format!("/{}", cmd.name),
|
||||
description: cmd.description.clone(),
|
||||
source: cmd.source.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Complete a file path
|
||||
fn complete_file_path(&self, partial: &str) -> Vec<Completion> {
|
||||
let path = Path::new(partial);
|
||||
|
||||
// Get the directory to search and the prefix to match
|
||||
let (dir, prefix) = if partial.ends_with('/') || partial.is_empty() {
|
||||
(partial, "")
|
||||
} else {
|
||||
let parent = path.parent().map(|p| p.to_str().unwrap_or("")).unwrap_or("");
|
||||
let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||||
(parent, file_name)
|
||||
};
|
||||
|
||||
// Search directory
|
||||
let search_dir = if dir.is_empty() { "." } else { dir };
|
||||
|
||||
match std::fs::read_dir(search_dir) {
|
||||
Ok(entries) => {
|
||||
entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
// Skip hidden files unless user started typing with .
|
||||
if !prefix.starts_with('.') && name_str.starts_with('.') {
|
||||
return false;
|
||||
}
|
||||
name_str.to_lowercase().starts_with(&prefix.to_lowercase())
|
||||
})
|
||||
.map(|entry| {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
|
||||
let full_path = if dir.is_empty() {
|
||||
name_str.to_string()
|
||||
} else if dir.ends_with('/') {
|
||||
format!("{}{}", dir, name_str)
|
||||
} else {
|
||||
format!("{}/{}", dir, name_str)
|
||||
};
|
||||
|
||||
Completion {
|
||||
text: format!("@{}{}", full_path, if is_dir { "/" } else { "" }),
|
||||
description: if is_dir { "Directory".to_string() } else { "File".to_string() },
|
||||
source: "filesystem".to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
Err(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all commands (for /help display)
|
||||
pub fn all_commands(&self) -> &[CommandInfo] {
|
||||
&self.commands
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_command_completion_exact() {
|
||||
let engine = CompletionEngine::new();
|
||||
let completions = engine.complete("/help");
|
||||
assert!(!completions.is_empty());
|
||||
assert!(completions.iter().any(|c| c.text == "/help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_completion_partial() {
|
||||
let engine = CompletionEngine::new();
|
||||
let completions = engine.complete("/hel");
|
||||
assert!(!completions.is_empty());
|
||||
assert!(completions.iter().any(|c| c.text == "/help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_completion_fuzzy() {
|
||||
let engine = CompletionEngine::new();
|
||||
// "cle" should match "clear"
|
||||
let completions = engine.complete("/cle");
|
||||
assert!(!completions.is_empty());
|
||||
assert!(completions.iter().any(|c| c.text == "/clear"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_info() {
|
||||
let info = CommandInfo::new("test", "A test command", "builtin");
|
||||
assert_eq!(info.name, "test");
|
||||
assert_eq!(info.description, "A test command");
|
||||
assert_eq!(info.source, "builtin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_plugin_commands() {
|
||||
let mut engine = CompletionEngine::new();
|
||||
let plugin_cmds = vec![
|
||||
CommandInfo::new("custom", "A custom command", ""),
|
||||
];
|
||||
engine.add_plugin_commands("my-plugin", plugin_cmds);
|
||||
|
||||
let completions = engine.complete("/custom");
|
||||
assert!(!completions.is_empty());
|
||||
assert!(completions.iter().any(|c| c.source == "plugin:my-plugin"));
|
||||
}
|
||||
}
|
||||
377
crates/app/ui/src/components/autocomplete.rs
Normal file
377
crates/app/ui/src/components/autocomplete.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
//! Command autocomplete dropdown component
|
||||
//!
|
||||
//! Displays inline autocomplete suggestions when user types `/`.
|
||||
//! Supports fuzzy filtering as user types.
|
||||
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// An autocomplete option
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocompleteOption {
|
||||
/// The trigger text (command name without /)
|
||||
pub trigger: String,
|
||||
/// Display text (e.g., "/model [name]")
|
||||
pub display: String,
|
||||
/// Short description
|
||||
pub description: String,
|
||||
/// Has submenu/subcommands
|
||||
pub has_submenu: bool,
|
||||
}
|
||||
|
||||
impl AutocompleteOption {
|
||||
pub fn new(trigger: &str, description: &str) -> Self {
|
||||
Self {
|
||||
trigger: trigger.to_string(),
|
||||
display: format!("/{}", trigger),
|
||||
description: description.to_string(),
|
||||
has_submenu: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_args(trigger: &str, args: &str, description: &str) -> Self {
|
||||
Self {
|
||||
trigger: trigger.to_string(),
|
||||
display: format!("/{} {}", trigger, args),
|
||||
description: description.to_string(),
|
||||
has_submenu: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_submenu(trigger: &str, description: &str) -> Self {
|
||||
Self {
|
||||
trigger: trigger.to_string(),
|
||||
display: format!("/{}", trigger),
|
||||
description: description.to_string(),
|
||||
has_submenu: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default command options
|
||||
fn default_options() -> Vec<AutocompleteOption> {
|
||||
vec![
|
||||
AutocompleteOption::new("help", "Show help"),
|
||||
AutocompleteOption::new("status", "Session info"),
|
||||
AutocompleteOption::with_args("model", "[name]", "Switch model"),
|
||||
AutocompleteOption::with_args("provider", "[name]", "Switch provider"),
|
||||
AutocompleteOption::new("history", "View history"),
|
||||
AutocompleteOption::new("checkpoint", "Save state"),
|
||||
AutocompleteOption::new("checkpoints", "List checkpoints"),
|
||||
AutocompleteOption::with_args("rewind", "[id]", "Restore"),
|
||||
AutocompleteOption::new("cost", "Token usage"),
|
||||
AutocompleteOption::new("clear", "Clear chat"),
|
||||
AutocompleteOption::new("compact", "Compact context"),
|
||||
AutocompleteOption::new("permissions", "Permission mode"),
|
||||
AutocompleteOption::new("themes", "List themes"),
|
||||
AutocompleteOption::with_args("theme", "[name]", "Switch theme"),
|
||||
AutocompleteOption::new("exit", "Exit"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Autocomplete dropdown component
|
||||
pub struct Autocomplete {
|
||||
options: Vec<AutocompleteOption>,
|
||||
filtered: Vec<usize>, // indices into options
|
||||
selected: usize,
|
||||
visible: bool,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl Autocomplete {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
let options = default_options();
|
||||
let filtered: Vec<usize> = (0..options.len()).collect();
|
||||
|
||||
Self {
|
||||
options,
|
||||
filtered,
|
||||
selected: 0,
|
||||
visible: false,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show autocomplete and reset filter
|
||||
pub fn show(&mut self) {
|
||||
self.visible = true;
|
||||
self.filtered = (0..self.options.len()).collect();
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
/// Hide autocomplete
|
||||
pub fn hide(&mut self) {
|
||||
self.visible = false;
|
||||
}
|
||||
|
||||
/// Check if visible
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
/// Update filter based on current input (text after /)
|
||||
pub fn update_filter(&mut self, query: &str) {
|
||||
if query.is_empty() {
|
||||
self.filtered = (0..self.options.len()).collect();
|
||||
} else {
|
||||
let query_lower = query.to_lowercase();
|
||||
self.filtered = self.options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, opt)| {
|
||||
// Fuzzy match: check if query chars appear in order
|
||||
fuzzy_match(&opt.trigger.to_lowercase(), &query_lower)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Reset selection if it's out of bounds
|
||||
if self.selected >= self.filtered.len() {
|
||||
self.selected = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Select next option
|
||||
pub fn select_next(&mut self) {
|
||||
if !self.filtered.is_empty() {
|
||||
self.selected = (self.selected + 1) % self.filtered.len();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select previous option
|
||||
pub fn select_prev(&mut self) {
|
||||
if !self.filtered.is_empty() {
|
||||
self.selected = if self.selected == 0 {
|
||||
self.filtered.len() - 1
|
||||
} else {
|
||||
self.selected - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently selected option's trigger
|
||||
pub fn confirm(&self) -> Option<String> {
|
||||
if self.filtered.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let idx = self.filtered[self.selected];
|
||||
Some(format!("/{}", self.options[idx].trigger))
|
||||
}
|
||||
|
||||
/// Handle key input, returns Some(command) if confirmed
|
||||
///
|
||||
/// Key behavior:
|
||||
/// - Tab: Confirm selection and insert into input
|
||||
/// - Down/Up: Navigate options
|
||||
/// - Enter: Pass through to submit (NotHandled)
|
||||
/// - Esc: Cancel autocomplete
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> AutocompleteResult {
|
||||
if !self.visible {
|
||||
return AutocompleteResult::NotHandled;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Tab => {
|
||||
// Tab confirms and inserts the selected command
|
||||
if let Some(cmd) = self.confirm() {
|
||||
self.hide();
|
||||
AutocompleteResult::Confirmed(cmd)
|
||||
} else {
|
||||
AutocompleteResult::Handled
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.select_next();
|
||||
AutocompleteResult::Handled
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Up => {
|
||||
self.select_prev();
|
||||
AutocompleteResult::Handled
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Enter should submit the message, not confirm autocomplete
|
||||
// Hide autocomplete and let Enter pass through
|
||||
self.hide();
|
||||
AutocompleteResult::NotHandled
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.hide();
|
||||
AutocompleteResult::Cancelled
|
||||
}
|
||||
_ => AutocompleteResult::NotHandled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Add custom options (from plugins)
|
||||
pub fn add_options(&mut self, options: Vec<AutocompleteOption>) {
|
||||
self.options.extend(options);
|
||||
// Re-filter with all options
|
||||
self.filtered = (0..self.options.len()).collect();
|
||||
}
|
||||
|
||||
/// Render the autocomplete dropdown above the input line
|
||||
pub fn render(&self, frame: &mut Frame, input_area: Rect) {
|
||||
if !self.visible || self.filtered.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate dropdown dimensions
|
||||
let max_visible = 8.min(self.filtered.len());
|
||||
let width = 40.min(input_area.width.saturating_sub(4));
|
||||
let height = (max_visible + 2) as u16; // +2 for borders
|
||||
|
||||
// Position above input, left-aligned with some padding
|
||||
let x = input_area.x + 2;
|
||||
let y = input_area.y.saturating_sub(height);
|
||||
|
||||
let dropdown_area = Rect::new(x, y, width, height);
|
||||
|
||||
// Clear area behind dropdown
|
||||
frame.render_widget(Clear, dropdown_area);
|
||||
|
||||
// Build option lines
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
for (display_idx, &opt_idx) in self.filtered.iter().take(max_visible).enumerate() {
|
||||
let opt = &self.options[opt_idx];
|
||||
let is_selected = display_idx == self.selected;
|
||||
|
||||
let style = if is_selected {
|
||||
self.theme.selected
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(" ", style),
|
||||
Span::styled("/", if is_selected { style } else { self.theme.cmd_slash }),
|
||||
Span::styled(&opt.trigger, if is_selected { style } else { self.theme.cmd_name }),
|
||||
];
|
||||
|
||||
// Submenu indicator
|
||||
if opt.has_submenu {
|
||||
spans.push(Span::styled(" >", if is_selected { style } else { self.theme.cmd_desc }));
|
||||
}
|
||||
|
||||
// Pad to fixed width for consistent selection highlighting
|
||||
let current_len: usize = spans.iter().map(|s| s.content.len()).sum();
|
||||
let padding = (width as usize).saturating_sub(current_len + 1);
|
||||
spans.push(Span::styled(" ".repeat(padding), style));
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
// Show overflow indicator if needed
|
||||
if self.filtered.len() > max_visible {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ... +{} more", self.filtered.len() - max_visible),
|
||||
self.theme.cmd_desc,
|
||||
)));
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.palette.border))
|
||||
.style(self.theme.overlay_bg);
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
|
||||
frame.render_widget(paragraph, dropdown_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of handling autocomplete key
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AutocompleteResult {
|
||||
/// Key was not handled by autocomplete
|
||||
NotHandled,
|
||||
/// Key was handled, no action needed
|
||||
Handled,
|
||||
/// User confirmed selection, returns command string
|
||||
Confirmed(String),
|
||||
/// User cancelled autocomplete
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Simple fuzzy match: check if query chars appear in order in text
|
||||
fn fuzzy_match(text: &str, query: &str) -> bool {
|
||||
let mut text_chars = text.chars().peekable();
|
||||
|
||||
for query_char in query.chars() {
|
||||
loop {
|
||||
match text_chars.next() {
|
||||
Some(c) if c == query_char => break,
|
||||
Some(_) => continue,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match() {
|
||||
assert!(fuzzy_match("help", "h"));
|
||||
assert!(fuzzy_match("help", "he"));
|
||||
assert!(fuzzy_match("help", "hel"));
|
||||
assert!(fuzzy_match("help", "help"));
|
||||
assert!(fuzzy_match("help", "hp")); // fuzzy: h...p
|
||||
assert!(!fuzzy_match("help", "x"));
|
||||
assert!(!fuzzy_match("help", "helping")); // query longer than text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autocomplete_filter() {
|
||||
let theme = Theme::default();
|
||||
let mut ac = Autocomplete::new(theme);
|
||||
|
||||
ac.update_filter("he");
|
||||
assert!(ac.filtered.len() < ac.options.len());
|
||||
|
||||
// Should match "help"
|
||||
assert!(ac.filtered.iter().any(|&i| ac.options[i].trigger == "help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autocomplete_navigation() {
|
||||
let theme = Theme::default();
|
||||
let mut ac = Autocomplete::new(theme);
|
||||
ac.show();
|
||||
|
||||
assert_eq!(ac.selected, 0);
|
||||
ac.select_next();
|
||||
assert_eq!(ac.selected, 1);
|
||||
ac.select_prev();
|
||||
assert_eq!(ac.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autocomplete_confirm() {
|
||||
let theme = Theme::default();
|
||||
let mut ac = Autocomplete::new(theme);
|
||||
ac.show();
|
||||
|
||||
let cmd = ac.confirm();
|
||||
assert!(cmd.is_some());
|
||||
assert!(cmd.unwrap().starts_with("/"));
|
||||
}
|
||||
}
|
||||
488
crates/app/ui/src/components/chat_panel.rs
Normal file
488
crates/app/ui/src/components/chat_panel.rs
Normal file
@@ -0,0 +1,488 @@
|
||||
//! Borderless chat panel component
|
||||
//!
|
||||
//! Displays chat messages with proper indentation, timestamps,
|
||||
//! and streaming indicators. Uses whitespace instead of borders.
|
||||
|
||||
use crate::theme::Theme;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
Frame,
|
||||
};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Chat message types
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatMessage {
|
||||
User(String),
|
||||
Assistant(String),
|
||||
ToolCall { name: String, args: String },
|
||||
ToolResult { success: bool, output: String },
|
||||
System(String),
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Get a timestamp for when the message was created (for display)
|
||||
pub fn timestamp_display() -> String {
|
||||
let now = SystemTime::now();
|
||||
let secs = now
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let hours = (secs / 3600) % 24;
|
||||
let mins = (secs / 60) % 60;
|
||||
format!("{:02}:{:02}", hours, mins)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message with metadata for display
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisplayMessage {
|
||||
pub message: ChatMessage,
|
||||
pub timestamp: String,
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
impl DisplayMessage {
|
||||
pub fn new(message: ChatMessage) -> Self {
|
||||
Self {
|
||||
message,
|
||||
timestamp: ChatMessage::timestamp_display(),
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Borderless chat panel
|
||||
pub struct ChatPanel {
|
||||
messages: Vec<DisplayMessage>,
|
||||
scroll_offset: usize,
|
||||
auto_scroll: bool,
|
||||
total_lines: usize,
|
||||
focused_index: Option<usize>,
|
||||
is_streaming: bool,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl ChatPanel {
|
||||
/// Create new borderless chat panel
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
auto_scroll: true,
|
||||
total_lines: 0,
|
||||
focused_index: None,
|
||||
is_streaming: false,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new message
|
||||
pub fn add_message(&mut self, message: ChatMessage) {
|
||||
self.messages.push(DisplayMessage::new(message));
|
||||
self.auto_scroll = true;
|
||||
self.is_streaming = false;
|
||||
}
|
||||
|
||||
/// Append content to the last assistant message, or create a new one
|
||||
pub fn append_to_assistant(&mut self, content: &str) {
|
||||
if let Some(DisplayMessage {
|
||||
message: ChatMessage::Assistant(last_content),
|
||||
..
|
||||
}) = self.messages.last_mut()
|
||||
{
|
||||
last_content.push_str(content);
|
||||
} else {
|
||||
self.messages.push(DisplayMessage::new(ChatMessage::Assistant(
|
||||
content.to_string(),
|
||||
)));
|
||||
}
|
||||
self.auto_scroll = true;
|
||||
self.is_streaming = true;
|
||||
}
|
||||
|
||||
/// Set streaming state
|
||||
pub fn set_streaming(&mut self, streaming: bool) {
|
||||
self.is_streaming = streaming;
|
||||
}
|
||||
|
||||
/// Scroll up
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
||||
self.auto_scroll = false;
|
||||
}
|
||||
|
||||
/// Scroll down
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(amount);
|
||||
let near_bottom_threshold = 5;
|
||||
if self.total_lines > 0 {
|
||||
let max_scroll = self.total_lines.saturating_sub(1);
|
||||
if self.scroll_offset.saturating_add(near_bottom_threshold) >= max_scroll {
|
||||
self.auto_scroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll to bottom
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_offset = self.total_lines.saturating_sub(1);
|
||||
self.auto_scroll = true;
|
||||
}
|
||||
|
||||
/// Scroll to top
|
||||
pub fn scroll_to_top(&mut self) {
|
||||
self.scroll_offset = 0;
|
||||
self.auto_scroll = false;
|
||||
}
|
||||
|
||||
/// Page up
|
||||
pub fn page_up(&mut self, page_size: usize) {
|
||||
self.scroll_up(page_size.saturating_sub(2));
|
||||
}
|
||||
|
||||
/// Page down
|
||||
pub fn page_down(&mut self, page_size: usize) {
|
||||
self.scroll_down(page_size.saturating_sub(2));
|
||||
}
|
||||
|
||||
/// Focus next message
|
||||
pub fn focus_next(&mut self) {
|
||||
if self.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.focused_index = Some(match self.focused_index {
|
||||
Some(i) if i + 1 < self.messages.len() => i + 1,
|
||||
Some(_) => 0,
|
||||
None => 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Focus previous message
|
||||
pub fn focus_previous(&mut self) {
|
||||
if self.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.focused_index = Some(match self.focused_index {
|
||||
Some(0) => self.messages.len() - 1,
|
||||
Some(i) => i - 1,
|
||||
None => self.messages.len() - 1,
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear focus
|
||||
pub fn clear_focus(&mut self) {
|
||||
self.focused_index = None;
|
||||
}
|
||||
|
||||
/// Get focused message index
|
||||
pub fn focused_index(&self) -> Option<usize> {
|
||||
self.focused_index
|
||||
}
|
||||
|
||||
/// Get focused message
|
||||
pub fn focused_message(&self) -> Option<&ChatMessage> {
|
||||
self.focused_index
|
||||
.and_then(|i| self.messages.get(i))
|
||||
.map(|m| &m.message)
|
||||
}
|
||||
|
||||
/// Update scroll position before rendering
|
||||
pub fn update_scroll(&mut self, area: Rect) {
|
||||
self.total_lines = self.count_total_lines(area);
|
||||
|
||||
if self.auto_scroll {
|
||||
let visible_height = area.height as usize;
|
||||
let max_scroll = self.total_lines.saturating_sub(visible_height);
|
||||
self.scroll_offset = max_scroll;
|
||||
} else {
|
||||
let visible_height = area.height as usize;
|
||||
let max_scroll = self.total_lines.saturating_sub(visible_height);
|
||||
self.scroll_offset = self.scroll_offset.min(max_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count total lines for scroll calculation
|
||||
/// Must match exactly what render() produces:
|
||||
/// - User: 1 (role line) + N (content) + 1 (empty) = N + 2
|
||||
/// - Assistant: 1 (role line) + N (content) + 1 (empty) = N + 2
|
||||
/// - ToolCall: 1 (call line) + 1 (empty) = 2
|
||||
/// - ToolResult: 1 (result line) + 1 (empty) = 2
|
||||
/// - System: N (content lines) + 1 (empty) = N + 1
|
||||
fn count_total_lines(&self, area: Rect) -> usize {
|
||||
let mut line_count = 0;
|
||||
let wrap_width = area.width.saturating_sub(4) as usize;
|
||||
|
||||
for msg in &self.messages {
|
||||
line_count += match &msg.message {
|
||||
ChatMessage::User(content) => {
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
// 1 role line + N content lines + 1 empty line
|
||||
1 + wrapped.len() + 1
|
||||
}
|
||||
ChatMessage::Assistant(content) => {
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
// 1 role line + N content lines + 1 empty line
|
||||
1 + wrapped.len() + 1
|
||||
}
|
||||
ChatMessage::ToolCall { .. } => 2,
|
||||
ChatMessage::ToolResult { .. } => 2,
|
||||
ChatMessage::System(content) => {
|
||||
// N content lines + 1 empty line (no role line for system)
|
||||
content.lines().count() + 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
line_count
|
||||
}
|
||||
|
||||
/// Render the borderless chat panel
|
||||
///
|
||||
/// Message display format (no symbols, clean typography):
|
||||
/// - Role: bold, appropriate color
|
||||
/// - Timestamp: dim, same line as role
|
||||
/// - Content: 2-space indent, normal weight
|
||||
/// - Blank line between messages
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut text_lines = Vec::new();
|
||||
let wrap_width = area.width.saturating_sub(4) as usize;
|
||||
|
||||
for (idx, display_msg) in self.messages.iter().enumerate() {
|
||||
let is_focused = self.focused_index == Some(idx);
|
||||
let is_last = idx == self.messages.len() - 1;
|
||||
|
||||
match &display_msg.message {
|
||||
ChatMessage::User(content) => {
|
||||
// Role line: "You" bold + timestamp dim
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled("You", self.theme.user_message),
|
||||
Span::styled(
|
||||
format!(" {}", display_msg.timestamp),
|
||||
self.theme.timestamp,
|
||||
),
|
||||
]));
|
||||
|
||||
// Message content with 2-space indent
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
for line in wrapped {
|
||||
let style = if is_focused {
|
||||
self.theme.user_message.add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
self.theme.user_message.remove_modifier(Modifier::BOLD)
|
||||
};
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", line),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
|
||||
// Focus hints
|
||||
if is_focused {
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
" [y]copy [e]edit [r]retry",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::Assistant(content) => {
|
||||
// Role line: streaming indicator (if active) + "Assistant" bold + timestamp
|
||||
let mut role_spans = vec![Span::styled(" ", Style::default())];
|
||||
|
||||
// Streaming indicator (subtle, no symbol)
|
||||
if is_last && self.is_streaming {
|
||||
role_spans.push(Span::styled(
|
||||
"... ",
|
||||
Style::default().fg(self.theme.palette.success),
|
||||
));
|
||||
}
|
||||
|
||||
role_spans.push(Span::styled(
|
||||
"Assistant",
|
||||
self.theme.assistant_message.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
role_spans.push(Span::styled(
|
||||
format!(" {}", display_msg.timestamp),
|
||||
self.theme.timestamp,
|
||||
));
|
||||
|
||||
text_lines.push(Line::from(role_spans));
|
||||
|
||||
// Content
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
for line in wrapped {
|
||||
let style = if is_focused {
|
||||
self.theme.assistant_message.add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
self.theme.assistant_message
|
||||
};
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", line),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
|
||||
// Focus hints
|
||||
if is_focused {
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
" [y]copy [r]retry",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::ToolCall { name, args } => {
|
||||
// Tool calls: name in tool color, args dimmed
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(format!("{} ", name), self.theme.tool_call),
|
||||
Span::styled(
|
||||
truncate_str(args, 60),
|
||||
self.theme.tool_call.add_modifier(Modifier::DIM),
|
||||
),
|
||||
]));
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::ToolResult { success, output } => {
|
||||
// Tool results: status prefix + output
|
||||
let (prefix, style) = if *success {
|
||||
("ok ", self.theme.tool_result_success)
|
||||
} else {
|
||||
("err ", self.theme.tool_result_error)
|
||||
};
|
||||
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(
|
||||
truncate_str(output, 100),
|
||||
style.remove_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::System(content) => {
|
||||
// System messages: handle multi-line content
|
||||
for line in content.lines() {
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(line.to_string(), self.theme.system_message),
|
||||
]));
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let text = Text::from(text_lines);
|
||||
let paragraph = Paragraph::new(text).scroll((self.scroll_offset as u16, 0));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
// Render scrollbar if needed
|
||||
if self.total_lines > area.height as usize {
|
||||
let scrollbar = Scrollbar::default()
|
||||
.orientation(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None)
|
||||
.track_symbol(Some(" "))
|
||||
.thumb_symbol("│")
|
||||
.style(self.theme.status_dim);
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::default()
|
||||
.content_length(self.total_lines)
|
||||
.position(self.scroll_offset);
|
||||
|
||||
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get messages
|
||||
pub fn messages(&self) -> &[DisplayMessage] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// Clear all messages
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.focused_index = None;
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to max length with ellipsis
|
||||
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chat_panel_add_message() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = ChatPanel::new(theme);
|
||||
|
||||
panel.add_message(ChatMessage::User("Hello".to_string()));
|
||||
panel.add_message(ChatMessage::Assistant("Hi there!".to_string()));
|
||||
|
||||
assert_eq!(panel.messages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_to_assistant() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = ChatPanel::new(theme);
|
||||
|
||||
panel.append_to_assistant("Hello");
|
||||
panel.append_to_assistant(" world");
|
||||
|
||||
assert_eq!(panel.messages().len(), 1);
|
||||
if let ChatMessage::Assistant(content) = &panel.messages()[0].message {
|
||||
assert_eq!(content, "Hello world");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus_navigation() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = ChatPanel::new(theme);
|
||||
|
||||
panel.add_message(ChatMessage::User("1".to_string()));
|
||||
panel.add_message(ChatMessage::User("2".to_string()));
|
||||
panel.add_message(ChatMessage::User("3".to_string()));
|
||||
|
||||
assert_eq!(panel.focused_index(), None);
|
||||
|
||||
panel.focus_next();
|
||||
assert_eq!(panel.focused_index(), Some(0));
|
||||
|
||||
panel.focus_next();
|
||||
assert_eq!(panel.focused_index(), Some(1));
|
||||
|
||||
panel.focus_previous();
|
||||
assert_eq!(panel.focused_index(), Some(0));
|
||||
}
|
||||
}
|
||||
322
crates/app/ui/src/components/command_help.rs
Normal file
322
crates/app/ui/src/components/command_help.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! Command help overlay component
|
||||
//!
|
||||
//! Modal overlay that displays available commands in a structured format.
|
||||
//! Shown when user types `/help` or `?`. Supports scrolling with j/k or arrows.
|
||||
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// A single command definition
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Command {
|
||||
pub name: &'static str,
|
||||
pub args: Option<&'static str>,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub const fn new(name: &'static str, description: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
args: None,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_args(name: &'static str, args: &'static str, description: &'static str) -> Self {
|
||||
Self {
|
||||
name,
|
||||
args: Some(args),
|
||||
description,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in commands
|
||||
pub fn builtin_commands() -> Vec<Command> {
|
||||
vec![
|
||||
Command::new("help", "Show this help"),
|
||||
Command::new("status", "Current session info"),
|
||||
Command::with_args("model", "[name]", "Switch model"),
|
||||
Command::with_args("provider", "[name]", "Switch provider (ollama, anthropic, openai)"),
|
||||
Command::new("history", "Browse conversation history"),
|
||||
Command::new("checkpoint", "Save conversation state"),
|
||||
Command::new("checkpoints", "List saved checkpoints"),
|
||||
Command::with_args("rewind", "[id]", "Restore checkpoint"),
|
||||
Command::new("cost", "Show token usage"),
|
||||
Command::new("clear", "Clear conversation"),
|
||||
Command::new("compact", "Compact conversation context"),
|
||||
Command::new("permissions", "Show permission mode"),
|
||||
Command::new("themes", "List available themes"),
|
||||
Command::with_args("theme", "[name]", "Switch theme"),
|
||||
Command::new("exit", "Exit OWLEN"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Command help overlay
|
||||
pub struct CommandHelp {
|
||||
commands: Vec<Command>,
|
||||
visible: bool,
|
||||
scroll_offset: usize,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl CommandHelp {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
commands: builtin_commands(),
|
||||
visible: false,
|
||||
scroll_offset: 0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the help overlay
|
||||
pub fn show(&mut self) {
|
||||
self.visible = true;
|
||||
self.scroll_offset = 0; // Reset scroll when showing
|
||||
}
|
||||
|
||||
/// Hide the help overlay
|
||||
pub fn hide(&mut self) {
|
||||
self.visible = false;
|
||||
}
|
||||
|
||||
/// Check if visible
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
/// Toggle visibility
|
||||
pub fn toggle(&mut self) {
|
||||
self.visible = !self.visible;
|
||||
if self.visible {
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll up by amount
|
||||
fn scroll_up(&mut self, amount: usize) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
||||
}
|
||||
|
||||
/// Scroll down by amount, respecting max
|
||||
fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
|
||||
self.scroll_offset = (self.scroll_offset + amount).min(max_scroll);
|
||||
}
|
||||
|
||||
/// Handle key input, returns true if overlay handled the key
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||
if !self.visible {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate max scroll (commands + padding lines - visible area)
|
||||
let total_lines = self.commands.len() + 3; // +3 for padding and footer
|
||||
let max_scroll = total_lines.saturating_sub(10); // Assume ~10 visible lines
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
|
||||
self.hide();
|
||||
true
|
||||
}
|
||||
// Scroll navigation
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.scroll_up(1);
|
||||
true
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.scroll_down(1, max_scroll);
|
||||
true
|
||||
}
|
||||
KeyCode::PageUp | KeyCode::Char('u') => {
|
||||
self.scroll_up(5);
|
||||
true
|
||||
}
|
||||
KeyCode::PageDown | KeyCode::Char('d') => {
|
||||
self.scroll_down(5, max_scroll);
|
||||
true
|
||||
}
|
||||
KeyCode::Home | KeyCode::Char('g') => {
|
||||
self.scroll_offset = 0;
|
||||
true
|
||||
}
|
||||
KeyCode::End | KeyCode::Char('G') => {
|
||||
self.scroll_offset = max_scroll;
|
||||
true
|
||||
}
|
||||
_ => true, // Consume all other keys while visible
|
||||
}
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Add plugin commands
|
||||
pub fn add_commands(&mut self, commands: Vec<Command>) {
|
||||
self.commands.extend(commands);
|
||||
}
|
||||
|
||||
/// Render the help overlay
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
if !self.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate overlay dimensions
|
||||
let width = (area.width as f32 * 0.7).min(65.0) as u16;
|
||||
let max_height = area.height.saturating_sub(4);
|
||||
let content_height = self.commands.len() as u16 + 4; // +4 for padding and footer
|
||||
let height = content_height.min(max_height).max(8);
|
||||
|
||||
// Center the overlay
|
||||
let x = (area.width.saturating_sub(width)) / 2;
|
||||
let y = (area.height.saturating_sub(height)) / 2;
|
||||
|
||||
let overlay_area = Rect::new(x, y, width, height);
|
||||
|
||||
// Clear the area behind the overlay
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
|
||||
// Build content lines
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Empty line for padding
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Command list
|
||||
for cmd in &self.commands {
|
||||
let name_with_args = if let Some(args) = cmd.args {
|
||||
format!("/{} {}", cmd.name, args)
|
||||
} else {
|
||||
format!("/{}", cmd.name)
|
||||
};
|
||||
|
||||
// Calculate padding for alignment
|
||||
let name_width: usize = 22;
|
||||
let padding = name_width.saturating_sub(name_with_args.len());
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled("/", self.theme.cmd_slash),
|
||||
Span::styled(
|
||||
if let Some(args) = cmd.args {
|
||||
format!("{} {}", cmd.name, args)
|
||||
} else {
|
||||
cmd.name.to_string()
|
||||
},
|
||||
self.theme.cmd_name,
|
||||
),
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(cmd.description, self.theme.cmd_desc),
|
||||
]));
|
||||
}
|
||||
|
||||
// Empty line for padding
|
||||
lines.push(Line::from(""));
|
||||
|
||||
// Footer hint with scroll info
|
||||
let scroll_hint = if self.commands.len() > (height as usize - 4) {
|
||||
format!(" (scroll: j/k or ↑/↓)")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Press ", self.theme.cmd_desc),
|
||||
Span::styled("Esc", self.theme.cmd_name),
|
||||
Span::styled(" to close", self.theme.cmd_desc),
|
||||
Span::styled(scroll_hint, self.theme.cmd_desc),
|
||||
]));
|
||||
|
||||
// Create the block with border
|
||||
let block = Block::default()
|
||||
.title(" Commands ")
|
||||
.title_style(self.theme.popup_title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(self.theme.popup_border)
|
||||
.style(self.theme.overlay_bg);
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
.scroll((self.scroll_offset as u16, 0));
|
||||
|
||||
frame.render_widget(paragraph, overlay_area);
|
||||
|
||||
// Render scrollbar if content exceeds visible area
|
||||
let visible_height = height.saturating_sub(2) as usize; // -2 for borders
|
||||
let total_lines = self.commands.len() + 3;
|
||||
if total_lines > visible_height {
|
||||
let scrollbar = Scrollbar::default()
|
||||
.orientation(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None)
|
||||
.track_symbol(Some(" "))
|
||||
.thumb_symbol("│")
|
||||
.style(self.theme.status_dim);
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::default()
|
||||
.content_length(total_lines)
|
||||
.position(self.scroll_offset);
|
||||
|
||||
// Adjust scrollbar area to be inside the border
|
||||
let scrollbar_area = Rect::new(
|
||||
overlay_area.x + overlay_area.width - 2,
|
||||
overlay_area.y + 1,
|
||||
1,
|
||||
overlay_area.height.saturating_sub(2),
|
||||
);
|
||||
|
||||
frame.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_command_help_visibility() {
|
||||
let theme = Theme::default();
|
||||
let mut help = CommandHelp::new(theme);
|
||||
|
||||
assert!(!help.is_visible());
|
||||
help.show();
|
||||
assert!(help.is_visible());
|
||||
help.hide();
|
||||
assert!(!help.is_visible());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builtin_commands() {
|
||||
let commands = builtin_commands();
|
||||
assert!(!commands.is_empty());
|
||||
assert!(commands.iter().any(|c| c.name == "help"));
|
||||
assert!(commands.iter().any(|c| c.name == "provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scroll_navigation() {
|
||||
let theme = Theme::default();
|
||||
let mut help = CommandHelp::new(theme);
|
||||
help.show();
|
||||
|
||||
assert_eq!(help.scroll_offset, 0);
|
||||
help.scroll_down(3, 10);
|
||||
assert_eq!(help.scroll_offset, 3);
|
||||
help.scroll_up(1);
|
||||
assert_eq!(help.scroll_offset, 2);
|
||||
help.scroll_up(10); // Should clamp to 0
|
||||
assert_eq!(help.scroll_offset, 0);
|
||||
}
|
||||
}
|
||||
507
crates/app/ui/src/components/input_box.rs
Normal file
507
crates/app/ui/src/components/input_box.rs
Normal file
@@ -0,0 +1,507 @@
|
||||
//! Vim-modal input component
|
||||
//!
|
||||
//! Borderless input with vim-like modes (Normal, Insert, Command).
|
||||
//! Uses mode prefix instead of borders for visual indication.
|
||||
|
||||
use crate::theme::{Theme, VimMode};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Input event from the input box
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
/// User submitted a message
|
||||
Message(String),
|
||||
/// User submitted a command (without / prefix)
|
||||
Command(String),
|
||||
/// Mode changed
|
||||
ModeChange(VimMode),
|
||||
/// Request to cancel current operation
|
||||
Cancel,
|
||||
/// Request to expand input (multiline)
|
||||
Expand,
|
||||
}
|
||||
|
||||
/// Vim-modal input box
|
||||
pub struct InputBox {
|
||||
input: String,
|
||||
cursor_position: usize,
|
||||
history: Vec<String>,
|
||||
history_index: usize,
|
||||
mode: VimMode,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl InputBox {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
input: String::new(),
|
||||
cursor_position: 0,
|
||||
history: Vec::new(),
|
||||
history_index: 0,
|
||||
mode: VimMode::Insert, // Start in insert mode for familiarity
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current vim mode
|
||||
pub fn mode(&self) -> VimMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Set vim mode
|
||||
pub fn set_mode(&mut self, mode: VimMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Handle key event, returns input event if action is needed
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match self.mode {
|
||||
VimMode::Normal => self.handle_normal_mode(key),
|
||||
VimMode::Insert => self.handle_insert_mode(key),
|
||||
VimMode::Command => self.handle_command_mode(key),
|
||||
VimMode::Visual => self.handle_visual_mode(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in normal mode
|
||||
fn handle_normal_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
// Enter insert mode
|
||||
KeyCode::Char('i') => {
|
||||
self.mode = VimMode::Insert;
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.mode = VimMode::Insert;
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('I') => {
|
||||
self.mode = VimMode::Insert;
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('A') => {
|
||||
self.mode = VimMode::Insert;
|
||||
self.cursor_position = self.input.len();
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
// Enter command mode
|
||||
KeyCode::Char(':') => {
|
||||
self.mode = VimMode::Command;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Command))
|
||||
}
|
||||
// Navigation
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('0') | KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
KeyCode::Char('$') | KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('w') => {
|
||||
// Jump to next word
|
||||
self.cursor_position = self.next_word_position();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
// Jump to previous word
|
||||
self.cursor_position = self.prev_word_position();
|
||||
None
|
||||
}
|
||||
// Editing
|
||||
KeyCode::Char('x') => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
// Delete line (dd would require tracking, simplify to clear)
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
// History
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.history_prev();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
self.history_next();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in insert mode
|
||||
fn handle_insert_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
// Move cursor back when exiting insert mode (vim behavior)
|
||||
if self.cursor_position > 0 {
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let message = self.input.clone();
|
||||
if !message.trim().is_empty() {
|
||||
self.history.push(message.clone());
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
return Some(InputEvent::Message(message));
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(InputEvent::Expand)
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(InputEvent::Cancel)
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
None
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.history_prev();
|
||||
None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.history_next();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in command mode
|
||||
fn handle_command_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let command = self.input.clone();
|
||||
self.mode = VimMode::Normal;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
if !command.trim().is_empty() {
|
||||
return Some(InputEvent::Command(command));
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
} else {
|
||||
// Empty command, exit to normal mode
|
||||
self.mode = VimMode::Normal;
|
||||
return Some(InputEvent::ModeChange(VimMode::Normal));
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in visual mode (simplified)
|
||||
fn handle_visual_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// History navigation - previous
|
||||
fn history_prev(&mut self) {
|
||||
if !self.history.is_empty() && self.history_index > 0 {
|
||||
self.history_index -= 1;
|
||||
self.input = self.history[self.history_index].clone();
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
}
|
||||
|
||||
/// History navigation - next
|
||||
fn history_next(&mut self) {
|
||||
if self.history_index < self.history.len().saturating_sub(1) {
|
||||
self.history_index += 1;
|
||||
self.input = self.history[self.history_index].clone();
|
||||
self.cursor_position = self.input.len();
|
||||
} else if self.history_index < self.history.len() {
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find next word position
|
||||
fn next_word_position(&self) -> usize {
|
||||
let bytes = self.input.as_bytes();
|
||||
let mut pos = self.cursor_position;
|
||||
|
||||
// Skip current word
|
||||
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
// Skip whitespace
|
||||
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find previous word position
|
||||
fn prev_word_position(&self) -> usize {
|
||||
let bytes = self.input.as_bytes();
|
||||
let mut pos = self.cursor_position.saturating_sub(1);
|
||||
|
||||
// Skip whitespace
|
||||
while pos > 0 && bytes[pos].is_ascii_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
// Skip to start of word
|
||||
while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Render the borderless input (single line)
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let is_empty = self.input.is_empty();
|
||||
let symbols = &self.theme.symbols;
|
||||
|
||||
// Mode-specific prefix
|
||||
let prefix = match self.mode {
|
||||
VimMode::Normal => Span::styled(
|
||||
format!("{} ", symbols.mode_normal),
|
||||
self.theme.status_dim,
|
||||
),
|
||||
VimMode::Insert => Span::styled(
|
||||
format!("{} ", symbols.user_prefix),
|
||||
self.theme.input_prefix,
|
||||
),
|
||||
VimMode::Command => Span::styled(
|
||||
": ",
|
||||
self.theme.input_prefix,
|
||||
),
|
||||
VimMode::Visual => Span::styled(
|
||||
format!("{} ", symbols.mode_visual),
|
||||
self.theme.status_accent,
|
||||
),
|
||||
};
|
||||
|
||||
// Cursor position handling
|
||||
let (text_before, cursor_char, text_after) = if self.cursor_position < self.input.len() {
|
||||
let before = &self.input[..self.cursor_position];
|
||||
let cursor = &self.input[self.cursor_position..self.cursor_position + 1];
|
||||
let after = &self.input[self.cursor_position + 1..];
|
||||
(before, cursor, after)
|
||||
} else {
|
||||
(&self.input[..], " ", "")
|
||||
};
|
||||
|
||||
let line = if is_empty && self.mode == VimMode::Insert {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled("▊", self.theme.input_prefix),
|
||||
Span::styled(" Type message...", self.theme.input_placeholder),
|
||||
])
|
||||
} else if is_empty && self.mode == VimMode::Command {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled("▊", self.theme.input_prefix),
|
||||
])
|
||||
} else {
|
||||
// Build cursor span with appropriate styling
|
||||
let cursor_style = if self.mode == VimMode::Normal {
|
||||
Style::default()
|
||||
.bg(self.theme.palette.fg)
|
||||
.fg(self.theme.palette.bg)
|
||||
} else {
|
||||
self.theme.input_prefix
|
||||
};
|
||||
|
||||
let cursor_span = if self.mode == VimMode::Normal && !is_empty {
|
||||
Span::styled(cursor_char.to_string(), cursor_style)
|
||||
} else {
|
||||
Span::styled("▊", self.theme.input_prefix)
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled(text_before.to_string(), self.theme.input_text),
|
||||
cursor_span,
|
||||
Span::styled(text_after.to_string(), self.theme.input_text),
|
||||
])
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Clear input
|
||||
pub fn clear(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
/// Get current input text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// Set input text
|
||||
pub fn set_text(&mut self, text: String) {
|
||||
self.input = text;
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mode_transitions() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
// Start in insert mode
|
||||
assert_eq!(input.mode(), VimMode::Insert);
|
||||
|
||||
// Escape to normal mode
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Normal))));
|
||||
assert_eq!(input.mode(), VimMode::Normal);
|
||||
|
||||
// 'i' to insert mode
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Insert))));
|
||||
assert_eq!(input.mode(), VimMode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_text() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('h')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
|
||||
assert_eq!(input.text(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_mode() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
// Escape to normal, then : to command
|
||||
input.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char(':')));
|
||||
|
||||
assert_eq!(input.mode(), VimMode::Command);
|
||||
|
||||
// Type command
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('q')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('u')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('t')));
|
||||
|
||||
assert_eq!(input.text(), "quit");
|
||||
|
||||
// Submit command
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(matches!(event, Some(InputEvent::Command(cmd)) if cmd == "quit"));
|
||||
}
|
||||
}
|
||||
23
crates/app/ui/src/components/mod.rs
Normal file
23
crates/app/ui/src/components/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! TUI components for the borderless multi-provider design
|
||||
|
||||
mod autocomplete;
|
||||
mod chat_panel;
|
||||
mod command_help;
|
||||
mod input_box;
|
||||
mod model_picker;
|
||||
mod permission_popup;
|
||||
mod plan_panel;
|
||||
mod provider_tabs;
|
||||
mod status_bar;
|
||||
mod todo_panel;
|
||||
|
||||
pub use autocomplete::{Autocomplete, AutocompleteOption, AutocompleteResult};
|
||||
pub use chat_panel::{ChatMessage, ChatPanel, DisplayMessage};
|
||||
pub use command_help::{Command, CommandHelp};
|
||||
pub use input_box::{InputBox, InputEvent};
|
||||
pub use model_picker::{ModelPicker, PickerResult, PickerState};
|
||||
pub use permission_popup::{PermissionOption, PermissionPopup};
|
||||
pub use plan_panel::PlanPanel;
|
||||
pub use provider_tabs::ProviderTabs;
|
||||
pub use status_bar::{AppState, StatusBar};
|
||||
pub use todo_panel::TodoPanel;
|
||||
811
crates/app/ui/src/components/model_picker.rs
Normal file
811
crates/app/ui/src/components/model_picker.rs
Normal file
@@ -0,0 +1,811 @@
|
||||
//! Model Picker Component
|
||||
//!
|
||||
//! A dropdown-style picker for selecting models from the current provider.
|
||||
//! Triggered by pressing 'm' when input is empty or in Normal mode.
|
||||
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use llm_core::{ModelInfo, ProviderType};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Result of handling a key event in the model picker
|
||||
pub enum PickerResult {
|
||||
/// Model was selected
|
||||
Selected(String),
|
||||
/// Picker was cancelled
|
||||
Cancelled,
|
||||
/// Key was handled, no action needed
|
||||
Handled,
|
||||
/// Key was not handled
|
||||
NotHandled,
|
||||
}
|
||||
|
||||
/// State of the model picker
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PickerState {
|
||||
/// Picker is hidden
|
||||
Hidden,
|
||||
/// Loading models from provider
|
||||
Loading,
|
||||
/// Picker is ready with models
|
||||
Ready,
|
||||
/// Error loading models
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Maximum number of visible models in the picker
|
||||
const MAX_VISIBLE_MODELS: usize = 10;
|
||||
|
||||
/// Model picker dropdown component
|
||||
pub struct ModelPicker {
|
||||
/// Available models for the current provider
|
||||
models: Vec<ModelInfo>,
|
||||
/// Currently selected index (within filtered_indices)
|
||||
selected_index: usize,
|
||||
/// Scroll offset for the visible window
|
||||
scroll_offset: usize,
|
||||
/// Picker state (hidden, loading, ready, error)
|
||||
state: PickerState,
|
||||
/// Filter text for searching models
|
||||
filter: String,
|
||||
/// Filtered model indices
|
||||
filtered_indices: Vec<usize>,
|
||||
/// Theme for styling
|
||||
theme: Theme,
|
||||
/// Provider name for display
|
||||
provider_name: String,
|
||||
/// Current provider type (for fallback models)
|
||||
provider_type: Option<ProviderType>,
|
||||
}
|
||||
|
||||
impl ModelPicker {
|
||||
/// Create a new model picker
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
models: Vec::new(),
|
||||
selected_index: 0,
|
||||
scroll_offset: 0,
|
||||
state: PickerState::Hidden,
|
||||
filter: String::new(),
|
||||
filtered_indices: Vec::new(),
|
||||
theme,
|
||||
provider_name: String::new(),
|
||||
provider_type: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the loading state while fetching models
|
||||
pub fn show_loading(&mut self, provider_type: ProviderType) {
|
||||
// Capitalize provider name for display
|
||||
let name = provider_type.to_string();
|
||||
self.provider_name = capitalize_first(&name);
|
||||
self.provider_type = Some(provider_type);
|
||||
self.models.clear();
|
||||
self.filtered_indices.clear();
|
||||
self.filter.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.state = PickerState::Loading;
|
||||
}
|
||||
|
||||
/// Show the picker with models for a provider
|
||||
pub fn show(&mut self, models: Vec<ModelInfo>, provider_name: &str, current_model: &str) {
|
||||
self.models = models;
|
||||
self.provider_name = provider_name.to_string();
|
||||
self.filter.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.update_filter();
|
||||
|
||||
// Find and select current model (index within filtered_indices)
|
||||
let model_idx = self
|
||||
.models
|
||||
.iter()
|
||||
.position(|m| m.id == current_model)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Find position in filtered list
|
||||
self.selected_index = self
|
||||
.filtered_indices
|
||||
.iter()
|
||||
.position(|&i| i == model_idx)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Adjust scroll to show selected item
|
||||
self.ensure_selected_visible();
|
||||
|
||||
self.state = PickerState::Ready;
|
||||
}
|
||||
|
||||
/// Show an error state
|
||||
pub fn show_error(&mut self, error: String) {
|
||||
self.state = PickerState::Error(error);
|
||||
}
|
||||
|
||||
/// Hide the picker
|
||||
pub fn hide(&mut self) {
|
||||
self.state = PickerState::Hidden;
|
||||
self.filter.clear();
|
||||
self.provider_type = None;
|
||||
}
|
||||
|
||||
/// Check if picker is visible (any non-hidden state)
|
||||
pub fn is_visible(&self) -> bool {
|
||||
!matches!(self.state, PickerState::Hidden)
|
||||
}
|
||||
|
||||
/// Get current picker state
|
||||
pub fn state(&self) -> &PickerState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Use fallback models for the current provider
|
||||
pub fn use_fallback_models(&mut self, current_model: &str) {
|
||||
if let Some(provider_type) = self.provider_type {
|
||||
let models = get_fallback_models(provider_type);
|
||||
if !models.is_empty() {
|
||||
self.show(models, &provider_type.to_string(), current_model);
|
||||
} else {
|
||||
self.show_error("No models available".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Update filter and recalculate visible models
|
||||
fn update_filter(&mut self) {
|
||||
let filter_lower = self.filter.to_lowercase();
|
||||
self.filtered_indices = self
|
||||
.models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| {
|
||||
if filter_lower.is_empty() {
|
||||
true
|
||||
} else {
|
||||
m.id.to_lowercase().contains(&filter_lower)
|
||||
|| m.display_name
|
||||
.as_ref()
|
||||
.map(|n| n.to_lowercase().contains(&filter_lower))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Reset selection to first item and scroll to top when filter changes
|
||||
self.selected_index = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Handle a key event
|
||||
pub fn handle_key(&mut self, key: KeyEvent, current_model: &str) -> PickerResult {
|
||||
// Handle different states
|
||||
match &self.state {
|
||||
PickerState::Hidden => return PickerResult::NotHandled,
|
||||
PickerState::Loading => {
|
||||
// Only allow escape while loading
|
||||
if key.code == KeyCode::Esc {
|
||||
self.hide();
|
||||
return PickerResult::Cancelled;
|
||||
}
|
||||
return PickerResult::Handled;
|
||||
}
|
||||
PickerState::Error(_) => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.hide();
|
||||
return PickerResult::Cancelled;
|
||||
}
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => {
|
||||
// Use fallback models
|
||||
self.use_fallback_models(current_model);
|
||||
return PickerResult::Handled;
|
||||
}
|
||||
_ => return PickerResult::Handled,
|
||||
}
|
||||
}
|
||||
PickerState::Ready => {
|
||||
// Fall through to model selection logic
|
||||
}
|
||||
}
|
||||
|
||||
// Ready state - handle model selection
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.hide();
|
||||
PickerResult::Cancelled
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// selected_index is position in filtered_indices
|
||||
// get the actual model index, then the model
|
||||
if let Some(&model_idx) = self.filtered_indices.get(self.selected_index) {
|
||||
let model_id = self.models[model_idx].id.clone();
|
||||
self.hide();
|
||||
PickerResult::Selected(model_id)
|
||||
} else {
|
||||
PickerResult::Cancelled
|
||||
}
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.select_previous();
|
||||
PickerResult::Handled
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.select_next();
|
||||
PickerResult::Handled
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.filter.push(c);
|
||||
self.update_filter();
|
||||
PickerResult::Handled
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.filter.pop();
|
||||
self.update_filter();
|
||||
PickerResult::Handled
|
||||
}
|
||||
_ => PickerResult::NotHandled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Select next model (selected_index is position in filtered_indices)
|
||||
fn select_next(&mut self) {
|
||||
if self.filtered_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Move to next position (wrapping)
|
||||
self.selected_index = (self.selected_index + 1) % self.filtered_indices.len();
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
|
||||
/// Select previous model (selected_index is position in filtered_indices)
|
||||
fn select_previous(&mut self) {
|
||||
if self.filtered_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Move to previous position (wrapping)
|
||||
if self.selected_index == 0 {
|
||||
self.selected_index = self.filtered_indices.len() - 1;
|
||||
} else {
|
||||
self.selected_index -= 1;
|
||||
}
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
|
||||
/// Ensure the selected item is visible by adjusting scroll_offset
|
||||
fn ensure_selected_visible(&mut self) {
|
||||
// If selected is above the visible window, scroll up
|
||||
if self.selected_index < self.scroll_offset {
|
||||
self.scroll_offset = self.selected_index;
|
||||
}
|
||||
// If selected is below the visible window, scroll down
|
||||
else if self.selected_index >= self.scroll_offset + MAX_VISIBLE_MODELS {
|
||||
self.scroll_offset = self.selected_index.saturating_sub(MAX_VISIBLE_MODELS - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the model picker as a centered modal
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
match &self.state {
|
||||
PickerState::Hidden => return,
|
||||
PickerState::Loading => self.render_loading(frame, area),
|
||||
PickerState::Ready => self.render_models(frame, area),
|
||||
PickerState::Error(msg) => self.render_error(frame, area, msg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the loading state
|
||||
fn render_loading(&self, frame: &mut Frame, area: Rect) {
|
||||
let modal_width = 50.min(area.width.saturating_sub(4));
|
||||
let modal_height = 5;
|
||||
|
||||
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
|
||||
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
|
||||
|
||||
let modal_area = Rect {
|
||||
x: modal_x,
|
||||
y: modal_y,
|
||||
width: modal_width,
|
||||
height: modal_height,
|
||||
};
|
||||
|
||||
frame.render_widget(Clear, modal_area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Model Picker - {}", self.provider_name),
|
||||
Style::default()
|
||||
.fg(self.theme.palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Loading models...",
|
||||
self.theme.status_dim,
|
||||
)),
|
||||
];
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.palette.border))
|
||||
.style(Style::default().bg(self.theme.palette.overlay_bg));
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, modal_area);
|
||||
}
|
||||
|
||||
/// Render the error state
|
||||
fn render_error(&self, frame: &mut Frame, area: Rect, error: &str) {
|
||||
let modal_width = 60.min(area.width.saturating_sub(4));
|
||||
let modal_height = 7;
|
||||
|
||||
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
|
||||
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
|
||||
|
||||
let modal_area = Rect {
|
||||
x: modal_x,
|
||||
y: modal_y,
|
||||
width: modal_width,
|
||||
height: modal_height,
|
||||
};
|
||||
|
||||
frame.render_widget(Clear, modal_area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(
|
||||
format!("Model Picker - {}", self.provider_name),
|
||||
Style::default()
|
||||
.fg(self.theme.palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
format!("Error: {}", error),
|
||||
Style::default().fg(self.theme.palette.error),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Press [f] for fallback models, [Esc] to close",
|
||||
self.theme.status_dim,
|
||||
)),
|
||||
];
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.palette.border))
|
||||
.style(Style::default().bg(self.theme.palette.overlay_bg));
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, modal_area);
|
||||
}
|
||||
|
||||
/// Render the model list
|
||||
fn render_models(&self, frame: &mut Frame, area: Rect) {
|
||||
if self.models.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate visible window
|
||||
let total_models = self.filtered_indices.len();
|
||||
let visible_count = total_models.min(MAX_VISIBLE_MODELS);
|
||||
|
||||
// Calculate modal dimensions
|
||||
let modal_width = 60.min(area.width.saturating_sub(4));
|
||||
let modal_height = visible_count as u16 + 6; // 2 for border, 1 for header, 1 for filter, 1 for separator, 1 for scroll hint
|
||||
|
||||
let modal_x = (area.width.saturating_sub(modal_width)) / 2;
|
||||
let modal_y = (area.height.saturating_sub(modal_height)) / 2;
|
||||
|
||||
let modal_area = Rect {
|
||||
x: modal_x,
|
||||
y: modal_y,
|
||||
width: modal_width,
|
||||
height: modal_height,
|
||||
};
|
||||
|
||||
// Clear background
|
||||
frame.render_widget(Clear, modal_area);
|
||||
|
||||
// Build the modal content
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Header with provider name and count
|
||||
let header = if total_models > MAX_VISIBLE_MODELS {
|
||||
format!(
|
||||
"Select Model - {} ({}-{}/{})",
|
||||
self.provider_name,
|
||||
self.scroll_offset + 1,
|
||||
(self.scroll_offset + visible_count).min(total_models),
|
||||
total_models
|
||||
)
|
||||
} else {
|
||||
format!("Select Model - {} ({})", self.provider_name, total_models)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
header,
|
||||
Style::default()
|
||||
.fg(self.theme.palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
|
||||
// Filter line
|
||||
let filter_text = if self.filter.is_empty() {
|
||||
"Type to filter... (j/k to navigate, Enter to select)".to_string()
|
||||
} else {
|
||||
format!("Filter: {}", self.filter)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
filter_text,
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from(Span::styled(
|
||||
"─".repeat((modal_width - 2) as usize),
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
|
||||
// Show scroll up indicator if needed
|
||||
if self.scroll_offset > 0 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" ▲ more above",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
|
||||
// Model list - show visible window based on scroll_offset
|
||||
let visible_range = self.scroll_offset..(self.scroll_offset + visible_count).min(total_models);
|
||||
for (display_idx, &model_idx) in self.filtered_indices.iter().enumerate() {
|
||||
// Skip items outside visible window
|
||||
if display_idx < visible_range.start || display_idx >= visible_range.end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let model = &self.models[model_idx];
|
||||
let is_selected = display_idx == self.selected_index;
|
||||
|
||||
let display_name = model
|
||||
.display_name
|
||||
.as_ref()
|
||||
.unwrap_or(&model.id)
|
||||
.clone();
|
||||
|
||||
// Build model line
|
||||
let prefix = if is_selected { "▶ " } else { " " };
|
||||
|
||||
let tool_indicator = if model.supports_tools { " [tools]" } else { "" };
|
||||
let vision_indicator = if model.supports_vision { " [vision]" } else { "" };
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(self.theme.palette.accent)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(self.theme.palette.fg)
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(display_name, style),
|
||||
Span::styled(
|
||||
format!("{}{}", tool_indicator, vision_indicator),
|
||||
self.theme.status_dim,
|
||||
),
|
||||
]));
|
||||
|
||||
// Add pricing info on a second line for selected model
|
||||
if is_selected {
|
||||
if let (Some(input_price), Some(output_price)) =
|
||||
(model.input_price_per_mtok, model.output_price_per_mtok)
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" ${:.2}/MTok in, ${:.2}/MTok out",
|
||||
input_price, output_price
|
||||
),
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show scroll down indicator if needed
|
||||
if self.scroll_offset + visible_count < total_models {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ▼ {} more below", total_models - self.scroll_offset - visible_count),
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
|
||||
// Render
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.theme.palette.border))
|
||||
.style(Style::default().bg(self.theme.palette.overlay_bg));
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, modal_area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Capitalize the first letter of a string
|
||||
fn capitalize_first(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get fallback models for a provider when API call fails
|
||||
fn get_fallback_models(provider: ProviderType) -> Vec<ModelInfo> {
|
||||
match provider {
|
||||
ProviderType::Ollama => vec![
|
||||
ModelInfo {
|
||||
id: "qwen3:8b".to_string(),
|
||||
display_name: Some("Qwen 3 8B".to_string()),
|
||||
description: Some("Efficient reasoning model with tool support".to_string()),
|
||||
context_window: Some(32768),
|
||||
max_output_tokens: Some(8192),
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
input_price_per_mtok: None,
|
||||
output_price_per_mtok: None,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "llama3.2:latest".to_string(),
|
||||
display_name: Some("Llama 3.2".to_string()),
|
||||
description: Some("Meta's latest model with vision".to_string()),
|
||||
context_window: Some(128000),
|
||||
max_output_tokens: Some(4096),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: None,
|
||||
output_price_per_mtok: None,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "deepseek-coder-v2:latest".to_string(),
|
||||
display_name: Some("DeepSeek Coder V2".to_string()),
|
||||
description: Some("Coding-focused model".to_string()),
|
||||
context_window: Some(65536),
|
||||
max_output_tokens: Some(8192),
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
input_price_per_mtok: None,
|
||||
output_price_per_mtok: None,
|
||||
},
|
||||
ModelInfo {
|
||||
id: "mistral:latest".to_string(),
|
||||
display_name: Some("Mistral 7B".to_string()),
|
||||
description: Some("Fast and efficient model".to_string()),
|
||||
context_window: Some(32768),
|
||||
max_output_tokens: Some(4096),
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
input_price_per_mtok: None,
|
||||
output_price_per_mtok: None,
|
||||
},
|
||||
],
|
||||
ProviderType::Anthropic => vec![
|
||||
ModelInfo {
|
||||
id: "claude-sonnet-4-20250514".to_string(),
|
||||
display_name: Some("Claude Sonnet 4".to_string()),
|
||||
description: Some("Best balance of speed and capability".to_string()),
|
||||
context_window: Some(200000),
|
||||
max_output_tokens: Some(8192),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(3.0),
|
||||
output_price_per_mtok: Some(15.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "claude-opus-4-20250514".to_string(),
|
||||
display_name: Some("Claude Opus 4".to_string()),
|
||||
description: Some("Most capable model".to_string()),
|
||||
context_window: Some(200000),
|
||||
max_output_tokens: Some(8192),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(15.0),
|
||||
output_price_per_mtok: Some(75.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "claude-haiku-3-5-20241022".to_string(),
|
||||
display_name: Some("Claude Haiku 3.5".to_string()),
|
||||
description: Some("Fast and affordable".to_string()),
|
||||
context_window: Some(200000),
|
||||
max_output_tokens: Some(8192),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(0.80),
|
||||
output_price_per_mtok: Some(4.0),
|
||||
},
|
||||
],
|
||||
ProviderType::OpenAI => vec![
|
||||
ModelInfo {
|
||||
id: "gpt-4o".to_string(),
|
||||
display_name: Some("GPT-4o".to_string()),
|
||||
description: Some("Most capable GPT-4 model".to_string()),
|
||||
context_window: Some(128000),
|
||||
max_output_tokens: Some(16384),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(2.50),
|
||||
output_price_per_mtok: Some(10.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4o-mini".to_string(),
|
||||
display_name: Some("GPT-4o Mini".to_string()),
|
||||
description: Some("Fast and affordable GPT-4".to_string()),
|
||||
context_window: Some(128000),
|
||||
max_output_tokens: Some(16384),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(0.15),
|
||||
output_price_per_mtok: Some(0.60),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "gpt-4-turbo".to_string(),
|
||||
display_name: Some("GPT-4 Turbo".to_string()),
|
||||
description: Some("Previous generation GPT-4".to_string()),
|
||||
context_window: Some(128000),
|
||||
max_output_tokens: Some(4096),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(10.0),
|
||||
output_price_per_mtok: Some(30.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "o1".to_string(),
|
||||
display_name: Some("o1".to_string()),
|
||||
description: Some("Reasoning model".to_string()),
|
||||
context_window: Some(200000),
|
||||
max_output_tokens: Some(100000),
|
||||
supports_tools: false,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(15.0),
|
||||
output_price_per_mtok: Some(60.0),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_models() -> Vec<ModelInfo> {
|
||||
vec![
|
||||
ModelInfo {
|
||||
id: "model-a".to_string(),
|
||||
display_name: Some("Model A".to_string()),
|
||||
description: None,
|
||||
context_window: Some(4096),
|
||||
max_output_tokens: Some(1024),
|
||||
supports_tools: true,
|
||||
supports_vision: false,
|
||||
input_price_per_mtok: Some(1.0),
|
||||
output_price_per_mtok: Some(2.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "model-b".to_string(),
|
||||
display_name: Some("Model B".to_string()),
|
||||
description: None,
|
||||
context_window: Some(8192),
|
||||
max_output_tokens: Some(2048),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(0.5),
|
||||
output_price_per_mtok: Some(1.0),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_picker_show_hide() {
|
||||
let theme = Theme::default();
|
||||
let mut picker = ModelPicker::new(theme);
|
||||
|
||||
assert!(!picker.is_visible());
|
||||
assert_eq!(*picker.state(), PickerState::Hidden);
|
||||
|
||||
picker.show(create_test_models(), "Test Provider", "model-a");
|
||||
assert!(picker.is_visible());
|
||||
assert_eq!(*picker.state(), PickerState::Ready);
|
||||
|
||||
picker.hide();
|
||||
assert!(!picker.is_visible());
|
||||
assert_eq!(*picker.state(), PickerState::Hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_picker_loading_state() {
|
||||
let theme = Theme::default();
|
||||
let mut picker = ModelPicker::new(theme);
|
||||
|
||||
picker.show_loading(ProviderType::Anthropic);
|
||||
assert!(picker.is_visible());
|
||||
assert_eq!(*picker.state(), PickerState::Loading);
|
||||
// Provider name is capitalized for display
|
||||
assert_eq!(picker.provider_name, "Anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_picker_error_state() {
|
||||
let theme = Theme::default();
|
||||
let mut picker = ModelPicker::new(theme);
|
||||
|
||||
picker.show_loading(ProviderType::Ollama);
|
||||
picker.show_error("Connection refused".to_string());
|
||||
assert!(picker.is_visible());
|
||||
assert!(matches!(picker.state(), PickerState::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_picker_fallback_models() {
|
||||
let theme = Theme::default();
|
||||
let mut picker = ModelPicker::new(theme);
|
||||
|
||||
// Start in loading state
|
||||
picker.show_loading(ProviderType::Anthropic);
|
||||
picker.show_error("API error".to_string());
|
||||
|
||||
// Use fallback models
|
||||
picker.use_fallback_models("claude-sonnet-4-20250514");
|
||||
|
||||
assert!(picker.is_visible());
|
||||
assert_eq!(*picker.state(), PickerState::Ready);
|
||||
assert!(!picker.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_picker_navigation() {
|
||||
let theme = Theme::default();
|
||||
let mut picker = ModelPicker::new(theme);
|
||||
|
||||
picker.show(create_test_models(), "Test Provider", "model-a");
|
||||
assert_eq!(picker.selected_index, 0);
|
||||
|
||||
picker.select_next();
|
||||
assert_eq!(picker.selected_index, 1);
|
||||
|
||||
picker.select_next();
|
||||
assert_eq!(picker.selected_index, 0); // Wraps around
|
||||
|
||||
picker.select_previous();
|
||||
assert_eq!(picker.selected_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_picker_filter() {
|
||||
let theme = Theme::default();
|
||||
let mut picker = ModelPicker::new(theme);
|
||||
|
||||
picker.show(create_test_models(), "Test Provider", "model-a");
|
||||
assert_eq!(picker.filtered_indices.len(), 2);
|
||||
|
||||
picker.filter = "model-b".to_string();
|
||||
picker.update_filter();
|
||||
assert_eq!(picker.filtered_indices.len(), 1);
|
||||
assert_eq!(picker.filtered_indices[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_models_exist_for_all_providers() {
|
||||
assert!(!get_fallback_models(ProviderType::Ollama).is_empty());
|
||||
assert!(!get_fallback_models(ProviderType::Anthropic).is_empty());
|
||||
assert!(!get_fallback_models(ProviderType::OpenAI).is_empty());
|
||||
}
|
||||
}
|
||||
196
crates/app/ui/src/components/permission_popup.rs
Normal file
196
crates/app/ui/src/components/permission_popup.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use permissions::PermissionDecision;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PermissionOption {
|
||||
AllowOnce,
|
||||
AlwaysAllow,
|
||||
Deny,
|
||||
Explain,
|
||||
}
|
||||
|
||||
pub struct PermissionPopup {
|
||||
tool: String,
|
||||
context: Option<String>,
|
||||
selected: usize,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl PermissionPopup {
|
||||
pub fn new(tool: String, context: Option<String>, theme: Theme) -> Self {
|
||||
Self {
|
||||
tool,
|
||||
context,
|
||||
selected: 0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<PermissionOption> {
|
||||
match key.code {
|
||||
KeyCode::Char('a') => Some(PermissionOption::AllowOnce),
|
||||
KeyCode::Char('A') => Some(PermissionOption::AlwaysAllow),
|
||||
KeyCode::Char('d') => Some(PermissionOption::Deny),
|
||||
KeyCode::Char('?') => Some(PermissionOption::Explain),
|
||||
KeyCode::Up => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.selected < 3 {
|
||||
self.selected += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Enter => match self.selected {
|
||||
0 => Some(PermissionOption::AllowOnce),
|
||||
1 => Some(PermissionOption::AlwaysAllow),
|
||||
2 => Some(PermissionOption::Deny),
|
||||
3 => Some(PermissionOption::Explain),
|
||||
_ => None,
|
||||
},
|
||||
KeyCode::Esc => Some(PermissionOption::Deny),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
// Center the popup
|
||||
let popup_area = crate::layout::AppLayout::center_popup(area, 64, 14);
|
||||
|
||||
// Clear the area behind the popup
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
// Render popup with styled border
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(self.theme.popup_border)
|
||||
.style(self.theme.popup_bg)
|
||||
.title(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("🔒", self.theme.popup_title),
|
||||
Span::raw(" "),
|
||||
Span::styled("Permission Required", self.theme.popup_title),
|
||||
Span::raw(" "),
|
||||
]));
|
||||
|
||||
frame.render_widget(block, popup_area);
|
||||
|
||||
// Split popup into sections
|
||||
let inner = popup_area.inner(ratatui::layout::Margin {
|
||||
vertical: 1,
|
||||
horizontal: 2,
|
||||
});
|
||||
|
||||
let sections = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2), // Tool name with box
|
||||
Constraint::Length(3), // Context (if any)
|
||||
Constraint::Length(1), // Separator
|
||||
Constraint::Length(1), // Option 1
|
||||
Constraint::Length(1), // Option 2
|
||||
Constraint::Length(1), // Option 3
|
||||
Constraint::Length(1), // Option 4
|
||||
Constraint::Length(1), // Help text
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Tool name with highlight
|
||||
let tool_line = Line::from(vec![
|
||||
Span::styled("⚡ Tool: ", Style::default().fg(self.theme.palette.warning)),
|
||||
Span::styled(&self.tool, self.theme.popup_title),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(tool_line), sections[0]);
|
||||
|
||||
// Context with wrapping
|
||||
if let Some(ctx) = &self.context {
|
||||
let context_text = if ctx.len() > 100 {
|
||||
format!("{}...", &ctx[..100])
|
||||
} else {
|
||||
ctx.clone()
|
||||
};
|
||||
let context_lines = textwrap::wrap(&context_text, (sections[1].width - 2) as usize);
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("📝 Context: ", Style::default().fg(self.theme.palette.info)),
|
||||
])
|
||||
];
|
||||
for line in context_lines.iter().take(2) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(line.to_string(), Style::default().fg(self.theme.palette.fg_dim)),
|
||||
]));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), sections[1]);
|
||||
}
|
||||
|
||||
// Separator
|
||||
let separator = Line::styled(
|
||||
"─".repeat(sections[2].width as usize),
|
||||
Style::default().fg(self.theme.palette.divider_fg),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(separator), sections[2]);
|
||||
|
||||
// Options with icons and colors
|
||||
let options = [
|
||||
("✓", " [a] Allow once", self.theme.palette.success, 0),
|
||||
("✓✓", " [A] Always allow", self.theme.palette.primary, 1),
|
||||
("✗", " [d] Deny", self.theme.palette.error, 2),
|
||||
("?", " [?] Explain", self.theme.palette.info, 3),
|
||||
];
|
||||
|
||||
for (icon, text, color, idx) in options.iter() {
|
||||
let (style, prefix) = if self.selected == *idx {
|
||||
(
|
||||
self.theme.selected,
|
||||
"▶ "
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(*color),
|
||||
" "
|
||||
)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(*icon, style),
|
||||
Span::styled(*text, style),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), sections[3 + idx]);
|
||||
}
|
||||
|
||||
// Help text at bottom
|
||||
let help_line = Line::from(vec![
|
||||
Span::styled(
|
||||
"↑↓ Navigate Enter to select Esc to deny",
|
||||
Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(help_line), sections[7]);
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionOption {
|
||||
pub fn to_decision(&self) -> Option<PermissionDecision> {
|
||||
match self {
|
||||
PermissionOption::AllowOnce => Some(PermissionDecision::Allow),
|
||||
PermissionOption::AlwaysAllow => Some(PermissionDecision::Allow),
|
||||
PermissionOption::Deny => Some(PermissionDecision::Deny),
|
||||
PermissionOption::Explain => None, // Special handling needed
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_persist(&self) -> bool {
|
||||
matches!(self, PermissionOption::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
337
crates/app/ui/src/components/plan_panel.rs
Normal file
337
crates/app/ui/src/components/plan_panel.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! Plan panel component for displaying accumulated plan steps
|
||||
//!
|
||||
//! Shows the current plan with steps, approval status, and keybindings.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
Frame,
|
||||
};
|
||||
use tools_plan::{AccumulatedPlan, AccumulatedPlanStatus, PlanStep};
|
||||
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Plan panel component for displaying and interacting with accumulated plans
|
||||
pub struct PlanPanel {
|
||||
theme: Theme,
|
||||
/// Currently selected step index
|
||||
selected: usize,
|
||||
/// Scroll offset for long lists
|
||||
scroll_offset: usize,
|
||||
/// Whether the panel is visible
|
||||
visible: bool,
|
||||
}
|
||||
|
||||
impl PlanPanel {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
theme,
|
||||
selected: 0,
|
||||
scroll_offset: 0,
|
||||
visible: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the panel
|
||||
pub fn show(&mut self) {
|
||||
self.visible = true;
|
||||
self.selected = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Hide the panel
|
||||
pub fn hide(&mut self) {
|
||||
self.visible = false;
|
||||
}
|
||||
|
||||
/// Check if visible
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.visible
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Move selection up
|
||||
pub fn select_prev(&mut self) {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
// Adjust scroll if needed
|
||||
if self.selected < self.scroll_offset {
|
||||
self.scroll_offset = self.selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection down
|
||||
pub fn select_next(&mut self, total_steps: usize) {
|
||||
if self.selected < total_steps.saturating_sub(1) {
|
||||
self.selected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently selected step index
|
||||
pub fn selected_index(&self) -> usize {
|
||||
self.selected
|
||||
}
|
||||
|
||||
/// Reset selection to first step
|
||||
pub fn reset_selection(&mut self) {
|
||||
self.selected = 0;
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// Get the minimum height needed for the panel
|
||||
pub fn min_height(&self) -> u16 {
|
||||
if self.visible { 10 } else { 0 }
|
||||
}
|
||||
|
||||
/// Render the plan panel
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, plan: Option<&AccumulatedPlan>) {
|
||||
if !self.visible {
|
||||
return;
|
||||
}
|
||||
|
||||
let plan = match plan {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
// Show "no plan" message
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" PLAN ")
|
||||
.border_style(self.theme.border);
|
||||
let paragraph = Paragraph::new("No active plan")
|
||||
.style(self.theme.status_dim)
|
||||
.block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Title line with status
|
||||
let status_str = match &plan.status {
|
||||
AccumulatedPlanStatus::Accumulating => "Accumulating".to_string(),
|
||||
AccumulatedPlanStatus::Reviewing => "Reviewing".to_string(),
|
||||
AccumulatedPlanStatus::Executing => "Executing".to_string(),
|
||||
AccumulatedPlanStatus::ExecutingParallel { active_count, remaining } => {
|
||||
format!("Executing ({} active, {} left)", active_count, remaining)
|
||||
}
|
||||
AccumulatedPlanStatus::Paused(reason) => {
|
||||
match reason {
|
||||
tools_plan::PauseReason::StepFailed { .. } => "Paused (Step Failed)".to_string(),
|
||||
tools_plan::PauseReason::AwaitingPermission { .. } => "Paused (Permission)".to_string(),
|
||||
}
|
||||
}
|
||||
AccumulatedPlanStatus::Completed => "Completed".to_string(),
|
||||
AccumulatedPlanStatus::Cancelled => "Cancelled".to_string(),
|
||||
AccumulatedPlanStatus::Aborted { reason } => format!("Aborted: {}", reason),
|
||||
};
|
||||
let status_color = match &plan.status {
|
||||
AccumulatedPlanStatus::Accumulating => self.theme.palette.warning,
|
||||
AccumulatedPlanStatus::Reviewing => self.theme.palette.info,
|
||||
AccumulatedPlanStatus::Executing | AccumulatedPlanStatus::ExecutingParallel { .. } => {
|
||||
self.theme.palette.success
|
||||
}
|
||||
AccumulatedPlanStatus::Paused(_) => self.theme.palette.warning,
|
||||
AccumulatedPlanStatus::Completed => self.theme.palette.success,
|
||||
AccumulatedPlanStatus::Cancelled | AccumulatedPlanStatus::Aborted { .. } => {
|
||||
self.theme.palette.error
|
||||
}
|
||||
};
|
||||
|
||||
let name = plan.name.as_deref().unwrap_or("unnamed");
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {} ", name),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" | ", self.theme.status_dim),
|
||||
Span::styled(status_str, Style::default().fg(status_color)),
|
||||
Span::styled(
|
||||
format!(" ({} steps)", plan.steps.len()),
|
||||
self.theme.status_dim,
|
||||
),
|
||||
]));
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from("─".repeat(area.width.saturating_sub(2) as usize)));
|
||||
|
||||
// Steps
|
||||
if plan.steps.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No steps yet...",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
} else {
|
||||
let visible_height = area.height.saturating_sub(6) as usize; // Account for header, footer, borders
|
||||
let start = self.scroll_offset;
|
||||
let end = (start + visible_height).min(plan.steps.len());
|
||||
|
||||
for (idx, step) in plan.steps.iter().enumerate().skip(start).take(end - start) {
|
||||
let is_selected = idx == self.selected;
|
||||
let line = self.render_step(step, idx + 1, is_selected);
|
||||
lines.push(line);
|
||||
|
||||
// Show rationale if selected and available
|
||||
if is_selected {
|
||||
if let Some(rationale) = &step.rationale {
|
||||
let truncated = if rationale.len() > 60 {
|
||||
format!("{}...", &rationale[..57])
|
||||
} else {
|
||||
rationale.clone()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("\"{}\"", truncated),
|
||||
Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer with keybindings
|
||||
lines.push(Line::from("─".repeat(area.width.saturating_sub(2) as usize)));
|
||||
|
||||
let keybinds = match plan.status {
|
||||
AccumulatedPlanStatus::Accumulating => {
|
||||
"[F]inalize [C]ancel"
|
||||
}
|
||||
AccumulatedPlanStatus::Reviewing => {
|
||||
"[j/k]nav [Space]toggle [A]pprove all [R]eject all [Enter]execute [S]ave [C]ancel"
|
||||
}
|
||||
_ => "[C]lose",
|
||||
};
|
||||
lines.push(Line::from(Span::styled(keybinds, self.theme.status_dim)));
|
||||
|
||||
// Create block with border
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" PLAN ")
|
||||
.border_style(self.theme.border);
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
// Scrollbar if needed
|
||||
if plan.steps.len() > (area.height.saturating_sub(6) as usize) {
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(Some("↑"))
|
||||
.end_symbol(Some("↓"));
|
||||
let mut scrollbar_state = ScrollbarState::new(plan.steps.len())
|
||||
.position(self.selected);
|
||||
frame.render_stateful_widget(
|
||||
scrollbar,
|
||||
area.inner(ratatui::layout::Margin { vertical: 2, horizontal: 0 }),
|
||||
&mut scrollbar_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single step line
|
||||
fn render_step(&self, step: &PlanStep, num: usize, selected: bool) -> Line<'static> {
|
||||
let checkbox = match step.approved {
|
||||
None => "☐", // Pending
|
||||
Some(true) => "☑", // Approved
|
||||
Some(false) => "☒", // Rejected
|
||||
};
|
||||
|
||||
let checkbox_color = match step.approved {
|
||||
None => self.theme.palette.warning,
|
||||
Some(true) => self.theme.palette.success,
|
||||
Some(false) => self.theme.palette.error,
|
||||
};
|
||||
|
||||
// Truncate args for display
|
||||
let args_str = step.args.to_string();
|
||||
let args_display = if args_str.len() > 40 {
|
||||
format!("{}...", &args_str[..37])
|
||||
} else {
|
||||
args_str
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(
|
||||
if selected { ">" } else { " " },
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(format!("[{}] ", num), self.theme.status_dim),
|
||||
Span::styled(checkbox, Style::default().fg(checkbox_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{:<10}", step.tool),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(args_display, self.theme.status_dim),
|
||||
];
|
||||
|
||||
if selected {
|
||||
// Highlight the entire line for selected step
|
||||
for span in &mut spans {
|
||||
span.style = span.style.add_modifier(Modifier::REVERSED);
|
||||
}
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme::Theme;
|
||||
|
||||
#[test]
|
||||
fn test_plan_panel_creation() {
|
||||
let theme = Theme::default();
|
||||
let panel = PlanPanel::new(theme);
|
||||
assert!(!panel.is_visible());
|
||||
assert_eq!(panel.selected_index(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_panel_navigation() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = PlanPanel::new(theme);
|
||||
panel.show();
|
||||
|
||||
// Can't go below 0
|
||||
panel.select_prev();
|
||||
assert_eq!(panel.selected_index(), 0);
|
||||
|
||||
// Navigate down
|
||||
panel.select_next(5);
|
||||
assert_eq!(panel.selected_index(), 1);
|
||||
panel.select_next(5);
|
||||
assert_eq!(panel.selected_index(), 2);
|
||||
|
||||
// Can't go past end
|
||||
panel.select_next(3);
|
||||
panel.select_next(3);
|
||||
assert_eq!(panel.selected_index(), 2);
|
||||
|
||||
// Navigate back up
|
||||
panel.select_prev();
|
||||
assert_eq!(panel.selected_index(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plan_panel_visibility() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = PlanPanel::new(theme);
|
||||
|
||||
assert!(!panel.is_visible());
|
||||
panel.show();
|
||||
assert!(panel.is_visible());
|
||||
panel.hide();
|
||||
assert!(!panel.is_visible());
|
||||
}
|
||||
}
|
||||
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Provider tabs component for multi-LLM support
|
||||
//!
|
||||
//! Displays horizontal tabs for switching between providers (Claude, Ollama, OpenAI)
|
||||
//! with icons and keybind hints.
|
||||
|
||||
use crate::theme::{Provider, Theme};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Provider tab state and rendering
|
||||
pub struct ProviderTabs {
|
||||
active: Provider,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl ProviderTabs {
|
||||
/// Create new provider tabs with default provider
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
active: Provider::Ollama, // Default to Ollama (local)
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific active provider
|
||||
pub fn with_provider(provider: Provider, theme: Theme) -> Self {
|
||||
Self {
|
||||
active: provider,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently active provider
|
||||
pub fn active(&self) -> Provider {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Set the active provider
|
||||
pub fn set_active(&mut self, provider: Provider) {
|
||||
self.active = provider;
|
||||
}
|
||||
|
||||
/// Cycle to the next provider
|
||||
pub fn next(&mut self) {
|
||||
self.active = match self.active {
|
||||
Provider::Claude => Provider::Ollama,
|
||||
Provider::Ollama => Provider::OpenAI,
|
||||
Provider::OpenAI => Provider::Claude,
|
||||
};
|
||||
}
|
||||
|
||||
/// Cycle to the previous provider
|
||||
pub fn previous(&mut self) {
|
||||
self.active = match self.active {
|
||||
Provider::Claude => Provider::OpenAI,
|
||||
Provider::Ollama => Provider::Claude,
|
||||
Provider::OpenAI => Provider::Ollama,
|
||||
};
|
||||
}
|
||||
|
||||
/// Select provider by number (1, 2, 3)
|
||||
pub fn select_by_number(&mut self, num: u8) {
|
||||
self.active = match num {
|
||||
1 => Provider::Claude,
|
||||
2 => Provider::Ollama,
|
||||
3 => Provider::OpenAI,
|
||||
_ => self.active,
|
||||
};
|
||||
}
|
||||
|
||||
/// Update the theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Render the provider tabs (borderless)
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Add spacing at start
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
for (i, provider) in Provider::all().iter().enumerate() {
|
||||
let is_active = *provider == self.active;
|
||||
let icon = self.theme.provider_icon(*provider);
|
||||
let name = provider.name();
|
||||
let number = (i + 1).to_string();
|
||||
|
||||
// Keybind hint
|
||||
spans.push(Span::styled(
|
||||
format!("[{}] ", number),
|
||||
self.theme.status_dim,
|
||||
));
|
||||
|
||||
// Icon and name
|
||||
let style = if is_active {
|
||||
Style::default()
|
||||
.fg(self.theme.provider_color(*provider))
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
self.theme.tab_inactive
|
||||
};
|
||||
|
||||
spans.push(Span::styled(format!("{} ", icon), style));
|
||||
spans.push(Span::styled(name.to_string(), style));
|
||||
|
||||
// Separator between tabs (not after last)
|
||||
if i < Provider::all().len() - 1 {
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", self.theme.symbols.vertical_separator),
|
||||
self.theme.status_dim,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tab cycling hint on the right
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("[Tab] cycle", self.theme.status_dim));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Render a compact version (just active provider)
|
||||
pub fn render_compact(&self, frame: &mut Frame, area: Rect) {
|
||||
let icon = self.theme.provider_icon(self.active);
|
||||
let name = self.active.name();
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{} {}", icon, name),
|
||||
Style::default()
|
||||
.fg(self.theme.provider_color(self.active))
|
||||
.add_modifier(ratatui::style::Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_cycling() {
|
||||
let theme = Theme::default();
|
||||
let mut tabs = ProviderTabs::new(theme);
|
||||
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::Claude);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_by_number() {
|
||||
let theme = Theme::default();
|
||||
let mut tabs = ProviderTabs::new(theme);
|
||||
|
||||
tabs.select_by_number(1);
|
||||
assert_eq!(tabs.active(), Provider::Claude);
|
||||
|
||||
tabs.select_by_number(2);
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
|
||||
tabs.select_by_number(3);
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
|
||||
// Invalid number should not change
|
||||
tabs.select_by_number(4);
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
}
|
||||
}
|
||||
208
crates/app/ui/src/components/status_bar.rs
Normal file
208
crates/app/ui/src/components/status_bar.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! Minimal status bar component
|
||||
//!
|
||||
//! Clean, readable status bar with essential info only.
|
||||
//! Format: ` Mode │ N msgs │ ~Nk tok │ state`
|
||||
|
||||
use crate::theme::{Provider, Theme, VimMode};
|
||||
use agent_core::SessionStats;
|
||||
use permissions::Mode;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Style, Stylize},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Application state for status display
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppState {
|
||||
Idle,
|
||||
Streaming,
|
||||
WaitingPermission,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
AppState::Idle => "idle",
|
||||
AppState::Streaming => "streaming...",
|
||||
AppState::WaitingPermission => "waiting",
|
||||
AppState::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StatusBar {
|
||||
provider: Provider,
|
||||
model: String,
|
||||
mode: Mode,
|
||||
vim_mode: VimMode,
|
||||
stats: SessionStats,
|
||||
last_tool: Option<String>,
|
||||
state: AppState,
|
||||
estimated_cost: f64,
|
||||
planning_mode: bool,
|
||||
theme: Theme,
|
||||
pending_permission: Option<String>,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
pub fn new(model: String, mode: Mode, theme: Theme) -> Self {
|
||||
Self {
|
||||
provider: Provider::Ollama, // Default provider
|
||||
model,
|
||||
mode,
|
||||
vim_mode: VimMode::Insert,
|
||||
stats: SessionStats::new(),
|
||||
last_tool: None,
|
||||
state: AppState::Idle,
|
||||
estimated_cost: 0.0,
|
||||
planning_mode: false,
|
||||
theme,
|
||||
pending_permission: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set pending permission tool name
|
||||
pub fn set_pending_permission(&mut self, tool: Option<String>) {
|
||||
self.pending_permission = tool;
|
||||
}
|
||||
|
||||
/// Set the active provider
|
||||
pub fn set_provider(&mut self, provider: Provider) {
|
||||
self.provider = provider;
|
||||
}
|
||||
|
||||
/// Set the current model
|
||||
pub fn set_model(&mut self, model: String) {
|
||||
self.model = model;
|
||||
}
|
||||
|
||||
/// Update session stats
|
||||
pub fn update_stats(&mut self, stats: SessionStats) {
|
||||
self.stats = stats;
|
||||
}
|
||||
|
||||
/// Set the last used tool
|
||||
pub fn set_last_tool(&mut self, tool: String) {
|
||||
self.last_tool = Some(tool);
|
||||
}
|
||||
|
||||
/// Set application state
|
||||
pub fn set_state(&mut self, state: AppState) {
|
||||
self.state = state;
|
||||
}
|
||||
|
||||
/// Set vim mode for display
|
||||
pub fn set_vim_mode(&mut self, mode: VimMode) {
|
||||
self.vim_mode = mode;
|
||||
}
|
||||
|
||||
/// Add to estimated cost
|
||||
pub fn add_cost(&mut self, cost: f64) {
|
||||
self.estimated_cost += cost;
|
||||
}
|
||||
|
||||
/// Reset cost
|
||||
pub fn reset_cost(&mut self) {
|
||||
self.estimated_cost = 0.0;
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Set planning mode status
|
||||
pub fn set_planning_mode(&mut self, active: bool) {
|
||||
self.planning_mode = active;
|
||||
}
|
||||
|
||||
/// Render the minimal status bar
|
||||
///
|
||||
/// Format: ` Mode │ N msgs │ ~Nk tok │ state`
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let sep = self.theme.symbols.vertical_separator;
|
||||
let sep_style = Style::default().fg(self.theme.palette.border);
|
||||
|
||||
// If waiting for permission, show prompt instead of normal status
|
||||
if let (AppState::WaitingPermission, Some(tool)) = (self.state, &self.pending_permission) {
|
||||
let prompt_spans = vec![
|
||||
Span::styled(" ALLOW TOOL: ", self.theme.status_dim),
|
||||
Span::styled(tool, Style::default().fg(self.theme.palette.warning).bold()),
|
||||
Span::styled("? (y/n/a/e) ", self.theme.status_dim),
|
||||
];
|
||||
let line = Line::from(prompt_spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
return;
|
||||
}
|
||||
|
||||
// Permission mode
|
||||
let mode_str = if self.planning_mode {
|
||||
"PLAN"
|
||||
} else {
|
||||
match self.mode {
|
||||
Mode::Plan => "Plan",
|
||||
Mode::AcceptEdits => "Edit",
|
||||
Mode::Code => "Code",
|
||||
}
|
||||
};
|
||||
|
||||
// Format token count
|
||||
let tokens_str = if self.stats.estimated_tokens >= 1000 {
|
||||
format!("~{}k tok", self.stats.estimated_tokens / 1000)
|
||||
} else {
|
||||
format!("~{} tok", self.stats.estimated_tokens)
|
||||
};
|
||||
|
||||
// State style - only highlight non-idle states
|
||||
let state_style = match self.state {
|
||||
AppState::Idle => self.theme.status_dim,
|
||||
AppState::Streaming => Style::default().fg(self.theme.palette.success),
|
||||
AppState::WaitingPermission => Style::default().fg(self.theme.palette.warning),
|
||||
AppState::Error => Style::default().fg(self.theme.palette.error),
|
||||
};
|
||||
|
||||
// Build minimal status line
|
||||
let spans = vec![
|
||||
Span::styled(" ", self.theme.status_dim),
|
||||
// Mode
|
||||
Span::styled(mode_str, self.theme.status_dim),
|
||||
Span::styled(format!(" {} ", sep), sep_style),
|
||||
// Message count
|
||||
Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_dim),
|
||||
Span::styled(format!(" {} ", sep), sep_style),
|
||||
// Token count
|
||||
Span::styled(&tokens_str, self.theme.status_dim),
|
||||
Span::styled(format!(" {} ", sep), sep_style),
|
||||
// State
|
||||
Span::styled(self.state.label(), state_style),
|
||||
];
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_creation() {
|
||||
let theme = Theme::default();
|
||||
let status_bar = StatusBar::new("gpt-4".to_string(), Mode::Plan, theme);
|
||||
assert_eq!(status_bar.model, "gpt-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_display() {
|
||||
assert_eq!(AppState::Idle.label(), "idle");
|
||||
assert_eq!(AppState::Streaming.label(), "streaming...");
|
||||
assert_eq!(AppState::Error.label(), "error");
|
||||
}
|
||||
}
|
||||
200
crates/app/ui/src/components/todo_panel.rs
Normal file
200
crates/app/ui/src/components/todo_panel.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! Todo panel component for displaying task list
|
||||
//!
|
||||
//! Shows the current todo list with status indicators and progress.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
use tools_todo::{Todo, TodoList, TodoStatus};
|
||||
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// Todo panel component
|
||||
pub struct TodoPanel {
|
||||
theme: Theme,
|
||||
collapsed: bool,
|
||||
}
|
||||
|
||||
impl TodoPanel {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
theme,
|
||||
collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle collapsed state
|
||||
pub fn toggle(&mut self) {
|
||||
self.collapsed = !self.collapsed;
|
||||
}
|
||||
|
||||
/// Check if collapsed
|
||||
pub fn is_collapsed(&self) -> bool {
|
||||
self.collapsed
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Get the minimum height needed for the panel
|
||||
pub fn min_height(&self) -> u16 {
|
||||
if self.collapsed {
|
||||
1
|
||||
} else {
|
||||
5
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the todo panel
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
|
||||
if self.collapsed {
|
||||
self.render_collapsed(frame, area, todos);
|
||||
} else {
|
||||
self.render_expanded(frame, area, todos);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render collapsed view (single line summary)
|
||||
fn render_collapsed(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
|
||||
let items = todos.read();
|
||||
let completed = items.iter().filter(|t| t.status == TodoStatus::Completed).count();
|
||||
let in_progress = items.iter().filter(|t| t.status == TodoStatus::InProgress).count();
|
||||
let pending = items.iter().filter(|t| t.status == TodoStatus::Pending).count();
|
||||
|
||||
let summary = if items.is_empty() {
|
||||
"No tasks".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} {} / {} {} / {} {}",
|
||||
self.theme.symbols.check, completed,
|
||||
self.theme.symbols.streaming, in_progress,
|
||||
self.theme.symbols.bullet, pending
|
||||
)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled("Tasks: ", self.theme.status_bar),
|
||||
Span::styled(summary, self.theme.status_dim),
|
||||
Span::styled(" [t to expand]", self.theme.status_dim),
|
||||
]);
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Render expanded view with task list
|
||||
fn render_expanded(&self, frame: &mut Frame, area: Rect, todos: &TodoList) {
|
||||
let items = todos.read();
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Header
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("Tasks", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::styled(" [t to collapse]", self.theme.status_dim),
|
||||
]));
|
||||
|
||||
if items.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No active tasks",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
} else {
|
||||
// Show tasks (limit to available space)
|
||||
let max_items = (area.height as usize).saturating_sub(2);
|
||||
let display_items: Vec<&Todo> = items.iter().take(max_items).collect();
|
||||
|
||||
for item in display_items {
|
||||
let (icon, style) = match item.status {
|
||||
TodoStatus::Completed => (
|
||||
self.theme.symbols.check,
|
||||
Style::default().fg(Color::Green),
|
||||
),
|
||||
TodoStatus::InProgress => (
|
||||
self.theme.symbols.streaming,
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
TodoStatus::Pending => (
|
||||
self.theme.symbols.bullet,
|
||||
self.theme.status_dim,
|
||||
),
|
||||
};
|
||||
|
||||
// Use active form for in-progress, content for others
|
||||
let text = if item.status == TodoStatus::InProgress {
|
||||
&item.active_form
|
||||
} else {
|
||||
&item.content
|
||||
};
|
||||
|
||||
// Truncate if too long
|
||||
let max_width = area.width.saturating_sub(6) as usize;
|
||||
let display_text = if text.len() > max_width {
|
||||
format!("{}...", &text[..max_width.saturating_sub(3)])
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", icon), style),
|
||||
Span::styled(display_text, style),
|
||||
]));
|
||||
}
|
||||
|
||||
// Show overflow indicator if needed
|
||||
if items.len() > max_items {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" ... and {} more", items.len() - max_items),
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(self.theme.status_dim);
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_todo_panel_creation() {
|
||||
let theme = Theme::default();
|
||||
let panel = TodoPanel::new(theme);
|
||||
assert!(!panel.is_collapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_todo_panel_toggle() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = TodoPanel::new(theme);
|
||||
|
||||
assert!(!panel.is_collapsed());
|
||||
panel.toggle();
|
||||
assert!(panel.is_collapsed());
|
||||
panel.toggle();
|
||||
assert!(!panel.is_collapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_height() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = TodoPanel::new(theme);
|
||||
|
||||
assert_eq!(panel.min_height(), 5);
|
||||
panel.toggle();
|
||||
assert_eq!(panel.min_height(), 1);
|
||||
}
|
||||
}
|
||||
62
crates/app/ui/src/events.rs
Normal file
62
crates/app/ui/src/events.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use llm_core::ProviderType;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Application events that drive the TUI
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppEvent {
|
||||
/// User input from keyboard
|
||||
Input(KeyEvent),
|
||||
/// User submitted a message
|
||||
UserMessage(String),
|
||||
/// LLM streaming started
|
||||
StreamStart,
|
||||
/// LLM response chunk (streaming)
|
||||
LlmChunk(String),
|
||||
/// LLM streaming completed
|
||||
StreamEnd { response: String },
|
||||
/// LLM streaming error
|
||||
StreamError(String),
|
||||
/// Tool call started
|
||||
ToolCall { name: String, args: Value },
|
||||
/// Tool execution result
|
||||
ToolResult { success: bool, output: String },
|
||||
/// Permission request from agent
|
||||
PermissionRequest {
|
||||
tool: String,
|
||||
context: Option<String>,
|
||||
},
|
||||
/// Session statistics updated
|
||||
StatusUpdate(agent_core::SessionStats),
|
||||
/// Terminal was resized
|
||||
Resize { width: u16, height: u16 },
|
||||
/// Mouse scroll up
|
||||
ScrollUp,
|
||||
/// Mouse scroll down
|
||||
ScrollDown,
|
||||
/// Toggle the todo panel
|
||||
ToggleTodo,
|
||||
/// Message from the background engine
|
||||
EngineMessage(agent_core::messages::Message),
|
||||
/// Switch to a specific provider
|
||||
SwitchProvider(ProviderType),
|
||||
/// Cycle to the next provider (Tab key)
|
||||
CycleProvider,
|
||||
/// Open model picker
|
||||
OpenModelPicker,
|
||||
/// Application should quit
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Process keyboard input into app events
|
||||
pub fn handle_key_event(key: KeyEvent) -> Option<AppEvent> {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(AppEvent::Quit)
|
||||
}
|
||||
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(AppEvent::ToggleTodo)
|
||||
}
|
||||
_ => Some(AppEvent::Input(key)),
|
||||
}
|
||||
}
|
||||
532
crates/app/ui/src/formatting.rs
Normal file
532
crates/app/ui/src/formatting.rs
Normal file
@@ -0,0 +1,532 @@
|
||||
//! Output formatting with markdown parsing and syntax highlighting
|
||||
//!
|
||||
//! This module provides rich text rendering for the TUI, converting markdown
|
||||
//! content into styled ratatui spans with proper syntax highlighting for code blocks.
|
||||
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{Theme, ThemeSet};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
/// Highlighter for syntax highlighting code blocks
|
||||
pub struct SyntaxHighlighter {
|
||||
syntax_set: SyntaxSet,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl SyntaxHighlighter {
|
||||
/// Create a new syntax highlighter with default theme
|
||||
pub fn new() -> Self {
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
// Use a dark theme that works well in terminals
|
||||
let theme = theme_set.themes["base16-ocean.dark"].clone();
|
||||
|
||||
Self { syntax_set, theme }
|
||||
}
|
||||
|
||||
/// Create highlighter with a specific theme name
|
||||
pub fn with_theme(theme_name: &str) -> Self {
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
let theme = theme_set
|
||||
.themes
|
||||
.get(theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| theme_set.themes["base16-ocean.dark"].clone());
|
||||
|
||||
Self { syntax_set, theme }
|
||||
}
|
||||
|
||||
/// Get available theme names
|
||||
pub fn available_themes() -> Vec<&'static str> {
|
||||
vec![
|
||||
"base16-ocean.dark",
|
||||
"base16-eighties.dark",
|
||||
"base16-mocha.dark",
|
||||
"base16-ocean.light",
|
||||
"InspiredGitHub",
|
||||
"Solarized (dark)",
|
||||
"Solarized (light)",
|
||||
]
|
||||
}
|
||||
|
||||
/// Highlight a code block and return styled lines
|
||||
pub fn highlight_code(&self, code: &str, language: &str) -> Vec<Line<'static>> {
|
||||
// Find syntax for the language
|
||||
let syntax = self
|
||||
.syntax_set
|
||||
.find_syntax_by_token(language)
|
||||
.or_else(|| self.syntax_set.find_syntax_by_extension(language))
|
||||
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
|
||||
|
||||
let mut highlighter = HighlightLines::new(syntax, &self.theme);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for line in LinesWithEndings::from(code) {
|
||||
let Ok(ranges) = highlighter.highlight_line(line, &self.syntax_set) else {
|
||||
// Fallback to plain text if highlighting fails
|
||||
lines.push(Line::from(Span::raw(line.trim_end().to_string())));
|
||||
continue;
|
||||
};
|
||||
|
||||
let spans: Vec<Span<'static>> = ranges
|
||||
.into_iter()
|
||||
.map(|(style, text)| {
|
||||
let fg = syntect_to_ratatui_color(style.foreground);
|
||||
let ratatui_style = Style::default().fg(fg);
|
||||
Span::styled(text.trim_end_matches('\n').to_string(), ratatui_style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SyntaxHighlighter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert syntect color to ratatui color
|
||||
fn syntect_to_ratatui_color(color: syntect::highlighting::Color) -> Color {
|
||||
Color::Rgb(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
/// Parsed markdown content ready for rendering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormattedContent {
|
||||
pub lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl FormattedContent {
|
||||
/// Create empty formatted content
|
||||
pub fn empty() -> Self {
|
||||
Self { lines: Vec::new() }
|
||||
}
|
||||
|
||||
/// Get the number of lines
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
/// Check if content is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown parser that converts markdown to styled ratatui lines
|
||||
pub struct MarkdownRenderer {
|
||||
highlighter: SyntaxHighlighter,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
/// Create a new markdown renderer
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
highlighter: SyntaxHighlighter::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create renderer with custom highlighter
|
||||
pub fn with_highlighter(highlighter: SyntaxHighlighter) -> Self {
|
||||
Self { highlighter }
|
||||
}
|
||||
|
||||
/// Render markdown text to formatted content
|
||||
pub fn render(&self, markdown: &str) -> FormattedContent {
|
||||
let parser = Parser::new(markdown);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut current_line_spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
// State tracking
|
||||
let mut in_code_block = false;
|
||||
let mut code_block_lang = String::new();
|
||||
let mut code_block_content = String::new();
|
||||
let mut current_style = Style::default();
|
||||
let mut list_depth: usize = 0;
|
||||
let mut ordered_list_index: Option<u64> = None;
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Heading { level, .. } => {
|
||||
// Flush current line
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
// Style for headings
|
||||
current_style = match level {
|
||||
pulldown_cmark::HeadingLevel::H1 => Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
pulldown_cmark::HeadingLevel::H2 => Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
pulldown_cmark::HeadingLevel::H3 => Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
_ => Style::default().add_modifier(Modifier::BOLD),
|
||||
};
|
||||
// Add heading prefix
|
||||
let prefix = "#".repeat(level as usize);
|
||||
current_line_spans.push(Span::styled(
|
||||
format!("{} ", prefix),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
Tag::Paragraph => {
|
||||
// Start a new paragraph
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
}
|
||||
Tag::CodeBlock(kind) => {
|
||||
in_code_block = true;
|
||||
code_block_content.clear();
|
||||
code_block_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) => lang.to_string(),
|
||||
CodeBlockKind::Indented => String::new(),
|
||||
};
|
||||
// Flush current line and add code block header
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
// Add code fence line
|
||||
let fence_line = if code_block_lang.is_empty() {
|
||||
"```".to_string()
|
||||
} else {
|
||||
format!("```{}", code_block_lang)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
fence_line,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
Tag::List(start) => {
|
||||
list_depth += 1;
|
||||
ordered_list_index = start;
|
||||
}
|
||||
Tag::Item => {
|
||||
// Flush current line
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
// Add list marker
|
||||
let indent = " ".repeat(list_depth.saturating_sub(1));
|
||||
let marker = if let Some(idx) = ordered_list_index {
|
||||
ordered_list_index = Some(idx + 1);
|
||||
format!("{}{}. ", indent, idx)
|
||||
} else {
|
||||
format!("{}- ", indent)
|
||||
};
|
||||
current_line_spans.push(Span::styled(
|
||||
marker,
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
Tag::Emphasis => {
|
||||
current_style = current_style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
Tag::Strong => {
|
||||
current_style = current_style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
Tag::Strikethrough => {
|
||||
current_style = current_style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
Tag::Link { dest_url, .. } => {
|
||||
current_style = Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
// Store URL for later
|
||||
current_line_spans.push(Span::styled(
|
||||
"[",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
// URL will be shown after link text
|
||||
code_block_content = dest_url.to_string();
|
||||
}
|
||||
Tag::BlockQuote(_) => {
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
current_line_spans.push(Span::styled(
|
||||
"│ ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
current_style = Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag_end) => match tag_end {
|
||||
TagEnd::Heading(_) => {
|
||||
current_style = Style::default();
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
TagEnd::Paragraph => {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
lines.push(Line::from("")); // Empty line after paragraph
|
||||
}
|
||||
TagEnd::CodeBlock => {
|
||||
in_code_block = false;
|
||||
// Highlight and add code content
|
||||
let highlighted =
|
||||
self.highlighter.highlight_code(&code_block_content, &code_block_lang);
|
||||
lines.extend(highlighted);
|
||||
// Add closing fence
|
||||
lines.push(Line::from(Span::styled(
|
||||
"```",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
code_block_content.clear();
|
||||
code_block_lang.clear();
|
||||
}
|
||||
TagEnd::List(_) => {
|
||||
list_depth = list_depth.saturating_sub(1);
|
||||
if list_depth == 0 {
|
||||
ordered_list_index = None;
|
||||
}
|
||||
}
|
||||
TagEnd::Item => {
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
}
|
||||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
|
||||
current_style = Style::default();
|
||||
}
|
||||
TagEnd::Link => {
|
||||
current_line_spans.push(Span::styled(
|
||||
"]",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
current_line_spans.push(Span::styled(
|
||||
format!("({})", code_block_content),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
code_block_content.clear();
|
||||
current_style = Style::default();
|
||||
}
|
||||
TagEnd::BlockQuote => {
|
||||
current_style = Style::default();
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
if in_code_block {
|
||||
code_block_content.push_str(&text);
|
||||
} else {
|
||||
current_line_spans.push(Span::styled(text.to_string(), current_style));
|
||||
}
|
||||
}
|
||||
Event::Code(code) => {
|
||||
// Inline code
|
||||
current_line_spans.push(Span::styled(
|
||||
format!("`{}`", code),
|
||||
Style::default().fg(Color::Magenta),
|
||||
));
|
||||
}
|
||||
Event::SoftBreak => {
|
||||
current_line_spans.push(Span::raw(" "));
|
||||
}
|
||||
Event::HardBreak => {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
Event::Rule => {
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
"─".repeat(40),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining content
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(current_line_spans));
|
||||
}
|
||||
|
||||
FormattedContent { lines }
|
||||
}
|
||||
|
||||
/// Render plain text (no markdown parsing)
|
||||
pub fn render_plain(&self, text: &str) -> FormattedContent {
|
||||
let lines = text
|
||||
.lines()
|
||||
.map(|line| Line::from(Span::raw(line.to_string())))
|
||||
.collect();
|
||||
FormattedContent { lines }
|
||||
}
|
||||
|
||||
/// Render a diff with +/- highlighting
|
||||
pub fn render_diff(&self, diff: &str) -> FormattedContent {
|
||||
let lines = diff
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let style = if line.starts_with('+') && !line.starts_with("+++") {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if line.starts_with('-') && !line.starts_with("---") {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if line.starts_with("@@") {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else if line.starts_with("diff ") || line.starts_with("index ") {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::from(Span::styled(line.to_string(), style))
|
||||
})
|
||||
.collect();
|
||||
FormattedContent { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MarkdownRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a file path with syntax highlighting based on extension
|
||||
pub fn format_file_path(path: &str) -> Span<'static> {
|
||||
let color = if path.ends_with(".rs") {
|
||||
Color::Rgb(222, 165, 132) // Rust orange
|
||||
} else if path.ends_with(".toml") {
|
||||
Color::Rgb(156, 220, 254) // Light blue
|
||||
} else if path.ends_with(".md") {
|
||||
Color::Rgb(86, 156, 214) // Blue
|
||||
} else if path.ends_with(".json") {
|
||||
Color::Rgb(206, 145, 120) // Brown
|
||||
} else if path.ends_with(".ts") || path.ends_with(".tsx") {
|
||||
Color::Rgb(49, 120, 198) // TypeScript blue
|
||||
} else if path.ends_with(".js") || path.ends_with(".jsx") {
|
||||
Color::Rgb(241, 224, 90) // JavaScript yellow
|
||||
} else if path.ends_with(".py") {
|
||||
Color::Rgb(55, 118, 171) // Python blue
|
||||
} else if path.ends_with(".go") {
|
||||
Color::Rgb(0, 173, 216) // Go cyan
|
||||
} else if path.ends_with(".sh") || path.ends_with(".bash") {
|
||||
Color::Rgb(137, 224, 81) // Shell green
|
||||
} else {
|
||||
Color::White
|
||||
};
|
||||
|
||||
Span::styled(path.to_string(), Style::default().fg(color))
|
||||
}
|
||||
|
||||
/// Format a tool name with appropriate styling
|
||||
pub fn format_tool_name(name: &str) -> Span<'static> {
|
||||
let style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
Span::styled(name.to_string(), style)
|
||||
}
|
||||
|
||||
/// Format an error message
|
||||
pub fn format_error(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("Error: ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Red)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Format a success message
|
||||
pub fn format_success(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("✓ ", Style::default().fg(Color::Green)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Green)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Format a warning message
|
||||
pub fn format_warning(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("⚠ ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Yellow)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Format an info message
|
||||
pub fn format_info(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("ℹ ", Style::default().fg(Color::Blue)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Blue)),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_syntax_highlighter_creation() {
|
||||
let highlighter = SyntaxHighlighter::new();
|
||||
let lines = highlighter.highlight_code("fn main() {}", "rust");
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_render_heading() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let content = renderer.render("# Hello World");
|
||||
assert!(!content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_render_code_block() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let content = renderer.render("```rust\nfn main() {}\n```");
|
||||
assert!(content.len() >= 3); // Opening fence, code, closing fence
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_render_list() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let content = renderer.render("- Item 1\n- Item 2\n- Item 3");
|
||||
assert!(content.len() >= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_rendering() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let diff = "+added line\n-removed line\n unchanged";
|
||||
let content = renderer.render_diff(diff);
|
||||
assert_eq!(content.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_file_path() {
|
||||
let span = format_file_path("src/main.rs");
|
||||
assert!(span.content.contains("main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_messages() {
|
||||
let error = format_error("Something went wrong");
|
||||
assert!(!error.spans.is_empty());
|
||||
|
||||
let success = format_success("Operation completed");
|
||||
assert!(!success.spans.is_empty());
|
||||
|
||||
let warning = format_warning("Be careful");
|
||||
assert!(!warning.spans.is_empty());
|
||||
|
||||
let info = format_info("FYI");
|
||||
assert!(!info.spans.is_empty());
|
||||
}
|
||||
}
|
||||
225
crates/app/ui/src/layout.rs
Normal file
225
crates/app/ui/src/layout.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! Layout calculation for the borderless TUI
|
||||
//!
|
||||
//! Uses vertical layout with whitespace for visual hierarchy instead of borders:
|
||||
//! - Header row (app name, mode, model, help)
|
||||
//! - Provider tabs
|
||||
//! - Horizontal divider
|
||||
//! - Chat area (scrollable)
|
||||
//! - Horizontal divider
|
||||
//! - Input area
|
||||
//! - Status bar
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Calculated layout areas for the borderless TUI
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AppLayout {
|
||||
/// Header row: app name, mode indicator, model, help hint
|
||||
pub header_area: Rect,
|
||||
/// Provider tabs row
|
||||
pub tabs_area: Rect,
|
||||
/// Top divider (horizontal rule)
|
||||
pub top_divider: Rect,
|
||||
/// Main chat/message area
|
||||
pub chat_area: Rect,
|
||||
/// Todo panel area (optional, between chat and input)
|
||||
pub todo_area: Rect,
|
||||
/// Bottom divider (horizontal rule)
|
||||
pub bottom_divider: Rect,
|
||||
/// Input area for user text
|
||||
pub input_area: Rect,
|
||||
/// Status bar at the bottom
|
||||
pub status_area: Rect,
|
||||
}
|
||||
|
||||
impl AppLayout {
|
||||
/// Calculate layout for the given terminal size
|
||||
pub fn calculate(area: Rect) -> Self {
|
||||
Self::calculate_with_todo(area, 0)
|
||||
}
|
||||
|
||||
/// Calculate layout with todo panel of specified height
|
||||
///
|
||||
/// Layout with provider tabs:
|
||||
/// - Header (1 line)
|
||||
/// - Provider tabs (1 line)
|
||||
/// - Top divider (1 line)
|
||||
/// - Chat area (flexible)
|
||||
/// - Todo panel (optional)
|
||||
/// - Bottom divider (1 line)
|
||||
/// - Input (1 line)
|
||||
/// - Status bar (1 line)
|
||||
pub fn calculate_with_todo(area: Rect, todo_height: u16) -> Self {
|
||||
let chunks = if todo_height > 0 {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Length(1), // Provider tabs
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(todo_height), // Todo panel
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(1), // Input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area)
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Length(1), // Provider tabs
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(0), // No todo panel
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(1), // Input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area)
|
||||
};
|
||||
|
||||
Self {
|
||||
header_area: chunks[0],
|
||||
tabs_area: chunks[1],
|
||||
top_divider: chunks[2],
|
||||
chat_area: chunks[3],
|
||||
todo_area: chunks[4],
|
||||
bottom_divider: chunks[5],
|
||||
input_area: chunks[6],
|
||||
status_area: chunks[7],
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate layout with expanded input (multiline)
|
||||
pub fn calculate_expanded_input(area: Rect, input_lines: u16) -> Self {
|
||||
let input_height = input_lines.min(10).max(1); // Cap at 10 lines
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(0), // No todo panel
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(input_height), // Expanded input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Self {
|
||||
header_area: chunks[0],
|
||||
tabs_area: Rect::default(),
|
||||
top_divider: chunks[1],
|
||||
chat_area: chunks[2],
|
||||
todo_area: chunks[3],
|
||||
bottom_divider: chunks[4],
|
||||
input_area: chunks[5],
|
||||
status_area: chunks[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate layout without tabs (compact mode)
|
||||
pub fn calculate_compact(area: Rect) -> Self {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Header (includes compact provider indicator)
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(0), // No todo panel
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(1), // Input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Self {
|
||||
header_area: chunks[0],
|
||||
tabs_area: Rect::default(), // No tabs area in compact mode
|
||||
top_divider: chunks[1],
|
||||
chat_area: chunks[2],
|
||||
todo_area: chunks[3],
|
||||
bottom_divider: chunks[4],
|
||||
input_area: chunks[5],
|
||||
status_area: chunks[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// Center a popup in the given area
|
||||
pub fn center_popup(area: Rect, width: u16, height: u16) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length((area.height.saturating_sub(height)) / 2),
|
||||
Constraint::Length(height),
|
||||
Constraint::Length((area.height.saturating_sub(height)) / 2),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length((area.width.saturating_sub(width)) / 2),
|
||||
Constraint::Length(width),
|
||||
Constraint::Length((area.width.saturating_sub(width)) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout mode based on terminal width
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LayoutMode {
|
||||
/// Full layout with provider tabs (>= 80 cols)
|
||||
Full,
|
||||
/// Compact layout without tabs (< 80 cols)
|
||||
Compact,
|
||||
}
|
||||
|
||||
impl LayoutMode {
|
||||
/// Determine layout mode based on terminal width
|
||||
pub fn for_width(width: u16) -> Self {
|
||||
if width >= 80 {
|
||||
LayoutMode::Full
|
||||
} else {
|
||||
LayoutMode::Compact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_layout_calculation() {
|
||||
let area = Rect::new(0, 0, 120, 40);
|
||||
let layout = AppLayout::calculate(area);
|
||||
|
||||
// Header should be at top
|
||||
assert_eq!(layout.header_area.y, 0);
|
||||
assert_eq!(layout.header_area.height, 1);
|
||||
|
||||
// Tabs should be after header
|
||||
assert_eq!(layout.tabs_area.y, 1);
|
||||
assert_eq!(layout.tabs_area.height, 1);
|
||||
|
||||
// Status should be at bottom
|
||||
assert_eq!(layout.status_area.y, 39);
|
||||
assert_eq!(layout.status_area.height, 1);
|
||||
|
||||
// Chat area should have most of the space (40 - header - tabs - divider*2 - input - status = 34)
|
||||
assert!(layout.chat_area.height > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_mode() {
|
||||
assert_eq!(LayoutMode::for_width(80), LayoutMode::Full);
|
||||
assert_eq!(LayoutMode::for_width(120), LayoutMode::Full);
|
||||
assert_eq!(LayoutMode::for_width(79), LayoutMode::Compact);
|
||||
assert_eq!(LayoutMode::for_width(60), LayoutMode::Compact);
|
||||
}
|
||||
}
|
||||
49
crates/app/ui/src/lib.rs
Normal file
49
crates/app/ui/src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
pub mod app;
|
||||
pub mod completions;
|
||||
pub mod components;
|
||||
pub mod events;
|
||||
pub mod formatting;
|
||||
pub mod layout;
|
||||
pub mod output;
|
||||
pub mod provider_manager;
|
||||
pub mod theme;
|
||||
|
||||
pub use app::TuiApp;
|
||||
pub use completions::{CompletionEngine, Completion, CommandInfo};
|
||||
pub use events::AppEvent;
|
||||
pub use output::{CommandOutput, OutputFormat, TreeNode, ListItem};
|
||||
pub use formatting::{
|
||||
FormattedContent, MarkdownRenderer, SyntaxHighlighter,
|
||||
format_file_path, format_tool_name, format_error, format_success, format_warning, format_info,
|
||||
};
|
||||
pub use provider_manager::ProviderManager;
|
||||
|
||||
use auth_manager::AuthManager;
|
||||
use color_eyre::eyre::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Run the TUI application with a single provider (legacy mode)
|
||||
pub async fn run(
|
||||
client: Arc<dyn llm_core::LlmProvider>,
|
||||
opts: llm_core::ChatOptions,
|
||||
perms: permissions::PermissionManager,
|
||||
settings: config_agent::Settings,
|
||||
) -> Result<()> {
|
||||
let mut app = TuiApp::new(client, opts, perms, settings)?;
|
||||
app.run().await
|
||||
}
|
||||
|
||||
/// Run the TUI application with multi-provider support and engine integration
|
||||
/// Uses a shared ProviderManager for dynamic provider/model switching
|
||||
pub async fn run_with_providers(
|
||||
provider_manager: Arc<tokio::sync::Mutex<ProviderManager>>,
|
||||
perms: permissions::PermissionManager,
|
||||
settings: config_agent::Settings,
|
||||
engine_tx: tokio::sync::mpsc::Sender<agent_core::messages::Message>,
|
||||
shared_state: Arc<tokio::sync::Mutex<agent_core::state::AppState>>,
|
||||
engine_rx: tokio::sync::mpsc::Receiver<agent_core::messages::Message>,
|
||||
) -> Result<()> {
|
||||
let mut app = TuiApp::with_shared_provider_manager(provider_manager, perms, settings)?;
|
||||
app.set_engine(engine_tx, shared_state, engine_rx);
|
||||
app.run().await
|
||||
}
|
||||
388
crates/app/ui/src/output.rs
Normal file
388
crates/app/ui/src/output.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
//! Rich command output formatting
|
||||
//!
|
||||
//! Provides formatted output for commands like /help, /mcp, /hooks
|
||||
//! with tables, trees, and syntax highlighting.
|
||||
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
use crate::completions::CommandInfo;
|
||||
use crate::theme::Theme;
|
||||
|
||||
/// A tree node for hierarchical display
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeNode {
|
||||
pub label: String,
|
||||
pub children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
impl TreeNode {
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
children: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_children(mut self, children: Vec<TreeNode>) -> Self {
|
||||
self.children = children;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A list item with optional icon/marker
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListItem {
|
||||
pub text: String,
|
||||
pub marker: Option<String>,
|
||||
pub style: Option<Style>,
|
||||
}
|
||||
|
||||
/// Different output formats
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OutputFormat {
|
||||
/// Formatted table with headers and rows
|
||||
Table {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
},
|
||||
/// Hierarchical tree view
|
||||
Tree {
|
||||
root: TreeNode,
|
||||
},
|
||||
/// Syntax-highlighted code block
|
||||
Code {
|
||||
language: String,
|
||||
content: String,
|
||||
},
|
||||
/// Side-by-side diff view
|
||||
Diff {
|
||||
old: String,
|
||||
new: String,
|
||||
},
|
||||
/// Simple list with markers
|
||||
List {
|
||||
items: Vec<ListItem>,
|
||||
},
|
||||
/// Plain text
|
||||
Text {
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Rich command output renderer
|
||||
pub struct CommandOutput {
|
||||
pub format: OutputFormat,
|
||||
}
|
||||
|
||||
impl CommandOutput {
|
||||
pub fn new(format: OutputFormat) -> Self {
|
||||
Self { format }
|
||||
}
|
||||
|
||||
/// Create a help table output
|
||||
pub fn help_table(commands: &[CommandInfo]) -> Self {
|
||||
let headers = vec![
|
||||
"Command".to_string(),
|
||||
"Description".to_string(),
|
||||
"Source".to_string(),
|
||||
];
|
||||
|
||||
let rows: Vec<Vec<String>> = commands
|
||||
.iter()
|
||||
.map(|c| vec![
|
||||
format!("/{}", c.name),
|
||||
c.description.clone(),
|
||||
c.source.clone(),
|
||||
])
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
format: OutputFormat::Table { headers, rows },
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an MCP servers tree view
|
||||
pub fn mcp_tree(servers: &[(String, Vec<String>)]) -> Self {
|
||||
let children: Vec<TreeNode> = servers
|
||||
.iter()
|
||||
.map(|(name, tools)| {
|
||||
TreeNode {
|
||||
label: name.clone(),
|
||||
children: tools.iter().map(|t| TreeNode::new(t)).collect(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
format: OutputFormat::Tree {
|
||||
root: TreeNode {
|
||||
label: "MCP Servers".to_string(),
|
||||
children,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a hooks list output
|
||||
pub fn hooks_list(hooks: &[(String, String, bool)]) -> Self {
|
||||
let items: Vec<ListItem> = hooks
|
||||
.iter()
|
||||
.map(|(event, path, enabled)| {
|
||||
let marker = if *enabled { "✓" } else { "✗" };
|
||||
let style = if *enabled {
|
||||
Some(Style::default().fg(Color::Green))
|
||||
} else {
|
||||
Some(Style::default().fg(Color::Red))
|
||||
};
|
||||
ListItem {
|
||||
text: format!("{}: {}", event, path),
|
||||
marker: Some(marker.to_string()),
|
||||
style,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
format: OutputFormat::List { items },
|
||||
}
|
||||
}
|
||||
|
||||
/// Render to TUI Lines
|
||||
pub fn render(&self, theme: &Theme) -> Vec<Line<'static>> {
|
||||
match &self.format {
|
||||
OutputFormat::Table { headers, rows } => {
|
||||
self.render_table(headers, rows, theme)
|
||||
}
|
||||
OutputFormat::Tree { root } => {
|
||||
self.render_tree(root, 0, theme)
|
||||
}
|
||||
OutputFormat::List { items } => {
|
||||
self.render_list(items, theme)
|
||||
}
|
||||
OutputFormat::Code { content, .. } => {
|
||||
content.lines()
|
||||
.map(|line| Line::from(Span::styled(line.to_string(), theme.tool_call)))
|
||||
.collect()
|
||||
}
|
||||
OutputFormat::Diff { old, new } => {
|
||||
self.render_diff(old, new, theme)
|
||||
}
|
||||
OutputFormat::Text { content } => {
|
||||
content.lines()
|
||||
.map(|line| Line::from(line.to_string()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_table(&self, headers: &[String], rows: &[Vec<String>], theme: &Theme) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Calculate column widths
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
|
||||
for row in rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i < widths.len() {
|
||||
widths[i] = widths[i].max(cell.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Header line
|
||||
let header_spans: Vec<Span> = headers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, h)| {
|
||||
let padded = format!("{:width$}", h, width = widths.get(i).copied().unwrap_or(h.len()));
|
||||
vec![
|
||||
Span::styled(padded, Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" "),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
lines.push(Line::from(header_spans));
|
||||
|
||||
// Separator
|
||||
let sep: String = widths.iter().map(|w| "─".repeat(*w)).collect::<Vec<_>>().join("──");
|
||||
lines.push(Line::from(Span::styled(sep, theme.status_dim)));
|
||||
|
||||
// Rows
|
||||
for row in rows {
|
||||
let row_spans: Vec<Span> = row
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, cell)| {
|
||||
let padded = format!("{:width$}", cell, width = widths.get(i).copied().unwrap_or(cell.len()));
|
||||
let style = if i == 0 {
|
||||
theme.status_accent // Command names in accent color
|
||||
} else {
|
||||
theme.status_bar
|
||||
};
|
||||
vec![
|
||||
Span::styled(padded, style),
|
||||
Span::raw(" "),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
lines.push(Line::from(row_spans));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_tree(&self, node: &TreeNode, depth: usize, theme: &Theme) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Render current node
|
||||
let prefix = if depth == 0 {
|
||||
"".to_string()
|
||||
} else {
|
||||
format!("{}├─ ", "│ ".repeat(depth - 1))
|
||||
};
|
||||
|
||||
let style = if depth == 0 {
|
||||
Style::default().add_modifier(Modifier::BOLD)
|
||||
} else if node.children.is_empty() {
|
||||
theme.status_bar
|
||||
} else {
|
||||
theme.status_accent
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, theme.status_dim),
|
||||
Span::styled(node.label.clone(), style),
|
||||
]));
|
||||
|
||||
// Render children
|
||||
for child in &node.children {
|
||||
lines.extend(self.render_tree(child, depth + 1, theme));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_list(&self, items: &[ListItem], theme: &Theme) -> Vec<Line<'static>> {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let marker_span = if let Some(marker) = &item.marker {
|
||||
Span::styled(
|
||||
format!("{} ", marker),
|
||||
item.style.unwrap_or(theme.status_bar),
|
||||
)
|
||||
} else {
|
||||
Span::raw("• ")
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
marker_span,
|
||||
Span::styled(
|
||||
item.text.clone(),
|
||||
item.style.unwrap_or(theme.status_bar),
|
||||
),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_diff(&self, old: &str, new: &str, _theme: &Theme) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Simple line-by-line diff
|
||||
let old_lines: Vec<&str> = old.lines().collect();
|
||||
let new_lines: Vec<&str> = new.lines().collect();
|
||||
|
||||
let max_len = old_lines.len().max(new_lines.len());
|
||||
|
||||
for i in 0..max_len {
|
||||
let old_line = old_lines.get(i).copied().unwrap_or("");
|
||||
let new_line = new_lines.get(i).copied().unwrap_or("");
|
||||
|
||||
if old_line != new_line {
|
||||
if !old_line.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("- {}", old_line),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
if !new_line.is_empty() {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("+ {}", new_line),
|
||||
Style::default().fg(Color::Green),
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
lines.push(Line::from(format!(" {}", old_line)));
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_help_table() {
|
||||
let commands = vec![
|
||||
CommandInfo::new("help", "Show help", "builtin"),
|
||||
CommandInfo::new("clear", "Clear screen", "builtin"),
|
||||
];
|
||||
let output = CommandOutput::help_table(&commands);
|
||||
|
||||
match output.format {
|
||||
OutputFormat::Table { headers, rows } => {
|
||||
assert_eq!(headers.len(), 3);
|
||||
assert_eq!(rows.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected Table format"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tree() {
|
||||
let servers = vec![
|
||||
("filesystem".to_string(), vec!["read".to_string(), "write".to_string()]),
|
||||
("database".to_string(), vec!["query".to_string()]),
|
||||
];
|
||||
let output = CommandOutput::mcp_tree(&servers);
|
||||
|
||||
match output.format {
|
||||
OutputFormat::Tree { root } => {
|
||||
assert_eq!(root.label, "MCP Servers");
|
||||
assert_eq!(root.children.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected Tree format"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hooks_list() {
|
||||
let hooks = vec![
|
||||
("PreToolUse".to_string(), "./hooks/pre".to_string(), true),
|
||||
("PostToolUse".to_string(), "./hooks/post".to_string(), false),
|
||||
];
|
||||
let output = CommandOutput::hooks_list(&hooks);
|
||||
|
||||
match output.format {
|
||||
OutputFormat::List { items } => {
|
||||
assert_eq!(items.len(), 2);
|
||||
}
|
||||
_ => panic!("Expected List format"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_node() {
|
||||
let node = TreeNode::new("root")
|
||||
.with_children(vec![
|
||||
TreeNode::new("child1"),
|
||||
TreeNode::new("child2"),
|
||||
]);
|
||||
assert_eq!(node.label, "root");
|
||||
assert_eq!(node.children.len(), 2);
|
||||
}
|
||||
}
|
||||
417
crates/app/ui/src/provider_manager.rs
Normal file
417
crates/app/ui/src/provider_manager.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
//! Provider Manager for In-Session Switching
|
||||
//!
|
||||
//! Manages multiple LLM providers with lazy initialization, enabling
|
||||
//! seamless provider switching during a TUI session without restart.
|
||||
|
||||
use auth_manager::AuthManager;
|
||||
use llm_anthropic::AnthropicClient;
|
||||
use llm_core::{AuthMethod, LlmProvider, ModelInfo, ProviderInfo, ProviderType};
|
||||
use llm_ollama::OllamaClient;
|
||||
use llm_openai::OpenAIClient;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Error type for provider operations
|
||||
#[derive(Debug)]
|
||||
pub enum ProviderError {
|
||||
/// Provider requires authentication
|
||||
AuthRequired(String),
|
||||
/// Failed to create provider
|
||||
CreationFailed(String),
|
||||
/// Failed to list models
|
||||
ModelListFailed(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::AuthRequired(msg) => write!(f, "Authentication required: {}", msg),
|
||||
Self::CreationFailed(msg) => write!(f, "Provider creation failed: {}", msg),
|
||||
Self::ModelListFailed(msg) => write!(f, "Failed to list models: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProviderError {}
|
||||
|
||||
/// Manages multiple LLM providers with lazy initialization
|
||||
pub struct ProviderManager {
|
||||
/// Auth manager for retrieving credentials
|
||||
auth_manager: Arc<AuthManager>,
|
||||
|
||||
/// Cached provider clients (created on-demand)
|
||||
providers: HashMap<ProviderType, Arc<dyn LlmProvider>>,
|
||||
|
||||
/// Current model per provider
|
||||
models: HashMap<ProviderType, String>,
|
||||
|
||||
/// Currently active provider
|
||||
current_provider: ProviderType,
|
||||
|
||||
/// Ollama base URL for local instances
|
||||
ollama_url: String,
|
||||
|
||||
/// Settings for fallback API keys
|
||||
settings: config_agent::Settings,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
/// Create a new provider manager
|
||||
pub fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
settings: config_agent::Settings,
|
||||
) -> Self {
|
||||
// Determine initial provider from settings
|
||||
let initial_provider = settings.get_provider().unwrap_or(ProviderType::Ollama);
|
||||
|
||||
// Initialize models from per-provider settings or defaults
|
||||
let mut models = HashMap::new();
|
||||
models.insert(
|
||||
ProviderType::Ollama,
|
||||
settings.get_model_for_provider(ProviderType::Ollama),
|
||||
);
|
||||
models.insert(
|
||||
ProviderType::Anthropic,
|
||||
settings.get_model_for_provider(ProviderType::Anthropic),
|
||||
);
|
||||
models.insert(
|
||||
ProviderType::OpenAI,
|
||||
settings.get_model_for_provider(ProviderType::OpenAI),
|
||||
);
|
||||
|
||||
Self {
|
||||
auth_manager,
|
||||
providers: HashMap::new(),
|
||||
models,
|
||||
current_provider: initial_provider,
|
||||
ollama_url: settings.ollama_url.clone(),
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently active provider type
|
||||
pub fn current_provider_type(&self) -> ProviderType {
|
||||
self.current_provider
|
||||
}
|
||||
|
||||
/// Get the current model for the active provider
|
||||
pub fn current_model(&self) -> &str {
|
||||
self.models
|
||||
.get(&self.current_provider)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(self.current_provider.default_model())
|
||||
}
|
||||
|
||||
/// Get the model for a specific provider
|
||||
pub fn model_for_provider(&self, provider: ProviderType) -> &str {
|
||||
self.models
|
||||
.get(&provider)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or(provider.default_model())
|
||||
}
|
||||
|
||||
/// Set the model for a provider
|
||||
pub fn set_model(&mut self, provider: ProviderType, model: String) {
|
||||
self.models.insert(provider, model.clone());
|
||||
|
||||
// Update settings and save
|
||||
self.settings.set_model_for_provider(provider, &model);
|
||||
if let Err(e) = self.settings.save() {
|
||||
// Log but don't fail - saving is best-effort
|
||||
eprintln!("Warning: Failed to save model preference: {}", e);
|
||||
}
|
||||
|
||||
// If provider is already initialized, we need to recreate it with the new model
|
||||
// For simplicity, just remove it so it will be recreated on next access
|
||||
self.providers.remove(&provider);
|
||||
}
|
||||
|
||||
/// Set the model for the current provider
|
||||
pub fn set_current_model(&mut self, model: String) {
|
||||
self.set_model(self.current_provider, model);
|
||||
}
|
||||
|
||||
/// Check if a provider is authenticated
|
||||
pub fn is_authenticated(&self, provider: ProviderType) -> bool {
|
||||
match provider {
|
||||
ProviderType::Ollama => true, // Local Ollama doesn't need auth
|
||||
_ => self.auth_manager.get_auth(provider).is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the active provider client, creating it if necessary
|
||||
pub fn get_provider(&mut self) -> Result<Arc<dyn LlmProvider>, ProviderError> {
|
||||
self.get_provider_for_type(self.current_provider)
|
||||
}
|
||||
|
||||
/// Get a specific provider client, creating it if necessary
|
||||
pub fn get_provider_for_type(
|
||||
&mut self,
|
||||
provider_type: ProviderType,
|
||||
) -> Result<Arc<dyn LlmProvider>, ProviderError> {
|
||||
// Return cached provider if available
|
||||
if let Some(provider) = self.providers.get(&provider_type) {
|
||||
return Ok(Arc::clone(provider));
|
||||
}
|
||||
|
||||
// Create new provider
|
||||
let model = self.model_for_provider(provider_type).to_string();
|
||||
let provider = self.create_provider(provider_type, &model)?;
|
||||
|
||||
// Cache and return
|
||||
self.providers.insert(provider_type, Arc::clone(&provider));
|
||||
Ok(provider)
|
||||
}
|
||||
|
||||
/// Switch to a different provider
|
||||
pub fn switch_provider(&mut self, provider_type: ProviderType) -> Result<Arc<dyn LlmProvider>, ProviderError> {
|
||||
self.current_provider = provider_type;
|
||||
self.get_provider()
|
||||
}
|
||||
|
||||
/// Create a provider client with authentication
|
||||
fn create_provider(
|
||||
&self,
|
||||
provider_type: ProviderType,
|
||||
model: &str,
|
||||
) -> Result<Arc<dyn LlmProvider>, ProviderError> {
|
||||
match provider_type {
|
||||
ProviderType::Ollama => {
|
||||
// Check for Ollama Cloud vs local
|
||||
let use_cloud = model.ends_with("-cloud");
|
||||
|
||||
let client = if use_cloud {
|
||||
// Try to get Ollama Cloud API key
|
||||
match self.auth_manager.get_auth(ProviderType::Ollama) {
|
||||
Ok(AuthMethod::ApiKey(key)) => {
|
||||
OllamaClient::with_cloud().with_api_key(key)
|
||||
}
|
||||
_ => {
|
||||
return Err(ProviderError::AuthRequired(
|
||||
"Ollama Cloud requires API key. Run 'owlen login ollama'".to_string()
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Local Ollama - no auth needed
|
||||
let mut client = OllamaClient::new(&self.ollama_url);
|
||||
|
||||
// Add API key if available (for authenticated local instances)
|
||||
if let Ok(AuthMethod::ApiKey(key)) = self.auth_manager.get_auth(ProviderType::Ollama) {
|
||||
client = client.with_api_key(key);
|
||||
}
|
||||
|
||||
client
|
||||
};
|
||||
|
||||
Ok(Arc::new(client.with_model(model)) as Arc<dyn LlmProvider>)
|
||||
}
|
||||
|
||||
ProviderType::Anthropic => {
|
||||
// Try auth manager first, then settings fallback
|
||||
let auth = self.auth_manager.get_auth(ProviderType::Anthropic)
|
||||
.ok()
|
||||
.or_else(|| self.settings.anthropic_api_key.clone().map(AuthMethod::ApiKey))
|
||||
.ok_or_else(|| ProviderError::AuthRequired(
|
||||
"Run 'owlen login anthropic' or set ANTHROPIC_API_KEY".to_string()
|
||||
))?;
|
||||
|
||||
let client = AnthropicClient::with_auth(auth).with_model(model);
|
||||
Ok(Arc::new(client) as Arc<dyn LlmProvider>)
|
||||
}
|
||||
|
||||
ProviderType::OpenAI => {
|
||||
// Try auth manager first, then settings fallback
|
||||
let auth = self.auth_manager.get_auth(ProviderType::OpenAI)
|
||||
.ok()
|
||||
.or_else(|| self.settings.openai_api_key.clone().map(AuthMethod::ApiKey))
|
||||
.ok_or_else(|| ProviderError::AuthRequired(
|
||||
"Run 'owlen login openai' or set OPENAI_API_KEY".to_string()
|
||||
))?;
|
||||
|
||||
let client = OpenAIClient::with_auth(auth).with_model(model);
|
||||
Ok(Arc::new(client) as Arc<dyn LlmProvider>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List available models for the current provider
|
||||
pub async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
|
||||
self.list_models_for_provider(self.current_provider).await
|
||||
}
|
||||
|
||||
/// List available models for a specific provider (only tool-capable models)
|
||||
pub async fn list_models_for_provider(
|
||||
&self,
|
||||
provider_type: ProviderType,
|
||||
) -> Result<Vec<ModelInfo>, ProviderError> {
|
||||
let models = match provider_type {
|
||||
ProviderType::Ollama => {
|
||||
// For Ollama, we need to fetch from the API
|
||||
let client = OllamaClient::new(&self.ollama_url);
|
||||
let mut models = client
|
||||
.list_models()
|
||||
.await
|
||||
.map_err(|e| ProviderError::ModelListFailed(e.to_string()))?;
|
||||
|
||||
// Update supports_tools based on known tool-capable model patterns
|
||||
for model in &mut models {
|
||||
model.supports_tools = is_ollama_tool_capable(&model.id);
|
||||
}
|
||||
models
|
||||
}
|
||||
ProviderType::Anthropic => {
|
||||
// Anthropic: return hardcoded list (no API endpoint)
|
||||
let client = AnthropicClient::new("dummy"); // Key not needed for list_models
|
||||
client
|
||||
.list_models()
|
||||
.await
|
||||
.map_err(|e| ProviderError::ModelListFailed(e.to_string()))?
|
||||
}
|
||||
ProviderType::OpenAI => {
|
||||
// OpenAI: return hardcoded list
|
||||
let client = OpenAIClient::new("dummy");
|
||||
client
|
||||
.list_models()
|
||||
.await
|
||||
.map_err(|e| ProviderError::ModelListFailed(e.to_string()))?
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to only tool-capable models
|
||||
Ok(models.into_iter().filter(|m| m.supports_tools).collect())
|
||||
}
|
||||
|
||||
/// Get authentication status for all providers
|
||||
pub fn auth_status(&self) -> Vec<(ProviderType, bool, Option<String>)> {
|
||||
vec![
|
||||
(
|
||||
ProviderType::Ollama,
|
||||
true, // Always "authenticated" for local
|
||||
Some("Local (no auth required)".to_string()),
|
||||
),
|
||||
(
|
||||
ProviderType::Anthropic,
|
||||
self.is_authenticated(ProviderType::Anthropic),
|
||||
if self.is_authenticated(ProviderType::Anthropic) {
|
||||
Some("API key configured".to_string())
|
||||
} else {
|
||||
Some("Not authenticated".to_string())
|
||||
},
|
||||
),
|
||||
(
|
||||
ProviderType::OpenAI,
|
||||
self.is_authenticated(ProviderType::OpenAI),
|
||||
if self.is_authenticated(ProviderType::OpenAI) {
|
||||
Some("API key configured".to_string())
|
||||
} else {
|
||||
Some("Not authenticated".to_string())
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an Ollama model is known to support tool calling
|
||||
fn is_ollama_tool_capable(model_id: &str) -> bool {
|
||||
let model_lower = model_id.to_lowercase();
|
||||
|
||||
// Extract base model name (before the colon for size variants)
|
||||
let base_name = model_lower.split(':').next().unwrap_or(&model_lower);
|
||||
|
||||
// Models known to support tool calling well
|
||||
let tool_capable_patterns = [
|
||||
"qwen", // Qwen models (qwen3, qwen2.5, etc.)
|
||||
"llama3.1", // Llama 3.1 and above
|
||||
"llama3.2", // Llama 3.2
|
||||
"llama3.3", // Llama 3.3
|
||||
"mistral", // Mistral models
|
||||
"mixtral", // Mixtral models
|
||||
"deepseek", // DeepSeek models
|
||||
"command-r", // Cohere Command-R
|
||||
"gemma2", // Gemma 2 (some versions)
|
||||
"phi3", // Phi-3 models
|
||||
"phi4", // Phi-4 models
|
||||
"granite", // IBM Granite
|
||||
"hermes", // Hermes models
|
||||
"openhermes", // OpenHermes
|
||||
"nous-hermes", // Nous Hermes
|
||||
"dolphin", // Dolphin models
|
||||
"wizard", // WizardLM
|
||||
"codellama", // Code Llama
|
||||
"starcoder", // StarCoder
|
||||
"codegemma", // CodeGemma
|
||||
"gpt-oss", // GPT-OSS models
|
||||
];
|
||||
|
||||
// Check if model matches any known pattern
|
||||
for pattern in tool_capable_patterns {
|
||||
if base_name.contains(pattern) {
|
||||
// Exclude small models (< 4B parameters) as they often struggle with tools
|
||||
if let Some(size_part) = model_lower.split(':').nth(1) {
|
||||
if is_small_model(size_part) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a model size string indicates a small model (< 4B parameters)
|
||||
fn is_small_model(size_str: &str) -> bool {
|
||||
// Extract the numeric part from strings like "1b", "1.5b", "3b", "8b", etc.
|
||||
let size_lower = size_str.to_lowercase();
|
||||
|
||||
// Try to extract the number before 'b'
|
||||
if let Some(b_pos) = size_lower.find('b') {
|
||||
let num_part = &size_lower[..b_pos];
|
||||
// Parse as float to handle "1.5", "0.5", etc.
|
||||
if let Ok(size) = num_part.parse::<f32>() {
|
||||
return size < 4.0; // Exclude models smaller than 4B
|
||||
}
|
||||
}
|
||||
|
||||
false // If we can't parse it, assume it's fine
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_models() {
|
||||
let settings = config_agent::Settings::default();
|
||||
let auth_manager = Arc::new(AuthManager::new().unwrap());
|
||||
let manager = ProviderManager::new(auth_manager, settings);
|
||||
|
||||
// Check default models are set
|
||||
assert!(!manager.model_for_provider(ProviderType::Ollama).is_empty());
|
||||
assert!(!manager.model_for_provider(ProviderType::Anthropic).is_empty());
|
||||
assert!(!manager.model_for_provider(ProviderType::OpenAI).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ollama_tool_capability_detection() {
|
||||
// Tool-capable models
|
||||
assert!(is_ollama_tool_capable("qwen3:8b"));
|
||||
assert!(is_ollama_tool_capable("qwen2.5:7b"));
|
||||
assert!(is_ollama_tool_capable("llama3.1:8b"));
|
||||
assert!(is_ollama_tool_capable("llama3.2:8b"));
|
||||
assert!(is_ollama_tool_capable("mistral:7b"));
|
||||
assert!(is_ollama_tool_capable("deepseek-coder:6.7b"));
|
||||
assert!(is_ollama_tool_capable("gpt-oss:120b-cloud"));
|
||||
|
||||
// Small models excluded (1b, 2b, 3b)
|
||||
assert!(!is_ollama_tool_capable("llama3.2:1b"));
|
||||
assert!(!is_ollama_tool_capable("llama3.2:3b"));
|
||||
assert!(!is_ollama_tool_capable("qwen2.5:1.5b"));
|
||||
|
||||
// Unknown models
|
||||
assert!(!is_ollama_tool_capable("gemma:7b")); // gemma (not gemma2)
|
||||
assert!(!is_ollama_tool_capable("llama2:7b")); // llama2, not 3.x
|
||||
assert!(!is_ollama_tool_capable("tinyllama:1b"));
|
||||
}
|
||||
}
|
||||
707
crates/app/ui/src/theme.rs
Normal file
707
crates/app/ui/src/theme.rs
Normal file
@@ -0,0 +1,707 @@
|
||||
//! Theme system for the borderless TUI design
|
||||
//!
|
||||
//! Provides color palettes, semantic styling, and terminal capability detection
|
||||
//! for graceful degradation across different terminal emulators.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// Terminal capability detection for graceful degradation
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TerminalCapability {
|
||||
/// Full Unicode support with true color
|
||||
Full,
|
||||
/// Basic Unicode with 256 colors
|
||||
Unicode256,
|
||||
/// ASCII only with 16 colors
|
||||
Basic,
|
||||
}
|
||||
|
||||
impl TerminalCapability {
|
||||
/// Detect terminal capabilities from environment
|
||||
pub fn detect() -> Self {
|
||||
// Check for true color support
|
||||
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
|
||||
if colorterm == "truecolor" || colorterm == "24bit" {
|
||||
return Self::Full;
|
||||
}
|
||||
|
||||
if term.contains("256color") || term.contains("kitty") || term.contains("alacritty") {
|
||||
return Self::Unicode256;
|
||||
}
|
||||
|
||||
// Check if we're in a linux VT or basic terminal
|
||||
if term == "linux" || term == "vt100" || term == "dumb" {
|
||||
return Self::Basic;
|
||||
}
|
||||
|
||||
// Default to unicode with 256 colors
|
||||
Self::Unicode256
|
||||
}
|
||||
|
||||
/// Check if Unicode box drawing is supported
|
||||
pub fn supports_unicode(&self) -> bool {
|
||||
matches!(self, Self::Full | Self::Unicode256)
|
||||
}
|
||||
|
||||
/// Check if true color (RGB) is supported
|
||||
pub fn supports_truecolor(&self) -> bool {
|
||||
matches!(self, Self::Full)
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols with fallbacks for different terminal capabilities
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Symbols {
|
||||
pub horizontal_rule: &'static str,
|
||||
pub vertical_separator: &'static str,
|
||||
pub bullet: &'static str,
|
||||
pub arrow: &'static str,
|
||||
pub check: &'static str,
|
||||
pub cross: &'static str,
|
||||
pub warning: &'static str,
|
||||
pub info: &'static str,
|
||||
pub streaming: &'static str,
|
||||
pub user_prefix: &'static str,
|
||||
pub assistant_prefix: &'static str,
|
||||
pub tool_prefix: &'static str,
|
||||
pub system_prefix: &'static str,
|
||||
// Provider icons
|
||||
pub claude_icon: &'static str,
|
||||
pub ollama_icon: &'static str,
|
||||
pub openai_icon: &'static str,
|
||||
// Vim mode indicators
|
||||
pub mode_normal: &'static str,
|
||||
pub mode_insert: &'static str,
|
||||
pub mode_visual: &'static str,
|
||||
pub mode_command: &'static str,
|
||||
}
|
||||
|
||||
impl Symbols {
|
||||
/// Unicode symbols for capable terminals
|
||||
pub fn unicode() -> Self {
|
||||
Self {
|
||||
horizontal_rule: "─",
|
||||
vertical_separator: "│",
|
||||
bullet: "•",
|
||||
arrow: "→",
|
||||
check: "✓",
|
||||
cross: "✗",
|
||||
warning: "⚠",
|
||||
info: "ℹ",
|
||||
streaming: "●",
|
||||
user_prefix: "❯",
|
||||
assistant_prefix: "◆",
|
||||
tool_prefix: "⚡",
|
||||
system_prefix: "○",
|
||||
claude_icon: "",
|
||||
ollama_icon: "",
|
||||
openai_icon: "",
|
||||
mode_normal: "[N]",
|
||||
mode_insert: "[I]",
|
||||
mode_visual: "[V]",
|
||||
mode_command: "[:]",
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII fallback symbols
|
||||
pub fn ascii() -> Self {
|
||||
Self {
|
||||
horizontal_rule: "-",
|
||||
vertical_separator: "|",
|
||||
bullet: "*",
|
||||
arrow: "->",
|
||||
check: "+",
|
||||
cross: "x",
|
||||
warning: "!",
|
||||
info: "i",
|
||||
streaming: "*",
|
||||
user_prefix: ">",
|
||||
assistant_prefix: "-",
|
||||
tool_prefix: "#",
|
||||
system_prefix: "-",
|
||||
claude_icon: "C",
|
||||
ollama_icon: "O",
|
||||
openai_icon: "G",
|
||||
mode_normal: "[N]",
|
||||
mode_insert: "[I]",
|
||||
mode_visual: "[V]",
|
||||
mode_command: "[:]",
|
||||
}
|
||||
}
|
||||
|
||||
/// Select symbols based on terminal capability
|
||||
pub fn for_capability(cap: TerminalCapability) -> Self {
|
||||
match cap {
|
||||
TerminalCapability::Full | TerminalCapability::Unicode256 => Self::unicode(),
|
||||
TerminalCapability::Basic => Self::ascii(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modern color palette inspired by contemporary design systems
|
||||
///
|
||||
/// Color assignment principles:
|
||||
/// - fg (#c0caf5): PRIMARY text - user messages, command names
|
||||
/// - assistant (#9aa5ce): Soft gray-blue for AI responses (distinct from user)
|
||||
/// - accent (#7aa2f7): Interactive elements ONLY (mode, prompt symbol)
|
||||
/// - cmd_slash (#bb9af7): Purple for / prefix (signals "command")
|
||||
/// - fg_dim (#565f89): Timestamps, hints, inactive elements
|
||||
/// - selection (#283457): Highlighted row background
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColorPalette {
|
||||
pub primary: Color,
|
||||
pub secondary: Color,
|
||||
pub accent: Color,
|
||||
pub success: Color,
|
||||
pub warning: Color,
|
||||
pub error: Color,
|
||||
pub info: Color,
|
||||
pub bg: Color,
|
||||
pub fg: Color,
|
||||
pub fg_dim: Color,
|
||||
pub fg_muted: Color,
|
||||
pub highlight: Color,
|
||||
pub border: Color, // For horizontal rules (subtle)
|
||||
pub selection: Color, // Highlighted row background
|
||||
// Provider-specific colors
|
||||
pub claude: Color,
|
||||
pub ollama: Color,
|
||||
pub openai: Color,
|
||||
// Semantic colors for messages
|
||||
pub user_fg: Color, // User message text (bright, fg)
|
||||
pub assistant_fg: Color, // Assistant message text (soft gray-blue)
|
||||
pub tool_fg: Color,
|
||||
pub timestamp_fg: Color,
|
||||
pub divider_fg: Color,
|
||||
// Command colors
|
||||
pub cmd_slash: Color, // Purple for / prefix
|
||||
pub cmd_name: Color, // Command name (same as fg)
|
||||
pub cmd_desc: Color, // Command description (dim)
|
||||
// Overlay/modal colors
|
||||
pub overlay_bg: Color, // Slightly lighter than main bg
|
||||
}
|
||||
|
||||
impl ColorPalette {
|
||||
/// Tokyo Night inspired palette - high contrast, readable
|
||||
///
|
||||
/// Key principles:
|
||||
/// - fg (#c0caf5) for user messages and command names
|
||||
/// - assistant (#a9b1d6) brighter gray-blue for AI responses (readable)
|
||||
/// - accent (#7aa2f7) only for interactive elements (mode indicator, prompt symbol)
|
||||
/// - cmd_slash (#bb9af7) purple for / prefix (signals "command")
|
||||
/// - fg_dim (#737aa2) for timestamps, hints, descriptions (brighter than before)
|
||||
/// - border (#3b4261) for horizontal rules
|
||||
pub fn tokyo_night() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(122, 162, 247), // #7aa2f7 - Blue accent
|
||||
secondary: Color::Rgb(187, 154, 247), // #bb9af7 - Purple
|
||||
accent: Color::Rgb(122, 162, 247), // #7aa2f7 - Interactive elements ONLY
|
||||
success: Color::Rgb(158, 206, 106), // #9ece6a - Green
|
||||
warning: Color::Rgb(224, 175, 104), // #e0af68 - Yellow
|
||||
error: Color::Rgb(247, 118, 142), // #f7768e - Pink/Red
|
||||
info: Color::Rgb(125, 207, 255), // Cyan (rarely used)
|
||||
bg: Color::Rgb(26, 27, 38), // #1a1b26 - Dark bg
|
||||
fg: Color::Rgb(192, 202, 245), // #c0caf5 - Primary text (HIGH CONTRAST)
|
||||
fg_dim: Color::Rgb(115, 122, 162), // #737aa2 - Secondary text (BRIGHTER)
|
||||
fg_muted: Color::Rgb(86, 95, 137), // #565f89 - Very dim
|
||||
highlight: Color::Rgb(56, 62, 90), // Selection bg (legacy)
|
||||
border: Color::Rgb(73, 82, 115), // #495273 - Horizontal rules (BRIGHTER)
|
||||
selection: Color::Rgb(40, 52, 87), // #283457 - Highlighted row bg
|
||||
// Provider colors
|
||||
claude: Color::Rgb(217, 119, 87), // Claude orange
|
||||
ollama: Color::Rgb(122, 162, 247), // Blue
|
||||
openai: Color::Rgb(16, 163, 127), // OpenAI green
|
||||
// Message colors - user bright, assistant readable
|
||||
user_fg: Color::Rgb(192, 202, 245), // #c0caf5 - Same as fg (bright)
|
||||
assistant_fg: Color::Rgb(169, 177, 214), // #a9b1d6 - Brighter gray-blue (READABLE)
|
||||
tool_fg: Color::Rgb(224, 175, 104), // #e0af68 - Yellow for tools
|
||||
timestamp_fg: Color::Rgb(115, 122, 162), // #737aa2 - Brighter dim
|
||||
divider_fg: Color::Rgb(73, 82, 115), // #495273 - Border color (BRIGHTER)
|
||||
// Command colors
|
||||
cmd_slash: Color::Rgb(187, 154, 247), // #bb9af7 - Purple for / prefix
|
||||
cmd_name: Color::Rgb(192, 202, 245), // #c0caf5 - White for command name
|
||||
cmd_desc: Color::Rgb(115, 122, 162), // #737aa2 - Brighter description
|
||||
// Overlay colors
|
||||
overlay_bg: Color::Rgb(36, 40, 59), // #24283b - Slightly lighter than bg
|
||||
}
|
||||
}
|
||||
|
||||
/// Dracula inspired palette - classic and elegant
|
||||
pub fn dracula() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(139, 233, 253), // Cyan
|
||||
secondary: Color::Rgb(189, 147, 249), // Purple
|
||||
accent: Color::Rgb(255, 121, 198), // Pink
|
||||
success: Color::Rgb(80, 250, 123), // Green
|
||||
warning: Color::Rgb(241, 250, 140), // Yellow
|
||||
error: Color::Rgb(255, 85, 85), // Red
|
||||
info: Color::Rgb(139, 233, 253), // Cyan
|
||||
bg: Color::Rgb(40, 42, 54), // Dark bg
|
||||
fg: Color::Rgb(248, 248, 242), // Light text
|
||||
fg_dim: Color::Rgb(98, 114, 164), // Comment
|
||||
fg_muted: Color::Rgb(68, 71, 90), // Very dim
|
||||
highlight: Color::Rgb(68, 71, 90), // Selection
|
||||
border: Color::Rgb(68, 71, 90),
|
||||
selection: Color::Rgb(68, 71, 90),
|
||||
claude: Color::Rgb(255, 121, 198),
|
||||
ollama: Color::Rgb(139, 233, 253),
|
||||
openai: Color::Rgb(80, 250, 123),
|
||||
user_fg: Color::Rgb(248, 248, 242),
|
||||
assistant_fg: Color::Rgb(189, 186, 220), // Softer purple-gray
|
||||
tool_fg: Color::Rgb(241, 250, 140),
|
||||
timestamp_fg: Color::Rgb(68, 71, 90),
|
||||
divider_fg: Color::Rgb(68, 71, 90),
|
||||
cmd_slash: Color::Rgb(189, 147, 249), // Purple
|
||||
cmd_name: Color::Rgb(248, 248, 242),
|
||||
cmd_desc: Color::Rgb(98, 114, 164),
|
||||
overlay_bg: Color::Rgb(50, 52, 64),
|
||||
}
|
||||
}
|
||||
|
||||
/// Catppuccin Mocha - warm and cozy
|
||||
pub fn catppuccin() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(137, 180, 250), // Blue
|
||||
secondary: Color::Rgb(203, 166, 247), // Mauve
|
||||
accent: Color::Rgb(245, 194, 231), // Pink
|
||||
success: Color::Rgb(166, 227, 161), // Green
|
||||
warning: Color::Rgb(249, 226, 175), // Yellow
|
||||
error: Color::Rgb(243, 139, 168), // Red
|
||||
info: Color::Rgb(148, 226, 213), // Teal
|
||||
bg: Color::Rgb(30, 30, 46), // Base
|
||||
fg: Color::Rgb(205, 214, 244), // Text
|
||||
fg_dim: Color::Rgb(108, 112, 134), // Overlay
|
||||
fg_muted: Color::Rgb(69, 71, 90), // Surface
|
||||
highlight: Color::Rgb(49, 50, 68), // Surface
|
||||
border: Color::Rgb(69, 71, 90),
|
||||
selection: Color::Rgb(49, 50, 68),
|
||||
claude: Color::Rgb(245, 194, 231),
|
||||
ollama: Color::Rgb(137, 180, 250),
|
||||
openai: Color::Rgb(166, 227, 161),
|
||||
user_fg: Color::Rgb(205, 214, 244),
|
||||
assistant_fg: Color::Rgb(166, 187, 213), // Softer blue-gray
|
||||
tool_fg: Color::Rgb(249, 226, 175),
|
||||
timestamp_fg: Color::Rgb(69, 71, 90),
|
||||
divider_fg: Color::Rgb(69, 71, 90),
|
||||
cmd_slash: Color::Rgb(203, 166, 247), // Mauve
|
||||
cmd_name: Color::Rgb(205, 214, 244),
|
||||
cmd_desc: Color::Rgb(108, 112, 134),
|
||||
overlay_bg: Color::Rgb(40, 40, 56),
|
||||
}
|
||||
}
|
||||
|
||||
/// Nord - minimal and clean
|
||||
pub fn nord() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(136, 192, 208), // Frost cyan
|
||||
secondary: Color::Rgb(129, 161, 193), // Frost blue
|
||||
accent: Color::Rgb(180, 142, 173), // Aurora purple
|
||||
success: Color::Rgb(163, 190, 140), // Aurora green
|
||||
warning: Color::Rgb(235, 203, 139), // Aurora yellow
|
||||
error: Color::Rgb(191, 97, 106), // Aurora red
|
||||
info: Color::Rgb(136, 192, 208), // Frost cyan
|
||||
bg: Color::Rgb(46, 52, 64), // Polar night
|
||||
fg: Color::Rgb(236, 239, 244), // Snow storm
|
||||
fg_dim: Color::Rgb(76, 86, 106), // Polar night light
|
||||
fg_muted: Color::Rgb(59, 66, 82),
|
||||
highlight: Color::Rgb(59, 66, 82), // Selection
|
||||
border: Color::Rgb(59, 66, 82),
|
||||
selection: Color::Rgb(59, 66, 82),
|
||||
claude: Color::Rgb(180, 142, 173),
|
||||
ollama: Color::Rgb(136, 192, 208),
|
||||
openai: Color::Rgb(163, 190, 140),
|
||||
user_fg: Color::Rgb(236, 239, 244),
|
||||
assistant_fg: Color::Rgb(180, 195, 210), // Softer blue-gray
|
||||
tool_fg: Color::Rgb(235, 203, 139),
|
||||
timestamp_fg: Color::Rgb(59, 66, 82),
|
||||
divider_fg: Color::Rgb(59, 66, 82),
|
||||
cmd_slash: Color::Rgb(180, 142, 173), // Aurora purple
|
||||
cmd_name: Color::Rgb(236, 239, 244),
|
||||
cmd_desc: Color::Rgb(76, 86, 106),
|
||||
overlay_bg: Color::Rgb(56, 62, 74),
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthwave - vibrant and retro
|
||||
pub fn synthwave() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(255, 0, 128), // Hot pink
|
||||
secondary: Color::Rgb(0, 229, 255), // Cyan
|
||||
accent: Color::Rgb(255, 128, 0), // Orange
|
||||
success: Color::Rgb(0, 255, 157), // Neon green
|
||||
warning: Color::Rgb(255, 215, 0), // Gold
|
||||
error: Color::Rgb(255, 64, 64), // Neon red
|
||||
info: Color::Rgb(0, 229, 255), // Cyan
|
||||
bg: Color::Rgb(20, 16, 32), // Dark purple
|
||||
fg: Color::Rgb(242, 233, 255), // Light purple
|
||||
fg_dim: Color::Rgb(127, 90, 180), // Mid purple
|
||||
fg_muted: Color::Rgb(72, 12, 168),
|
||||
highlight: Color::Rgb(72, 12, 168), // Deep purple
|
||||
border: Color::Rgb(72, 12, 168),
|
||||
selection: Color::Rgb(72, 12, 168),
|
||||
claude: Color::Rgb(255, 128, 0),
|
||||
ollama: Color::Rgb(0, 229, 255),
|
||||
openai: Color::Rgb(0, 255, 157),
|
||||
user_fg: Color::Rgb(242, 233, 255),
|
||||
assistant_fg: Color::Rgb(180, 170, 220), // Softer purple
|
||||
tool_fg: Color::Rgb(255, 215, 0),
|
||||
timestamp_fg: Color::Rgb(72, 12, 168),
|
||||
divider_fg: Color::Rgb(72, 12, 168),
|
||||
cmd_slash: Color::Rgb(255, 0, 128), // Hot pink
|
||||
cmd_name: Color::Rgb(242, 233, 255),
|
||||
cmd_desc: Color::Rgb(127, 90, 180),
|
||||
overlay_bg: Color::Rgb(30, 26, 42),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rose Pine - elegant and muted
|
||||
pub fn rose_pine() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(156, 207, 216), // Foam
|
||||
secondary: Color::Rgb(235, 188, 186), // Rose
|
||||
accent: Color::Rgb(234, 154, 151), // Love
|
||||
success: Color::Rgb(49, 116, 143), // Pine
|
||||
warning: Color::Rgb(246, 193, 119), // Gold
|
||||
error: Color::Rgb(235, 111, 146), // Love (darker)
|
||||
info: Color::Rgb(156, 207, 216), // Foam
|
||||
bg: Color::Rgb(25, 23, 36), // Base
|
||||
fg: Color::Rgb(224, 222, 244), // Text
|
||||
fg_dim: Color::Rgb(110, 106, 134), // Muted
|
||||
fg_muted: Color::Rgb(42, 39, 63),
|
||||
highlight: Color::Rgb(42, 39, 63), // Highlight
|
||||
border: Color::Rgb(42, 39, 63),
|
||||
selection: Color::Rgb(42, 39, 63),
|
||||
claude: Color::Rgb(234, 154, 151),
|
||||
ollama: Color::Rgb(156, 207, 216),
|
||||
openai: Color::Rgb(49, 116, 143),
|
||||
user_fg: Color::Rgb(224, 222, 244),
|
||||
assistant_fg: Color::Rgb(180, 185, 210), // Softer lavender-gray
|
||||
tool_fg: Color::Rgb(246, 193, 119),
|
||||
timestamp_fg: Color::Rgb(42, 39, 63),
|
||||
divider_fg: Color::Rgb(42, 39, 63),
|
||||
cmd_slash: Color::Rgb(235, 188, 186), // Rose
|
||||
cmd_name: Color::Rgb(224, 222, 244),
|
||||
cmd_desc: Color::Rgb(110, 106, 134),
|
||||
overlay_bg: Color::Rgb(35, 33, 46),
|
||||
}
|
||||
}
|
||||
|
||||
/// Midnight Ocean - deep and serene
|
||||
pub fn midnight_ocean() -> Self {
|
||||
Self {
|
||||
primary: Color::Rgb(102, 217, 239), // Bright cyan
|
||||
secondary: Color::Rgb(130, 170, 255), // Periwinkle
|
||||
accent: Color::Rgb(199, 146, 234), // Purple
|
||||
success: Color::Rgb(163, 190, 140), // Sea green
|
||||
warning: Color::Rgb(229, 200, 144), // Sandy yellow
|
||||
error: Color::Rgb(236, 95, 103), // Coral red
|
||||
info: Color::Rgb(102, 217, 239), // Bright cyan
|
||||
bg: Color::Rgb(1, 22, 39), // Deep ocean
|
||||
fg: Color::Rgb(201, 211, 235), // Light blue-white
|
||||
fg_dim: Color::Rgb(71, 103, 145), // Muted blue
|
||||
fg_muted: Color::Rgb(13, 43, 69),
|
||||
highlight: Color::Rgb(13, 43, 69), // Deep blue
|
||||
border: Color::Rgb(13, 43, 69),
|
||||
selection: Color::Rgb(13, 43, 69),
|
||||
claude: Color::Rgb(199, 146, 234),
|
||||
ollama: Color::Rgb(102, 217, 239),
|
||||
openai: Color::Rgb(163, 190, 140),
|
||||
user_fg: Color::Rgb(201, 211, 235),
|
||||
assistant_fg: Color::Rgb(150, 175, 200), // Softer blue-gray
|
||||
tool_fg: Color::Rgb(229, 200, 144),
|
||||
timestamp_fg: Color::Rgb(13, 43, 69),
|
||||
divider_fg: Color::Rgb(13, 43, 69),
|
||||
cmd_slash: Color::Rgb(199, 146, 234), // Purple
|
||||
cmd_name: Color::Rgb(201, 211, 235),
|
||||
cmd_desc: Color::Rgb(71, 103, 145),
|
||||
overlay_bg: Color::Rgb(11, 32, 49),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM Provider enum
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Provider {
|
||||
Claude,
|
||||
Ollama,
|
||||
OpenAI,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Claude => "Claude",
|
||||
Provider::Ollama => "Ollama",
|
||||
Provider::OpenAI => "OpenAI",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> &'static [Provider] {
|
||||
&[Provider::Claude, Provider::Ollama, Provider::OpenAI]
|
||||
}
|
||||
}
|
||||
|
||||
/// Vim-like editing mode
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VimMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Command,
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
pub fn indicator(&self, symbols: &Symbols) -> &'static str {
|
||||
match self {
|
||||
VimMode::Normal => symbols.mode_normal,
|
||||
VimMode::Insert => symbols.mode_insert,
|
||||
VimMode::Visual => symbols.mode_visual,
|
||||
VimMode::Command => symbols.mode_command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme configuration for the borderless TUI
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
pub palette: ColorPalette,
|
||||
pub symbols: Symbols,
|
||||
pub capability: TerminalCapability,
|
||||
// Message styles
|
||||
pub user_message: Style,
|
||||
pub assistant_message: Style,
|
||||
pub tool_call: Style,
|
||||
pub tool_result_success: Style,
|
||||
pub tool_result_error: Style,
|
||||
pub system_message: Style,
|
||||
pub timestamp: Style,
|
||||
// UI element styles
|
||||
pub divider: Style,
|
||||
pub header: Style,
|
||||
pub header_accent: Style,
|
||||
pub tab_active: Style,
|
||||
pub tab_inactive: Style,
|
||||
pub input_prefix: Style,
|
||||
pub input_text: Style,
|
||||
pub input_placeholder: Style,
|
||||
pub status_bar: Style,
|
||||
pub status_accent: Style,
|
||||
pub status_dim: Style,
|
||||
// Command styles
|
||||
pub cmd_slash: Style, // Purple for / prefix
|
||||
pub cmd_name: Style, // White for command name
|
||||
pub cmd_desc: Style, // Dim for description
|
||||
// Overlay/modal styles
|
||||
pub overlay_bg: Style, // Modal background
|
||||
pub selection_bg: Style, // Selected row background
|
||||
// Popup styles (for permission dialogs)
|
||||
pub popup_border: Style,
|
||||
pub popup_bg: Style,
|
||||
pub popup_title: Style,
|
||||
pub selected: Style,
|
||||
// Legacy compatibility
|
||||
pub border: Style,
|
||||
pub border_active: Style,
|
||||
pub status_bar_highlight: Style,
|
||||
pub input_box: Style,
|
||||
pub input_box_active: Style,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Create theme from color palette with automatic capability detection
|
||||
pub fn from_palette(palette: ColorPalette) -> Self {
|
||||
let capability = TerminalCapability::detect();
|
||||
Self::from_palette_with_capability(palette, capability)
|
||||
}
|
||||
|
||||
/// Create theme with specific terminal capability
|
||||
pub fn from_palette_with_capability(palette: ColorPalette, capability: TerminalCapability) -> Self {
|
||||
let symbols = Symbols::for_capability(capability);
|
||||
|
||||
Self {
|
||||
// Message styles
|
||||
user_message: Style::default()
|
||||
.fg(palette.user_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
assistant_message: Style::default().fg(palette.assistant_fg),
|
||||
tool_call: Style::default()
|
||||
.fg(palette.tool_fg)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
tool_result_success: Style::default()
|
||||
.fg(palette.success)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
tool_result_error: Style::default()
|
||||
.fg(palette.error)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
system_message: Style::default().fg(palette.fg_dim),
|
||||
timestamp: Style::default().fg(palette.timestamp_fg),
|
||||
// UI elements
|
||||
divider: Style::default().fg(palette.divider_fg),
|
||||
header: Style::default()
|
||||
.fg(palette.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
header_accent: Style::default()
|
||||
.fg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
tab_active: Style::default()
|
||||
.fg(palette.primary)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
tab_inactive: Style::default().fg(palette.fg_dim),
|
||||
input_prefix: Style::default()
|
||||
.fg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
input_text: Style::default().fg(palette.fg),
|
||||
input_placeholder: Style::default().fg(palette.fg_muted),
|
||||
status_bar: Style::default().fg(palette.fg_dim),
|
||||
status_accent: Style::default().fg(palette.accent),
|
||||
status_dim: Style::default().fg(palette.fg_muted),
|
||||
// Command styles
|
||||
cmd_slash: Style::default().fg(palette.cmd_slash),
|
||||
cmd_name: Style::default().fg(palette.cmd_name),
|
||||
cmd_desc: Style::default().fg(palette.cmd_desc),
|
||||
// Overlay/modal styles
|
||||
overlay_bg: Style::default().bg(palette.overlay_bg),
|
||||
selection_bg: Style::default().bg(palette.selection),
|
||||
// Popup styles
|
||||
popup_border: Style::default()
|
||||
.fg(palette.border)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
popup_bg: Style::default().bg(palette.overlay_bg),
|
||||
popup_title: Style::default()
|
||||
.fg(palette.fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
selected: Style::default()
|
||||
.fg(palette.fg)
|
||||
.bg(palette.selection)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
// Legacy compatibility
|
||||
border: Style::default().fg(palette.fg_dim),
|
||||
border_active: Style::default()
|
||||
.fg(palette.primary)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
status_bar_highlight: Style::default()
|
||||
.fg(palette.bg)
|
||||
.bg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
input_box: Style::default().fg(palette.fg),
|
||||
input_box_active: Style::default()
|
||||
.fg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
symbols,
|
||||
capability,
|
||||
palette,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get provider-specific color
|
||||
pub fn provider_color(&self, provider: Provider) -> Color {
|
||||
match provider {
|
||||
Provider::Claude => self.palette.claude,
|
||||
Provider::Ollama => self.palette.ollama,
|
||||
Provider::OpenAI => self.palette.openai,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get provider icon
|
||||
pub fn provider_icon(&self, provider: Provider) -> &str {
|
||||
match provider {
|
||||
Provider::Claude => self.symbols.claude_icon,
|
||||
Provider::Ollama => self.symbols.ollama_icon,
|
||||
Provider::OpenAI => self.symbols.openai_icon,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a horizontal rule string of given width
|
||||
pub fn horizontal_rule(&self, width: usize) -> String {
|
||||
self.symbols.horizontal_rule.repeat(width)
|
||||
}
|
||||
|
||||
/// Tokyo Night theme (default) - modern and vibrant
|
||||
pub fn tokyo_night() -> Self {
|
||||
Self::from_palette(ColorPalette::tokyo_night())
|
||||
}
|
||||
|
||||
/// Dracula theme - classic dark theme
|
||||
pub fn dracula() -> Self {
|
||||
Self::from_palette(ColorPalette::dracula())
|
||||
}
|
||||
|
||||
/// Catppuccin Mocha - warm and cozy
|
||||
pub fn catppuccin() -> Self {
|
||||
Self::from_palette(ColorPalette::catppuccin())
|
||||
}
|
||||
|
||||
/// Nord theme - minimal and clean
|
||||
pub fn nord() -> Self {
|
||||
Self::from_palette(ColorPalette::nord())
|
||||
}
|
||||
|
||||
/// Synthwave theme - vibrant retro
|
||||
pub fn synthwave() -> Self {
|
||||
Self::from_palette(ColorPalette::synthwave())
|
||||
}
|
||||
|
||||
/// Rose Pine theme - elegant and muted
|
||||
pub fn rose_pine() -> Self {
|
||||
Self::from_palette(ColorPalette::rose_pine())
|
||||
}
|
||||
|
||||
/// Midnight Ocean theme - deep and serene
|
||||
pub fn midnight_ocean() -> Self {
|
||||
Self::from_palette(ColorPalette::midnight_ocean())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Theme {
|
||||
fn default() -> Self {
|
||||
Self::tokyo_night()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_terminal_capability_detection() {
|
||||
let cap = TerminalCapability::detect();
|
||||
// Should return some valid capability
|
||||
assert!(matches!(
|
||||
cap,
|
||||
TerminalCapability::Full | TerminalCapability::Unicode256 | TerminalCapability::Basic
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_symbols_for_capability() {
|
||||
let unicode = Symbols::for_capability(TerminalCapability::Full);
|
||||
assert_eq!(unicode.horizontal_rule, "─");
|
||||
|
||||
let ascii = Symbols::for_capability(TerminalCapability::Basic);
|
||||
assert_eq!(ascii.horizontal_rule, "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_from_palette() {
|
||||
let theme = Theme::tokyo_night();
|
||||
assert!(theme.capability.supports_unicode() || !theme.capability.supports_unicode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_colors() {
|
||||
let theme = Theme::tokyo_night();
|
||||
let claude_color = theme.provider_color(Provider::Claude);
|
||||
let ollama_color = theme.provider_color(Provider::Ollama);
|
||||
assert_ne!(claude_color, ollama_color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_mode_indicator() {
|
||||
let symbols = Symbols::unicode();
|
||||
assert_eq!(VimMode::Normal.indicator(&symbols), "[N]");
|
||||
assert_eq!(VimMode::Insert.indicator(&symbols), "[I]");
|
||||
}
|
||||
}
|
||||
31
crates/core/agent/Cargo.toml
Normal file
31
crates/core/agent/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "agent-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
tracing = "0.1"
|
||||
async-trait = "0.1"
|
||||
chrono = "0.4"
|
||||
|
||||
# Internal dependencies
|
||||
llm-core = { path = "../../llm/core" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
tools-fs = { path = "../../tools/fs" }
|
||||
tools-bash = { path = "../../tools/bash" }
|
||||
tools-ask = { path = "../../tools/ask" }
|
||||
tools-todo = { path = "../../tools/todo" }
|
||||
tools-web = { path = "../../tools/web" }
|
||||
tools-plan = { path = "../../tools/plan" }
|
||||
plugins = { path = "../../platform/plugins" }
|
||||
jsonrpc = { path = "../../integration/jsonrpc" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13"
|
||||
74
crates/core/agent/examples/git_demo.rs
Normal file
74
crates/core/agent/examples/git_demo.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
//! Example demonstrating the git integration module
|
||||
//!
|
||||
//! Run with: cargo run -p agent-core --example git_demo
|
||||
|
||||
use agent_core::{detect_git_state, format_git_status, is_safe_git_command, is_destructive_git_command};
|
||||
use std::env;
|
||||
|
||||
fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
// Get current working directory
|
||||
let cwd = env::current_dir()?;
|
||||
println!("Detecting git state in: {}\n", cwd.display());
|
||||
|
||||
// Detect git state
|
||||
let state = detect_git_state(&cwd)?;
|
||||
|
||||
// Display formatted status
|
||||
println!("{}\n", format_git_status(&state));
|
||||
|
||||
// Show detailed file status if there are changes
|
||||
if !state.status.is_empty() {
|
||||
println!("Detailed file status:");
|
||||
for status in &state.status {
|
||||
match status {
|
||||
agent_core::GitFileStatus::Modified { path } => {
|
||||
println!(" M {}", path);
|
||||
}
|
||||
agent_core::GitFileStatus::Added { path } => {
|
||||
println!(" A {}", path);
|
||||
}
|
||||
agent_core::GitFileStatus::Deleted { path } => {
|
||||
println!(" D {}", path);
|
||||
}
|
||||
agent_core::GitFileStatus::Renamed { from, to } => {
|
||||
println!(" R {} -> {}", from, to);
|
||||
}
|
||||
agent_core::GitFileStatus::Untracked { path } => {
|
||||
println!(" ? {}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// Test command safety checking
|
||||
println!("Command safety checks:");
|
||||
let test_commands = vec![
|
||||
"git status",
|
||||
"git log --oneline",
|
||||
"git diff HEAD",
|
||||
"git commit -m 'test'",
|
||||
"git push --force origin main",
|
||||
"git reset --hard HEAD~1",
|
||||
"git rebase main",
|
||||
"git branch -D feature",
|
||||
];
|
||||
|
||||
for cmd in test_commands {
|
||||
let is_safe = is_safe_git_command(cmd);
|
||||
let (is_destructive, warning) = is_destructive_git_command(cmd);
|
||||
|
||||
print!(" {} - ", cmd);
|
||||
if is_safe {
|
||||
println!("SAFE (read-only)");
|
||||
} else if is_destructive {
|
||||
println!("DESTRUCTIVE: {}", warning);
|
||||
} else {
|
||||
println!("UNSAFE (modifies state)");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
88
crates/core/agent/examples/streaming_agent.rs
Normal file
88
crates/core/agent/examples/streaming_agent.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
//! Example demonstrating the streaming agent loop API
|
||||
//!
|
||||
//! This example shows how to use `run_agent_loop_streaming` to receive
|
||||
//! real-time events during agent execution, including:
|
||||
//! - Text deltas as the LLM generates text
|
||||
//! - Tool execution start/end events
|
||||
//! - Tool output events
|
||||
//! - Final completion events
|
||||
//!
|
||||
//! Run with: cargo run --example streaming_agent -p agent-core
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> color_eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
// Note: This is a minimal example. In a real application, you would:
|
||||
// 1. Initialize a real LLM provider (e.g., OllamaClient)
|
||||
// 2. Configure the ChatOptions with your preferred model
|
||||
// 3. Set up appropriate permissions and tool context
|
||||
|
||||
println!("=== Streaming Agent Example ===\n");
|
||||
println!("This example demonstrates how to use the streaming agent loop API.");
|
||||
println!("To run with a real LLM provider, modify this example to:");
|
||||
println!(" 1. Create an LLM provider instance");
|
||||
println!(" 2. Set up permissions and tool context");
|
||||
println!(" 3. Call run_agent_loop_streaming with your prompt\n");
|
||||
|
||||
// Example code structure:
|
||||
println!("Example code:");
|
||||
println!("```rust");
|
||||
println!("// Create LLM provider");
|
||||
println!("let provider = OllamaClient::new(\"http://localhost:11434\");");
|
||||
println!();
|
||||
println!("// Set up permissions and context");
|
||||
println!("let perms = PermissionManager::new(Mode::Plan);");
|
||||
println!("let ctx = ToolContext::default();");
|
||||
println!();
|
||||
println!("// Create event channel");
|
||||
println!("let (tx, mut rx) = create_event_channel();");
|
||||
println!();
|
||||
println!("// Spawn agent loop");
|
||||
println!("let handle = tokio::spawn(async move {{");
|
||||
println!(" run_agent_loop_streaming(");
|
||||
println!(" &provider,");
|
||||
println!(" \"Your prompt here\",");
|
||||
println!(" &ChatOptions::default(),");
|
||||
println!(" &perms,");
|
||||
println!(" &ctx,");
|
||||
println!(" tx,");
|
||||
println!(" ).await");
|
||||
println!("}});");
|
||||
println!();
|
||||
println!("// Process events");
|
||||
println!("while let Some(event) = rx.recv().await {{");
|
||||
println!(" match event {{");
|
||||
println!(" AgentEvent::TextDelta(text) => {{");
|
||||
println!(" print!(\"{{text}}\");");
|
||||
println!(" }}");
|
||||
println!(" AgentEvent::ToolStart {{ tool_name, .. }} => {{");
|
||||
println!(" println!(\"\\n[Executing tool: {{tool_name}}]\");");
|
||||
println!(" }}");
|
||||
println!(" AgentEvent::ToolOutput {{ content, is_error, .. }} => {{");
|
||||
println!(" if is_error {{");
|
||||
println!(" eprintln!(\"Error: {{content}}\");");
|
||||
println!(" }} else {{");
|
||||
println!(" println!(\"Output: {{content}}\");");
|
||||
println!(" }}");
|
||||
println!(" }}");
|
||||
println!(" AgentEvent::ToolEnd {{ success, .. }} => {{");
|
||||
println!(" println!(\"[Tool finished: {{}}]\", if success {{ \"success\" }} else {{ \"failed\" }});");
|
||||
println!(" }}");
|
||||
println!(" AgentEvent::Done {{ final_response }} => {{");
|
||||
println!(" println!(\"\\n\\nFinal response: {{final_response}}\");");
|
||||
println!(" break;");
|
||||
println!(" }}");
|
||||
println!(" AgentEvent::Error(e) => {{");
|
||||
println!(" eprintln!(\"Error: {{e}}\");");
|
||||
println!(" break;");
|
||||
println!(" }}");
|
||||
println!(" }}");
|
||||
println!("}}");
|
||||
println!();
|
||||
println!("// Wait for completion");
|
||||
println!("let result = handle.await??;");
|
||||
println!("```");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
219
crates/core/agent/src/compact.rs
Normal file
219
crates/core/agent/src/compact.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! Context compaction for long conversations.
|
||||
//!
|
||||
//! When the conversation context grows too large, this module compacts
|
||||
//! earlier messages into a summary while preserving recent context.
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
use llm_core::{ChatMessage, ChatOptions, LlmProvider};
|
||||
|
||||
/// Token limit threshold for triggering compaction.
|
||||
const CONTEXT_LIMIT: usize = 180_000;
|
||||
|
||||
/// Threshold ratio at which to trigger compaction (90% of limit).
|
||||
const COMPACTION_THRESHOLD: f64 = 0.9;
|
||||
|
||||
/// Number of recent messages to preserve during compaction.
|
||||
const PRESERVE_RECENT: usize = 10;
|
||||
|
||||
/// Token counter for estimating the size of conversation history in tokens.
|
||||
pub struct TokenCounter {
|
||||
chars_per_token: f64,
|
||||
}
|
||||
|
||||
impl Default for TokenCounter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenCounter {
|
||||
/// Creates a new `TokenCounter` with default settings.
|
||||
pub fn new() -> Self {
|
||||
// Rough estimate: ~4 chars per token for English text
|
||||
Self { chars_per_token: 4.0 }
|
||||
}
|
||||
|
||||
/// Estimates the token count for a single message.
|
||||
pub fn count_message(&self, message: &ChatMessage) -> usize {
|
||||
let content_len = message.content.as_ref().map(|c| c.len()).unwrap_or(0);
|
||||
// Add overhead for role, metadata
|
||||
let overhead = 10;
|
||||
((content_len as f64 / self.chars_per_token) as usize) + overhead
|
||||
}
|
||||
|
||||
/// Estimates the total token count for a list of messages.
|
||||
pub fn count_messages(&self, messages: &[ChatMessage]) -> usize {
|
||||
messages.iter().map(|m| self.count_message(m)).sum()
|
||||
}
|
||||
|
||||
/// Determines if the conversation history should be compacted based on token limits.
|
||||
pub fn should_compact(&self, messages: &[ChatMessage]) -> bool {
|
||||
let count = self.count_messages(messages);
|
||||
count > (CONTEXT_LIMIT as f64 * COMPACTION_THRESHOLD) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Context compactor that uses an LLM to summarize conversation history.
|
||||
pub struct Compactor {
|
||||
token_counter: TokenCounter,
|
||||
}
|
||||
|
||||
impl Default for Compactor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Compactor {
|
||||
/// Creates a new `Compactor`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
token_counter: TokenCounter::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the provided messages exceed the compaction threshold.
|
||||
pub fn needs_compaction(&self, messages: &[ChatMessage]) -> bool {
|
||||
self.token_counter.should_compact(messages)
|
||||
}
|
||||
|
||||
/// Compacts conversation history by summarizing earlier messages.
|
||||
///
|
||||
/// The resulting message list will contain a summary system message followed
|
||||
/// by the most recent conversation context.
|
||||
pub async fn compact<P: LlmProvider>(
|
||||
&self,
|
||||
provider: &P,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
) -> Result<Vec<ChatMessage>> {
|
||||
// If not enough messages to compact, return as-is
|
||||
if messages.len() <= PRESERVE_RECENT + 1 {
|
||||
return Ok(messages.to_vec());
|
||||
}
|
||||
|
||||
// Split into messages to summarize and messages to preserve
|
||||
let split_point = messages.len().saturating_sub(PRESERVE_RECENT);
|
||||
let to_summarize = &messages[..split_point];
|
||||
let to_preserve = &messages[split_point..];
|
||||
|
||||
// Generate summary of earlier messages
|
||||
let summary = self.summarize_messages(provider, to_summarize, options).await?;
|
||||
|
||||
// Build compacted message list
|
||||
let mut compacted = Vec::with_capacity(PRESERVE_RECENT + 1);
|
||||
|
||||
// Add system message with summary
|
||||
compacted.push(ChatMessage::system(format!(
|
||||
"## Earlier Conversation Summary\n\n{}\n\n---\n\n\
|
||||
The above summarizes the earlier part of this conversation. \
|
||||
Continue from the recent messages below.",
|
||||
summary
|
||||
)));
|
||||
|
||||
// Add preserved recent messages
|
||||
compacted.extend(to_preserve.iter().cloned());
|
||||
|
||||
Ok(compacted)
|
||||
}
|
||||
|
||||
/// Generate a summary of messages using the LLM
|
||||
async fn summarize_messages<P: LlmProvider>(
|
||||
&self,
|
||||
provider: &P,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
) -> Result<String> {
|
||||
// Format messages for summarization
|
||||
let mut context = String::new();
|
||||
for msg in messages {
|
||||
let role = &msg.role;
|
||||
let content = msg.content.as_deref().unwrap_or("");
|
||||
context.push_str(&format!("[{:?}]: {}\n\n", role, content));
|
||||
}
|
||||
|
||||
// Create summarization prompt
|
||||
let summary_prompt = format!(
|
||||
"Please provide a concise summary of the following conversation. \
|
||||
Focus on:\n\
|
||||
1. Key decisions made\n\
|
||||
2. Important files or code mentioned\n\
|
||||
3. Tasks completed and their outcomes\n\
|
||||
4. Any pending items or next steps discussed\n\n\
|
||||
Keep the summary informative but brief (under 500 words).\n\n\
|
||||
Conversation:\n{}\n\n\
|
||||
Summary:",
|
||||
context
|
||||
);
|
||||
|
||||
// Call LLM to generate summary
|
||||
let summary_options = ChatOptions {
|
||||
model: options.model.clone(),
|
||||
max_tokens: Some(1000),
|
||||
temperature: Some(0.3), // Lower temperature for more focused summary
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let summary_messages = vec![ChatMessage::user(&summary_prompt)];
|
||||
let mut stream = provider.chat_stream(&summary_messages, &summary_options, None).await?;
|
||||
|
||||
let mut summary = String::new();
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
if let Ok(chunk) = chunk_result
|
||||
&& let Some(content) = &chunk.content
|
||||
{
|
||||
summary.push_str(content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(summary.trim().to_string())
|
||||
}
|
||||
|
||||
/// Returns a reference to the internal `TokenCounter`.
|
||||
pub fn token_counter(&self) -> &TokenCounter {
|
||||
&self.token_counter
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_token_counter_estimate() {
|
||||
let counter = TokenCounter::new();
|
||||
let msg = ChatMessage::user("Hello, world!");
|
||||
let count = counter.count_message(&msg);
|
||||
// Should be approximately 13/4 + 10 overhead = 13
|
||||
assert!(count > 10);
|
||||
assert!(count < 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_compact() {
|
||||
let counter = TokenCounter::new();
|
||||
|
||||
// Small message list shouldn't compact
|
||||
let small_messages: Vec<ChatMessage> = (0..10)
|
||||
.map(|i| ChatMessage::user(format!("Message {}", i)))
|
||||
.collect();
|
||||
assert!(!counter.should_compact(&small_messages));
|
||||
|
||||
// Large message list should compact
|
||||
// Need ~162,000 tokens = ~648,000 chars (at 4 chars per token)
|
||||
let large_content = "x".repeat(700_000);
|
||||
let large_messages = vec![ChatMessage::user(large_content)];
|
||||
assert!(counter.should_compact(&large_messages));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compactor_needs_compaction() {
|
||||
let compactor = Compactor::new();
|
||||
|
||||
let small: Vec<ChatMessage> = (0..5)
|
||||
.map(|i| ChatMessage::user(format!("Short message {}", i)))
|
||||
.collect();
|
||||
assert!(!compactor.needs_compaction(&small));
|
||||
}
|
||||
}
|
||||
546
crates/core/agent/src/git.rs
Normal file
546
crates/core/agent/src/git.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
//! Git integration module for detecting repository state and validating git commands.
|
||||
//!
|
||||
//! This module provides functionality to:
|
||||
//! - Detect if the current directory is a git repository
|
||||
//! - Capture git repository state (branch, status, uncommitted changes)
|
||||
//! - Validate git commands for safety (read-only vs destructive operations)
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Represents the git status of a specific file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GitFileStatus {
|
||||
/// File has been modified.
|
||||
Modified { path: String },
|
||||
/// File has been added to the index (staged).
|
||||
Added { path: String },
|
||||
/// File has been deleted.
|
||||
Deleted { path: String },
|
||||
/// File has been renamed.
|
||||
Renamed { from: String, to: String },
|
||||
/// File is not tracked by git.
|
||||
Untracked { path: String },
|
||||
}
|
||||
|
||||
impl GitFileStatus {
|
||||
/// Returns the primary path associated with this status entry.
|
||||
///
|
||||
/// For renames, this returns the new path.
|
||||
pub fn path(&self) -> &str {
|
||||
match self {
|
||||
Self::Modified { path } => path,
|
||||
Self::Added { path } => path,
|
||||
Self::Deleted { path } => path,
|
||||
Self::Renamed { to, .. } => to,
|
||||
Self::Untracked { path } => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the captured state of a git repository.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitState {
|
||||
/// `true` if the directory is a git repository.
|
||||
pub is_git_repo: bool,
|
||||
/// The name of the current branch, if any.
|
||||
pub current_branch: Option<String>,
|
||||
/// The name of the detected main/master branch.
|
||||
pub main_branch: Option<String>,
|
||||
/// List of file status entries for the working tree.
|
||||
pub status: Vec<GitFileStatus>,
|
||||
/// `true` if there are any staged or unstaged changes.
|
||||
pub has_uncommitted_changes: bool,
|
||||
/// The URL of the 'origin' remote, if configured.
|
||||
pub remote_url: Option<String>,
|
||||
}
|
||||
|
||||
impl GitState {
|
||||
/// Creates a `GitState` representing a non-repository directory.
|
||||
pub fn not_a_repo() -> Self {
|
||||
Self {
|
||||
is_git_repo: false,
|
||||
current_branch: None,
|
||||
main_branch: None,
|
||||
status: Vec::new(),
|
||||
has_uncommitted_changes: false,
|
||||
remote_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the git state of the specified working directory.
|
||||
///
|
||||
/// This function executes git commands to inspect the repository. It handles cases
|
||||
/// where git is missing or the directory is not a repository by returning a
|
||||
/// "not a repo" state rather than an error.
|
||||
pub fn detect_git_state(working_dir: &Path) -> Result<GitState> {
|
||||
// Check if this is a git repository
|
||||
let is_repo = Command::new("git")
|
||||
.arg("rev-parse")
|
||||
.arg("--git-dir")
|
||||
.current_dir(working_dir)
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_repo {
|
||||
return Ok(GitState::not_a_repo());
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
let current_branch = get_current_branch(working_dir)?;
|
||||
|
||||
// Detect main branch (try main first, then master)
|
||||
let main_branch = detect_main_branch(working_dir)?;
|
||||
|
||||
// Get file status
|
||||
let status = get_git_status(working_dir)?;
|
||||
|
||||
// Check if there are uncommitted changes
|
||||
let has_uncommitted_changes = !status.is_empty();
|
||||
|
||||
// Get remote URL
|
||||
let remote_url = get_remote_url(working_dir)?;
|
||||
|
||||
Ok(GitState {
|
||||
is_git_repo: true,
|
||||
current_branch,
|
||||
main_branch,
|
||||
status,
|
||||
has_uncommitted_changes,
|
||||
remote_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current branch name
|
||||
fn get_current_branch(working_dir: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("rev-parse")
|
||||
.arg("--abbrev-ref")
|
||||
.arg("HEAD")
|
||||
.current_dir(working_dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
// "HEAD" means detached HEAD state
|
||||
if branch == "HEAD" {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(branch))
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the main branch (main or master)
|
||||
fn detect_main_branch(working_dir: &Path) -> Result<Option<String>> {
|
||||
// Try to get all branches
|
||||
let output = Command::new("git")
|
||||
.arg("branch")
|
||||
.arg("-a")
|
||||
.current_dir(working_dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let branches = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Check for main branch first (modern convention)
|
||||
if branches.lines().any(|line| {
|
||||
let trimmed = line.trim_start_matches('*').trim();
|
||||
trimmed == "main" || trimmed.ends_with("/main")
|
||||
}) {
|
||||
return Ok(Some("main".to_string()));
|
||||
}
|
||||
|
||||
// Fall back to master
|
||||
if branches.lines().any(|line| {
|
||||
let trimmed = line.trim_start_matches('*').trim();
|
||||
trimmed == "master" || trimmed.ends_with("/master")
|
||||
}) {
|
||||
return Ok(Some("master".to_string()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Get the git status for all files
|
||||
fn get_git_status(working_dir: &Path) -> Result<Vec<GitFileStatus>> {
|
||||
let output = Command::new("git")
|
||||
.arg("status")
|
||||
.arg("--porcelain")
|
||||
.arg("-z") // Null-terminated for better parsing
|
||||
.current_dir(working_dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let status_text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut statuses = Vec::new();
|
||||
|
||||
// Parse porcelain format with null termination
|
||||
// Format: XY filename\0 (where X is staged status, Y is unstaged status)
|
||||
for entry in status_text.split('\0').filter(|s| !s.is_empty()) {
|
||||
if entry.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let status_code = &entry[0..2];
|
||||
let path = entry[3..].to_string();
|
||||
|
||||
// Parse status codes
|
||||
match status_code {
|
||||
"M " | " M" | "MM" => {
|
||||
statuses.push(GitFileStatus::Modified { path });
|
||||
}
|
||||
"A " | " A" | "AM" => {
|
||||
statuses.push(GitFileStatus::Added { path });
|
||||
}
|
||||
"D " | " D" | "AD" => {
|
||||
statuses.push(GitFileStatus::Deleted { path });
|
||||
}
|
||||
"??" => {
|
||||
statuses.push(GitFileStatus::Untracked { path });
|
||||
}
|
||||
s if s.starts_with('R') => {
|
||||
// Renamed files have format "R old_name -> new_name"
|
||||
if let Some((from, to)) = path.split_once(" -> ") {
|
||||
statuses.push(GitFileStatus::Renamed {
|
||||
from: from.to_string(),
|
||||
to: to.to_string(),
|
||||
});
|
||||
} else {
|
||||
// Fallback if parsing fails
|
||||
statuses.push(GitFileStatus::Modified { path });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown status code, treat as modified
|
||||
statuses.push(GitFileStatus::Modified { path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
/// Get the remote URL for the repository
|
||||
fn get_remote_url(working_dir: &Path) -> Result<Option<String>> {
|
||||
let output = Command::new("git")
|
||||
.arg("remote")
|
||||
.arg("get-url")
|
||||
.arg("origin")
|
||||
.current_dir(working_dir)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
if url.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(url))
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristically determines if a git command string is "safe" (read-only).
|
||||
///
|
||||
/// Safe commands (like `status` or `diff`) are generally allowed without
|
||||
/// explicit confirmation in certain modes.
|
||||
pub fn is_safe_git_command(command: &str) -> bool {
|
||||
let parts: Vec<&str> = command.split_whitespace().collect();
|
||||
|
||||
if parts.is_empty() || parts[0] != "git" {
|
||||
return false;
|
||||
}
|
||||
|
||||
if parts.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let subcommand = parts[1];
|
||||
|
||||
// List of read-only git commands
|
||||
match subcommand {
|
||||
"status" | "log" | "show" | "diff" | "blame" | "reflog" => true,
|
||||
"ls-files" | "ls-tree" | "ls-remote" => true,
|
||||
"rev-parse" | "rev-list" => true,
|
||||
"describe" | "tag" if !command.contains("-d") && !command.contains("--delete") => true,
|
||||
"branch" if !command.contains("-D") && !command.contains("-d") && !command.contains("-m") => true,
|
||||
"remote" if command.contains("get-url") || command.contains("-v") || command.contains("show") => true,
|
||||
"config" if command.contains("--get") || command.contains("--list") => true,
|
||||
"grep" | "shortlog" | "whatchanged" => true,
|
||||
"fetch" if !command.contains("--prune") => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristically determines if a git command is "destructive" or potentially dangerous.
|
||||
///
|
||||
/// Returns a tuple of `(is_destructive, warning_message)`. Destructive commands
|
||||
/// (like `reset --hard` or `push --force`) should always trigger a warning or
|
||||
/// require explicit approval.
|
||||
pub fn is_destructive_git_command(command: &str) -> (bool, &'static str) {
|
||||
let cmd_lower = command.to_lowercase();
|
||||
|
||||
// Check for force push
|
||||
if cmd_lower.contains("push") && (cmd_lower.contains("--force") || cmd_lower.contains("-f")) {
|
||||
return (true, "Force push can overwrite remote history and affect other collaborators");
|
||||
}
|
||||
|
||||
// Check for hard reset
|
||||
if cmd_lower.contains("reset") && cmd_lower.contains("--hard") {
|
||||
return (true, "Hard reset will discard uncommitted changes permanently");
|
||||
}
|
||||
|
||||
// Check for git clean
|
||||
if cmd_lower.contains("clean") && (cmd_lower.contains("-f") || cmd_lower.contains("-d")) {
|
||||
return (true, "Git clean will permanently delete untracked files");
|
||||
}
|
||||
|
||||
// Check for rebase
|
||||
if cmd_lower.contains("rebase") {
|
||||
return (true, "Rebase rewrites commit history and can cause conflicts");
|
||||
}
|
||||
|
||||
// Check for amend
|
||||
if cmd_lower.contains("commit") && cmd_lower.contains("--amend") {
|
||||
return (true, "Amending rewrites the last commit and changes its hash");
|
||||
}
|
||||
|
||||
// Check for filter-branch or filter-repo
|
||||
if cmd_lower.contains("filter-branch") || cmd_lower.contains("filter-repo") {
|
||||
return (true, "Filter operations rewrite repository history");
|
||||
}
|
||||
|
||||
// Check for branch/tag deletion
|
||||
if (cmd_lower.contains("branch") && (cmd_lower.contains("-D") || cmd_lower.contains("-d")))
|
||||
|| (cmd_lower.contains("tag") && (cmd_lower.contains("-d") || cmd_lower.contains("--delete")))
|
||||
{
|
||||
return (true, "This will delete a branch or tag");
|
||||
}
|
||||
|
||||
// Check for reflog expire
|
||||
if cmd_lower.contains("reflog") && cmd_lower.contains("expire") {
|
||||
return (true, "Expiring reflog removes recovery points for lost commits");
|
||||
}
|
||||
|
||||
// Check for gc with aggressive or prune
|
||||
if cmd_lower.contains("gc") && (cmd_lower.contains("--aggressive") || cmd_lower.contains("--prune")) {
|
||||
return (true, "Aggressive garbage collection can make recovery difficult");
|
||||
}
|
||||
|
||||
(false, "")
|
||||
}
|
||||
|
||||
/// Formats a `GitState` into a human-readable summary string.
|
||||
pub fn format_git_status(state: &GitState) -> String {
|
||||
if !state.is_git_repo {
|
||||
return "Not a git repository".to_string();
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
lines.push("Git Repository: yes".to_string());
|
||||
|
||||
if let Some(branch) = &state.current_branch {
|
||||
lines.push(format!("Current branch: {}", branch));
|
||||
} else {
|
||||
lines.push("Current branch: (detached HEAD)".to_string());
|
||||
}
|
||||
|
||||
if let Some(main) = &state.main_branch {
|
||||
lines.push(format!("Main branch: {}", main));
|
||||
}
|
||||
|
||||
// Summarize status
|
||||
if state.status.is_empty() {
|
||||
lines.push("Status: clean working tree".to_string());
|
||||
} else {
|
||||
let mut modified = 0;
|
||||
let mut added = 0;
|
||||
let mut deleted = 0;
|
||||
let mut renamed = 0;
|
||||
let mut untracked = 0;
|
||||
|
||||
for status in &state.status {
|
||||
match status {
|
||||
GitFileStatus::Modified { .. } => modified += 1,
|
||||
GitFileStatus::Added { .. } => added += 1,
|
||||
GitFileStatus::Deleted { .. } => deleted += 1,
|
||||
GitFileStatus::Renamed { .. } => renamed += 1,
|
||||
GitFileStatus::Untracked { .. } => untracked += 1,
|
||||
}
|
||||
}
|
||||
|
||||
let mut status_parts = Vec::new();
|
||||
if modified > 0 {
|
||||
status_parts.push(format!("{} modified", modified));
|
||||
}
|
||||
if added > 0 {
|
||||
status_parts.push(format!("{} added", added));
|
||||
}
|
||||
if deleted > 0 {
|
||||
status_parts.push(format!("{} deleted", deleted));
|
||||
}
|
||||
if renamed > 0 {
|
||||
status_parts.push(format!("{} renamed", renamed));
|
||||
}
|
||||
if untracked > 0 {
|
||||
status_parts.push(format!("{} untracked", untracked));
|
||||
}
|
||||
|
||||
lines.push(format!("Status: {}", status_parts.join(", ")));
|
||||
}
|
||||
|
||||
if let Some(url) = &state.remote_url {
|
||||
lines.push(format!("Remote: {}", url));
|
||||
} else {
|
||||
lines.push("Remote: (none)".to_string());
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_safe_git_command() {
|
||||
// Safe commands
|
||||
assert!(is_safe_git_command("git status"));
|
||||
assert!(is_safe_git_command("git log --oneline"));
|
||||
assert!(is_safe_git_command("git diff HEAD"));
|
||||
assert!(is_safe_git_command("git branch -v"));
|
||||
assert!(is_safe_git_command("git remote -v"));
|
||||
assert!(is_safe_git_command("git config --get user.name"));
|
||||
|
||||
// Unsafe commands
|
||||
assert!(!is_safe_git_command("git commit -m test"));
|
||||
assert!(!is_safe_git_command("git push origin main"));
|
||||
assert!(!is_safe_git_command("git branch -D feature"));
|
||||
assert!(!is_safe_git_command("git remote add origin url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_destructive_git_command() {
|
||||
// Destructive commands
|
||||
let (is_dest, msg) = is_destructive_git_command("git push --force origin main");
|
||||
assert!(is_dest);
|
||||
assert!(msg.contains("Force push"));
|
||||
|
||||
let (is_dest, msg) = is_destructive_git_command("git reset --hard HEAD~1");
|
||||
assert!(is_dest);
|
||||
assert!(msg.contains("Hard reset"));
|
||||
|
||||
let (is_dest, msg) = is_destructive_git_command("git clean -fd");
|
||||
assert!(is_dest);
|
||||
assert!(msg.contains("clean"));
|
||||
|
||||
let (is_dest, msg) = is_destructive_git_command("git rebase main");
|
||||
assert!(is_dest);
|
||||
assert!(msg.contains("Rebase"));
|
||||
|
||||
let (is_dest, msg) = is_destructive_git_command("git commit --amend");
|
||||
assert!(is_dest);
|
||||
assert!(msg.contains("Amending"));
|
||||
|
||||
// Non-destructive commands
|
||||
let (is_dest, _) = is_destructive_git_command("git status");
|
||||
assert!(!is_dest);
|
||||
|
||||
let (is_dest, _) = is_destructive_git_command("git log");
|
||||
assert!(!is_dest);
|
||||
|
||||
let (is_dest, _) = is_destructive_git_command("git diff");
|
||||
assert!(!is_dest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_state_not_a_repo() {
|
||||
let state = GitState::not_a_repo();
|
||||
assert!(!state.is_git_repo);
|
||||
assert!(state.current_branch.is_none());
|
||||
assert!(state.main_branch.is_none());
|
||||
assert!(state.status.is_empty());
|
||||
assert!(!state.has_uncommitted_changes);
|
||||
assert!(state.remote_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_file_status_path() {
|
||||
let status = GitFileStatus::Modified {
|
||||
path: "test.rs".to_string(),
|
||||
};
|
||||
assert_eq!(status.path(), "test.rs");
|
||||
|
||||
let status = GitFileStatus::Renamed {
|
||||
from: "old.rs".to_string(),
|
||||
to: "new.rs".to_string(),
|
||||
};
|
||||
assert_eq!(status.path(), "new.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_git_status_not_repo() {
|
||||
let state = GitState::not_a_repo();
|
||||
let formatted = format_git_status(&state);
|
||||
assert_eq!(formatted, "Not a git repository");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_git_status_clean() {
|
||||
let state = GitState {
|
||||
is_git_repo: true,
|
||||
current_branch: Some("main".to_string()),
|
||||
main_branch: Some("main".to_string()),
|
||||
status: Vec::new(),
|
||||
has_uncommitted_changes: false,
|
||||
remote_url: Some("https://github.com/user/repo.git".to_string()),
|
||||
};
|
||||
|
||||
let formatted = format_git_status(&state);
|
||||
assert!(formatted.contains("Git Repository: yes"));
|
||||
assert!(formatted.contains("Current branch: main"));
|
||||
assert!(formatted.contains("clean working tree"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_git_status_with_changes() {
|
||||
let state = GitState {
|
||||
is_git_repo: true,
|
||||
current_branch: Some("feature".to_string()),
|
||||
main_branch: Some("main".to_string()),
|
||||
status: vec![
|
||||
GitFileStatus::Modified {
|
||||
path: "file1.rs".to_string(),
|
||||
},
|
||||
GitFileStatus::Modified {
|
||||
path: "file2.rs".to_string(),
|
||||
},
|
||||
GitFileStatus::Untracked {
|
||||
path: "new.rs".to_string(),
|
||||
},
|
||||
],
|
||||
has_uncommitted_changes: true,
|
||||
remote_url: None,
|
||||
};
|
||||
|
||||
let formatted = format_git_status(&state);
|
||||
assert!(formatted.contains("2 modified"));
|
||||
assert!(formatted.contains("1 untracked"));
|
||||
}
|
||||
}
|
||||
1279
crates/core/agent/src/lib.rs
Normal file
1279
crates/core/agent/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
110
crates/core/agent/src/messages.rs
Normal file
110
crates/core/agent/src/messages.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tools_plan::{PlanStep, AccumulatedPlanStatus, PlanApproval};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Message {
|
||||
UserAction(UserAction),
|
||||
AgentResponse(AgentResponse),
|
||||
System(SystemNotification),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum UserAction {
|
||||
/// User typed a message
|
||||
Input(String),
|
||||
/// User issued a command
|
||||
Command(String),
|
||||
/// User responded to a permission request
|
||||
PermissionResult(bool),
|
||||
/// Exit the application
|
||||
Exit,
|
||||
// Plan mode actions
|
||||
/// Stop accumulating steps, enter review mode
|
||||
FinalizePlan,
|
||||
/// User's approval/rejection of plan steps
|
||||
PlanApproval(PlanApproval),
|
||||
/// Save the current plan with a name
|
||||
SavePlan(String),
|
||||
/// Load a saved plan by ID
|
||||
LoadPlan(String),
|
||||
/// Cancel the current plan
|
||||
CancelPlan,
|
||||
// Provider switching
|
||||
/// Switch to a different provider (ollama, anthropic, openai)
|
||||
SwitchProvider(String),
|
||||
/// Switch to a different model for the current provider
|
||||
SwitchModel(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AgentResponse {
|
||||
/// Streaming text token from LLM
|
||||
Token(String),
|
||||
/// Tool call being executed
|
||||
ToolCall { name: String, args: String },
|
||||
/// Permission needed for a tool
|
||||
PermissionRequest { tool: String, context: Option<String> },
|
||||
/// Legacy: batch staging (deprecated, use PlanStepAdded)
|
||||
PlanStaging(Vec<ToolCallStaging>),
|
||||
/// Agent done responding
|
||||
Complete,
|
||||
/// Error occurred
|
||||
Error(String),
|
||||
// Plan mode responses
|
||||
/// A new step was added to the accumulated plan
|
||||
PlanStepAdded(PlanStep),
|
||||
/// Agent finished proposing steps (in plan mode)
|
||||
PlanComplete {
|
||||
total_steps: usize,
|
||||
status: AccumulatedPlanStatus,
|
||||
},
|
||||
/// A step is currently being executed
|
||||
PlanExecuting {
|
||||
step_id: String,
|
||||
step_index: usize,
|
||||
total_steps: usize,
|
||||
},
|
||||
/// Plan execution finished
|
||||
PlanExecutionComplete {
|
||||
executed: usize,
|
||||
skipped: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Legacy tool call staging (for backward compatibility)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallStaging {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub args: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SystemNotification {
|
||||
StateUpdate(String),
|
||||
Warning(String),
|
||||
/// Plan was saved successfully
|
||||
PlanSaved { id: String, path: String },
|
||||
/// Plan was loaded successfully
|
||||
PlanLoaded { id: String, name: Option<String>, steps: usize },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_channel() {
|
||||
let (tx, mut rx) = mpsc::channel(32);
|
||||
|
||||
let msg = Message::UserAction(UserAction::Input("Hello".to_string()));
|
||||
tx.send(msg).await.unwrap();
|
||||
|
||||
let received = rx.recv().await.unwrap();
|
||||
match received {
|
||||
Message::UserAction(UserAction::Input(s)) => assert_eq!(s, "Hello"),
|
||||
_ => panic!("Wrong message type"),
|
||||
}
|
||||
}
|
||||
}
|
||||
393
crates/core/agent/src/session.rs
Normal file
393
crates/core/agent/src/session.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
//! Session state and history management.
|
||||
//!
|
||||
//! This module provides tools for tracking conversation history, capturing
|
||||
//! usage statistics, and managing session checkpoints for persistence and rewind.
|
||||
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Statistics for a single chat session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionStats {
|
||||
/// The time when the session started.
|
||||
pub start_time: SystemTime,
|
||||
/// Total number of messages exchanged.
|
||||
pub total_messages: usize,
|
||||
/// Total number of tools executed by the agent.
|
||||
pub total_tool_calls: usize,
|
||||
/// Total wall-clock time spent in the session.
|
||||
pub total_duration: Duration,
|
||||
/// Rough estimate of the total tokens used.
|
||||
pub estimated_tokens: usize,
|
||||
}
|
||||
|
||||
impl SessionStats {
|
||||
/// Creates a new `SessionStats` instance with zeroed values.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
start_time: SystemTime::now(),
|
||||
total_messages: 0,
|
||||
total_tool_calls: 0,
|
||||
total_duration: Duration::ZERO,
|
||||
estimated_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a new message in the statistics.
|
||||
pub fn record_message(&mut self, tokens: usize, duration: Duration) {
|
||||
self.total_messages += 1;
|
||||
self.estimated_tokens += tokens;
|
||||
self.total_duration += duration;
|
||||
}
|
||||
|
||||
/// Increments the tool call counter.
|
||||
pub fn record_tool_call(&mut self) {
|
||||
self.total_tool_calls += 1;
|
||||
}
|
||||
|
||||
/// Formats a duration into a human-readable string.
|
||||
pub fn format_duration(d: Duration) -> String {
|
||||
let secs = d.as_secs();
|
||||
if secs < 60 {
|
||||
format!("{}s", secs)
|
||||
} else if secs < 3600 {
|
||||
format!("{}m {}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionStats {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory history of the current session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionHistory {
|
||||
/// List of prompts provided by the user.
|
||||
pub user_prompts: Vec<String>,
|
||||
/// List of responses generated by the assistant.
|
||||
pub assistant_responses: Vec<String>,
|
||||
/// Chronological log of all tool calls made.
|
||||
pub tool_calls: Vec<ToolCallRecord>,
|
||||
}
|
||||
|
||||
/// Record of a single tool execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallRecord {
|
||||
/// Name of the tool that was called.
|
||||
pub tool_name: String,
|
||||
/// JSON-encoded arguments provided to the tool.
|
||||
pub arguments: String,
|
||||
/// Output produced by the tool.
|
||||
pub result: String,
|
||||
/// Whether the tool execution was successful.
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
impl SessionHistory {
|
||||
/// Creates a new, empty `SessionHistory`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
user_prompts: Vec::new(),
|
||||
assistant_responses: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a user message to history.
|
||||
pub fn add_user_message(&mut self, message: String) {
|
||||
self.user_prompts.push(message);
|
||||
}
|
||||
|
||||
/// Appends an assistant response to history.
|
||||
pub fn add_assistant_message(&mut self, message: String) {
|
||||
self.assistant_responses.push(message);
|
||||
}
|
||||
|
||||
/// Appends a tool call record to history.
|
||||
pub fn add_tool_call(&mut self, record: ToolCallRecord) {
|
||||
self.tool_calls.push(record);
|
||||
}
|
||||
|
||||
/// Clears all stored history.
|
||||
pub fn clear(&mut self) {
|
||||
self.user_prompts.clear();
|
||||
self.assistant_responses.clear();
|
||||
self.tool_calls.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionHistory {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a file modification with before/after content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileDiff {
|
||||
/// Absolute path to the file.
|
||||
pub path: PathBuf,
|
||||
/// Content of the file before modification.
|
||||
pub before: String,
|
||||
/// Content of the file after modification.
|
||||
pub after: String,
|
||||
/// When the modification occurred.
|
||||
pub timestamp: SystemTime,
|
||||
}
|
||||
|
||||
impl FileDiff {
|
||||
/// Creates a new `FileDiff`.
|
||||
pub fn new(path: PathBuf, before: String, after: String) -> Self {
|
||||
Self {
|
||||
path,
|
||||
before,
|
||||
after,
|
||||
timestamp: SystemTime::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkpoint captures the full state of a session at a point in time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Checkpoint {
|
||||
/// Unique identifier for the checkpoint.
|
||||
pub id: String,
|
||||
/// When the checkpoint was created.
|
||||
pub timestamp: SystemTime,
|
||||
/// Session statistics at the time of checkpoint.
|
||||
pub stats: SessionStats,
|
||||
/// History of user prompts.
|
||||
pub user_prompts: Vec<String>,
|
||||
/// History of assistant responses.
|
||||
pub assistant_responses: Vec<String>,
|
||||
/// History of tool calls.
|
||||
pub tool_calls: Vec<ToolCallRecord>,
|
||||
/// List of file modifications made during the session.
|
||||
pub file_diffs: Vec<FileDiff>,
|
||||
}
|
||||
|
||||
impl Checkpoint {
|
||||
/// Creates a new checkpoint from the current session state.
|
||||
pub fn new(
|
||||
id: String,
|
||||
stats: SessionStats,
|
||||
history: &SessionHistory,
|
||||
file_diffs: Vec<FileDiff>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
timestamp: SystemTime::now(),
|
||||
stats,
|
||||
user_prompts: history.user_prompts.clone(),
|
||||
assistant_responses: history.assistant_responses.clone(),
|
||||
tool_calls: history.tool_calls.clone(),
|
||||
file_diffs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the checkpoint to a JSON file on disk.
|
||||
pub fn save(&self, checkpoint_dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(checkpoint_dir)?;
|
||||
let path = checkpoint_dir.join(format!("{}.json", self.id));
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads a checkpoint from disk by ID.
|
||||
pub fn load(checkpoint_dir: &Path, id: &str) -> Result<Self> {
|
||||
let path = checkpoint_dir.join(format!("{}.json", id));
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|e| eyre!("Failed to read checkpoint: {}", e))?;
|
||||
let checkpoint: Checkpoint = serde_json::from_str(&content)
|
||||
.map_err(|e| eyre!("Failed to parse checkpoint: {}", e))?;
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
/// Lists all available checkpoint IDs in the given directory.
|
||||
pub fn list(checkpoint_dir: &Path) -> Result<Vec<String>> {
|
||||
if !checkpoint_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut checkpoints = Vec::new();
|
||||
for entry in fs::read_dir(checkpoint_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("json")
|
||||
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
|
||||
{
|
||||
checkpoints.push(stem.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by checkpoint ID (which includes timestamp)
|
||||
checkpoints.sort();
|
||||
Ok(checkpoints)
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages the creation and restoration of session checkpoints.
|
||||
pub struct CheckpointManager {
|
||||
checkpoint_dir: PathBuf,
|
||||
file_snapshots: HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl CheckpointManager {
|
||||
/// Creates a new `CheckpointManager` pointing to the specified directory.
|
||||
pub fn new(checkpoint_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
checkpoint_dir,
|
||||
file_snapshots: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshots a file's current content before modification to track changes.
|
||||
pub fn snapshot_file(&mut self, path: &Path) -> Result<()> {
|
||||
if !self.file_snapshots.contains_key(path) {
|
||||
let content = fs::read_to_string(path).unwrap_or_default();
|
||||
self.file_snapshots.insert(path.to_path_buf(), content);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a `FileDiff` if the file has been modified since it was snapshotted.
|
||||
pub fn create_diff(&self, path: &Path) -> Result<Option<FileDiff>> {
|
||||
if let Some(before) = self.file_snapshots.get(path) {
|
||||
let after = fs::read_to_string(path).unwrap_or_default();
|
||||
if before != &after {
|
||||
Ok(Some(FileDiff::new(
|
||||
path.to_path_buf(),
|
||||
before.clone(),
|
||||
after,
|
||||
)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all file modifications tracked since the last checkpoint.
|
||||
pub fn get_all_diffs(&self) -> Result<Vec<FileDiff>> {
|
||||
let mut diffs = Vec::new();
|
||||
for (path, before) in &self.file_snapshots {
|
||||
let after = fs::read_to_string(path).unwrap_or_default();
|
||||
if before != &after {
|
||||
diffs.push(FileDiff::new(path.clone(), before.clone(), after));
|
||||
}
|
||||
}
|
||||
Ok(diffs)
|
||||
}
|
||||
|
||||
/// Clears all internal file snapshots.
|
||||
pub fn clear_snapshots(&mut self) {
|
||||
self.file_snapshots.clear();
|
||||
}
|
||||
|
||||
/// Saves the current session state as a new checkpoint.
|
||||
pub fn save_checkpoint(
|
||||
&mut self,
|
||||
id: String,
|
||||
stats: SessionStats,
|
||||
history: &SessionHistory,
|
||||
) -> Result<Checkpoint> {
|
||||
let file_diffs = self.get_all_diffs()?;
|
||||
let checkpoint = Checkpoint::new(id, stats, history, file_diffs);
|
||||
checkpoint.save(&self.checkpoint_dir)?;
|
||||
self.clear_snapshots();
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
/// Loads a checkpoint by ID.
|
||||
pub fn load_checkpoint(&self, id: &str) -> Result<Checkpoint> {
|
||||
Checkpoint::load(&self.checkpoint_dir, id)
|
||||
}
|
||||
|
||||
/// Lists all available checkpoints.
|
||||
pub fn list_checkpoints(&self) -> Result<Vec<String>> {
|
||||
Checkpoint::list(&self.checkpoint_dir)
|
||||
}
|
||||
|
||||
/// Rewinds the local filesystem to the state captured in the specified checkpoint.
|
||||
///
|
||||
/// Returns a list of paths that were restored.
|
||||
pub fn rewind_to(&self, checkpoint_id: &str) -> Result<Vec<PathBuf>> {
|
||||
let checkpoint = self.load_checkpoint(checkpoint_id)?;
|
||||
let mut restored_files = Vec::new();
|
||||
|
||||
// Restore files from diffs (revert to 'before' state)
|
||||
for diff in &checkpoint.file_diffs {
|
||||
fs::write(&diff.path, &diff.before)?;
|
||||
restored_files.push(diff.path.clone());
|
||||
}
|
||||
|
||||
Ok(restored_files)
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to accumulate streaming tool call deltas
|
||||
#[derive(Default)]
|
||||
pub struct ToolCallsBuilder {
|
||||
calls: Vec<PartialToolCallBuilder>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PartialToolCallBuilder {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
impl ToolCallsBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_deltas(&mut self, deltas: &[llm_core::ToolCallDelta]) {
|
||||
for delta in deltas {
|
||||
while self.calls.len() <= delta.index {
|
||||
self.calls.push(PartialToolCallBuilder::default());
|
||||
}
|
||||
let call = &mut self.calls[delta.index];
|
||||
if let Some(id) = &delta.id {
|
||||
call.id = Some(id.clone());
|
||||
}
|
||||
if let Some(name) = &delta.function_name {
|
||||
call.name = Some(name.clone());
|
||||
}
|
||||
if let Some(args) = &delta.arguments_delta {
|
||||
call.arguments.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(self) -> Vec<llm_core::ToolCall> {
|
||||
self.calls
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let id = p.id?;
|
||||
let name = p.name?;
|
||||
let args: serde_json::Value = serde_json::from_str(&p.arguments).ok()?;
|
||||
Some(llm_core::ToolCall {
|
||||
id,
|
||||
call_type: "function".to_string(),
|
||||
function: llm_core::FunctionCall {
|
||||
name,
|
||||
arguments: args,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
128
crates/core/agent/src/state.rs
Normal file
128
crates/core/agent/src/state.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use llm_core::ChatMessage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tools_plan::{AccumulatedPlan, PlanApproval};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AppMode {
|
||||
Normal,
|
||||
Plan,
|
||||
AcceptAll,
|
||||
}
|
||||
|
||||
impl Default for AppMode {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared application state
|
||||
#[derive(Debug)]
|
||||
pub struct AppState {
|
||||
/// Conversation history
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// Whether the application is running
|
||||
pub running: bool,
|
||||
/// Current permission mode
|
||||
pub mode: AppMode,
|
||||
/// Result of last permission request
|
||||
pub last_permission_result: Option<bool>,
|
||||
/// Notify channel for permission responses
|
||||
pub permission_notify: Arc<Notify>,
|
||||
/// Legacy: pending actions (deprecated)
|
||||
pub pending_actions: Vec<String>,
|
||||
/// Current accumulated plan (for Plan mode)
|
||||
pub accumulated_plan: Option<AccumulatedPlan>,
|
||||
/// Pending plan approval from user
|
||||
pub plan_approval: Option<PlanApproval>,
|
||||
/// Notify channel for plan approval responses
|
||||
pub plan_notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
running: true,
|
||||
mode: AppMode::Normal,
|
||||
last_permission_result: None,
|
||||
permission_notify: Arc::new(Notify::new()),
|
||||
pending_actions: Vec::new(),
|
||||
accumulated_plan: None,
|
||||
plan_approval: None,
|
||||
plan_notify: Arc::new(Notify::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&mut self, message: ChatMessage) {
|
||||
self.messages.push(message);
|
||||
}
|
||||
|
||||
pub fn set_permission_result(&mut self, result: bool) {
|
||||
self.last_permission_result = Some(result);
|
||||
self.permission_notify.notify_one();
|
||||
}
|
||||
|
||||
/// Start a new accumulated plan
|
||||
pub fn start_plan(&mut self) {
|
||||
self.accumulated_plan = Some(AccumulatedPlan::new());
|
||||
}
|
||||
|
||||
/// Start a new accumulated plan with a name
|
||||
pub fn start_plan_with_name(&mut self, name: String) {
|
||||
self.accumulated_plan = Some(AccumulatedPlan::with_name(name));
|
||||
}
|
||||
|
||||
/// Get the current plan (if any)
|
||||
pub fn current_plan(&self) -> Option<&AccumulatedPlan> {
|
||||
self.accumulated_plan.as_ref()
|
||||
}
|
||||
|
||||
/// Get the current plan mutably (if any)
|
||||
pub fn current_plan_mut(&mut self) -> Option<&mut AccumulatedPlan> {
|
||||
self.accumulated_plan.as_mut()
|
||||
}
|
||||
|
||||
/// Clear the current plan
|
||||
pub fn clear_plan(&mut self) {
|
||||
self.accumulated_plan = None;
|
||||
self.plan_approval = None;
|
||||
}
|
||||
|
||||
/// Set plan approval and notify waiting tasks
|
||||
pub fn set_plan_approval(&mut self, approval: PlanApproval) {
|
||||
self.plan_approval = Some(approval);
|
||||
self.plan_notify.notify_one();
|
||||
}
|
||||
|
||||
/// Take the plan approval (consuming it)
|
||||
pub fn take_plan_approval(&mut self) -> Option<PlanApproval> {
|
||||
self.plan_approval.take()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_app_state_sharing() {
|
||||
let state = Arc::new(Mutex::new(AppState::new()));
|
||||
|
||||
let state_clone = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = state_clone.lock().await;
|
||||
guard.add_message(ChatMessage::user("Test"));
|
||||
}).await.unwrap();
|
||||
|
||||
let guard = state.lock().await;
|
||||
assert_eq!(guard.messages.len(), 1);
|
||||
}
|
||||
}
|
||||
273
crates/core/agent/src/system_prompt.rs
Normal file
273
crates/core/agent/src/system_prompt.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! System Prompt Management.
|
||||
//!
|
||||
//! This module is responsible for composing the complex system prompts sent to the LLM.
|
||||
//! It merges base instructions, tool definitions, project-specific context, and
|
||||
//! dynamically injected content from skills and hooks.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Builder for incrementally composing a system prompt from various sources.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SystemPromptBuilder {
|
||||
sections: Vec<PromptSection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PromptSection {
|
||||
#[allow(dead_code)] // Used for debugging/display purposes
|
||||
name: String,
|
||||
content: String,
|
||||
priority: i32, // Lower = earlier in prompt
|
||||
}
|
||||
|
||||
impl SystemPromptBuilder {
|
||||
/// Creates a new, empty `SystemPromptBuilder`.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Adds the base agent identity and instructions section.
|
||||
pub fn with_base_prompt(mut self, content: impl Into<String>) -> Self {
|
||||
self.sections.push(PromptSection {
|
||||
name: "base".to_string(),
|
||||
content: content.into(),
|
||||
priority: 0,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds tool usage instructions.
|
||||
pub fn with_tool_instructions(mut self, content: impl Into<String>) -> Self {
|
||||
self.sections.push(PromptSection {
|
||||
name: "tools".to_string(),
|
||||
content: content.into(),
|
||||
priority: 10,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Attempts to load project-specific instructions from `CLAUDE.md` or `.owlen.md`
|
||||
/// located in the provided project root.
|
||||
pub fn with_project_instructions(mut self, project_root: &Path) -> Self {
|
||||
// Try CLAUDE.md first (Claude Code compatibility)
|
||||
let claude_md = project_root.join("CLAUDE.md");
|
||||
if claude_md.exists()
|
||||
&& let Ok(content) = std::fs::read_to_string(&claude_md)
|
||||
{
|
||||
self.sections.push(PromptSection {
|
||||
name: "project".to_string(),
|
||||
content: format!("# Project Instructions\n\n{}", content),
|
||||
priority: 20,
|
||||
});
|
||||
return self;
|
||||
}
|
||||
|
||||
// Fallback to .owlen.md
|
||||
let owlen_md = project_root.join(".owlen.md");
|
||||
if owlen_md.exists()
|
||||
&& let Ok(content) = std::fs::read_to_string(&owlen_md)
|
||||
{
|
||||
self.sections.push(PromptSection {
|
||||
name: "project".to_string(),
|
||||
content: format!("# Project Instructions\n\n{}", content),
|
||||
priority: 20,
|
||||
});
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds domain-specific knowledge or instructions as a "skill".
|
||||
pub fn with_skill(mut self, skill_name: &str, content: impl Into<String>) -> Self {
|
||||
self.sections.push(PromptSection {
|
||||
name: format!("skill:{}", skill_name),
|
||||
content: content.into(),
|
||||
priority: 30,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Injects context dynamically from hooks (e.g., during session initialization).
|
||||
pub fn with_hook_injection(mut self, content: impl Into<String>) -> Self {
|
||||
self.sections.push(PromptSection {
|
||||
name: "hook".to_string(),
|
||||
content: content.into(),
|
||||
priority: 40,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a generic custom section with a specific name and priority.
|
||||
pub fn with_section(mut self, name: impl Into<String>, content: impl Into<String>, priority: i32) -> Self {
|
||||
self.sections.push(PromptSection {
|
||||
name: name.into(),
|
||||
content: content.into(),
|
||||
priority,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Finalizes the build process and returns the combined system prompt string.
|
||||
///
|
||||
/// Sections are sorted by priority and separated by a horizontal rule (`---`).
|
||||
pub fn build(mut self) -> String {
|
||||
// Sort by priority
|
||||
self.sections.sort_by_key(|s| s.priority);
|
||||
|
||||
// Join sections with separators
|
||||
self.sections
|
||||
.iter()
|
||||
.map(|s| s.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n---\n\n")
|
||||
}
|
||||
|
||||
/// Returns `true` if no prompt sections have been added yet.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sections.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the standard default identity and guideline prompt for the Owlen agent.
|
||||
pub fn default_base_prompt() -> &'static str {
|
||||
r#"You are Owlen, an AI assistant that helps with software engineering tasks.
|
||||
|
||||
You have access to tools for reading files, writing code, running commands, and searching the web.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. Be direct and concise in your responses
|
||||
2. Use tools to gather information before making changes
|
||||
3. Explain your reasoning when making decisions
|
||||
4. Ask for clarification when requirements are unclear
|
||||
5. Prefer editing existing files over creating new ones
|
||||
|
||||
## Tool Usage
|
||||
|
||||
- Use `read` to examine file contents before editing
|
||||
- Use `glob` and `grep` to find relevant files
|
||||
- Use `edit` for precise changes, `write` for new files
|
||||
- Use `bash` for running tests and commands
|
||||
- Use `web_search` for current information"#
|
||||
}
|
||||
|
||||
/// Dynamically generates a summary of available tools to be included in the system prompt.
|
||||
pub fn generate_tool_instructions(tool_names: &[&str]) -> String {
|
||||
let mut instructions = String::from("## Available Tools\n\n");
|
||||
|
||||
for name in tool_names {
|
||||
let desc = match *name {
|
||||
"read" => "Read file contents",
|
||||
"write" => "Create or overwrite a file",
|
||||
"edit" => "Edit a file by replacing text",
|
||||
"multi_edit" => "Apply multiple edits atomically",
|
||||
"glob" => "Find files by pattern",
|
||||
"grep" => "Search file contents",
|
||||
"ls" => "List directory contents",
|
||||
"bash" => "Execute shell commands",
|
||||
"web_search" => "Search the web",
|
||||
"web_fetch" => "Fetch a URL",
|
||||
"todo_write" => "Update task list",
|
||||
"ask_user" => "Ask user a question",
|
||||
_ => continue,
|
||||
};
|
||||
instructions.push_str(&format!("- `{}`: {}\n", name, desc));
|
||||
}
|
||||
|
||||
instructions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builder() {
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_base_prompt("You are helpful")
|
||||
.with_tool_instructions("Use tools wisely")
|
||||
.build();
|
||||
|
||||
assert!(prompt.contains("You are helpful"));
|
||||
assert!(prompt.contains("Use tools wisely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_priority_ordering() {
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_section("last", "Third", 100)
|
||||
.with_section("first", "First", 0)
|
||||
.with_section("middle", "Second", 50)
|
||||
.build();
|
||||
|
||||
let first_pos = prompt.find("First").unwrap();
|
||||
let second_pos = prompt.find("Second").unwrap();
|
||||
let third_pos = prompt.find("Third").unwrap();
|
||||
|
||||
assert!(first_pos < second_pos);
|
||||
assert!(second_pos < third_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_prompt() {
|
||||
let prompt = default_base_prompt();
|
||||
assert!(prompt.contains("Owlen"));
|
||||
assert!(prompt.contains("Guidelines"));
|
||||
assert!(prompt.contains("Tool Usage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_tool_instructions() {
|
||||
let tools = vec!["read", "write", "edit", "bash"];
|
||||
let instructions = generate_tool_instructions(&tools);
|
||||
|
||||
assert!(instructions.contains("Available Tools"));
|
||||
assert!(instructions.contains("read"));
|
||||
assert!(instructions.contains("write"));
|
||||
assert!(instructions.contains("edit"));
|
||||
assert!(instructions.contains("bash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_empty() {
|
||||
let builder = SystemPromptBuilder::new();
|
||||
assert!(builder.is_empty());
|
||||
|
||||
let builder = builder.with_base_prompt("test");
|
||||
assert!(!builder.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_section() {
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_base_prompt("Base")
|
||||
.with_skill("rust", "Rust expertise")
|
||||
.build();
|
||||
|
||||
assert!(prompt.contains("Base"));
|
||||
assert!(prompt.contains("Rust expertise"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hook_injection() {
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_base_prompt("Base")
|
||||
.with_hook_injection("Additional context from hook")
|
||||
.build();
|
||||
|
||||
assert!(prompt.contains("Base"));
|
||||
assert!(prompt.contains("Additional context from hook"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_separator_between_sections() {
|
||||
let prompt = SystemPromptBuilder::new()
|
||||
.with_section("first", "First section", 0)
|
||||
.with_section("second", "Second section", 10)
|
||||
.build();
|
||||
|
||||
assert!(prompt.contains("---"));
|
||||
assert!(prompt.contains("First section"));
|
||||
assert!(prompt.contains("Second section"));
|
||||
}
|
||||
}
|
||||
210
crates/core/agent/tests/checkpoint.rs
Normal file
210
crates/core/agent/tests/checkpoint.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
use agent_core::{Checkpoint, CheckpointManager, FileDiff, SessionHistory, SessionStats};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_save_and_load() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().to_path_buf();
|
||||
|
||||
let stats = SessionStats::new();
|
||||
let mut history = SessionHistory::new();
|
||||
history.add_user_message("Hello".to_string());
|
||||
history.add_assistant_message("Hi there!".to_string());
|
||||
|
||||
let file_diffs = vec![FileDiff::new(
|
||||
PathBuf::from("test.txt"),
|
||||
"before".to_string(),
|
||||
"after".to_string(),
|
||||
)];
|
||||
|
||||
let checkpoint = Checkpoint::new(
|
||||
"test-checkpoint".to_string(),
|
||||
stats.clone(),
|
||||
&history,
|
||||
file_diffs,
|
||||
);
|
||||
|
||||
// Save checkpoint
|
||||
checkpoint.save(&checkpoint_dir).unwrap();
|
||||
|
||||
// Load checkpoint
|
||||
let loaded = Checkpoint::load(&checkpoint_dir, "test-checkpoint").unwrap();
|
||||
|
||||
assert_eq!(loaded.id, "test-checkpoint");
|
||||
assert_eq!(loaded.user_prompts, vec!["Hello"]);
|
||||
assert_eq!(loaded.assistant_responses, vec!["Hi there!"]);
|
||||
assert_eq!(loaded.file_diffs.len(), 1);
|
||||
assert_eq!(loaded.file_diffs[0].path, PathBuf::from("test.txt"));
|
||||
assert_eq!(loaded.file_diffs[0].before, "before");
|
||||
assert_eq!(loaded.file_diffs[0].after, "after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_list() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().to_path_buf();
|
||||
|
||||
// Create a few checkpoints
|
||||
for i in 1..=3 {
|
||||
let checkpoint = Checkpoint::new(
|
||||
format!("checkpoint-{}", i),
|
||||
SessionStats::new(),
|
||||
&SessionHistory::new(),
|
||||
vec![],
|
||||
);
|
||||
checkpoint.save(&checkpoint_dir).unwrap();
|
||||
}
|
||||
|
||||
let checkpoints = Checkpoint::list(&checkpoint_dir).unwrap();
|
||||
assert_eq!(checkpoints.len(), 3);
|
||||
assert!(checkpoints.contains(&"checkpoint-1".to_string()));
|
||||
assert!(checkpoints.contains(&"checkpoint-2".to_string()));
|
||||
assert!(checkpoints.contains(&"checkpoint-3".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_manager_snapshot_and_diff() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
|
||||
// Create initial file content
|
||||
fs::write(&test_file, "initial content").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot the file
|
||||
manager.snapshot_file(&test_file).unwrap();
|
||||
|
||||
// Modify the file
|
||||
fs::write(&test_file, "modified content").unwrap();
|
||||
|
||||
// Create a diff
|
||||
let diff = manager.create_diff(&test_file).unwrap();
|
||||
assert!(diff.is_some());
|
||||
|
||||
let diff = diff.unwrap();
|
||||
assert_eq!(diff.path, test_file);
|
||||
assert_eq!(diff.before, "initial content");
|
||||
assert_eq!(diff.after, "modified content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_manager_save_and_restore() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
|
||||
// Create initial file content
|
||||
fs::write(&test_file, "initial content").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot the file
|
||||
manager.snapshot_file(&test_file).unwrap();
|
||||
|
||||
// Modify the file
|
||||
fs::write(&test_file, "modified content").unwrap();
|
||||
|
||||
// Save checkpoint
|
||||
let mut history = SessionHistory::new();
|
||||
history.add_user_message("test".to_string());
|
||||
let checkpoint = manager
|
||||
.save_checkpoint("test-checkpoint".to_string(), SessionStats::new(), &history)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checkpoint.file_diffs.len(), 1);
|
||||
assert_eq!(checkpoint.file_diffs[0].before, "initial content");
|
||||
assert_eq!(checkpoint.file_diffs[0].after, "modified content");
|
||||
|
||||
// Modify file again
|
||||
fs::write(&test_file, "final content").unwrap();
|
||||
assert_eq!(fs::read_to_string(&test_file).unwrap(), "final content");
|
||||
|
||||
// Rewind to checkpoint
|
||||
let restored_files = manager.rewind_to("test-checkpoint").unwrap();
|
||||
assert_eq!(restored_files.len(), 1);
|
||||
assert_eq!(restored_files[0], test_file);
|
||||
|
||||
// File should be reverted to initial content (before the checkpoint)
|
||||
assert_eq!(fs::read_to_string(&test_file).unwrap(), "initial content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_manager_multiple_files() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file1 = temp_dir.path().join("file1.txt");
|
||||
let test_file2 = temp_dir.path().join("file2.txt");
|
||||
|
||||
// Create initial files
|
||||
fs::write(&test_file1, "file1 initial").unwrap();
|
||||
fs::write(&test_file2, "file2 initial").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot both files
|
||||
manager.snapshot_file(&test_file1).unwrap();
|
||||
manager.snapshot_file(&test_file2).unwrap();
|
||||
|
||||
// Modify both files
|
||||
fs::write(&test_file1, "file1 modified").unwrap();
|
||||
fs::write(&test_file2, "file2 modified").unwrap();
|
||||
|
||||
// Save checkpoint
|
||||
let checkpoint = manager
|
||||
.save_checkpoint(
|
||||
"multi-file-checkpoint".to_string(),
|
||||
SessionStats::new(),
|
||||
&SessionHistory::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checkpoint.file_diffs.len(), 2);
|
||||
|
||||
// Modify files again
|
||||
fs::write(&test_file1, "file1 final").unwrap();
|
||||
fs::write(&test_file2, "file2 final").unwrap();
|
||||
|
||||
// Rewind
|
||||
let restored_files = manager.rewind_to("multi-file-checkpoint").unwrap();
|
||||
assert_eq!(restored_files.len(), 2);
|
||||
|
||||
// Both files should be reverted
|
||||
assert_eq!(fs::read_to_string(&test_file1).unwrap(), "file1 initial");
|
||||
assert_eq!(fs::read_to_string(&test_file2).unwrap(), "file2 initial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_no_changes() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let checkpoint_dir = temp_dir.path().join("checkpoints");
|
||||
let test_file = temp_dir.path().join("test.txt");
|
||||
|
||||
// Create file
|
||||
fs::write(&test_file, "content").unwrap();
|
||||
|
||||
let mut manager = CheckpointManager::new(checkpoint_dir.clone());
|
||||
|
||||
// Snapshot the file
|
||||
manager.snapshot_file(&test_file).unwrap();
|
||||
|
||||
// Don't modify the file
|
||||
|
||||
// Create diff - should be None because nothing changed
|
||||
let diff = manager.create_diff(&test_file).unwrap();
|
||||
assert!(diff.is_none());
|
||||
|
||||
// Save checkpoint - should have no diffs
|
||||
let checkpoint = manager
|
||||
.save_checkpoint(
|
||||
"no-change-checkpoint".to_string(),
|
||||
SessionStats::new(),
|
||||
&SessionHistory::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(checkpoint.file_diffs.len(), 0);
|
||||
}
|
||||
75
crates/core/agent/tests/core_logic.rs
Normal file
75
crates/core/agent/tests/core_logic.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use agent_core::{get_tool_definitions, ToolContext, execute_tool, AgentMode};
|
||||
use permissions::{Mode, PermissionManager};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_get_tool_definitions() {
|
||||
let tools = get_tool_definitions();
|
||||
assert!(!tools.is_empty());
|
||||
|
||||
// Check for some specific tools
|
||||
let has_read = tools.iter().any(|t| t.function.name == "read");
|
||||
let has_write = tools.iter().any(|t| t.function.name == "write");
|
||||
let has_bash = tools.iter().any(|t| t.function.name == "bash");
|
||||
|
||||
assert!(has_read);
|
||||
assert!(has_write);
|
||||
assert!(has_bash);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_tool_permission_ask() {
|
||||
let ctx = ToolContext::new();
|
||||
let perms = PermissionManager::new(Mode::Plan); // Plan mode asks for write
|
||||
|
||||
let arguments = json!({
|
||||
"path": "test.txt",
|
||||
"content": "hello"
|
||||
});
|
||||
|
||||
let result = execute_tool("write", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Permission required"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_tool() {
|
||||
let ctx = ToolContext::new();
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
let arguments = json!({});
|
||||
|
||||
let result = execute_tool("non_existent_tool", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unknown tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_context_mode_management() {
|
||||
let ctx = ToolContext::new();
|
||||
assert_eq!(ctx.get_mode().await, AgentMode::Normal);
|
||||
assert!(!ctx.is_planning().await);
|
||||
|
||||
ctx.set_mode(AgentMode::Planning {
|
||||
plan_file: "test_plan.md".into(),
|
||||
started_at: chrono::Utc::now()
|
||||
}).await;
|
||||
|
||||
match ctx.get_mode().await {
|
||||
AgentMode::Planning { plan_file, .. } => assert_eq!(plan_file.to_str().unwrap(), "test_plan.md"),
|
||||
_ => panic!("Expected Planning mode"),
|
||||
}
|
||||
assert!(ctx.is_planning().await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_event_channel() {
|
||||
let (tx, mut rx) = agent_core::create_event_channel();
|
||||
|
||||
tx.send(agent_core::AgentEvent::TextDelta("hello".into())).await.unwrap();
|
||||
|
||||
if let Some(agent_core::AgentEvent::TextDelta(text)) = rx.recv().await {
|
||||
assert_eq!(text, "hello");
|
||||
} else {
|
||||
panic!("Expected TextDelta event");
|
||||
}
|
||||
}
|
||||
200
crates/core/agent/tests/plan_mode.rs
Normal file
200
crates/core/agent/tests/plan_mode.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
//! Integration tests for Plan Mode multi-turn accumulation
|
||||
|
||||
use agent_core::{
|
||||
AccumulatedPlan, AccumulatedPlanStatus, PlanApproval, PlanStep,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// Test that PlanStep can be created and has correct initial state
|
||||
#[test]
|
||||
fn test_plan_step_creation() {
|
||||
let step = PlanStep::new(
|
||||
"call_123".to_string(),
|
||||
1,
|
||||
"read".to_string(),
|
||||
json!({"path": "/src/main.rs"}),
|
||||
);
|
||||
|
||||
assert_eq!(step.id, "call_123");
|
||||
assert_eq!(step.turn, 1);
|
||||
assert_eq!(step.tool, "read");
|
||||
assert!(step.is_pending());
|
||||
assert!(!step.is_approved());
|
||||
assert!(!step.is_rejected());
|
||||
}
|
||||
|
||||
/// Test multi-turn accumulation of steps
|
||||
#[test]
|
||||
fn test_multi_turn_accumulation() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
|
||||
assert_eq!(plan.current_turn, 0);
|
||||
|
||||
// Turn 1: Agent proposes reading a file
|
||||
plan.next_turn();
|
||||
plan.add_step_with_rationale(
|
||||
"call_1".to_string(),
|
||||
"read".to_string(),
|
||||
json!({"path": "Cargo.toml"}),
|
||||
"I need to read the project configuration".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(plan.steps.len(), 1);
|
||||
assert_eq!(plan.current_turn, 1);
|
||||
|
||||
// Turn 2: Agent proposes editing the file
|
||||
plan.next_turn();
|
||||
plan.add_step_with_rationale(
|
||||
"call_2".to_string(),
|
||||
"edit".to_string(),
|
||||
json!({"path": "Cargo.toml", "old": "version = \"0.1.0\"", "new": "version = \"0.2.0\""}),
|
||||
"Bump the version".to_string(),
|
||||
);
|
||||
|
||||
// Turn 2: Agent also proposes running tests
|
||||
plan.add_step_with_rationale(
|
||||
"call_3".to_string(),
|
||||
"bash".to_string(),
|
||||
json!({"command": "cargo test"}),
|
||||
"Verify the change doesn't break anything".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(plan.steps.len(), 3);
|
||||
assert_eq!(plan.current_turn, 2);
|
||||
|
||||
// All steps should be pending
|
||||
let (pending, approved, rejected) = plan.counts();
|
||||
assert_eq!((pending, approved, rejected), (3, 0, 0));
|
||||
}
|
||||
|
||||
/// Test plan finalization and status transition
|
||||
#[test]
|
||||
fn test_plan_finalization() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.next_turn();
|
||||
plan.add_step_for_current_turn("call_1".to_string(), "read".to_string(), json!({}));
|
||||
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Accumulating);
|
||||
|
||||
plan.finalize();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing);
|
||||
}
|
||||
|
||||
/// Test selective approval workflow
|
||||
#[test]
|
||||
fn test_selective_approval() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
|
||||
// Add three steps
|
||||
plan.add_step_for_current_turn("step_read".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("step_write".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("step_bash".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
plan.finalize();
|
||||
|
||||
// User approves read and write, rejects bash
|
||||
let approval = PlanApproval {
|
||||
approved_ids: vec!["step_read".to_string(), "step_write".to_string()],
|
||||
rejected_ids: vec!["step_bash".to_string()],
|
||||
};
|
||||
|
||||
approval.apply_to(&mut plan);
|
||||
|
||||
// Verify approval state
|
||||
assert!(plan.steps[0].is_approved());
|
||||
assert!(plan.steps[1].is_approved());
|
||||
assert!(plan.steps[2].is_rejected());
|
||||
|
||||
assert_eq!(plan.approved_steps().len(), 2);
|
||||
assert_eq!(plan.rejected_steps().len(), 1);
|
||||
assert!(plan.all_decided());
|
||||
}
|
||||
|
||||
/// Test execution workflow
|
||||
#[test]
|
||||
fn test_execution_workflow() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("s2".to_string(), "write".to_string(), json!({}));
|
||||
|
||||
// Finalize
|
||||
plan.finalize();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Reviewing);
|
||||
|
||||
// Approve all
|
||||
plan.approve_all();
|
||||
|
||||
// Start execution
|
||||
plan.start_execution();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Executing);
|
||||
|
||||
// Complete execution
|
||||
plan.complete();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Completed);
|
||||
}
|
||||
|
||||
/// Test plan cancellation
|
||||
#[test]
|
||||
fn test_plan_cancellation() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({}));
|
||||
plan.finalize();
|
||||
|
||||
plan.cancel();
|
||||
assert_eq!(plan.status, AccumulatedPlanStatus::Cancelled);
|
||||
}
|
||||
|
||||
/// Test approve_all only affects pending steps
|
||||
#[test]
|
||||
fn test_approve_all_respects_existing_decisions() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("s1".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("s2".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("s3".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
// Reject s3 before approve_all
|
||||
plan.reject_step("s3");
|
||||
|
||||
// Approve all (should only affect pending)
|
||||
plan.approve_all();
|
||||
|
||||
assert!(plan.steps[0].is_approved()); // was pending, now approved
|
||||
assert!(plan.steps[1].is_approved()); // was pending, now approved
|
||||
assert!(plan.steps[2].is_rejected()); // was rejected, stays rejected
|
||||
}
|
||||
|
||||
/// Test plan with name
|
||||
#[test]
|
||||
fn test_named_plan() {
|
||||
let plan = AccumulatedPlan::with_name("Fix bug #123".to_string());
|
||||
assert_eq!(plan.name, Some("Fix bug #123".to_string()));
|
||||
}
|
||||
|
||||
/// Test step rationale tracking
|
||||
#[test]
|
||||
fn test_step_rationale() {
|
||||
let step = PlanStep::new("id".to_string(), 1, "read".to_string(), json!({}))
|
||||
.with_rationale("I need to understand the code structure".to_string());
|
||||
|
||||
assert_eq!(
|
||||
step.rationale,
|
||||
Some("I need to understand the code structure".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// Test filtering steps by approval status
|
||||
#[test]
|
||||
fn test_step_filtering() {
|
||||
let mut plan = AccumulatedPlan::new();
|
||||
plan.add_step_for_current_turn("a".to_string(), "read".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("b".to_string(), "write".to_string(), json!({}));
|
||||
plan.add_step_for_current_turn("c".to_string(), "bash".to_string(), json!({}));
|
||||
|
||||
plan.approve_step("a");
|
||||
plan.reject_step("b");
|
||||
// c remains pending
|
||||
|
||||
assert_eq!(plan.approved_steps().len(), 1);
|
||||
assert_eq!(plan.rejected_steps().len(), 1);
|
||||
assert_eq!(plan.pending_steps().len(), 1);
|
||||
}
|
||||
276
crates/core/agent/tests/streaming.rs
Normal file
276
crates/core/agent/tests/streaming.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use agent_core::{create_event_channel, run_agent_loop_streaming, AgentEvent, ToolContext};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::stream;
|
||||
use llm_core::{
|
||||
ChatMessage, ChatOptions, LlmError, StreamChunk, LlmProvider, Tool, ToolCallDelta,
|
||||
};
|
||||
use permissions::{Mode, PermissionManager};
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Mock LLM provider for testing streaming
|
||||
struct MockStreamingProvider {
|
||||
responses: Vec<MockResponse>,
|
||||
}
|
||||
|
||||
enum MockResponse {
|
||||
/// Text-only response (no tool calls)
|
||||
Text(Vec<String>), // Chunks of text
|
||||
/// Tool call response
|
||||
ToolCall {
|
||||
text_chunks: Vec<String>,
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
tool_args: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for MockStreamingProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
"mock-model"
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_options: &ChatOptions,
|
||||
_tools: Option<&[Tool]>,
|
||||
) -> Result<Pin<Box<dyn futures_util::Stream<Item = Result<StreamChunk, LlmError>> + Send>>, LlmError> {
|
||||
// Determine which response to use based on message count
|
||||
let response_idx = (messages.len() / 2).min(self.responses.len() - 1);
|
||||
let response = &self.responses[response_idx];
|
||||
|
||||
let chunks: Vec<Result<StreamChunk, LlmError>> = match response {
|
||||
MockResponse::Text(text_chunks) => text_chunks
|
||||
.iter()
|
||||
.map(|text| {
|
||||
Ok(StreamChunk {
|
||||
content: Some(text.clone()),
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: None,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
MockResponse::ToolCall {
|
||||
text_chunks,
|
||||
tool_id,
|
||||
tool_name,
|
||||
tool_args,
|
||||
} => {
|
||||
let mut result = vec![];
|
||||
|
||||
// First emit text chunks
|
||||
for text in text_chunks {
|
||||
result.push(Ok(StreamChunk {
|
||||
content: Some(text.clone()),
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// Then emit tool call in chunks
|
||||
result.push(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: 0,
|
||||
id: Some(tool_id.clone()),
|
||||
function_name: Some(tool_name.clone()),
|
||||
arguments_delta: None,
|
||||
}]),
|
||||
done: false,
|
||||
usage: None,
|
||||
}));
|
||||
|
||||
// Emit args in chunks
|
||||
for chunk in tool_args.chars().collect::<Vec<_>>().chunks(5) {
|
||||
result.push(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: 0,
|
||||
id: None,
|
||||
function_name: None,
|
||||
arguments_delta: Some(chunk.iter().collect()),
|
||||
}]),
|
||||
done: false,
|
||||
usage: None,
|
||||
}));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Box::pin(stream::iter(chunks)))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_text_only() {
|
||||
let provider = MockStreamingProvider {
|
||||
responses: vec![MockResponse::Text(vec![
|
||||
"Hello".to_string(),
|
||||
" ".to_string(),
|
||||
"world".to_string(),
|
||||
"!".to_string(),
|
||||
])],
|
||||
};
|
||||
|
||||
let perms = PermissionManager::new(Mode::Plan);
|
||||
let ctx = ToolContext::default();
|
||||
let (tx, mut rx) = create_event_channel();
|
||||
|
||||
// Spawn the agent loop
|
||||
let handle = tokio::spawn(async move {
|
||||
run_agent_loop_streaming(
|
||||
&provider,
|
||||
"Say hello",
|
||||
&ChatOptions::default(),
|
||||
&perms,
|
||||
&ctx,
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// Collect events
|
||||
let mut text_deltas = vec![];
|
||||
let mut done_response = None;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
AgentEvent::TextDelta(text) => {
|
||||
text_deltas.push(text);
|
||||
}
|
||||
AgentEvent::Done { final_response } => {
|
||||
done_response = Some(final_response);
|
||||
break;
|
||||
}
|
||||
AgentEvent::Error(e) => {
|
||||
panic!("Unexpected error: {}", e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for agent loop to complete
|
||||
let result = handle.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify events
|
||||
assert_eq!(text_deltas, vec!["Hello", " ", "world", "!"]);
|
||||
assert_eq!(done_response, Some("Hello world!".to_string()));
|
||||
assert_eq!(result.unwrap(), "Hello world!");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_with_tool_call() {
|
||||
let provider = MockStreamingProvider {
|
||||
responses: vec![
|
||||
MockResponse::ToolCall {
|
||||
text_chunks: vec!["Let me ".to_string(), "check...".to_string()],
|
||||
tool_id: "call_123".to_string(),
|
||||
tool_name: "glob".to_string(),
|
||||
tool_args: r#"{"pattern":"*.rs"}"#.to_string(),
|
||||
},
|
||||
MockResponse::Text(vec!["Found ".to_string(), "the files!".to_string()]),
|
||||
],
|
||||
};
|
||||
|
||||
let perms = PermissionManager::new(Mode::Plan);
|
||||
let ctx = ToolContext::default();
|
||||
let (tx, mut rx) = create_event_channel();
|
||||
|
||||
// Spawn the agent loop
|
||||
let handle = tokio::spawn(async move {
|
||||
run_agent_loop_streaming(
|
||||
&provider,
|
||||
"Find Rust files",
|
||||
&ChatOptions::default(),
|
||||
&perms,
|
||||
&ctx,
|
||||
tx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
// Collect events
|
||||
let mut text_deltas = vec![];
|
||||
let mut tool_starts = vec![];
|
||||
let mut tool_outputs = vec![];
|
||||
let mut tool_ends = vec![];
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
AgentEvent::TextDelta(text) => {
|
||||
text_deltas.push(text);
|
||||
}
|
||||
AgentEvent::ToolStart {
|
||||
tool_name,
|
||||
tool_id,
|
||||
} => {
|
||||
tool_starts.push((tool_name, tool_id));
|
||||
}
|
||||
AgentEvent::ToolOutput {
|
||||
tool_id,
|
||||
content,
|
||||
is_error,
|
||||
} => {
|
||||
tool_outputs.push((tool_id, content, is_error));
|
||||
}
|
||||
AgentEvent::ToolEnd { tool_id, success } => {
|
||||
tool_ends.push((tool_id, success));
|
||||
}
|
||||
AgentEvent::Done { .. } => {
|
||||
break;
|
||||
}
|
||||
AgentEvent::Error(e) => {
|
||||
panic!("Unexpected error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for agent loop to complete
|
||||
let result = handle.await.unwrap();
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify we got text deltas from both responses
|
||||
assert!(text_deltas.contains(&"Let me ".to_string()));
|
||||
assert!(text_deltas.contains(&"check...".to_string()));
|
||||
assert!(text_deltas.contains(&"Found ".to_string()));
|
||||
assert!(text_deltas.contains(&"the files!".to_string()));
|
||||
|
||||
// Verify tool events
|
||||
assert_eq!(tool_starts.len(), 1);
|
||||
assert_eq!(tool_starts[0].0, "glob");
|
||||
assert_eq!(tool_starts[0].1, "call_123");
|
||||
|
||||
assert_eq!(tool_outputs.len(), 1);
|
||||
assert_eq!(tool_outputs[0].0, "call_123");
|
||||
assert!(!tool_outputs[0].2); // not an error
|
||||
|
||||
assert_eq!(tool_ends.len(), 1);
|
||||
assert_eq!(tool_ends[0].0, "call_123");
|
||||
assert!(tool_ends[0].1); // success
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_channel_creation() {
|
||||
let (tx, mut rx) = create_event_channel();
|
||||
|
||||
// Test that channel works
|
||||
tx.send(AgentEvent::TextDelta("test".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = rx.recv().await.unwrap();
|
||||
match event {
|
||||
AgentEvent::TextDelta(text) => assert_eq!(text, "test"),
|
||||
_ => panic!("Wrong event type"),
|
||||
}
|
||||
}
|
||||
224
crates/core/agent/tests/tool_context.rs
Normal file
224
crates/core/agent/tests/tool_context.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
// Test that ToolContext properly wires up the placeholder tools
|
||||
use agent_core::{ToolContext, execute_tool, get_tool_definitions_with_external};
|
||||
use permissions::{Mode, PermissionManager};
|
||||
use plugins::{ExternalToolDefinition, ExternalToolTransport, ExternalToolSchema};
|
||||
use tools_todo::{TodoList, TodoStatus};
|
||||
use tools_bash::ShellManager;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_todo_write_with_context() {
|
||||
let todo_list = TodoList::new();
|
||||
let ctx = ToolContext::new().with_todo_list(todo_list.clone());
|
||||
let perms = PermissionManager::new(Mode::Code); // Allow all tools
|
||||
|
||||
let arguments = json!({
|
||||
"todos": [
|
||||
{
|
||||
"content": "First task",
|
||||
"status": "pending",
|
||||
"active_form": "Working on first task"
|
||||
},
|
||||
{
|
||||
"content": "Second task",
|
||||
"status": "in_progress",
|
||||
"active_form": "Working on second task"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = execute_tool("todo_write", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_ok(), "TodoWrite should succeed: {:?}", result);
|
||||
|
||||
// Verify the todos were written
|
||||
let todos = todo_list.read();
|
||||
assert_eq!(todos.len(), 2);
|
||||
assert_eq!(todos[0].content, "First task");
|
||||
assert_eq!(todos[1].status, TodoStatus::InProgress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_todo_write_without_context() {
|
||||
let ctx = ToolContext::new(); // No todo_list
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({
|
||||
"todos": []
|
||||
});
|
||||
|
||||
let result = execute_tool("todo_write", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err(), "TodoWrite should fail without TodoList");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bash_output_with_context() {
|
||||
let manager = ShellManager::new();
|
||||
let ctx = ToolContext::new().with_shell_manager(manager.clone());
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
// Start a shell and run a command
|
||||
let shell_id = manager.start_shell().await.unwrap();
|
||||
let _ = manager.execute(&shell_id, "echo test", None).await.unwrap();
|
||||
|
||||
let arguments = json!({
|
||||
"shell_id": shell_id
|
||||
});
|
||||
|
||||
let result = execute_tool("bash_output", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_ok(), "BashOutput should succeed: {:?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bash_output_without_context() {
|
||||
let ctx = ToolContext::new(); // No shell_manager
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({
|
||||
"shell_id": "fake-id"
|
||||
});
|
||||
|
||||
let result = execute_tool("bash_output", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err(), "BashOutput should fail without ShellManager");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_kill_shell_with_context() {
|
||||
let manager = ShellManager::new();
|
||||
let ctx = ToolContext::new().with_shell_manager(manager.clone());
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
// Start a shell
|
||||
let shell_id = manager.start_shell().await.unwrap();
|
||||
|
||||
let arguments = json!({
|
||||
"shell_id": shell_id
|
||||
});
|
||||
|
||||
let result = execute_tool("kill_shell", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_ok(), "KillShell should succeed: {:?}", result);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ask_user_without_context() {
|
||||
let ctx = ToolContext::new(); // No ask_sender
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({
|
||||
"questions": []
|
||||
});
|
||||
|
||||
let result = execute_tool("ask_user", &arguments, &perms, &ctx).await;
|
||||
assert!(result.is_err(), "AskUser should fail without AskSender");
|
||||
assert!(result.unwrap_err().to_string().contains("not available"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External Tools Tests
|
||||
// ============================================================================
|
||||
|
||||
fn create_test_external_tool(name: &str, description: &str) -> ExternalToolDefinition {
|
||||
ExternalToolDefinition {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
transport: ExternalToolTransport::Stdio,
|
||||
command: Some("echo".to_string()),
|
||||
args: vec![],
|
||||
url: None,
|
||||
timeout_ms: 5000,
|
||||
input_schema: ExternalToolSchema {
|
||||
schema_type: "object".to_string(),
|
||||
properties: {
|
||||
let mut props = HashMap::new();
|
||||
props.insert(
|
||||
"input".to_string(),
|
||||
json!({"type": "string", "description": "Test input"}),
|
||||
);
|
||||
props
|
||||
},
|
||||
required: vec!["input".to_string()],
|
||||
},
|
||||
source_path: PathBuf::from("/test/plugin"),
|
||||
plugin_name: "test-plugin".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_with_external_tools() {
|
||||
let mut ext_tools = HashMap::new();
|
||||
ext_tools.insert(
|
||||
"my_custom_tool".to_string(),
|
||||
create_test_external_tool("my_custom_tool", "A custom tool for testing"),
|
||||
);
|
||||
|
||||
let ctx = ToolContext::new().with_external_tools(ext_tools);
|
||||
|
||||
assert!(ctx.has_external_tool("my_custom_tool"));
|
||||
assert!(!ctx.has_external_tool("nonexistent"));
|
||||
|
||||
let tool = ctx.get_external_tool("my_custom_tool").unwrap();
|
||||
assert_eq!(tool.name, "my_custom_tool");
|
||||
assert_eq!(tool.description, "A custom tool for testing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_tool_definitions_with_external() {
|
||||
let mut ext_tools = HashMap::new();
|
||||
ext_tools.insert(
|
||||
"external_analyzer".to_string(),
|
||||
create_test_external_tool("external_analyzer", "Analyze stuff externally"),
|
||||
);
|
||||
ext_tools.insert(
|
||||
"external_formatter".to_string(),
|
||||
create_test_external_tool("external_formatter", "Format things externally"),
|
||||
);
|
||||
|
||||
let ctx = ToolContext::new().with_external_tools(ext_tools);
|
||||
let all_tools = get_tool_definitions_with_external(&ctx);
|
||||
|
||||
// Should have built-in tools plus our 2 external tools
|
||||
let external_tool_names: Vec<_> = all_tools
|
||||
.iter()
|
||||
.filter(|t| t.function.name == "external_analyzer" || t.function.name == "external_formatter")
|
||||
.collect();
|
||||
assert_eq!(external_tool_names.len(), 2);
|
||||
|
||||
// Verify external tool schema is correct
|
||||
let analyzer = all_tools.iter().find(|t| t.function.name == "external_analyzer").unwrap();
|
||||
assert!(analyzer.function.description.contains("Analyze stuff"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unknown_tool_without_external() {
|
||||
let ctx = ToolContext::new();
|
||||
let perms = PermissionManager::new(Mode::Code);
|
||||
|
||||
let arguments = json!({"input": "test"});
|
||||
let result = execute_tool("completely_unknown_tool", &arguments, &perms, &ctx).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Unknown tool"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_tool_permission_denied() {
|
||||
let mut ext_tools = HashMap::new();
|
||||
ext_tools.insert(
|
||||
"dangerous_tool".to_string(),
|
||||
create_test_external_tool("dangerous_tool", "A dangerous tool"),
|
||||
);
|
||||
|
||||
let ctx = ToolContext::new().with_external_tools(ext_tools);
|
||||
let perms = PermissionManager::new(Mode::Plan); // Plan mode denies bash-like tools
|
||||
|
||||
let arguments = json!({"input": "test"});
|
||||
let result = execute_tool("dangerous_tool", &arguments, &perms, &ctx).await;
|
||||
|
||||
// External tools are treated like Bash, so should require permission in Plan mode
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("Permission required") || err.contains("Permission denied"));
|
||||
}
|
||||
17
crates/integration/jsonrpc/Cargo.toml
Normal file
17
crates/integration/jsonrpc/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "jsonrpc"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "JSON-RPC 2.0 client for external tool communication"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["process", "io-util", "time", "sync"] }
|
||||
color-eyre = "0.6"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full", "test-util"] }
|
||||
391
crates/integration/jsonrpc/src/lib.rs
Normal file
391
crates/integration/jsonrpc/src/lib.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! JSON-RPC 2.0 client for external tool communication
|
||||
//!
|
||||
//! This crate provides a JSON-RPC 2.0 client for invoking external tools
|
||||
//! via stdio (spawning a process) or HTTP endpoints.
|
||||
|
||||
use color_eyre::eyre::{eyre, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ============================================================================
|
||||
// JSON-RPC 2.0 Protocol Types
|
||||
// ============================================================================
|
||||
|
||||
/// JSON-RPC 2.0 request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcRequest {
|
||||
/// JSON-RPC version (must be "2.0")
|
||||
pub jsonrpc: String,
|
||||
/// Request ID for matching responses
|
||||
pub id: serde_json::Value,
|
||||
/// Method name to invoke
|
||||
pub method: String,
|
||||
/// Method parameters
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl JsonRpcRequest {
|
||||
/// Create a new JSON-RPC request
|
||||
pub fn new(method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::Value::String(Uuid::new_v4().to_string()),
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a request with a specific ID
|
||||
pub fn with_id(id: impl Into<serde_json::Value>, method: impl Into<String>, params: Option<serde_json::Value>) -> Self {
|
||||
Self {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: id.into(),
|
||||
method: method.into(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to JSON string with newline
|
||||
pub fn to_json_line(&self) -> Result<String> {
|
||||
let mut json = serde_json::to_string(self)?;
|
||||
json.push('\n');
|
||||
Ok(json)
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcResponse {
|
||||
/// JSON-RPC version (must be "2.0")
|
||||
pub jsonrpc: String,
|
||||
/// Request ID this is responding to
|
||||
pub id: serde_json::Value,
|
||||
/// Result (on success)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
/// Error (on failure)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
impl JsonRpcResponse {
|
||||
/// Check if the response is an error
|
||||
pub fn is_error(&self) -> bool {
|
||||
self.error.is_some()
|
||||
}
|
||||
|
||||
/// Get the result or error as a Result type
|
||||
pub fn into_result(self) -> Result<serde_json::Value> {
|
||||
if let Some(err) = self.error {
|
||||
Err(eyre!("JSON-RPC error {}: {}", err.code, err.message))
|
||||
} else {
|
||||
self.result.ok_or_else(|| eyre!("No result in response"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 error
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JsonRpcError {
|
||||
/// Error code (standard or application-specific)
|
||||
pub code: i64,
|
||||
/// Short error message
|
||||
pub message: String,
|
||||
/// Additional error data
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Standard JSON-RPC 2.0 error codes
|
||||
pub mod error_codes {
|
||||
/// Invalid JSON was received
|
||||
pub const PARSE_ERROR: i64 = -32700;
|
||||
/// The JSON sent is not a valid Request object
|
||||
pub const INVALID_REQUEST: i64 = -32600;
|
||||
/// The method does not exist or is not available
|
||||
pub const METHOD_NOT_FOUND: i64 = -32601;
|
||||
/// Invalid method parameter(s)
|
||||
pub const INVALID_PARAMS: i64 = -32602;
|
||||
/// Internal JSON-RPC error
|
||||
pub const INTERNAL_ERROR: i64 = -32603;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stdio Client
|
||||
// ============================================================================
|
||||
|
||||
/// JSON-RPC client over stdio (spawned process)
|
||||
pub struct StdioClient {
|
||||
/// Spawned child process
|
||||
child: Child,
|
||||
/// Request timeout
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl StdioClient {
|
||||
/// Spawn a new stdio client
|
||||
pub async fn spawn(
|
||||
command: impl AsRef<str>,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut cmd = Command::new(command.as_ref());
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
// Add environment variables
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
timeout: Duration::from_millis(timeout_ms),
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn from a path (resolving relative paths)
|
||||
pub async fn spawn_from_path(
|
||||
command: &PathBuf,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
working_dir: Option<&PathBuf>,
|
||||
timeout_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut cmd = Command::new(command);
|
||||
cmd.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit());
|
||||
|
||||
if let Some(dir) = working_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
|
||||
for (key, value) in env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
child,
|
||||
timeout: Duration::from_millis(timeout_ms),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a request and wait for response
|
||||
pub async fn call(&mut self, method: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
|
||||
let request = JsonRpcRequest::new(method, params);
|
||||
let request_id = request.id.clone();
|
||||
|
||||
// Get handles to stdin/stdout
|
||||
let stdin = self.child.stdin.as_mut()
|
||||
.ok_or_else(|| eyre!("Failed to get stdin handle"))?;
|
||||
let stdout = self.child.stdout.take()
|
||||
.ok_or_else(|| eyre!("Failed to get stdout handle"))?;
|
||||
|
||||
// Write request
|
||||
let request_json = request.to_json_line()?;
|
||||
stdin.write_all(request_json.as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
// Read response with timeout
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut line = String::new();
|
||||
|
||||
let result = timeout(self.timeout, reader.read_line(&mut line)).await;
|
||||
|
||||
// Restore stdout for future calls
|
||||
self.child.stdout = Some(reader.into_inner());
|
||||
|
||||
match result {
|
||||
Ok(Ok(0)) => Err(eyre!("Process closed stdout")),
|
||||
Ok(Ok(_)) => {
|
||||
let response: JsonRpcResponse = serde_json::from_str(&line)?;
|
||||
|
||||
// Verify ID matches
|
||||
if response.id != request_id {
|
||||
return Err(eyre!(
|
||||
"Response ID mismatch: expected {:?}, got {:?}",
|
||||
request_id,
|
||||
response.id
|
||||
));
|
||||
}
|
||||
|
||||
response.into_result()
|
||||
}
|
||||
Ok(Err(e)) => Err(e.into()),
|
||||
Err(_) => Err(eyre!("Request timed out after {:?}", self.timeout)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the process is still running
|
||||
pub fn is_alive(&mut self) -> bool {
|
||||
matches!(self.child.try_wait(), Ok(None))
|
||||
}
|
||||
|
||||
/// Kill the process
|
||||
pub async fn kill(&mut self) -> Result<()> {
|
||||
self.child.kill().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StdioClient {
|
||||
fn drop(&mut self) {
|
||||
// Try to kill the process on drop
|
||||
let _ = self.child.start_kill();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Executor
|
||||
// ============================================================================
|
||||
|
||||
/// Executor for external tools via JSON-RPC
|
||||
pub struct ToolExecutor {
|
||||
/// Default timeout for tool calls
|
||||
default_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ToolExecutor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor {
|
||||
/// Create a new tool executor with custom timeout
|
||||
pub fn with_timeout(timeout_ms: u64) -> Self {
|
||||
Self {
|
||||
default_timeout: Duration::from_millis(timeout_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a tool via stdio
|
||||
pub async fn execute_stdio(
|
||||
&self,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
env: &HashMap<String, String>,
|
||||
tool_params: serde_json::Value,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let timeout = timeout_ms.unwrap_or(self.default_timeout.as_millis() as u64);
|
||||
|
||||
let mut client = StdioClient::spawn(command, args, env, timeout).await?;
|
||||
|
||||
// The standard method name for tool execution
|
||||
let result = client.call("execute", Some(tool_params)).await?;
|
||||
|
||||
// Kill the process after we're done
|
||||
let _ = client.kill().await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute a tool via HTTP (not implemented yet)
|
||||
pub async fn execute_http(
|
||||
&self,
|
||||
_url: &str,
|
||||
_tool_params: serde_json::Value,
|
||||
_timeout_ms: Option<u64>,
|
||||
) -> Result<serde_json::Value> {
|
||||
Err(eyre!("HTTP transport not yet implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_request_serialization() {
|
||||
let request = JsonRpcRequest::new("test_method", Some(serde_json::json!({"arg": "value"})));
|
||||
|
||||
assert_eq!(request.jsonrpc, "2.0");
|
||||
assert_eq!(request.method, "test_method");
|
||||
assert!(request.params.is_some());
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"method\":\"test_method\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_response_success() {
|
||||
let json = r#"{"jsonrpc":"2.0","id":"123","result":{"data":"hello"}}"#;
|
||||
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(!response.is_error());
|
||||
assert_eq!(response.id, serde_json::json!("123"));
|
||||
assert_eq!(response.result.unwrap()["data"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_response_error() {
|
||||
let json = r#"{"jsonrpc":"2.0","id":"123","error":{"code":-32600,"message":"Invalid Request"}}"#;
|
||||
let response: JsonRpcResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(response.is_error());
|
||||
let err = response.error.unwrap();
|
||||
assert_eq!(err.code, error_codes::INVALID_REQUEST);
|
||||
assert_eq!(err.message, "Invalid Request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jsonrpc_request_line_format() {
|
||||
let request = JsonRpcRequest::with_id("test-id", "method", None);
|
||||
let line = request.to_json_line().unwrap();
|
||||
|
||||
assert!(line.ends_with('\n'));
|
||||
assert!(!line.ends_with("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_into_result_success() {
|
||||
let response = JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::json!("1"),
|
||||
result: Some(serde_json::json!({"success": true})),
|
||||
error: None,
|
||||
};
|
||||
|
||||
let result = response.into_result().unwrap();
|
||||
assert_eq!(result["success"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_response_into_result_error() {
|
||||
let response = JsonRpcResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: serde_json::json!("1"),
|
||||
result: None,
|
||||
error: Some(JsonRpcError {
|
||||
code: -32600,
|
||||
message: "Invalid".to_string(),
|
||||
data: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = response.into_result();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("-32600"));
|
||||
}
|
||||
}
|
||||
16
crates/integration/mcp-client/Cargo.toml
Normal file
16
crates/integration/mcp-client/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "mcp-client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1.39", features = ["process", "io-util", "sync", "time"] }
|
||||
color-eyre = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
tokio = { version = "1.39", features = ["macros", "rt-multi-thread"] }
|
||||
273
crates/integration/mcp-client/src/lib.rs
Normal file
273
crates/integration/mcp-client/src/lib.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// JSON-RPC 2.0 request
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonRpcRequest {
|
||||
jsonrpc: String,
|
||||
id: u64,
|
||||
method: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
params: Option<Value>,
|
||||
}
|
||||
|
||||
/// JSON-RPC 2.0 response
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)] // jsonrpc field required for protocol compliance
|
||||
struct JsonRpcResponse {
|
||||
jsonrpc: String,
|
||||
id: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<JsonRpcError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct JsonRpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// MCP server capabilities
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ServerCapabilities {
|
||||
#[serde(default)]
|
||||
pub tools: Option<ToolsCapability>,
|
||||
#[serde(default)]
|
||||
pub resources: Option<ResourcesCapability>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ToolsCapability {
|
||||
#[serde(default)]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ResourcesCapability {
|
||||
#[serde(default)]
|
||||
pub subscribe: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
/// MCP Tool definition
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpTool {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub input_schema: Option<Value>,
|
||||
}
|
||||
|
||||
/// MCP Resource definition
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct McpResource {
|
||||
pub uri: String,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP Client over stdio transport
|
||||
pub struct McpClient {
|
||||
process: Mutex<Child>,
|
||||
next_id: Mutex<u64>,
|
||||
server_name: String,
|
||||
}
|
||||
|
||||
impl McpClient {
|
||||
/// Create a new MCP client by spawning a subprocess
|
||||
pub async fn spawn(command: &str, args: &[&str], server_name: &str) -> Result<Self> {
|
||||
let mut child = Command::new(command)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
// Verify process is running
|
||||
if child.try_wait()?.is_some() {
|
||||
return Err(eyre!("MCP server process exited immediately"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
process: Mutex::new(child),
|
||||
next_id: Mutex::new(1),
|
||||
server_name: server_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Initialize the MCP connection
|
||||
pub async fn initialize(&self) -> Result<ServerCapabilities> {
|
||||
let params = serde_json::json!({
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"roots": {
|
||||
"listChanged": true
|
||||
}
|
||||
},
|
||||
"clientInfo": {
|
||||
"name": "owlen",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
});
|
||||
|
||||
let response = self.send_request("initialize", Some(params)).await?;
|
||||
|
||||
let capabilities = response
|
||||
.get("capabilities")
|
||||
.ok_or_else(|| eyre!("No capabilities in initialize response"))?;
|
||||
|
||||
Ok(serde_json::from_value(capabilities.clone())?)
|
||||
}
|
||||
|
||||
/// List available tools
|
||||
pub async fn list_tools(&self) -> Result<Vec<McpTool>> {
|
||||
let response = self.send_request("tools/list", None).await?;
|
||||
|
||||
let tools = response
|
||||
.get("tools")
|
||||
.ok_or_else(|| eyre!("No tools in response"))?;
|
||||
|
||||
Ok(serde_json::from_value(tools.clone())?)
|
||||
}
|
||||
|
||||
/// Call a tool
|
||||
pub async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value> {
|
||||
let params = serde_json::json!({
|
||||
"name": name,
|
||||
"arguments": arguments
|
||||
});
|
||||
|
||||
let response = self.send_request("tools/call", Some(params)).await?;
|
||||
|
||||
response
|
||||
.get("content")
|
||||
.cloned()
|
||||
.ok_or_else(|| eyre!("No content in tool call response"))
|
||||
}
|
||||
|
||||
/// List available resources
|
||||
pub async fn list_resources(&self) -> Result<Vec<McpResource>> {
|
||||
let response = self.send_request("resources/list", None).await?;
|
||||
|
||||
let resources = response
|
||||
.get("resources")
|
||||
.ok_or_else(|| eyre!("No resources in response"))?;
|
||||
|
||||
Ok(serde_json::from_value(resources.clone())?)
|
||||
}
|
||||
|
||||
/// Read a resource
|
||||
pub async fn read_resource(&self, uri: &str) -> Result<Value> {
|
||||
let params = serde_json::json!({
|
||||
"uri": uri
|
||||
});
|
||||
|
||||
let response = self.send_request("resources/read", Some(params)).await?;
|
||||
|
||||
response
|
||||
.get("contents")
|
||||
.cloned()
|
||||
.ok_or_else(|| eyre!("No contents in resource read response"))
|
||||
}
|
||||
|
||||
/// Get the server name
|
||||
pub fn server_name(&self) -> &str {
|
||||
&self.server_name
|
||||
}
|
||||
|
||||
/// Send a JSON-RPC request and get the response
|
||||
async fn send_request(&self, method: &str, params: Option<Value>) -> Result<Value> {
|
||||
let mut next_id = self.next_id.lock().await;
|
||||
let id = *next_id;
|
||||
*next_id += 1;
|
||||
drop(next_id);
|
||||
|
||||
let request = JsonRpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
};
|
||||
|
||||
let request_json = serde_json::to_string(&request)?;
|
||||
|
||||
let mut process = self.process.lock().await;
|
||||
|
||||
// Write request
|
||||
let stdin = process.stdin.as_mut().ok_or_else(|| eyre!("No stdin"))?;
|
||||
stdin.write_all(request_json.as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?;
|
||||
stdin.flush().await?;
|
||||
|
||||
// Read response
|
||||
let stdout = process.stdout.take().ok_or_else(|| eyre!("No stdout"))?;
|
||||
let mut reader = BufReader::new(stdout);
|
||||
let mut response_line = String::new();
|
||||
reader.read_line(&mut response_line).await?;
|
||||
|
||||
// Put stdout back
|
||||
process.stdout = Some(reader.into_inner());
|
||||
|
||||
drop(process);
|
||||
|
||||
let response: JsonRpcResponse = serde_json::from_str(&response_line)?;
|
||||
|
||||
if response.id != id {
|
||||
return Err(eyre!("Response ID mismatch: expected {}, got {}", id, response.id));
|
||||
}
|
||||
|
||||
if let Some(error) = response.error {
|
||||
return Err(eyre!("MCP error {}: {}", error.code, error.message));
|
||||
}
|
||||
|
||||
response.result.ok_or_else(|| eyre!("No result in response"))
|
||||
}
|
||||
|
||||
/// Close the MCP connection
|
||||
pub async fn close(self) -> Result<()> {
|
||||
let mut process = self.process.into_inner();
|
||||
|
||||
// Close stdin to signal the server to exit
|
||||
drop(process.stdin.take());
|
||||
|
||||
// Wait for process to exit (with timeout)
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
process.wait()
|
||||
).await??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jsonrpc_request_serializes() {
|
||||
let req = JsonRpcRequest {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: 1,
|
||||
method: "test".to_string(),
|
||||
params: Some(serde_json::json!({"key": "value"})),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"method\":\"test\""));
|
||||
assert!(json.contains("\"id\":1"));
|
||||
}
|
||||
}
|
||||
347
crates/integration/mcp-client/tests/mcp.rs
Normal file
347
crates/integration/mcp-client/tests/mcp.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
use mcp_client::McpClient;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_server_capability_negotiation() {
|
||||
// Create a mock MCP server script
|
||||
let dir = tempdir().unwrap();
|
||||
let server_script = dir.path().join("mock_server.py");
|
||||
|
||||
let script_content = r#"#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
|
||||
def read_request():
|
||||
line = sys.stdin.readline()
|
||||
return json.loads(line)
|
||||
|
||||
def send_response(response):
|
||||
sys.stdout.write(json.dumps(response) + '\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
try:
|
||||
req = read_request()
|
||||
method = req.get('method')
|
||||
req_id = req.get('id')
|
||||
|
||||
if method == 'initialize':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'protocolVersion': '2024-11-05',
|
||||
'capabilities': {
|
||||
'tools': {'list_changed': True},
|
||||
'resources': {'subscribe': False}
|
||||
},
|
||||
'serverInfo': {
|
||||
'name': 'test-server',
|
||||
'version': '1.0.0'
|
||||
}
|
||||
}
|
||||
})
|
||||
elif method == 'tools/list':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'tools': []
|
||||
}
|
||||
})
|
||||
else:
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'error': {
|
||||
'code': -32601,
|
||||
'message': f'Method not found: {method}'
|
||||
}
|
||||
})
|
||||
except EOFError:
|
||||
break
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'Error: {e}\n')
|
||||
break
|
||||
"#;
|
||||
|
||||
fs::write(&server_script, script_content).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&server_script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
// Connect to the server
|
||||
let client = McpClient::spawn(
|
||||
"python3",
|
||||
&[server_script.to_str().unwrap()],
|
||||
"test-server"
|
||||
).await.unwrap();
|
||||
|
||||
// Initialize
|
||||
let capabilities = client.initialize().await.unwrap();
|
||||
|
||||
// Verify capabilities
|
||||
assert!(capabilities.tools.is_some());
|
||||
assert_eq!(capabilities.tools.unwrap().list_changed, Some(true));
|
||||
|
||||
client.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_invocation() {
|
||||
let dir = tempdir().unwrap();
|
||||
let server_script = dir.path().join("mock_server.py");
|
||||
|
||||
let script_content = r#"#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
|
||||
def read_request():
|
||||
line = sys.stdin.readline()
|
||||
return json.loads(line)
|
||||
|
||||
def send_response(response):
|
||||
sys.stdout.write(json.dumps(response) + '\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
while True:
|
||||
try:
|
||||
req = read_request()
|
||||
method = req.get('method')
|
||||
req_id = req.get('id')
|
||||
params = req.get('params', {})
|
||||
|
||||
if method == 'initialize':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'protocolVersion': '2024-11-05',
|
||||
'capabilities': {
|
||||
'tools': {}
|
||||
},
|
||||
'serverInfo': {
|
||||
'name': 'test-server',
|
||||
'version': '1.0.0'
|
||||
}
|
||||
}
|
||||
})
|
||||
elif method == 'tools/list':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'tools': [
|
||||
{
|
||||
'name': 'echo',
|
||||
'description': 'Echo the input',
|
||||
'input_schema': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'message': {'type': 'string'}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
elif method == 'tools/call':
|
||||
tool_name = params.get('name')
|
||||
arguments = params.get('arguments', {})
|
||||
if tool_name == 'echo':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': arguments.get('message', '')
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
else:
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'error': {
|
||||
'code': -32602,
|
||||
'message': f'Unknown tool: {tool_name}'
|
||||
}
|
||||
})
|
||||
else:
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'error': {
|
||||
'code': -32601,
|
||||
'message': f'Method not found: {method}'
|
||||
}
|
||||
})
|
||||
except EOFError:
|
||||
break
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'Error: {e}\n')
|
||||
break
|
||||
"#;
|
||||
|
||||
fs::write(&server_script, script_content).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&server_script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
let client = McpClient::spawn(
|
||||
"python3",
|
||||
&[server_script.to_str().unwrap()],
|
||||
"test-server"
|
||||
).await.unwrap();
|
||||
|
||||
client.initialize().await.unwrap();
|
||||
|
||||
// List tools
|
||||
let tools = client.list_tools().await.unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
assert_eq!(tools[0].name, "echo");
|
||||
|
||||
// Call tool
|
||||
let result = client.call_tool(
|
||||
"echo",
|
||||
serde_json::json!({"message": "Hello, MCP!"})
|
||||
).await.unwrap();
|
||||
|
||||
// Verify result
|
||||
let content = result.as_array().unwrap();
|
||||
assert_eq!(content[0]["text"].as_str().unwrap(), "Hello, MCP!");
|
||||
|
||||
client.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_resource_reads() {
|
||||
let dir = tempdir().unwrap();
|
||||
let server_script = dir.path().join("mock_server.py");
|
||||
|
||||
let script_content = r#"#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
|
||||
def read_request():
|
||||
line = sys.stdin.readline()
|
||||
return json.loads(line)
|
||||
|
||||
def send_response(response):
|
||||
sys.stdout.write(json.dumps(response) + '\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
while True:
|
||||
try:
|
||||
req = read_request()
|
||||
method = req.get('method')
|
||||
req_id = req.get('id')
|
||||
params = req.get('params', {})
|
||||
|
||||
if method == 'initialize':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'protocolVersion': '2024-11-05',
|
||||
'capabilities': {
|
||||
'resources': {}
|
||||
},
|
||||
'serverInfo': {
|
||||
'name': 'test-server',
|
||||
'version': '1.0.0'
|
||||
}
|
||||
}
|
||||
})
|
||||
elif method == 'resources/list':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'resources': [
|
||||
{
|
||||
'uri': 'file:///test.txt',
|
||||
'name': 'Test File',
|
||||
'description': 'A test file',
|
||||
'mime_type': 'text/plain'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
elif method == 'resources/read':
|
||||
uri = params.get('uri')
|
||||
if uri == 'file:///test.txt':
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'result': {
|
||||
'contents': [
|
||||
{
|
||||
'uri': uri,
|
||||
'mime_type': 'text/plain',
|
||||
'text': 'Hello from resource!'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
else:
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'error': {
|
||||
'code': -32602,
|
||||
'message': f'Unknown resource: {uri}'
|
||||
}
|
||||
})
|
||||
else:
|
||||
send_response({
|
||||
'jsonrpc': '2.0',
|
||||
'id': req_id,
|
||||
'error': {
|
||||
'code': -32601,
|
||||
'message': f'Method not found: {method}'
|
||||
}
|
||||
})
|
||||
except EOFError:
|
||||
break
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'Error: {e}\n')
|
||||
break
|
||||
"#;
|
||||
|
||||
fs::write(&server_script, script_content).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&server_script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
let client = McpClient::spawn(
|
||||
"python3",
|
||||
&[server_script.to_str().unwrap()],
|
||||
"test-server"
|
||||
).await.unwrap();
|
||||
|
||||
client.initialize().await.unwrap();
|
||||
|
||||
// List resources
|
||||
let resources = client.list_resources().await.unwrap();
|
||||
assert_eq!(resources.len(), 1);
|
||||
assert_eq!(resources[0].uri, "file:///test.txt");
|
||||
|
||||
// Read resource
|
||||
let contents = client.read_resource("file:///test.txt").await.unwrap();
|
||||
let contents_array = contents.as_array().unwrap();
|
||||
assert_eq!(contents_array[0]["text"].as_str().unwrap(), "Hello from resource!");
|
||||
|
||||
client.close().await.unwrap();
|
||||
}
|
||||
18
crates/llm/anthropic/Cargo.toml
Normal file
18
crates/llm/anthropic/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "llm-anthropic"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Anthropic Claude API client for Owlen"
|
||||
|
||||
[dependencies]
|
||||
llm-core = { path = "../core" }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
reqwest-eventsource = "0.6"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["sync", "time"] }
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
15
crates/llm/anthropic/README.md
Normal file
15
crates/llm/anthropic/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Owlen Anthropic Provider
|
||||
|
||||
Anthropic Claude integration for the Owlen AI agent.
|
||||
|
||||
## Overview
|
||||
This crate provides the implementation of the `LlmProvider` trait for Anthropic's Claude models. It handles the specific API requirements for Claude, including its unique tool calling format and streaming response structure.
|
||||
|
||||
## Features
|
||||
- **Claude 3.5 Sonnet Support:** Optimized for the latest high-performance models.
|
||||
- **Tool Use:** Native integration with Claude's tool calling capabilities.
|
||||
- **Streaming:** Efficient real-time response generation using server-sent events.
|
||||
- **Token Counting:** Accurate token estimation using Anthropic-specific logic.
|
||||
|
||||
## Configuration
|
||||
Requires an `ANTHROPIC_API_KEY` to be set in the environment or configuration.
|
||||
306
crates/llm/anthropic/src/auth.rs
Normal file
306
crates/llm/anthropic/src/auth.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
//! Anthropic OAuth Authentication
|
||||
//!
|
||||
//! Implements device code flow for authenticating with Anthropic without API keys.
|
||||
|
||||
use llm_core::{AuthMethod, DeviceAuthResult, DeviceCodeResponse, LlmError, OAuthProvider};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// OAuth client for Anthropic device flow
|
||||
pub struct AnthropicAuth {
|
||||
http: Client,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
// Anthropic OAuth endpoints
|
||||
// Note: Anthropic doesn't currently have a public OAuth device code flow.
|
||||
// These are placeholder endpoints. Users should use API keys instead.
|
||||
const AUTH_BASE_URL: &str = "https://console.anthropic.com";
|
||||
const DEVICE_CODE_ENDPOINT: &str = "/oauth/device/code";
|
||||
const TOKEN_ENDPOINT: &str = "/oauth/token";
|
||||
|
||||
// Default client ID for Owlen CLI
|
||||
const DEFAULT_CLIENT_ID: &str = "owlen-cli";
|
||||
|
||||
// User-Agent to avoid Cloudflare blocks
|
||||
const USER_AGENT: &str = concat!("owlen/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
impl AnthropicAuth {
|
||||
/// Create a new OAuth client with the default CLI client ID
|
||||
pub fn new() -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http,
|
||||
client_id: DEFAULT_CLIENT_ID.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom client ID
|
||||
pub fn with_client_id(client_id: impl Into<String>) -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http,
|
||||
client_id: client_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if OAuth is available for Anthropic
|
||||
/// Currently, Anthropic doesn't provide public OAuth for third-party CLI tools.
|
||||
pub fn is_oauth_available() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnthropicAuth {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceCodeRequest<'a> {
|
||||
client_id: &'a str,
|
||||
scope: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceCodeApiResponse {
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_in: u64,
|
||||
interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TokenRequest<'a> {
|
||||
client_id: &'a str,
|
||||
device_code: &'a str,
|
||||
grant_type: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenApiResponse {
|
||||
access_token: String,
|
||||
#[allow(dead_code)]
|
||||
token_type: String,
|
||||
expires_in: Option<u64>,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenErrorResponse {
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OAuthProvider for AnthropicAuth {
|
||||
async fn start_device_auth(&self) -> Result<DeviceCodeResponse, LlmError> {
|
||||
let url = format!("{}{}", AUTH_BASE_URL, DEVICE_CODE_ENDPOINT);
|
||||
|
||||
let request = DeviceCodeRequest {
|
||||
client_id: &self.client_id,
|
||||
scope: "api:read api:write", // Request API access
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(LlmError::Auth(format!(
|
||||
"Device code request failed ({}): {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
let api_response: DeviceCodeApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
Ok(DeviceCodeResponse {
|
||||
device_code: api_response.device_code,
|
||||
user_code: api_response.user_code,
|
||||
verification_uri: api_response.verification_uri,
|
||||
verification_uri_complete: api_response.verification_uri_complete,
|
||||
expires_in: api_response.expires_in,
|
||||
interval: api_response.interval,
|
||||
})
|
||||
}
|
||||
|
||||
async fn poll_device_auth(&self, device_code: &str) -> Result<DeviceAuthResult, LlmError> {
|
||||
let url = format!("{}{}", AUTH_BASE_URL, TOKEN_ENDPOINT);
|
||||
|
||||
let request = TokenRequest {
|
||||
client_id: &self.client_id,
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let token_response: TokenApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
return Ok(DeviceAuthResult::Success {
|
||||
access_token: token_response.access_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
expires_in: token_response.expires_in,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse error response
|
||||
let error_response: TokenErrorResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
match error_response.error.as_str() {
|
||||
"authorization_pending" => Ok(DeviceAuthResult::Pending),
|
||||
"slow_down" => Ok(DeviceAuthResult::Pending), // Treat as pending, caller should slow down
|
||||
"access_denied" => Ok(DeviceAuthResult::Denied),
|
||||
"expired_token" => Ok(DeviceAuthResult::Expired),
|
||||
_ => Err(LlmError::Auth(format!(
|
||||
"Token request failed: {} - {}",
|
||||
error_response.error,
|
||||
error_response.error_description.unwrap_or_default()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthMethod, LlmError> {
|
||||
let url = format!("{}{}", AUTH_BASE_URL, TOKEN_ENDPOINT);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
client_id: &'a str,
|
||||
refresh_token: &'a str,
|
||||
grant_type: &'a str,
|
||||
}
|
||||
|
||||
let request = RefreshRequest {
|
||||
client_id: &self.client_id,
|
||||
refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(LlmError::Auth(format!("Token refresh failed: {}", text)));
|
||||
}
|
||||
|
||||
let token_response: TokenApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
let expires_at = token_response.expires_in.map(|secs| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() + secs)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
Ok(AuthMethod::OAuth {
|
||||
access_token: token_response.access_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to perform the full device auth flow with polling
|
||||
pub async fn perform_device_auth<F>(
|
||||
auth: &AnthropicAuth,
|
||||
on_code: F,
|
||||
) -> Result<AuthMethod, LlmError>
|
||||
where
|
||||
F: FnOnce(&DeviceCodeResponse),
|
||||
{
|
||||
// Start the device flow
|
||||
let device_code = auth.start_device_auth().await?;
|
||||
|
||||
// Let caller display the code to user
|
||||
on_code(&device_code);
|
||||
|
||||
// Poll for completion
|
||||
let poll_interval = std::time::Duration::from_secs(device_code.interval);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(device_code.expires_in);
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(LlmError::Auth("Device code expired".to_string()));
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
match auth.poll_device_auth(&device_code.device_code).await? {
|
||||
DeviceAuthResult::Success {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
} => {
|
||||
let expires_at = expires_in.map(|secs| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() + secs)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
return Ok(AuthMethod::OAuth {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
DeviceAuthResult::Pending => continue,
|
||||
DeviceAuthResult::Denied => {
|
||||
return Err(LlmError::Auth("Authorization denied by user".to_string()));
|
||||
}
|
||||
DeviceAuthResult::Expired => {
|
||||
return Err(LlmError::Auth("Device code expired".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
577
crates/llm/anthropic/src/client.rs
Normal file
577
crates/llm/anthropic/src/client.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
//! Anthropic Claude API Client
|
||||
//!
|
||||
//! Implements the Messages API with streaming support.
|
||||
|
||||
use crate::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use llm_core::{
|
||||
AccountInfo, AuthMethod, ChatMessage, ChatOptions, ChatResponse, ChunkStream, FunctionCall,
|
||||
LlmError, LlmProvider, ModelInfo, ProviderInfo, ProviderStatus, Role, StreamChunk, Tool,
|
||||
ToolCall, ToolCallDelta, Usage, UsageStats,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use reqwest_eventsource::{Event, EventSource};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const API_BASE_URL: &str = "https://api.anthropic.com";
|
||||
const MESSAGES_ENDPOINT: &str = "/v1/messages";
|
||||
const API_VERSION: &str = "2023-06-01";
|
||||
const DEFAULT_MAX_TOKENS: u32 = 8192;
|
||||
|
||||
/// Anthropic Claude API client
|
||||
pub struct AnthropicClient {
|
||||
http: Client,
|
||||
auth: AuthMethod,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl AnthropicClient {
|
||||
/// Create a new client with API key authentication
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: Client::new(),
|
||||
auth: AuthMethod::api_key(api_key),
|
||||
model: "claude-sonnet-4-20250514".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new client with OAuth token
|
||||
pub fn with_oauth(access_token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: Client::new(),
|
||||
auth: AuthMethod::oauth(access_token),
|
||||
model: "claude-sonnet-4-20250514".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new client with full AuthMethod
|
||||
pub fn with_auth(auth: AuthMethod) -> Self {
|
||||
Self {
|
||||
http: Client::new(),
|
||||
auth,
|
||||
model: "claude-sonnet-4-20250514".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the model to use
|
||||
pub fn with_model(mut self, model: impl Into<String>) -> Self {
|
||||
self.model = model.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Get current auth method (for token refresh)
|
||||
pub fn auth(&self) -> &AuthMethod {
|
||||
&self.auth
|
||||
}
|
||||
|
||||
/// Update the auth method (after refresh)
|
||||
pub fn set_auth(&mut self, auth: AuthMethod) {
|
||||
self.auth = auth;
|
||||
}
|
||||
|
||||
/// Convert messages to Anthropic format, extracting system message
|
||||
fn prepare_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<AnthropicMessage>) {
|
||||
let mut system_content = None;
|
||||
let mut anthropic_messages = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
if msg.role == Role::System {
|
||||
// Collect system messages
|
||||
if let Some(content) = &msg.content {
|
||||
if let Some(existing) = &mut system_content {
|
||||
*existing = format!("{}\n\n{}", existing, content);
|
||||
} else {
|
||||
system_content = Some(content.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
anthropic_messages.push(AnthropicMessage::from(msg));
|
||||
}
|
||||
}
|
||||
|
||||
(system_content, anthropic_messages)
|
||||
}
|
||||
|
||||
/// Convert tools to Anthropic format
|
||||
fn prepare_tools(tools: Option<&[Tool]>) -> Option<Vec<AnthropicTool>> {
|
||||
tools.map(|t| t.iter().map(AnthropicTool::from).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for AnthropicClient {
|
||||
fn name(&self) -> &str {
|
||||
"anthropic"
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
let url = format!("{}{}", API_BASE_URL, MESSAGES_ENDPOINT);
|
||||
|
||||
let model = if options.model.is_empty() {
|
||||
&self.model
|
||||
} else {
|
||||
&options.model
|
||||
};
|
||||
|
||||
let (system, anthropic_messages) = Self::prepare_messages(messages);
|
||||
let anthropic_tools = Self::prepare_tools(tools);
|
||||
|
||||
let request = MessagesRequest {
|
||||
model,
|
||||
messages: anthropic_messages,
|
||||
max_tokens: options.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
|
||||
system: system.as_deref(),
|
||||
temperature: options.temperature,
|
||||
top_p: options.top_p,
|
||||
stop_sequences: options.stop.as_deref(),
|
||||
tools: anthropic_tools,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
let bearer = self
|
||||
.auth
|
||||
.bearer_token()
|
||||
.ok_or_else(|| LlmError::Auth("No authentication configured".to_string()))?;
|
||||
|
||||
// Build the SSE request
|
||||
let req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("x-api-key", bearer)
|
||||
.header("anthropic-version", API_VERSION)
|
||||
.header("content-type", "application/json")
|
||||
.json(&request);
|
||||
|
||||
let es = EventSource::new(req).map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
// State for accumulating tool calls across deltas
|
||||
let tool_state: Arc<Mutex<Vec<PartialToolCall>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let stream = es.filter_map(move |event| {
|
||||
let tool_state = Arc::clone(&tool_state);
|
||||
async move {
|
||||
match event {
|
||||
Ok(Event::Open) => None,
|
||||
Ok(Event::Message(msg)) => {
|
||||
// Parse the SSE data as JSON
|
||||
let event: StreamEvent = match serde_json::from_str(&msg.data) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse SSE event: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
convert_stream_event(event, &tool_state).await
|
||||
}
|
||||
Err(reqwest_eventsource::Error::StreamEnded) => None,
|
||||
Err(e) => Some(Err(LlmError::Stream(e.to_string()))),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let url = format!("{}{}", API_BASE_URL, MESSAGES_ENDPOINT);
|
||||
|
||||
let model = if options.model.is_empty() {
|
||||
&self.model
|
||||
} else {
|
||||
&options.model
|
||||
};
|
||||
|
||||
let (system, anthropic_messages) = Self::prepare_messages(messages);
|
||||
let anthropic_tools = Self::prepare_tools(tools);
|
||||
|
||||
let request = MessagesRequest {
|
||||
model,
|
||||
messages: anthropic_messages,
|
||||
max_tokens: options.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS),
|
||||
system: system.as_deref(),
|
||||
temperature: options.temperature,
|
||||
top_p: options.top_p,
|
||||
stop_sequences: options.stop.as_deref(),
|
||||
tools: anthropic_tools,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
let bearer = self
|
||||
.auth
|
||||
.bearer_token()
|
||||
.ok_or_else(|| LlmError::Auth("No authentication configured".to_string()))?;
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("x-api-key", bearer)
|
||||
.header("anthropic-version", API_VERSION)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
// Check for rate limiting
|
||||
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
return Err(LlmError::RateLimit {
|
||||
retry_after_secs: None,
|
||||
});
|
||||
}
|
||||
|
||||
return Err(LlmError::Api {
|
||||
message: text,
|
||||
code: Some(status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let api_response: MessagesResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
// Convert response to common format
|
||||
let mut content = String::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for block in api_response.content {
|
||||
match block {
|
||||
ResponseContentBlock::Text { text } => {
|
||||
content.push_str(&text);
|
||||
}
|
||||
ResponseContentBlock::ToolUse { id, name, input } => {
|
||||
tool_calls.push(ToolCall {
|
||||
id,
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name,
|
||||
arguments: input,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let usage = api_response.usage.map(|u| Usage {
|
||||
prompt_tokens: u.input_tokens,
|
||||
completion_tokens: u.output_tokens,
|
||||
total_tokens: u.input_tokens + u.output_tokens,
|
||||
});
|
||||
|
||||
Ok(ChatResponse {
|
||||
content: if content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(content)
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper struct for accumulating streaming tool calls
|
||||
#[derive(Default)]
|
||||
struct PartialToolCall {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
name: String,
|
||||
input_json: String,
|
||||
}
|
||||
|
||||
/// Convert an Anthropic stream event to our common StreamChunk format
|
||||
async fn convert_stream_event(
|
||||
event: StreamEvent,
|
||||
tool_state: &Arc<Mutex<Vec<PartialToolCall>>>,
|
||||
) -> Option<Result<StreamChunk, LlmError>> {
|
||||
match event {
|
||||
StreamEvent::ContentBlockStart {
|
||||
index,
|
||||
content_block,
|
||||
} => {
|
||||
match content_block {
|
||||
ContentBlockStartInfo::Text { text } => {
|
||||
if text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Ok(StreamChunk {
|
||||
content: Some(text),
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
ContentBlockStartInfo::ToolUse { id, name } => {
|
||||
// Store the tool call start
|
||||
let mut state = tool_state.lock().await;
|
||||
while state.len() <= index {
|
||||
state.push(PartialToolCall::default());
|
||||
}
|
||||
state[index] = PartialToolCall {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
input_json: String::new(),
|
||||
};
|
||||
|
||||
Some(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index,
|
||||
id: Some(id),
|
||||
function_name: Some(name),
|
||||
arguments_delta: None,
|
||||
}]),
|
||||
done: false,
|
||||
usage: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StreamEvent::ContentBlockDelta { index, delta } => match delta {
|
||||
ContentDelta::TextDelta { text } => Some(Ok(StreamChunk {
|
||||
content: Some(text),
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: None,
|
||||
})),
|
||||
ContentDelta::InputJsonDelta { partial_json } => {
|
||||
// Accumulate the JSON
|
||||
let mut state = tool_state.lock().await;
|
||||
if index < state.len() {
|
||||
state[index].input_json.push_str(&partial_json);
|
||||
}
|
||||
|
||||
Some(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index,
|
||||
id: None,
|
||||
function_name: None,
|
||||
arguments_delta: Some(partial_json),
|
||||
}]),
|
||||
done: false,
|
||||
usage: None,
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
StreamEvent::MessageDelta { usage, .. } => {
|
||||
let u = usage.map(|u| Usage {
|
||||
prompt_tokens: u.input_tokens,
|
||||
completion_tokens: u.output_tokens,
|
||||
total_tokens: u.input_tokens + u.output_tokens,
|
||||
});
|
||||
|
||||
Some(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: None,
|
||||
done: false,
|
||||
usage: u,
|
||||
}))
|
||||
}
|
||||
|
||||
StreamEvent::MessageStop => Some(Ok(StreamChunk {
|
||||
content: None,
|
||||
tool_calls: None,
|
||||
done: true,
|
||||
usage: None,
|
||||
})),
|
||||
|
||||
StreamEvent::Error { error } => Some(Err(LlmError::Api {
|
||||
message: error.message,
|
||||
code: Some(error.error_type),
|
||||
})),
|
||||
|
||||
// Ignore other events
|
||||
StreamEvent::MessageStart { .. }
|
||||
| StreamEvent::ContentBlockStop { .. }
|
||||
| StreamEvent::Ping => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ProviderInfo Implementation
|
||||
// ============================================================================
|
||||
|
||||
/// Known Claude models with their specifications
|
||||
fn get_claude_models() -> Vec<ModelInfo> {
|
||||
vec![
|
||||
ModelInfo {
|
||||
id: "claude-opus-4-20250514".to_string(),
|
||||
display_name: Some("Claude Opus 4".to_string()),
|
||||
description: Some("Most capable model for complex tasks".to_string()),
|
||||
context_window: Some(200_000),
|
||||
max_output_tokens: Some(32_000),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(15.0),
|
||||
output_price_per_mtok: Some(75.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "claude-sonnet-4-20250514".to_string(),
|
||||
display_name: Some("Claude Sonnet 4".to_string()),
|
||||
description: Some("Best balance of performance and speed".to_string()),
|
||||
context_window: Some(200_000),
|
||||
max_output_tokens: Some(64_000),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(3.0),
|
||||
output_price_per_mtok: Some(15.0),
|
||||
},
|
||||
ModelInfo {
|
||||
id: "claude-haiku-3-5-20241022".to_string(),
|
||||
display_name: Some("Claude 3.5 Haiku".to_string()),
|
||||
description: Some("Fast and affordable for simple tasks".to_string()),
|
||||
context_window: Some(200_000),
|
||||
max_output_tokens: Some(8_192),
|
||||
supports_tools: true,
|
||||
supports_vision: true,
|
||||
input_price_per_mtok: Some(0.80),
|
||||
output_price_per_mtok: Some(4.0),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderInfo for AnthropicClient {
|
||||
async fn status(&self) -> Result<ProviderStatus, LlmError> {
|
||||
let authenticated = self.auth.bearer_token().is_some();
|
||||
|
||||
// Try to reach the API with a simple request
|
||||
let reachable = if authenticated {
|
||||
// Test with a minimal message to verify auth works
|
||||
let test_messages = vec![ChatMessage::user("Hi")];
|
||||
let test_opts = ChatOptions::new(&self.model).with_max_tokens(1);
|
||||
|
||||
match self.chat(&test_messages, &test_opts, None).await {
|
||||
Ok(_) => true,
|
||||
Err(LlmError::Auth(_)) => false, // Auth failed
|
||||
Err(_) => true, // Other errors mean API is reachable
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let account = if authenticated && reachable {
|
||||
self.account_info().await.ok().flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let message = if !authenticated {
|
||||
Some("Not authenticated - run 'owlen login anthropic' to authenticate".to_string())
|
||||
} else if !reachable {
|
||||
Some("Cannot reach Anthropic API".to_string())
|
||||
} else {
|
||||
Some("Connected".to_string())
|
||||
};
|
||||
|
||||
Ok(ProviderStatus {
|
||||
provider: "anthropic".to_string(),
|
||||
authenticated,
|
||||
account,
|
||||
model: self.model.clone(),
|
||||
endpoint: API_BASE_URL.to_string(),
|
||||
reachable,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
async fn account_info(&self) -> Result<Option<AccountInfo>, LlmError> {
|
||||
// Anthropic doesn't have a public account info endpoint
|
||||
// Return None - account info would come from OAuth token claims
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn usage_stats(&self) -> Result<Option<UsageStats>, LlmError> {
|
||||
// Anthropic doesn't expose usage stats via API
|
||||
// This would require the admin/billing API with different auth
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
|
||||
// Return known models - Anthropic doesn't have a models list endpoint
|
||||
Ok(get_claude_models())
|
||||
}
|
||||
|
||||
async fn model_info(&self, model_id: &str) -> Result<Option<ModelInfo>, LlmError> {
|
||||
let models = get_claude_models();
|
||||
Ok(models.into_iter().find(|m| m.id == model_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_core::ToolParameters;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_message_conversion() {
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are helpful"),
|
||||
ChatMessage::user("Hello"),
|
||||
ChatMessage::assistant("Hi there!"),
|
||||
];
|
||||
|
||||
let (system, anthropic_msgs) = AnthropicClient::prepare_messages(&messages);
|
||||
|
||||
assert_eq!(system, Some("You are helpful".to_string()));
|
||||
assert_eq!(anthropic_msgs.len(), 2);
|
||||
assert_eq!(anthropic_msgs[0].role, "user");
|
||||
assert_eq!(anthropic_msgs[1].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_conversion() {
|
||||
let tools = vec![Tool::function(
|
||||
"read_file",
|
||||
"Read a file's contents",
|
||||
ToolParameters::object(
|
||||
json!({
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path"
|
||||
}
|
||||
}),
|
||||
vec!["path".to_string()],
|
||||
),
|
||||
)];
|
||||
|
||||
let anthropic_tools = AnthropicClient::prepare_tools(Some(&tools)).unwrap();
|
||||
|
||||
assert_eq!(anthropic_tools.len(), 1);
|
||||
assert_eq!(anthropic_tools[0].name, "read_file");
|
||||
assert_eq!(anthropic_tools[0].description, "Read a file's contents");
|
||||
}
|
||||
}
|
||||
12
crates/llm/anthropic/src/lib.rs
Normal file
12
crates/llm/anthropic/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
//! Anthropic Claude API Client
|
||||
//!
|
||||
//! Implements the LlmProvider trait for Anthropic's Claude models.
|
||||
//! Supports both API key authentication and OAuth device flow.
|
||||
|
||||
mod auth;
|
||||
mod client;
|
||||
mod types;
|
||||
|
||||
pub use auth::*;
|
||||
pub use client::*;
|
||||
pub use types::*;
|
||||
276
crates/llm/anthropic/src/types.rs
Normal file
276
crates/llm/anthropic/src/types.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! Anthropic API request/response types
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
// ============================================================================
|
||||
// Request Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessagesRequest<'a> {
|
||||
pub model: &'a str,
|
||||
pub messages: Vec<AnthropicMessage>,
|
||||
pub max_tokens: u32,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<&'a str>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_sequences: Option<&'a [String]>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<AnthropicTool>>,
|
||||
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicMessage {
|
||||
pub role: String, // "user" or "assistant"
|
||||
pub content: AnthropicContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum AnthropicContent {
|
||||
Text(String),
|
||||
Blocks(Vec<ContentBlock>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
is_error: Option<bool>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnthropicTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: ToolInputSchema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolInputSchema {
|
||||
#[serde(rename = "type")]
|
||||
pub schema_type: String,
|
||||
pub properties: Value,
|
||||
pub required: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MessagesResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub response_type: String,
|
||||
pub role: String,
|
||||
pub content: Vec<ResponseContentBlock>,
|
||||
pub model: String,
|
||||
pub stop_reason: Option<String>,
|
||||
pub usage: Option<UsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ResponseContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct UsageInfo {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streaming Event Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum StreamEvent {
|
||||
#[serde(rename = "message_start")]
|
||||
MessageStart { message: MessageStartInfo },
|
||||
|
||||
#[serde(rename = "content_block_start")]
|
||||
ContentBlockStart {
|
||||
index: usize,
|
||||
content_block: ContentBlockStartInfo,
|
||||
},
|
||||
|
||||
#[serde(rename = "content_block_delta")]
|
||||
ContentBlockDelta { index: usize, delta: ContentDelta },
|
||||
|
||||
#[serde(rename = "content_block_stop")]
|
||||
ContentBlockStop { index: usize },
|
||||
|
||||
#[serde(rename = "message_delta")]
|
||||
MessageDelta {
|
||||
delta: MessageDeltaInfo,
|
||||
usage: Option<UsageInfo>,
|
||||
},
|
||||
|
||||
#[serde(rename = "message_stop")]
|
||||
MessageStop,
|
||||
|
||||
#[serde(rename = "ping")]
|
||||
Ping,
|
||||
|
||||
#[serde(rename = "error")]
|
||||
Error { error: ApiError },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MessageStartInfo {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: String,
|
||||
pub role: String,
|
||||
pub model: String,
|
||||
pub usage: Option<UsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentBlockStartInfo {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse { id: String, name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ContentDelta {
|
||||
#[serde(rename = "text_delta")]
|
||||
TextDelta { text: String },
|
||||
|
||||
#[serde(rename = "input_json_delta")]
|
||||
InputJsonDelta { partial_json: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MessageDeltaInfo {
|
||||
pub stop_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ApiError {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions
|
||||
// ============================================================================
|
||||
|
||||
impl From<&llm_core::Tool> for AnthropicTool {
|
||||
fn from(tool: &llm_core::Tool) -> Self {
|
||||
Self {
|
||||
name: tool.function.name.clone(),
|
||||
description: tool.function.description.clone(),
|
||||
input_schema: ToolInputSchema {
|
||||
schema_type: tool.function.parameters.param_type.clone(),
|
||||
properties: tool.function.parameters.properties.clone(),
|
||||
required: tool.function.parameters.required.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&llm_core::ChatMessage> for AnthropicMessage {
|
||||
fn from(msg: &llm_core::ChatMessage) -> Self {
|
||||
use llm_core::Role;
|
||||
|
||||
let role = match msg.role {
|
||||
Role::User | Role::System => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::Tool => "user", // Tool results come as user messages in Anthropic
|
||||
};
|
||||
|
||||
// Handle tool results
|
||||
if msg.role == Role::Tool {
|
||||
if let (Some(tool_call_id), Some(content)) = (&msg.tool_call_id, &msg.content) {
|
||||
return Self {
|
||||
role: "user".to_string(),
|
||||
content: AnthropicContent::Blocks(vec![ContentBlock::ToolResult {
|
||||
tool_use_id: tool_call_id.clone(),
|
||||
content: content.clone(),
|
||||
is_error: None,
|
||||
}]),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle assistant messages with tool calls
|
||||
if msg.role == Role::Assistant {
|
||||
if let Some(tool_calls) = &msg.tool_calls {
|
||||
let mut blocks: Vec<ContentBlock> = Vec::new();
|
||||
|
||||
// Add text content if present
|
||||
if let Some(text) = &msg.content {
|
||||
if !text.is_empty() {
|
||||
blocks.push(ContentBlock::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool use blocks
|
||||
for call in tool_calls {
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
id: call.id.clone(),
|
||||
name: call.function.name.clone(),
|
||||
input: call.function.arguments.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
return Self {
|
||||
role: "assistant".to_string(),
|
||||
content: AnthropicContent::Blocks(blocks),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Simple text message
|
||||
Self {
|
||||
role: role.to_string(),
|
||||
content: AnthropicContent::Text(msg.content.clone().unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
18
crates/llm/core/Cargo.toml
Normal file
18
crates/llm/core/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "llm-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LLM provider abstraction layer for Owlen"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
rand = "0.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.0", features = ["time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.0", features = ["macros", "rt"] }
|
||||
18
crates/llm/core/README.md
Normal file
18
crates/llm/core/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Owlen LLM Core
|
||||
|
||||
The core abstraction layer for Large Language Model (LLM) providers in the Owlen AI agent.
|
||||
|
||||
## Overview
|
||||
This crate defines the common traits and types used to integrate various AI providers. It enables the agent to be model-agnostic and switch between different backends at runtime.
|
||||
|
||||
## Key Components
|
||||
- `LlmProvider` Trait: The primary interface that all provider implementations (Anthropic, OpenAI, Ollama) must satisfy.
|
||||
- `ChatMessage`: Unified message structure for conversation history.
|
||||
- `StreamChunk`: Standardized format for streaming LLM responses.
|
||||
- `ToolCall`: Abstraction for tool invocation requests from the model.
|
||||
- `TokenCounter`: Utilities for estimating token usage and managing context windows.
|
||||
|
||||
## Supported Providers
|
||||
- **Anthropic:** Integration with Claude 3.5 models.
|
||||
- **OpenAI:** Integration with GPT-4o models.
|
||||
- **Ollama:** Support for local models (e.g., Llama 3, Qwen).
|
||||
195
crates/llm/core/examples/token_counting.rs
Normal file
195
crates/llm/core/examples/token_counting.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! Token counting example
|
||||
//!
|
||||
//! This example demonstrates how to use the token counting utilities
|
||||
//! to manage LLM context windows.
|
||||
//!
|
||||
//! Run with: cargo run --example token_counting -p llm-core
|
||||
|
||||
use llm_core::{
|
||||
ChatMessage, ClaudeTokenCounter, ContextWindow, SimpleTokenCounter, TokenCounter,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
println!("=== Token Counting Example ===\n");
|
||||
|
||||
// Example 1: Basic token counting with SimpleTokenCounter
|
||||
println!("1. Basic Token Counting");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
let simple_counter = SimpleTokenCounter::new(8192);
|
||||
let text = "The quick brown fox jumps over the lazy dog.";
|
||||
|
||||
let token_count = simple_counter.count(text);
|
||||
println!("Text: \"{}\"", text);
|
||||
println!("Estimated tokens: {}", token_count);
|
||||
println!("Max context: {}\n", simple_counter.max_context());
|
||||
|
||||
// Example 2: Counting tokens in chat messages
|
||||
println!("2. Counting Tokens in Chat Messages");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant that provides concise answers."),
|
||||
ChatMessage::user("What is the capital of France?"),
|
||||
ChatMessage::assistant("The capital of France is Paris."),
|
||||
ChatMessage::user("What is its population?"),
|
||||
];
|
||||
|
||||
let total_tokens = simple_counter.count_messages(&messages);
|
||||
println!("Number of messages: {}", messages.len());
|
||||
println!("Total tokens (with overhead): {}\n", total_tokens);
|
||||
|
||||
// Example 3: Using ClaudeTokenCounter for Claude models
|
||||
println!("3. Claude-Specific Token Counting");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
let claude_counter = ClaudeTokenCounter::new();
|
||||
let claude_total = claude_counter.count_messages(&messages);
|
||||
|
||||
println!("Claude counter max context: {}", claude_counter.max_context());
|
||||
println!("Claude estimated tokens: {}\n", claude_total);
|
||||
|
||||
// Example 4: Context window management
|
||||
println!("4. Context Window Management");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
let mut context = ContextWindow::new(8192);
|
||||
println!("Created context window with max: {} tokens", context.max());
|
||||
|
||||
// Simulate adding messages
|
||||
let conversation = vec![
|
||||
ChatMessage::user("Tell me about Rust programming."),
|
||||
ChatMessage::assistant(
|
||||
"Rust is a systems programming language focused on safety, \
|
||||
speed, and concurrency. It prevents common bugs like null pointer \
|
||||
dereferences and data races through its ownership system.",
|
||||
),
|
||||
ChatMessage::user("What are its main features?"),
|
||||
ChatMessage::assistant(
|
||||
"Rust's main features include: 1) Memory safety without garbage collection, \
|
||||
2) Zero-cost abstractions, 3) Fearless concurrency, 4) Pattern matching, \
|
||||
5) Type inference, and 6) A powerful macro system.",
|
||||
),
|
||||
];
|
||||
|
||||
for (i, msg) in conversation.iter().enumerate() {
|
||||
let tokens = simple_counter.count_messages(&[msg.clone()]);
|
||||
context.add_tokens(tokens);
|
||||
|
||||
let role = msg.role.as_str();
|
||||
let preview = msg
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|c| {
|
||||
if c.len() > 50 {
|
||||
format!("{}...", &c[..50])
|
||||
} else {
|
||||
c.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
println!(
|
||||
"Message {}: [{}] \"{}\"",
|
||||
i + 1,
|
||||
role,
|
||||
preview
|
||||
);
|
||||
println!(" Added {} tokens", tokens);
|
||||
println!(" Total used: {} / {}", context.used(), context.max());
|
||||
println!(" Usage: {:.1}%", context.usage_percent() * 100.0);
|
||||
println!(" Progress: {}\n", context.progress_bar(30));
|
||||
}
|
||||
|
||||
// Example 5: Checking context limits
|
||||
println!("5. Checking Context Limits");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
if context.is_near_limit(0.8) {
|
||||
println!("Warning: Context is over 80% full!");
|
||||
} else {
|
||||
println!("Context usage is below 80%");
|
||||
}
|
||||
|
||||
let remaining = context.remaining();
|
||||
println!("Remaining tokens: {}", remaining);
|
||||
|
||||
let new_message_tokens = 500;
|
||||
if context.has_room_for(new_message_tokens) {
|
||||
println!(
|
||||
"Can fit a message of {} tokens",
|
||||
new_message_tokens
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Cannot fit a message of {} tokens - would need to compact or start new context",
|
||||
new_message_tokens
|
||||
);
|
||||
}
|
||||
|
||||
// Example 6: Different counter variants
|
||||
println!("\n6. Using Different Counter Variants");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
let counter_8k = SimpleTokenCounter::default_8k();
|
||||
let counter_32k = SimpleTokenCounter::with_32k();
|
||||
let counter_128k = SimpleTokenCounter::with_128k();
|
||||
|
||||
println!("8k context counter: {} tokens", counter_8k.max_context());
|
||||
println!("32k context counter: {} tokens", counter_32k.max_context());
|
||||
println!("128k context counter: {} tokens", counter_128k.max_context());
|
||||
|
||||
let haiku = ClaudeTokenCounter::haiku();
|
||||
let sonnet = ClaudeTokenCounter::sonnet();
|
||||
let opus = ClaudeTokenCounter::opus();
|
||||
|
||||
println!("\nClaude Haiku: {} tokens", haiku.max_context());
|
||||
println!("Claude Sonnet: {} tokens", sonnet.max_context());
|
||||
println!("Claude Opus: {} tokens", opus.max_context());
|
||||
|
||||
// Example 7: Managing context for a long conversation
|
||||
println!("\n7. Long Conversation Simulation");
|
||||
println!("{}", "-".repeat(50));
|
||||
|
||||
let mut long_context = ContextWindow::new(4096); // Smaller context for demo
|
||||
let counter = SimpleTokenCounter::new(4096);
|
||||
|
||||
let mut message_count = 0;
|
||||
let mut compaction_count = 0;
|
||||
|
||||
// Simulate 20 exchanges
|
||||
for i in 0..20 {
|
||||
let user_msg = ChatMessage::user(format!(
|
||||
"This is user message number {} asking a question.",
|
||||
i + 1
|
||||
));
|
||||
let assistant_msg = ChatMessage::assistant(format!(
|
||||
"This is assistant response number {} providing a detailed answer with multiple sentences to make it longer.",
|
||||
i + 1
|
||||
));
|
||||
|
||||
let tokens_needed = counter.count_messages(&[user_msg, assistant_msg]);
|
||||
|
||||
if !long_context.has_room_for(tokens_needed) {
|
||||
println!(
|
||||
"After {} messages, context is full ({}%). Compacting...",
|
||||
message_count,
|
||||
(long_context.usage_percent() * 100.0) as u32
|
||||
);
|
||||
// In a real scenario, we would compact the conversation
|
||||
// For now, just reset
|
||||
long_context.reset();
|
||||
compaction_count += 1;
|
||||
}
|
||||
|
||||
long_context.add_tokens(tokens_needed);
|
||||
message_count += 2;
|
||||
}
|
||||
|
||||
println!("Total messages: {}", message_count);
|
||||
println!("Compactions needed: {}", compaction_count);
|
||||
println!("Final context usage: {:.1}%", long_context.usage_percent() * 100.0);
|
||||
println!("Final progress: {}", long_context.progress_bar(40));
|
||||
|
||||
println!("\n=== Example Complete ===");
|
||||
}
|
||||
864
crates/llm/core/src/lib.rs
Normal file
864
crates/llm/core/src/lib.rs
Normal file
@@ -0,0 +1,864 @@
|
||||
//! LLM Provider Abstraction Layer
|
||||
//!
|
||||
//! This crate defines the common types and traits for LLM provider integration.
|
||||
//! Providers (Ollama, Anthropic Claude, OpenAI) implement the `LlmProvider` trait
|
||||
//! to enable swapping providers at runtime.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::pin::Pin;
|
||||
use thiserror::Error;
|
||||
|
||||
// ============================================================================
|
||||
// Public Modules
|
||||
// ============================================================================
|
||||
|
||||
pub mod retry;
|
||||
pub mod tokens;
|
||||
|
||||
// Re-export token counting types for convenience
|
||||
pub use tokens::{ClaudeTokenCounter, ContextWindow, SimpleTokenCounter, TokenCounter};
|
||||
|
||||
// Re-export retry types for convenience
|
||||
pub use retry::{is_retryable_error, RetryConfig, RetryStrategy};
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum LlmError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(String),
|
||||
|
||||
#[error("JSON parsing error: {0}")]
|
||||
Json(String),
|
||||
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Rate limit exceeded: retry after {retry_after_secs:?} seconds")]
|
||||
RateLimit { retry_after_secs: Option<u64> },
|
||||
|
||||
#[error("API error: {message}")]
|
||||
Api { message: String, code: Option<String> },
|
||||
|
||||
#[error("Provider error: {0}")]
|
||||
Provider(String),
|
||||
|
||||
#[error("Stream error: {0}")]
|
||||
Stream(String),
|
||||
|
||||
#[error("Request timeout: {0}")]
|
||||
Timeout(String),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Message Types
|
||||
// ============================================================================
|
||||
|
||||
/// Role of a message in the conversation
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::System => "system",
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::Tool => "tool",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Role {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"system" => Role::System,
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
"tool" => Role::Tool,
|
||||
_ => Role::User, // Default fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A message in the conversation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
|
||||
/// Tool calls made by the assistant
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
|
||||
/// For tool role messages: the ID of the tool call this responds to
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
|
||||
/// For tool role messages: the name of the tool
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Create a system message
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::System,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::User,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message with tool calls (no text content)
|
||||
pub fn assistant_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool result message
|
||||
pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::Tool,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Types
|
||||
// ============================================================================
|
||||
|
||||
/// A tool call requested by the LLM
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
/// Unique identifier for this tool call
|
||||
pub id: String,
|
||||
|
||||
/// The type of tool call (always "function" for now)
|
||||
#[serde(rename = "type", default = "default_function_type")]
|
||||
pub call_type: String,
|
||||
|
||||
/// The function being called
|
||||
pub function: FunctionCall,
|
||||
}
|
||||
|
||||
fn default_function_type() -> String {
|
||||
"function".to_string()
|
||||
}
|
||||
|
||||
/// Details of a function call
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FunctionCall {
|
||||
/// Name of the function to call
|
||||
pub name: String,
|
||||
|
||||
/// Arguments as a JSON object
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Definition of a tool available to the LLM
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tool {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
/// Create a new function tool
|
||||
pub fn function(
|
||||
name: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
parameters: ToolParameters,
|
||||
) -> Self {
|
||||
Self {
|
||||
tool_type: "function".to_string(),
|
||||
function: ToolFunction {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Function definition within a tool
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: ToolParameters,
|
||||
}
|
||||
|
||||
/// Parameters schema for a function
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolParameters {
|
||||
#[serde(rename = "type")]
|
||||
pub param_type: String,
|
||||
|
||||
/// JSON Schema properties object
|
||||
pub properties: Value,
|
||||
|
||||
/// Required parameter names
|
||||
pub required: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolParameters {
|
||||
/// Create an object parameter schema
|
||||
pub fn object(properties: Value, required: Vec<String>) -> Self {
|
||||
Self {
|
||||
param_type: "object".to_string(),
|
||||
properties,
|
||||
required,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streaming Response Types
|
||||
// ============================================================================
|
||||
|
||||
/// A chunk of a streaming response
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamChunk {
|
||||
/// Incremental text content
|
||||
pub content: Option<String>,
|
||||
|
||||
/// Tool calls (may be partial/streaming)
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
|
||||
/// Whether this is the final chunk
|
||||
pub done: bool,
|
||||
|
||||
/// Usage statistics (typically only in final chunk)
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
/// Partial tool call for streaming
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCallDelta {
|
||||
/// Index of this tool call in the array
|
||||
pub index: usize,
|
||||
|
||||
/// Tool call ID (may only be present in first delta)
|
||||
pub id: Option<String>,
|
||||
|
||||
/// Function name (may only be present in first delta)
|
||||
pub function_name: Option<String>,
|
||||
|
||||
/// Incremental arguments string
|
||||
pub arguments_delta: Option<String>,
|
||||
}
|
||||
|
||||
/// Token usage statistics
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Usage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// Options for a chat request
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChatOptions {
|
||||
/// Model to use
|
||||
pub model: String,
|
||||
|
||||
/// Temperature (0.0 - 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
|
||||
/// Maximum tokens to generate
|
||||
pub max_tokens: Option<u32>,
|
||||
|
||||
/// Top-p sampling
|
||||
pub top_p: Option<f32>,
|
||||
|
||||
/// Stop sequences
|
||||
pub stop: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ChatOptions {
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
model: model.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_temperature(mut self, temp: f32) -> Self {
|
||||
self.temperature = Some(temp);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_tokens(mut self, max: u32) -> Self {
|
||||
self.max_tokens = Some(max);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Trait
|
||||
// ============================================================================
|
||||
|
||||
/// A boxed stream of chunks
|
||||
pub type ChunkStream = Pin<Box<dyn Stream<Item = Result<StreamChunk, LlmError>> + Send>>;
|
||||
|
||||
/// The main trait that all LLM providers must implement
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// Get the provider name (e.g., "ollama", "anthropic", "openai")
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Get the current model name
|
||||
fn model(&self) -> &str;
|
||||
|
||||
/// Send a chat request and receive a streaming response
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `messages` - The conversation history
|
||||
/// * `options` - Request options (model, temperature, etc.)
|
||||
/// * `tools` - Optional list of tools the model can use
|
||||
///
|
||||
/// # Returns
|
||||
/// A stream of response chunks
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError>;
|
||||
|
||||
/// Send a chat request and receive a complete response (non-streaming)
|
||||
///
|
||||
/// Default implementation collects the stream, but providers may override
|
||||
/// for efficiency.
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
use futures::StreamExt;
|
||||
|
||||
let mut stream = self.chat_stream(messages, options, tools).await?;
|
||||
let mut content = String::new();
|
||||
let mut tool_calls: Vec<PartialToolCall> = Vec::new();
|
||||
let mut usage = None;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
|
||||
if let Some(text) = chunk.content {
|
||||
content.push_str(&text);
|
||||
}
|
||||
|
||||
if let Some(deltas) = chunk.tool_calls {
|
||||
for delta in deltas {
|
||||
// Grow the tool_calls vec if needed
|
||||
while tool_calls.len() <= delta.index {
|
||||
tool_calls.push(PartialToolCall::default());
|
||||
}
|
||||
|
||||
let partial = &mut tool_calls[delta.index];
|
||||
if let Some(id) = delta.id {
|
||||
partial.id = Some(id);
|
||||
}
|
||||
if let Some(name) = delta.function_name {
|
||||
partial.function_name = Some(name);
|
||||
}
|
||||
if let Some(args) = delta.arguments_delta {
|
||||
partial.arguments.push_str(&args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.usage.is_some() {
|
||||
usage = chunk.usage;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert partial tool calls to complete tool calls
|
||||
let final_tool_calls: Vec<ToolCall> = tool_calls
|
||||
.into_iter()
|
||||
.filter_map(|p| p.try_into_tool_call())
|
||||
.collect();
|
||||
|
||||
Ok(ChatResponse {
|
||||
content: if content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(content)
|
||||
},
|
||||
tool_calls: if final_tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(final_tool_calls)
|
||||
},
|
||||
usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A complete chat response (non-streaming)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatResponse {
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Blanket Implementations
|
||||
// ============================================================================
|
||||
|
||||
/// Allow `Arc<dyn LlmProvider>` to be used as an `LlmProvider`
|
||||
#[async_trait]
|
||||
impl LlmProvider for std::sync::Arc<dyn LlmProvider> {
|
||||
fn name(&self) -> &str {
|
||||
(**self).name()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
(**self).model()
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
(**self).chat_stream(messages, options, tools).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
(**self).chat(messages, options, tools).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow `&Arc<dyn LlmProvider>` to be used as an `LlmProvider`
|
||||
#[async_trait]
|
||||
impl LlmProvider for &std::sync::Arc<dyn LlmProvider> {
|
||||
fn name(&self) -> &str {
|
||||
(***self).name()
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
(***self).model()
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
(***self).chat_stream(messages, options, tools).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
(***self).chat(messages, options, tools).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for accumulating streaming tool calls
|
||||
#[derive(Default)]
|
||||
struct PartialToolCall {
|
||||
id: Option<String>,
|
||||
function_name: Option<String>,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
impl PartialToolCall {
|
||||
fn try_into_tool_call(self) -> Option<ToolCall> {
|
||||
let id = self.id?;
|
||||
let name = self.function_name?;
|
||||
let arguments: Value = serde_json::from_str(&self.arguments).ok()?;
|
||||
|
||||
Some(ToolCall {
|
||||
id,
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall { name, arguments },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication
|
||||
// ============================================================================
|
||||
|
||||
/// Authentication method for LLM providers
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthMethod {
|
||||
/// No authentication (for local providers like Ollama)
|
||||
None,
|
||||
|
||||
/// API key authentication
|
||||
ApiKey(String),
|
||||
|
||||
/// OAuth access token (from login flow)
|
||||
OAuth {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_at: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
/// Create API key auth
|
||||
pub fn api_key(key: impl Into<String>) -> Self {
|
||||
Self::ApiKey(key.into())
|
||||
}
|
||||
|
||||
/// Create OAuth auth from tokens
|
||||
pub fn oauth(access_token: impl Into<String>) -> Self {
|
||||
Self::OAuth {
|
||||
access_token: access_token.into(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create OAuth auth with refresh token
|
||||
pub fn oauth_with_refresh(
|
||||
access_token: impl Into<String>,
|
||||
refresh_token: impl Into<String>,
|
||||
expires_at: Option<u64>,
|
||||
) -> Self {
|
||||
Self::OAuth {
|
||||
access_token: access_token.into(),
|
||||
refresh_token: Some(refresh_token.into()),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the bearer token for Authorization header
|
||||
pub fn bearer_token(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::ApiKey(key) => Some(key),
|
||||
Self::OAuth { access_token, .. } => Some(access_token),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if token might need refresh
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
match self {
|
||||
Self::OAuth {
|
||||
expires_at: Some(exp),
|
||||
refresh_token: Some(_),
|
||||
..
|
||||
} => {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
// Refresh if expiring within 5 minutes
|
||||
*exp < now + 300
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Device code response for OAuth device flow
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
/// Code the user enters on the verification page
|
||||
pub user_code: String,
|
||||
|
||||
/// URL the user visits to authorize
|
||||
pub verification_uri: String,
|
||||
|
||||
/// Full URL with code pre-filled (if supported)
|
||||
pub verification_uri_complete: Option<String>,
|
||||
|
||||
/// Device code for polling (internal use)
|
||||
pub device_code: String,
|
||||
|
||||
/// How often to poll (in seconds)
|
||||
pub interval: u64,
|
||||
|
||||
/// When the codes expire (in seconds)
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Result of polling for device authorization
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DeviceAuthResult {
|
||||
/// Still waiting for user to authorize
|
||||
Pending,
|
||||
|
||||
/// User authorized, here are the tokens
|
||||
Success {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: Option<u64>,
|
||||
},
|
||||
|
||||
/// User denied authorization
|
||||
Denied,
|
||||
|
||||
/// Code expired
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// Trait for providers that support OAuth device flow
|
||||
#[async_trait]
|
||||
pub trait OAuthProvider {
|
||||
/// Start the device authorization flow
|
||||
async fn start_device_auth(&self) -> Result<DeviceCodeResponse, LlmError>;
|
||||
|
||||
/// Poll for the authorization result
|
||||
async fn poll_device_auth(&self, device_code: &str) -> Result<DeviceAuthResult, LlmError>;
|
||||
|
||||
/// Refresh an access token using a refresh token
|
||||
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthMethod, LlmError>;
|
||||
}
|
||||
|
||||
/// Stored credentials for a provider
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredCredentials {
|
||||
pub provider: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Status & Info
|
||||
// ============================================================================
|
||||
|
||||
/// Status information for a provider connection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderStatus {
|
||||
/// Provider name
|
||||
pub provider: String,
|
||||
|
||||
/// Whether the connection is authenticated
|
||||
pub authenticated: bool,
|
||||
|
||||
/// Current user/account info if authenticated
|
||||
pub account: Option<AccountInfo>,
|
||||
|
||||
/// Current model being used
|
||||
pub model: String,
|
||||
|
||||
/// API endpoint URL
|
||||
pub endpoint: String,
|
||||
|
||||
/// Whether the provider is reachable
|
||||
pub reachable: bool,
|
||||
|
||||
/// Any status message or error
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Account/user information from the provider
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountInfo {
|
||||
/// Account/user ID
|
||||
pub id: Option<String>,
|
||||
|
||||
/// Display name or email
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Account email
|
||||
pub email: Option<String>,
|
||||
|
||||
/// Account type (free, pro, team, enterprise)
|
||||
pub account_type: Option<String>,
|
||||
|
||||
/// Organization name if applicable
|
||||
pub organization: Option<String>,
|
||||
}
|
||||
|
||||
/// Usage statistics from the provider
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UsageStats {
|
||||
/// Total tokens used in current period
|
||||
pub tokens_used: Option<u64>,
|
||||
|
||||
/// Token limit for current period (if applicable)
|
||||
pub token_limit: Option<u64>,
|
||||
|
||||
/// Number of requests made
|
||||
pub requests_made: Option<u64>,
|
||||
|
||||
/// Request limit (if applicable)
|
||||
pub request_limit: Option<u64>,
|
||||
|
||||
/// Cost incurred (if available)
|
||||
pub cost_usd: Option<f64>,
|
||||
|
||||
/// Period start timestamp
|
||||
pub period_start: Option<u64>,
|
||||
|
||||
/// Period end timestamp
|
||||
pub period_end: Option<u64>,
|
||||
}
|
||||
|
||||
/// Available model information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
/// Model ID/name
|
||||
pub id: String,
|
||||
|
||||
/// Human-readable display name
|
||||
pub display_name: Option<String>,
|
||||
|
||||
/// Model description
|
||||
pub description: Option<String>,
|
||||
|
||||
/// Context window size (tokens)
|
||||
pub context_window: Option<u32>,
|
||||
|
||||
/// Max output tokens
|
||||
pub max_output_tokens: Option<u32>,
|
||||
|
||||
/// Whether the model supports tool use
|
||||
pub supports_tools: bool,
|
||||
|
||||
/// Whether the model supports vision/images
|
||||
pub supports_vision: bool,
|
||||
|
||||
/// Input token price per 1M tokens (USD)
|
||||
pub input_price_per_mtok: Option<f64>,
|
||||
|
||||
/// Output token price per 1M tokens (USD)
|
||||
pub output_price_per_mtok: Option<f64>,
|
||||
}
|
||||
|
||||
/// Trait for providers that support status/info queries
|
||||
#[async_trait]
|
||||
pub trait ProviderInfo {
|
||||
/// Get the current connection status
|
||||
async fn status(&self) -> Result<ProviderStatus, LlmError>;
|
||||
|
||||
/// Get account information (if authenticated)
|
||||
async fn account_info(&self) -> Result<Option<AccountInfo>, LlmError>;
|
||||
|
||||
/// Get usage statistics (if available)
|
||||
async fn usage_stats(&self) -> Result<Option<UsageStats>, LlmError>;
|
||||
|
||||
/// List available models
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError>;
|
||||
|
||||
/// Check if a specific model is available
|
||||
async fn model_info(&self, model_id: &str) -> Result<Option<ModelInfo>, LlmError> {
|
||||
let models = self.list_models().await?;
|
||||
Ok(models.into_iter().find(|m| m.id == model_id))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Factory
|
||||
// ============================================================================
|
||||
|
||||
/// Supported LLM providers
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ProviderType {
|
||||
Ollama,
|
||||
Anthropic,
|
||||
OpenAI,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"ollama" => Ok(Self::Ollama),
|
||||
"anthropic" | "claude" => Ok(Self::Anthropic),
|
||||
"openai" | "gpt" => Ok(Self::OpenAI),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ollama => "ollama",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::OpenAI => "openai",
|
||||
}
|
||||
}
|
||||
|
||||
/// Default model for this provider
|
||||
pub fn default_model(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ollama => "qwen3:8b",
|
||||
Self::Anthropic => "claude-sonnet-4-20250514",
|
||||
Self::OpenAI => "gpt-4o",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
386
crates/llm/core/src/retry.rs
Normal file
386
crates/llm/core/src/retry.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
//! Error recovery and retry logic for LLM operations
|
||||
//!
|
||||
//! This module provides configurable retry strategies with exponential backoff
|
||||
//! for handling transient failures when communicating with LLM providers.
|
||||
|
||||
use crate::LlmError;
|
||||
use rand::Rng;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Configuration for retry behavior
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
/// Maximum number of retry attempts
|
||||
pub max_retries: u32,
|
||||
/// Initial delay before first retry (in milliseconds)
|
||||
pub initial_delay_ms: u64,
|
||||
/// Maximum delay between retries (in milliseconds)
|
||||
pub max_delay_ms: u64,
|
||||
/// Multiplier for exponential backoff
|
||||
pub backoff_multiplier: f32,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
initial_delay_ms: 1000,
|
||||
max_delay_ms: 30000,
|
||||
backoff_multiplier: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
/// Create a new retry configuration with custom values
|
||||
pub fn new(
|
||||
max_retries: u32,
|
||||
initial_delay_ms: u64,
|
||||
max_delay_ms: u64,
|
||||
backoff_multiplier: f32,
|
||||
) -> Self {
|
||||
Self {
|
||||
max_retries,
|
||||
initial_delay_ms,
|
||||
max_delay_ms,
|
||||
backoff_multiplier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a configuration with no retries
|
||||
pub fn no_retry() -> Self {
|
||||
Self {
|
||||
max_retries: 0,
|
||||
initial_delay_ms: 0,
|
||||
max_delay_ms: 0,
|
||||
backoff_multiplier: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a configuration with aggressive retries for rate-limited scenarios
|
||||
pub fn aggressive() -> Self {
|
||||
Self {
|
||||
max_retries: 5,
|
||||
initial_delay_ms: 2000,
|
||||
max_delay_ms: 60000,
|
||||
backoff_multiplier: 2.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines whether an error is retryable
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `error` - The error to check
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if the error is transient and the operation should be retried,
|
||||
/// `false` if the error is permanent and retrying won't help
|
||||
pub fn is_retryable_error(error: &LlmError) -> bool {
|
||||
match error {
|
||||
// Always retry rate limits
|
||||
LlmError::RateLimit { .. } => true,
|
||||
|
||||
// Always retry timeouts
|
||||
LlmError::Timeout(_) => true,
|
||||
|
||||
// Retry HTTP errors that are server-side (5xx)
|
||||
LlmError::Http(msg) => {
|
||||
// Check if the error message contains a 5xx status code
|
||||
msg.contains("500")
|
||||
|| msg.contains("502")
|
||||
|| msg.contains("503")
|
||||
|| msg.contains("504")
|
||||
|| msg.contains("Internal Server Error")
|
||||
|| msg.contains("Bad Gateway")
|
||||
|| msg.contains("Service Unavailable")
|
||||
|| msg.contains("Gateway Timeout")
|
||||
}
|
||||
|
||||
// Don't retry authentication errors - they need user intervention
|
||||
LlmError::Auth(_) => false,
|
||||
|
||||
// Don't retry JSON parsing errors - the data is malformed
|
||||
LlmError::Json(_) => false,
|
||||
|
||||
// Don't retry API errors - these are typically client-side issues
|
||||
LlmError::Api { .. } => false,
|
||||
|
||||
// Provider errors might be transient, but we conservatively don't retry
|
||||
LlmError::Provider(_) => false,
|
||||
|
||||
// Stream errors are typically not retryable
|
||||
LlmError::Stream(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strategy for retrying failed operations with exponential backoff
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryStrategy {
|
||||
config: RetryConfig,
|
||||
}
|
||||
|
||||
impl RetryStrategy {
|
||||
/// Create a new retry strategy with the given configuration
|
||||
pub fn new(config: RetryConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Create a retry strategy with default configuration
|
||||
pub fn default_config() -> Self {
|
||||
Self::new(RetryConfig::default())
|
||||
}
|
||||
|
||||
/// Execute an async operation with retries
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `operation` - A function that returns a Future producing a Result
|
||||
///
|
||||
/// # Returns
|
||||
/// The result of the operation, or the last error if all retries fail
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// let strategy = RetryStrategy::default_config();
|
||||
/// let result = strategy.execute(|| async {
|
||||
/// // Your LLM API call here
|
||||
/// llm_client.chat(&messages, &options, None).await
|
||||
/// }).await?;
|
||||
/// ```
|
||||
pub async fn execute<F, T, Fut>(&self, operation: F) -> Result<T, LlmError>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, LlmError>>,
|
||||
{
|
||||
let mut attempt = 0;
|
||||
|
||||
loop {
|
||||
// Try the operation
|
||||
match operation().await {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(err) => {
|
||||
// Check if we should retry
|
||||
if !is_retryable_error(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
|
||||
// Check if we've exhausted retries
|
||||
if attempt > self.config.max_retries {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Calculate delay with exponential backoff and jitter
|
||||
let delay = self.delay_for_attempt(attempt);
|
||||
|
||||
// Log retry attempt (in a real implementation, you might use tracing)
|
||||
eprintln!(
|
||||
"Retry attempt {}/{} after {:?}",
|
||||
attempt, self.config.max_retries, delay
|
||||
);
|
||||
|
||||
// Sleep before next attempt
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the delay for a given attempt number with jitter
|
||||
///
|
||||
/// Uses exponential backoff: delay = initial_delay * (backoff_multiplier ^ (attempt - 1))
|
||||
/// Adds random jitter of ±10% to prevent thundering herd problems
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `attempt` - The attempt number (1-indexed)
|
||||
///
|
||||
/// # Returns
|
||||
/// The delay duration to wait before the next retry
|
||||
fn delay_for_attempt(&self, attempt: u32) -> Duration {
|
||||
// Calculate base delay with exponential backoff
|
||||
let base_delay_ms = self.config.initial_delay_ms as f64
|
||||
* self.config.backoff_multiplier.powi((attempt - 1) as i32) as f64;
|
||||
|
||||
// Cap at max_delay_ms
|
||||
let capped_delay_ms = base_delay_ms.min(self.config.max_delay_ms as f64);
|
||||
|
||||
// Add jitter: ±10%
|
||||
let mut rng = rand::thread_rng();
|
||||
let jitter_factor = rng.gen_range(0.9..=1.1);
|
||||
let final_delay_ms = capped_delay_ms * jitter_factor;
|
||||
|
||||
Duration::from_millis(final_delay_ms as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn test_default_retry_config() {
|
||||
let config = RetryConfig::default();
|
||||
assert_eq!(config.max_retries, 3);
|
||||
assert_eq!(config.initial_delay_ms, 1000);
|
||||
assert_eq!(config.max_delay_ms, 30000);
|
||||
assert_eq!(config.backoff_multiplier, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_retry_config() {
|
||||
let config = RetryConfig::no_retry();
|
||||
assert_eq!(config.max_retries, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retryable_error() {
|
||||
// Retryable errors
|
||||
assert!(is_retryable_error(&LlmError::RateLimit {
|
||||
retry_after_secs: Some(60)
|
||||
}));
|
||||
assert!(is_retryable_error(&LlmError::Timeout(
|
||||
"Request timed out".to_string()
|
||||
)));
|
||||
assert!(is_retryable_error(&LlmError::Http(
|
||||
"500 Internal Server Error".to_string()
|
||||
)));
|
||||
assert!(is_retryable_error(&LlmError::Http(
|
||||
"503 Service Unavailable".to_string()
|
||||
)));
|
||||
|
||||
// Non-retryable errors
|
||||
assert!(!is_retryable_error(&LlmError::Auth(
|
||||
"Invalid API key".to_string()
|
||||
)));
|
||||
assert!(!is_retryable_error(&LlmError::Json(
|
||||
"Invalid JSON".to_string()
|
||||
)));
|
||||
assert!(!is_retryable_error(&LlmError::Api {
|
||||
message: "Invalid request".to_string(),
|
||||
code: Some("400".to_string())
|
||||
}));
|
||||
assert!(!is_retryable_error(&LlmError::Http(
|
||||
"400 Bad Request".to_string()
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delay_calculation() {
|
||||
let config = RetryConfig::default();
|
||||
let strategy = RetryStrategy::new(config);
|
||||
|
||||
// Test that delays increase exponentially
|
||||
let delay1 = strategy.delay_for_attempt(1);
|
||||
let delay2 = strategy.delay_for_attempt(2);
|
||||
let delay3 = strategy.delay_for_attempt(3);
|
||||
|
||||
// Base delays should be around 1000ms, 2000ms, 4000ms (with jitter)
|
||||
assert!(delay1.as_millis() >= 900 && delay1.as_millis() <= 1100);
|
||||
assert!(delay2.as_millis() >= 1800 && delay2.as_millis() <= 2200);
|
||||
assert!(delay3.as_millis() >= 3600 && delay3.as_millis() <= 4400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delay_max_cap() {
|
||||
let config = RetryConfig {
|
||||
max_retries: 10,
|
||||
initial_delay_ms: 1000,
|
||||
max_delay_ms: 5000,
|
||||
backoff_multiplier: 2.0,
|
||||
};
|
||||
let strategy = RetryStrategy::new(config);
|
||||
|
||||
// Even with high attempt numbers, delay should be capped
|
||||
let delay = strategy.delay_for_attempt(10);
|
||||
assert!(delay.as_millis() <= 5500); // max + jitter
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_success_on_first_attempt() {
|
||||
let strategy = RetryStrategy::default_config();
|
||||
let call_count = Arc::new(AtomicU32::new(0));
|
||||
let count_clone = call_count.clone();
|
||||
|
||||
let result = strategy
|
||||
.execute(|| {
|
||||
let count = count_clone.clone();
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok::<_, LlmError>(42)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(result.unwrap(), 42);
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_success_after_retries() {
|
||||
let config = RetryConfig::new(3, 10, 100, 2.0); // Fast retries for testing
|
||||
let strategy = RetryStrategy::new(config);
|
||||
let call_count = Arc::new(AtomicU32::new(0));
|
||||
let count_clone = call_count.clone();
|
||||
|
||||
let result = strategy
|
||||
.execute(|| {
|
||||
let count = count_clone.clone();
|
||||
async move {
|
||||
let current = count.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if current < 3 {
|
||||
Err(LlmError::Timeout("Timeout".to_string()))
|
||||
} else {
|
||||
Ok(42)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(result.unwrap(), 42);
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retry_exhausted() {
|
||||
let config = RetryConfig::new(2, 10, 100, 2.0); // Fast retries for testing
|
||||
let strategy = RetryStrategy::new(config);
|
||||
let call_count = Arc::new(AtomicU32::new(0));
|
||||
let count_clone = call_count.clone();
|
||||
|
||||
let result = strategy
|
||||
.execute(|| {
|
||||
let count = count_clone.clone();
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
Err::<(), _>(LlmError::Timeout("Always fails".to_string()))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 3); // Initial attempt + 2 retries
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_non_retryable_error() {
|
||||
let strategy = RetryStrategy::default_config();
|
||||
let call_count = Arc::new(AtomicU32::new(0));
|
||||
let count_clone = call_count.clone();
|
||||
|
||||
let result = strategy
|
||||
.execute(|| {
|
||||
let count = count_clone.clone();
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
Err::<(), _>(LlmError::Auth("Invalid API key".to_string()))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 1); // Should not retry
|
||||
}
|
||||
}
|
||||
607
crates/llm/core/src/tokens.rs
Normal file
607
crates/llm/core/src/tokens.rs
Normal file
@@ -0,0 +1,607 @@
|
||||
//! Token counting utilities for LLM context management
|
||||
//!
|
||||
//! This module provides token counting abstractions and implementations for
|
||||
//! managing LLM context windows. Token counters estimate token usage without
|
||||
//! requiring external tokenization libraries, using heuristic-based approaches.
|
||||
|
||||
use crate::ChatMessage;
|
||||
|
||||
// ============================================================================
|
||||
// TokenCounter Trait
|
||||
// ============================================================================
|
||||
|
||||
/// Trait for counting tokens in text and chat messages
|
||||
///
|
||||
/// Implementations provide model-specific token counting logic to help
|
||||
/// manage context windows and estimate API costs.
|
||||
pub trait TokenCounter: Send + Sync {
|
||||
/// Count tokens in a string
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `text` - The text to count tokens for
|
||||
///
|
||||
/// # Returns
|
||||
/// Estimated number of tokens
|
||||
fn count(&self, text: &str) -> usize;
|
||||
|
||||
/// Count tokens in chat messages
|
||||
///
|
||||
/// This accounts for both the message content and the overhead
|
||||
/// from the chat message structure (roles, delimiters, etc.).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `messages` - The messages to count tokens for
|
||||
///
|
||||
/// # Returns
|
||||
/// Estimated total tokens including message structure overhead
|
||||
fn count_messages(&self, messages: &[ChatMessage]) -> usize;
|
||||
|
||||
/// Get the model's max context window size
|
||||
///
|
||||
/// # Returns
|
||||
/// Maximum number of tokens the model can handle
|
||||
fn max_context(&self) -> usize;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SimpleTokenCounter
|
||||
// ============================================================================
|
||||
|
||||
/// A basic token counter using simple heuristics
|
||||
///
|
||||
/// This counter uses the rule of thumb that English text averages about
|
||||
/// 4 characters per token. It adds overhead for message structure.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use llm_core::tokens::{TokenCounter, SimpleTokenCounter};
|
||||
/// use llm_core::ChatMessage;
|
||||
///
|
||||
/// let counter = SimpleTokenCounter::new(8192);
|
||||
/// let text = "Hello, world!";
|
||||
/// let tokens = counter.count(text);
|
||||
/// assert!(tokens > 0);
|
||||
///
|
||||
/// let messages = vec![
|
||||
/// ChatMessage::user("What is the weather?"),
|
||||
/// ChatMessage::assistant("I don't have access to weather data."),
|
||||
/// ];
|
||||
/// let total = counter.count_messages(&messages);
|
||||
/// assert!(total > 0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SimpleTokenCounter {
|
||||
max_context: usize,
|
||||
}
|
||||
|
||||
impl SimpleTokenCounter {
|
||||
/// Create a new simple token counter
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `max_context` - Maximum context window size for the model
|
||||
pub fn new(max_context: usize) -> Self {
|
||||
Self { max_context }
|
||||
}
|
||||
|
||||
/// Create a token counter with a default 8192 token context
|
||||
pub fn default_8k() -> Self {
|
||||
Self::new(8192)
|
||||
}
|
||||
|
||||
/// Create a token counter with a 32k token context
|
||||
pub fn with_32k() -> Self {
|
||||
Self::new(32768)
|
||||
}
|
||||
|
||||
/// Create a token counter with a 128k token context
|
||||
pub fn with_128k() -> Self {
|
||||
Self::new(131072)
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenCounter for SimpleTokenCounter {
|
||||
fn count(&self, text: &str) -> usize {
|
||||
// Estimate: approximately 4 characters per token for English
|
||||
// Add 3 before dividing to round up
|
||||
text.len().div_ceil(4)
|
||||
}
|
||||
|
||||
fn count_messages(&self, messages: &[ChatMessage]) -> usize {
|
||||
let mut total = 0;
|
||||
|
||||
// Base overhead for message formatting (estimated)
|
||||
// Each message has role, delimiters, etc.
|
||||
const MESSAGE_OVERHEAD: usize = 4;
|
||||
|
||||
for msg in messages {
|
||||
// Count role
|
||||
total += MESSAGE_OVERHEAD;
|
||||
|
||||
// Count content
|
||||
if let Some(content) = &msg.content {
|
||||
total += self.count(content);
|
||||
}
|
||||
|
||||
// Count tool calls (more expensive due to JSON structure)
|
||||
if let Some(tool_calls) = &msg.tool_calls {
|
||||
for tc in tool_calls {
|
||||
// ID overhead
|
||||
total += self.count(&tc.id);
|
||||
// Function name
|
||||
total += self.count(&tc.function.name);
|
||||
// Arguments (JSON serialized, add 20% overhead for JSON structure)
|
||||
let args_str = tc.function.arguments.to_string();
|
||||
total += (self.count(&args_str) * 12) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Count tool call id for tool result messages
|
||||
if let Some(tool_call_id) = &msg.tool_call_id {
|
||||
total += self.count(tool_call_id);
|
||||
}
|
||||
|
||||
// Count tool name for tool result messages
|
||||
if let Some(name) = &msg.name {
|
||||
total += self.count(name);
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
fn max_context(&self) -> usize {
|
||||
self.max_context
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ClaudeTokenCounter
|
||||
// ============================================================================
|
||||
|
||||
/// Token counter optimized for Anthropic Claude models
|
||||
///
|
||||
/// Claude models have specific tokenization characteristics and overhead.
|
||||
/// This counter adjusts the estimates accordingly.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use llm_core::tokens::{TokenCounter, ClaudeTokenCounter};
|
||||
/// use llm_core::ChatMessage;
|
||||
///
|
||||
/// let counter = ClaudeTokenCounter::new();
|
||||
/// let messages = vec![
|
||||
/// ChatMessage::system("You are a helpful assistant."),
|
||||
/// ChatMessage::user("Hello!"),
|
||||
/// ];
|
||||
/// let total = counter.count_messages(&messages);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClaudeTokenCounter {
|
||||
max_context: usize,
|
||||
}
|
||||
|
||||
impl ClaudeTokenCounter {
|
||||
/// Create a new Claude token counter with default 200k context
|
||||
///
|
||||
/// This is suitable for Claude 3.5 Sonnet, Claude 4 Sonnet, and Claude 4 Opus.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_context: 200_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Claude counter with a custom context window
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `max_context` - Maximum context window size
|
||||
pub fn with_context(max_context: usize) -> Self {
|
||||
Self { max_context }
|
||||
}
|
||||
|
||||
/// Create a counter for Claude 3 Haiku (200k context)
|
||||
pub fn haiku() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
/// Create a counter for Claude 3.5 Sonnet (200k context)
|
||||
pub fn sonnet() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
/// Create a counter for Claude 4 Opus (200k context)
|
||||
pub fn opus() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClaudeTokenCounter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenCounter for ClaudeTokenCounter {
|
||||
fn count(&self, text: &str) -> usize {
|
||||
// Claude's tokenization is similar to the 4 chars/token heuristic
|
||||
// but tends to be slightly more efficient with structured content
|
||||
text.len().div_ceil(4)
|
||||
}
|
||||
|
||||
fn count_messages(&self, messages: &[ChatMessage]) -> usize {
|
||||
let mut total = 0;
|
||||
|
||||
// Claude has specific message formatting overhead
|
||||
const MESSAGE_OVERHEAD: usize = 5;
|
||||
const SYSTEM_MESSAGE_OVERHEAD: usize = 3;
|
||||
|
||||
for msg in messages {
|
||||
// Different overhead for system vs other messages
|
||||
let overhead = if matches!(msg.role, crate::Role::System) {
|
||||
SYSTEM_MESSAGE_OVERHEAD
|
||||
} else {
|
||||
MESSAGE_OVERHEAD
|
||||
};
|
||||
|
||||
total += overhead;
|
||||
|
||||
// Count content
|
||||
if let Some(content) = &msg.content {
|
||||
total += self.count(content);
|
||||
}
|
||||
|
||||
// Count tool calls
|
||||
if let Some(tool_calls) = &msg.tool_calls {
|
||||
// Claude's tool call format has additional overhead
|
||||
const TOOL_CALL_OVERHEAD: usize = 10;
|
||||
|
||||
for tc in tool_calls {
|
||||
total += TOOL_CALL_OVERHEAD;
|
||||
total += self.count(&tc.id);
|
||||
total += self.count(&tc.function.name);
|
||||
|
||||
// Arguments with JSON structure overhead
|
||||
let args_str = tc.function.arguments.to_string();
|
||||
total += (self.count(&args_str) * 12) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Tool result overhead
|
||||
if msg.tool_call_id.is_some() {
|
||||
const TOOL_RESULT_OVERHEAD: usize = 8;
|
||||
total += TOOL_RESULT_OVERHEAD;
|
||||
|
||||
if let Some(tool_call_id) = &msg.tool_call_id {
|
||||
total += self.count(tool_call_id);
|
||||
}
|
||||
|
||||
if let Some(name) = &msg.name {
|
||||
total += self.count(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
fn max_context(&self) -> usize {
|
||||
self.max_context
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ContextWindow
|
||||
// ============================================================================
|
||||
|
||||
/// Manages context window tracking for a conversation
|
||||
///
|
||||
/// Helps monitor token usage and determine when context limits are approaching.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// use llm_core::tokens::{ContextWindow, TokenCounter, SimpleTokenCounter};
|
||||
/// use llm_core::ChatMessage;
|
||||
///
|
||||
/// let counter = SimpleTokenCounter::new(8192);
|
||||
/// let mut window = ContextWindow::new(counter.max_context());
|
||||
///
|
||||
/// let messages = vec![
|
||||
/// ChatMessage::user("Hello!"),
|
||||
/// ChatMessage::assistant("Hi there!"),
|
||||
/// ];
|
||||
///
|
||||
/// let tokens = counter.count_messages(&messages);
|
||||
/// window.add_tokens(tokens);
|
||||
///
|
||||
/// println!("Used: {} tokens", window.used());
|
||||
/// println!("Remaining: {} tokens", window.remaining());
|
||||
/// println!("Usage: {:.1}%", window.usage_percent() * 100.0);
|
||||
///
|
||||
/// if window.is_near_limit(0.8) {
|
||||
/// println!("Warning: Context is 80% full!");
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextWindow {
|
||||
/// Number of tokens currently used
|
||||
used: usize,
|
||||
/// Maximum number of tokens allowed
|
||||
max: usize,
|
||||
}
|
||||
|
||||
impl ContextWindow {
|
||||
/// Create a new context window tracker
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `max` - Maximum context window size in tokens
|
||||
pub fn new(max: usize) -> Self {
|
||||
Self { used: 0, max }
|
||||
}
|
||||
|
||||
/// Create a context window with initial usage
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `max` - Maximum context window size
|
||||
/// * `used` - Initial number of tokens used
|
||||
pub fn with_usage(max: usize, used: usize) -> Self {
|
||||
Self { used, max }
|
||||
}
|
||||
|
||||
/// Get the number of tokens currently used
|
||||
pub fn used(&self) -> usize {
|
||||
self.used
|
||||
}
|
||||
|
||||
/// Get the maximum number of tokens
|
||||
pub fn max(&self) -> usize {
|
||||
self.max
|
||||
}
|
||||
|
||||
/// Get the number of remaining tokens
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.max.saturating_sub(self.used)
|
||||
}
|
||||
|
||||
/// Get the usage as a percentage (0.0 to 1.0)
|
||||
///
|
||||
/// Returns the fraction of the context window that is currently used.
|
||||
pub fn usage_percent(&self) -> f32 {
|
||||
if self.max == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.used as f32 / self.max as f32
|
||||
}
|
||||
|
||||
/// Check if usage is near the limit
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `threshold` - Threshold as a fraction (0.0 to 1.0). For example,
|
||||
/// 0.8 means "is usage > 80%?"
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if the current usage exceeds the threshold percentage
|
||||
pub fn is_near_limit(&self, threshold: f32) -> bool {
|
||||
self.usage_percent() > threshold
|
||||
}
|
||||
|
||||
/// Add tokens to the usage count
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `tokens` - Number of tokens to add
|
||||
pub fn add_tokens(&mut self, tokens: usize) {
|
||||
self.used = self.used.saturating_add(tokens);
|
||||
}
|
||||
|
||||
/// Set the current usage
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `used` - Number of tokens currently used
|
||||
pub fn set_used(&mut self, used: usize) {
|
||||
self.used = used;
|
||||
}
|
||||
|
||||
/// Reset the usage counter to zero
|
||||
pub fn reset(&mut self) {
|
||||
self.used = 0;
|
||||
}
|
||||
|
||||
/// Check if there's enough room for additional tokens
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `tokens` - Number of tokens needed
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if adding these tokens would stay within the limit
|
||||
pub fn has_room_for(&self, tokens: usize) -> bool {
|
||||
self.used.saturating_add(tokens) <= self.max
|
||||
}
|
||||
|
||||
/// Get a visual progress bar representation
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `width` - Width of the progress bar in characters
|
||||
///
|
||||
/// # Returns
|
||||
/// A string with a simple text-based progress bar
|
||||
pub fn progress_bar(&self, width: usize) -> String {
|
||||
if width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let percent = self.usage_percent();
|
||||
let filled = ((percent * width as f32) as usize).min(width);
|
||||
let empty = width - filled;
|
||||
|
||||
format!(
|
||||
"[{}{}] {:.1}%",
|
||||
"=".repeat(filled),
|
||||
" ".repeat(empty),
|
||||
percent * 100.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ChatMessage, FunctionCall, ToolCall};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_simple_counter_basic() {
|
||||
let counter = SimpleTokenCounter::new(8192);
|
||||
|
||||
// Empty string
|
||||
assert_eq!(counter.count(""), 0);
|
||||
|
||||
// Short string (~4 chars/token)
|
||||
let text = "Hello, world!"; // 13 chars -> ~4 tokens
|
||||
let count = counter.count(text);
|
||||
assert!(count >= 3 && count <= 5);
|
||||
|
||||
// Longer text
|
||||
let text = "The quick brown fox jumps over the lazy dog"; // 44 chars -> ~11 tokens
|
||||
let count = counter.count(text);
|
||||
assert!(count >= 10 && count <= 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_counter_messages() {
|
||||
let counter = SimpleTokenCounter::new(8192);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::user("Hello!"),
|
||||
ChatMessage::assistant("Hi there! How can I help you today?"),
|
||||
];
|
||||
|
||||
let total = counter.count_messages(&messages);
|
||||
|
||||
// Should be more than just the text due to overhead
|
||||
let text_only = counter.count("Hello!") + counter.count("Hi there! How can I help you today?");
|
||||
assert!(total > text_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_counter_with_tool_calls() {
|
||||
let counter = SimpleTokenCounter::new(8192);
|
||||
|
||||
let tool_call = ToolCall {
|
||||
id: "call_123".to_string(),
|
||||
call_type: "function".to_string(),
|
||||
function: FunctionCall {
|
||||
name: "read_file".to_string(),
|
||||
arguments: json!({"path": "/etc/hosts"}),
|
||||
},
|
||||
};
|
||||
|
||||
let messages = vec![ChatMessage::assistant_tool_calls(vec![tool_call])];
|
||||
|
||||
let total = counter.count_messages(&messages);
|
||||
assert!(total > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_counter() {
|
||||
let counter = ClaudeTokenCounter::new();
|
||||
|
||||
assert_eq!(counter.max_context(), 200_000);
|
||||
|
||||
let text = "Hello, Claude!";
|
||||
let count = counter.count(text);
|
||||
assert!(count > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_counter_system_message() {
|
||||
let counter = ClaudeTokenCounter::new();
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant."),
|
||||
ChatMessage::user("Hello!"),
|
||||
];
|
||||
|
||||
let total = counter.count_messages(&messages);
|
||||
assert!(total > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window() {
|
||||
let mut window = ContextWindow::new(1000);
|
||||
|
||||
assert_eq!(window.used(), 0);
|
||||
assert_eq!(window.max(), 1000);
|
||||
assert_eq!(window.remaining(), 1000);
|
||||
assert_eq!(window.usage_percent(), 0.0);
|
||||
|
||||
window.add_tokens(200);
|
||||
assert_eq!(window.used(), 200);
|
||||
assert_eq!(window.remaining(), 800);
|
||||
assert_eq!(window.usage_percent(), 0.2);
|
||||
|
||||
window.add_tokens(600);
|
||||
assert_eq!(window.used(), 800);
|
||||
assert!(window.is_near_limit(0.7));
|
||||
assert!(!window.is_near_limit(0.9));
|
||||
|
||||
assert!(window.has_room_for(200));
|
||||
assert!(!window.has_room_for(300));
|
||||
|
||||
window.reset();
|
||||
assert_eq!(window.used(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window_progress_bar() {
|
||||
let mut window = ContextWindow::new(100);
|
||||
|
||||
window.add_tokens(50);
|
||||
let bar = window.progress_bar(10);
|
||||
assert!(bar.contains("====="));
|
||||
assert!(bar.contains("50.0%"));
|
||||
|
||||
window.add_tokens(40);
|
||||
let bar = window.progress_bar(10);
|
||||
assert!(bar.contains("========="));
|
||||
assert!(bar.contains("90.0%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window_saturation() {
|
||||
let mut window = ContextWindow::new(100);
|
||||
|
||||
// Adding more tokens than max should saturate, not overflow
|
||||
window.add_tokens(150);
|
||||
assert_eq!(window.used(), 150);
|
||||
assert_eq!(window.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_counter_constructors() {
|
||||
let counter1 = SimpleTokenCounter::default_8k();
|
||||
assert_eq!(counter1.max_context(), 8192);
|
||||
|
||||
let counter2 = SimpleTokenCounter::with_32k();
|
||||
assert_eq!(counter2.max_context(), 32768);
|
||||
|
||||
let counter3 = SimpleTokenCounter::with_128k();
|
||||
assert_eq!(counter3.max_context(), 131072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_counter_variants() {
|
||||
let haiku = ClaudeTokenCounter::haiku();
|
||||
assert_eq!(haiku.max_context(), 200_000);
|
||||
|
||||
let sonnet = ClaudeTokenCounter::sonnet();
|
||||
assert_eq!(sonnet.max_context(), 200_000);
|
||||
|
||||
let opus = ClaudeTokenCounter::opus();
|
||||
assert_eq!(opus.max_context(), 200_000);
|
||||
|
||||
let custom = ClaudeTokenCounter::with_context(100_000);
|
||||
assert_eq!(custom.max_context(), 100_000);
|
||||
}
|
||||
}
|
||||
22
crates/llm/ollama/.gitignore
vendored
Normal file
22
crates/llm/ollama/.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
/target
|
||||
### Rust template
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
### rust-analyzer template
|
||||
# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules)
|
||||
rust-project.json
|
||||
|
||||
|
||||
18
crates/llm/ollama/Cargo.toml
Normal file
18
crates/llm/ollama/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "llm-ollama"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
llm-core = { path = "../core" }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1.39", features = ["rt-multi-thread", "macros"] }
|
||||
futures = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "1"
|
||||
bytes = "1"
|
||||
tokio-stream = "0.1.17"
|
||||
async-trait = "0.1"
|
||||
14
crates/llm/ollama/README.md
Normal file
14
crates/llm/ollama/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Owlen Ollama Provider
|
||||
|
||||
Local LLM integration via Ollama for the Owlen AI agent.
|
||||
|
||||
## Overview
|
||||
This crate enables the Owlen agent to use local models running via Ollama. This is ideal for privacy-focused workflows or development without an internet connection.
|
||||
|
||||
## Features
|
||||
- **Local Execution:** No API keys required for basic local use.
|
||||
- **Llama 3 / Qwen Support:** Compatible with popular open-source models.
|
||||
- **Custom Model URLs:** Connect to Ollama instances running on non-standard ports or remote servers.
|
||||
|
||||
## Configuration
|
||||
Requires a running Ollama instance. The default connection URL is `http://localhost:11434`.
|
||||
358
crates/llm/ollama/src/client.rs
Normal file
358
crates/llm/ollama/src/client.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
use crate::types::{ChatMessage, ChatResponseChunk, Tool};
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use async_trait::async_trait;
|
||||
use llm_core::{
|
||||
LlmProvider, ProviderInfo, LlmError, ChatOptions, ChunkStream,
|
||||
ProviderStatus, AccountInfo, UsageStats, ModelInfo,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OllamaClient {
|
||||
http: Client,
|
||||
base_url: String, // e.g. "http://localhost:11434"
|
||||
api_key: Option<String>, // For Ollama Cloud authentication
|
||||
current_model: String, // Default model for this client
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OllamaOptions {
|
||||
pub model: String,
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum OllamaError {
|
||||
#[error("http: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("protocol: {0}")]
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
// Convert OllamaError to LlmError
|
||||
impl From<OllamaError> for LlmError {
|
||||
fn from(err: OllamaError) -> Self {
|
||||
match err {
|
||||
OllamaError::Http(e) => LlmError::Http(e.to_string()),
|
||||
OllamaError::Json(e) => LlmError::Json(e.to_string()),
|
||||
OllamaError::Protocol(msg) => LlmError::Provider(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OllamaClient {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: Client::new(),
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
api_key: None,
|
||||
current_model: "qwen3:8b".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
|
||||
self.api_key = Some(api_key.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_model(mut self, model: impl Into<String>) -> Self {
|
||||
self.current_model = model.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cloud() -> Self {
|
||||
// Same API, different base
|
||||
Self::new("https://ollama.com")
|
||||
}
|
||||
|
||||
pub async fn chat_stream_raw(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
opts: &OllamaOptions,
|
||||
tools: Option<&[Tool]>,
|
||||
) -> Result<impl Stream<Item = Result<ChatResponseChunk, OllamaError>>, OllamaError> {
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
model: &'a str,
|
||||
messages: &'a [ChatMessage],
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<&'a [Tool]>,
|
||||
}
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
let body = Body {model: &opts.model, messages, stream: true, tools};
|
||||
let mut req = self.http.post(url).json(&body);
|
||||
|
||||
// Add Authorization header if API key is present
|
||||
if let Some(ref key) = self.api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
let bytes_stream = resp.bytes_stream();
|
||||
|
||||
// NDJSON parser: split by '\n', parse each as JSON and stream the results
|
||||
let out = bytes_stream
|
||||
.map_err(OllamaError::Http)
|
||||
.map_ok(|bytes| {
|
||||
// Convert the chunk to a UTF‑8 string and own it
|
||||
let txt = String::from_utf8_lossy(&bytes).into_owned();
|
||||
// Parse each non‑empty line into a ChatResponseChunk
|
||||
let results: Vec<Result<ChatResponseChunk, OllamaError>> = txt
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
serde_json::from_str::<ChatResponseChunk>(trimmed)
|
||||
.map_err(OllamaError::Json),
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
futures::stream::iter(results)
|
||||
})
|
||||
.try_flatten(); // Stream<Item = Result<ChatResponseChunk, OllamaError>>
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LlmProvider Trait Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OllamaClient {
|
||||
fn name(&self) -> &str {
|
||||
"ollama"
|
||||
}
|
||||
|
||||
fn model(&self) -> &str {
|
||||
&self.current_model
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
messages: &[llm_core::ChatMessage],
|
||||
options: &ChatOptions,
|
||||
tools: Option<&[llm_core::Tool]>,
|
||||
) -> Result<ChunkStream, LlmError> {
|
||||
// Convert llm_core messages to Ollama messages
|
||||
let ollama_messages: Vec<ChatMessage> = messages.iter().map(|m| m.into()).collect();
|
||||
|
||||
// Convert llm_core tools to Ollama tools if present
|
||||
let ollama_tools: Option<Vec<Tool>> = tools.map(|tools| {
|
||||
tools.iter().map(|t| Tool {
|
||||
tool_type: t.tool_type.clone(),
|
||||
function: crate::types::ToolFunction {
|
||||
name: t.function.name.clone(),
|
||||
description: t.function.description.clone(),
|
||||
parameters: crate::types::ToolParameters {
|
||||
param_type: t.function.parameters.param_type.clone(),
|
||||
properties: t.function.parameters.properties.clone(),
|
||||
required: t.function.parameters.required.clone(),
|
||||
},
|
||||
},
|
||||
}).collect()
|
||||
});
|
||||
|
||||
let opts = OllamaOptions {
|
||||
model: options.model.clone(),
|
||||
stream: true,
|
||||
};
|
||||
|
||||
// Make the request and build the body inline to avoid lifetime issues
|
||||
#[derive(Serialize)]
|
||||
struct Body<'a> {
|
||||
model: &'a str,
|
||||
messages: &'a [ChatMessage],
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<&'a [Tool]>,
|
||||
}
|
||||
|
||||
let url = format!("{}/api/chat", self.base_url);
|
||||
let body = Body {
|
||||
model: &opts.model,
|
||||
messages: &ollama_messages,
|
||||
stream: true,
|
||||
tools: ollama_tools.as_deref(),
|
||||
};
|
||||
|
||||
let mut req = self.http.post(url).json(&body);
|
||||
|
||||
// Add Authorization header if API key is present
|
||||
if let Some(ref key) = self.api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let resp = req.send().await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
// Check response status
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let error_body = resp.text().await.unwrap_or_else(|_| "unknown error".to_string());
|
||||
return Err(LlmError::Api {
|
||||
message: format!("Ollama API error: {} - {}", status, error_body),
|
||||
code: Some(status.as_str().to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes_stream = resp.bytes_stream();
|
||||
|
||||
// NDJSON parser with buffering for partial lines across chunks
|
||||
// Uses scan to maintain state (incomplete line buffer) between chunks
|
||||
use std::sync::{Arc, Mutex};
|
||||
let buffer: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
|
||||
|
||||
let converted_stream = bytes_stream
|
||||
.map(move |result| {
|
||||
result.map_err(|e| LlmError::Http(e.to_string()))
|
||||
})
|
||||
.map_ok(move |bytes| {
|
||||
let buffer = Arc::clone(&buffer);
|
||||
// Convert the chunk to a UTF-8 string
|
||||
let txt = String::from_utf8_lossy(&bytes).into_owned();
|
||||
|
||||
// Get the buffered incomplete line from previous chunk
|
||||
let mut buf = buffer.lock().unwrap();
|
||||
let combined = std::mem::take(&mut *buf) + &txt;
|
||||
|
||||
// Split by newlines, keeping track of complete vs incomplete lines
|
||||
let mut results: Vec<Result<llm_core::StreamChunk, LlmError>> = Vec::new();
|
||||
let mut lines: Vec<&str> = combined.split('\n').collect();
|
||||
|
||||
// If the data doesn't end with newline, the last element is incomplete
|
||||
// Save it for the next chunk
|
||||
if !combined.ends_with('\n') && !lines.is_empty() {
|
||||
*buf = lines.pop().unwrap_or("").to_string();
|
||||
}
|
||||
|
||||
// Parse all complete lines
|
||||
for line in lines {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
results.push(
|
||||
serde_json::from_str::<ChatResponseChunk>(trimmed)
|
||||
.map(|chunk| llm_core::StreamChunk::from(chunk))
|
||||
.map_err(|e| LlmError::Json(format!("{}: {}", e, trimmed))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
futures::stream::iter(results)
|
||||
})
|
||||
.try_flatten();
|
||||
|
||||
Ok(Box::pin(converted_stream))
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ProviderInfo Trait Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct OllamaModelList {
|
||||
models: Vec<OllamaModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // Fields kept for API completeness
|
||||
struct OllamaModel {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
modified_at: Option<String>,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
#[serde(default)]
|
||||
digest: Option<String>,
|
||||
#[serde(default)]
|
||||
details: Option<OllamaModelDetails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[allow(dead_code)] // Fields kept for API completeness
|
||||
struct OllamaModelDetails {
|
||||
#[serde(default)]
|
||||
format: Option<String>,
|
||||
#[serde(default)]
|
||||
family: Option<String>,
|
||||
#[serde(default)]
|
||||
parameter_size: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderInfo for OllamaClient {
|
||||
async fn status(&self) -> Result<ProviderStatus, LlmError> {
|
||||
// Try to ping the Ollama server
|
||||
let url = format!("{}/api/tags", self.base_url);
|
||||
let reachable = self.http.get(&url).send().await.is_ok();
|
||||
|
||||
Ok(ProviderStatus {
|
||||
provider: "ollama".to_string(),
|
||||
authenticated: self.api_key.is_some(),
|
||||
account: None, // Ollama is local, no account info
|
||||
model: self.current_model.clone(),
|
||||
endpoint: self.base_url.clone(),
|
||||
reachable,
|
||||
message: if reachable {
|
||||
Some("Connected to Ollama".to_string())
|
||||
} else {
|
||||
Some("Cannot reach Ollama server".to_string())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn account_info(&self) -> Result<Option<AccountInfo>, LlmError> {
|
||||
// Ollama is a local service, no account info
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn usage_stats(&self) -> Result<Option<UsageStats>, LlmError> {
|
||||
// Ollama doesn't track usage statistics
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
|
||||
let url = format!("{}/api/tags", self.base_url);
|
||||
let mut req = self.http.get(&url);
|
||||
|
||||
// Add Authorization header if API key is present
|
||||
if let Some(ref key) = self.api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let resp = req.send().await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
let model_list: OllamaModelList = resp.json().await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
// Convert Ollama models to ModelInfo
|
||||
let models = model_list.models.into_iter().map(|m| {
|
||||
ModelInfo {
|
||||
id: m.name.clone(),
|
||||
display_name: Some(m.name.clone()),
|
||||
description: m.details.as_ref()
|
||||
.and_then(|d| d.family.as_ref())
|
||||
.map(|f| format!("{} model", f)),
|
||||
context_window: None, // Ollama doesn't provide this in list
|
||||
max_output_tokens: None,
|
||||
supports_tools: true, // Most Ollama models support tools
|
||||
supports_vision: false, // Would need to check model capabilities
|
||||
input_price_per_mtok: None, // Local models are free
|
||||
output_price_per_mtok: None,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
}
|
||||
13
crates/llm/ollama/src/lib.rs
Normal file
13
crates/llm/ollama/src/lib.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
|
||||
pub use client::{OllamaClient, OllamaOptions, OllamaError};
|
||||
pub use types::{ChatMessage, ChatResponseChunk, Tool, ToolCall, ToolFunction, ToolParameters, FunctionCall};
|
||||
|
||||
// Re-export llm-core traits and types for convenience
|
||||
pub use llm_core::{
|
||||
LlmProvider, ProviderInfo, LlmError,
|
||||
ChatOptions, StreamChunk, ToolCallDelta, Usage,
|
||||
ProviderStatus, AccountInfo, UsageStats, ModelInfo,
|
||||
Role,
|
||||
};
|
||||
130
crates/llm/ollama/src/types.rs
Normal file
130
crates/llm/ollama/src/types.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use llm_core::{StreamChunk, ToolCallDelta};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String, // "user" | "assistant" | "system" | "tool"
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ToolCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
pub call_type: Option<String>, // "function"
|
||||
pub function: FunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct FunctionCall {
|
||||
pub name: String,
|
||||
pub arguments: Value, // JSON object with arguments
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tool {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String, // "function"
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: ToolParameters,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolParameters {
|
||||
#[serde(rename = "type")]
|
||||
pub param_type: String, // "object"
|
||||
pub properties: Value,
|
||||
pub required: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChatResponseChunk {
|
||||
pub model: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub message: Option<ChunkMessage>,
|
||||
pub done: Option<bool>,
|
||||
pub total_duration: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ChunkMessage {
|
||||
pub role: Option<String>,
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Conversions to/from llm-core types
|
||||
// ============================================================================
|
||||
|
||||
/// Convert from llm_core::ChatMessage to Ollama's ChatMessage
|
||||
impl From<&llm_core::ChatMessage> for ChatMessage {
|
||||
fn from(msg: &llm_core::ChatMessage) -> Self {
|
||||
let role = msg.role.as_str().to_string();
|
||||
|
||||
// Convert tool_calls if present
|
||||
let tool_calls = msg.tool_calls.as_ref().map(|calls| {
|
||||
calls.iter().map(|tc| ToolCall {
|
||||
id: Some(tc.id.clone()),
|
||||
call_type: Some(tc.call_type.clone()),
|
||||
function: FunctionCall {
|
||||
name: tc.function.name.clone(),
|
||||
arguments: tc.function.arguments.clone(),
|
||||
},
|
||||
}).collect()
|
||||
});
|
||||
|
||||
ChatMessage {
|
||||
role,
|
||||
content: msg.content.clone(),
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert from Ollama's ChatResponseChunk to llm_core::StreamChunk
|
||||
impl From<ChatResponseChunk> for StreamChunk {
|
||||
fn from(chunk: ChatResponseChunk) -> Self {
|
||||
let done = chunk.done.unwrap_or(false);
|
||||
let content = chunk.message.as_ref().and_then(|m| m.content.clone());
|
||||
|
||||
// Convert tool calls to deltas
|
||||
let tool_calls = chunk.message.as_ref().and_then(|m| {
|
||||
m.tool_calls.as_ref().map(|calls| {
|
||||
calls.iter().enumerate().map(|(index, tc)| {
|
||||
// Serialize arguments back to JSON string for delta
|
||||
let arguments_delta = serde_json::to_string(&tc.function.arguments).ok();
|
||||
|
||||
ToolCallDelta {
|
||||
index,
|
||||
id: tc.id.clone(),
|
||||
function_name: Some(tc.function.name.clone()),
|
||||
arguments_delta,
|
||||
}
|
||||
}).collect()
|
||||
})
|
||||
});
|
||||
|
||||
// Ollama doesn't provide per-chunk usage stats, only in final chunk
|
||||
let usage = None;
|
||||
|
||||
StreamChunk {
|
||||
content,
|
||||
tool_calls,
|
||||
done,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
crates/llm/ollama/tests/ndjson.rs
Normal file
12
crates/llm/ollama/tests/ndjson.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use llm_ollama::{OllamaClient, OllamaOptions};
|
||||
|
||||
// This test stubs NDJSON by spinning a tiny local server is overkill for M0.
|
||||
// Instead, test the line parser indirectly by mocking reqwest is complex.
|
||||
// We'll smoke-test the client type compiles and leave end-to-end to cli tests.
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_compiles_smoke() {
|
||||
let _ = OllamaClient::new("http://localhost:11434");
|
||||
let _ = OllamaClient::with_cloud();
|
||||
let _ = OllamaOptions { model: "qwen2.5".into(), stream: true };
|
||||
}
|
||||
18
crates/llm/openai/Cargo.toml
Normal file
18
crates/llm/openai/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "llm-openai"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "OpenAI GPT API client for Owlen"
|
||||
|
||||
[dependencies]
|
||||
llm-core = { path = "../core" }
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["sync", "time", "io-util"] }
|
||||
tokio-stream = { version = "0.1", default-features = false, features = ["io-util"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "io"] }
|
||||
tracing = "0.1"
|
||||
14
crates/llm/openai/README.md
Normal file
14
crates/llm/openai/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Owlen OpenAI Provider
|
||||
|
||||
OpenAI GPT integration for the Owlen AI agent.
|
||||
|
||||
## Overview
|
||||
This crate provides the implementation of the `LlmProvider` trait for OpenAI's models (e.g., GPT-4o). It supports the standard OpenAI chat completion API, including tool calling and streaming.
|
||||
|
||||
## Features
|
||||
- **GPT-4o Support:** Reliable integration with flagship OpenAI models.
|
||||
- **Function Calling:** Full support for OpenAI's tool/function calling mechanism.
|
||||
- **Streaming:** Robust real-time response generation.
|
||||
|
||||
## Configuration
|
||||
Requires an `OPENAI_API_KEY` to be set in the environment or configuration.
|
||||
304
crates/llm/openai/src/auth.rs
Normal file
304
crates/llm/openai/src/auth.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
//! OpenAI OAuth Authentication
|
||||
//!
|
||||
//! Implements device code flow for authenticating with OpenAI without API keys.
|
||||
|
||||
use llm_core::{AuthMethod, DeviceAuthResult, DeviceCodeResponse, LlmError, OAuthProvider};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// OAuth client for OpenAI device flow
|
||||
pub struct OpenAIAuth {
|
||||
http: Client,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
// OpenAI OAuth endpoints
|
||||
const AUTH_BASE_URL: &str = "https://auth.openai.com";
|
||||
const DEVICE_CODE_ENDPOINT: &str = "/oauth/device/code";
|
||||
const TOKEN_ENDPOINT: &str = "/oauth/token";
|
||||
|
||||
// Default client ID for Owlen CLI
|
||||
const DEFAULT_CLIENT_ID: &str = "owlen-cli";
|
||||
|
||||
// User-Agent to avoid bot protection blocks
|
||||
const USER_AGENT: &str = concat!("owlen/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
impl OpenAIAuth {
|
||||
/// Create a new OAuth client with the default CLI client ID
|
||||
pub fn new() -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http,
|
||||
client_id: DEFAULT_CLIENT_ID.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom client ID
|
||||
pub fn with_client_id(client_id: impl Into<String>) -> Self {
|
||||
let http = Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
http,
|
||||
client_id: client_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if OAuth is available for OpenAI
|
||||
/// Currently, OpenAI doesn't provide public OAuth device code flow for third-party CLI tools.
|
||||
pub fn is_oauth_available() -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenAIAuth {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DeviceCodeRequest<'a> {
|
||||
client_id: &'a str,
|
||||
scope: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceCodeApiResponse {
|
||||
device_code: String,
|
||||
user_code: String,
|
||||
verification_uri: String,
|
||||
verification_uri_complete: Option<String>,
|
||||
expires_in: u64,
|
||||
interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct TokenRequest<'a> {
|
||||
client_id: &'a str,
|
||||
device_code: &'a str,
|
||||
grant_type: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenApiResponse {
|
||||
access_token: String,
|
||||
#[allow(dead_code)]
|
||||
token_type: String,
|
||||
expires_in: Option<u64>,
|
||||
refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenErrorResponse {
|
||||
error: String,
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OAuthProvider for OpenAIAuth {
|
||||
async fn start_device_auth(&self) -> Result<DeviceCodeResponse, LlmError> {
|
||||
let url = format!("{}{}", AUTH_BASE_URL, DEVICE_CODE_ENDPOINT);
|
||||
|
||||
let request = DeviceCodeRequest {
|
||||
client_id: &self.client_id,
|
||||
scope: "api.read api.write",
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(LlmError::Auth(format!(
|
||||
"Device code request failed ({}): {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
|
||||
let api_response: DeviceCodeApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
Ok(DeviceCodeResponse {
|
||||
device_code: api_response.device_code,
|
||||
user_code: api_response.user_code,
|
||||
verification_uri: api_response.verification_uri,
|
||||
verification_uri_complete: api_response.verification_uri_complete,
|
||||
expires_in: api_response.expires_in,
|
||||
interval: api_response.interval,
|
||||
})
|
||||
}
|
||||
|
||||
async fn poll_device_auth(&self, device_code: &str) -> Result<DeviceAuthResult, LlmError> {
|
||||
let url = format!("{}{}", AUTH_BASE_URL, TOKEN_ENDPOINT);
|
||||
|
||||
let request = TokenRequest {
|
||||
client_id: &self.client_id,
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let token_response: TokenApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
return Ok(DeviceAuthResult::Success {
|
||||
access_token: token_response.access_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
expires_in: token_response.expires_in,
|
||||
});
|
||||
}
|
||||
|
||||
// Parse error response
|
||||
let error_response: TokenErrorResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
match error_response.error.as_str() {
|
||||
"authorization_pending" => Ok(DeviceAuthResult::Pending),
|
||||
"slow_down" => Ok(DeviceAuthResult::Pending),
|
||||
"access_denied" => Ok(DeviceAuthResult::Denied),
|
||||
"expired_token" => Ok(DeviceAuthResult::Expired),
|
||||
_ => Err(LlmError::Auth(format!(
|
||||
"Token request failed: {} - {}",
|
||||
error_response.error,
|
||||
error_response.error_description.unwrap_or_default()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_token(&self, refresh_token: &str) -> Result<AuthMethod, LlmError> {
|
||||
let url = format!("{}{}", AUTH_BASE_URL, TOKEN_ENDPOINT);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
client_id: &'a str,
|
||||
refresh_token: &'a str,
|
||||
grant_type: &'a str,
|
||||
}
|
||||
|
||||
let request = RefreshRequest {
|
||||
client_id: &self.client_id,
|
||||
refresh_token,
|
||||
grant_type: "refresh_token",
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LlmError::Http(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(LlmError::Auth(format!("Token refresh failed: {}", text)));
|
||||
}
|
||||
|
||||
let token_response: TokenApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||
|
||||
let expires_at = token_response.expires_in.map(|secs| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() + secs)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
Ok(AuthMethod::OAuth {
|
||||
access_token: token_response.access_token,
|
||||
refresh_token: token_response.refresh_token,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to perform the full device auth flow with polling
|
||||
pub async fn perform_device_auth<F>(
|
||||
auth: &OpenAIAuth,
|
||||
on_code: F,
|
||||
) -> Result<AuthMethod, LlmError>
|
||||
where
|
||||
F: FnOnce(&DeviceCodeResponse),
|
||||
{
|
||||
// Start the device flow
|
||||
let device_code = auth.start_device_auth().await?;
|
||||
|
||||
// Let caller display the code to user
|
||||
on_code(&device_code);
|
||||
|
||||
// Poll for completion
|
||||
let poll_interval = std::time::Duration::from_secs(device_code.interval);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(device_code.expires_in);
|
||||
|
||||
loop {
|
||||
if std::time::Instant::now() > deadline {
|
||||
return Err(LlmError::Auth("Device code expired".to_string()));
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
match auth.poll_device_auth(&device_code.device_code).await? {
|
||||
DeviceAuthResult::Success {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
} => {
|
||||
let expires_at = expires_in.map(|secs| {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() + secs)
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
return Ok(AuthMethod::OAuth {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
DeviceAuthResult::Pending => continue,
|
||||
DeviceAuthResult::Denied => {
|
||||
return Err(LlmError::Auth("Authorization denied by user".to_string()));
|
||||
}
|
||||
DeviceAuthResult::Expired => {
|
||||
return Err(LlmError::Auth("Device code expired".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user