Compare commits
200 Commits
v0.1.10
...
4a07b97eab
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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"
|
|
||||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,13 +1,12 @@
|
|||||||
|
### Custom
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
### Rust template
|
### Rust template
|
||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
dev/
|
|
||||||
.agents/
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
# 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
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
@@ -19,17 +18,10 @@ Cargo.lock
|
|||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.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
|
### JetBrains template
|
||||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
# 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
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
.idea/
|
|
||||||
# User-specific stuff
|
# User-specific stuff
|
||||||
.idea/**/workspace.xml
|
.idea/**/workspace.xml
|
||||||
.idea/**/tasks.xml
|
.idea/**/tasks.xml
|
||||||
@@ -60,14 +52,15 @@ Cargo.lock
|
|||||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
# 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
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
# auto-import.
|
# auto-import.
|
||||||
# .idea/artifacts
|
.idea/artifacts
|
||||||
# .idea/compiler.xml
|
.idea/compiler.xml
|
||||||
# .idea/jarRepositories.xml
|
.idea/jarRepositories.xml
|
||||||
# .idea/modules.xml
|
.idea/modules.xml
|
||||||
# .idea/*.iml
|
.idea/*.iml
|
||||||
# .idea/modules
|
.idea/modules
|
||||||
# *.iml
|
*.iml
|
||||||
# *.ipr
|
*.ipr
|
||||||
|
.idea
|
||||||
|
|
||||||
# CMake
|
# CMake
|
||||||
cmake-build-*/
|
cmake-build-*/
|
||||||
@@ -104,3 +97,9 @@ fabric.properties
|
|||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
# Android studio 3.1+ serialized cache file
|
||||||
.idea/caches/build_file_checksums.ser
|
.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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
131
.woodpecker.yml
131
.woodpecker.yml
@@ -1,131 +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: build
|
|
||||||
image: *rust_image
|
|
||||||
commands:
|
|
||||||
# Install cross-compilation tools
|
|
||||||
- apt-get update
|
|
||||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu g++-aarch64-linux-gnu gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf mingw-w64 zip
|
|
||||||
|
|
||||||
# Verify cross-compilers are installed
|
|
||||||
- which aarch64-linux-gnu-gcc || echo "aarch64-linux-gnu-gcc not found!"
|
|
||||||
- which arm-linux-gnueabihf-gcc || echo "arm-linux-gnueabihf-gcc not found!"
|
|
||||||
- which x86_64-w64-mingw32-gcc || echo "x86_64-w64-mingw32-gcc not found!"
|
|
||||||
|
|
||||||
# Add rust target
|
|
||||||
- rustup target add ${TARGET}
|
|
||||||
|
|
||||||
# Set up cross-compilation environment variables and build
|
|
||||||
- |
|
|
||||||
case "${TARGET}" in
|
|
||||||
aarch64-unknown-linux-gnu)
|
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
|
||||||
export CC_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-gcc
|
|
||||||
export CXX_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-g++
|
|
||||||
export AR_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-ar
|
|
||||||
;;
|
|
||||||
aarch64-unknown-linux-musl)
|
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
|
||||||
export CC_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-gcc
|
|
||||||
export CXX_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-g++
|
|
||||||
export AR_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-ar
|
|
||||||
;;
|
|
||||||
armv7-unknown-linux-gnueabihf)
|
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
|
||||||
export CC_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
|
||||||
export CXX_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-g++
|
|
||||||
export AR_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-ar
|
|
||||||
;;
|
|
||||||
armv7-unknown-linux-musleabihf)
|
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
|
||||||
export CC_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
|
||||||
export CXX_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-g++
|
|
||||||
export AR_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-ar
|
|
||||||
;;
|
|
||||||
x86_64-pc-windows-gnu)
|
|
||||||
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=/usr/bin/x86_64-w64-mingw32-gcc
|
|
||||||
export CC_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-gcc
|
|
||||||
export CXX_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-g++
|
|
||||||
export AR_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-ar
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Build the project
|
|
||||||
cargo build --release --all-features --target ${TARGET}
|
|
||||||
|
|
||||||
- name: package
|
|
||||||
image: *rust_image
|
|
||||||
commands:
|
|
||||||
- apt-get update && apt-get install -y zip
|
|
||||||
- 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}"
|
|
||||||
83
Cargo.toml
83
Cargo.toml
@@ -1,64 +1,31 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
|
||||||
members = [
|
members = [
|
||||||
"crates/owlen-core",
|
"crates/app/cli",
|
||||||
"crates/owlen-tui",
|
"crates/app/ui",
|
||||||
"crates/owlen-cli",
|
"crates/core/agent",
|
||||||
"crates/owlen-ollama",
|
"crates/llm/core",
|
||||||
|
"crates/llm/anthropic",
|
||||||
|
"crates/llm/ollama",
|
||||||
|
"crates/llm/openai",
|
||||||
|
"crates/platform/config",
|
||||||
|
"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",
|
||||||
]
|
]
|
||||||
exclude = []
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.9"
|
edition = "2024"
|
||||||
edition = "2021"
|
|
||||||
authors = ["Owlibou"]
|
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
repository = "https://somegit.dev/Owlibou/owlen"
|
rust-version = "1.91"
|
||||||
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
|
|
||||||
|
|||||||
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/>.
|
|
||||||
|
|
||||||
50
PKGBUILD
50
PKGBUILD
@@ -1,50 +0,0 @@
|
|||||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
|
||||||
pkgname=owlen
|
|
||||||
pkgver=0.1.9
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Install built-in themes for reference
|
|
||||||
install -Dm644 themes/README.md "$pkgdir/usr/share/$pkgname/themes/README.md"
|
|
||||||
for theme in themes/*.toml; do
|
|
||||||
install -Dm644 "$theme" "$pkgdir/usr/share/$pkgname/themes/$(basename $theme)"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
386
README.md
386
README.md
@@ -1,386 +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.9) 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`)
|
|
||||||
- **Command Autocompletion** - Intelligent Tab completion and suggestions in command mode
|
|
||||||
- **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
|
|
||||||
- **Theming System** - 10 built-in themes plus custom theme support
|
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
### Installation from Source on Linux and macOS
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://somegit.dev/Owlibou/owlen.git
|
|
||||||
cd owlen
|
|
||||||
cargo install --path crates/owlen-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: Make sure `~/.cargo/bin` is in your PATH to run the installed binary:
|
|
||||||
```bash
|
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
- `Ctrl-A` / `Ctrl-E` - Jump to start/end of line
|
|
||||||
- `Ctrl-W` / `Ctrl-B` - Word movement
|
|
||||||
- `Ctrl-R` - Redo
|
|
||||||
- Paste events handled automatically
|
|
||||||
|
|
||||||
**Visual Mode**:
|
|
||||||
- `j/k/h/l` - Extend selection
|
|
||||||
- `w/b/e` - Word-based selection
|
|
||||||
- `y` - Yank (copy) selection
|
|
||||||
- `d` / `Delete` - Cut selection (Input panel only)
|
|
||||||
- `Esc` / `v` - Cancel selection
|
|
||||||
|
|
||||||
**Command Mode**:
|
|
||||||
- `Tab` - Autocomplete selected command suggestion
|
|
||||||
- `↑` / `↓` or `Ctrl-k` / `Ctrl-j` - Navigate command suggestions
|
|
||||||
- `: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
|
|
||||||
- `:theme <name>` - Switch theme (saved to config)
|
|
||||||
- `:themes` - Browse themes in interactive modal
|
|
||||||
- `:reload` - Reload configuration and themes
|
|
||||||
- *Commands show real-time suggestions as you type*
|
|
||||||
|
|
||||||
**Theme Browser** (accessed via `:themes`):
|
|
||||||
- `j` / `k` / `↑` / `↓` - Navigate themes
|
|
||||||
- `Enter` - Apply selected theme
|
|
||||||
- `g` / `G` - Jump to top/bottom
|
|
||||||
- `Esc` / `q` - Close browser
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
### Theming
|
|
||||||
|
|
||||||
OWLEN includes 10 built-in themes that are embedded in the binary. You can also create custom themes.
|
|
||||||
|
|
||||||
**Built-in themes:**
|
|
||||||
- `default_dark` (default) - High-contrast dark theme
|
|
||||||
- `default_light` - Clean light theme
|
|
||||||
- `gruvbox` - Retro warm color scheme
|
|
||||||
- `dracula` - Vibrant purple and cyan
|
|
||||||
- `solarized` - Precision colors for readability
|
|
||||||
- `midnight-ocean` - Deep blue oceanic theme
|
|
||||||
- `rose-pine` - Soho vibes with muted pastels
|
|
||||||
- `monokai` - Classic code editor theme
|
|
||||||
- `material-dark` - Google's Material Design dark variant
|
|
||||||
- `material-light` - Google's Material Design light variant
|
|
||||||
|
|
||||||
**Commands:**
|
|
||||||
- `:theme <name>` - Switch theme instantly (automatically saved to config)
|
|
||||||
- `:themes` - Browse and select themes in an interactive modal
|
|
||||||
- `:reload` - Reload configuration and themes
|
|
||||||
|
|
||||||
**Setting default theme:**
|
|
||||||
```toml
|
|
||||||
[ui]
|
|
||||||
theme = "gruvbox" # or any built-in/custom theme name
|
|
||||||
```
|
|
||||||
|
|
||||||
**Creating custom themes:**
|
|
||||||
|
|
||||||
Create a `.toml` file in `~/.config/owlen/themes/`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# ~/.config/owlen/themes/my-theme.toml
|
|
||||||
name = "my-theme"
|
|
||||||
text = "#ffffff"
|
|
||||||
background = "#000000"
|
|
||||||
focused_panel_border = "#ff00ff"
|
|
||||||
unfocused_panel_border = "#800080"
|
|
||||||
user_message_role = "#00ffff"
|
|
||||||
assistant_message_role = "#ffff00"
|
|
||||||
# ... see themes/README.md for full schema
|
|
||||||
```
|
|
||||||
|
|
||||||
**Colors** can be hex RGB (`#rrggbb`) or named colors (`red`, `blue`, `lightgreen`, etc.). See `themes/README.md` for the complete list of supported color names.
|
|
||||||
|
|
||||||
For reference theme files and detailed documentation, see the `themes/` directory in the repository or `/usr/share/owlen/themes/` after installation.
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
owlen/
|
|
||||||
├── crates/
|
|
||||||
│ ├── owlen-core/ # Core types, session management, theming, 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)
|
|
||||||
├── themes/ # Built-in theme definitions (embedded in binary)
|
|
||||||
├── 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
|
|
||||||
- [x] Command autocompletion with Tab completion
|
|
||||||
- [x] Session persistence (save/load conversations)
|
|
||||||
- [x] Theming system with 9 built-in themes and custom theme support
|
|
||||||
|
|
||||||
### In Progress
|
|
||||||
- [ ] 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.9 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
33
crates/app/cli/Cargo.toml
Normal file
33
crates/app/cli/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[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" }
|
||||||
|
tools-fs = { path = "../../tools/fs" }
|
||||||
|
tools-bash = { path = "../../tools/bash" }
|
||||||
|
tools-slash = { path = "../../tools/slash" }
|
||||||
|
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"
|
||||||
|
|
||||||
|
[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"
|
||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
873
crates/app/cli/src/main.rs
Normal file
873
crates/app/cli/src/main.rs
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
mod commands;
|
||||||
|
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use color_eyre::eyre::{Result, eyre};
|
||||||
|
use config_agent::load_settings;
|
||||||
|
use hooks::{HookEvent, HookManager, HookResult};
|
||||||
|
use llm_core::ChatOptions;
|
||||||
|
use llm_ollama::OllamaClient;
|
||||||
|
use permissions::{PermissionDecision, Tool};
|
||||||
|
use plugins::PluginManager;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub use commands::{BuiltinCommands, CommandResult};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||||
|
enum OutputFormat {
|
||||||
|
Text,
|
||||||
|
Json,
|
||||||
|
StreamJson,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SessionOutput {
|
||||||
|
session_id: String,
|
||||||
|
messages: Vec<serde_json::Value>,
|
||||||
|
stats: Stats,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
result: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tool: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Stats {
|
||||||
|
total_tokens: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
prompt_tokens: Option<u64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
completion_tokens: Option<u64>,
|
||||||
|
duration_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct StreamEvent {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
session_id: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
stats: Option<Stats>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application context shared across the session
|
||||||
|
pub struct AppContext {
|
||||||
|
pub plugin_manager: PluginManager,
|
||||||
|
pub config: config_agent::Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppContext {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let config = load_settings(None).unwrap_or_default();
|
||||||
|
|
||||||
|
let mut plugin_manager = PluginManager::new();
|
||||||
|
// Non-fatal: just log warnings, don't fail startup
|
||||||
|
if let Err(e) = plugin_manager.load_all() {
|
||||||
|
eprintln!("Warning: Failed to load some plugins: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
plugin_manager,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print loaded plugins and available commands
|
||||||
|
pub fn print_plugin_info(&self) {
|
||||||
|
let plugins = self.plugin_manager.plugins();
|
||||||
|
if !plugins.is_empty() {
|
||||||
|
println!("\nLoaded {} plugin(s):", plugins.len());
|
||||||
|
for plugin in plugins {
|
||||||
|
println!(" - {} v{}", plugin.manifest.name, plugin.manifest.version);
|
||||||
|
if let Some(desc) = &plugin.manifest.description {
|
||||||
|
println!(" {}", desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let commands = self.plugin_manager.all_commands();
|
||||||
|
if !commands.is_empty() {
|
||||||
|
println!("\nAvailable plugin commands:");
|
||||||
|
for (name, _path) in &commands {
|
||||||
|
println!(" /{}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_session_id() -> String {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_millis();
|
||||||
|
format!("session-{}", timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_tool_result(
|
||||||
|
format: OutputFormat,
|
||||||
|
tool: &str,
|
||||||
|
result: serde_json::Value,
|
||||||
|
session_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Text => {
|
||||||
|
// For text, just print the result as-is
|
||||||
|
if let Some(s) = result.as_str() {
|
||||||
|
println!("{}", s);
|
||||||
|
} else {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
let output = SessionOutput {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
messages: vec![],
|
||||||
|
stats: Stats {
|
||||||
|
total_tokens: 0,
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
duration_ms: 0,
|
||||||
|
},
|
||||||
|
result: Some(result),
|
||||||
|
tool: Some(tool.to_string()),
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&output)?);
|
||||||
|
}
|
||||||
|
OutputFormat::StreamJson => {
|
||||||
|
// For stream-json, emit session_start, result, and session_end
|
||||||
|
let session_start = StreamEvent {
|
||||||
|
event_type: "session_start".to_string(),
|
||||||
|
session_id: Some(session_id.to_string()),
|
||||||
|
content: None,
|
||||||
|
stats: None,
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&session_start)?);
|
||||||
|
|
||||||
|
let result_event = StreamEvent {
|
||||||
|
event_type: "tool_result".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
content: Some(serde_json::to_string(&result)?),
|
||||||
|
stats: None,
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&result_event)?);
|
||||||
|
|
||||||
|
let session_end = StreamEvent {
|
||||||
|
event_type: "session_end".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
content: None,
|
||||||
|
stats: Some(Stats {
|
||||||
|
total_tokens: 0,
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
duration_ms: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&session_end)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(clap::Subcommand, Debug)]
|
||||||
|
enum Cmd {
|
||||||
|
Read { path: String },
|
||||||
|
Glob { pattern: String },
|
||||||
|
Grep { root: String, pattern: String },
|
||||||
|
Write { path: String, content: String },
|
||||||
|
Edit { path: String, old_string: String, new_string: String },
|
||||||
|
Bash { command: String, #[arg(long)] timeout: Option<u64> },
|
||||||
|
Slash { command_name: String, args: Vec<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "code", version)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(long)]
|
||||||
|
ollama_url: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
api_key: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
print: bool,
|
||||||
|
/// Override the permission mode (plan, acceptEdits, code)
|
||||||
|
#[arg(long)]
|
||||||
|
mode: Option<String>,
|
||||||
|
/// Output format (text, json, stream-json)
|
||||||
|
#[arg(long, value_enum, default_value = "text")]
|
||||||
|
output_format: OutputFormat,
|
||||||
|
/// Disable TUI and use legacy text-based REPL
|
||||||
|
#[arg(long)]
|
||||||
|
no_tui: bool,
|
||||||
|
#[arg()]
|
||||||
|
prompt: Vec<String>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Initialize application context with plugins
|
||||||
|
let app_context = AppContext::new()?;
|
||||||
|
let mut settings = app_context.config.clone();
|
||||||
|
|
||||||
|
// Override mode if specified via CLI
|
||||||
|
if let Some(mode) = args.mode {
|
||||||
|
settings.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create permission manager from settings
|
||||||
|
let perms = settings.create_permission_manager();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate session ID
|
||||||
|
let session_id = generate_session_id();
|
||||||
|
let output_format = args.output_format;
|
||||||
|
|
||||||
|
if let Some(cmd) = args.cmd {
|
||||||
|
match cmd {
|
||||||
|
Cmd::Read { path } => {
|
||||||
|
// Check permission
|
||||||
|
match perms.check(Tool::Read, None) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "Read".to_string(),
|
||||||
|
args: serde_json::json!({"path": &path}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied Read operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = tools_fs::read_file(&path)?;
|
||||||
|
output_tool_result(output_format, "Read", serde_json::json!(s), &session_id)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Read operation requires approval. Use --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Read operation is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Glob { pattern } => {
|
||||||
|
// Check permission
|
||||||
|
match perms.check(Tool::Glob, None) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "Glob".to_string(),
|
||||||
|
args: serde_json::json!({"pattern": &pattern}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied Glob operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in tools_fs::glob_list(&pattern)? {
|
||||||
|
println!("{}", p);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Glob operation requires approval. Use --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Glob operation is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Grep { root, pattern } => {
|
||||||
|
// Check permission
|
||||||
|
match perms.check(Tool::Grep, None) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "Grep".to_string(),
|
||||||
|
args: serde_json::json!({"root": &root, "pattern": &pattern}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied Grep operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, line_number, text) in tools_fs::grep(&root, &pattern)? {
|
||||||
|
println!("{path}:{line_number}:{text}")
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Grep operation requires approval. Use --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Grep operation is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Write { path, content } => {
|
||||||
|
// Check permission
|
||||||
|
match perms.check(Tool::Write, None) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "Write".to_string(),
|
||||||
|
args: serde_json::json!({"path": &path, "content": &content}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied Write operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools_fs::write_file(&path, &content)?;
|
||||||
|
println!("File written: {}", path);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Write operation requires approval. Use --mode acceptEdits or --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Write operation is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Edit { path, old_string, new_string } => {
|
||||||
|
// Check permission
|
||||||
|
match perms.check(Tool::Edit, None) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "Edit".to_string(),
|
||||||
|
args: serde_json::json!({"path": &path, "old_string": &old_string, "new_string": &new_string}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied Edit operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools_fs::edit_file(&path, &old_string, &new_string)?;
|
||||||
|
println!("File edited: {}", path);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Edit operation requires approval. Use --mode acceptEdits or --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Edit operation is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Bash { command, timeout } => {
|
||||||
|
// Check permission with command context for pattern matching
|
||||||
|
match perms.check(Tool::Bash, Some(&command)) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "Bash".to_string(),
|
||||||
|
args: serde_json::json!({"command": &command, "timeout": timeout}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied Bash operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut session = tools_bash::BashSession::new().await?;
|
||||||
|
let output = session.execute(&command, timeout).await?;
|
||||||
|
|
||||||
|
// Print stdout
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
print!("{}", output.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print stderr to stderr
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
eprint!("{}", output.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.close().await?;
|
||||||
|
|
||||||
|
// Exit with same code as command
|
||||||
|
if !output.success {
|
||||||
|
std::process::exit(output.exit_code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Bash operation requires approval. Use --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Bash operation is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Slash { command_name, args } => {
|
||||||
|
// Check permission
|
||||||
|
match perms.check(Tool::SlashCommand, None) {
|
||||||
|
PermissionDecision::Allow => {
|
||||||
|
// Check PreToolUse hook
|
||||||
|
let event = HookEvent::PreToolUse {
|
||||||
|
tool: "SlashCommand".to_string(),
|
||||||
|
args: serde_json::json!({"command_name": &command_name, "args": &args}),
|
||||||
|
};
|
||||||
|
match hook_mgr.execute(&event, Some(5000)).await? {
|
||||||
|
HookResult::Deny => {
|
||||||
|
return Err(eyre!("Hook denied SlashCommand operation"));
|
||||||
|
}
|
||||||
|
HookResult::Allow => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for command file in .owlen/commands/ first
|
||||||
|
let local_command_path = format!(".owlen/commands/{}.md", command_name);
|
||||||
|
|
||||||
|
// Try local commands first, then plugin commands
|
||||||
|
let content = if let Ok(c) = tools_fs::read_file(&local_command_path) {
|
||||||
|
c
|
||||||
|
} else if let Some(plugin_path) = app_context.plugin_manager.all_commands().get(&command_name) {
|
||||||
|
// Found in plugins
|
||||||
|
tools_fs::read_file(&plugin_path.to_string_lossy())?
|
||||||
|
} else {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Slash command '{}' not found in .owlen/commands/ or plugins",
|
||||||
|
command_name
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse with arguments
|
||||||
|
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||||
|
let slash_cmd = tools_slash::parse_slash_command(&content, &args_refs)?;
|
||||||
|
|
||||||
|
// Resolve file references
|
||||||
|
let resolved_body = slash_cmd.resolve_file_refs()?;
|
||||||
|
|
||||||
|
// Print the resolved command body
|
||||||
|
println!("{}", resolved_body);
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
PermissionDecision::Ask => {
|
||||||
|
return Err(eyre!(
|
||||||
|
"Permission denied: Slash command requires approval. Use --mode code to allow."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
PermissionDecision::Deny => {
|
||||||
|
return Err(eyre!("Permission denied: Slash command is blocked."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = args.model.unwrap_or(settings.model.clone());
|
||||||
|
let api_key = args.api_key.or(settings.api_key.clone());
|
||||||
|
|
||||||
|
// Use Ollama Cloud when model has "-cloud" suffix AND API key is set
|
||||||
|
let use_cloud = model.ends_with("-cloud") && api_key.is_some();
|
||||||
|
let client = if use_cloud {
|
||||||
|
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
|
||||||
|
} else {
|
||||||
|
let base_url = args.ollama_url.unwrap_or(settings.ollama_url.clone());
|
||||||
|
let mut client = OllamaClient::new(base_url);
|
||||||
|
if let Some(key) = api_key {
|
||||||
|
client = client.with_api_key(key);
|
||||||
|
}
|
||||||
|
client
|
||||||
|
};
|
||||||
|
let opts = ChatOptions::new(model);
|
||||||
|
|
||||||
|
// Check if interactive mode (no prompt provided)
|
||||||
|
if args.prompt.is_empty() {
|
||||||
|
// Use TUI mode unless --no-tui flag is set or not a TTY
|
||||||
|
if !args.no_tui && atty::is(atty::Stream::Stdout) {
|
||||||
|
// Launch TUI
|
||||||
|
// Note: For now, TUI doesn't use plugin manager directly
|
||||||
|
// In the future, we'll integrate plugin commands into TUI
|
||||||
|
return ui::run(client, opts, perms, settings).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy text-based REPL
|
||||||
|
println!("🤖 Owlen Interactive Mode");
|
||||||
|
println!("Model: {}", opts.model);
|
||||||
|
println!("Mode: {:?}", settings.mode);
|
||||||
|
|
||||||
|
// Show loaded plugins
|
||||||
|
let plugins = app_context.plugin_manager.plugins();
|
||||||
|
if !plugins.is_empty() {
|
||||||
|
println!("Plugins: {} loaded", plugins.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Type your message or /help for commands. Press Ctrl+C to exit.\n");
|
||||||
|
|
||||||
|
use std::io::{stdin, BufRead};
|
||||||
|
let stdin = stdin();
|
||||||
|
let mut lines = stdin.lock().lines();
|
||||||
|
let mut stats = agent_core::SessionStats::new();
|
||||||
|
let mut history = agent_core::SessionHistory::new();
|
||||||
|
let mut checkpoint_mgr = agent_core::CheckpointManager::new(
|
||||||
|
std::path::PathBuf::from(".owlen/checkpoints")
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("> ");
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
|
||||||
|
if let Some(Ok(line)) = lines.next() {
|
||||||
|
let input = line.trim();
|
||||||
|
if input.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle slash commands
|
||||||
|
if input.starts_with('/') {
|
||||||
|
match input {
|
||||||
|
"/help" => {
|
||||||
|
println!("\n📖 Available Commands:");
|
||||||
|
println!(" /help - Show this help message");
|
||||||
|
println!(" /status - Show session status");
|
||||||
|
println!(" /permissions - Show permission settings");
|
||||||
|
println!(" /cost - Show token usage and timing");
|
||||||
|
println!(" /history - Show conversation history");
|
||||||
|
println!(" /checkpoint - Save current session state");
|
||||||
|
println!(" /checkpoints - List all saved checkpoints");
|
||||||
|
println!(" /rewind <id> - Restore session from checkpoint");
|
||||||
|
println!(" /clear - Clear conversation history");
|
||||||
|
println!(" /plugins - Show loaded plugins and commands");
|
||||||
|
println!(" /exit - Exit interactive mode");
|
||||||
|
|
||||||
|
// Show plugin commands if any are loaded
|
||||||
|
let plugin_commands = app_context.plugin_manager.all_commands();
|
||||||
|
if !plugin_commands.is_empty() {
|
||||||
|
println!("\n📦 Plugin Commands:");
|
||||||
|
for (name, _path) in &plugin_commands {
|
||||||
|
println!(" /{}", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/status" => {
|
||||||
|
println!("\n📊 Session Status:");
|
||||||
|
println!(" Model: {}", opts.model);
|
||||||
|
println!(" Mode: {:?}", settings.mode);
|
||||||
|
println!(" Messages: {}", stats.total_messages);
|
||||||
|
println!(" Tools: {} calls", stats.total_tool_calls);
|
||||||
|
let elapsed = stats.start_time.elapsed().unwrap_or_default();
|
||||||
|
println!(" Uptime: {}", agent_core::SessionStats::format_duration(elapsed));
|
||||||
|
}
|
||||||
|
"/permissions" => {
|
||||||
|
println!("\n🔒 Permission Settings:");
|
||||||
|
println!(" Mode: {:?}", perms.mode());
|
||||||
|
println!("\n Read-only tools: Read, Grep, Glob, NotebookRead");
|
||||||
|
match perms.mode() {
|
||||||
|
permissions::Mode::Plan => {
|
||||||
|
println!(" ✅ Allowed (plan mode)");
|
||||||
|
println!("\n Write tools: Write, Edit, NotebookEdit");
|
||||||
|
println!(" ❓ Ask permission");
|
||||||
|
println!("\n System tools: Bash");
|
||||||
|
println!(" ❓ Ask permission");
|
||||||
|
}
|
||||||
|
permissions::Mode::AcceptEdits => {
|
||||||
|
println!(" ✅ Allowed");
|
||||||
|
println!("\n Write tools: Write, Edit, NotebookEdit");
|
||||||
|
println!(" ✅ Allowed (acceptEdits mode)");
|
||||||
|
println!("\n System tools: Bash");
|
||||||
|
println!(" ❓ Ask permission");
|
||||||
|
}
|
||||||
|
permissions::Mode::Code => {
|
||||||
|
println!(" ✅ Allowed");
|
||||||
|
println!("\n Write tools: Write, Edit, NotebookEdit");
|
||||||
|
println!(" ✅ Allowed (code mode)");
|
||||||
|
println!("\n System tools: Bash");
|
||||||
|
println!(" ✅ Allowed (code mode)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/cost" => {
|
||||||
|
println!("\n💰 Token Usage & Timing:");
|
||||||
|
println!(" Est. Tokens: ~{}", stats.estimated_tokens);
|
||||||
|
println!(" Total Time: {}", agent_core::SessionStats::format_duration(stats.total_duration));
|
||||||
|
if stats.total_messages > 0 {
|
||||||
|
let avg_time = stats.total_duration / stats.total_messages as u32;
|
||||||
|
println!(" Avg/Message: {}", agent_core::SessionStats::format_duration(avg_time));
|
||||||
|
}
|
||||||
|
println!("\n Note: Ollama is free - no cost incurred!");
|
||||||
|
}
|
||||||
|
"/history" => {
|
||||||
|
println!("\n📜 Conversation History:");
|
||||||
|
if history.user_prompts.is_empty() {
|
||||||
|
println!(" (No messages yet)");
|
||||||
|
} else {
|
||||||
|
for (i, (user, assistant)) in history.user_prompts.iter()
|
||||||
|
.zip(history.assistant_responses.iter()).enumerate() {
|
||||||
|
println!("\n [{}] User: {}", i + 1, user);
|
||||||
|
println!(" Assistant: {}...",
|
||||||
|
assistant.chars().take(100).collect::<String>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !history.tool_calls.is_empty() {
|
||||||
|
println!("\n Tool Calls: {}", history.tool_calls.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/checkpoint" => {
|
||||||
|
let checkpoint_id = format!("checkpoint-{}",
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
);
|
||||||
|
match checkpoint_mgr.save_checkpoint(
|
||||||
|
checkpoint_id.clone(),
|
||||||
|
stats.clone(),
|
||||||
|
&history,
|
||||||
|
) {
|
||||||
|
Ok(checkpoint) => {
|
||||||
|
println!("\n💾 Checkpoint saved: {}", checkpoint_id);
|
||||||
|
if !checkpoint.file_diffs.is_empty() {
|
||||||
|
println!(" Files tracked: {}", checkpoint.file_diffs.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Failed to save checkpoint: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/checkpoints" => {
|
||||||
|
match checkpoint_mgr.list_checkpoints() {
|
||||||
|
Ok(checkpoints) => {
|
||||||
|
if checkpoints.is_empty() {
|
||||||
|
println!("\n📋 No checkpoints saved yet");
|
||||||
|
} else {
|
||||||
|
println!("\n📋 Saved Checkpoints:");
|
||||||
|
for (i, cp_id) in checkpoints.iter().enumerate() {
|
||||||
|
println!(" [{}] {}", i + 1, cp_id);
|
||||||
|
}
|
||||||
|
println!("\n Use /rewind <id> to restore");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Failed to list checkpoints: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/clear" => {
|
||||||
|
history.clear();
|
||||||
|
stats = agent_core::SessionStats::new();
|
||||||
|
println!("\n🗑️ Session history cleared!");
|
||||||
|
}
|
||||||
|
"/plugins" => {
|
||||||
|
let plugins = app_context.plugin_manager.plugins();
|
||||||
|
if plugins.is_empty() {
|
||||||
|
println!("\n📦 No plugins loaded");
|
||||||
|
println!(" Place plugins in:");
|
||||||
|
println!(" - ~/.config/owlen/plugins (user plugins)");
|
||||||
|
println!(" - .owlen/plugins (project plugins)");
|
||||||
|
} else {
|
||||||
|
println!("\n📦 Loaded Plugins:");
|
||||||
|
for plugin in plugins {
|
||||||
|
println!("\n {} v{}", plugin.manifest.name, plugin.manifest.version);
|
||||||
|
if let Some(desc) = &plugin.manifest.description {
|
||||||
|
println!(" {}", desc);
|
||||||
|
}
|
||||||
|
if let Some(author) = &plugin.manifest.author {
|
||||||
|
println!(" Author: {}", author);
|
||||||
|
}
|
||||||
|
|
||||||
|
let commands = plugin.all_command_names();
|
||||||
|
if !commands.is_empty() {
|
||||||
|
println!(" Commands: {}", commands.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
let agents = plugin.all_agent_names();
|
||||||
|
if !agents.is_empty() {
|
||||||
|
println!(" Agents: {}", agents.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
let skills = plugin.all_skill_names();
|
||||||
|
if !skills.is_empty() {
|
||||||
|
println!(" Skills: {}", skills.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/exit" => {
|
||||||
|
println!("\n👋 Goodbye!");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cmd if cmd.starts_with("/rewind ") => {
|
||||||
|
let checkpoint_id = cmd.strip_prefix("/rewind ").unwrap().trim();
|
||||||
|
match checkpoint_mgr.rewind_to(checkpoint_id) {
|
||||||
|
Ok(restored_files) => {
|
||||||
|
println!("\n⏪ Rewound to checkpoint: {}", checkpoint_id);
|
||||||
|
if !restored_files.is_empty() {
|
||||||
|
println!(" Restored files:");
|
||||||
|
for file in restored_files {
|
||||||
|
println!(" - {}", file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Load the checkpoint to restore history and stats
|
||||||
|
if let Ok(checkpoint) = checkpoint_mgr.load_checkpoint(checkpoint_id) {
|
||||||
|
stats = checkpoint.stats;
|
||||||
|
history.user_prompts = checkpoint.user_prompts;
|
||||||
|
history.assistant_responses = checkpoint.assistant_responses;
|
||||||
|
history.tool_calls = checkpoint.tool_calls;
|
||||||
|
println!(" Session state restored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Failed to rewind: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!("\n❌ Unknown command: {}", input);
|
||||||
|
println!(" Type /help for available commands");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message - run through agent loop
|
||||||
|
history.add_user_message(input.to_string());
|
||||||
|
let start = SystemTime::now();
|
||||||
|
|
||||||
|
let ctx = agent_core::ToolContext::new();
|
||||||
|
match agent_core::run_agent_loop(&client, input, &opts, &perms, &ctx).await {
|
||||||
|
Ok(response) => {
|
||||||
|
println!("\n{}", response);
|
||||||
|
history.add_assistant_message(response.clone());
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
let duration = start.elapsed().unwrap_or_default();
|
||||||
|
let tokens = (input.len() + response.len()) / 4; // Rough estimate
|
||||||
|
stats.record_message(tokens, duration);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-interactive mode - process single prompt
|
||||||
|
let prompt = args.prompt.join(" ");
|
||||||
|
let start_time = SystemTime::now();
|
||||||
|
|
||||||
|
// Handle different output formats
|
||||||
|
let ctx = agent_core::ToolContext::new();
|
||||||
|
match output_format {
|
||||||
|
OutputFormat::Text => {
|
||||||
|
// Text format: Use agent orchestrator with tool calling
|
||||||
|
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||||
|
println!("{}", response);
|
||||||
|
}
|
||||||
|
OutputFormat::Json => {
|
||||||
|
// JSON format: Use agent loop and output as JSON
|
||||||
|
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||||
|
|
||||||
|
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
|
||||||
|
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
|
||||||
|
|
||||||
|
let output = SessionOutput {
|
||||||
|
session_id,
|
||||||
|
messages: vec![
|
||||||
|
serde_json::json!({"role": "user", "content": prompt}),
|
||||||
|
serde_json::json!({"role": "assistant", "content": response}),
|
||||||
|
],
|
||||||
|
stats: Stats {
|
||||||
|
total_tokens: estimated_tokens,
|
||||||
|
prompt_tokens: Some((prompt.len() / 4) as u64),
|
||||||
|
completion_tokens: Some((response.len() / 4) as u64),
|
||||||
|
duration_ms,
|
||||||
|
},
|
||||||
|
result: None,
|
||||||
|
tool: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output)?);
|
||||||
|
}
|
||||||
|
OutputFormat::StreamJson => {
|
||||||
|
// Stream-JSON format: emit session_start, response, and session_end
|
||||||
|
let session_start = StreamEvent {
|
||||||
|
event_type: "session_start".to_string(),
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
content: None,
|
||||||
|
stats: None,
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&session_start)?);
|
||||||
|
|
||||||
|
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||||
|
|
||||||
|
let chunk_event = StreamEvent {
|
||||||
|
event_type: "chunk".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
content: Some(response.clone()),
|
||||||
|
stats: None,
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&chunk_event)?);
|
||||||
|
|
||||||
|
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
|
||||||
|
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
|
||||||
|
|
||||||
|
let session_end = StreamEvent {
|
||||||
|
event_type: "session_end".to_string(),
|
||||||
|
session_id: None,
|
||||||
|
content: None,
|
||||||
|
stats: Some(Stats {
|
||||||
|
total_tokens: estimated_tokens,
|
||||||
|
prompt_tokens: Some((prompt.len() / 4) as u64),
|
||||||
|
completion_tokens: Some((response.len() / 4) as u64),
|
||||||
|
duration_ms,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string(&session_end)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
27
crates/app/ui/Cargo.toml
Normal file
27
crates/app/ui/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[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" }
|
||||||
|
permissions = { path = "../../platform/permissions" }
|
||||||
|
llm-core = { path = "../../llm/core" }
|
||||||
|
llm-ollama = { path = "../../llm/ollama" }
|
||||||
|
config-agent = { path = "../../platform/config" }
|
||||||
|
tools-todo = { path = "../../tools/todo" }
|
||||||
1101
crates/app/ui/src/app.rs
Normal file
1101
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("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
468
crates/app/ui/src/components/chat_panel.rs
Normal file
468
crates/app/ui/src/components/chat_panel.rs
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
//! 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
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);
|
||||||
|
wrapped.len() + 1 // +1 for spacing
|
||||||
|
}
|
||||||
|
ChatMessage::Assistant(content) => {
|
||||||
|
let wrapped = textwrap::wrap(content, wrap_width);
|
||||||
|
wrapped.len() + 1
|
||||||
|
}
|
||||||
|
ChatMessage::ToolCall { .. } => 2,
|
||||||
|
ChatMessage::ToolResult { .. } => 2,
|
||||||
|
ChatMessage::System(_) => 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: just dim text, no prefix
|
||||||
|
text_lines.push(Line::from(vec![
|
||||||
|
Span::styled(" ", Style::default()),
|
||||||
|
Span::styled(content.to_string(), self.theme.system_message),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/app/ui/src/components/mod.rs
Normal file
19
crates/app/ui/src/components/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//! TUI components for the borderless multi-provider design
|
||||||
|
|
||||||
|
mod autocomplete;
|
||||||
|
mod chat_panel;
|
||||||
|
mod command_help;
|
||||||
|
mod input_box;
|
||||||
|
mod permission_popup;
|
||||||
|
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 permission_popup::{PermissionOption, PermissionPopup};
|
||||||
|
pub use provider_tabs::ProviderTabs;
|
||||||
|
pub use status_bar::{AppState, StatusBar};
|
||||||
|
pub use todo_panel::TodoPanel;
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
crates/app/ui/src/components/status_bar.rs
Normal file
188
crates/app/ui/src/components/status_bar.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
//! 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,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
crates/app/ui/src/events.rs
Normal file
53
crates/app/ui/src/events.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
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,
|
||||||
|
/// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
218
crates/app/ui/src/layout.rs
Normal file
218
crates/app/ui/src/layout.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//! 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
|
||||||
|
///
|
||||||
|
/// Simplified layout without provider tabs:
|
||||||
|
/// - Header (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), // 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), // 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(), // Not used in simplified layout
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/app/ui/src/lib.rs
Normal file
30
crates/app/ui/src/lib.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod completions;
|
||||||
|
pub mod components;
|
||||||
|
pub mod events;
|
||||||
|
pub mod formatting;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod output;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
use color_eyre::eyre::Result;
|
||||||
|
|
||||||
|
/// Run the TUI application
|
||||||
|
pub async fn run(
|
||||||
|
client: llm_ollama::OllamaClient,
|
||||||
|
opts: llm_core::ChatOptions,
|
||||||
|
perms: permissions::PermissionManager,
|
||||||
|
settings: config_agent::Settings,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut app = TuiApp::new(client, opts, perms, settings)?;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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]");
|
||||||
|
}
|
||||||
|
}
|
||||||
29
crates/core/agent/Cargo.toml
Normal file
29
crates/core/agent/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[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" }
|
||||||
|
|
||||||
|
[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(())
|
||||||
|
}
|
||||||
92
crates/core/agent/examples/streaming_agent.rs
Normal file
92
crates/core/agent/examples/streaming_agent.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/// 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
|
||||||
|
|
||||||
|
use agent_core::{create_event_channel, run_agent_loop_streaming, AgentEvent, ToolContext};
|
||||||
|
use llm_core::ChatOptions;
|
||||||
|
use permissions::{Mode, PermissionManager};
|
||||||
|
|
||||||
|
#[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(())
|
||||||
|
}
|
||||||
218
crates/core/agent/src/compact.rs
Normal file
218
crates/core/agent/src/compact.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//! 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 context size
|
||||||
|
pub struct TokenCounter {
|
||||||
|
chars_per_token: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TokenCounter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenCounter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
// Rough estimate: ~4 chars per token for English text
|
||||||
|
Self { chars_per_token: 4.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate token count for a 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate total token count for all messages
|
||||||
|
pub fn count_messages(&self, messages: &[ChatMessage]) -> usize {
|
||||||
|
messages.iter().map(|m| self.count_message(m)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if context should be compacted
|
||||||
|
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 summarizes conversation history
|
||||||
|
pub struct Compactor {
|
||||||
|
token_counter: TokenCounter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Compactor {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compactor {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
token_counter: TokenCounter::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if messages need compaction
|
||||||
|
pub fn needs_compaction(&self, messages: &[ChatMessage]) -> bool {
|
||||||
|
self.token_counter.should_compact(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact messages by summarizing earlier conversation
|
||||||
|
///
|
||||||
|
/// Returns compacted messages with:
|
||||||
|
/// - A system message containing the summary of earlier context
|
||||||
|
/// - The most recent N messages preserved in full
|
||||||
|
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 {
|
||||||
|
if let Some(content) = &chunk.content {
|
||||||
|
summary.push_str(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summary.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get token counter for external use
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
557
crates/core/agent/src/git.rs
Normal file
557
crates/core/agent/src/git.rs
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// Status of a file in the git working tree
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum GitFileStatus {
|
||||||
|
/// File has been modified
|
||||||
|
Modified { path: String },
|
||||||
|
/// File has been added (staged)
|
||||||
|
Added { path: String },
|
||||||
|
/// File has been deleted
|
||||||
|
Deleted { path: String },
|
||||||
|
/// File has been renamed
|
||||||
|
Renamed { from: String, to: String },
|
||||||
|
/// File is untracked
|
||||||
|
Untracked { path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitFileStatus {
|
||||||
|
/// Get the primary path associated with this status
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete state of a git repository
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GitState {
|
||||||
|
/// Whether the current directory is in a git repository
|
||||||
|
pub is_git_repo: bool,
|
||||||
|
/// Current branch name (None if not in a repo or detached HEAD)
|
||||||
|
pub current_branch: Option<String>,
|
||||||
|
/// Main branch name (main/master, None if not detected)
|
||||||
|
pub main_branch: Option<String>,
|
||||||
|
/// Status of files in the working tree
|
||||||
|
pub status: Vec<GitFileStatus>,
|
||||||
|
/// Whether there are any uncommitted changes
|
||||||
|
pub has_uncommitted_changes: bool,
|
||||||
|
/// Remote URL for the repository (None if no remote configured)
|
||||||
|
pub remote_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitState {
|
||||||
|
/// Create a default GitState for non-git directories
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the current git repository state
|
||||||
|
///
|
||||||
|
/// This function runs various git commands to gather information about the repository.
|
||||||
|
/// If git is not available or the directory is not a git repo, returns a default state.
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a git command is safe (read-only)
|
||||||
|
///
|
||||||
|
/// Safe commands include:
|
||||||
|
/// - status, log, show, diff, branch (without -D)
|
||||||
|
/// - remote (without add/remove)
|
||||||
|
/// - config --get
|
||||||
|
/// - rev-parse, ls-files, ls-tree
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a git command is destructive
|
||||||
|
///
|
||||||
|
/// Returns (is_destructive, warning_message) tuple.
|
||||||
|
/// Destructive commands include:
|
||||||
|
/// - push --force, reset --hard, clean -fd
|
||||||
|
/// - rebase, amend, filter-branch
|
||||||
|
/// - branch -D, tag -d
|
||||||
|
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, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format git state for human-readable display
|
||||||
|
///
|
||||||
|
/// Example output:
|
||||||
|
/// ```text
|
||||||
|
/// Git Repository: yes
|
||||||
|
/// Current branch: feature-branch
|
||||||
|
/// Main branch: main
|
||||||
|
/// Status: 3 modified, 1 untracked
|
||||||
|
/// Remote: https://github.com/user/repo.git
|
||||||
|
/// ```
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
1130
crates/core/agent/src/lib.rs
Normal file
1130
crates/core/agent/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
295
crates/core/agent/src/session.rs
Normal file
295
crates/core/agent/src/session.rs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
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};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionStats {
|
||||||
|
pub start_time: SystemTime,
|
||||||
|
pub total_messages: usize,
|
||||||
|
pub total_tool_calls: usize,
|
||||||
|
pub total_duration: Duration,
|
||||||
|
pub estimated_tokens: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStats {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
start_time: SystemTime::now(),
|
||||||
|
total_messages: 0,
|
||||||
|
total_tool_calls: 0,
|
||||||
|
total_duration: Duration::ZERO,
|
||||||
|
estimated_tokens: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_message(&mut self, tokens: usize, duration: Duration) {
|
||||||
|
self.total_messages += 1;
|
||||||
|
self.estimated_tokens += tokens;
|
||||||
|
self.total_duration += duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_tool_call(&mut self) {
|
||||||
|
self.total_tool_calls += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionHistory {
|
||||||
|
pub user_prompts: Vec<String>,
|
||||||
|
pub assistant_responses: Vec<String>,
|
||||||
|
pub tool_calls: Vec<ToolCallRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolCallRecord {
|
||||||
|
pub tool_name: String,
|
||||||
|
pub arguments: String,
|
||||||
|
pub result: String,
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionHistory {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
user_prompts: Vec::new(),
|
||||||
|
assistant_responses: Vec::new(),
|
||||||
|
tool_calls: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_user_message(&mut self, message: String) {
|
||||||
|
self.user_prompts.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_assistant_message(&mut self, message: String) {
|
||||||
|
self.assistant_responses.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tool_call(&mut self, record: ToolCallRecord) {
|
||||||
|
self.tool_calls.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub before: String,
|
||||||
|
pub after: String,
|
||||||
|
pub timestamp: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileDiff {
|
||||||
|
/// Create a new file diff
|
||||||
|
pub fn new(path: PathBuf, before: String, after: String) -> Self {
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
timestamp: SystemTime::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A checkpoint captures the state of a session at a point in time
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Checkpoint {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: SystemTime,
|
||||||
|
pub stats: SessionStats,
|
||||||
|
pub user_prompts: Vec<String>,
|
||||||
|
pub assistant_responses: Vec<String>,
|
||||||
|
pub tool_calls: Vec<ToolCallRecord>,
|
||||||
|
pub file_diffs: Vec<FileDiff>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Checkpoint {
|
||||||
|
/// Create a new checkpoint from 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save checkpoint to 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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load checkpoint from disk
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all available checkpoints in a 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") {
|
||||||
|
if 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session checkpoint manager
|
||||||
|
pub struct CheckpointManager {
|
||||||
|
checkpoint_dir: PathBuf,
|
||||||
|
file_snapshots: HashMap<PathBuf, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CheckpointManager {
|
||||||
|
/// Create a new checkpoint manager
|
||||||
|
pub fn new(checkpoint_dir: PathBuf) -> Self {
|
||||||
|
Self {
|
||||||
|
checkpoint_dir,
|
||||||
|
file_snapshots: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot a file's current content before modification
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a file diff after modification
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all file diffs since 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear file snapshots
|
||||||
|
pub fn clear_snapshots(&mut self) {
|
||||||
|
self.file_snapshots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save a 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a checkpoint
|
||||||
|
pub fn load_checkpoint(&self, id: &str) -> Result<Checkpoint> {
|
||||||
|
Checkpoint::load(&self.checkpoint_dir, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all checkpoints
|
||||||
|
pub fn list_checkpoints(&self) -> Result<Vec<String>> {
|
||||||
|
Checkpoint::list(&self.checkpoint_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewind to a checkpoint by restoring file contents
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
266
crates/core/agent/src/system_prompt.rs
Normal file
266
crates/core/agent/src/system_prompt.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
//! System Prompt Management
|
||||||
|
//!
|
||||||
|
//! Composes system prompts from multiple sources for agent sessions.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Builder for composing system prompts
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SystemPromptBuilder {
|
||||||
|
sections: Vec<PromptSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PromptSection {
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
priority: i32, // Lower = earlier in prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SystemPromptBuilder {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the base agent prompt
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and add project instructions from CLAUDE.md or .owlen.md
|
||||||
|
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() {
|
||||||
|
if 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() {
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add skill content
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add hook-injected content (from SessionStart hooks)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add custom section
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the final system prompt
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if any content has been added
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.sections.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default base prompt for 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"#
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate tool instructions based on available tools
|
||||||
|
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);
|
||||||
|
}
|
||||||
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
114
crates/core/agent/tests/tool_context.rs
Normal file
114
crates/core/agent/tests/tool_context.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// Test that ToolContext properly wires up the placeholder tools
|
||||||
|
use agent_core::{ToolContext, execute_tool};
|
||||||
|
use permissions::{Mode, PermissionManager};
|
||||||
|
use tools_todo::{TodoList, TodoStatus};
|
||||||
|
use tools_bash::ShellManager;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[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"));
|
||||||
|
}
|
||||||
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"] }
|
||||||
272
crates/integration/mcp-client/src/lib.rs
Normal file
272
crates/integration/mcp-client/src/lib.rs
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
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)]
|
||||||
|
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"] }
|
||||||
285
crates/llm/anthropic/src/auth.rs
Normal file
285
crates/llm/anthropic/src/auth.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! 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 (these would be the real endpoints)
|
||||||
|
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";
|
||||||
|
|
||||||
|
impl AnthropicAuth {
|
||||||
|
/// Create a new OAuth client with the default CLI client ID
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
http: Client::new(),
|
||||||
|
client_id: DEFAULT_CLIENT_ID.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with a custom client ID
|
||||||
|
pub fn with_client_id(client_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
http: Client::new(),
|
||||||
|
client_id: client_id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"] }
|
||||||
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 ===");
|
||||||
|
}
|
||||||
796
crates/llm/core/src/lib.rs
Normal file
796
crates/llm/core/src/lib.rs
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
//! 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ProviderType {
|
||||||
|
Ollama,
|
||||||
|
Anthropic,
|
||||||
|
OpenAI,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderType {
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"ollama" => Some(Self::Ollama),
|
||||||
|
"anthropic" | "claude" => Some(Self::Anthropic),
|
||||||
|
"openai" | "gpt" => Some(Self::OpenAI),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() + 3) / 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() + 3) / 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"
|
||||||
329
crates/llm/ollama/src/client.rs
Normal file
329
crates/llm/ollama/src/client.rs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
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()))?;
|
||||||
|
let bytes_stream = resp.bytes_stream();
|
||||||
|
|
||||||
|
// NDJSON parser: split by '\n', parse each as JSON and stream the results
|
||||||
|
let converted_stream = bytes_stream
|
||||||
|
.map(|result| {
|
||||||
|
result.map_err(|e| LlmError::Http(e.to_string()))
|
||||||
|
})
|
||||||
|
.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<llm_core::StreamChunk, LlmError>> = txt
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
serde_json::from_str::<ChatResponseChunk>(trimmed)
|
||||||
|
.map(|chunk| llm_core::StreamChunk::from(chunk))
|
||||||
|
.map_err(|e| LlmError::Json(e.to_string())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
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"
|
||||||
285
crates/llm/openai/src/auth.rs
Normal file
285
crates/llm/openai/src/auth.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! 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";
|
||||||
|
|
||||||
|
impl OpenAIAuth {
|
||||||
|
/// Create a new OAuth client with the default CLI client ID
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
http: Client::new(),
|
||||||
|
client_id: DEFAULT_CLIENT_ID.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create with a custom client ID
|
||||||
|
pub fn with_client_id(client_id: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
http: Client::new(),
|
||||||
|
client_id: client_id.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
561
crates/llm/openai/src/client.rs
Normal file
561
crates/llm/openai/src/client.rs
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
//! OpenAI GPT API Client
|
||||||
|
//!
|
||||||
|
//! Implements the Chat Completions 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, StreamChunk, Tool, ToolCall,
|
||||||
|
ToolCallDelta, Usage, UsageStats,
|
||||||
|
};
|
||||||
|
use reqwest::Client;
|
||||||
|
use tokio::io::AsyncBufReadExt;
|
||||||
|
use tokio_stream::wrappers::LinesStream;
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
const API_BASE_URL: &str = "https://api.openai.com/v1";
|
||||||
|
const CHAT_ENDPOINT: &str = "/chat/completions";
|
||||||
|
const MODELS_ENDPOINT: &str = "/models";
|
||||||
|
|
||||||
|
/// OpenAI GPT API client
|
||||||
|
pub struct OpenAIClient {
|
||||||
|
http: Client,
|
||||||
|
auth: AuthMethod,
|
||||||
|
model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenAIClient {
|
||||||
|
/// 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: "gpt-4o".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: "gpt-4o".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new client with full AuthMethod
|
||||||
|
pub fn with_auth(auth: AuthMethod) -> Self {
|
||||||
|
Self {
|
||||||
|
http: Client::new(),
|
||||||
|
auth,
|
||||||
|
model: "gpt-4o".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 OpenAI format
|
||||||
|
fn prepare_messages(messages: &[ChatMessage]) -> Vec<OpenAIMessage> {
|
||||||
|
messages.iter().map(OpenAIMessage::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert tools to OpenAI format
|
||||||
|
fn prepare_tools(tools: Option<&[Tool]>) -> Option<Vec<OpenAITool>> {
|
||||||
|
tools.map(|t| t.iter().map(OpenAITool::from).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmProvider for OpenAIClient {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"openai"
|
||||||
|
}
|
||||||
|
|
||||||
|
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, CHAT_ENDPOINT);
|
||||||
|
|
||||||
|
let model = if options.model.is_empty() {
|
||||||
|
&self.model
|
||||||
|
} else {
|
||||||
|
&options.model
|
||||||
|
};
|
||||||
|
|
||||||
|
let openai_messages = Self::prepare_messages(messages);
|
||||||
|
let openai_tools = Self::prepare_tools(tools);
|
||||||
|
|
||||||
|
let request = ChatCompletionRequest {
|
||||||
|
model,
|
||||||
|
messages: openai_messages,
|
||||||
|
temperature: options.temperature,
|
||||||
|
max_tokens: options.max_tokens,
|
||||||
|
top_p: options.top_p,
|
||||||
|
stop: options.stop.as_deref(),
|
||||||
|
tools: openai_tools,
|
||||||
|
tool_choice: None,
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let bearer = self
|
||||||
|
.auth
|
||||||
|
.bearer_token()
|
||||||
|
.ok_or_else(|| LlmError::Auth("No authentication configured".to_string()))?;
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.http
|
||||||
|
.post(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", bearer))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.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());
|
||||||
|
|
||||||
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||||
|
return Err(LlmError::RateLimit {
|
||||||
|
retry_after_secs: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as error response
|
||||||
|
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&text) {
|
||||||
|
return Err(LlmError::Api {
|
||||||
|
message: err_resp.error.message,
|
||||||
|
code: err_resp.error.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(LlmError::Api {
|
||||||
|
message: text,
|
||||||
|
code: Some(status.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse SSE stream
|
||||||
|
let byte_stream = response
|
||||||
|
.bytes_stream()
|
||||||
|
.map(|result| result.map_err(std::io::Error::other));
|
||||||
|
|
||||||
|
let reader = StreamReader::new(byte_stream);
|
||||||
|
let buf_reader = tokio::io::BufReader::new(reader);
|
||||||
|
let lines_stream = LinesStream::new(buf_reader.lines());
|
||||||
|
|
||||||
|
let chunk_stream = lines_stream.filter_map(|line_result| async move {
|
||||||
|
match line_result {
|
||||||
|
Ok(line) => parse_sse_line(&line),
|
||||||
|
Err(e) => Some(Err(LlmError::Stream(e.to_string()))),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Box::pin(chunk_stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
options: &ChatOptions,
|
||||||
|
tools: Option<&[Tool]>,
|
||||||
|
) -> Result<ChatResponse, LlmError> {
|
||||||
|
let url = format!("{}{}", API_BASE_URL, CHAT_ENDPOINT);
|
||||||
|
|
||||||
|
let model = if options.model.is_empty() {
|
||||||
|
&self.model
|
||||||
|
} else {
|
||||||
|
&options.model
|
||||||
|
};
|
||||||
|
|
||||||
|
let openai_messages = Self::prepare_messages(messages);
|
||||||
|
let openai_tools = Self::prepare_tools(tools);
|
||||||
|
|
||||||
|
let request = ChatCompletionRequest {
|
||||||
|
model,
|
||||||
|
messages: openai_messages,
|
||||||
|
temperature: options.temperature,
|
||||||
|
max_tokens: options.max_tokens,
|
||||||
|
top_p: options.top_p,
|
||||||
|
stop: options.stop.as_deref(),
|
||||||
|
tools: openai_tools,
|
||||||
|
tool_choice: None,
|
||||||
|
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("Authorization", format!("Bearer {}", bearer))
|
||||||
|
.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());
|
||||||
|
|
||||||
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||||
|
return Err(LlmError::RateLimit {
|
||||||
|
retry_after_secs: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(err_resp) = serde_json::from_str::<ErrorResponse>(&text) {
|
||||||
|
return Err(LlmError::Api {
|
||||||
|
message: err_resp.error.message,
|
||||||
|
code: err_resp.error.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(LlmError::Api {
|
||||||
|
message: text,
|
||||||
|
code: Some(status.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let api_response: ChatCompletionResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| LlmError::Json(e.to_string()))?;
|
||||||
|
|
||||||
|
// Extract the first choice
|
||||||
|
let choice = api_response
|
||||||
|
.choices
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| LlmError::Api {
|
||||||
|
message: "No choices in response".to_string(),
|
||||||
|
code: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let content = choice.message.content.clone();
|
||||||
|
|
||||||
|
let tool_calls = choice.message.tool_calls.as_ref().map(|calls| {
|
||||||
|
calls
|
||||||
|
.iter()
|
||||||
|
.map(|call| {
|
||||||
|
let arguments: serde_json::Value =
|
||||||
|
serde_json::from_str(&call.function.arguments).unwrap_or_default();
|
||||||
|
|
||||||
|
ToolCall {
|
||||||
|
id: call.id.clone(),
|
||||||
|
call_type: "function".to_string(),
|
||||||
|
function: FunctionCall {
|
||||||
|
name: call.function.name.clone(),
|
||||||
|
arguments,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = api_response.usage.map(|u| Usage {
|
||||||
|
prompt_tokens: u.prompt_tokens,
|
||||||
|
completion_tokens: u.completion_tokens,
|
||||||
|
total_tokens: u.total_tokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ChatResponse {
|
||||||
|
content,
|
||||||
|
tool_calls,
|
||||||
|
usage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single SSE line into a StreamChunk
|
||||||
|
fn parse_sse_line(line: &str) -> Option<Result<StreamChunk, LlmError>> {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if line.is_empty() || line.starts_with(':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE format: "data: <json>"
|
||||||
|
if let Some(data) = line.strip_prefix("data: ") {
|
||||||
|
// OpenAI sends [DONE] to signal end
|
||||||
|
if data == "[DONE]" {
|
||||||
|
return Some(Ok(StreamChunk {
|
||||||
|
content: None,
|
||||||
|
tool_calls: None,
|
||||||
|
done: true,
|
||||||
|
usage: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON chunk
|
||||||
|
match serde_json::from_str::<ChatCompletionChunk>(data) {
|
||||||
|
Ok(chunk) => Some(convert_chunk_to_stream_chunk(chunk)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to parse SSE chunk: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert OpenAI chunk to our common format
|
||||||
|
fn convert_chunk_to_stream_chunk(chunk: ChatCompletionChunk) -> Result<StreamChunk, LlmError> {
|
||||||
|
let choice = chunk.choices.first();
|
||||||
|
|
||||||
|
if let Some(choice) = choice {
|
||||||
|
let content = choice.delta.content.clone();
|
||||||
|
|
||||||
|
let tool_calls = choice.delta.tool_calls.as_ref().map(|deltas| {
|
||||||
|
deltas
|
||||||
|
.iter()
|
||||||
|
.map(|delta| ToolCallDelta {
|
||||||
|
index: delta.index,
|
||||||
|
id: delta.id.clone(),
|
||||||
|
function_name: delta.function.as_ref().and_then(|f| f.name.clone()),
|
||||||
|
arguments_delta: delta.function.as_ref().and_then(|f| f.arguments.clone()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let done = choice.finish_reason.is_some();
|
||||||
|
|
||||||
|
Ok(StreamChunk {
|
||||||
|
content,
|
||||||
|
tool_calls,
|
||||||
|
done,
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// No choices, treat as done
|
||||||
|
Ok(StreamChunk {
|
||||||
|
content: None,
|
||||||
|
tool_calls: None,
|
||||||
|
done: true,
|
||||||
|
usage: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ProviderInfo Implementation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Known GPT models with their specifications
|
||||||
|
fn get_gpt_models() -> Vec<ModelInfo> {
|
||||||
|
vec![
|
||||||
|
ModelInfo {
|
||||||
|
id: "gpt-4o".to_string(),
|
||||||
|
display_name: Some("GPT-4o".to_string()),
|
||||||
|
description: Some("Most advanced multimodal model with vision".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
max_output_tokens: Some(16_384),
|
||||||
|
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("Affordable and fast model for simple tasks".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
max_output_tokens: Some(16_384),
|
||||||
|
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 high-performance model".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
max_output_tokens: Some(4_096),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_vision: true,
|
||||||
|
input_price_per_mtok: Some(10.0),
|
||||||
|
output_price_per_mtok: Some(30.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "gpt-3.5-turbo".to_string(),
|
||||||
|
display_name: Some("GPT-3.5 Turbo".to_string()),
|
||||||
|
description: Some("Fast and affordable for simple tasks".to_string()),
|
||||||
|
context_window: Some(16_385),
|
||||||
|
max_output_tokens: Some(4_096),
|
||||||
|
supports_tools: true,
|
||||||
|
supports_vision: false,
|
||||||
|
input_price_per_mtok: Some(0.50),
|
||||||
|
output_price_per_mtok: Some(1.50),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "o1".to_string(),
|
||||||
|
display_name: Some("OpenAI o1".to_string()),
|
||||||
|
description: Some("Reasoning model optimized for complex problems".to_string()),
|
||||||
|
context_window: Some(200_000),
|
||||||
|
max_output_tokens: Some(100_000),
|
||||||
|
supports_tools: false,
|
||||||
|
supports_vision: true,
|
||||||
|
input_price_per_mtok: Some(15.0),
|
||||||
|
output_price_per_mtok: Some(60.0),
|
||||||
|
},
|
||||||
|
ModelInfo {
|
||||||
|
id: "o1-mini".to_string(),
|
||||||
|
display_name: Some("OpenAI o1-mini".to_string()),
|
||||||
|
description: Some("Faster reasoning model for STEM".to_string()),
|
||||||
|
context_window: Some(128_000),
|
||||||
|
max_output_tokens: Some(65_536),
|
||||||
|
supports_tools: false,
|
||||||
|
supports_vision: true,
|
||||||
|
input_price_per_mtok: Some(3.0),
|
||||||
|
output_price_per_mtok: Some(12.0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderInfo for OpenAIClient {
|
||||||
|
async fn status(&self) -> Result<ProviderStatus, LlmError> {
|
||||||
|
let authenticated = self.auth.bearer_token().is_some();
|
||||||
|
|
||||||
|
// Try to reach the API by listing models
|
||||||
|
let reachable = if authenticated {
|
||||||
|
let url = format!("{}{}", API_BASE_URL, MODELS_ENDPOINT);
|
||||||
|
let bearer = self.auth.bearer_token().unwrap();
|
||||||
|
|
||||||
|
match self
|
||||||
|
.http
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", bearer))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(resp) => resp.status().is_success(),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = if !authenticated {
|
||||||
|
Some("Not authenticated - set OPENAI_API_KEY or run 'owlen login openai'".to_string())
|
||||||
|
} else if !reachable {
|
||||||
|
Some("Cannot reach OpenAI API".to_string())
|
||||||
|
} else {
|
||||||
|
Some("Connected".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ProviderStatus {
|
||||||
|
provider: "openai".to_string(),
|
||||||
|
authenticated,
|
||||||
|
account: None, // OpenAI doesn't expose account info via API
|
||||||
|
model: self.model.clone(),
|
||||||
|
endpoint: API_BASE_URL.to_string(),
|
||||||
|
reachable,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn account_info(&self) -> Result<Option<AccountInfo>, LlmError> {
|
||||||
|
// OpenAI doesn't have a public account info endpoint
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn usage_stats(&self) -> Result<Option<UsageStats>, LlmError> {
|
||||||
|
// OpenAI doesn't expose usage stats via the standard API
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelInfo>, LlmError> {
|
||||||
|
// We can optionally fetch from API, but return known models for now
|
||||||
|
Ok(get_gpt_models())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn model_info(&self, model_id: &str) -> Result<Option<ModelInfo>, LlmError> {
|
||||||
|
let models = get_gpt_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 openai_msgs = OpenAIClient::prepare_messages(&messages);
|
||||||
|
|
||||||
|
assert_eq!(openai_msgs.len(), 3);
|
||||||
|
assert_eq!(openai_msgs[0].role, "system");
|
||||||
|
assert_eq!(openai_msgs[1].role, "user");
|
||||||
|
assert_eq!(openai_msgs[2].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 openai_tools = OpenAIClient::prepare_tools(Some(&tools)).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(openai_tools.len(), 1);
|
||||||
|
assert_eq!(openai_tools[0].function.name, "read_file");
|
||||||
|
assert_eq!(
|
||||||
|
openai_tools[0].function.description,
|
||||||
|
"Read a file's contents"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/llm/openai/src/lib.rs
Normal file
12
crates/llm/openai/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//! OpenAI GPT API Client
|
||||||
|
//!
|
||||||
|
//! Implements the LlmProvider trait for OpenAI's GPT 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::*;
|
||||||
285
crates/llm/openai/src/types.rs
Normal file
285
crates/llm/openai/src/types.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! OpenAI API request/response types
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Request Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ChatCompletionRequest<'a> {
|
||||||
|
pub model: &'a str,
|
||||||
|
pub messages: Vec<OpenAIMessage>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub top_p: Option<f32>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub stop: Option<&'a [String]>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tools: Option<Vec<OpenAITool>>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_choice: Option<&'a str>,
|
||||||
|
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIMessage {
|
||||||
|
pub role: String, // "system", "user", "assistant", "tool"
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_calls: Option<Vec<OpenAIToolCall>>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_call_id: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIToolCall {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub call_type: String,
|
||||||
|
pub function: OpenAIFunctionCall,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIFunctionCall {
|
||||||
|
pub name: String,
|
||||||
|
pub arguments: String, // JSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAITool {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub tool_type: String,
|
||||||
|
pub function: OpenAIFunction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OpenAIFunction {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub parameters: FunctionParameters,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FunctionParameters {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub param_type: String,
|
||||||
|
pub properties: Value,
|
||||||
|
pub required: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ChatCompletionResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub object: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub model: String,
|
||||||
|
pub choices: Vec<Choice>,
|
||||||
|
pub usage: Option<UsageInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Choice {
|
||||||
|
pub index: u32,
|
||||||
|
pub message: OpenAIMessage,
|
||||||
|
pub finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct UsageInfo {
|
||||||
|
pub prompt_tokens: u32,
|
||||||
|
pub completion_tokens: u32,
|
||||||
|
pub total_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Streaming Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ChatCompletionChunk {
|
||||||
|
pub id: String,
|
||||||
|
pub object: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub model: String,
|
||||||
|
pub choices: Vec<ChunkChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ChunkChoice {
|
||||||
|
pub index: u32,
|
||||||
|
pub delta: Delta,
|
||||||
|
pub finish_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Delta {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub role: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub content: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_calls: Option<Vec<DeltaToolCall>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DeltaToolCall {
|
||||||
|
pub index: usize,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none", rename = "type")]
|
||||||
|
pub call_type: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub function: Option<DeltaFunction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct DeltaFunction {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub arguments: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Response Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: ApiError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ApiError {
|
||||||
|
pub message: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub error_type: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Models List Response
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ModelsResponse {
|
||||||
|
pub object: String,
|
||||||
|
pub data: Vec<ModelData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ModelData {
|
||||||
|
pub id: String,
|
||||||
|
pub object: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub owned_by: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Conversions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
impl From<&llm_core::Tool> for OpenAITool {
|
||||||
|
fn from(tool: &llm_core::Tool) -> Self {
|
||||||
|
Self {
|
||||||
|
tool_type: "function".to_string(),
|
||||||
|
function: OpenAIFunction {
|
||||||
|
name: tool.function.name.clone(),
|
||||||
|
description: tool.function.description.clone(),
|
||||||
|
parameters: FunctionParameters {
|
||||||
|
param_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 OpenAIMessage {
|
||||||
|
fn from(msg: &llm_core::ChatMessage) -> Self {
|
||||||
|
use llm_core::Role;
|
||||||
|
|
||||||
|
let role = match msg.role {
|
||||||
|
Role::System => "system",
|
||||||
|
Role::User => "user",
|
||||||
|
Role::Assistant => "assistant",
|
||||||
|
Role::Tool => "tool",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle tool result messages
|
||||||
|
if msg.role == Role::Tool {
|
||||||
|
return Self {
|
||||||
|
role: "tool".to_string(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: msg.tool_call_id.clone(),
|
||||||
|
name: msg.name.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle assistant messages with tool calls
|
||||||
|
if msg.role == Role::Assistant && msg.tool_calls.is_some() {
|
||||||
|
let tool_calls = msg.tool_calls.as_ref().map(|calls| {
|
||||||
|
calls
|
||||||
|
.iter()
|
||||||
|
.map(|call| OpenAIToolCall {
|
||||||
|
id: call.id.clone(),
|
||||||
|
call_type: "function".to_string(),
|
||||||
|
function: OpenAIFunctionCall {
|
||||||
|
name: call.function.name.clone(),
|
||||||
|
arguments: serde_json::to_string(&call.function.arguments)
|
||||||
|
.unwrap_or_else(|_| "{}".to_string()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
return Self {
|
||||||
|
role: "assistant".to_string(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
tool_calls,
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple text message
|
||||||
|
Self {
|
||||||
|
role: role.to_string(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "owlen-cli"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
description = "Command-line interface for OWLEN LLM client"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["chat-client"]
|
|
||||||
chat-client = []
|
|
||||||
code-client = []
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "owlen"
|
|
||||||
path = "src/main.rs"
|
|
||||||
required-features = ["chat-client"]
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "owlen-code"
|
|
||||||
path = "src/code_main.rs"
|
|
||||||
required-features = ["code-client"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
owlen-core = { path = "../owlen-core" }
|
|
||||||
owlen-tui = { path = "../owlen-tui" }
|
|
||||||
owlen-ollama = { path = "../owlen-ollama" }
|
|
||||||
|
|
||||||
# CLI framework
|
|
||||||
clap = { version = "4.0", features = ["derive"] }
|
|
||||||
|
|
||||||
# Async runtime
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tokio-util = { workspace = true }
|
|
||||||
|
|
||||||
# TUI framework
|
|
||||||
ratatui = { workspace = true }
|
|
||||||
crossterm = { workspace = true }
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
//! OWLEN Code Mode - TUI client optimized for coding assistance
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use clap::{Arg, Command};
|
|
||||||
use owlen_core::session::SessionController;
|
|
||||||
use owlen_ollama::OllamaProvider;
|
|
||||||
use owlen_tui::{config, ui, AppState, CodeApp, Event, EventHandler, SessionEvent};
|
|
||||||
use std::io;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crossterm::{
|
|
||||||
event::{DisableMouseCapture, EnableMouseCapture},
|
|
||||||
execute,
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let matches = Command::new("owlen-code")
|
|
||||||
.about("OWLEN Code Mode - TUI optimized for programming assistance")
|
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
|
||||||
.arg(
|
|
||||||
Arg::new("model")
|
|
||||||
.short('m')
|
|
||||||
.long("model")
|
|
||||||
.value_name("MODEL")
|
|
||||||
.help("Preferred model to use for this session"),
|
|
||||||
)
|
|
||||||
.get_matches();
|
|
||||||
|
|
||||||
let mut config = config::try_load_config().unwrap_or_default();
|
|
||||||
|
|
||||||
if let Some(model) = matches.get_one::<String>("model") {
|
|
||||||
config.general.default_model = Some(model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
|
|
||||||
let provider = Arc::new(OllamaProvider::from_config(
|
|
||||||
&provider_cfg,
|
|
||||||
Some(&config.general),
|
|
||||||
)?);
|
|
||||||
|
|
||||||
let controller = SessionController::new(provider, config.clone());
|
|
||||||
let (mut app, mut session_rx) = CodeApp::new(controller);
|
|
||||||
app.inner_mut().initialize_models().await?;
|
|
||||||
|
|
||||||
let cancellation_token = CancellationToken::new();
|
|
||||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
|
||||||
let event_handler = EventHandler::new(event_tx, cancellation_token.clone());
|
|
||||||
let event_handle = tokio::spawn(async move { event_handler.run().await });
|
|
||||||
|
|
||||||
enable_raw_mode()?;
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
let result = run_app(&mut terminal, &mut app, event_rx, &mut session_rx).await;
|
|
||||||
|
|
||||||
cancellation_token.cancel();
|
|
||||||
event_handle.await?;
|
|
||||||
|
|
||||||
config::save_config(app.inner().config())?;
|
|
||||||
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
println!("{err:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_app(
|
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
||||||
app: &mut CodeApp,
|
|
||||||
mut event_rx: mpsc::UnboundedReceiver<Event>,
|
|
||||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
loop {
|
|
||||||
terminal.draw(|f| ui::render_chat(f, app.inner_mut()))?;
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
Some(event) = event_rx.recv() => {
|
|
||||||
if let AppState::Quit = app.handle_event(event).await? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(session_event) = session_rx.recv() => {
|
|
||||||
app.handle_session_event(session_event)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
//! OWLEN CLI - Chat TUI client
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use clap::{Arg, Command};
|
|
||||||
use owlen_core::session::SessionController;
|
|
||||||
use owlen_ollama::OllamaProvider;
|
|
||||||
use owlen_tui::{config, ui, AppState, ChatApp, Event, EventHandler, SessionEvent};
|
|
||||||
use std::io;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
use crossterm::{
|
|
||||||
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
|
|
||||||
execute,
|
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
||||||
};
|
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
let matches = Command::new("owlen")
|
|
||||||
.about("OWLEN - A chat-focused TUI client for Ollama")
|
|
||||||
.version(env!("CARGO_PKG_VERSION"))
|
|
||||||
.arg(
|
|
||||||
Arg::new("model")
|
|
||||||
.short('m')
|
|
||||||
.long("model")
|
|
||||||
.value_name("MODEL")
|
|
||||||
.help("Preferred model to use for this session"),
|
|
||||||
)
|
|
||||||
.get_matches();
|
|
||||||
|
|
||||||
let mut config = config::try_load_config().unwrap_or_default();
|
|
||||||
|
|
||||||
if let Some(model) = matches.get_one::<String>("model") {
|
|
||||||
config.general.default_model = Some(model.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare provider from configuration
|
|
||||||
let provider_cfg = config::ensure_ollama_config(&mut config).clone();
|
|
||||||
let provider = Arc::new(OllamaProvider::from_config(
|
|
||||||
&provider_cfg,
|
|
||||||
Some(&config.general),
|
|
||||||
)?);
|
|
||||||
|
|
||||||
let controller = SessionController::new(provider, config.clone());
|
|
||||||
let (mut app, mut session_rx) = ChatApp::new(controller);
|
|
||||||
app.initialize_models().await?;
|
|
||||||
|
|
||||||
// Event infrastructure
|
|
||||||
let cancellation_token = CancellationToken::new();
|
|
||||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
|
||||||
let event_handler = EventHandler::new(event_tx, cancellation_token.clone());
|
|
||||||
let event_handle = tokio::spawn(async move { event_handler.run().await });
|
|
||||||
|
|
||||||
// Terminal setup
|
|
||||||
enable_raw_mode()?;
|
|
||||||
let mut stdout = io::stdout();
|
|
||||||
execute!(
|
|
||||||
stdout,
|
|
||||||
EnterAlternateScreen,
|
|
||||||
EnableMouseCapture,
|
|
||||||
EnableBracketedPaste
|
|
||||||
)?;
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
let result = run_app(&mut terminal, &mut app, event_rx, &mut session_rx).await;
|
|
||||||
|
|
||||||
// Shutdown
|
|
||||||
cancellation_token.cancel();
|
|
||||||
event_handle.await?;
|
|
||||||
|
|
||||||
// Persist configuration updates (e.g., selected model)
|
|
||||||
config::save_config(app.config())?;
|
|
||||||
|
|
||||||
disable_raw_mode()?;
|
|
||||||
execute!(
|
|
||||||
terminal.backend_mut(),
|
|
||||||
LeaveAlternateScreen,
|
|
||||||
DisableMouseCapture,
|
|
||||||
DisableBracketedPaste
|
|
||||||
)?;
|
|
||||||
terminal.show_cursor()?;
|
|
||||||
|
|
||||||
if let Err(err) = result {
|
|
||||||
println!("{err:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_app(
|
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
||||||
app: &mut ChatApp,
|
|
||||||
mut event_rx: mpsc::UnboundedReceiver<Event>,
|
|
||||||
session_rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
|
|
||||||
) -> Result<()> {
|
|
||||||
loop {
|
|
||||||
// Advance loading animation frame
|
|
||||||
app.advance_loading_animation();
|
|
||||||
|
|
||||||
terminal.draw(|f| ui::render_chat(f, app))?;
|
|
||||||
|
|
||||||
// Process any pending LLM requests AFTER UI has been drawn
|
|
||||||
app.process_pending_llm_request().await?;
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
Some(event) = event_rx.recv() => {
|
|
||||||
if let AppState::Quit = app.handle_event(event).await? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(session_event) = session_rx.recv() => {
|
|
||||||
app.handle_session_event(session_event)?;
|
|
||||||
}
|
|
||||||
// Add a timeout to keep the animation going even when there are no events
|
|
||||||
_ = tokio::time::sleep(tokio::time::Duration::from_millis(100)) => {
|
|
||||||
// This will cause the loop to continue and advance the animation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "owlen-core"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
description = "Core traits and types for OWLEN LLM client"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1.0.75"
|
|
||||||
log = "0.4.20"
|
|
||||||
serde = { version = "1.0.188", features = ["derive"] }
|
|
||||||
serde_json = "1.0.105"
|
|
||||||
thiserror = "1.0.48"
|
|
||||||
tokio = { version = "1.32.0", features = ["full"] }
|
|
||||||
unicode-segmentation = "1.11"
|
|
||||||
unicode-width = "0.1"
|
|
||||||
uuid = { version = "1.4.1", features = ["v4", "serde"] }
|
|
||||||
textwrap = "0.16.0"
|
|
||||||
futures = "0.3.28"
|
|
||||||
async-trait = "0.1.73"
|
|
||||||
toml = "0.8.0"
|
|
||||||
shellexpand = "3.1.0"
|
|
||||||
dirs = "5.0"
|
|
||||||
ratatui = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio-test = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
use crate::provider::ProviderConfig;
|
|
||||||
use crate::Result;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Default location for the OWLEN configuration file
|
|
||||||
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
|
|
||||||
|
|
||||||
/// Core configuration shared by all OWLEN clients
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
/// General application settings
|
|
||||||
pub general: GeneralSettings,
|
|
||||||
/// Provider specific configuration keyed by provider name
|
|
||||||
#[serde(default)]
|
|
||||||
pub providers: HashMap<String, ProviderConfig>,
|
|
||||||
/// UI preferences that frontends can opt into
|
|
||||||
#[serde(default)]
|
|
||||||
pub ui: UiSettings,
|
|
||||||
/// Storage related options
|
|
||||||
#[serde(default)]
|
|
||||||
pub storage: StorageSettings,
|
|
||||||
/// Input handling preferences
|
|
||||||
#[serde(default)]
|
|
||||||
pub input: InputSettings,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
let mut providers = HashMap::new();
|
|
||||||
providers.insert(
|
|
||||||
"ollama".to_string(),
|
|
||||||
ProviderConfig {
|
|
||||||
provider_type: "ollama".to_string(),
|
|
||||||
base_url: Some("http://localhost:11434".to_string()),
|
|
||||||
api_key: None,
|
|
||||||
extra: HashMap::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
general: GeneralSettings::default(),
|
|
||||||
providers,
|
|
||||||
ui: UiSettings::default(),
|
|
||||||
storage: StorageSettings::default(),
|
|
||||||
input: InputSettings::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Load configuration from disk, falling back to defaults when missing
|
|
||||||
pub fn load(path: Option<&Path>) -> Result<Self> {
|
|
||||||
let path = match path {
|
|
||||||
Some(path) => path.to_path_buf(),
|
|
||||||
None => default_config_path(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if path.exists() {
|
|
||||||
let content = fs::read_to_string(&path)?;
|
|
||||||
let mut config: Config =
|
|
||||||
toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))?;
|
|
||||||
config.ensure_defaults();
|
|
||||||
Ok(config)
|
|
||||||
} else {
|
|
||||||
Ok(Config::default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist configuration to disk
|
|
||||||
pub fn save(&self, path: Option<&Path>) -> Result<()> {
|
|
||||||
let path = match path {
|
|
||||||
Some(path) => path.to_path_buf(),
|
|
||||||
None => default_config_path(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content =
|
|
||||||
toml::to_string_pretty(self).map_err(|e| crate::Error::Config(e.to_string()))?;
|
|
||||||
fs::write(path, content)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get provider configuration by provider name
|
|
||||||
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
|
|
||||||
self.providers.get(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update or insert a provider configuration
|
|
||||||
pub fn upsert_provider(&mut self, name: impl Into<String>, config: ProviderConfig) {
|
|
||||||
self.providers.insert(name.into(), config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve default model in order of priority: explicit default, first cached model, provider fallback
|
|
||||||
pub fn resolve_default_model<'a>(
|
|
||||||
&'a self,
|
|
||||||
models: &'a [crate::types::ModelInfo],
|
|
||||||
) -> Option<&'a str> {
|
|
||||||
if let Some(model) = self.general.default_model.as_deref() {
|
|
||||||
if models.iter().any(|m| m.id == model || m.name == model) {
|
|
||||||
return Some(model);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(first) = models.first() {
|
|
||||||
return Some(&first.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.general.default_model.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_defaults(&mut self) {
|
|
||||||
if self.general.default_provider.is_empty() {
|
|
||||||
self.general.default_provider = "ollama".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.providers.contains_key("ollama") {
|
|
||||||
self.providers.insert(
|
|
||||||
"ollama".to_string(),
|
|
||||||
ProviderConfig {
|
|
||||||
provider_type: "ollama".to_string(),
|
|
||||||
base_url: Some("http://localhost:11434".to_string()),
|
|
||||||
api_key: None,
|
|
||||||
extra: HashMap::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default configuration path with user home expansion
|
|
||||||
pub fn default_config_path() -> PathBuf {
|
|
||||||
PathBuf::from(shellexpand::tilde(DEFAULT_CONFIG_PATH).as_ref())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// General behaviour settings shared across clients
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct GeneralSettings {
|
|
||||||
/// Default provider name for routing
|
|
||||||
pub default_provider: String,
|
|
||||||
/// Optional default model id
|
|
||||||
#[serde(default)]
|
|
||||||
pub default_model: Option<String>,
|
|
||||||
/// Whether streaming responses are preferred
|
|
||||||
#[serde(default = "GeneralSettings::default_streaming")]
|
|
||||||
pub enable_streaming: bool,
|
|
||||||
/// Optional path to a project context file automatically injected as system prompt
|
|
||||||
#[serde(default)]
|
|
||||||
pub project_context_file: Option<String>,
|
|
||||||
/// TTL for cached model listings in seconds
|
|
||||||
#[serde(default = "GeneralSettings::default_model_cache_ttl")]
|
|
||||||
pub model_cache_ttl_secs: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GeneralSettings {
|
|
||||||
fn default_streaming() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_model_cache_ttl() -> u64 {
|
|
||||||
60
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duration representation of model cache TTL
|
|
||||||
pub fn model_cache_ttl(&self) -> Duration {
|
|
||||||
Duration::from_secs(self.model_cache_ttl_secs.max(5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GeneralSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
default_provider: "ollama".to_string(),
|
|
||||||
default_model: Some("llama3.2:latest".to_string()),
|
|
||||||
enable_streaming: Self::default_streaming(),
|
|
||||||
project_context_file: Some("OWLEN.md".to_string()),
|
|
||||||
model_cache_ttl_secs: Self::default_model_cache_ttl(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// UI preferences that consumers can respect as needed
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct UiSettings {
|
|
||||||
#[serde(default = "UiSettings::default_theme")]
|
|
||||||
pub theme: String,
|
|
||||||
#[serde(default = "UiSettings::default_word_wrap")]
|
|
||||||
pub word_wrap: bool,
|
|
||||||
#[serde(default = "UiSettings::default_max_history_lines")]
|
|
||||||
pub max_history_lines: usize,
|
|
||||||
#[serde(default = "UiSettings::default_show_role_labels")]
|
|
||||||
pub show_role_labels: bool,
|
|
||||||
#[serde(default = "UiSettings::default_wrap_column")]
|
|
||||||
pub wrap_column: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UiSettings {
|
|
||||||
fn default_theme() -> String {
|
|
||||||
"default_dark".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_word_wrap() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_max_history_lines() -> usize {
|
|
||||||
2000
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_show_role_labels() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_wrap_column() -> u16 {
|
|
||||||
100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for UiSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
theme: Self::default_theme(),
|
|
||||||
word_wrap: Self::default_word_wrap(),
|
|
||||||
max_history_lines: Self::default_max_history_lines(),
|
|
||||||
show_role_labels: Self::default_show_role_labels(),
|
|
||||||
wrap_column: Self::default_wrap_column(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Storage related preferences
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct StorageSettings {
|
|
||||||
#[serde(default = "StorageSettings::default_conversation_dir")]
|
|
||||||
pub conversation_dir: Option<String>,
|
|
||||||
#[serde(default = "StorageSettings::default_auto_save")]
|
|
||||||
pub auto_save_sessions: bool,
|
|
||||||
#[serde(default = "StorageSettings::default_max_sessions")]
|
|
||||||
pub max_saved_sessions: usize,
|
|
||||||
#[serde(default = "StorageSettings::default_session_timeout")]
|
|
||||||
pub session_timeout_minutes: u64,
|
|
||||||
#[serde(default = "StorageSettings::default_generate_descriptions")]
|
|
||||||
pub generate_descriptions: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StorageSettings {
|
|
||||||
fn default_conversation_dir() -> Option<String> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_auto_save() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_max_sessions() -> usize {
|
|
||||||
25
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_session_timeout() -> u64 {
|
|
||||||
120
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_generate_descriptions() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve storage directory path
|
|
||||||
/// Uses platform-specific data directory if not explicitly configured:
|
|
||||||
/// - Linux: ~/.local/share/owlen/sessions
|
|
||||||
/// - Windows: %APPDATA%\owlen\sessions
|
|
||||||
/// - macOS: ~/Library/Application Support/owlen/sessions
|
|
||||||
pub fn conversation_path(&self) -> PathBuf {
|
|
||||||
if let Some(ref dir) = self.conversation_dir {
|
|
||||||
PathBuf::from(shellexpand::tilde(dir).as_ref())
|
|
||||||
} else {
|
|
||||||
// Use platform-specific data directory
|
|
||||||
dirs::data_local_dir()
|
|
||||||
.map(|d| d.join("owlen").join("sessions"))
|
|
||||||
.unwrap_or_else(|| PathBuf::from("./owlen_sessions"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StorageSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
conversation_dir: None, // Use platform-specific defaults
|
|
||||||
auto_save_sessions: Self::default_auto_save(),
|
|
||||||
max_saved_sessions: Self::default_max_sessions(),
|
|
||||||
session_timeout_minutes: Self::default_session_timeout(),
|
|
||||||
generate_descriptions: Self::default_generate_descriptions(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Input handling preferences shared across clients
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct InputSettings {
|
|
||||||
#[serde(default = "InputSettings::default_multiline")]
|
|
||||||
pub multiline: bool,
|
|
||||||
#[serde(default = "InputSettings::default_history_size")]
|
|
||||||
pub history_size: usize,
|
|
||||||
#[serde(default = "InputSettings::default_tab_width")]
|
|
||||||
pub tab_width: u8,
|
|
||||||
#[serde(default = "InputSettings::default_confirm_send")]
|
|
||||||
pub confirm_send: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InputSettings {
|
|
||||||
fn default_multiline() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_history_size() -> usize {
|
|
||||||
100
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_tab_width() -> u8 {
|
|
||||||
4
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_confirm_send() -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InputSettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
multiline: Self::default_multiline(),
|
|
||||||
history_size: Self::default_history_size(),
|
|
||||||
tab_width: Self::default_tab_width(),
|
|
||||||
confirm_send: Self::default_confirm_send(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience accessor for an Ollama provider entry, creating a default if missing
|
|
||||||
pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
|
|
||||||
config
|
|
||||||
.providers
|
|
||||||
.entry("ollama".to_string())
|
|
||||||
.or_insert_with(|| ProviderConfig {
|
|
||||||
provider_type: "ollama".to_string(),
|
|
||||||
base_url: Some("http://localhost:11434".to_string()),
|
|
||||||
api_key: None,
|
|
||||||
extra: HashMap::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate absolute timeout for session data based on configuration
|
|
||||||
pub fn session_timeout(config: &Config) -> Duration {
|
|
||||||
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_storage_platform_specific_paths() {
|
|
||||||
let config = Config::default();
|
|
||||||
let path = config.storage.conversation_path();
|
|
||||||
|
|
||||||
// Verify it contains owlen/sessions
|
|
||||||
assert!(path.to_string_lossy().contains("owlen"));
|
|
||||||
assert!(path.to_string_lossy().contains("sessions"));
|
|
||||||
|
|
||||||
// Platform-specific checks
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
// Linux should use ~/.local/share/owlen/sessions
|
|
||||||
assert!(path.to_string_lossy().contains(".local/share"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
// Windows should use AppData
|
|
||||||
assert!(path.to_string_lossy().contains("AppData"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// macOS should use ~/Library/Application Support
|
|
||||||
assert!(path
|
|
||||||
.to_string_lossy()
|
|
||||||
.contains("Library/Application Support"));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Config conversation path: {}", path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_storage_custom_path() {
|
|
||||||
let mut config = Config::default();
|
|
||||||
config.storage.conversation_dir = Some("~/custom/path".to_string());
|
|
||||||
|
|
||||||
let path = config.storage.conversation_path();
|
|
||||||
assert!(path.to_string_lossy().contains("custom/path"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
use crate::storage::StorageManager;
|
|
||||||
use crate::types::{Conversation, Message};
|
|
||||||
use crate::Result;
|
|
||||||
use serde_json::{Number, Value};
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const STREAMING_FLAG: &str = "streaming";
|
|
||||||
const LAST_CHUNK_TS: &str = "last_chunk_ts";
|
|
||||||
const PLACEHOLDER_FLAG: &str = "placeholder";
|
|
||||||
|
|
||||||
/// Manage active and historical conversations, including streaming updates.
|
|
||||||
pub struct ConversationManager {
|
|
||||||
active: Conversation,
|
|
||||||
history: VecDeque<Conversation>,
|
|
||||||
message_index: HashMap<Uuid, usize>,
|
|
||||||
streaming: HashMap<Uuid, StreamingMetadata>,
|
|
||||||
max_history: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct StreamingMetadata {
|
|
||||||
started: Instant,
|
|
||||||
last_update: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConversationManager {
|
|
||||||
/// Create a new conversation manager with a default model
|
|
||||||
pub fn new(model: impl Into<String>) -> Self {
|
|
||||||
Self::with_history_capacity(model, 32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create with explicit history capacity
|
|
||||||
pub fn with_history_capacity(model: impl Into<String>, max_history: usize) -> Self {
|
|
||||||
let conversation = Conversation::new(model.into());
|
|
||||||
Self {
|
|
||||||
active: conversation,
|
|
||||||
history: VecDeque::new(),
|
|
||||||
message_index: HashMap::new(),
|
|
||||||
streaming: HashMap::new(),
|
|
||||||
max_history: max_history.max(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access the active conversation
|
|
||||||
pub fn active(&self) -> &Conversation {
|
|
||||||
&self.active
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public mutable access to the active conversation
|
|
||||||
pub fn active_mut(&mut self) -> &mut Conversation {
|
|
||||||
&mut self.active
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the active conversation with a provided one, archiving the existing conversation if it contains data
|
|
||||||
pub fn load(&mut self, conversation: Conversation) {
|
|
||||||
if !self.active.messages.is_empty() {
|
|
||||||
self.archive_active();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.message_index.clear();
|
|
||||||
for (idx, message) in conversation.messages.iter().enumerate() {
|
|
||||||
self.message_index.insert(message.id, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.stream_reset();
|
|
||||||
self.active = conversation;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a brand new conversation, archiving the previous one
|
|
||||||
pub fn start_new(&mut self, model: Option<String>, name: Option<String>) {
|
|
||||||
self.archive_active();
|
|
||||||
let model = model.unwrap_or_else(|| self.active.model.clone());
|
|
||||||
self.active = Conversation::new(model);
|
|
||||||
self.active.name = name;
|
|
||||||
self.message_index.clear();
|
|
||||||
self.stream_reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Archive the active conversation into history
|
|
||||||
pub fn archive_active(&mut self) {
|
|
||||||
if self.active.messages.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut archived = self.active.clone();
|
|
||||||
archived.updated_at = std::time::SystemTime::now();
|
|
||||||
self.history.push_front(archived);
|
|
||||||
|
|
||||||
while self.history.len() > self.max_history {
|
|
||||||
self.history.pop_back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get immutable history
|
|
||||||
pub fn history(&self) -> impl Iterator<Item = &Conversation> {
|
|
||||||
self.history.iter()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a user message and return its identifier
|
|
||||||
pub fn push_user_message(&mut self, content: impl Into<String>) -> Uuid {
|
|
||||||
let message = Message::user(content.into());
|
|
||||||
self.register_message(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a system message and return its identifier
|
|
||||||
pub fn push_system_message(&mut self, content: impl Into<String>) -> Uuid {
|
|
||||||
let message = Message::system(content.into());
|
|
||||||
self.register_message(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add an assistant message (non-streaming) and return its identifier
|
|
||||||
pub fn push_assistant_message(&mut self, content: impl Into<String>) -> Uuid {
|
|
||||||
let message = Message::assistant(content.into());
|
|
||||||
self.register_message(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push an arbitrary message into the active conversation
|
|
||||||
pub fn push_message(&mut self, message: Message) -> Uuid {
|
|
||||||
self.register_message(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start tracking a streaming assistant response, returning the message id to update
|
|
||||||
pub fn start_streaming_response(&mut self) -> Uuid {
|
|
||||||
let mut message = Message::assistant(String::new());
|
|
||||||
message
|
|
||||||
.metadata
|
|
||||||
.insert(STREAMING_FLAG.to_string(), Value::Bool(true));
|
|
||||||
let id = message.id;
|
|
||||||
self.register_message(message);
|
|
||||||
self.streaming.insert(
|
|
||||||
id,
|
|
||||||
StreamingMetadata {
|
|
||||||
started: Instant::now(),
|
|
||||||
last_update: Instant::now(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append streaming content to an assistant message
|
|
||||||
pub fn append_stream_chunk(
|
|
||||||
&mut self,
|
|
||||||
message_id: Uuid,
|
|
||||||
chunk: &str,
|
|
||||||
is_final: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
let index = self
|
|
||||||
.message_index
|
|
||||||
.get(&message_id)
|
|
||||||
.copied()
|
|
||||||
.ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?;
|
|
||||||
|
|
||||||
let conversation = self.active_mut();
|
|
||||||
if let Some(message) = conversation.messages.get_mut(index) {
|
|
||||||
let was_placeholder = message
|
|
||||||
.metadata
|
|
||||||
.remove(PLACEHOLDER_FLAG)
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if was_placeholder {
|
|
||||||
message.content.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !chunk.is_empty() {
|
|
||||||
message.content.push_str(chunk);
|
|
||||||
}
|
|
||||||
message.timestamp = std::time::SystemTime::now();
|
|
||||||
let millis = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis() as u64;
|
|
||||||
message.metadata.insert(
|
|
||||||
LAST_CHUNK_TS.to_string(),
|
|
||||||
Value::Number(Number::from(millis)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if is_final {
|
|
||||||
message
|
|
||||||
.metadata
|
|
||||||
.insert(STREAMING_FLAG.to_string(), Value::Bool(false));
|
|
||||||
self.streaming.remove(&message_id);
|
|
||||||
} else if let Some(info) = self.streaming.get_mut(&message_id) {
|
|
||||||
info.last_update = Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set placeholder text for a streaming message
|
|
||||||
pub fn set_stream_placeholder(
|
|
||||||
&mut self,
|
|
||||||
message_id: Uuid,
|
|
||||||
text: impl Into<String>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let index = self
|
|
||||||
.message_index
|
|
||||||
.get(&message_id)
|
|
||||||
.copied()
|
|
||||||
.ok_or_else(|| crate::Error::Unknown(format!("Unknown message id: {message_id}")))?;
|
|
||||||
|
|
||||||
if let Some(message) = self.active_mut().messages.get_mut(index) {
|
|
||||||
message.content = text.into();
|
|
||||||
message.timestamp = std::time::SystemTime::now();
|
|
||||||
message
|
|
||||||
.metadata
|
|
||||||
.insert(PLACEHOLDER_FLAG.to_string(), Value::Bool(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the active model (used when user changes model mid session)
|
|
||||||
pub fn set_model(&mut self, model: impl Into<String>) {
|
|
||||||
self.active.model = model.into();
|
|
||||||
self.active.updated_at = std::time::SystemTime::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide read access to the cached streaming metadata
|
|
||||||
pub fn streaming_metadata(&self, message_id: &Uuid) -> Option<StreamingMetadata> {
|
|
||||||
self.streaming.get(message_id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove inactive streaming messages that have stalled beyond the provided timeout
|
|
||||||
pub fn expire_stalled_streams(&mut self, idle_timeout: Duration) -> Vec<Uuid> {
|
|
||||||
let cutoff = Instant::now() - idle_timeout;
|
|
||||||
let mut expired = Vec::new();
|
|
||||||
|
|
||||||
self.streaming.retain(|id, meta| {
|
|
||||||
if meta.last_update < cutoff {
|
|
||||||
expired.push(*id);
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expired
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all state
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.active.clear();
|
|
||||||
self.history.clear();
|
|
||||||
self.message_index.clear();
|
|
||||||
self.streaming.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn register_message(&mut self, message: Message) -> Uuid {
|
|
||||||
let id = message.id;
|
|
||||||
let idx;
|
|
||||||
{
|
|
||||||
let conversation = self.active_mut();
|
|
||||||
idx = conversation.messages.len();
|
|
||||||
conversation.messages.push(message);
|
|
||||||
conversation.updated_at = std::time::SystemTime::now();
|
|
||||||
}
|
|
||||||
self.message_index.insert(id, idx);
|
|
||||||
id
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stream_reset(&mut self) {
|
|
||||||
self.streaming.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save the active conversation to disk
|
|
||||||
pub fn save_active(&self, storage: &StorageManager, name: Option<String>) -> Result<PathBuf> {
|
|
||||||
storage.save_conversation(&self.active, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save the active conversation to disk with a description
|
|
||||||
pub fn save_active_with_description(
|
|
||||||
&self,
|
|
||||||
storage: &StorageManager,
|
|
||||||
name: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
) -> Result<PathBuf> {
|
|
||||||
storage.save_conversation_with_description(&self.active, name, description)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a conversation from disk and make it active
|
|
||||||
pub fn load_from_disk(
|
|
||||||
&mut self,
|
|
||||||
storage: &StorageManager,
|
|
||||||
path: impl AsRef<Path>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let conversation = storage.load_conversation(path)?;
|
|
||||||
self.load(conversation);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all saved sessions
|
|
||||||
pub fn list_saved_sessions(
|
|
||||||
storage: &StorageManager,
|
|
||||||
) -> Result<Vec<crate::storage::SessionMeta>> {
|
|
||||||
storage.list_sessions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StreamingMetadata {
|
|
||||||
/// Duration since the stream started
|
|
||||||
pub fn elapsed(&self) -> Duration {
|
|
||||||
self.started.elapsed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Duration since the last chunk was received
|
|
||||||
pub fn idle_duration(&self) -> Duration {
|
|
||||||
self.last_update.elapsed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Timestamp when streaming started
|
|
||||||
pub fn started_at(&self) -> Instant {
|
|
||||||
self.started
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Timestamp of most recent update
|
|
||||||
pub fn last_update_at(&self) -> Instant {
|
|
||||||
self.last_update
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
use crate::types::Message;
|
|
||||||
|
|
||||||
/// Formats messages for display across different clients.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MessageFormatter {
|
|
||||||
wrap_width: usize,
|
|
||||||
show_role_labels: bool,
|
|
||||||
preserve_empty_lines: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessageFormatter {
|
|
||||||
/// Create a new formatter
|
|
||||||
pub fn new(wrap_width: usize, show_role_labels: bool) -> Self {
|
|
||||||
Self {
|
|
||||||
wrap_width: wrap_width.max(20),
|
|
||||||
show_role_labels,
|
|
||||||
preserve_empty_lines: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Override whether empty lines should be preserved
|
|
||||||
pub fn with_preserve_empty(mut self, preserve: bool) -> Self {
|
|
||||||
self.preserve_empty_lines = preserve;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the wrap width
|
|
||||||
pub fn set_wrap_width(&mut self, width: usize) {
|
|
||||||
self.wrap_width = width.max(20);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether role labels should be shown alongside messages
|
|
||||||
pub fn show_role_labels(&self) -> bool {
|
|
||||||
self.show_role_labels
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn format_message(&self, message: &Message) -> Vec<String> {
|
|
||||||
message
|
|
||||||
.content
|
|
||||||
.trim()
|
|
||||||
.lines()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract thinking content from <think> tags, returning (content_without_think, thinking_content)
|
|
||||||
/// This handles both complete and incomplete (streaming) think tags.
|
|
||||||
pub fn extract_thinking(&self, content: &str) -> (String, Option<String>) {
|
|
||||||
let mut result = String::new();
|
|
||||||
let mut thinking = String::new();
|
|
||||||
let mut current_pos = 0;
|
|
||||||
|
|
||||||
while let Some(start_pos) = content[current_pos..].find("<think>") {
|
|
||||||
let abs_start = current_pos + start_pos;
|
|
||||||
|
|
||||||
// Add content before <think> tag to result
|
|
||||||
result.push_str(&content[current_pos..abs_start]);
|
|
||||||
|
|
||||||
// Find closing tag
|
|
||||||
if let Some(end_pos) = content[abs_start..].find("</think>") {
|
|
||||||
let abs_end = abs_start + end_pos;
|
|
||||||
let think_content = &content[abs_start + 7..abs_end]; // 7 = len("<think>")
|
|
||||||
|
|
||||||
if !thinking.is_empty() {
|
|
||||||
thinking.push_str("\n\n");
|
|
||||||
}
|
|
||||||
thinking.push_str(think_content.trim());
|
|
||||||
|
|
||||||
current_pos = abs_end + 8; // 8 = len("</think>")
|
|
||||||
} else {
|
|
||||||
// Unclosed tag - this is streaming content
|
|
||||||
// Extract everything after <think> as thinking content
|
|
||||||
let think_content = &content[abs_start + 7..]; // 7 = len("<think>")
|
|
||||||
|
|
||||||
if !thinking.is_empty() {
|
|
||||||
thinking.push_str("\n\n");
|
|
||||||
}
|
|
||||||
thinking.push_str(think_content);
|
|
||||||
|
|
||||||
current_pos = content.len();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining content
|
|
||||||
result.push_str(&content[current_pos..]);
|
|
||||||
|
|
||||||
let thinking_result = if thinking.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(thinking)
|
|
||||||
};
|
|
||||||
|
|
||||||
(result, thinking_result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
/// Text input buffer with history and cursor management.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct InputBuffer {
|
|
||||||
buffer: String,
|
|
||||||
cursor: usize,
|
|
||||||
history: VecDeque<String>,
|
|
||||||
history_index: Option<usize>,
|
|
||||||
max_history: usize,
|
|
||||||
pub multiline: bool,
|
|
||||||
tab_width: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InputBuffer {
|
|
||||||
/// Create a new input buffer
|
|
||||||
pub fn new(max_history: usize, multiline: bool, tab_width: u8) -> Self {
|
|
||||||
Self {
|
|
||||||
buffer: String::new(),
|
|
||||||
cursor: 0,
|
|
||||||
history: VecDeque::with_capacity(max_history.max(1)),
|
|
||||||
history_index: None,
|
|
||||||
max_history: max_history.max(1),
|
|
||||||
multiline,
|
|
||||||
tab_width: tab_width.max(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current text
|
|
||||||
pub fn text(&self) -> &str {
|
|
||||||
&self.buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Current cursor position
|
|
||||||
pub fn cursor(&self) -> usize {
|
|
||||||
self.cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace buffer contents
|
|
||||||
pub fn set_text(&mut self, text: impl Into<String>) {
|
|
||||||
self.buffer = text.into();
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
self.history_index = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear buffer and reset cursor
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.buffer.clear();
|
|
||||||
self.cursor = 0;
|
|
||||||
self.history_index = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a character at the cursor position
|
|
||||||
pub fn insert_char(&mut self, ch: char) {
|
|
||||||
if ch == '\t' {
|
|
||||||
self.insert_tab();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.buffer.insert(self.cursor, ch);
|
|
||||||
self.cursor += ch.len_utf8();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert text at cursor
|
|
||||||
pub fn insert_text(&mut self, text: &str) {
|
|
||||||
self.buffer.insert_str(self.cursor, text);
|
|
||||||
self.cursor += text.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert spaces representing a tab
|
|
||||||
pub fn insert_tab(&mut self) {
|
|
||||||
let spaces = " ".repeat(self.tab_width as usize);
|
|
||||||
self.insert_text(&spaces);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove character before cursor
|
|
||||||
pub fn backspace(&mut self) {
|
|
||||||
if self.cursor == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let prev_index = prev_char_boundary(&self.buffer, self.cursor);
|
|
||||||
self.buffer.drain(prev_index..self.cursor);
|
|
||||||
self.cursor = prev_index;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove character at cursor
|
|
||||||
pub fn delete(&mut self) {
|
|
||||||
if self.cursor >= self.buffer.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next_index = next_char_boundary(&self.buffer, self.cursor);
|
|
||||||
self.buffer.drain(self.cursor..next_index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor left by one grapheme
|
|
||||||
pub fn move_left(&mut self) {
|
|
||||||
if self.cursor == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.cursor = prev_char_boundary(&self.buffer, self.cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor right by one grapheme
|
|
||||||
pub fn move_right(&mut self) {
|
|
||||||
if self.cursor >= self.buffer.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.cursor = next_char_boundary(&self.buffer, self.cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor to start of the buffer
|
|
||||||
pub fn move_home(&mut self) {
|
|
||||||
self.cursor = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move cursor to end of the buffer
|
|
||||||
pub fn move_end(&mut self) {
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push current buffer into history, clearing the buffer afterwards
|
|
||||||
pub fn commit_to_history(&mut self) -> String {
|
|
||||||
let text = std::mem::take(&mut self.buffer);
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
self.push_history_entry(text.clone());
|
|
||||||
}
|
|
||||||
self.cursor = 0;
|
|
||||||
self.history_index = None;
|
|
||||||
text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to previous history entry
|
|
||||||
pub fn history_previous(&mut self) {
|
|
||||||
if self.history.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_index = match self.history_index {
|
|
||||||
Some(idx) if idx + 1 < self.history.len() => idx + 1,
|
|
||||||
None => 0,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
self.history_index = Some(new_index);
|
|
||||||
if let Some(entry) = self.history.get(new_index) {
|
|
||||||
self.buffer = entry.clone();
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigate to next history entry
|
|
||||||
pub fn history_next(&mut self) {
|
|
||||||
if self.history.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(idx) = self.history_index {
|
|
||||||
if idx > 0 {
|
|
||||||
let new_idx = idx - 1;
|
|
||||||
self.history_index = Some(new_idx);
|
|
||||||
if let Some(entry) = self.history.get(new_idx) {
|
|
||||||
self.buffer = entry.clone();
|
|
||||||
self.cursor = self.buffer.len();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.history_index = None;
|
|
||||||
self.buffer.clear();
|
|
||||||
self.cursor = 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.buffer.clear();
|
|
||||||
self.cursor = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push a new entry into the history buffer, enforcing capacity
|
|
||||||
pub fn push_history_entry(&mut self, entry: String) {
|
|
||||||
if self
|
|
||||||
.history
|
|
||||||
.front()
|
|
||||||
.map(|existing| existing == &entry)
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.history.push_front(entry);
|
|
||||||
while self.history.len() > self.max_history {
|
|
||||||
self.history.pop_back();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prev_char_boundary(buffer: &str, cursor: usize) -> usize {
|
|
||||||
buffer[..cursor]
|
|
||||||
.char_indices()
|
|
||||||
.last()
|
|
||||||
.map(|(idx, _)| idx)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_char_boundary(buffer: &str, cursor: usize) -> usize {
|
|
||||||
if cursor >= buffer.len() {
|
|
||||||
return buffer.len();
|
|
||||||
}
|
|
||||||
|
|
||||||
let slice = &buffer[cursor..];
|
|
||||||
let mut iter = slice.char_indices();
|
|
||||||
iter.next();
|
|
||||||
if let Some((idx, _)) = iter.next() {
|
|
||||||
cursor + idx
|
|
||||||
} else {
|
|
||||||
buffer.len()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
//! Core traits and types for OWLEN LLM client
|
|
||||||
//!
|
|
||||||
//! This crate provides the foundational abstractions for building
|
|
||||||
//! LLM providers, routers, and MCP (Model Context Protocol) adapters.
|
|
||||||
|
|
||||||
pub mod config;
|
|
||||||
pub mod conversation;
|
|
||||||
pub mod formatting;
|
|
||||||
pub mod input;
|
|
||||||
pub mod model;
|
|
||||||
pub mod provider;
|
|
||||||
pub mod router;
|
|
||||||
pub mod session;
|
|
||||||
pub mod storage;
|
|
||||||
pub mod theme;
|
|
||||||
pub mod types;
|
|
||||||
pub mod ui;
|
|
||||||
pub mod wrap_cursor;
|
|
||||||
|
|
||||||
pub use config::*;
|
|
||||||
pub use conversation::*;
|
|
||||||
pub use formatting::*;
|
|
||||||
pub use input::*;
|
|
||||||
pub use model::*;
|
|
||||||
pub use provider::*;
|
|
||||||
pub use router::*;
|
|
||||||
pub use session::*;
|
|
||||||
pub use theme::*;
|
|
||||||
|
|
||||||
/// Result type used throughout the OWLEN ecosystem
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
|
||||||
|
|
||||||
/// Core error types for OWLEN
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
|
||||||
pub enum Error {
|
|
||||||
#[error("Provider error: {0}")]
|
|
||||||
Provider(#[from] anyhow::Error),
|
|
||||||
|
|
||||||
#[error("Network error: {0}")]
|
|
||||||
Network(String),
|
|
||||||
|
|
||||||
#[error("Authentication error: {0}")]
|
|
||||||
Auth(String),
|
|
||||||
|
|
||||||
#[error("Configuration error: {0}")]
|
|
||||||
Config(String),
|
|
||||||
|
|
||||||
#[error("I/O error: {0}")]
|
|
||||||
Io(#[from] std::io::Error),
|
|
||||||
|
|
||||||
#[error("Invalid input: {0}")]
|
|
||||||
InvalidInput(String),
|
|
||||||
|
|
||||||
#[error("Operation timed out: {0}")]
|
|
||||||
Timeout(String),
|
|
||||||
|
|
||||||
#[error("Serialization error: {0}")]
|
|
||||||
Serialization(#[from] serde_json::Error),
|
|
||||||
|
|
||||||
#[error("Storage error: {0}")]
|
|
||||||
Storage(String),
|
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
|
||||||
Unknown(String),
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
use crate::types::ModelInfo;
|
|
||||||
use crate::Result;
|
|
||||||
use std::future::Future;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
|
||||||
struct ModelCache {
|
|
||||||
models: Vec<ModelInfo>,
|
|
||||||
last_refresh: Option<Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Caches model listings for improved selection performance
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ModelManager {
|
|
||||||
cache: Arc<RwLock<ModelCache>>,
|
|
||||||
ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelManager {
|
|
||||||
/// Create a new manager with the desired cache TTL
|
|
||||||
pub fn new(ttl: Duration) -> Self {
|
|
||||||
Self {
|
|
||||||
cache: Arc::new(RwLock::new(ModelCache::default())),
|
|
||||||
ttl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get cached models, refreshing via the provided fetcher when stale. Returns the up-to-date model list.
|
|
||||||
pub async fn get_or_refresh<F, Fut>(
|
|
||||||
&self,
|
|
||||||
force_refresh: bool,
|
|
||||||
fetcher: F,
|
|
||||||
) -> Result<Vec<ModelInfo>>
|
|
||||||
where
|
|
||||||
F: FnOnce() -> Fut,
|
|
||||||
Fut: Future<Output = Result<Vec<ModelInfo>>>,
|
|
||||||
{
|
|
||||||
if !force_refresh {
|
|
||||||
if let Some(models) = self.cached_if_fresh().await {
|
|
||||||
return Ok(models);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let models = fetcher().await?;
|
|
||||||
let mut cache = self.cache.write().await;
|
|
||||||
cache.models = models.clone();
|
|
||||||
cache.last_refresh = Some(Instant::now());
|
|
||||||
Ok(models)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return cached models without refreshing
|
|
||||||
pub async fn cached(&self) -> Vec<ModelInfo> {
|
|
||||||
self.cache.read().await.models.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop cached models, forcing next call to refresh
|
|
||||||
pub async fn invalidate(&self) {
|
|
||||||
let mut cache = self.cache.write().await;
|
|
||||||
cache.models.clear();
|
|
||||||
cache.last_refresh = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Select a model by id or name from the cache
|
|
||||||
pub async fn select(&self, identifier: &str) -> Option<ModelInfo> {
|
|
||||||
let cache = self.cache.read().await;
|
|
||||||
cache
|
|
||||||
.models
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.id == identifier || m.name == identifier)
|
|
||||||
.cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cached_if_fresh(&self) -> Option<Vec<ModelInfo>> {
|
|
||||||
let cache = self.cache.read().await;
|
|
||||||
let fresh = matches!(cache.last_refresh, Some(ts) if ts.elapsed() < self.ttl);
|
|
||||||
if fresh && !cache.models.is_empty() {
|
|
||||||
Some(cache.models.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
//! Provider trait and related types
|
|
||||||
|
|
||||||
use crate::{types::*, Result};
|
|
||||||
use futures::Stream;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// A stream of chat responses
|
|
||||||
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
|
||||||
|
|
||||||
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub trait Provider: Send + Sync {
|
|
||||||
/// Get the name of this provider
|
|
||||||
fn name(&self) -> &str;
|
|
||||||
|
|
||||||
/// List available models from this provider
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>>;
|
|
||||||
|
|
||||||
/// Send a chat completion request
|
|
||||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse>;
|
|
||||||
|
|
||||||
/// Send a streaming chat completion request
|
|
||||||
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream>;
|
|
||||||
|
|
||||||
/// Check if the provider is available/healthy
|
|
||||||
async fn health_check(&self) -> Result<()>;
|
|
||||||
|
|
||||||
/// Get provider-specific configuration schema
|
|
||||||
fn config_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for a provider
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
||||||
pub struct ProviderConfig {
|
|
||||||
/// Provider type identifier
|
|
||||||
pub provider_type: String,
|
|
||||||
/// Base URL for API calls
|
|
||||||
pub base_url: Option<String>,
|
|
||||||
/// API key or token
|
|
||||||
pub api_key: Option<String>,
|
|
||||||
/// Additional provider-specific configuration
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub extra: std::collections::HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A registry of providers
|
|
||||||
pub struct ProviderRegistry {
|
|
||||||
providers: std::collections::HashMap<String, Arc<dyn Provider>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProviderRegistry {
|
|
||||||
/// Create a new provider registry
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
providers: std::collections::HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a provider
|
|
||||||
pub fn register<P: Provider + 'static>(&mut self, provider: P) {
|
|
||||||
self.register_arc(Arc::new(provider));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register an already wrapped provider
|
|
||||||
pub fn register_arc(&mut self, provider: Arc<dyn Provider>) {
|
|
||||||
let name = provider.name().to_string();
|
|
||||||
self.providers.insert(name, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a provider by name
|
|
||||||
pub fn get(&self, name: &str) -> Option<Arc<dyn Provider>> {
|
|
||||||
self.providers.get(name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all registered provider names
|
|
||||||
pub fn list_providers(&self) -> Vec<String> {
|
|
||||||
self.providers.keys().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all models from all providers
|
|
||||||
pub async fn list_all_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
let mut all_models = Vec::new();
|
|
||||||
|
|
||||||
for provider in self.providers.values() {
|
|
||||||
match provider.list_models().await {
|
|
||||||
Ok(mut models) => all_models.append(&mut models),
|
|
||||||
Err(_) => {
|
|
||||||
// Continue with other providers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(all_models)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ProviderRegistry {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
//! Router for managing multiple providers and routing requests
|
|
||||||
|
|
||||||
use crate::{provider::*, types::*, Result};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// A router that can distribute requests across multiple providers
|
|
||||||
pub struct Router {
|
|
||||||
registry: ProviderRegistry,
|
|
||||||
routing_rules: Vec<RoutingRule>,
|
|
||||||
default_provider: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A rule for routing requests to specific providers
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct RoutingRule {
|
|
||||||
/// Pattern to match against model names
|
|
||||||
pub model_pattern: String,
|
|
||||||
/// Provider to route to
|
|
||||||
pub provider: String,
|
|
||||||
/// Priority (higher numbers are checked first)
|
|
||||||
pub priority: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Router {
|
|
||||||
/// Create a new router
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
registry: ProviderRegistry::new(),
|
|
||||||
routing_rules: Vec::new(),
|
|
||||||
default_provider: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register a provider with the router
|
|
||||||
pub fn register_provider<P: Provider + 'static>(&mut self, provider: P) {
|
|
||||||
self.registry.register(provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the default provider
|
|
||||||
pub fn set_default_provider(&mut self, provider_name: String) {
|
|
||||||
self.default_provider = Some(provider_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a routing rule
|
|
||||||
pub fn add_routing_rule(&mut self, rule: RoutingRule) {
|
|
||||||
self.routing_rules.push(rule);
|
|
||||||
// Sort by priority (descending)
|
|
||||||
self.routing_rules
|
|
||||||
.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route a request to the appropriate provider
|
|
||||||
pub async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
|
||||||
let provider = self.find_provider_for_model(&request.model)?;
|
|
||||||
provider.chat(request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Route a streaming request to the appropriate provider
|
|
||||||
pub async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
|
||||||
let provider = self.find_provider_for_model(&request.model)?;
|
|
||||||
provider.chat_stream(request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all available models from all providers
|
|
||||||
pub async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
self.registry.list_all_models().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the appropriate provider for a given model
|
|
||||||
fn find_provider_for_model(&self, model: &str) -> Result<Arc<dyn Provider>> {
|
|
||||||
// Check routing rules first
|
|
||||||
for rule in &self.routing_rules {
|
|
||||||
if self.matches_pattern(&rule.model_pattern, model) {
|
|
||||||
if let Some(provider) = self.registry.get(&rule.provider) {
|
|
||||||
return Ok(provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to default provider
|
|
||||||
if let Some(default) = &self.default_provider {
|
|
||||||
if let Some(provider) = self.registry.get(default) {
|
|
||||||
return Ok(provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no default, try to find any provider that has this model
|
|
||||||
// This is a fallback for cases where routing isn't configured
|
|
||||||
for provider_name in self.registry.list_providers() {
|
|
||||||
if let Some(provider) = self.registry.get(&provider_name) {
|
|
||||||
return Ok(provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(crate::Error::Provider(anyhow::anyhow!(
|
|
||||||
"No provider found for model: {}",
|
|
||||||
model
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a model name matches a pattern
|
|
||||||
fn matches_pattern(&self, pattern: &str, model: &str) -> bool {
|
|
||||||
// Simple pattern matching for now
|
|
||||||
// Could be extended to support more complex patterns
|
|
||||||
if pattern == "*" {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(prefix) = pattern.strip_suffix('*') {
|
|
||||||
return model.starts_with(prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(suffix) = pattern.strip_prefix('*') {
|
|
||||||
return model.ends_with(suffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
pattern == model
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get routing configuration
|
|
||||||
pub fn get_routing_rules(&self) -> &[RoutingRule] {
|
|
||||||
&self.routing_rules
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the default provider name
|
|
||||||
pub fn get_default_provider(&self) -> Option<&str> {
|
|
||||||
self.default_provider.as_deref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Router {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_pattern_matching() {
|
|
||||||
let router = Router::new();
|
|
||||||
|
|
||||||
assert!(router.matches_pattern("*", "any-model"));
|
|
||||||
assert!(router.matches_pattern("gpt*", "gpt-4"));
|
|
||||||
assert!(router.matches_pattern("gpt*", "gpt-3.5-turbo"));
|
|
||||||
assert!(!router.matches_pattern("gpt*", "claude-3"));
|
|
||||||
assert!(router.matches_pattern("*:latest", "llama2:latest"));
|
|
||||||
assert!(router.matches_pattern("exact-match", "exact-match"));
|
|
||||||
assert!(!router.matches_pattern("exact-match", "different-model"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
use crate::config::Config;
|
|
||||||
use crate::conversation::ConversationManager;
|
|
||||||
use crate::formatting::MessageFormatter;
|
|
||||||
use crate::input::InputBuffer;
|
|
||||||
use crate::model::ModelManager;
|
|
||||||
use crate::provider::{ChatStream, Provider};
|
|
||||||
use crate::types::{ChatParameters, ChatRequest, ChatResponse, Conversation, ModelInfo};
|
|
||||||
use crate::Result;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// Outcome of submitting a chat request
|
|
||||||
pub enum SessionOutcome {
|
|
||||||
/// Immediate response received (non-streaming)
|
|
||||||
Complete(ChatResponse),
|
|
||||||
/// Streaming response where chunks will arrive asynchronously
|
|
||||||
Streaming {
|
|
||||||
response_id: Uuid,
|
|
||||||
stream: ChatStream,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// High-level controller encapsulating session state and provider interactions
|
|
||||||
pub struct SessionController {
|
|
||||||
provider: Arc<dyn Provider>,
|
|
||||||
conversation: ConversationManager,
|
|
||||||
model_manager: ModelManager,
|
|
||||||
input_buffer: InputBuffer,
|
|
||||||
formatter: MessageFormatter,
|
|
||||||
config: Config,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionController {
|
|
||||||
/// Create a new controller with the given provider and configuration
|
|
||||||
pub fn new(provider: Arc<dyn Provider>, config: Config) -> Self {
|
|
||||||
let model = config
|
|
||||||
.general
|
|
||||||
.default_model
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "ollama/default".to_string());
|
|
||||||
|
|
||||||
let conversation =
|
|
||||||
ConversationManager::with_history_capacity(model, config.storage.max_saved_sessions);
|
|
||||||
let formatter =
|
|
||||||
MessageFormatter::new(config.ui.wrap_column as usize, config.ui.show_role_labels)
|
|
||||||
.with_preserve_empty(config.ui.word_wrap);
|
|
||||||
let input_buffer = InputBuffer::new(
|
|
||||||
config.input.history_size,
|
|
||||||
config.input.multiline,
|
|
||||||
config.input.tab_width,
|
|
||||||
);
|
|
||||||
|
|
||||||
let model_manager = ModelManager::new(config.general.model_cache_ttl());
|
|
||||||
|
|
||||||
Self {
|
|
||||||
provider,
|
|
||||||
conversation,
|
|
||||||
model_manager,
|
|
||||||
input_buffer,
|
|
||||||
formatter,
|
|
||||||
config,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access the active conversation
|
|
||||||
pub fn conversation(&self) -> &Conversation {
|
|
||||||
self.conversation.active()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutable access to the conversation manager
|
|
||||||
pub fn conversation_mut(&mut self) -> &mut ConversationManager {
|
|
||||||
&mut self.conversation
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access input buffer
|
|
||||||
pub fn input_buffer(&self) -> &InputBuffer {
|
|
||||||
&self.input_buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutable input buffer access
|
|
||||||
pub fn input_buffer_mut(&mut self) -> &mut InputBuffer {
|
|
||||||
&mut self.input_buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formatter for rendering messages
|
|
||||||
pub fn formatter(&self) -> &MessageFormatter {
|
|
||||||
&self.formatter
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the wrap width of the message formatter
|
|
||||||
pub fn set_formatter_wrap_width(&mut self, width: usize) {
|
|
||||||
self.formatter.set_wrap_width(width);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access configuration
|
|
||||||
pub fn config(&self) -> &Config {
|
|
||||||
&self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mutable configuration access
|
|
||||||
pub fn config_mut(&mut self) -> &mut Config {
|
|
||||||
&mut self.config
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Currently selected model identifier
|
|
||||||
pub fn selected_model(&self) -> &str {
|
|
||||||
&self.conversation.active().model
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Change current model for upcoming requests
|
|
||||||
pub fn set_model(&mut self, model: String) {
|
|
||||||
self.conversation.set_model(model.clone());
|
|
||||||
self.config.general.default_model = Some(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve cached models, refreshing from provider as needed
|
|
||||||
pub async fn models(&self, force_refresh: bool) -> Result<Vec<ModelInfo>> {
|
|
||||||
self.model_manager
|
|
||||||
.get_or_refresh(force_refresh, || async {
|
|
||||||
self.provider.list_models().await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to select the configured default model from cached models
|
|
||||||
pub fn ensure_default_model(&mut self, models: &[ModelInfo]) {
|
|
||||||
if let Some(default) = self.config.general.default_model.clone() {
|
|
||||||
if models.iter().any(|m| m.id == default || m.name == default) {
|
|
||||||
self.set_model(default);
|
|
||||||
}
|
|
||||||
} else if let Some(model) = models.first() {
|
|
||||||
self.set_model(model.id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Submit a user message; optionally stream the response
|
|
||||||
pub async fn send_message(
|
|
||||||
&mut self,
|
|
||||||
content: String,
|
|
||||||
mut parameters: ChatParameters,
|
|
||||||
) -> Result<SessionOutcome> {
|
|
||||||
let streaming = parameters.stream || self.config.general.enable_streaming;
|
|
||||||
parameters.stream = streaming;
|
|
||||||
|
|
||||||
self.conversation.push_user_message(content);
|
|
||||||
|
|
||||||
self.send_request_with_current_conversation(parameters)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a request using the current conversation without adding a new user message
|
|
||||||
pub async fn send_request_with_current_conversation(
|
|
||||||
&mut self,
|
|
||||||
mut parameters: ChatParameters,
|
|
||||||
) -> Result<SessionOutcome> {
|
|
||||||
let streaming = parameters.stream || self.config.general.enable_streaming;
|
|
||||||
parameters.stream = streaming;
|
|
||||||
|
|
||||||
let request = ChatRequest {
|
|
||||||
model: self.conversation.active().model.clone(),
|
|
||||||
messages: self.conversation.active().messages.clone(),
|
|
||||||
parameters,
|
|
||||||
};
|
|
||||||
|
|
||||||
if streaming {
|
|
||||||
match self.provider.chat_stream(request).await {
|
|
||||||
Ok(stream) => {
|
|
||||||
let response_id = self.conversation.start_streaming_response();
|
|
||||||
Ok(SessionOutcome::Streaming {
|
|
||||||
response_id,
|
|
||||||
stream,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
self.conversation
|
|
||||||
.push_assistant_message(format!("Error starting stream: {}", err));
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match self.provider.chat(request).await {
|
|
||||||
Ok(response) => {
|
|
||||||
self.conversation.push_message(response.message.clone());
|
|
||||||
Ok(SessionOutcome::Complete(response))
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
self.conversation
|
|
||||||
.push_assistant_message(format!("Error: {}", err));
|
|
||||||
Err(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark a streaming response message with placeholder content
|
|
||||||
pub fn mark_stream_placeholder(&mut self, message_id: Uuid, text: &str) -> Result<()> {
|
|
||||||
self.conversation
|
|
||||||
.set_stream_placeholder(message_id, text.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply streaming chunk to the conversation
|
|
||||||
pub fn apply_stream_chunk(&mut self, message_id: Uuid, chunk: &ChatResponse) -> Result<()> {
|
|
||||||
self.conversation
|
|
||||||
.append_stream_chunk(message_id, &chunk.message.content, chunk.is_final)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Access conversation history
|
|
||||||
pub fn history(&self) -> Vec<Conversation> {
|
|
||||||
self.conversation.history().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a new conversation optionally targeting a specific model
|
|
||||||
pub fn start_new_conversation(&mut self, model: Option<String>, name: Option<String>) {
|
|
||||||
self.conversation.start_new(model, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear current conversation messages
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.conversation.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a short AI description for the current conversation
|
|
||||||
pub async fn generate_conversation_description(&self) -> Result<String> {
|
|
||||||
let conv = self.conversation.active();
|
|
||||||
|
|
||||||
// If conversation is empty or very short, return a simple description
|
|
||||||
if conv.messages.is_empty() {
|
|
||||||
return Ok("Empty conversation".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if conv.messages.len() == 1 {
|
|
||||||
let first_msg = &conv.messages[0];
|
|
||||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
|
||||||
return Ok(format!(
|
|
||||||
"{}{}",
|
|
||||||
preview,
|
|
||||||
if first_msg.content.len() > 50 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a summary prompt from the first few and last few messages
|
|
||||||
let mut summary_messages = Vec::new();
|
|
||||||
|
|
||||||
// Add system message to guide the description
|
|
||||||
summary_messages.push(crate::types::Message::system(
|
|
||||||
"Summarize this conversation in 1-2 short sentences (max 100 characters). \
|
|
||||||
Focus on the main topic or question being discussed. Be concise and descriptive."
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Include first message
|
|
||||||
if let Some(first) = conv.messages.first() {
|
|
||||||
summary_messages.push(first.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include a middle message if conversation is long enough
|
|
||||||
if conv.messages.len() > 4 {
|
|
||||||
if let Some(mid) = conv.messages.get(conv.messages.len() / 2) {
|
|
||||||
summary_messages.push(mid.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include last message
|
|
||||||
if let Some(last) = conv.messages.last() {
|
|
||||||
if conv.messages.len() > 1 {
|
|
||||||
summary_messages.push(last.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a summarization request
|
|
||||||
let request = crate::types::ChatRequest {
|
|
||||||
model: conv.model.clone(),
|
|
||||||
messages: summary_messages,
|
|
||||||
parameters: crate::types::ChatParameters {
|
|
||||||
temperature: Some(0.3), // Lower temperature for more focused summaries
|
|
||||||
max_tokens: Some(50), // Keep it short
|
|
||||||
stream: false,
|
|
||||||
extra: std::collections::HashMap::new(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the summary from the provider
|
|
||||||
match self.provider.chat(request).await {
|
|
||||||
Ok(response) => {
|
|
||||||
let description = response.message.content.trim().to_string();
|
|
||||||
|
|
||||||
// If description is empty, use fallback
|
|
||||||
if description.is_empty() {
|
|
||||||
let first_msg = &conv.messages[0];
|
|
||||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
|
||||||
return Ok(format!(
|
|
||||||
"{}{}",
|
|
||||||
preview,
|
|
||||||
if first_msg.content.len() > 50 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate if too long
|
|
||||||
let truncated = if description.len() > 100 {
|
|
||||||
format!("{}...", description.chars().take(97).collect::<String>())
|
|
||||||
} else {
|
|
||||||
description
|
|
||||||
};
|
|
||||||
Ok(truncated)
|
|
||||||
}
|
|
||||||
Err(_e) => {
|
|
||||||
// Fallback to simple description if AI generation fails
|
|
||||||
let first_msg = &conv.messages[0];
|
|
||||||
let preview = first_msg.content.chars().take(50).collect::<String>();
|
|
||||||
Ok(format!(
|
|
||||||
"{}{}",
|
|
||||||
preview,
|
|
||||||
if first_msg.content.len() > 50 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
//! Session persistence and storage management
|
|
||||||
|
|
||||||
use crate::types::Conversation;
|
|
||||||
use crate::{Error, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
/// Metadata about a saved session
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SessionMeta {
|
|
||||||
/// Session file path
|
|
||||||
pub path: PathBuf,
|
|
||||||
/// Conversation ID
|
|
||||||
pub id: uuid::Uuid,
|
|
||||||
/// Optional session name
|
|
||||||
pub name: Option<String>,
|
|
||||||
/// Optional AI-generated description
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Number of messages in the conversation
|
|
||||||
pub message_count: usize,
|
|
||||||
/// Model used
|
|
||||||
pub model: String,
|
|
||||||
/// When the session was created
|
|
||||||
pub created_at: SystemTime,
|
|
||||||
/// When the session was last updated
|
|
||||||
pub updated_at: SystemTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Storage manager for persisting conversations
|
|
||||||
pub struct StorageManager {
|
|
||||||
sessions_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StorageManager {
|
|
||||||
/// Create a new storage manager with the default sessions directory
|
|
||||||
pub fn new() -> Result<Self> {
|
|
||||||
let sessions_dir = Self::default_sessions_dir()?;
|
|
||||||
Self::with_directory(sessions_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a storage manager with a custom sessions directory
|
|
||||||
pub fn with_directory(sessions_dir: PathBuf) -> Result<Self> {
|
|
||||||
// Ensure the directory exists
|
|
||||||
if !sessions_dir.exists() {
|
|
||||||
fs::create_dir_all(&sessions_dir).map_err(|e| {
|
|
||||||
Error::Storage(format!("Failed to create sessions directory: {}", e))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self { sessions_dir })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the default sessions directory
|
|
||||||
/// - Linux: ~/.local/share/owlen/sessions
|
|
||||||
/// - Windows: %APPDATA%\owlen\sessions
|
|
||||||
/// - macOS: ~/Library/Application Support/owlen/sessions
|
|
||||||
pub fn default_sessions_dir() -> Result<PathBuf> {
|
|
||||||
let data_dir = dirs::data_local_dir()
|
|
||||||
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
|
|
||||||
Ok(data_dir.join("owlen").join("sessions"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a conversation to disk
|
|
||||||
pub fn save_conversation(
|
|
||||||
&self,
|
|
||||||
conversation: &Conversation,
|
|
||||||
name: Option<String>,
|
|
||||||
) -> Result<PathBuf> {
|
|
||||||
self.save_conversation_with_description(conversation, name, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save a conversation to disk with an optional description
|
|
||||||
pub fn save_conversation_with_description(
|
|
||||||
&self,
|
|
||||||
conversation: &Conversation,
|
|
||||||
name: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
) -> Result<PathBuf> {
|
|
||||||
let filename = if let Some(ref session_name) = name {
|
|
||||||
// Use provided name, sanitized
|
|
||||||
let sanitized = sanitize_filename(session_name);
|
|
||||||
format!("{}_{}.json", conversation.id, sanitized)
|
|
||||||
} else {
|
|
||||||
// Use conversation ID and timestamp
|
|
||||||
let timestamp = SystemTime::now()
|
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
format!("{}_{}.json", conversation.id, timestamp)
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = self.sessions_dir.join(filename);
|
|
||||||
|
|
||||||
// Create a saveable version with the name and description
|
|
||||||
let mut save_conv = conversation.clone();
|
|
||||||
if name.is_some() {
|
|
||||||
save_conv.name = name;
|
|
||||||
}
|
|
||||||
if description.is_some() {
|
|
||||||
save_conv.description = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&save_conv)
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?;
|
|
||||||
|
|
||||||
fs::write(&path, json)
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a conversation from disk
|
|
||||||
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
|
|
||||||
let content = fs::read_to_string(path.as_ref())
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?;
|
|
||||||
|
|
||||||
let conversation: Conversation = serde_json::from_str(&content)
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(conversation)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all saved sessions with metadata
|
|
||||||
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
|
|
||||||
let mut sessions = Vec::new();
|
|
||||||
|
|
||||||
let entries = fs::read_dir(&self.sessions_dir)
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?;
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let entry = entry
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
|
|
||||||
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to load the conversation to extract metadata
|
|
||||||
match self.load_conversation(&path) {
|
|
||||||
Ok(conv) => {
|
|
||||||
sessions.push(SessionMeta {
|
|
||||||
path: path.clone(),
|
|
||||||
id: conv.id,
|
|
||||||
name: conv.name.clone(),
|
|
||||||
description: conv.description.clone(),
|
|
||||||
message_count: conv.messages.len(),
|
|
||||||
model: conv.model.clone(),
|
|
||||||
created_at: conv.created_at,
|
|
||||||
updated_at: conv.updated_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Skip files that can't be parsed
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by updated_at, most recent first
|
|
||||||
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
|
||||||
|
|
||||||
Ok(sessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a saved session
|
|
||||||
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
|
|
||||||
fs::remove_file(path.as_ref())
|
|
||||||
.map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the sessions directory path
|
|
||||||
pub fn sessions_dir(&self) -> &Path {
|
|
||||||
&self.sessions_dir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StorageManager {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new().expect("Failed to create default storage manager")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sanitize a filename by removing invalid characters
|
|
||||||
fn sanitize_filename(name: &str) -> String {
|
|
||||||
name.chars()
|
|
||||||
.map(|c| {
|
|
||||||
if c.is_alphanumeric() || c == '_' || c == '-' {
|
|
||||||
c
|
|
||||||
} else if c.is_whitespace() {
|
|
||||||
'_'
|
|
||||||
} else {
|
|
||||||
'-'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
.chars()
|
|
||||||
.take(50) // Limit length
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::types::Message;
|
|
||||||
use tempfile::TempDir;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_platform_specific_default_path() {
|
|
||||||
let path = StorageManager::default_sessions_dir().unwrap();
|
|
||||||
|
|
||||||
// Verify it contains owlen/sessions
|
|
||||||
assert!(path.to_string_lossy().contains("owlen"));
|
|
||||||
assert!(path.to_string_lossy().contains("sessions"));
|
|
||||||
|
|
||||||
// Platform-specific checks
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
// Linux should use ~/.local/share/owlen/sessions
|
|
||||||
assert!(path.to_string_lossy().contains(".local/share"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
// Windows should use AppData
|
|
||||||
assert!(path.to_string_lossy().contains("AppData"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// macOS should use ~/Library/Application Support
|
|
||||||
assert!(path
|
|
||||||
.to_string_lossy()
|
|
||||||
.contains("Library/Application Support"));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Default sessions directory: {}", path.display());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_filename() {
|
|
||||||
assert_eq!(sanitize_filename("Hello World"), "Hello_World");
|
|
||||||
assert_eq!(sanitize_filename("test/path\\file"), "test-path-file");
|
|
||||||
assert_eq!(sanitize_filename("file:name?"), "file-name-");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_save_and_load_conversation() {
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
|
|
||||||
|
|
||||||
let mut conv = Conversation::new("test-model".to_string());
|
|
||||||
conv.messages.push(Message::user("Hello".to_string()));
|
|
||||||
conv.messages
|
|
||||||
.push(Message::assistant("Hi there!".to_string()));
|
|
||||||
|
|
||||||
// Save conversation
|
|
||||||
let path = storage
|
|
||||||
.save_conversation(&conv, Some("test_session".to_string()))
|
|
||||||
.unwrap();
|
|
||||||
assert!(path.exists());
|
|
||||||
|
|
||||||
// Load conversation
|
|
||||||
let loaded = storage.load_conversation(&path).unwrap();
|
|
||||||
assert_eq!(loaded.id, conv.id);
|
|
||||||
assert_eq!(loaded.model, conv.model);
|
|
||||||
assert_eq!(loaded.messages.len(), 2);
|
|
||||||
assert_eq!(loaded.name, Some("test_session".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_list_sessions() {
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
|
|
||||||
|
|
||||||
// Create multiple sessions
|
|
||||||
for i in 0..3 {
|
|
||||||
let mut conv = Conversation::new("test-model".to_string());
|
|
||||||
conv.messages.push(Message::user(format!("Message {}", i)));
|
|
||||||
storage
|
|
||||||
.save_conversation(&conv, Some(format!("session_{}", i)))
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// List sessions
|
|
||||||
let sessions = storage.list_sessions().unwrap();
|
|
||||||
assert_eq!(sessions.len(), 3);
|
|
||||||
|
|
||||||
// Check that sessions are sorted by updated_at (most recent first)
|
|
||||||
for i in 0..sessions.len() - 1 {
|
|
||||||
assert!(sessions[i].updated_at >= sessions[i + 1].updated_at);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_delete_session() {
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
|
|
||||||
|
|
||||||
let conv = Conversation::new("test-model".to_string());
|
|
||||||
let path = storage.save_conversation(&conv, None).unwrap();
|
|
||||||
assert!(path.exists());
|
|
||||||
|
|
||||||
storage.delete_session(&path).unwrap();
|
|
||||||
assert!(!path.exists());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
//! Theming system for OWLEN TUI
|
|
||||||
//!
|
|
||||||
//! Provides customizable color schemes for all UI components.
|
|
||||||
|
|
||||||
use ratatui::style::Color;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
/// A complete theme definition for OWLEN TUI
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Theme {
|
|
||||||
/// Name of the theme
|
|
||||||
pub name: String,
|
|
||||||
|
|
||||||
/// Default text color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub text: Color,
|
|
||||||
|
|
||||||
/// Default background color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub background: Color,
|
|
||||||
|
|
||||||
/// Border color for focused panels
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub focused_panel_border: Color,
|
|
||||||
|
|
||||||
/// Border color for unfocused panels
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub unfocused_panel_border: Color,
|
|
||||||
|
|
||||||
/// Color for user message role indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub user_message_role: Color,
|
|
||||||
|
|
||||||
/// Color for assistant message role indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub assistant_message_role: Color,
|
|
||||||
|
|
||||||
/// Color for thinking panel title
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub thinking_panel_title: Color,
|
|
||||||
|
|
||||||
/// Background color for command bar
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub command_bar_background: Color,
|
|
||||||
|
|
||||||
/// Status line background color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub status_background: Color,
|
|
||||||
|
|
||||||
/// Color for Normal mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_normal: Color,
|
|
||||||
|
|
||||||
/// Color for Editing mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_editing: Color,
|
|
||||||
|
|
||||||
/// Color for Model Selection mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_model_selection: Color,
|
|
||||||
|
|
||||||
/// Color for Provider Selection mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_provider_selection: Color,
|
|
||||||
|
|
||||||
/// Color for Help mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_help: Color,
|
|
||||||
|
|
||||||
/// Color for Visual mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_visual: Color,
|
|
||||||
|
|
||||||
/// Color for Command mode indicator
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub mode_command: Color,
|
|
||||||
|
|
||||||
/// Selection/highlight background color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub selection_bg: Color,
|
|
||||||
|
|
||||||
/// Selection/highlight foreground color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub selection_fg: Color,
|
|
||||||
|
|
||||||
/// Cursor indicator color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub cursor: Color,
|
|
||||||
|
|
||||||
/// Placeholder text color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub placeholder: Color,
|
|
||||||
|
|
||||||
/// Warning/error message color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub error: Color,
|
|
||||||
|
|
||||||
/// Success/info message color
|
|
||||||
#[serde(deserialize_with = "deserialize_color")]
|
|
||||||
#[serde(serialize_with = "serialize_color")]
|
|
||||||
pub info: Color,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Theme {
|
|
||||||
fn default() -> Self {
|
|
||||||
default_dark()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the default themes directory path
|
|
||||||
pub fn default_themes_dir() -> PathBuf {
|
|
||||||
let config_dir = PathBuf::from(shellexpand::tilde(crate::config::DEFAULT_CONFIG_PATH).as_ref())
|
|
||||||
.parent()
|
|
||||||
.map(|p| p.to_path_buf())
|
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.config/owlen"));
|
|
||||||
|
|
||||||
config_dir.join("themes")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all available themes (built-in + custom)
|
|
||||||
pub fn load_all_themes() -> HashMap<String, Theme> {
|
|
||||||
let mut themes = HashMap::new();
|
|
||||||
|
|
||||||
// Load built-in themes
|
|
||||||
for (name, theme) in built_in_themes() {
|
|
||||||
themes.insert(name, theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load custom themes from disk
|
|
||||||
let themes_dir = default_themes_dir();
|
|
||||||
if let Ok(entries) = fs::read_dir(&themes_dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|s| s.to_str()) == Some("toml") {
|
|
||||||
let name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("unknown")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
match load_theme_from_file(&path) {
|
|
||||||
Ok(theme) => {
|
|
||||||
themes.insert(name.clone(), theme);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Warning: Failed to load custom theme '{}': {}", name, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
themes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load a theme from a TOML file
|
|
||||||
pub fn load_theme_from_file(path: &Path) -> Result<Theme, String> {
|
|
||||||
let content =
|
|
||||||
fs::read_to_string(path).map_err(|e| format!("Failed to read theme file: {}", e))?;
|
|
||||||
|
|
||||||
toml::from_str(&content).map_err(|e| format!("Failed to parse theme file: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a theme by name (built-in or custom)
|
|
||||||
pub fn get_theme(name: &str) -> Option<Theme> {
|
|
||||||
load_all_themes().get(name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all built-in themes (embedded in the binary)
|
|
||||||
pub fn built_in_themes() -> HashMap<String, Theme> {
|
|
||||||
let mut themes = HashMap::new();
|
|
||||||
|
|
||||||
// Load embedded theme files
|
|
||||||
let embedded_themes = [
|
|
||||||
(
|
|
||||||
"default_dark",
|
|
||||||
include_str!("../../../themes/default_dark.toml"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"default_light",
|
|
||||||
include_str!("../../../themes/default_light.toml"),
|
|
||||||
),
|
|
||||||
("gruvbox", include_str!("../../../themes/gruvbox.toml")),
|
|
||||||
("dracula", include_str!("../../../themes/dracula.toml")),
|
|
||||||
("solarized", include_str!("../../../themes/solarized.toml")),
|
|
||||||
(
|
|
||||||
"midnight-ocean",
|
|
||||||
include_str!("../../../themes/midnight-ocean.toml"),
|
|
||||||
),
|
|
||||||
("rose-pine", include_str!("../../../themes/rose-pine.toml")),
|
|
||||||
("monokai", include_str!("../../../themes/monokai.toml")),
|
|
||||||
(
|
|
||||||
"material-dark",
|
|
||||||
include_str!("../../../themes/material-dark.toml"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"material-light",
|
|
||||||
include_str!("../../../themes/material-light.toml"),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (name, content) in embedded_themes {
|
|
||||||
match toml::from_str::<Theme>(content) {
|
|
||||||
Ok(theme) => {
|
|
||||||
themes.insert(name.to_string(), theme);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Warning: Failed to parse built-in theme '{}': {}", name, e);
|
|
||||||
// Fallback to hardcoded version if parsing fails
|
|
||||||
if let Some(fallback) = get_fallback_theme(name) {
|
|
||||||
themes.insert(name.to_string(), fallback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
themes
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get fallback hardcoded theme (used if embedded TOML fails to parse)
|
|
||||||
fn get_fallback_theme(name: &str) -> Option<Theme> {
|
|
||||||
match name {
|
|
||||||
"default_dark" => Some(default_dark()),
|
|
||||||
"default_light" => Some(default_light()),
|
|
||||||
"gruvbox" => Some(gruvbox()),
|
|
||||||
"dracula" => Some(dracula()),
|
|
||||||
"solarized" => Some(solarized()),
|
|
||||||
"midnight-ocean" => Some(midnight_ocean()),
|
|
||||||
"rose-pine" => Some(rose_pine()),
|
|
||||||
"monokai" => Some(monokai()),
|
|
||||||
"material-dark" => Some(material_dark()),
|
|
||||||
"material-light" => Some(material_light()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default dark theme
|
|
||||||
fn default_dark() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "default_dark".to_string(),
|
|
||||||
text: Color::White,
|
|
||||||
background: Color::Black,
|
|
||||||
focused_panel_border: Color::LightMagenta,
|
|
||||||
unfocused_panel_border: Color::Rgb(95, 20, 135),
|
|
||||||
user_message_role: Color::LightBlue,
|
|
||||||
assistant_message_role: Color::Yellow,
|
|
||||||
thinking_panel_title: Color::LightMagenta,
|
|
||||||
command_bar_background: Color::Black,
|
|
||||||
status_background: Color::Black,
|
|
||||||
mode_normal: Color::LightBlue,
|
|
||||||
mode_editing: Color::LightGreen,
|
|
||||||
mode_model_selection: Color::LightYellow,
|
|
||||||
mode_provider_selection: Color::LightCyan,
|
|
||||||
mode_help: Color::LightMagenta,
|
|
||||||
mode_visual: Color::Magenta,
|
|
||||||
mode_command: Color::Yellow,
|
|
||||||
selection_bg: Color::LightBlue,
|
|
||||||
selection_fg: Color::Black,
|
|
||||||
cursor: Color::Magenta,
|
|
||||||
placeholder: Color::DarkGray,
|
|
||||||
error: Color::Red,
|
|
||||||
info: Color::LightGreen,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default light theme
|
|
||||||
fn default_light() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "default_light".to_string(),
|
|
||||||
text: Color::Black,
|
|
||||||
background: Color::White,
|
|
||||||
focused_panel_border: Color::Rgb(74, 144, 226),
|
|
||||||
unfocused_panel_border: Color::Rgb(221, 221, 221),
|
|
||||||
user_message_role: Color::Rgb(0, 85, 164),
|
|
||||||
assistant_message_role: Color::Rgb(142, 68, 173),
|
|
||||||
thinking_panel_title: Color::Rgb(142, 68, 173),
|
|
||||||
command_bar_background: Color::White,
|
|
||||||
status_background: Color::White,
|
|
||||||
mode_normal: Color::Rgb(0, 85, 164),
|
|
||||||
mode_editing: Color::Rgb(46, 139, 87),
|
|
||||||
mode_model_selection: Color::Rgb(181, 137, 0),
|
|
||||||
mode_provider_selection: Color::Rgb(0, 139, 139),
|
|
||||||
mode_help: Color::Rgb(142, 68, 173),
|
|
||||||
mode_visual: Color::Rgb(142, 68, 173),
|
|
||||||
mode_command: Color::Rgb(181, 137, 0),
|
|
||||||
selection_bg: Color::Rgb(164, 200, 240),
|
|
||||||
selection_fg: Color::Black,
|
|
||||||
cursor: Color::Rgb(217, 95, 2),
|
|
||||||
placeholder: Color::Gray,
|
|
||||||
error: Color::Rgb(192, 57, 43),
|
|
||||||
info: Color::Green,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gruvbox theme
|
|
||||||
fn gruvbox() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "gruvbox".to_string(),
|
|
||||||
text: Color::Rgb(235, 219, 178), // #ebdbb2
|
|
||||||
background: Color::Rgb(40, 40, 40), // #282828
|
|
||||||
focused_panel_border: Color::Rgb(254, 128, 25), // #fe8019 (orange)
|
|
||||||
unfocused_panel_border: Color::Rgb(124, 111, 100), // #7c6f64
|
|
||||||
user_message_role: Color::Rgb(184, 187, 38), // #b8bb26 (green)
|
|
||||||
assistant_message_role: Color::Rgb(131, 165, 152), // #83a598 (blue)
|
|
||||||
thinking_panel_title: Color::Rgb(211, 134, 155), // #d3869b (purple)
|
|
||||||
command_bar_background: Color::Rgb(60, 56, 54), // #3c3836
|
|
||||||
status_background: Color::Rgb(60, 56, 54),
|
|
||||||
mode_normal: Color::Rgb(131, 165, 152), // blue
|
|
||||||
mode_editing: Color::Rgb(184, 187, 38), // green
|
|
||||||
mode_model_selection: Color::Rgb(250, 189, 47), // yellow
|
|
||||||
mode_provider_selection: Color::Rgb(142, 192, 124), // aqua
|
|
||||||
mode_help: Color::Rgb(211, 134, 155), // purple
|
|
||||||
mode_visual: Color::Rgb(254, 128, 25), // orange
|
|
||||||
mode_command: Color::Rgb(250, 189, 47), // yellow
|
|
||||||
selection_bg: Color::Rgb(80, 73, 69),
|
|
||||||
selection_fg: Color::Rgb(235, 219, 178),
|
|
||||||
cursor: Color::Rgb(254, 128, 25),
|
|
||||||
placeholder: Color::Rgb(102, 92, 84),
|
|
||||||
error: Color::Rgb(251, 73, 52), // #fb4934
|
|
||||||
info: Color::Rgb(184, 187, 38),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dracula theme
|
|
||||||
fn dracula() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "dracula".to_string(),
|
|
||||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
|
||||||
background: Color::Rgb(40, 42, 54), // #282a36
|
|
||||||
focused_panel_border: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
|
||||||
unfocused_panel_border: Color::Rgb(68, 71, 90), // #44475a
|
|
||||||
user_message_role: Color::Rgb(139, 233, 253), // #8be9fd (cyan)
|
|
||||||
assistant_message_role: Color::Rgb(255, 121, 198), // #ff79c6 (pink)
|
|
||||||
thinking_panel_title: Color::Rgb(189, 147, 249), // #bd93f9 (purple)
|
|
||||||
command_bar_background: Color::Rgb(68, 71, 90),
|
|
||||||
status_background: Color::Rgb(68, 71, 90),
|
|
||||||
mode_normal: Color::Rgb(139, 233, 253),
|
|
||||||
mode_editing: Color::Rgb(80, 250, 123), // #50fa7b (green)
|
|
||||||
mode_model_selection: Color::Rgb(241, 250, 140), // #f1fa8c (yellow)
|
|
||||||
mode_provider_selection: Color::Rgb(139, 233, 253),
|
|
||||||
mode_help: Color::Rgb(189, 147, 249),
|
|
||||||
mode_visual: Color::Rgb(255, 121, 198),
|
|
||||||
mode_command: Color::Rgb(241, 250, 140),
|
|
||||||
selection_bg: Color::Rgb(68, 71, 90),
|
|
||||||
selection_fg: Color::Rgb(248, 248, 242),
|
|
||||||
cursor: Color::Rgb(255, 121, 198),
|
|
||||||
placeholder: Color::Rgb(98, 114, 164),
|
|
||||||
error: Color::Rgb(255, 85, 85), // #ff5555
|
|
||||||
info: Color::Rgb(80, 250, 123),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Solarized Dark theme
|
|
||||||
fn solarized() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "solarized".to_string(),
|
|
||||||
text: Color::Rgb(131, 148, 150), // #839496 (base0)
|
|
||||||
background: Color::Rgb(0, 43, 54), // #002b36 (base03)
|
|
||||||
focused_panel_border: Color::Rgb(38, 139, 210), // #268bd2 (blue)
|
|
||||||
unfocused_panel_border: Color::Rgb(7, 54, 66), // #073642 (base02)
|
|
||||||
user_message_role: Color::Rgb(42, 161, 152), // #2aa198 (cyan)
|
|
||||||
assistant_message_role: Color::Rgb(203, 75, 22), // #cb4b16 (orange)
|
|
||||||
thinking_panel_title: Color::Rgb(108, 113, 196), // #6c71c4 (violet)
|
|
||||||
command_bar_background: Color::Rgb(7, 54, 66),
|
|
||||||
status_background: Color::Rgb(7, 54, 66),
|
|
||||||
mode_normal: Color::Rgb(38, 139, 210), // blue
|
|
||||||
mode_editing: Color::Rgb(133, 153, 0), // #859900 (green)
|
|
||||||
mode_model_selection: Color::Rgb(181, 137, 0), // #b58900 (yellow)
|
|
||||||
mode_provider_selection: Color::Rgb(42, 161, 152), // cyan
|
|
||||||
mode_help: Color::Rgb(108, 113, 196), // violet
|
|
||||||
mode_visual: Color::Rgb(211, 54, 130), // #d33682 (magenta)
|
|
||||||
mode_command: Color::Rgb(181, 137, 0), // yellow
|
|
||||||
selection_bg: Color::Rgb(7, 54, 66),
|
|
||||||
selection_fg: Color::Rgb(147, 161, 161),
|
|
||||||
cursor: Color::Rgb(211, 54, 130),
|
|
||||||
placeholder: Color::Rgb(88, 110, 117),
|
|
||||||
error: Color::Rgb(220, 50, 47), // #dc322f (red)
|
|
||||||
info: Color::Rgb(133, 153, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Midnight Ocean theme
|
|
||||||
fn midnight_ocean() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "midnight-ocean".to_string(),
|
|
||||||
text: Color::Rgb(192, 202, 245),
|
|
||||||
background: Color::Rgb(13, 17, 23),
|
|
||||||
focused_panel_border: Color::Rgb(88, 166, 255),
|
|
||||||
unfocused_panel_border: Color::Rgb(48, 54, 61),
|
|
||||||
user_message_role: Color::Rgb(121, 192, 255),
|
|
||||||
assistant_message_role: Color::Rgb(137, 221, 255),
|
|
||||||
thinking_panel_title: Color::Rgb(158, 206, 106),
|
|
||||||
command_bar_background: Color::Rgb(22, 27, 34),
|
|
||||||
status_background: Color::Rgb(22, 27, 34),
|
|
||||||
mode_normal: Color::Rgb(121, 192, 255),
|
|
||||||
mode_editing: Color::Rgb(158, 206, 106),
|
|
||||||
mode_model_selection: Color::Rgb(255, 212, 59),
|
|
||||||
mode_provider_selection: Color::Rgb(137, 221, 255),
|
|
||||||
mode_help: Color::Rgb(255, 115, 157),
|
|
||||||
mode_visual: Color::Rgb(246, 140, 245),
|
|
||||||
mode_command: Color::Rgb(255, 212, 59),
|
|
||||||
selection_bg: Color::Rgb(56, 139, 253),
|
|
||||||
selection_fg: Color::Rgb(13, 17, 23),
|
|
||||||
cursor: Color::Rgb(246, 140, 245),
|
|
||||||
placeholder: Color::Rgb(110, 118, 129),
|
|
||||||
error: Color::Rgb(248, 81, 73),
|
|
||||||
info: Color::Rgb(158, 206, 106),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rose Pine theme
|
|
||||||
fn rose_pine() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "rose-pine".to_string(),
|
|
||||||
text: Color::Rgb(224, 222, 244), // #e0def4
|
|
||||||
background: Color::Rgb(25, 23, 36), // #191724
|
|
||||||
focused_panel_border: Color::Rgb(235, 111, 146), // #eb6f92 (love)
|
|
||||||
unfocused_panel_border: Color::Rgb(38, 35, 58), // #26233a
|
|
||||||
user_message_role: Color::Rgb(49, 116, 143), // #31748f (foam)
|
|
||||||
assistant_message_role: Color::Rgb(156, 207, 216), // #9ccfd8 (foam light)
|
|
||||||
thinking_panel_title: Color::Rgb(196, 167, 231), // #c4a7e7 (iris)
|
|
||||||
command_bar_background: Color::Rgb(38, 35, 58),
|
|
||||||
status_background: Color::Rgb(38, 35, 58),
|
|
||||||
mode_normal: Color::Rgb(156, 207, 216),
|
|
||||||
mode_editing: Color::Rgb(235, 188, 186), // #ebbcba (rose)
|
|
||||||
mode_model_selection: Color::Rgb(246, 193, 119),
|
|
||||||
mode_provider_selection: Color::Rgb(49, 116, 143),
|
|
||||||
mode_help: Color::Rgb(196, 167, 231),
|
|
||||||
mode_visual: Color::Rgb(235, 111, 146),
|
|
||||||
mode_command: Color::Rgb(246, 193, 119),
|
|
||||||
selection_bg: Color::Rgb(64, 61, 82),
|
|
||||||
selection_fg: Color::Rgb(224, 222, 244),
|
|
||||||
cursor: Color::Rgb(235, 111, 146),
|
|
||||||
placeholder: Color::Rgb(110, 106, 134),
|
|
||||||
error: Color::Rgb(235, 111, 146),
|
|
||||||
info: Color::Rgb(156, 207, 216),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Monokai theme
|
|
||||||
fn monokai() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "monokai".to_string(),
|
|
||||||
text: Color::Rgb(248, 248, 242), // #f8f8f2
|
|
||||||
background: Color::Rgb(39, 40, 34), // #272822
|
|
||||||
focused_panel_border: Color::Rgb(249, 38, 114), // #f92672 (pink)
|
|
||||||
unfocused_panel_border: Color::Rgb(117, 113, 94), // #75715e
|
|
||||||
user_message_role: Color::Rgb(102, 217, 239), // #66d9ef (cyan)
|
|
||||||
assistant_message_role: Color::Rgb(174, 129, 255), // #ae81ff (purple)
|
|
||||||
thinking_panel_title: Color::Rgb(230, 219, 116), // #e6db74 (yellow)
|
|
||||||
command_bar_background: Color::Rgb(39, 40, 34),
|
|
||||||
status_background: Color::Rgb(39, 40, 34),
|
|
||||||
mode_normal: Color::Rgb(102, 217, 239),
|
|
||||||
mode_editing: Color::Rgb(166, 226, 46), // #a6e22e (green)
|
|
||||||
mode_model_selection: Color::Rgb(230, 219, 116),
|
|
||||||
mode_provider_selection: Color::Rgb(102, 217, 239),
|
|
||||||
mode_help: Color::Rgb(174, 129, 255),
|
|
||||||
mode_visual: Color::Rgb(249, 38, 114),
|
|
||||||
mode_command: Color::Rgb(230, 219, 116),
|
|
||||||
selection_bg: Color::Rgb(117, 113, 94),
|
|
||||||
selection_fg: Color::Rgb(248, 248, 242),
|
|
||||||
cursor: Color::Rgb(249, 38, 114),
|
|
||||||
placeholder: Color::Rgb(117, 113, 94),
|
|
||||||
error: Color::Rgb(249, 38, 114),
|
|
||||||
info: Color::Rgb(166, 226, 46),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Material Dark theme
|
|
||||||
fn material_dark() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "material-dark".to_string(),
|
|
||||||
text: Color::Rgb(238, 255, 255), // #eeffff
|
|
||||||
background: Color::Rgb(38, 50, 56), // #263238
|
|
||||||
focused_panel_border: Color::Rgb(128, 203, 196), // #80cbc4 (cyan)
|
|
||||||
unfocused_panel_border: Color::Rgb(84, 110, 122), // #546e7a
|
|
||||||
user_message_role: Color::Rgb(130, 170, 255), // #82aaff (blue)
|
|
||||||
assistant_message_role: Color::Rgb(199, 146, 234), // #c792ea (purple)
|
|
||||||
thinking_panel_title: Color::Rgb(255, 203, 107), // #ffcb6b (yellow)
|
|
||||||
command_bar_background: Color::Rgb(33, 43, 48),
|
|
||||||
status_background: Color::Rgb(33, 43, 48),
|
|
||||||
mode_normal: Color::Rgb(130, 170, 255),
|
|
||||||
mode_editing: Color::Rgb(195, 232, 141), // #c3e88d (green)
|
|
||||||
mode_model_selection: Color::Rgb(255, 203, 107),
|
|
||||||
mode_provider_selection: Color::Rgb(128, 203, 196),
|
|
||||||
mode_help: Color::Rgb(199, 146, 234),
|
|
||||||
mode_visual: Color::Rgb(240, 113, 120), // #f07178 (red)
|
|
||||||
mode_command: Color::Rgb(255, 203, 107),
|
|
||||||
selection_bg: Color::Rgb(84, 110, 122),
|
|
||||||
selection_fg: Color::Rgb(238, 255, 255),
|
|
||||||
cursor: Color::Rgb(255, 204, 0),
|
|
||||||
placeholder: Color::Rgb(84, 110, 122),
|
|
||||||
error: Color::Rgb(240, 113, 120),
|
|
||||||
info: Color::Rgb(195, 232, 141),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Material Light theme
|
|
||||||
fn material_light() -> Theme {
|
|
||||||
Theme {
|
|
||||||
name: "material-light".to_string(),
|
|
||||||
text: Color::Rgb(33, 33, 33),
|
|
||||||
background: Color::Rgb(236, 239, 241),
|
|
||||||
focused_panel_border: Color::Rgb(0, 150, 136),
|
|
||||||
unfocused_panel_border: Color::Rgb(176, 190, 197),
|
|
||||||
user_message_role: Color::Rgb(68, 138, 255),
|
|
||||||
assistant_message_role: Color::Rgb(124, 77, 255),
|
|
||||||
thinking_panel_title: Color::Rgb(245, 124, 0),
|
|
||||||
command_bar_background: Color::Rgb(255, 255, 255),
|
|
||||||
status_background: Color::Rgb(255, 255, 255),
|
|
||||||
mode_normal: Color::Rgb(68, 138, 255),
|
|
||||||
mode_editing: Color::Rgb(56, 142, 60),
|
|
||||||
mode_model_selection: Color::Rgb(245, 124, 0),
|
|
||||||
mode_provider_selection: Color::Rgb(0, 150, 136),
|
|
||||||
mode_help: Color::Rgb(124, 77, 255),
|
|
||||||
mode_visual: Color::Rgb(211, 47, 47),
|
|
||||||
mode_command: Color::Rgb(245, 124, 0),
|
|
||||||
selection_bg: Color::Rgb(176, 190, 197),
|
|
||||||
selection_fg: Color::Rgb(33, 33, 33),
|
|
||||||
cursor: Color::Rgb(194, 24, 91),
|
|
||||||
placeholder: Color::Rgb(144, 164, 174),
|
|
||||||
error: Color::Rgb(211, 47, 47),
|
|
||||||
info: Color::Rgb(56, 142, 60),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for color serialization/deserialization
|
|
||||||
|
|
||||||
fn deserialize_color<'de, D>(deserializer: D) -> Result<Color, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let s = String::deserialize(deserializer)?;
|
|
||||||
parse_color(&s).map_err(serde::de::Error::custom)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_color<S>(color: &Color, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: serde::Serializer,
|
|
||||||
{
|
|
||||||
let s = color_to_string(color);
|
|
||||||
serializer.serialize_str(&s)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_color(s: &str) -> Result<Color, String> {
|
|
||||||
if let Some(hex) = s.strip_prefix('#') {
|
|
||||||
if hex.len() == 6 {
|
|
||||||
let r = u8::from_str_radix(&hex[0..2], 16)
|
|
||||||
.map_err(|_| format!("Invalid hex color: {}", s))?;
|
|
||||||
let g = u8::from_str_radix(&hex[2..4], 16)
|
|
||||||
.map_err(|_| format!("Invalid hex color: {}", s))?;
|
|
||||||
let b = u8::from_str_radix(&hex[4..6], 16)
|
|
||||||
.map_err(|_| format!("Invalid hex color: {}", s))?;
|
|
||||||
return Ok(Color::Rgb(r, g, b));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try named colors
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"black" => Ok(Color::Black),
|
|
||||||
"red" => Ok(Color::Red),
|
|
||||||
"green" => Ok(Color::Green),
|
|
||||||
"yellow" => Ok(Color::Yellow),
|
|
||||||
"blue" => Ok(Color::Blue),
|
|
||||||
"magenta" => Ok(Color::Magenta),
|
|
||||||
"cyan" => Ok(Color::Cyan),
|
|
||||||
"gray" | "grey" => Ok(Color::Gray),
|
|
||||||
"darkgray" | "darkgrey" => Ok(Color::DarkGray),
|
|
||||||
"lightred" => Ok(Color::LightRed),
|
|
||||||
"lightgreen" => Ok(Color::LightGreen),
|
|
||||||
"lightyellow" => Ok(Color::LightYellow),
|
|
||||||
"lightblue" => Ok(Color::LightBlue),
|
|
||||||
"lightmagenta" => Ok(Color::LightMagenta),
|
|
||||||
"lightcyan" => Ok(Color::LightCyan),
|
|
||||||
"white" => Ok(Color::White),
|
|
||||||
_ => Err(format!("Unknown color: {}", s)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn color_to_string(color: &Color) -> String {
|
|
||||||
match color {
|
|
||||||
Color::Black => "black".to_string(),
|
|
||||||
Color::Red => "red".to_string(),
|
|
||||||
Color::Green => "green".to_string(),
|
|
||||||
Color::Yellow => "yellow".to_string(),
|
|
||||||
Color::Blue => "blue".to_string(),
|
|
||||||
Color::Magenta => "magenta".to_string(),
|
|
||||||
Color::Cyan => "cyan".to_string(),
|
|
||||||
Color::Gray => "gray".to_string(),
|
|
||||||
Color::DarkGray => "darkgray".to_string(),
|
|
||||||
Color::LightRed => "lightred".to_string(),
|
|
||||||
Color::LightGreen => "lightgreen".to_string(),
|
|
||||||
Color::LightYellow => "lightyellow".to_string(),
|
|
||||||
Color::LightBlue => "lightblue".to_string(),
|
|
||||||
Color::LightMagenta => "lightmagenta".to_string(),
|
|
||||||
Color::LightCyan => "lightcyan".to_string(),
|
|
||||||
Color::White => "white".to_string(),
|
|
||||||
Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
|
|
||||||
_ => "#ffffff".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_color_parsing() {
|
|
||||||
assert!(matches!(parse_color("#ff0000"), Ok(Color::Rgb(255, 0, 0))));
|
|
||||||
assert!(matches!(parse_color("red"), Ok(Color::Red)));
|
|
||||||
assert!(matches!(parse_color("lightblue"), Ok(Color::LightBlue)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_built_in_themes() {
|
|
||||||
let themes = built_in_themes();
|
|
||||||
assert!(themes.contains_key("default_dark"));
|
|
||||||
assert!(themes.contains_key("gruvbox"));
|
|
||||||
assert!(themes.contains_key("dracula"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
//! Core types used across OWLEN
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fmt;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// A message in a conversation
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Message {
|
|
||||||
/// Unique identifier for this message
|
|
||||||
pub id: Uuid,
|
|
||||||
/// Role of the message sender (user, assistant, system)
|
|
||||||
pub role: Role,
|
|
||||||
/// Content of the message
|
|
||||||
pub content: String,
|
|
||||||
/// Optional metadata
|
|
||||||
pub metadata: HashMap<String, serde_json::Value>,
|
|
||||||
/// Timestamp when the message was created
|
|
||||||
pub timestamp: std::time::SystemTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Role of a message sender
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum Role {
|
|
||||||
/// Message from the user
|
|
||||||
User,
|
|
||||||
/// Message from the AI assistant
|
|
||||||
Assistant,
|
|
||||||
/// System message (prompts, context, etc.)
|
|
||||||
System,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Role {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let label = match self {
|
|
||||||
Role::User => "user",
|
|
||||||
Role::Assistant => "assistant",
|
|
||||||
Role::System => "system",
|
|
||||||
};
|
|
||||||
f.write_str(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A conversation containing multiple messages
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Conversation {
|
|
||||||
/// Unique identifier for this conversation
|
|
||||||
pub id: Uuid,
|
|
||||||
/// Optional name/title for the conversation
|
|
||||||
pub name: Option<String>,
|
|
||||||
/// Optional AI-generated description of the conversation
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Messages in chronological order
|
|
||||||
pub messages: Vec<Message>,
|
|
||||||
/// Model used for this conversation
|
|
||||||
pub model: String,
|
|
||||||
/// When the conversation was created
|
|
||||||
pub created_at: std::time::SystemTime,
|
|
||||||
/// When the conversation was last updated
|
|
||||||
pub updated_at: std::time::SystemTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for a chat completion request
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChatRequest {
|
|
||||||
/// The model to use for completion
|
|
||||||
pub model: String,
|
|
||||||
/// The conversation messages
|
|
||||||
pub messages: Vec<Message>,
|
|
||||||
/// Optional parameters for the request
|
|
||||||
pub parameters: ChatParameters,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters for chat completion
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct ChatParameters {
|
|
||||||
/// Temperature for randomness (0.0 to 2.0)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub temperature: Option<f32>,
|
|
||||||
/// Maximum tokens to generate
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_tokens: Option<u32>,
|
|
||||||
/// Whether to stream the response
|
|
||||||
#[serde(default)]
|
|
||||||
pub stream: bool,
|
|
||||||
/// Additional provider-specific parameters
|
|
||||||
#[serde(flatten)]
|
|
||||||
#[serde(default)]
|
|
||||||
pub extra: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response from a chat completion request
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ChatResponse {
|
|
||||||
/// The generated message
|
|
||||||
pub message: Message,
|
|
||||||
/// Token usage information
|
|
||||||
pub usage: Option<TokenUsage>,
|
|
||||||
/// Whether this is a streaming chunk
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_streaming: bool,
|
|
||||||
/// Whether this is the final chunk in a stream
|
|
||||||
#[serde(default)]
|
|
||||||
pub is_final: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Token usage information
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct TokenUsage {
|
|
||||||
/// Tokens in the prompt
|
|
||||||
pub prompt_tokens: u32,
|
|
||||||
/// Tokens in the completion
|
|
||||||
pub completion_tokens: u32,
|
|
||||||
/// Total tokens used
|
|
||||||
pub total_tokens: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Information about an available model
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ModelInfo {
|
|
||||||
/// Model identifier
|
|
||||||
pub id: String,
|
|
||||||
/// Human-readable name
|
|
||||||
pub name: String,
|
|
||||||
/// Model description
|
|
||||||
pub description: Option<String>,
|
|
||||||
/// Provider that hosts this model
|
|
||||||
pub provider: String,
|
|
||||||
/// Context window size
|
|
||||||
pub context_window: Option<u32>,
|
|
||||||
/// Additional capabilities
|
|
||||||
pub capabilities: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
/// Create a new message
|
|
||||||
pub fn new(role: Role, content: String) -> Self {
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
role,
|
|
||||||
content,
|
|
||||||
metadata: HashMap::new(),
|
|
||||||
timestamp: std::time::SystemTime::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a user message
|
|
||||||
pub fn user(content: String) -> Self {
|
|
||||||
Self::new(Role::User, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an assistant message
|
|
||||||
pub fn assistant(content: String) -> Self {
|
|
||||||
Self::new(Role::Assistant, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a system message
|
|
||||||
pub fn system(content: String) -> Self {
|
|
||||||
Self::new(Role::System, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Conversation {
|
|
||||||
/// Create a new conversation
|
|
||||||
pub fn new(model: String) -> Self {
|
|
||||||
let now = std::time::SystemTime::now();
|
|
||||||
Self {
|
|
||||||
id: Uuid::new_v4(),
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
messages: Vec::new(),
|
|
||||||
model,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a message to the conversation
|
|
||||||
pub fn add_message(&mut self, message: Message) {
|
|
||||||
self.messages.push(message);
|
|
||||||
self.updated_at = std::time::SystemTime::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the last message in the conversation
|
|
||||||
pub fn last_message(&self) -> Option<&Message> {
|
|
||||||
self.messages.last()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all messages
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.messages.clear();
|
|
||||||
self.updated_at = std::time::SystemTime::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
//! Shared UI components and state management for TUI applications
|
|
||||||
//!
|
|
||||||
//! This module contains reusable UI components that can be shared between
|
|
||||||
//! different TUI applications (chat, code, etc.)
|
|
||||||
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Application state
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AppState {
|
|
||||||
Running,
|
|
||||||
Quit,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Input modes for TUI applications
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum InputMode {
|
|
||||||
Normal,
|
|
||||||
Editing,
|
|
||||||
ProviderSelection,
|
|
||||||
ModelSelection,
|
|
||||||
Help,
|
|
||||||
Visual,
|
|
||||||
Command,
|
|
||||||
SessionBrowser,
|
|
||||||
ThemeBrowser,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for InputMode {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let label = match self {
|
|
||||||
InputMode::Normal => "Normal",
|
|
||||||
InputMode::Editing => "Editing",
|
|
||||||
InputMode::ModelSelection => "Model",
|
|
||||||
InputMode::ProviderSelection => "Provider",
|
|
||||||
InputMode::Help => "Help",
|
|
||||||
InputMode::Visual => "Visual",
|
|
||||||
InputMode::Command => "Command",
|
|
||||||
InputMode::SessionBrowser => "Sessions",
|
|
||||||
InputMode::ThemeBrowser => "Themes",
|
|
||||||
};
|
|
||||||
f.write_str(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents which panel is currently focused
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum FocusedPanel {
|
|
||||||
Chat,
|
|
||||||
Thinking,
|
|
||||||
Input,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Auto-scroll state manager for scrollable panels
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct AutoScroll {
|
|
||||||
pub scroll: usize,
|
|
||||||
pub content_len: usize,
|
|
||||||
pub stick_to_bottom: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AutoScroll {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
scroll: 0,
|
|
||||||
content_len: 0,
|
|
||||||
stick_to_bottom: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AutoScroll {
|
|
||||||
/// Update scroll position based on viewport height
|
|
||||||
pub fn on_viewport(&mut self, viewport_h: usize) {
|
|
||||||
let max = self.content_len.saturating_sub(viewport_h);
|
|
||||||
if self.stick_to_bottom {
|
|
||||||
self.scroll = max;
|
|
||||||
} else {
|
|
||||||
self.scroll = self.scroll.min(max);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle user scroll input
|
|
||||||
pub fn on_user_scroll(&mut self, delta: isize, viewport_h: usize) {
|
|
||||||
let max = self.content_len.saturating_sub(viewport_h) as isize;
|
|
||||||
let s = (self.scroll as isize + delta).clamp(0, max) as usize;
|
|
||||||
self.scroll = s;
|
|
||||||
self.stick_to_bottom = s as isize == max;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll down half page
|
|
||||||
pub fn scroll_half_page_down(&mut self, viewport_h: usize) {
|
|
||||||
let delta = (viewport_h / 2) as isize;
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll up half page
|
|
||||||
pub fn scroll_half_page_up(&mut self, viewport_h: usize) {
|
|
||||||
let delta = -((viewport_h / 2) as isize);
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll down full page
|
|
||||||
pub fn scroll_full_page_down(&mut self, viewport_h: usize) {
|
|
||||||
let delta = viewport_h as isize;
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scroll up full page
|
|
||||||
pub fn scroll_full_page_up(&mut self, viewport_h: usize) {
|
|
||||||
let delta = -(viewport_h as isize);
|
|
||||||
self.on_user_scroll(delta, viewport_h);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump to top
|
|
||||||
pub fn jump_to_top(&mut self) {
|
|
||||||
self.scroll = 0;
|
|
||||||
self.stick_to_bottom = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Jump to bottom
|
|
||||||
pub fn jump_to_bottom(&mut self, viewport_h: usize) {
|
|
||||||
self.stick_to_bottom = true;
|
|
||||||
self.on_viewport(viewport_h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Visual selection state for text selection
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct VisualSelection {
|
|
||||||
pub start: Option<(usize, usize)>, // (row, col)
|
|
||||||
pub end: Option<(usize, usize)>, // (row, col)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl VisualSelection {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_at(&mut self, pos: (usize, usize)) {
|
|
||||||
self.start = Some(pos);
|
|
||||||
self.end = Some(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extend_to(&mut self, pos: (usize, usize)) {
|
|
||||||
self.end = Some(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
self.start = None;
|
|
||||||
self.end = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_active(&self) -> bool {
|
|
||||||
self.start.is_some() && self.end.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_normalized(&self) -> Option<((usize, usize), (usize, usize))> {
|
|
||||||
if let (Some(s), Some(e)) = (self.start, self.end) {
|
|
||||||
// Normalize selection so start is always before end
|
|
||||||
if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) {
|
|
||||||
Some((s, e))
|
|
||||||
} else {
|
|
||||||
Some((e, s))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract text from a selection range in a list of lines
|
|
||||||
pub fn extract_text_from_selection(
|
|
||||||
lines: &[String],
|
|
||||||
start: (usize, usize),
|
|
||||||
end: (usize, usize),
|
|
||||||
) -> Option<String> {
|
|
||||||
if lines.is_empty() || start.0 >= lines.len() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let start_row = start.0;
|
|
||||||
let start_col = start.1;
|
|
||||||
let end_row = end.0.min(lines.len() - 1);
|
|
||||||
let end_col = end.1;
|
|
||||||
|
|
||||||
if start_row == end_row {
|
|
||||||
// Single line selection
|
|
||||||
let line = &lines[start_row];
|
|
||||||
let chars: Vec<char> = line.chars().collect();
|
|
||||||
let start_c = start_col.min(chars.len());
|
|
||||||
let end_c = end_col.min(chars.len());
|
|
||||||
|
|
||||||
if start_c >= end_c {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected: String = chars[start_c..end_c].iter().collect();
|
|
||||||
Some(selected)
|
|
||||||
} else {
|
|
||||||
// Multi-line selection
|
|
||||||
let mut result = Vec::new();
|
|
||||||
|
|
||||||
// First line: from start_col to end
|
|
||||||
let first_line = &lines[start_row];
|
|
||||||
let first_chars: Vec<char> = first_line.chars().collect();
|
|
||||||
let start_c = start_col.min(first_chars.len());
|
|
||||||
if start_c < first_chars.len() {
|
|
||||||
result.push(first_chars[start_c..].iter().collect::<String>());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middle lines: entire lines
|
|
||||||
for row in (start_row + 1)..end_row {
|
|
||||||
if row < lines.len() {
|
|
||||||
result.push(lines[row].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last line: from start to end_col
|
|
||||||
if end_row < lines.len() && end_row > start_row {
|
|
||||||
let last_line = &lines[end_row];
|
|
||||||
let last_chars: Vec<char> = last_line.chars().collect();
|
|
||||||
let end_c = end_col.min(last_chars.len());
|
|
||||||
if end_c > 0 {
|
|
||||||
result.push(last_chars[..end_c].iter().collect::<String>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(result.join("\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cursor position for navigating scrollable content
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
pub struct CursorPosition {
|
|
||||||
pub row: usize,
|
|
||||||
pub col: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CursorPosition {
|
|
||||||
pub fn new(row: usize, col: usize) -> Self {
|
|
||||||
Self { row, col }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_up(&mut self, amount: usize) {
|
|
||||||
self.row = self.row.saturating_sub(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_down(&mut self, amount: usize, max: usize) {
|
|
||||||
self.row = (self.row + amount).min(max);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_left(&mut self, amount: usize) {
|
|
||||||
self.col = self.col.saturating_sub(amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_right(&mut self, amount: usize, max: usize) {
|
|
||||||
self.col = (self.col + amount).min(max);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_tuple(&self) -> (usize, usize) {
|
|
||||||
(self.row, self.col)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Word boundary detection for navigation
|
|
||||||
pub fn find_next_word_boundary(line: &str, col: usize) -> Option<usize> {
|
|
||||||
let chars: Vec<char> = line.chars().collect();
|
|
||||||
|
|
||||||
if col >= chars.len() {
|
|
||||||
return Some(chars.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pos = col;
|
|
||||||
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
|
||||||
|
|
||||||
// Skip current word
|
|
||||||
if is_word_char(chars[pos]) {
|
|
||||||
while pos < chars.len() && is_word_char(chars[pos]) {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Skip non-word characters
|
|
||||||
while pos < chars.len() && !is_word_char(chars[pos]) {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_word_end(line: &str, col: usize) -> Option<usize> {
|
|
||||||
let chars: Vec<char> = line.chars().collect();
|
|
||||||
|
|
||||||
if col >= chars.len() {
|
|
||||||
return Some(chars.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pos = col;
|
|
||||||
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
|
||||||
|
|
||||||
// If on a word character, move to end of current word
|
|
||||||
if is_word_char(chars[pos]) {
|
|
||||||
while pos < chars.len() && is_word_char(chars[pos]) {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
// Move back one to be ON the last character
|
|
||||||
pos = pos.saturating_sub(1);
|
|
||||||
} else {
|
|
||||||
// Skip non-word characters
|
|
||||||
while pos < chars.len() && !is_word_char(chars[pos]) {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
// Now on first char of next word, move to its end
|
|
||||||
while pos < chars.len() && is_word_char(chars[pos]) {
|
|
||||||
pos += 1;
|
|
||||||
}
|
|
||||||
pos = pos.saturating_sub(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
|
|
||||||
let chars: Vec<char> = line.chars().collect();
|
|
||||||
|
|
||||||
if col == 0 || chars.is_empty() {
|
|
||||||
return Some(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut pos = col.min(chars.len());
|
|
||||||
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
|
||||||
|
|
||||||
// Move back one position first
|
|
||||||
pos = pos.saturating_sub(1);
|
|
||||||
|
|
||||||
// Skip non-word characters
|
|
||||||
while pos > 0 && !is_word_char(chars[pos]) {
|
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip word characters to find start of word
|
|
||||||
while pos > 0 && is_word_char(chars[pos - 1]) {
|
|
||||||
pos -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_auto_scroll() {
|
|
||||||
let mut scroll = AutoScroll::default();
|
|
||||||
scroll.content_len = 100;
|
|
||||||
|
|
||||||
// Test on_viewport with stick_to_bottom
|
|
||||||
scroll.on_viewport(10);
|
|
||||||
assert_eq!(scroll.scroll, 90);
|
|
||||||
|
|
||||||
// Test user scroll up
|
|
||||||
scroll.on_user_scroll(-10, 10);
|
|
||||||
assert_eq!(scroll.scroll, 80);
|
|
||||||
assert!(!scroll.stick_to_bottom);
|
|
||||||
|
|
||||||
// Test jump to bottom
|
|
||||||
scroll.jump_to_bottom(10);
|
|
||||||
assert!(scroll.stick_to_bottom);
|
|
||||||
assert_eq!(scroll.scroll, 90);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_visual_selection() {
|
|
||||||
let mut selection = VisualSelection::new();
|
|
||||||
assert!(!selection.is_active());
|
|
||||||
|
|
||||||
selection.start_at((0, 0));
|
|
||||||
assert!(selection.is_active());
|
|
||||||
|
|
||||||
selection.extend_to((2, 5));
|
|
||||||
let normalized = selection.get_normalized();
|
|
||||||
assert_eq!(normalized, Some(((0, 0), (2, 5))));
|
|
||||||
|
|
||||||
selection.clear();
|
|
||||||
assert!(!selection.is_active());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_text_single_line() {
|
|
||||||
let lines = vec!["Hello World".to_string()];
|
|
||||||
let result = extract_text_from_selection(&lines, (0, 0), (0, 5));
|
|
||||||
assert_eq!(result, Some("Hello".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_text_multi_line() {
|
|
||||||
let lines = vec![
|
|
||||||
"First line".to_string(),
|
|
||||||
"Second line".to_string(),
|
|
||||||
"Third line".to_string(),
|
|
||||||
];
|
|
||||||
let result = extract_text_from_selection(&lines, (0, 6), (2, 5));
|
|
||||||
assert_eq!(result, Some("line\nSecond line\nThird".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_word_boundaries() {
|
|
||||||
let line = "hello world test";
|
|
||||||
assert_eq!(find_next_word_boundary(line, 0), Some(5));
|
|
||||||
assert_eq!(find_next_word_boundary(line, 5), Some(6));
|
|
||||||
assert_eq!(find_next_word_boundary(line, 6), Some(11));
|
|
||||||
|
|
||||||
assert_eq!(find_prev_word_boundary(line, 16), Some(12));
|
|
||||||
assert_eq!(find_prev_word_boundary(line, 11), Some(6));
|
|
||||||
assert_eq!(find_prev_word_boundary(line, 6), Some(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#![allow(clippy::cast_possible_truncation)]
|
|
||||||
|
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct ScreenPos {
|
|
||||||
pub row: u16,
|
|
||||||
pub col: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_cursor_map(text: &str, width: u16) -> Vec<ScreenPos> {
|
|
||||||
assert!(width > 0);
|
|
||||||
let width = width as usize;
|
|
||||||
let mut pos_map = vec![ScreenPos { row: 0, col: 0 }; text.len() + 1];
|
|
||||||
let mut row = 0;
|
|
||||||
let mut col = 0;
|
|
||||||
|
|
||||||
let mut word_start_idx = 0;
|
|
||||||
let mut word_start_col = 0;
|
|
||||||
|
|
||||||
for (byte_offset, grapheme) in text.grapheme_indices(true) {
|
|
||||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
|
||||||
|
|
||||||
if grapheme == "\n" {
|
|
||||||
row += 1;
|
|
||||||
col = 0;
|
|
||||||
word_start_col = 0;
|
|
||||||
word_start_idx = byte_offset + grapheme.len();
|
|
||||||
// Set position for the end of this grapheme and any intermediate bytes
|
|
||||||
let end_pos = ScreenPos {
|
|
||||||
row: row as u16,
|
|
||||||
col: col as u16,
|
|
||||||
};
|
|
||||||
for i in 1..=grapheme.len() {
|
|
||||||
if byte_offset + i < pos_map.len() {
|
|
||||||
pos_map[byte_offset + i] = end_pos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if grapheme.chars().all(char::is_whitespace) {
|
|
||||||
if col + grapheme_width > width {
|
|
||||||
// Whitespace causes wrap
|
|
||||||
row += 1;
|
|
||||||
col = 1; // Position after wrapping space
|
|
||||||
word_start_col = 1;
|
|
||||||
word_start_idx = byte_offset + grapheme.len();
|
|
||||||
} else {
|
|
||||||
col += grapheme_width;
|
|
||||||
word_start_col = col;
|
|
||||||
word_start_idx = byte_offset + grapheme.len();
|
|
||||||
}
|
|
||||||
} else if col + grapheme_width > width {
|
|
||||||
if word_start_col > 0 && byte_offset == word_start_idx {
|
|
||||||
// This is the first character of a new word that won't fit, wrap it
|
|
||||||
row += 1;
|
|
||||||
col = grapheme_width;
|
|
||||||
} else if word_start_col == 0 {
|
|
||||||
// No previous word boundary, hard break
|
|
||||||
row += 1;
|
|
||||||
col = grapheme_width;
|
|
||||||
} else {
|
|
||||||
// This is part of a word already on the line, let it extend beyond width
|
|
||||||
col += grapheme_width;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
col += grapheme_width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set position for the end of this grapheme and any intermediate bytes
|
|
||||||
let end_pos = ScreenPos {
|
|
||||||
row: row as u16,
|
|
||||||
col: col as u16,
|
|
||||||
};
|
|
||||||
for i in 1..=grapheme.len() {
|
|
||||||
if byte_offset + i < pos_map.len() {
|
|
||||||
pos_map[byte_offset + i] = end_pos;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pos_map
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn byte_to_screen_pos(text: &str, byte_idx: usize, width: u16) -> ScreenPos {
|
|
||||||
let pos_map = build_cursor_map(text, width);
|
|
||||||
pos_map[byte_idx.min(text.len())]
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
use owlen_core::wrap_cursor::build_cursor_map;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn debug_long_word_wrapping() {
|
|
||||||
// Test the exact scenario from the user's issue
|
|
||||||
let text = "asdnklasdnaklsdnkalsdnaskldaskldnaskldnaskldnaskldnaskldnaskldnaskld asdnklska dnskadl dasnksdl asdn";
|
|
||||||
let width = 50; // Approximate width from the user's example
|
|
||||||
|
|
||||||
println!("Testing long word text with width {}", width);
|
|
||||||
println!("Text: '{}'", text);
|
|
||||||
|
|
||||||
// Check what the cursor map shows
|
|
||||||
let cursor_map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
println!("\nCursor map for key positions:");
|
|
||||||
let long_word_end = text.find(' ').unwrap_or(text.len());
|
|
||||||
for i in [
|
|
||||||
0,
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50,
|
|
||||||
60,
|
|
||||||
70,
|
|
||||||
long_word_end,
|
|
||||||
long_word_end + 1,
|
|
||||||
text.len(),
|
|
||||||
] {
|
|
||||||
if i <= text.len() {
|
|
||||||
let pos = cursor_map[i];
|
|
||||||
let char_at = if i < text.len() {
|
|
||||||
format!("'{}'", text.chars().nth(i).unwrap_or('?'))
|
|
||||||
} else {
|
|
||||||
"END".to_string()
|
|
||||||
};
|
|
||||||
println!(
|
|
||||||
" Byte {}: {} -> row {}, col {}",
|
|
||||||
i, char_at, pos.row, pos.col
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test what my formatting function produces
|
|
||||||
let lines = format_text_with_word_wrap_debug(text, width);
|
|
||||||
|
|
||||||
println!("\nFormatted lines:");
|
|
||||||
for (i, line) in lines.iter().enumerate() {
|
|
||||||
println!(" Line {}: '{}' (length: {})", i, line, line.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
// The long word should be broken up, not kept on one line
|
|
||||||
assert!(
|
|
||||||
lines[0].len() <= width as usize + 5,
|
|
||||||
"First line is too long: {} chars",
|
|
||||||
lines[0].len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_text_with_word_wrap_debug(text: &str, width: u16) -> Vec<String> {
|
|
||||||
if text.is_empty() {
|
|
||||||
return vec!["".to_string()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the cursor map to determine where line breaks should occur
|
|
||||||
let cursor_map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
let mut current_line = String::new();
|
|
||||||
let mut current_row = 0;
|
|
||||||
|
|
||||||
for (byte_idx, ch) in text.char_indices() {
|
|
||||||
let pos_before = if byte_idx > 0 {
|
|
||||||
cursor_map[byte_idx]
|
|
||||||
} else {
|
|
||||||
cursor_map[0]
|
|
||||||
};
|
|
||||||
let pos_after = cursor_map[byte_idx + ch.len_utf8()];
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Processing '{}' at byte {}: before=({},{}) after=({},{})",
|
|
||||||
ch, byte_idx, pos_before.row, pos_before.col, pos_after.row, pos_after.col
|
|
||||||
);
|
|
||||||
|
|
||||||
// If the row changed, we need to start a new line
|
|
||||||
if pos_after.row > current_row {
|
|
||||||
println!(
|
|
||||||
" Row changed from {} to {}! Finishing line: '{}'",
|
|
||||||
current_row, pos_after.row, current_line
|
|
||||||
);
|
|
||||||
if !current_line.is_empty() {
|
|
||||||
lines.push(current_line.clone());
|
|
||||||
current_line.clear();
|
|
||||||
}
|
|
||||||
current_row = pos_after.row;
|
|
||||||
|
|
||||||
// If this character is a space that caused the wrap, don't include it
|
|
||||||
if ch.is_whitespace() && pos_before.row < pos_after.row {
|
|
||||||
println!(" Skipping wrapping space");
|
|
||||||
continue; // Skip the wrapping space
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
current_line.push(ch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the final line
|
|
||||||
if !current_line.is_empty() {
|
|
||||||
lines.push(current_line);
|
|
||||||
} else if lines.is_empty() {
|
|
||||||
lines.push("".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#![allow(non_snake_case)]
|
|
||||||
|
|
||||||
use owlen_core::wrap_cursor::{build_cursor_map, ScreenPos};
|
|
||||||
|
|
||||||
fn assert_cursor_pos(map: &[ScreenPos], byte_idx: usize, expected: ScreenPos) {
|
|
||||||
assert_eq!(map[byte_idx], expected, "Mismatch at byte {}", byte_idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_basic_wrap_at_spaces() {
|
|
||||||
let text = "hello world";
|
|
||||||
let width = 5;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 5, ScreenPos { row: 0, col: 5 }); // after "hello"
|
|
||||||
assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 1 }); // after "hello "
|
|
||||||
assert_cursor_pos(&map, 11, ScreenPos { row: 1, col: 6 }); // after "world"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_hard_line_break() {
|
|
||||||
let text = "a\nb";
|
|
||||||
let width = 10;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "a"
|
|
||||||
assert_cursor_pos(&map, 2, ScreenPos { row: 1, col: 0 }); // after "\n"
|
|
||||||
assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "b"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_long_word_split() {
|
|
||||||
let text = "abcdefgh";
|
|
||||||
let width = 3;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 });
|
|
||||||
assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 });
|
|
||||||
assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 });
|
|
||||||
assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 });
|
|
||||||
assert_cursor_pos(&map, 5, ScreenPos { row: 1, col: 2 });
|
|
||||||
assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 3 });
|
|
||||||
assert_cursor_pos(&map, 7, ScreenPos { row: 2, col: 1 });
|
|
||||||
assert_cursor_pos(&map, 8, ScreenPos { row: 2, col: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_trailing_spaces_preserved() {
|
|
||||||
let text = "x y";
|
|
||||||
let width = 2;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "x"
|
|
||||||
assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 }); // after "x "
|
|
||||||
assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "x "
|
|
||||||
assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 2 }); // after "y"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_graphemes_emoji() {
|
|
||||||
let text = "🙂🙂a";
|
|
||||||
let width = 3;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 4, ScreenPos { row: 0, col: 2 }); // after first emoji
|
|
||||||
assert_cursor_pos(&map, 8, ScreenPos { row: 1, col: 2 }); // after second emoji
|
|
||||||
assert_cursor_pos(&map, 9, ScreenPos { row: 1, col: 3 }); // after "a"
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_graphemes_combining() {
|
|
||||||
let text = "e\u{0301}";
|
|
||||||
let width = 10;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "e"
|
|
||||||
assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 1 }); // after combining mark
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_exact_edge() {
|
|
||||||
let text = "abc def";
|
|
||||||
let width = 3;
|
|
||||||
let map = build_cursor_map(text, width);
|
|
||||||
|
|
||||||
assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 });
|
|
||||||
assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 }); // after "abc"
|
|
||||||
assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 }); // after " "
|
|
||||||
assert_cursor_pos(&map, 7, ScreenPos { row: 1, col: 4 }); // after "def"
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "owlen-ollama"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
description = "Ollama provider for OWLEN LLM client"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
owlen-core = { path = "../owlen-core" }
|
|
||||||
|
|
||||||
# HTTP client
|
|
||||||
reqwest = { workspace = true }
|
|
||||||
|
|
||||||
# Async runtime
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tokio-stream = { workspace = true }
|
|
||||||
futures = { workspace = true }
|
|
||||||
futures-util = { workspace = true }
|
|
||||||
|
|
||||||
# Serialization
|
|
||||||
serde = { workspace = true }
|
|
||||||
serde_json = { workspace = true }
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
async-trait = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio-test = { workspace = true }
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
//! Ollama provider for OWLEN LLM client
|
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
use owlen_core::{
|
|
||||||
config::GeneralSettings,
|
|
||||||
model::ModelManager,
|
|
||||||
provider::{ChatStream, Provider, ProviderConfig},
|
|
||||||
types::{ChatParameters, ChatRequest, ChatResponse, Message, ModelInfo, Role, TokenUsage},
|
|
||||||
Result,
|
|
||||||
};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
|
||||||
const DEFAULT_MODEL_CACHE_TTL_SECS: u64 = 60;
|
|
||||||
|
|
||||||
/// Ollama provider implementation with enhanced configuration and caching
|
|
||||||
pub struct OllamaProvider {
|
|
||||||
client: Client,
|
|
||||||
base_url: String,
|
|
||||||
model_manager: ModelManager,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Options for configuring the Ollama provider
|
|
||||||
pub struct OllamaOptions {
|
|
||||||
pub base_url: String,
|
|
||||||
pub request_timeout: Duration,
|
|
||||||
pub model_cache_ttl: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OllamaOptions {
|
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: base_url.into(),
|
|
||||||
request_timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
|
||||||
model_cache_ttl: Duration::from_secs(DEFAULT_MODEL_CACHE_TTL_SECS),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_general(mut self, general: &GeneralSettings) -> Self {
|
|
||||||
self.model_cache_ttl = general.model_cache_ttl();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama-specific message format
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct OllamaMessage {
|
|
||||||
role: String,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama chat request format
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct OllamaChatRequest {
|
|
||||||
model: String,
|
|
||||||
messages: Vec<OllamaMessage>,
|
|
||||||
stream: bool,
|
|
||||||
#[serde(flatten)]
|
|
||||||
options: HashMap<String, Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama chat response format
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaChatResponse {
|
|
||||||
message: Option<OllamaMessage>,
|
|
||||||
done: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
prompt_eval_count: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
eval_count: Option<u32>,
|
|
||||||
#[serde(default)]
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaErrorResponse {
|
|
||||||
error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama models list response
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaModelsResponse {
|
|
||||||
models: Vec<OllamaModelInfo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ollama model information
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaModelInfo {
|
|
||||||
name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
details: Option<OllamaModelDetails>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct OllamaModelDetails {
|
|
||||||
#[serde(default)]
|
|
||||||
family: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OllamaProvider {
|
|
||||||
/// Create a new Ollama provider with sensible defaults
|
|
||||||
pub fn new(base_url: impl Into<String>) -> Result<Self> {
|
|
||||||
Self::with_options(OllamaOptions::new(base_url))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a provider from configuration settings
|
|
||||||
pub fn from_config(config: &ProviderConfig, general: Option<&GeneralSettings>) -> Result<Self> {
|
|
||||||
let mut options = OllamaOptions::new(
|
|
||||||
config
|
|
||||||
.base_url
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| "http://localhost:11434".to_string()),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(timeout) = config
|
|
||||||
.extra
|
|
||||||
.get("timeout_secs")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
{
|
|
||||||
options.request_timeout = Duration::from_secs(timeout.max(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cache_ttl) = config
|
|
||||||
.extra
|
|
||||||
.get("model_cache_ttl_secs")
|
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
{
|
|
||||||
options.model_cache_ttl = Duration::from_secs(cache_ttl.max(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(general) = general {
|
|
||||||
options = options.with_general(general);
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::with_options(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a provider from explicit options
|
|
||||||
pub fn with_options(options: OllamaOptions) -> Result<Self> {
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(options.request_timeout)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| owlen_core::Error::Config(format!("Failed to build HTTP client: {e}")))?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
client,
|
|
||||||
base_url: options.base_url.trim_end_matches('/').to_string(),
|
|
||||||
model_manager: ModelManager::new(options.model_cache_ttl),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Accessor for the underlying model manager
|
|
||||||
pub fn model_manager(&self) -> &ModelManager {
|
|
||||||
&self.model_manager
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_message(message: &Message) -> OllamaMessage {
|
|
||||||
OllamaMessage {
|
|
||||||
role: match message.role {
|
|
||||||
Role::User => "user".to_string(),
|
|
||||||
Role::Assistant => "assistant".to_string(),
|
|
||||||
Role::System => "system".to_string(),
|
|
||||||
},
|
|
||||||
content: message.content.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn convert_ollama_message(message: &OllamaMessage) -> Message {
|
|
||||||
let role = match message.role.as_str() {
|
|
||||||
"user" => Role::User,
|
|
||||||
"assistant" => Role::Assistant,
|
|
||||||
"system" => Role::System,
|
|
||||||
_ => Role::Assistant,
|
|
||||||
};
|
|
||||||
|
|
||||||
Message::new(role, message.content.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_options(parameters: ChatParameters) -> HashMap<String, Value> {
|
|
||||||
let mut options = parameters.extra;
|
|
||||||
|
|
||||||
if let Some(temperature) = parameters.temperature {
|
|
||||||
options
|
|
||||||
.entry("temperature".to_string())
|
|
||||||
.or_insert(json!(temperature as f64));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(max_tokens) = parameters.max_tokens {
|
|
||||||
options
|
|
||||||
.entry("num_predict".to_string())
|
|
||||||
.or_insert(json!(max_tokens));
|
|
||||||
}
|
|
||||||
|
|
||||||
options
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
let url = format!("{}/api/tags", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Failed to fetch models: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let code = response.status();
|
|
||||||
let error = parse_error_body(response).await;
|
|
||||||
return Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama model listing failed ({code}): {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = response.text().await.map_err(|e| {
|
|
||||||
owlen_core::Error::Network(format!("Failed to read models response: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let ollama_response: OllamaModelsResponse =
|
|
||||||
serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?;
|
|
||||||
|
|
||||||
let models = ollama_response
|
|
||||||
.models
|
|
||||||
.into_iter()
|
|
||||||
.map(|model| ModelInfo {
|
|
||||||
id: model.name.clone(),
|
|
||||||
name: model.name.clone(),
|
|
||||||
description: model
|
|
||||||
.details
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|d| d.family.as_ref().map(|f| format!("Ollama {f} model"))),
|
|
||||||
provider: "ollama".to_string(),
|
|
||||||
context_window: None,
|
|
||||||
capabilities: vec!["chat".to_string()],
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(models)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Provider for OllamaProvider {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"ollama"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
|
||||||
self.model_manager
|
|
||||||
.get_or_refresh(false, || async { self.fetch_models().await })
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
|
||||||
let ChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
parameters,
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
|
||||||
|
|
||||||
let options = Self::build_options(parameters);
|
|
||||||
|
|
||||||
let ollama_request = OllamaChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
stream: false,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}/api/chat", self.base_url);
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.json(&ollama_request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Chat request failed: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let code = response.status();
|
|
||||||
let error = parse_error_body(response).await;
|
|
||||||
return Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama chat failed ({code}): {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = response.text().await.map_err(|e| {
|
|
||||||
owlen_core::Error::Network(format!("Failed to read chat response: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut ollama_response: OllamaChatResponse =
|
|
||||||
serde_json::from_str(&body).map_err(owlen_core::Error::Serialization)?;
|
|
||||||
|
|
||||||
if let Some(error) = ollama_response.error.take() {
|
|
||||||
return Err(owlen_core::Error::Provider(anyhow::anyhow!(error)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = match ollama_response.message {
|
|
||||||
Some(ref msg) => Self::convert_ollama_message(msg),
|
|
||||||
None => {
|
|
||||||
return Err(owlen_core::Error::Provider(anyhow::anyhow!(
|
|
||||||
"Ollama response missing message"
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let usage = if let (Some(prompt_tokens), Some(completion_tokens)) = (
|
|
||||||
ollama_response.prompt_eval_count,
|
|
||||||
ollama_response.eval_count,
|
|
||||||
) {
|
|
||||||
Some(TokenUsage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens: prompt_tokens + completion_tokens,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ChatResponse {
|
|
||||||
message,
|
|
||||||
usage,
|
|
||||||
is_streaming: false,
|
|
||||||
is_final: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
|
||||||
let ChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
parameters,
|
|
||||||
} = request;
|
|
||||||
|
|
||||||
let messages: Vec<OllamaMessage> = messages.iter().map(Self::convert_message).collect();
|
|
||||||
|
|
||||||
let options = Self::build_options(parameters);
|
|
||||||
|
|
||||||
let ollama_request = OllamaChatRequest {
|
|
||||||
model,
|
|
||||||
messages,
|
|
||||||
stream: true,
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}/api/chat", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.json(&ollama_request)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Streaming request failed: {e}")))?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let code = response.status();
|
|
||||||
let error = parse_error_body(response).await;
|
|
||||||
return Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama streaming chat failed ({code}): {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
|
||||||
let mut stream = response.bytes_stream();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut buffer = String::new();
|
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
|
||||||
match chunk {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if let Ok(text) = String::from_utf8(bytes.to_vec()) {
|
|
||||||
buffer.push_str(&text);
|
|
||||||
|
|
||||||
while let Some(pos) = buffer.find('\n') {
|
|
||||||
let mut line = buffer[..pos].trim().to_string();
|
|
||||||
buffer.drain(..=pos);
|
|
||||||
|
|
||||||
if line.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if line.ends_with('\r') {
|
|
||||||
line.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
match serde_json::from_str::<OllamaChatResponse>(&line) {
|
|
||||||
Ok(mut ollama_response) => {
|
|
||||||
if let Some(error) = ollama_response.error.take() {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Provider(
|
|
||||||
anyhow::anyhow!(error),
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(message) = ollama_response.message {
|
|
||||||
let mut chat_response = ChatResponse {
|
|
||||||
message: Self::convert_ollama_message(&message),
|
|
||||||
usage: None,
|
|
||||||
is_streaming: true,
|
|
||||||
is_final: ollama_response.done,
|
|
||||||
};
|
|
||||||
|
|
||||||
if let (Some(prompt_tokens), Some(completion_tokens)) = (
|
|
||||||
ollama_response.prompt_eval_count,
|
|
||||||
ollama_response.eval_count,
|
|
||||||
) {
|
|
||||||
chat_response.usage = Some(TokenUsage {
|
|
||||||
prompt_tokens,
|
|
||||||
completion_tokens,
|
|
||||||
total_tokens: prompt_tokens + completion_tokens,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if tx.send(Ok(chat_response)).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ollama_response.done {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Serialization(e)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Serialization(
|
|
||||||
serde_json::Error::io(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"Non UTF-8 chunk from Ollama",
|
|
||||||
)),
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(Err(owlen_core::Error::Network(format!(
|
|
||||||
"Stream error: {e}"
|
|
||||||
))));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let stream = UnboundedReceiverStream::new(rx);
|
|
||||||
Ok(Box::pin(stream))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health_check(&self) -> Result<()> {
|
|
||||||
let url = format!("{}/api/version", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| owlen_core::Error::Network(format!("Health check failed: {e}")))?;
|
|
||||||
|
|
||||||
if response.status().is_success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(owlen_core::Error::Network(format!(
|
|
||||||
"Ollama health check failed: HTTP {}",
|
|
||||||
response.status()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_schema(&self) -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"base_url": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Base URL for Ollama API",
|
|
||||||
"default": "http://localhost:11434"
|
|
||||||
},
|
|
||||||
"timeout_secs": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "HTTP request timeout in seconds",
|
|
||||||
"minimum": 5,
|
|
||||||
"default": DEFAULT_TIMEOUT_SECS
|
|
||||||
},
|
|
||||||
"model_cache_ttl_secs": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Seconds to cache model listings",
|
|
||||||
"minimum": 5,
|
|
||||||
"default": DEFAULT_MODEL_CACHE_TTL_SECS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn parse_error_body(response: reqwest::Response) -> String {
|
|
||||||
match response.bytes().await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
if bytes.is_empty() {
|
|
||||||
return "unknown error".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(err) = serde_json::from_slice::<OllamaErrorResponse>(&bytes) {
|
|
||||||
if let Some(error) = err.error {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match String::from_utf8(bytes.to_vec()) {
|
|
||||||
Ok(text) if !text.trim().is_empty() => text,
|
|
||||||
_ => "unknown error".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => "unknown error".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "owlen-tui"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
repository.workspace = true
|
|
||||||
homepage.workspace = true
|
|
||||||
description = "Terminal User Interface for OWLEN LLM client"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
owlen-core = { path = "../owlen-core" }
|
|
||||||
|
|
||||||
# TUI framework
|
|
||||||
ratatui = { workspace = true }
|
|
||||||
crossterm = { workspace = true }
|
|
||||||
tui-textarea = { workspace = true }
|
|
||||||
textwrap = { workspace = true }
|
|
||||||
unicode-width = "0.1"
|
|
||||||
|
|
||||||
# Async runtime
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tokio-util = { workspace = true }
|
|
||||||
futures-util = { workspace = true }
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio-test = { workspace = true }
|
|
||||||
tempfile = { workspace = true }
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use owlen_core::session::SessionController;
|
|
||||||
use owlen_core::ui::{AppState, InputMode};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use crate::chat_app::{ChatApp, SessionEvent};
|
|
||||||
use crate::events::Event;
|
|
||||||
|
|
||||||
const DEFAULT_SYSTEM_PROMPT: &str =
|
|
||||||
"You are OWLEN Code Assistant. Provide detailed, actionable programming help.";
|
|
||||||
|
|
||||||
pub struct CodeApp {
|
|
||||||
inner: ChatApp,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CodeApp {
|
|
||||||
pub fn new(mut controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
|
|
||||||
controller
|
|
||||||
.conversation_mut()
|
|
||||||
.push_system_message(DEFAULT_SYSTEM_PROMPT.to_string());
|
|
||||||
let (inner, rx) = ChatApp::new(controller);
|
|
||||||
(Self { inner }, rx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
|
|
||||||
self.inner.handle_event(event).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
|
|
||||||
self.inner.handle_session_event(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mode(&self) -> InputMode {
|
|
||||||
self.inner.mode()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inner(&self) -> &ChatApp {
|
|
||||||
&self.inner
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inner_mut(&mut self) -> &mut ChatApp {
|
|
||||||
&mut self.inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
pub use owlen_core::config::{
|
|
||||||
default_config_path, ensure_ollama_config, session_timeout, Config, GeneralSettings,
|
|
||||||
InputSettings, StorageSettings, UiSettings, DEFAULT_CONFIG_PATH,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Attempt to load configuration from default location
|
|
||||||
pub fn try_load_config() -> Option<Config> {
|
|
||||||
Config::load(None).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist configuration to default path
|
|
||||||
pub fn save_config(config: &Config) -> anyhow::Result<()> {
|
|
||||||
config
|
|
||||||
.save(None)
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
/// Application events
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum Event {
|
|
||||||
/// Terminal key press event
|
|
||||||
Key(KeyEvent),
|
|
||||||
/// Terminal resize event
|
|
||||||
#[allow(dead_code)]
|
|
||||||
Resize(u16, u16),
|
|
||||||
/// Paste event
|
|
||||||
Paste(String),
|
|
||||||
/// Tick event for regular updates
|
|
||||||
Tick,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Event handler that captures terminal events and sends them to the application
|
|
||||||
pub struct EventHandler {
|
|
||||||
sender: mpsc::UnboundedSender<Event>,
|
|
||||||
tick_rate: Duration,
|
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EventHandler {
|
|
||||||
pub fn new(
|
|
||||||
sender: mpsc::UnboundedSender<Event>,
|
|
||||||
cancellation_token: CancellationToken,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
sender,
|
|
||||||
tick_rate: Duration::from_millis(250), // 4 times per second
|
|
||||||
cancellation_token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(&self) {
|
|
||||||
let mut last_tick = tokio::time::Instant::now();
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if self.cancellation_token.is_cancelled() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeout = self
|
|
||||||
.tick_rate
|
|
||||||
.checked_sub(last_tick.elapsed())
|
|
||||||
.unwrap_or_else(|| Duration::from_secs(0));
|
|
||||||
|
|
||||||
if event::poll(timeout).unwrap_or(false) {
|
|
||||||
match event::read() {
|
|
||||||
Ok(event) => {
|
|
||||||
match event {
|
|
||||||
crossterm::event::Event::Key(key) => {
|
|
||||||
// Only handle KeyEventKind::Press to avoid duplicate events
|
|
||||||
if key.kind == KeyEventKind::Press {
|
|
||||||
let _ = self.sender.send(Event::Key(key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crossterm::event::Event::Resize(width, height) => {
|
|
||||||
let _ = self.sender.send(Event::Resize(width, height));
|
|
||||||
}
|
|
||||||
crossterm::event::Event::Paste(text) => {
|
|
||||||
let _ = self.sender.send(Event::Paste(text));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Handle error by continuing the loop
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if last_tick.elapsed() >= self.tick_rate {
|
|
||||||
let _ = self.sender.send(Event::Tick);
|
|
||||||
last_tick = tokio::time::Instant::now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper functions for key event handling
|
|
||||||
impl Event {
|
|
||||||
/// Check if this is a quit command (Ctrl+C or 'q')
|
|
||||||
pub fn is_quit(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Char('q'),
|
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
..
|
|
||||||
}) | Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Char('c'),
|
|
||||||
modifiers: KeyModifiers::CONTROL,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is an enter key press
|
|
||||||
pub fn is_enter(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Enter,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a tab key press
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn is_tab(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Tab,
|
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a backspace
|
|
||||||
pub fn is_backspace(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Backspace,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is an escape key press
|
|
||||||
pub fn is_escape(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Esc,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the character if this is a character key event
|
|
||||||
pub fn as_char(&self) -> Option<char> {
|
|
||||||
match self {
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Char(c),
|
|
||||||
modifiers: KeyModifiers::NONE,
|
|
||||||
..
|
|
||||||
}) => Some(*c),
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Char(c),
|
|
||||||
modifiers: KeyModifiers::SHIFT,
|
|
||||||
..
|
|
||||||
}) => Some(*c),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is an up arrow key press
|
|
||||||
pub fn is_up(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Up,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a down arrow key press
|
|
||||||
pub fn is_down(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Down,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a left arrow key press
|
|
||||||
pub fn is_left(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Left,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a right arrow key press
|
|
||||||
pub fn is_right(&self) -> bool {
|
|
||||||
matches!(
|
|
||||||
self,
|
|
||||||
Event::Key(KeyEvent {
|
|
||||||
code: KeyCode::Right,
|
|
||||||
..
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
pub mod chat_app;
|
|
||||||
pub mod code_app;
|
|
||||||
pub mod config;
|
|
||||||
pub mod events;
|
|
||||||
pub mod ui;
|
|
||||||
|
|
||||||
pub use chat_app::{ChatApp, SessionEvent};
|
|
||||||
pub use code_app::CodeApp;
|
|
||||||
pub use events::{Event, EventHandler};
|
|
||||||
pub use owlen_core::ui::{AppState, FocusedPanel, InputMode};
|
|
||||||
File diff suppressed because it is too large
Load Diff
22
crates/platform/config/.gitignore
vendored
Normal file
22
crates/platform/config/.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
|
||||||
|
|
||||||
|
|
||||||
16
crates/platform/config/Cargo.toml
Normal file
16
crates/platform/config/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "config-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
directories = "5"
|
||||||
|
figment = { version = "0.10", features = ["toml", "env"] }
|
||||||
|
permissions = { path = "../permissions" }
|
||||||
|
llm-core = { path = "../../llm/core" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.23.0"
|
||||||
183
crates/platform/config/src/lib.rs
Normal file
183
crates/platform/config/src/lib.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
use directories::ProjectDirs;
|
||||||
|
use figment::{
|
||||||
|
Figment,
|
||||||
|
providers::{Env, Format, Serialized, Toml},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::env;
|
||||||
|
use permissions::{Mode, PermissionManager};
|
||||||
|
use llm_core::ProviderType;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
// Provider configuration
|
||||||
|
#[serde(default = "default_provider")]
|
||||||
|
pub provider: String, // "ollama" | "anthropic" | "openai"
|
||||||
|
|
||||||
|
#[serde(default = "default_model")]
|
||||||
|
pub model: String,
|
||||||
|
|
||||||
|
// Ollama-specific
|
||||||
|
#[serde(default = "default_ollama_url")]
|
||||||
|
pub ollama_url: String,
|
||||||
|
|
||||||
|
// API keys for different providers
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: Option<String>, // For Ollama Cloud or backwards compatibility
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub anthropic_api_key: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub openai_api_key: Option<String>,
|
||||||
|
|
||||||
|
// Permission mode
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
pub mode: String, // "plan" | "acceptEdits" | "code"
|
||||||
|
|
||||||
|
// Tool permission lists
|
||||||
|
/// Tools that are always allowed without prompting
|
||||||
|
/// Format: "tool_name" or "tool_name:pattern"
|
||||||
|
/// Example: ["bash:npm test:*", "bash:cargo test:*", "mcp:filesystem__*"]
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowed_tools: Vec<String>,
|
||||||
|
|
||||||
|
/// Tools that are always denied (blocked)
|
||||||
|
/// Format: "tool_name" or "tool_name:pattern"
|
||||||
|
/// Example: ["bash:rm -rf*", "bash:sudo*"]
|
||||||
|
#[serde(default)]
|
||||||
|
pub disallowed_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_provider() -> String {
|
||||||
|
"ollama".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ollama_url() -> String {
|
||||||
|
"http://localhost:11434".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_model() -> String {
|
||||||
|
// Default model depends on provider, but we use ollama's default here
|
||||||
|
// Users can override this per-provider or use get_effective_model()
|
||||||
|
"qwen3:8b".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mode() -> String {
|
||||||
|
"plan".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
provider: default_provider(),
|
||||||
|
model: default_model(),
|
||||||
|
ollama_url: default_ollama_url(),
|
||||||
|
api_key: None,
|
||||||
|
anthropic_api_key: None,
|
||||||
|
openai_api_key: None,
|
||||||
|
mode: default_mode(),
|
||||||
|
allowed_tools: Vec::new(),
|
||||||
|
disallowed_tools: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
/// Create a PermissionManager based on the configured mode and tool lists
|
||||||
|
///
|
||||||
|
/// Tool lists are applied in order:
|
||||||
|
/// 1. Disallowed tools (highest priority - blocked first)
|
||||||
|
/// 2. Allowed tools
|
||||||
|
/// 3. Mode-based defaults
|
||||||
|
pub fn create_permission_manager(&self) -> PermissionManager {
|
||||||
|
let mode = Mode::from_str(&self.mode).unwrap_or(Mode::Plan);
|
||||||
|
let mut pm = PermissionManager::new(mode);
|
||||||
|
|
||||||
|
// Add disallowed tools first (deny rules take precedence)
|
||||||
|
pm.add_disallowed_tools(&self.disallowed_tools);
|
||||||
|
|
||||||
|
// Then add allowed tools
|
||||||
|
pm.add_allowed_tools(&self.allowed_tools);
|
||||||
|
|
||||||
|
pm
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the Mode enum from the mode string
|
||||||
|
pub fn get_mode(&self) -> Mode {
|
||||||
|
Mode::from_str(&self.mode).unwrap_or(Mode::Plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the ProviderType enum from the provider string
|
||||||
|
pub fn get_provider(&self) -> Option<ProviderType> {
|
||||||
|
ProviderType::from_str(&self.provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the effective model for the current provider
|
||||||
|
/// If no model is explicitly set, returns the provider's default
|
||||||
|
pub fn get_effective_model(&self) -> String {
|
||||||
|
// If model is explicitly set and not the default, use it
|
||||||
|
if self.model != default_model() {
|
||||||
|
return self.model.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use provider-specific default
|
||||||
|
self.get_provider()
|
||||||
|
.map(|p| p.default_model().to_string())
|
||||||
|
.unwrap_or_else(|| self.model.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the API key for the current provider
|
||||||
|
pub fn get_provider_api_key(&self) -> Option<String> {
|
||||||
|
match self.get_provider()? {
|
||||||
|
ProviderType::Ollama => self.api_key.clone(),
|
||||||
|
ProviderType::Anthropic => self.anthropic_api_key.clone(),
|
||||||
|
ProviderType::OpenAI => self.openai_api_key.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_settings(project_root: Option<&str>) -> Result<Settings, figment::Error> {
|
||||||
|
let mut fig = Figment::from(Serialized::defaults(Settings::default()));
|
||||||
|
|
||||||
|
// User file: ~/.config/owlen/config.toml
|
||||||
|
if let Some(pd) = ProjectDirs::from("dev", "owlibou", "owlen") {
|
||||||
|
let user = pd.config_dir().join("config.toml");
|
||||||
|
fig = fig.merge(Toml::file(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project file: <root>/.owlen.toml
|
||||||
|
if let Some(root) = project_root {
|
||||||
|
fig = fig.merge(Toml::file(PathBuf::from(root).join(".owlen.toml")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variables have highest precedence
|
||||||
|
// OWLEN_* prefix (e.g., OWLEN_PROVIDER, OWLEN_MODEL, OWLEN_API_KEY, OWLEN_ANTHROPIC_API_KEY)
|
||||||
|
fig = fig.merge(Env::prefixed("OWLEN_").split("__"));
|
||||||
|
|
||||||
|
// Support OLLAMA_* prefix for backwards compatibility
|
||||||
|
fig = fig.merge(Env::prefixed("OLLAMA_"));
|
||||||
|
|
||||||
|
// Support PROVIDER env var (without OWLEN_ prefix)
|
||||||
|
fig = fig.merge(Env::raw().only(&["PROVIDER"]));
|
||||||
|
|
||||||
|
// Extract the settings
|
||||||
|
let mut settings: Settings = fig.extract()?;
|
||||||
|
|
||||||
|
// Manually handle standard provider API key env vars (ANTHROPIC_API_KEY, OPENAI_API_KEY)
|
||||||
|
// These override config files but are overridden by OWLEN_* vars
|
||||||
|
if settings.anthropic_api_key.is_none() {
|
||||||
|
if let Ok(key) = env::var("ANTHROPIC_API_KEY") {
|
||||||
|
settings.anthropic_api_key = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.openai_api_key.is_none() {
|
||||||
|
if let Ok(key) = env::var("OPENAI_API_KEY") {
|
||||||
|
settings.openai_api_key = Some(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
234
crates/platform/config/tests/precedence.rs
Normal file
234
crates/platform/config/tests/precedence.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
use config_agent::{load_settings, Settings};
|
||||||
|
use permissions::{Mode, PermissionDecision, Tool};
|
||||||
|
use llm_core::ProviderType;
|
||||||
|
use std::{env, fs};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn precedence_env_overrides_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"model="local-model""#).unwrap();
|
||||||
|
|
||||||
|
unsafe { env::set_var("OWLEN_MODEL", "env-model"); }
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.model, "env-model");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_mode_is_plan() {
|
||||||
|
let s = Settings::default();
|
||||||
|
assert_eq!(s.mode, "plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_create_permission_manager_with_plan_mode() {
|
||||||
|
let s = Settings::default();
|
||||||
|
let mgr = s.create_permission_manager();
|
||||||
|
|
||||||
|
// Plan mode should allow read operations
|
||||||
|
assert_eq!(mgr.check(Tool::Read, None), PermissionDecision::Allow);
|
||||||
|
|
||||||
|
// Plan mode should ask for write operations
|
||||||
|
assert_eq!(mgr.check(Tool::Write, None), PermissionDecision::Ask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_parse_mode_from_config() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"mode="code""#).unwrap();
|
||||||
|
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.mode, "code");
|
||||||
|
assert_eq!(s.get_mode(), Mode::Code);
|
||||||
|
|
||||||
|
let mgr = s.create_permission_manager();
|
||||||
|
// Code mode should allow everything
|
||||||
|
assert_eq!(mgr.check(Tool::Write, None), PermissionDecision::Allow);
|
||||||
|
assert_eq!(mgr.check(Tool::Bash, None), PermissionDecision::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_provider_is_ollama() {
|
||||||
|
let s = Settings::default();
|
||||||
|
assert_eq!(s.provider, "ollama");
|
||||||
|
assert_eq!(s.get_provider(), Some(ProviderType::Ollama));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_from_config_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"provider="anthropic""#).unwrap();
|
||||||
|
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.provider, "anthropic");
|
||||||
|
assert_eq!(s.get_provider(), Some(ProviderType::Anthropic));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Ignore due to env var interaction in parallel tests
|
||||||
|
fn provider_from_env_var() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var("OWLEN_PROVIDER", "openai");
|
||||||
|
env::remove_var("PROVIDER");
|
||||||
|
env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
env::remove_var("OPENAI_API_KEY");
|
||||||
|
}
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.provider, "openai");
|
||||||
|
assert_eq!(s.get_provider(), Some(ProviderType::OpenAI));
|
||||||
|
unsafe { env::remove_var("OWLEN_PROVIDER"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Ignore due to env var interaction in parallel tests
|
||||||
|
fn provider_from_provider_env_var() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var("PROVIDER", "anthropic");
|
||||||
|
env::remove_var("OWLEN_PROVIDER");
|
||||||
|
env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
env::remove_var("OPENAI_API_KEY");
|
||||||
|
}
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.provider, "anthropic");
|
||||||
|
assert_eq!(s.get_provider(), Some(ProviderType::Anthropic));
|
||||||
|
unsafe { env::remove_var("PROVIDER"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anthropic_api_key_from_owlen_env() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"provider="anthropic""#).unwrap();
|
||||||
|
|
||||||
|
unsafe { env::set_var("OWLEN_ANTHROPIC_API_KEY", "sk-ant-test123"); }
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.anthropic_api_key, Some("sk-ant-test123".to_string()));
|
||||||
|
assert_eq!(s.get_provider_api_key(), Some("sk-ant-test123".to_string()));
|
||||||
|
unsafe { env::remove_var("OWLEN_ANTHROPIC_API_KEY"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_api_key_from_owlen_env() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"provider="openai""#).unwrap();
|
||||||
|
|
||||||
|
unsafe { env::set_var("OWLEN_OPENAI_API_KEY", "sk-test-456"); }
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.openai_api_key, Some("sk-test-456".to_string()));
|
||||||
|
assert_eq!(s.get_provider_api_key(), Some("sk-test-456".to_string()));
|
||||||
|
unsafe { env::remove_var("OWLEN_OPENAI_API_KEY"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Ignore due to env var interaction in parallel tests
|
||||||
|
fn api_keys_from_config_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"
|
||||||
|
provider = "anthropic"
|
||||||
|
anthropic_api_key = "sk-ant-from-file"
|
||||||
|
openai_api_key = "sk-openai-from-file"
|
||||||
|
"#).unwrap();
|
||||||
|
|
||||||
|
// Clear any env vars that might interfere
|
||||||
|
unsafe {
|
||||||
|
env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
env::remove_var("OPENAI_API_KEY");
|
||||||
|
env::remove_var("OWLEN_ANTHROPIC_API_KEY");
|
||||||
|
env::remove_var("OWLEN_OPENAI_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.anthropic_api_key, Some("sk-ant-from-file".to_string()));
|
||||||
|
assert_eq!(s.openai_api_key, Some("sk-openai-from-file".to_string()));
|
||||||
|
assert_eq!(s.get_provider_api_key(), Some("sk-ant-from-file".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Ignore due to env var interaction in parallel tests
|
||||||
|
fn anthropic_api_key_from_standard_env() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"provider="anthropic""#).unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var("ANTHROPIC_API_KEY", "sk-ant-std");
|
||||||
|
env::remove_var("OWLEN_ANTHROPIC_API_KEY");
|
||||||
|
env::remove_var("PROVIDER");
|
||||||
|
env::remove_var("OWLEN_PROVIDER");
|
||||||
|
}
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.anthropic_api_key, Some("sk-ant-std".to_string()));
|
||||||
|
assert_eq!(s.get_provider_api_key(), Some("sk-ant-std".to_string()));
|
||||||
|
unsafe { env::remove_var("ANTHROPIC_API_KEY"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Ignore due to env var interaction in parallel tests
|
||||||
|
fn openai_api_key_from_standard_env() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_file = tmp.path().join(".owlen.toml");
|
||||||
|
fs::write(&project_file, r#"provider="openai""#).unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var("OPENAI_API_KEY", "sk-openai-std");
|
||||||
|
env::remove_var("OWLEN_OPENAI_API_KEY");
|
||||||
|
env::remove_var("PROVIDER");
|
||||||
|
env::remove_var("OWLEN_PROVIDER");
|
||||||
|
}
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
assert_eq!(s.openai_api_key, Some("sk-openai-std".to_string()));
|
||||||
|
assert_eq!(s.get_provider_api_key(), Some("sk-openai-std".to_string()));
|
||||||
|
unsafe { env::remove_var("OPENAI_API_KEY"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore] // Ignore due to env var interaction in parallel tests
|
||||||
|
fn owlen_prefix_overrides_standard_env() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
env::set_var("ANTHROPIC_API_KEY", "sk-ant-std");
|
||||||
|
env::set_var("OWLEN_ANTHROPIC_API_KEY", "sk-ant-owlen");
|
||||||
|
}
|
||||||
|
let s = load_settings(Some(tmp.path().to_str().unwrap())).unwrap();
|
||||||
|
// OWLEN_ prefix should take precedence
|
||||||
|
assert_eq!(s.anthropic_api_key, Some("sk-ant-owlen".to_string()));
|
||||||
|
unsafe {
|
||||||
|
env::remove_var("ANTHROPIC_API_KEY");
|
||||||
|
env::remove_var("OWLEN_ANTHROPIC_API_KEY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_model_uses_provider_default() {
|
||||||
|
// Test Anthropic provider default
|
||||||
|
let mut s = Settings::default();
|
||||||
|
s.provider = "anthropic".to_string();
|
||||||
|
assert_eq!(s.get_effective_model(), "claude-sonnet-4-20250514");
|
||||||
|
|
||||||
|
// Test OpenAI provider default
|
||||||
|
s.provider = "openai".to_string();
|
||||||
|
assert_eq!(s.get_effective_model(), "gpt-4o");
|
||||||
|
|
||||||
|
// Test Ollama provider default
|
||||||
|
s.provider = "ollama".to_string();
|
||||||
|
assert_eq!(s.get_effective_model(), "qwen3:8b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_model_respects_explicit_model() {
|
||||||
|
let mut s = Settings::default();
|
||||||
|
s.provider = "anthropic".to_string();
|
||||||
|
s.model = "claude-opus-4-20250514".to_string();
|
||||||
|
|
||||||
|
// Should use explicit model, not provider default
|
||||||
|
assert_eq!(s.get_effective_model(), "claude-opus-4-20250514");
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user