Compare commits
30 Commits
v0.1.2
...
4d7ad2c330
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d7ad2c330 | |||
| 13af046eff | |||
| 5b202fed4f | |||
| 979347bf53 | |||
| 76b55ccff5 | |||
| f0e162d551 | |||
| 6c4571804f | |||
| a0cdcfdf6c | |||
| 96e2482782 | |||
| 6a3f44f911 | |||
| e0e5a2a83d | |||
| 23e86591d1 | |||
| b60a317788 | |||
| 2788e8b7e2 | |||
| 7c186882dc | |||
| bdda669d4d | |||
| 108070db4b | |||
| 08ba04e99f | |||
| e58032deae | |||
| 5c59539120 | |||
| c725bb1ce6 | |||
| c4a6bb1c0f | |||
| dcbfe6ef06 | |||
| e468658d63 | |||
| 2ad801f0c1 | |||
| 1bfc6e5956 | |||
| 6b8774f0aa | |||
| ec6876727f | |||
| e3eb4d7a04 | |||
| 7234021014 |
20
.cargo/config.toml
Normal file
20
.cargo/config.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[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"
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -4,6 +4,10 @@
|
|||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
dev/
|
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
|
||||||
@@ -100,4 +104,3 @@ 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
|
||||||
|
|
||||||
|
|||||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Pre-commit hooks configuration
|
||||||
|
# See https://pre-commit.com for more information
|
||||||
|
|
||||||
|
repos:
|
||||||
|
# General file checks
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: ['--maxkb=1000']
|
||||||
|
- id: mixed-line-ending
|
||||||
|
|
||||||
|
# Rust formatting
|
||||||
|
- repo: https://github.com/doublify/pre-commit-rust
|
||||||
|
rev: v1.0
|
||||||
|
hooks:
|
||||||
|
- id: fmt
|
||||||
|
name: cargo fmt
|
||||||
|
description: Format Rust code with rustfmt
|
||||||
|
- id: cargo-check
|
||||||
|
name: cargo check
|
||||||
|
description: Check Rust code compilation
|
||||||
|
- id: clippy
|
||||||
|
name: cargo clippy
|
||||||
|
description: Lint Rust code with clippy
|
||||||
|
args: ['--all-features', '--', '-D', 'warnings']
|
||||||
|
|
||||||
|
# Optional: run on all files when config changes
|
||||||
|
default_install_hook_types: [pre-commit, pre-push]
|
||||||
@@ -11,63 +11,110 @@ matrix:
|
|||||||
- TARGET: x86_64-unknown-linux-gnu
|
- TARGET: x86_64-unknown-linux-gnu
|
||||||
ARTIFACT: owlen-linux-x86_64-gnu
|
ARTIFACT: owlen-linux-x86_64-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: x86_64-unknown-linux-musl
|
- TARGET: x86_64-unknown-linux-musl
|
||||||
ARTIFACT: owlen-linux-x86_64-musl
|
ARTIFACT: owlen-linux-x86_64-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: aarch64-unknown-linux-gnu
|
- TARGET: aarch64-unknown-linux-gnu
|
||||||
ARTIFACT: owlen-linux-aarch64-gnu
|
ARTIFACT: owlen-linux-aarch64-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: aarch64-unknown-linux-musl
|
- TARGET: aarch64-unknown-linux-musl
|
||||||
ARTIFACT: owlen-linux-aarch64-musl
|
ARTIFACT: owlen-linux-aarch64-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: armv7-unknown-linux-gnueabihf
|
- TARGET: armv7-unknown-linux-gnueabihf
|
||||||
ARTIFACT: owlen-linux-armv7-gnu
|
ARTIFACT: owlen-linux-armv7-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: armv7-unknown-linux-musleabihf
|
- TARGET: armv7-unknown-linux-musleabihf
|
||||||
ARTIFACT: owlen-linux-armv7-musl
|
ARTIFACT: owlen-linux-armv7-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
# Windows
|
||||||
|
- TARGET: x86_64-pc-windows-gnu
|
||||||
|
ARTIFACT: owlen-windows-x86_64
|
||||||
|
PLATFORM: windows
|
||||||
|
EXT: ".exe"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: install-deps
|
|
||||||
image: *rust_image
|
|
||||||
commands:
|
|
||||||
- apt-get update
|
|
||||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
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}
|
- rustup target add ${TARGET}
|
||||||
|
|
||||||
|
# Set up cross-compilation environment variables and build
|
||||||
- |
|
- |
|
||||||
case "${TARGET}" in
|
case "${TARGET}" in
|
||||||
aarch64-unknown-linux-gnu)
|
aarch64-unknown-linux-gnu)
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
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)
|
aarch64-unknown-linux-musl)
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
export CC_aarch64_unknown_linux_musl=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)
|
armv7-unknown-linux-gnueabihf)
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
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)
|
armv7-unknown-linux-musleabihf)
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
export CC_armv7_unknown_linux_musleabihf=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
|
esac
|
||||||
- cargo build --release --all-features --target ${TARGET}
|
|
||||||
|
# Build the project
|
||||||
|
cargo build --release --all-features --target ${TARGET}
|
||||||
|
|
||||||
- name: package
|
- name: package
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
|
- apt-get update && apt-get install -y zip
|
||||||
- mkdir -p dist
|
- mkdir -p dist
|
||||||
- cp target/${TARGET}/release/owlen dist/owlen
|
- |
|
||||||
- cp target/${TARGET}/release/owlen-code dist/owlen-code
|
if [ "${PLATFORM}" = "windows" ]; then
|
||||||
- cd dist
|
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
|
||||||
- tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
|
||||||
- cd ..
|
cd dist
|
||||||
- mv dist/${ARTIFACT}.tar.gz .
|
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
|
||||||
- sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
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
|
- name: release
|
||||||
image: plugins/gitea-release
|
image: plugins/gitea-release
|
||||||
@@ -78,5 +125,7 @@ steps:
|
|||||||
files:
|
files:
|
||||||
- ${ARTIFACT}.tar.gz
|
- ${ARTIFACT}.tar.gz
|
||||||
- ${ARTIFACT}.tar.gz.sha256
|
- ${ARTIFACT}.tar.gz.sha256
|
||||||
|
- ${ARTIFACT}.zip
|
||||||
|
- ${ARTIFACT}.zip.sha256
|
||||||
title: Release ${CI_COMMIT_TAG}
|
title: Release ${CI_COMMIT_TAG}
|
||||||
note: "Release ${CI_COMMIT_TAG}"
|
note: "Release ${CI_COMMIT_TAG}"
|
||||||
|
|||||||
81
CHANGELOG.md
Normal file
81
CHANGELOG.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Comprehensive documentation suite including guides for architecture, configuration, testing, and more.
|
||||||
|
- Rustdoc examples for core components like `Provider` and `SessionController`.
|
||||||
|
- Module-level documentation for `owlen-tui`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The main `README.md` has been updated to be more concise and link to the new documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.10] - 2025-10-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
|
||||||
|
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
|
||||||
|
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
|
||||||
|
|
||||||
|
## [0.1.9] - 2025-10-03
|
||||||
|
|
||||||
|
*This version corresponds to the release tagged v0.1.10 in the source repository.*
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
|
||||||
|
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
|
||||||
|
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
|
||||||
|
|
||||||
|
## [0.1.8] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Command Autocompletion**: Implemented intelligent command suggestions and Tab completion in command mode.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Build & CI**: Fixed cross-compilation for ARM64, ARMv7, and Windows.
|
||||||
|
|
||||||
|
## [0.1.7] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Tabbed Help System**: The help menu is now organized into five tabs for easier navigation.
|
||||||
|
- **Command Aliases**: Added `:o` as a short alias for `:load` / `:open`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Session Management**: Improved AI-generated session descriptions.
|
||||||
|
|
||||||
|
## [0.1.6] - 2025-10-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Platform-Specific Storage**: Sessions are now saved to platform-appropriate directories (e.g., `~/.local/share/owlen` on Linux).
|
||||||
|
- **AI-Generated Session Descriptions**: Conversations can be automatically summarized on save.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Migration**: Users on older versions can manually move their sessions from `~/.config/owlen/sessions` to the new platform-specific directory.
|
||||||
|
|
||||||
|
## [0.1.4] - 2025-10-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-Platform Builds**: Pre-built binaries are now provided for Linux (x86_64, aarch64, armv7) and Windows (x86_64).
|
||||||
|
- **AUR Package**: Owlen is now available on the Arch User Repository.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Build System**: Switched from OpenSSL to rustls for better cross-platform compatibility.
|
||||||
121
CODE_OF_CONDUCT.md
Normal file
121
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that are welcoming, open, and respectful.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
[security@owlibou.com](mailto:security@owlibou.com). All complaints will be
|
||||||
|
reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interaction in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
121
CONTRIBUTING.md
Normal file
121
CONTRIBUTING.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Contributing to Owlen
|
||||||
|
|
||||||
|
First off, thank you for considering contributing to Owlen! It's people like you that make Owlen such a great tool.
|
||||||
|
|
||||||
|
Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project and everyone participating in it is governed by the [Owlen Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior.
|
||||||
|
|
||||||
|
## How Can I Contribute?
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
This is one of the most helpful ways you can contribute. Before creating a bug report, please check a few things:
|
||||||
|
|
||||||
|
1. **Check the [troubleshooting guide](docs/troubleshooting.md).** Your issue might be a common one with a known solution.
|
||||||
|
2. **Search the existing issues.** It's possible someone has already reported the same bug. If so, add a comment to the existing issue instead of creating a new one.
|
||||||
|
|
||||||
|
When you are creating a bug report, please include as many details as possible. Fill out the required template, the information it asks for helps us resolve issues faster.
|
||||||
|
|
||||||
|
### Suggesting Enhancements
|
||||||
|
|
||||||
|
If you have an idea for a new feature or an improvement to an existing one, we'd love to hear about it. Please provide as much context as you can about what you're trying to achieve.
|
||||||
|
|
||||||
|
### Your First Code Contribution
|
||||||
|
|
||||||
|
Unsure where to begin contributing to Owlen? You can start by looking through `good first issue` and `help wanted` issues.
|
||||||
|
|
||||||
|
### Pull Requests
|
||||||
|
|
||||||
|
The process for submitting a pull request is as follows:
|
||||||
|
|
||||||
|
1. **Fork the repository** and create your branch from `main`.
|
||||||
|
2. **Set up pre-commit hooks** (see [Development Setup](#development-setup) above). This will automatically format and lint your code.
|
||||||
|
3. **Make your changes.**
|
||||||
|
4. **Run the tests.**
|
||||||
|
- `cargo test --all`
|
||||||
|
5. **Commit your changes.** The pre-commit hooks will automatically run `cargo fmt`, `cargo check`, and `cargo clippy`. If you need to bypass the hooks (not recommended), use `git commit --no-verify`.
|
||||||
|
6. **Add a clear, concise commit message.** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
|
||||||
|
7. **Push to your fork** and submit a pull request to Owlen's `main` branch.
|
||||||
|
8. **Include a clear description** of the problem and solution. Include the relevant issue number if applicable.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
To get started with the codebase, you'll need to have Rust installed. Then, you can clone the repository and build the project:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://github.com/Owlibou/owlen.git
|
||||||
|
cd owlen
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
|
||||||
|
We use [pre-commit](https://pre-commit.com/) to automatically run formatting and linting checks before each commit. This helps maintain code quality and consistency.
|
||||||
|
|
||||||
|
**Install pre-commit:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S pre-commit
|
||||||
|
|
||||||
|
# Other Linux/macOS
|
||||||
|
pip install pre-commit
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
pre-commit --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup the hooks:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd owlen
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
Once installed, the hooks will automatically run on every commit. You can also run them manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Run on all files
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
# Run on staged files only
|
||||||
|
pre-commit run
|
||||||
|
```
|
||||||
|
|
||||||
|
The pre-commit hooks will check:
|
||||||
|
- Code formatting (`cargo fmt`)
|
||||||
|
- Compilation (`cargo check`)
|
||||||
|
- Linting (`cargo clippy --all-features`)
|
||||||
|
- General file hygiene (trailing whitespace, EOF newlines, etc.)
|
||||||
|
|
||||||
|
## Coding Style
|
||||||
|
|
||||||
|
- We use `cargo fmt` for automated code formatting. Please run it before committing your changes.
|
||||||
|
- We use `cargo clippy` for linting. Your code should be free of any clippy warnings.
|
||||||
|
|
||||||
|
## Commit Message Conventions
|
||||||
|
|
||||||
|
We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our commit messages. This allows for automated changelog generation and makes the project history easier to read.
|
||||||
|
|
||||||
|
The basic format is:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(provider): add support for Gemini Pro
|
||||||
|
```
|
||||||
|
|
||||||
|
Thank you for your contribution!
|
||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Owlibou"]
|
authors = ["Owlibou"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
@@ -32,7 +32,7 @@ crossterm = "0.28"
|
|||||||
tui-textarea = "0.6"
|
tui-textarea = "0.6"
|
||||||
|
|
||||||
# HTTP client and JSON handling
|
# HTTP client and JSON handling
|
||||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
|||||||
1
LICENSE
1
LICENSE
@@ -659,4 +659,3 @@ specific requirements.
|
|||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
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
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|||||||
42
PKGBUILD
42
PKGBUILD
@@ -1,45 +1,49 @@
|
|||||||
# Maintainer: Owlibou
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlen
|
pkgname=owlen
|
||||||
pkgver=0.1.0
|
pkgver=0.1.9
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64')
|
||||||
url="https://somegit.dev/Owlibou/owlen"
|
url="https://somegit.dev/Owlibou/owlen"
|
||||||
license=('AGPL-3.0-only')
|
license=('AGPL-3.0-or-later')
|
||||||
depends=('gcc-libs')
|
depends=('gcc-libs')
|
||||||
makedepends=('cargo' 'git')
|
makedepends=('cargo' 'git')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz::https://somegit.dev/Owlibou/owlen/archive/v${pkgver}.tar.gz")
|
options=(!lto) # avoid LTO-linked ring symbol drop with lld
|
||||||
sha256sums=('SKIP') # Update this after first release
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec')
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
export CARGO_TARGET_DIR=target
|
export CARGO_TARGET_DIR=target
|
||||||
cargo build --frozen --release --all-features
|
cargo build --frozen --release --all-features
|
||||||
}
|
}
|
||||||
|
|
||||||
check() {
|
check() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||||
cargo test --frozen --all-features
|
cargo test --frozen --all-features
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
|
|
||||||
# Install binaries
|
# Install binaries
|
||||||
install -Dm755 "target/release/owlen" "$pkgdir/usr/bin/owlen"
|
install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen"
|
||||||
install -Dm755 "target/release/owlen-code" "$pkgdir/usr/bin/owlen-code"
|
install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code"
|
||||||
|
|
||||||
# Install license
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
|
||||||
|
|
||||||
# Install documentation
|
# Install documentation
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
268
README.md
268
README.md
@@ -3,17 +3,10 @@
|
|||||||
> Terminal-native assistant for running local language models with a comfortable TUI.
|
> Terminal-native assistant for running local language models with a comfortable TUI.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Alpha Status
|
|
||||||
|
|
||||||
- This project is currently in **alpha** (v0.1.0) 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?
|
## What Is OWLEN?
|
||||||
|
|
||||||
OWLEN is a Rust-powered, terminal-first interface for interacting with local large
|
OWLEN is a Rust-powered, terminal-first interface for interacting with local large
|
||||||
@@ -21,259 +14,90 @@ language models. It provides a responsive chat workflow that runs against
|
|||||||
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
|
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
|
||||||
and seamless session management—all without leaving your terminal.
|
and seamless session management—all without leaving your terminal.
|
||||||
|
|
||||||
|
## Alpha Status
|
||||||
|
|
||||||
|
This project is currently in **alpha** and under active development. Core features are functional, but expect occasional bugs and breaking changes. Feedback, bug reports, and contributions are very welcome!
|
||||||
|
|
||||||
## Screenshots
|
## 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:
|
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory.
|
||||||
- Full chat conversations (`chat_view.png`)
|
|
||||||
- Help menu (`help.png`)
|
|
||||||
- Model selection (`model_select.png`)
|
|
||||||
- Visual selection mode (`select_mode.png`)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Chat Client (`owlen`)
|
- **Vim-style Navigation**: Normal, editing, visual, and command modes.
|
||||||
- **Vim-style Navigation** - Normal, editing, visual, and command modes
|
- **Streaming Responses**: Real-time token streaming from Ollama.
|
||||||
- **Streaming Responses** - Real-time token streaming from Ollama
|
- **Advanced Text Editing**: Multi-line input, history, and clipboard support.
|
||||||
- **Multi-Panel Interface** - Separate panels for chat, thinking content, and input
|
- **Session Management**: Save, load, and manage conversations.
|
||||||
- **Advanced Text Editing** - Multi-line input with `tui-textarea`, history navigation
|
- **Theming System**: 10 built-in themes and support for custom themes.
|
||||||
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
- **Modular Architecture**: Extensible provider system (currently Ollama).
|
||||||
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
|
||||||
- **Model Management** - Interactive model and provider selection (press `m`)
|
|
||||||
- **Session Management** - Start new conversations, clear history
|
|
||||||
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
|
||||||
- **Bracketed Paste** - Safe paste handling for multi-line content
|
|
||||||
|
|
||||||
### Code Client (`owlen-code`) [Experimental]
|
|
||||||
- All chat client features
|
|
||||||
- Optimized system prompt for programming assistance
|
|
||||||
- Foundation for future code-specific features
|
|
||||||
|
|
||||||
### Core Infrastructure
|
|
||||||
- **Modular Architecture** - Separated core logic, TUI components, and providers
|
|
||||||
- **Provider System** - Extensible provider trait (currently: Ollama)
|
|
||||||
- **Session Controller** - Unified conversation and state management
|
|
||||||
- **Configuration Management** - TOML-based config with sensible defaults
|
|
||||||
- **Message Formatting** - Markdown rendering, thinking content extraction
|
|
||||||
- **Async Runtime** - Built on Tokio for efficient streaming
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Rust 1.75+ and Cargo (`rustup` recommended)
|
- Rust 1.75+ and Cargo.
|
||||||
- A running Ollama instance with at least one model pulled
|
- A running Ollama instance.
|
||||||
(defaults to `http://localhost:11434`)
|
- A terminal that supports 256 colors.
|
||||||
- A terminal that supports 256 colors
|
|
||||||
|
|
||||||
### Clone and Build
|
### Installation
|
||||||
|
|
||||||
|
#### Linux & macOS
|
||||||
|
The recommended way to install on Linux and macOS is to clone the repository and install using `cargo`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://somegit.dev/Owlibou/owlen.git
|
git clone https://github.com/Owlibou/owlen.git
|
||||||
cd owlen
|
cd owlen
|
||||||
cargo build --release
|
cargo install --path crates/owlen-cli
|
||||||
```
|
```
|
||||||
|
**Note for macOS**: While this method works, official binary releases for macOS are planned for the future.
|
||||||
|
|
||||||
### Run the Chat Client
|
#### Windows
|
||||||
|
The Windows build has not been thoroughly tested yet. Installation is possible via the same `cargo install` method, but it is considered experimental at this time.
|
||||||
|
|
||||||
Make sure Ollama is running, then launch:
|
### Running OWLEN
|
||||||
|
|
||||||
|
Make sure Ollama is running, then launch the application:
|
||||||
|
```bash
|
||||||
|
owlen
|
||||||
|
```
|
||||||
|
If you built from source without installing, you can run it with:
|
||||||
```bash
|
```bash
|
||||||
./target/release/owlen
|
./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
|
## Using the TUI
|
||||||
|
|
||||||
### Mode System (Vim-inspired)
|
OWLEN uses a modal, vim-inspired interface. Press `?` in Normal mode to view the help screen with all keybindings.
|
||||||
|
|
||||||
**Normal Mode** (default):
|
- **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`.
|
||||||
- `i` / `Enter` - Enter editing mode
|
- **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`.
|
||||||
- `a` - Append (move right and enter editing mode)
|
- **Command Mode**: Enter with `:`. Access commands like `:quit`, `:save`, `:theme`.
|
||||||
- `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**:
|
## Documentation
|
||||||
- `Esc` - Return to normal mode
|
|
||||||
- `Enter` - Send message and return to normal mode
|
|
||||||
- `Ctrl-J` / `Shift-Enter` - Insert newline
|
|
||||||
- `Ctrl-↑/↓` - Navigate input history
|
|
||||||
- Paste events handled automatically
|
|
||||||
|
|
||||||
**Visual Mode**:
|
For more detailed information, please refer to the following documents:
|
||||||
- `j/k/h/l` - Extend selection
|
|
||||||
- `w/b/e` - Word-based selection
|
|
||||||
- `y` - Yank (copy) selection
|
|
||||||
- `d` - Cut selection (Input panel only)
|
|
||||||
- `Esc` - Cancel selection
|
|
||||||
|
|
||||||
**Command Mode**:
|
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: Guidelines for contributing to the project.
|
||||||
- `:q` / `:quit` - Quit application
|
- **[CHANGELOG.md](CHANGELOG.md)**: A log of changes for each version.
|
||||||
- `:c` / `:clear` - Clear conversation
|
- **[docs/architecture.md](docs/architecture.md)**: An overview of the project's architecture.
|
||||||
- `:m` / `:model` - Open model selector
|
- **[docs/troubleshooting.md](docs/troubleshooting.md)**: Help with common issues.
|
||||||
- `:n` / `:new` - Start new conversation
|
- **[docs/provider-implementation.md](docs/provider-implementation.md)**: A guide for adding new providers.
|
||||||
- `:h` / `:help` - Show help
|
|
||||||
|
|
||||||
### 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
|
## Configuration
|
||||||
|
|
||||||
OWLEN stores configuration in `~/.config/owlen/config.toml`. The file is created
|
OWLEN stores its configuration in `~/.config/owlen/config.toml`. This file is created on the first run and can be customized. You can also add custom themes in `~/.config/owlen/themes/`.
|
||||||
on first run and can be edited to customize behavior:
|
|
||||||
|
|
||||||
```toml
|
See the [themes/README.md](themes/README.md) for more details on theming.
|
||||||
[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
|
|
||||||
```
|
|
||||||
|
|
||||||
Configuration is automatically saved when you change models or providers.
|
|
||||||
|
|
||||||
## Repository Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
owlen/
|
|
||||||
├── crates/
|
|
||||||
│ ├── owlen-core/ # Core types, session management, shared UI components
|
|
||||||
│ ├── owlen-ollama/ # Ollama provider implementation
|
|
||||||
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
|
|
||||||
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
|
|
||||||
├── LICENSE # AGPL-3.0 License
|
|
||||||
├── Cargo.toml # Workspace configuration
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Architecture Highlights
|
|
||||||
- **owlen-core**: Provider-agnostic core with session controller, UI primitives (AutoScroll, InputMode, FocusedPanel), and shared utilities
|
|
||||||
- **owlen-tui**: Ratatui-based UI implementation with vim-style modal editing
|
|
||||||
- **Separation of Concerns**: Clean boundaries between business logic, presentation, and provider implementations
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Building
|
|
||||||
```bash
|
|
||||||
# Debug build
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Release build
|
|
||||||
cargo build --release
|
|
||||||
|
|
||||||
# Build with all features
|
|
||||||
cargo build --all-features
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
cargo test
|
|
||||||
|
|
||||||
# Check code
|
|
||||||
cargo clippy
|
|
||||||
cargo fmt
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Notes
|
|
||||||
- Standard Rust workflows apply (`cargo fmt`, `cargo clippy`, `cargo test`)
|
|
||||||
- Codebase uses async Rust (`tokio`) for event handling and streaming
|
|
||||||
- Configuration is cached in `~/.config/owlen` (wipe to reset)
|
|
||||||
- UI components are extensively tested in `owlen-core/src/ui.rs`
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Completed ✓
|
We are actively working on enhancing the code client, adding more providers (OpenAI, Anthropic), and improving the overall user experience. See the [Roadmap section in the old README](https://github.com/Owlibou/owlen/blob/main/README.md?plain=1#L295) for more details.
|
||||||
- [x] Streaming responses with real-time display
|
|
||||||
- [x] Autoscroll and viewport management
|
|
||||||
- [x] Push user message before loading LLM response
|
|
||||||
- [x] Thinking mode support with dedicated panel
|
|
||||||
- [x] Vim-style modal editing (Normal, Visual, Command modes)
|
|
||||||
- [x] Multi-panel focus management
|
|
||||||
- [x] Text selection and clipboard functionality
|
|
||||||
- [x] Comprehensive keyboard navigation
|
|
||||||
- [x] Bracketed paste support
|
|
||||||
|
|
||||||
### In Progress
|
|
||||||
- [ ] Theming options and color customization
|
|
||||||
- [ ] Enhanced configuration UX (in-app settings)
|
|
||||||
- [ ] Chat history management (save/load/export)
|
|
||||||
|
|
||||||
### 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
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Here's how to get started:
|
Contributions are highly welcome! Please see our **[Contributing Guide](CONTRIBUTING.md)** for details on how to get started, including our code style, commit conventions, and pull request process.
|
||||||
|
|
||||||
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
|
## License
|
||||||
|
|
||||||
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details.
|
This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
## Acknowledgments
|
|
||||||
|
|
||||||
Built with:
|
|
||||||
- [ratatui](https://ratatui.rs/) - Terminal UI framework
|
|
||||||
- [crossterm](https://github.com/crossterm-rs/crossterm) - Cross-platform terminal manipulation
|
|
||||||
- [tokio](https://tokio.rs/) - Async runtime
|
|
||||||
- [Ollama](https://ollama.com/) - Local LLM runtime
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status**: Alpha v0.1.0 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
|
||||||
|
|||||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
We are currently in a pre-release phase, so only the latest version is actively supported. As we move towards a 1.0 release, this policy will be updated with specific version support.
|
||||||
|
|
||||||
|
| Version | Supported |
|
||||||
|
| ------- | ------------------ |
|
||||||
|
| < 1.0 | :white_check_mark: |
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
The Owlen team and community take all security vulnerabilities seriously. Thank you for improving the security of our project. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security vulnerability, please email the project lead at [security@owlibou.com](mailto:security@owlibou.com) with a detailed description of the issue, the steps to reproduce it, and any affected versions.
|
||||||
|
|
||||||
|
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible, depending on the complexity of the issue.
|
||||||
|
|
||||||
|
Please do not report security vulnerabilities through public GitHub issues.
|
||||||
5
crates/owlen-anthropic/README.md
Normal file
5
crates/owlen-anthropic/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Owlen Anthropic
|
||||||
|
|
||||||
|
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Anthropic (Claude) API.
|
||||||
|
|
||||||
|
This provider is not yet implemented. Contributions are welcome!
|
||||||
15
crates/owlen-cli/README.md
Normal file
15
crates/owlen-cli/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Owlen CLI
|
||||||
|
|
||||||
|
This crate is the command-line entry point for the Owlen application.
|
||||||
|
|
||||||
|
It is responsible for:
|
||||||
|
|
||||||
|
- Parsing command-line arguments.
|
||||||
|
- Loading the configuration.
|
||||||
|
- Initializing the providers.
|
||||||
|
- Starting the `owlen-tui` application.
|
||||||
|
|
||||||
|
There are two binaries:
|
||||||
|
|
||||||
|
- `owlen`: The main chat application.
|
||||||
|
- `owlen-code`: A specialized version for code-related tasks.
|
||||||
@@ -23,6 +23,9 @@ futures = "0.3.28"
|
|||||||
async-trait = "0.1.73"
|
async-trait = "0.1.73"
|
||||||
toml = "0.8.0"
|
toml = "0.8.0"
|
||||||
shellexpand = "3.1.0"
|
shellexpand = "3.1.0"
|
||||||
|
dirs = "5.0"
|
||||||
|
ratatui = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|||||||
12
crates/owlen-core/README.md
Normal file
12
crates/owlen-core/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Owlen Core
|
||||||
|
|
||||||
|
This crate provides the core abstractions and data structures for the Owlen ecosystem.
|
||||||
|
|
||||||
|
It defines the essential traits and types that enable communication with various LLM providers, manage sessions, and handle configuration.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
- **`Provider` trait**: The fundamental abstraction for all LLM providers. Implement this trait to add support for a new provider.
|
||||||
|
- **`Session`**: Represents a single conversation, managing message history and context.
|
||||||
|
- **`Model`**: Defines the structure for LLM models, including their names and properties.
|
||||||
|
- **Configuration**: Handles loading and parsing of the application's configuration.
|
||||||
@@ -202,7 +202,7 @@ pub struct UiSettings {
|
|||||||
|
|
||||||
impl UiSettings {
|
impl UiSettings {
|
||||||
fn default_theme() -> String {
|
fn default_theme() -> String {
|
||||||
"default".to_string()
|
"default_dark".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_word_wrap() -> bool {
|
fn default_word_wrap() -> bool {
|
||||||
@@ -238,18 +238,20 @@ impl Default for UiSettings {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StorageSettings {
|
pub struct StorageSettings {
|
||||||
#[serde(default = "StorageSettings::default_conversation_dir")]
|
#[serde(default = "StorageSettings::default_conversation_dir")]
|
||||||
pub conversation_dir: String,
|
pub conversation_dir: Option<String>,
|
||||||
#[serde(default = "StorageSettings::default_auto_save")]
|
#[serde(default = "StorageSettings::default_auto_save")]
|
||||||
pub auto_save_sessions: bool,
|
pub auto_save_sessions: bool,
|
||||||
#[serde(default = "StorageSettings::default_max_sessions")]
|
#[serde(default = "StorageSettings::default_max_sessions")]
|
||||||
pub max_saved_sessions: usize,
|
pub max_saved_sessions: usize,
|
||||||
#[serde(default = "StorageSettings::default_session_timeout")]
|
#[serde(default = "StorageSettings::default_session_timeout")]
|
||||||
pub session_timeout_minutes: u64,
|
pub session_timeout_minutes: u64,
|
||||||
|
#[serde(default = "StorageSettings::default_generate_descriptions")]
|
||||||
|
pub generate_descriptions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StorageSettings {
|
impl StorageSettings {
|
||||||
fn default_conversation_dir() -> String {
|
fn default_conversation_dir() -> Option<String> {
|
||||||
"~/.local/share/owlen/conversations".to_string()
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_auto_save() -> bool {
|
fn default_auto_save() -> bool {
|
||||||
@@ -264,19 +266,35 @@ impl StorageSettings {
|
|||||||
120
|
120
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_generate_descriptions() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve storage directory path
|
/// 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 {
|
pub fn conversation_path(&self) -> PathBuf {
|
||||||
PathBuf::from(shellexpand::tilde(&self.conversation_dir).as_ref())
|
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 {
|
impl Default for StorageSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
conversation_dir: Self::default_conversation_dir(),
|
conversation_dir: None, // Use platform-specific defaults
|
||||||
auto_save_sessions: Self::default_auto_save(),
|
auto_save_sessions: Self::default_auto_save(),
|
||||||
max_saved_sessions: Self::default_max_sessions(),
|
max_saved_sessions: Self::default_max_sessions(),
|
||||||
session_timeout_minutes: Self::default_session_timeout(),
|
session_timeout_minutes: Self::default_session_timeout(),
|
||||||
|
generate_descriptions: Self::default_generate_descriptions(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,3 +358,50 @@ pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
|
|||||||
pub fn session_timeout(config: &Config) -> Duration {
|
pub fn session_timeout(config: &Config) -> Duration {
|
||||||
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
|
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,7 +1,9 @@
|
|||||||
|
use crate::storage::StorageManager;
|
||||||
use crate::types::{Conversation, Message};
|
use crate::types::{Conversation, Message};
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use serde_json::{Number, Value};
|
use serde_json::{Number, Value};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -47,8 +49,8 @@ impl ConversationManager {
|
|||||||
&self.active
|
&self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutable access to the active conversation (auto refreshing indexes afterwards)
|
/// Public mutable access to the active conversation
|
||||||
fn active_mut(&mut self) -> &mut Conversation {
|
pub fn active_mut(&mut self) -> &mut Conversation {
|
||||||
&mut self.active
|
&mut self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +266,39 @@ impl ConversationManager {
|
|||||||
fn stream_reset(&mut self) {
|
fn stream_reset(&mut self) {
|
||||||
self.streaming.clear();
|
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 {
|
impl StreamingMetadata {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ pub mod model;
|
|||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod storage;
|
||||||
|
pub mod theme;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod wrap_cursor;
|
pub mod wrap_cursor;
|
||||||
@@ -23,6 +25,7 @@ pub use model::*;
|
|||||||
pub use provider::*;
|
pub use provider::*;
|
||||||
pub use router::*;
|
pub use router::*;
|
||||||
pub use session::*;
|
pub use session::*;
|
||||||
|
pub use theme::*;
|
||||||
|
|
||||||
/// Result type used throughout the OWLEN ecosystem
|
/// Result type used throughout the OWLEN ecosystem
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
@@ -54,6 +57,9 @@ pub enum Error {
|
|||||||
#[error("Serialization error: {0}")]
|
#[error("Serialization error: {0}")]
|
||||||
Serialization(#[from] serde_json::Error),
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Storage error: {0}")]
|
||||||
|
Storage(String),
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,72 @@ use std::sync::Arc;
|
|||||||
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
|
||||||
|
|
||||||
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
|
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use std::pin::Pin;
|
||||||
|
/// use std::sync::Arc;
|
||||||
|
/// use futures::Stream;
|
||||||
|
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
|
||||||
|
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
|
||||||
|
/// use owlen_core::Result;
|
||||||
|
///
|
||||||
|
/// // 1. Create a mock provider
|
||||||
|
/// struct MockProvider;
|
||||||
|
///
|
||||||
|
/// #[async_trait::async_trait]
|
||||||
|
/// impl Provider for MockProvider {
|
||||||
|
/// fn name(&self) -> &str {
|
||||||
|
/// "mock"
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
|
||||||
|
/// Ok(vec![ModelInfo {
|
||||||
|
/// provider: "mock".to_string(),
|
||||||
|
/// name: "mock-model".to_string(),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// }])
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
|
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
|
||||||
|
/// Ok(ChatResponse {
|
||||||
|
/// model: request.model,
|
||||||
|
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
|
||||||
|
/// ..Default::default()
|
||||||
|
/// })
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
|
||||||
|
/// unimplemented!();
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// async fn health_check(&self) -> Result<()> {
|
||||||
|
/// Ok(())
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // 2. Use the provider with a registry
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let mut registry = ProviderRegistry::new();
|
||||||
|
/// registry.register(MockProvider);
|
||||||
|
///
|
||||||
|
/// let provider = registry.get("mock").unwrap();
|
||||||
|
/// let models = provider.list_models().await.unwrap();
|
||||||
|
/// assert_eq!(models[0].name, "mock-model");
|
||||||
|
///
|
||||||
|
/// let request = ChatRequest {
|
||||||
|
/// model: "mock-model".to_string(),
|
||||||
|
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
|
||||||
|
/// ..Default::default()
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// let response = provider.chat(request).await.unwrap();
|
||||||
|
/// assert_eq!(response.message.content, "Response to: Hello");
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait Provider: Send + Sync {
|
pub trait Provider: Send + Sync {
|
||||||
/// Get the name of this provider
|
/// Get the name of this provider
|
||||||
@@ -87,9 +153,8 @@ impl ProviderRegistry {
|
|||||||
for provider in self.providers.values() {
|
for provider in self.providers.values() {
|
||||||
match provider.list_models().await {
|
match provider.list_models().await {
|
||||||
Ok(mut models) => all_models.append(&mut models),
|
Ok(mut models) => all_models.append(&mut models),
|
||||||
Err(e) => {
|
Err(_) => {
|
||||||
// Log error but continue with other providers
|
// Continue with other providers
|
||||||
eprintln!("Failed to get models from {}: {}", provider.name(), e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,61 @@ pub enum SessionOutcome {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-level controller encapsulating session state and provider interactions
|
/// High-level controller encapsulating session state and provider interactions.
|
||||||
|
///
|
||||||
|
/// This is the main entry point for managing conversations and interacting with LLM providers.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use std::sync::Arc;
|
||||||
|
/// use owlen_core::config::Config;
|
||||||
|
/// use owlen_core::provider::{Provider, ChatStream};
|
||||||
|
/// use owlen_core::session::{SessionController, SessionOutcome};
|
||||||
|
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
|
||||||
|
/// use owlen_core::Result;
|
||||||
|
///
|
||||||
|
/// // Mock provider for the example
|
||||||
|
/// struct MockProvider;
|
||||||
|
/// #[async_trait::async_trait]
|
||||||
|
/// impl Provider for MockProvider {
|
||||||
|
/// fn name(&self) -> &str { "mock" }
|
||||||
|
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
|
||||||
|
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
|
||||||
|
/// Ok(ChatResponse {
|
||||||
|
/// model: request.model,
|
||||||
|
/// message: Message::assistant("Hello back!".to_string()),
|
||||||
|
/// ..Default::default()
|
||||||
|
/// })
|
||||||
|
/// }
|
||||||
|
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
|
||||||
|
/// async fn health_check(&self) -> Result<()> { Ok(()) }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// #[tokio::main]
|
||||||
|
/// async fn main() {
|
||||||
|
/// let provider = Arc::new(MockProvider);
|
||||||
|
/// let config = Config::default();
|
||||||
|
/// let mut session_controller = SessionController::new(provider, config);
|
||||||
|
///
|
||||||
|
/// // Send a message
|
||||||
|
/// let outcome = session_controller.send_message(
|
||||||
|
/// "Hello".to_string(),
|
||||||
|
/// ChatParameters { stream: false, ..Default::default() }
|
||||||
|
/// ).await.unwrap();
|
||||||
|
///
|
||||||
|
/// // Check the response
|
||||||
|
/// if let SessionOutcome::Complete(response) = outcome {
|
||||||
|
/// assert_eq!(response.message.content, "Hello back!");
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // The conversation now contains both messages
|
||||||
|
/// let messages = session_controller.conversation().messages.clone();
|
||||||
|
/// assert_eq!(messages.len(), 2);
|
||||||
|
/// assert_eq!(messages[0].content, "Hello");
|
||||||
|
/// assert_eq!(messages[1].content, "Hello back!");
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
pub struct SessionController {
|
pub struct SessionController {
|
||||||
provider: Arc<dyn Provider>,
|
provider: Arc<dyn Provider>,
|
||||||
conversation: ConversationManager,
|
conversation: ConversationManager,
|
||||||
@@ -218,4 +272,113 @@ impl SessionController {
|
|||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.conversation.clear();
|
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 {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
309
crates/owlen-core/src/storage.rs
Normal file
309
crates/owlen-core/src/storage.rs
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
645
crates/owlen-core/src/theme.rs
Normal file
645
crates/owlen-core/src/theme.rs
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
//! 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ pub struct Conversation {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
/// Optional name/title for the conversation
|
/// Optional name/title for the conversation
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
/// Optional AI-generated description of the conversation
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
/// Messages in chronological order
|
/// Messages in chronological order
|
||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
/// Model used for this conversation
|
/// Model used for this conversation
|
||||||
@@ -167,6 +170,7 @@ impl Conversation {
|
|||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: None,
|
name: None,
|
||||||
|
description: None,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
model,
|
model,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub enum InputMode {
|
|||||||
Help,
|
Help,
|
||||||
Visual,
|
Visual,
|
||||||
Command,
|
Command,
|
||||||
|
SessionBrowser,
|
||||||
|
ThemeBrowser,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InputMode {
|
impl fmt::Display for InputMode {
|
||||||
@@ -34,6 +36,8 @@ impl fmt::Display for InputMode {
|
|||||||
InputMode::Help => "Help",
|
InputMode::Help => "Help",
|
||||||
InputMode::Visual => "Visual",
|
InputMode::Visual => "Visual",
|
||||||
InputMode::Command => "Command",
|
InputMode::Command => "Command",
|
||||||
|
InputMode::SessionBrowser => "Sessions",
|
||||||
|
InputMode::ThemeBrowser => "Themes",
|
||||||
};
|
};
|
||||||
f.write_str(label)
|
f.write_str(label)
|
||||||
}
|
}
|
||||||
|
|||||||
5
crates/owlen-gemini/README.md
Normal file
5
crates/owlen-gemini/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Owlen Gemini
|
||||||
|
|
||||||
|
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Google Gemini API.
|
||||||
|
|
||||||
|
This provider is not yet implemented. Contributions are welcome!
|
||||||
@@ -31,4 +31,4 @@ uuid = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
|
|||||||
9
crates/owlen-ollama/README.md
Normal file
9
crates/owlen-ollama/README.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Owlen Ollama
|
||||||
|
|
||||||
|
This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend.
|
||||||
|
|
||||||
|
It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To use this provider, you need to have Ollama installed and running. The default address is `http://localhost:11434`. You can configure this in your `config.toml` if your Ollama instance is running elsewhere.
|
||||||
5
crates/owlen-openai/README.md
Normal file
5
crates/owlen-openai/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Owlen OpenAI
|
||||||
|
|
||||||
|
This crate is a placeholder for a future `owlen-core::Provider` implementation for the OpenAI API.
|
||||||
|
|
||||||
|
This provider is not yet implemented. Contributions are welcome!
|
||||||
12
crates/owlen-tui/README.md
Normal file
12
crates/owlen-tui/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Owlen TUI
|
||||||
|
|
||||||
|
This crate contains all the logic for the terminal user interface (TUI) of Owlen.
|
||||||
|
|
||||||
|
It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for rendering the chat interface, handling user input, and managing the application state.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Chat View**: A scrollable view of the conversation history.
|
||||||
|
- **Input Box**: A text input area for composing messages.
|
||||||
|
- **Model Selection**: An interface for switching between different models.
|
||||||
|
- **Event Handling**: A system for managing keyboard events and asynchronous operations.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
session::{SessionController, SessionOutcome},
|
session::{SessionController, SessionOutcome},
|
||||||
|
storage::{SessionMeta, StorageManager},
|
||||||
|
theme::Theme,
|
||||||
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
||||||
ui::{AppState, AutoScroll, FocusedPanel, InputMode},
|
ui::{AppState, AutoScroll, FocusedPanel, InputMode},
|
||||||
};
|
};
|
||||||
@@ -50,11 +52,20 @@ pub struct ChatApp {
|
|||||||
pending_key: Option<char>, // For multi-key sequences like gg, dd
|
pending_key: Option<char>, // For multi-key sequences like gg, dd
|
||||||
clipboard: String, // Vim-style clipboard for yank/paste
|
clipboard: String, // Vim-style clipboard for yank/paste
|
||||||
command_buffer: String, // Buffer for command mode input
|
command_buffer: String, // Buffer for command mode input
|
||||||
|
command_suggestions: Vec<String>, // Filtered command suggestions based on current input
|
||||||
|
selected_suggestion: usize, // Index of selected suggestion
|
||||||
visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel
|
visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel
|
||||||
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
|
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
|
||||||
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
||||||
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
|
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
|
||||||
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
||||||
|
storage: StorageManager, // Storage manager for session persistence
|
||||||
|
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
|
||||||
|
selected_session_index: usize, // Index of selected session in browser
|
||||||
|
help_tab_index: usize, // Currently selected help tab (0-4)
|
||||||
|
theme: Theme, // Current theme
|
||||||
|
available_themes: Vec<String>, // Cached list of theme names
|
||||||
|
selected_theme_index: usize, // Index of selected theme in browser
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatApp {
|
impl ChatApp {
|
||||||
@@ -63,6 +74,19 @@ impl ChatApp {
|
|||||||
let mut textarea = TextArea::default();
|
let mut textarea = TextArea::default();
|
||||||
configure_textarea_defaults(&mut textarea);
|
configure_textarea_defaults(&mut textarea);
|
||||||
|
|
||||||
|
let storage = StorageManager::new().unwrap_or_else(|e| {
|
||||||
|
eprintln!("Warning: Failed to initialize storage: {}", e);
|
||||||
|
StorageManager::with_directory(std::path::PathBuf::from("/tmp/owlen_sessions"))
|
||||||
|
.expect("Failed to create fallback storage")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load theme based on config
|
||||||
|
let theme_name = &controller.config().ui.theme;
|
||||||
|
let theme = owlen_core::theme::get_theme(theme_name).unwrap_or_else(|| {
|
||||||
|
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
|
||||||
|
Theme::default()
|
||||||
|
});
|
||||||
|
|
||||||
let app = Self {
|
let app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: InputMode::Normal,
|
mode: InputMode::Normal,
|
||||||
@@ -88,11 +112,20 @@ impl ChatApp {
|
|||||||
pending_key: None,
|
pending_key: None,
|
||||||
clipboard: String::new(),
|
clipboard: String::new(),
|
||||||
command_buffer: String::new(),
|
command_buffer: String::new(),
|
||||||
|
command_suggestions: Vec::new(),
|
||||||
|
selected_suggestion: 0,
|
||||||
visual_start: None,
|
visual_start: None,
|
||||||
visual_end: None,
|
visual_end: None,
|
||||||
focused_panel: FocusedPanel::Input,
|
focused_panel: FocusedPanel::Input,
|
||||||
chat_cursor: (0, 0),
|
chat_cursor: (0, 0),
|
||||||
thinking_cursor: (0, 0),
|
thinking_cursor: (0, 0),
|
||||||
|
storage,
|
||||||
|
saved_sessions: Vec::new(),
|
||||||
|
selected_session_index: 0,
|
||||||
|
help_tab_index: 0,
|
||||||
|
theme,
|
||||||
|
available_themes: Vec::new(),
|
||||||
|
selected_theme_index: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
(app, session_rx)
|
(app, session_rx)
|
||||||
@@ -189,6 +222,79 @@ impl ChatApp {
|
|||||||
&self.command_buffer
|
&self.command_buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn command_suggestions(&self) -> &[String] {
|
||||||
|
&self.command_suggestions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_suggestion(&self) -> usize {
|
||||||
|
self.selected_suggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all available commands with their aliases
|
||||||
|
fn get_all_commands() -> Vec<(&'static str, &'static str)> {
|
||||||
|
vec![
|
||||||
|
("quit", "Exit the application"),
|
||||||
|
("q", "Alias for quit"),
|
||||||
|
("clear", "Clear the conversation"),
|
||||||
|
("c", "Alias for clear"),
|
||||||
|
("w", "Alias for write"),
|
||||||
|
("save", "Alias for write"),
|
||||||
|
("load", "Load a saved conversation"),
|
||||||
|
("open", "Alias for load"),
|
||||||
|
("o", "Alias for load"),
|
||||||
|
("sessions", "List saved sessions"),
|
||||||
|
("ls", "Alias for sessions"),
|
||||||
|
("help", "Show help documentation"),
|
||||||
|
("h", "Alias for help"),
|
||||||
|
("model", "Select a model"),
|
||||||
|
("m", "Alias for model"),
|
||||||
|
("new", "Start a new conversation"),
|
||||||
|
("n", "Alias for new"),
|
||||||
|
("theme", "Switch theme"),
|
||||||
|
("themes", "List available themes"),
|
||||||
|
("reload", "Reload configuration and themes"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update command suggestions based on current input
|
||||||
|
fn update_command_suggestions(&mut self) {
|
||||||
|
let input = self.command_buffer.trim();
|
||||||
|
|
||||||
|
if input.is_empty() {
|
||||||
|
// Show all commands when input is empty
|
||||||
|
self.command_suggestions = Self::get_all_commands()
|
||||||
|
.iter()
|
||||||
|
.map(|(cmd, _)| cmd.to_string())
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
// Filter commands that start with the input
|
||||||
|
self.command_suggestions = Self::get_all_commands()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(cmd, _)| {
|
||||||
|
if cmd.starts_with(input) {
|
||||||
|
Some(cmd.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset selection if out of bounds
|
||||||
|
if self.selected_suggestion >= self.command_suggestions.len() {
|
||||||
|
self.selected_suggestion = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete the current command with the selected suggestion
|
||||||
|
fn complete_command(&mut self) {
|
||||||
|
if let Some(suggestion) = self.command_suggestions.get(self.selected_suggestion) {
|
||||||
|
self.command_buffer = suggestion.clone();
|
||||||
|
self.update_command_suggestions();
|
||||||
|
self.status = format!(":{}", self.command_buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn focused_panel(&self) -> FocusedPanel {
|
pub fn focused_panel(&self) -> FocusedPanel {
|
||||||
self.focused_panel
|
self.focused_panel
|
||||||
}
|
}
|
||||||
@@ -209,6 +315,51 @@ impl ChatApp {
|
|||||||
self.thinking_cursor
|
self.thinking_cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn saved_sessions(&self) -> &[SessionMeta] {
|
||||||
|
&self.saved_sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_session_index(&self) -> usize {
|
||||||
|
self.selected_session_index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn help_tab_index(&self) -> usize {
|
||||||
|
self.help_tab_index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available_themes(&self) -> &[String] {
|
||||||
|
&self.available_themes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_theme_index(&self) -> usize {
|
||||||
|
self.selected_theme_index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn theme(&self) -> &Theme {
|
||||||
|
&self.theme
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_theme(&mut self, theme: Theme) {
|
||||||
|
self.theme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> {
|
||||||
|
if let Some(theme) = owlen_core::theme::get_theme(theme_name) {
|
||||||
|
self.theme = theme;
|
||||||
|
// Save theme to config
|
||||||
|
self.controller.config_mut().ui.theme = theme_name.to_string();
|
||||||
|
if let Err(err) = config::save_config(self.controller.config()) {
|
||||||
|
self.error = Some(format!("Failed to save theme config: {}", err));
|
||||||
|
} else {
|
||||||
|
self.status = format!("Switched to theme: {}", theme_name);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
self.error = Some(format!("Theme '{}' not found", theme_name));
|
||||||
|
Err(anyhow::anyhow!("Theme '{}' not found", theme_name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cycle_focus_forward(&mut self) {
|
pub fn cycle_focus_forward(&mut self) {
|
||||||
self.focused_panel = match self.focused_panel {
|
self.focused_panel = match self.focused_panel {
|
||||||
FocusedPanel::Chat => {
|
FocusedPanel::Chat => {
|
||||||
@@ -387,6 +538,8 @@ impl ChatApp {
|
|||||||
(KeyCode::Char(':'), KeyModifiers::NONE) => {
|
(KeyCode::Char(':'), KeyModifiers::NONE) => {
|
||||||
self.mode = InputMode::Command;
|
self.mode = InputMode::Command;
|
||||||
self.command_buffer.clear();
|
self.command_buffer.clear();
|
||||||
|
self.selected_suggestion = 0;
|
||||||
|
self.update_command_suggestions();
|
||||||
self.status = ":".to_string();
|
self.status = ":".to_string();
|
||||||
}
|
}
|
||||||
// Enter editing mode
|
// Enter editing mode
|
||||||
@@ -971,12 +1124,34 @@ impl ChatApp {
|
|||||||
(KeyCode::Esc, _) => {
|
(KeyCode::Esc, _) => {
|
||||||
self.mode = InputMode::Normal;
|
self.mode = InputMode::Normal;
|
||||||
self.command_buffer.clear();
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
self.reset_status();
|
self.reset_status();
|
||||||
}
|
}
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
// Tab completion
|
||||||
|
self.complete_command();
|
||||||
|
}
|
||||||
|
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
|
||||||
|
// Navigate up in suggestions
|
||||||
|
if !self.command_suggestions.is_empty() {
|
||||||
|
self.selected_suggestion = self.selected_suggestion.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
|
||||||
|
// Navigate down in suggestions
|
||||||
|
if !self.command_suggestions.is_empty() {
|
||||||
|
self.selected_suggestion = (self.selected_suggestion + 1)
|
||||||
|
.min(self.command_suggestions.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
(KeyCode::Enter, _) => {
|
(KeyCode::Enter, _) => {
|
||||||
// Execute command
|
// Execute command
|
||||||
let cmd = self.command_buffer.trim();
|
let cmd = self.command_buffer.trim();
|
||||||
match cmd {
|
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||||
|
let command = parts.first().copied().unwrap_or("");
|
||||||
|
let args = &parts[1..];
|
||||||
|
|
||||||
|
match command {
|
||||||
"q" | "quit" => {
|
"q" | "quit" => {
|
||||||
return Ok(AppState::Quit);
|
return Ok(AppState::Quit);
|
||||||
}
|
}
|
||||||
@@ -984,39 +1159,174 @@ impl ChatApp {
|
|||||||
self.controller.clear();
|
self.controller.clear();
|
||||||
self.status = "Conversation cleared".to_string();
|
self.status = "Conversation cleared".to_string();
|
||||||
}
|
}
|
||||||
"w" | "write" => {
|
"w" | "write" | "save" => {
|
||||||
// Could implement saving conversation here
|
// Save current conversation with AI-generated description
|
||||||
self.status = "Conversation saved".to_string();
|
let name = if !args.is_empty() {
|
||||||
|
Some(args.join(" "))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate description if enabled in config
|
||||||
|
let description =
|
||||||
|
if self.controller.config().storage.generate_descriptions {
|
||||||
|
self.status = "Generating description...".to_string();
|
||||||
|
(self.controller.generate_conversation_description().await)
|
||||||
|
.ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the conversation with description
|
||||||
|
match self
|
||||||
|
.controller
|
||||||
|
.conversation_mut()
|
||||||
|
.save_active_with_description(
|
||||||
|
&self.storage,
|
||||||
|
name.clone(),
|
||||||
|
description,
|
||||||
|
) {
|
||||||
|
Ok(path) => {
|
||||||
|
self.status = format!("Session saved: {}", path.display());
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error = Some(format!("Failed to save session: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"load" | "open" | "o" => {
|
||||||
|
// Load saved sessions and enter browser mode
|
||||||
|
match self.storage.list_sessions() {
|
||||||
|
Ok(sessions) => {
|
||||||
|
self.saved_sessions = sessions;
|
||||||
|
self.selected_session_index = 0;
|
||||||
|
self.mode = InputMode::SessionBrowser;
|
||||||
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
|
return Ok(AppState::Running);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error =
|
||||||
|
Some(format!("Failed to list sessions: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"sessions" | "ls" => {
|
||||||
|
// List saved sessions
|
||||||
|
match self.storage.list_sessions() {
|
||||||
|
Ok(sessions) => {
|
||||||
|
self.saved_sessions = sessions;
|
||||||
|
self.selected_session_index = 0;
|
||||||
|
self.mode = InputMode::SessionBrowser;
|
||||||
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
|
return Ok(AppState::Running);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error =
|
||||||
|
Some(format!("Failed to list sessions: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"h" | "help" => {
|
"h" | "help" => {
|
||||||
self.mode = InputMode::Help;
|
self.mode = InputMode::Help;
|
||||||
self.command_buffer.clear();
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
"m" | "model" => {
|
"m" | "model" => {
|
||||||
self.refresh_models().await?;
|
self.refresh_models().await?;
|
||||||
self.mode = InputMode::ProviderSelection;
|
self.mode = InputMode::ProviderSelection;
|
||||||
self.command_buffer.clear();
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
"n" | "new" => {
|
"n" | "new" => {
|
||||||
self.controller.start_new_conversation(None, None);
|
self.controller.start_new_conversation(None, None);
|
||||||
self.status = "Started new conversation".to_string();
|
self.status = "Started new conversation".to_string();
|
||||||
}
|
}
|
||||||
|
"theme" => {
|
||||||
|
if args.is_empty() {
|
||||||
|
self.error = Some("Usage: :theme <name>".to_string());
|
||||||
|
} else {
|
||||||
|
let theme_name = args.join(" ");
|
||||||
|
match self.switch_theme(&theme_name) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Success message already set by switch_theme
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Error message already set by switch_theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"themes" => {
|
||||||
|
// Load all themes and enter browser mode
|
||||||
|
let themes = owlen_core::theme::load_all_themes();
|
||||||
|
let mut theme_list: Vec<String> = themes.keys().cloned().collect();
|
||||||
|
theme_list.sort();
|
||||||
|
|
||||||
|
self.available_themes = theme_list;
|
||||||
|
|
||||||
|
// Set selected index to current theme
|
||||||
|
let current_theme = &self.theme.name;
|
||||||
|
self.selected_theme_index = self
|
||||||
|
.available_themes
|
||||||
|
.iter()
|
||||||
|
.position(|name| name == current_theme)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
self.mode = InputMode::ThemeBrowser;
|
||||||
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
|
return Ok(AppState::Running);
|
||||||
|
}
|
||||||
|
"reload" => {
|
||||||
|
// Reload config
|
||||||
|
match owlen_core::config::Config::load(None) {
|
||||||
|
Ok(new_config) => {
|
||||||
|
// Update controller config
|
||||||
|
*self.controller.config_mut() = new_config.clone();
|
||||||
|
|
||||||
|
// Reload theme based on updated config
|
||||||
|
let theme_name = &new_config.ui.theme;
|
||||||
|
if let Some(new_theme) =
|
||||||
|
owlen_core::theme::get_theme(theme_name)
|
||||||
|
{
|
||||||
|
self.theme = new_theme;
|
||||||
|
self.status = format!(
|
||||||
|
"Configuration and theme reloaded (theme: {})",
|
||||||
|
theme_name
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.status = "Configuration reloaded, but theme not found. Using current theme.".to_string();
|
||||||
|
}
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error =
|
||||||
|
Some(format!("Failed to reload config: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.error = Some(format!("Unknown command: {}", cmd));
|
self.error = Some(format!("Unknown command: {}", cmd));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.command_buffer.clear();
|
self.command_buffer.clear();
|
||||||
|
self.command_suggestions.clear();
|
||||||
self.mode = InputMode::Normal;
|
self.mode = InputMode::Normal;
|
||||||
}
|
}
|
||||||
(KeyCode::Char(c), KeyModifiers::NONE)
|
(KeyCode::Char(c), KeyModifiers::NONE)
|
||||||
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
|
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
|
||||||
self.command_buffer.push(c);
|
self.command_buffer.push(c);
|
||||||
|
self.update_command_suggestions();
|
||||||
self.status = format!(":{}", self.command_buffer);
|
self.status = format!(":{}", self.command_buffer);
|
||||||
}
|
}
|
||||||
(KeyCode::Backspace, _) => {
|
(KeyCode::Backspace, _) => {
|
||||||
self.command_buffer.pop();
|
self.command_buffer.pop();
|
||||||
|
self.update_command_suggestions();
|
||||||
self.status = format!(":{}", self.command_buffer);
|
self.status = format!(":{}", self.command_buffer);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -1092,8 +1402,130 @@ impl ChatApp {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
InputMode::Help => match key.code {
|
InputMode::Help => match key.code {
|
||||||
KeyCode::Esc | KeyCode::Enter => {
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
|
||||||
self.mode = InputMode::Normal;
|
self.mode = InputMode::Normal;
|
||||||
|
self.help_tab_index = 0; // Reset to first tab
|
||||||
|
}
|
||||||
|
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||||
|
// Next tab
|
||||||
|
if self.help_tab_index < 5 {
|
||||||
|
self.help_tab_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||||
|
// Previous tab
|
||||||
|
if self.help_tab_index > 0 {
|
||||||
|
self.help_tab_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('1') => self.help_tab_index = 0,
|
||||||
|
KeyCode::Char('2') => self.help_tab_index = 1,
|
||||||
|
KeyCode::Char('3') => self.help_tab_index = 2,
|
||||||
|
KeyCode::Char('4') => self.help_tab_index = 3,
|
||||||
|
KeyCode::Char('5') => self.help_tab_index = 4,
|
||||||
|
KeyCode::Char('6') => self.help_tab_index = 5,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
InputMode::SessionBrowser => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.mode = InputMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Load selected session
|
||||||
|
if let Some(session) = self.saved_sessions.get(self.selected_session_index)
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
.controller
|
||||||
|
.conversation_mut()
|
||||||
|
.load_from_disk(&self.storage, &session.path)
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
self.status = format!(
|
||||||
|
"Loaded session: {}",
|
||||||
|
session.name.as_deref().unwrap_or("Unnamed")
|
||||||
|
);
|
||||||
|
self.error = None;
|
||||||
|
// Update thinking panel
|
||||||
|
self.update_thinking_from_last_message();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error = Some(format!("Failed to load session: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.mode = InputMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if self.selected_session_index > 0 {
|
||||||
|
self.selected_session_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if self.selected_session_index + 1 < self.saved_sessions.len() {
|
||||||
|
self.selected_session_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') => {
|
||||||
|
// Delete selected session
|
||||||
|
if let Some(session) = self.saved_sessions.get(self.selected_session_index)
|
||||||
|
{
|
||||||
|
match self.storage.delete_session(&session.path) {
|
||||||
|
Ok(_) => {
|
||||||
|
self.saved_sessions.remove(self.selected_session_index);
|
||||||
|
if self.selected_session_index >= self.saved_sessions.len()
|
||||||
|
&& !self.saved_sessions.is_empty()
|
||||||
|
{
|
||||||
|
self.selected_session_index = self.saved_sessions.len() - 1;
|
||||||
|
}
|
||||||
|
self.status = "Session deleted".to_string();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error = Some(format!("Failed to delete session: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
InputMode::ThemeBrowser => match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
self.mode = InputMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
// Apply selected theme
|
||||||
|
if let Some(theme_name) = self
|
||||||
|
.available_themes
|
||||||
|
.get(self.selected_theme_index)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
match self.switch_theme(&theme_name) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Success message already set by switch_theme
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Error message already set by switch_theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.mode = InputMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if self.selected_theme_index > 0 {
|
||||||
|
self.selected_theme_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if self.selected_theme_index + 1 < self.available_themes.len() {
|
||||||
|
self.selected_theme_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home | KeyCode::Char('g') => {
|
||||||
|
self.selected_theme_index = 0;
|
||||||
|
}
|
||||||
|
KeyCode::End | KeyCode::Char('G') => {
|
||||||
|
if !self.available_themes.is_empty() {
|
||||||
|
self.selected_theme_index = self.available_themes.len() - 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
@@ -1342,7 +1774,7 @@ impl ChatApp {
|
|||||||
stream,
|
stream,
|
||||||
}) => {
|
}) => {
|
||||||
// Step 3: Model loaded, now generating response
|
// Step 3: Model loaded, now generating response
|
||||||
self.status = "Generating response...".to_string();
|
self.status = "Model loaded. Generating response... (streaming)".to_string();
|
||||||
|
|
||||||
self.spawn_stream(response_id, stream);
|
self.spawn_stream(response_id, stream);
|
||||||
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
//! # Owlen TUI
|
||||||
|
//!
|
||||||
|
//! This crate contains all the logic for the terminal user interface (TUI) of Owlen.
|
||||||
|
//!
|
||||||
|
//! It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for
|
||||||
|
//! rendering the chat interface, handling user input, and managing the application state.
|
||||||
|
//!
|
||||||
|
//! ## Modules
|
||||||
|
//! - `chat_app`: The main application logic for the chat client.
|
||||||
|
//! - `code_app`: The main application logic for the experimental code client.
|
||||||
|
//! - `config`: TUI-specific configuration.
|
||||||
|
//! - `events`: Event handling for user input and other asynchronous actions.
|
||||||
|
//! - `ui`: The rendering logic for all TUI components.
|
||||||
|
|
||||||
pub mod chat_app;
|
pub mod chat_app;
|
||||||
pub mod code_app;
|
pub mod code_app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
71
docs/architecture.md
Normal file
71
docs/architecture.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Owlen Architecture
|
||||||
|
|
||||||
|
This document provides a high-level overview of the Owlen architecture. Its purpose is to help developers understand how the different parts of the application fit together.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
The architecture is designed to be modular and extensible, centered around a few key concepts:
|
||||||
|
|
||||||
|
- **Providers**: Connect to various LLM APIs (Ollama, OpenAI, etc.).
|
||||||
|
- **Session**: Manages the conversation history and state.
|
||||||
|
- **TUI**: The terminal user interface, built with `ratatui`.
|
||||||
|
- **Events**: A system for handling user input and other events.
|
||||||
|
|
||||||
|
## Component Interaction
|
||||||
|
|
||||||
|
A simplified diagram of how components interact:
|
||||||
|
|
||||||
|
```
|
||||||
|
[User Input] -> [Event Loop] -> [Session Controller] -> [Provider]
|
||||||
|
^ |
|
||||||
|
| v
|
||||||
|
[TUI Renderer] <------------------------------------ [API Response]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **User Input**: The user interacts with the TUI, generating events (e.g., key presses).
|
||||||
|
2. **Event Loop**: The main event loop in `owlen-tui` captures these events.
|
||||||
|
3. **Session Controller**: The event is processed, and if it's a prompt, the session controller sends a request to the current provider.
|
||||||
|
4. **Provider**: The provider formats the request for the specific LLM API and sends it.
|
||||||
|
5. **API Response**: The LLM API returns a response.
|
||||||
|
6. **TUI Renderer**: The response is processed, the session state is updated, and the TUI is re-rendered to display the new information.
|
||||||
|
|
||||||
|
## Crate Breakdown
|
||||||
|
|
||||||
|
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`.
|
||||||
|
- `owlen-tui`: Contains all the logic for the terminal user interface, including event handling and rendering.
|
||||||
|
- `owlen-cli`: The command-line entry point, responsible for parsing arguments and starting the TUI.
|
||||||
|
- `owlen-ollama` / `owlen-openai` / etc.: Implementations of the `Provider` trait for specific services.
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
The session management system is responsible for tracking the state of a conversation. The two main structs are:
|
||||||
|
|
||||||
|
- **`Conversation`**: Found in `owlen-core`, this struct holds the messages of a single conversation, the model being used, and other metadata. It is a simple data container.
|
||||||
|
- **`SessionController`**: This is the high-level controller that manages the active conversation. It handles:
|
||||||
|
- Storing and retrieving conversation history via the `ConversationManager`.
|
||||||
|
- Managing the context that is sent to the LLM provider.
|
||||||
|
- Switching between different models.
|
||||||
|
- Sending requests to the provider and handling the responses (both streaming and complete).
|
||||||
|
|
||||||
|
When a user sends a message, the `SessionController` adds the message to the current `Conversation`, sends the updated message list to the `Provider`, and then adds the provider's response to the `Conversation`.
|
||||||
|
|
||||||
|
## Event Flow
|
||||||
|
|
||||||
|
The event flow is managed by the `EventHandler` in `owlen-tui`. It operates in a loop, waiting for events and dispatching them to the active application (`ChatApp` or `CodeApp`).
|
||||||
|
|
||||||
|
1. **Event Source**: Events are primarily generated by `crossterm` from user keyboard input. Asynchronous events, like responses from a `Provider`, are also fed into the event system via a `tokio::mpsc` channel.
|
||||||
|
2. **`EventHandler::next()`**: The main application loop calls this method to wait for the next event.
|
||||||
|
3. **Event Enum**: Events are defined in the `owlen_tui::events::Event` enum. This includes `Key` events, `Tick` events (for UI updates), and `Message` events (for async provider data).
|
||||||
|
4. **Dispatch**: The application's `run` method matches on the `Event` type and calls the appropriate handler function (e.g., `dispatch_key_event`).
|
||||||
|
5. **State Update**: The handler function updates the application state based on the event. For example, a key press might change the `InputMode` or modify the text in the input buffer.
|
||||||
|
6. **Re-render**: After the state is updated, the UI is re-rendered to reflect the changes.
|
||||||
|
|
||||||
|
## TUI Rendering Pipeline
|
||||||
|
|
||||||
|
The TUI is rendered on each iteration of the main application loop in `owlen-tui`. The process is as follows:
|
||||||
|
|
||||||
|
1. **`tui.draw()`**: The main loop calls this method, passing the current application state.
|
||||||
|
2. **`Terminal::draw()`**: This method, from `ratatui`, takes a closure that receives a `Frame`.
|
||||||
|
3. **UI Composition**: Inside the closure, the UI is built by composing `ratatui` widgets. The root UI is defined in `owlen_tui::ui::render`, which builds the main layout and calls other functions to render specific components (like the chat panel, input box, etc.).
|
||||||
|
4. **State-Driven Rendering**: Each rendering function takes the current application state as an argument. It uses this state to decide what and how to render. For example, the border color of a panel might change if it is focused.
|
||||||
|
5. **Buffer and Diff**: `ratatui` does not draw directly to the terminal. Instead, it renders the widgets to an in-memory buffer. It then compares this buffer to the previous buffer and only sends the necessary changes to the terminal. This is highly efficient and prevents flickering.
|
||||||
118
docs/configuration.md
Normal file
118
docs/configuration.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# Owlen Configuration
|
||||||
|
|
||||||
|
Owlen uses a TOML file for configuration, allowing you to customize its behavior to your liking. This document details all the available options.
|
||||||
|
|
||||||
|
## File Location
|
||||||
|
|
||||||
|
By default, Owlen looks for its configuration file at `~/.config/owlen/config.toml`.
|
||||||
|
|
||||||
|
A default configuration file is created on the first run if one doesn't exist.
|
||||||
|
|
||||||
|
## Configuration Precedence
|
||||||
|
|
||||||
|
Configuration values are resolved in the following order:
|
||||||
|
|
||||||
|
1. **Defaults**: The application has hard-coded default values for all settings.
|
||||||
|
2. **Configuration File**: Any values set in `config.toml` will override the defaults.
|
||||||
|
3. **Command-Line Arguments / In-App Changes**: Any settings changed during runtime (e.g., via the `:theme` or `:model` commands) will override the configuration file for the current session. Some of these changes (like theme and model) are automatically saved back to the configuration file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Settings (`[general]`)
|
||||||
|
|
||||||
|
These settings control the core behavior of the application.
|
||||||
|
|
||||||
|
- `default_provider` (string, default: `"ollama"`)
|
||||||
|
The name of the provider to use by default.
|
||||||
|
|
||||||
|
- `default_model` (string, optional, default: `"llama3.2:latest"`)
|
||||||
|
The default model to use for new conversations.
|
||||||
|
|
||||||
|
- `enable_streaming` (boolean, default: `true`)
|
||||||
|
Whether to stream responses from the provider by default.
|
||||||
|
|
||||||
|
- `project_context_file` (string, optional, default: `"OWLEN.md"`)
|
||||||
|
Path to a file whose content will be automatically injected as a system prompt. This is useful for providing project-specific context.
|
||||||
|
|
||||||
|
- `model_cache_ttl_secs` (integer, default: `60`)
|
||||||
|
Time-to-live in seconds for the cached list of available models.
|
||||||
|
|
||||||
|
## UI Settings (`[ui]`)
|
||||||
|
|
||||||
|
These settings customize the look and feel of the terminal interface.
|
||||||
|
|
||||||
|
- `theme` (string, default: `"default_dark"`)
|
||||||
|
The name of the theme to use. See the [Theming Guide](https://github.com/Owlibou/owlen/blob/main/themes/README.md) for available themes.
|
||||||
|
|
||||||
|
- `word_wrap` (boolean, default: `true`)
|
||||||
|
Whether to wrap long lines in the chat view.
|
||||||
|
|
||||||
|
- `max_history_lines` (integer, default: `2000`)
|
||||||
|
The maximum number of lines to keep in the scrollback buffer for the chat history.
|
||||||
|
|
||||||
|
- `show_role_labels` (boolean, default: `true`)
|
||||||
|
Whether to show the `user` and `bot` role labels next to messages.
|
||||||
|
|
||||||
|
- `wrap_column` (integer, default: `100`)
|
||||||
|
The column at which to wrap text if `word_wrap` is enabled.
|
||||||
|
|
||||||
|
## Storage Settings (`[storage]`)
|
||||||
|
|
||||||
|
These settings control how conversations are saved and loaded.
|
||||||
|
|
||||||
|
- `conversation_dir` (string, optional, default: platform-specific)
|
||||||
|
The directory where conversation sessions are saved. If not set, a default directory is used:
|
||||||
|
- **Linux**: `~/.local/share/owlen/sessions`
|
||||||
|
- **Windows**: `%APPDATA%\owlen\sessions`
|
||||||
|
- **macOS**: `~/Library/Application Support/owlen/sessions`
|
||||||
|
|
||||||
|
- `auto_save_sessions` (boolean, default: `true`)
|
||||||
|
Whether to automatically save the session when the application exits.
|
||||||
|
|
||||||
|
- `max_saved_sessions` (integer, default: `25`)
|
||||||
|
The maximum number of saved sessions to keep.
|
||||||
|
|
||||||
|
- `session_timeout_minutes` (integer, default: `120`)
|
||||||
|
The number of minutes of inactivity before a session is considered for auto-saving as a new session.
|
||||||
|
|
||||||
|
- `generate_descriptions` (boolean, default: `true`)
|
||||||
|
Whether to automatically generate a short summary of a conversation when saving it.
|
||||||
|
|
||||||
|
## Input Settings (`[input]`)
|
||||||
|
|
||||||
|
These settings control the behavior of the text input area.
|
||||||
|
|
||||||
|
- `multiline` (boolean, default: `true`)
|
||||||
|
Whether to allow multi-line input.
|
||||||
|
|
||||||
|
- `history_size` (integer, default: `100`)
|
||||||
|
The number of sent messages to keep in the input history (accessible with `Ctrl-Up/Down`).
|
||||||
|
|
||||||
|
- `tab_width` (integer, default: `4`)
|
||||||
|
The number of spaces to insert when the `Tab` key is pressed.
|
||||||
|
|
||||||
|
- `confirm_send` (boolean, default: `false`)
|
||||||
|
If true, requires an additional confirmation before sending a message.
|
||||||
|
|
||||||
|
## Provider Settings (`[providers]`)
|
||||||
|
|
||||||
|
This section contains a table for each provider you want to configure. The key is the provider name (e.g., `ollama`).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[providers.ollama]
|
||||||
|
provider_type = "ollama"
|
||||||
|
base_url = "http://localhost:11434"
|
||||||
|
# api_key = "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
- `provider_type` (string, required)
|
||||||
|
The type of the provider. Currently, only `"ollama"` is built-in.
|
||||||
|
|
||||||
|
- `base_url` (string, optional)
|
||||||
|
The base URL of the provider's API.
|
||||||
|
|
||||||
|
- `api_key` (string, optional)
|
||||||
|
The API key to use for authentication, if required.
|
||||||
|
|
||||||
|
- `extra` (table, optional)
|
||||||
|
Any additional, provider-specific parameters can be added here.
|
||||||
42
docs/faq.md
Normal file
42
docs/faq.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Frequently Asked Questions (FAQ)
|
||||||
|
|
||||||
|
### What is the difference between `owlen` and `owlen-code`?
|
||||||
|
|
||||||
|
- `owlen` is the general-purpose chat client.
|
||||||
|
- `owlen-code` is an experimental client with a system prompt that is optimized for programming and code-related questions. In the future, it will include more code-specific features like file context and syntax highlighting.
|
||||||
|
|
||||||
|
### How do I use Owlen with a different terminal?
|
||||||
|
|
||||||
|
Owlen is designed to work with most modern terminals that support 256 colors and Unicode. If you experience rendering issues, you might try:
|
||||||
|
|
||||||
|
- **WezTerm**: Excellent cross-platform, GPU-accelerated terminal.
|
||||||
|
- **Alacritty**: Another fast, GPU-accelerated terminal.
|
||||||
|
- **Kitty**: A feature-rich terminal emulator.
|
||||||
|
|
||||||
|
If issues persist, please open an issue and let us know what terminal you are using.
|
||||||
|
|
||||||
|
### What is the setup for Windows?
|
||||||
|
|
||||||
|
The Windows build is currently experimental. However, you can install it from source using `cargo` if you have the Rust toolchain installed.
|
||||||
|
|
||||||
|
1. Install Rust from [rustup.rs](https://rustup.rs).
|
||||||
|
2. Install Git for Windows.
|
||||||
|
3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git`
|
||||||
|
4. Install: `cd owlen && cargo install --path crates/owlen-cli`
|
||||||
|
|
||||||
|
Official binary releases for Windows are planned for the future.
|
||||||
|
|
||||||
|
### What is the setup for macOS?
|
||||||
|
|
||||||
|
Similar to Windows, the recommended installation method for macOS is to build from source using `cargo`.
|
||||||
|
|
||||||
|
1. Install the Xcode command-line tools: `xcode-select --install`
|
||||||
|
2. Install Rust from [rustup.rs](https://rustup.rs).
|
||||||
|
3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git`
|
||||||
|
4. Install: `cd owlen && cargo install --path crates/owlen-cli`
|
||||||
|
|
||||||
|
Official binary releases for macOS are planned.
|
||||||
|
|
||||||
|
### I'm getting connection failures to Ollama.
|
||||||
|
|
||||||
|
Please see the [Troubleshooting Guide](troubleshooting.md#connection-failures-to-ollama) for help with this common issue.
|
||||||
34
docs/migration-guide.md
Normal file
34
docs/migration-guide.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Migration Guide
|
||||||
|
|
||||||
|
This guide documents breaking changes between versions of Owlen and provides instructions on how to migrate your configuration or usage.
|
||||||
|
|
||||||
|
As Owlen is currently in its alpha phase (pre-v1.0), breaking changes may occur more frequently. We will do our best to document them here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrating from v0.1.x to v0.2.x (Example)
|
||||||
|
|
||||||
|
*This is a template for a future migration. No breaking changes have occurred yet.*
|
||||||
|
|
||||||
|
Version 0.2.0 introduces a new configuration structure for providers.
|
||||||
|
|
||||||
|
### Configuration File Changes
|
||||||
|
|
||||||
|
Previously, your `config.toml` might have looked like this:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# old config.toml (pre-v0.2.0)
|
||||||
|
ollama_base_url = "http://localhost:11434"
|
||||||
|
```
|
||||||
|
|
||||||
|
In v0.2.0, all provider settings are now nested under a `[providers]` table. You will need to update your `config.toml` to the new format:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# new config.toml (v0.2.0+)
|
||||||
|
[providers.ollama]
|
||||||
|
base_url = "http://localhost:11434"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Required
|
||||||
|
|
||||||
|
Update your `~/.config/owlen/config.toml` to match the new structure. If you do not, Owlen will fall back to its default provider configuration.
|
||||||
75
docs/provider-implementation.md
Normal file
75
docs/provider-implementation.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Provider Implementation Guide
|
||||||
|
|
||||||
|
This guide explains how to implement a new provider for Owlen. Providers are the components that connect to different LLM APIs.
|
||||||
|
|
||||||
|
## The `Provider` Trait
|
||||||
|
|
||||||
|
The core of the provider system is the `Provider` trait, located in `owlen-core`. Any new provider must implement this trait.
|
||||||
|
|
||||||
|
Here is a simplified version of the trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use owlen_core::model::Model;
|
||||||
|
use owlen_core::session::Session;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Provider {
|
||||||
|
/// Returns the name of the provider.
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Sends the session to the provider and returns the response.
|
||||||
|
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Crate
|
||||||
|
|
||||||
|
1. **Create a new crate** in the `crates/` directory. For example, `owlen-myprovider`.
|
||||||
|
2. **Add dependencies** to your new crate's `Cargo.toml`. You will need `owlen-core`, `async-trait`, `tokio`, and any crates required for interacting with the new API (e.g., `reqwest`).
|
||||||
|
3. **Add the new crate to the workspace** in the root `Cargo.toml`.
|
||||||
|
|
||||||
|
## Implementing the Trait
|
||||||
|
|
||||||
|
In your new crate's `lib.rs`, you will define a struct for your provider and implement the `Provider` trait for it.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use owlen_core::model::Model;
|
||||||
|
use owlen_core::provider::Provider;
|
||||||
|
use owlen_core::session::Session;
|
||||||
|
|
||||||
|
pub struct MyProvider;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for MyProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"my-provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
|
||||||
|
// 1. Get the conversation history from the session.
|
||||||
|
let history = session.get_messages();
|
||||||
|
|
||||||
|
// 2. Format the request for your provider's API.
|
||||||
|
// This might involve creating a JSON body with the messages.
|
||||||
|
|
||||||
|
// 3. Send the request to the API using a client like reqwest.
|
||||||
|
|
||||||
|
// 4. Parse the response from the API.
|
||||||
|
|
||||||
|
// 5. Return the content of the response as a String.
|
||||||
|
|
||||||
|
Ok("Hello from my provider!".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integrating with Owlen
|
||||||
|
|
||||||
|
Once your provider is implemented, you will need to integrate it into the main Owlen application.
|
||||||
|
|
||||||
|
1. **Add your provider crate** as a dependency to `owlen-cli`.
|
||||||
|
2. **In `owlen-cli`, modify the provider registration** to include your new provider. This will likely involve adding it to a list of available providers that the user can select from in the configuration.
|
||||||
|
|
||||||
|
This guide provides a basic outline. For more detailed examples, you can look at the existing provider implementations, such as `owlen-ollama`.
|
||||||
58
docs/testing.md
Normal file
58
docs/testing.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
This guide provides instructions on how to run existing tests and how to write new tests for Owlen.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
The entire test suite can be run from the root of the repository using the standard `cargo test` command.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Run all tests in the workspace
|
||||||
|
cargo test --all
|
||||||
|
|
||||||
|
# Run tests for a specific crate
|
||||||
|
cargo test -p owlen-core
|
||||||
|
```
|
||||||
|
|
||||||
|
We use `cargo clippy` for linting and `cargo fmt` for formatting. Please run these before submitting a pull request.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo clippy --all -- -D warnings
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
Tests are located in the `tests/` directory within each crate, or in a `tests` module at the bottom of the file they are testing. We follow standard Rust testing practices.
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
For testing specific functions or components in isolation, use unit tests. These should be placed in a `#[cfg(test)]` module in the same file as the code being tested.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// in src/my_module.rs
|
||||||
|
|
||||||
|
pub fn add(a: i32, b: i32) -> i32 {
|
||||||
|
a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add() {
|
||||||
|
assert_eq!(add(2, 2), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
For testing how different parts of the application work together, use integration tests. These should be placed in the `tests/` directory of the crate.
|
||||||
|
|
||||||
|
For example, to test the `SessionController`, you might create a mock `Provider` and simulate sending messages, as seen in the `SessionController` documentation example.
|
||||||
|
|
||||||
|
### TUI and UI Component Tests
|
||||||
|
|
||||||
|
Testing TUI components can be challenging. For UI logic in `owlen-core` (like `wrap_cursor`), we have detailed unit tests that manipulate the component's state and assert the results. For higher-level TUI components in `owlen-tui`, the focus is on testing the state management logic rather than the visual output.
|
||||||
40
docs/troubleshooting.md
Normal file
40
docs/troubleshooting.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
This guide is intended to help you with common issues you might encounter while using Owlen.
|
||||||
|
|
||||||
|
## Connection Failures to Ollama
|
||||||
|
|
||||||
|
If you are unable to connect to a local Ollama instance, here are a few things to check:
|
||||||
|
|
||||||
|
1. **Is Ollama running?** Make sure the Ollama service is active. You can usually check this with `ollama list`.
|
||||||
|
2. **Is the address correct?** By default, Owlen tries to connect to `http://localhost:11434`. If your Ollama instance is running on a different address or port, you will need to configure it in your `config.toml` file.
|
||||||
|
3. **Firewall issues:** Ensure that your firewall is not blocking the connection.
|
||||||
|
|
||||||
|
## Model Not Found Errors
|
||||||
|
|
||||||
|
If you get a "model not found" error, it means that the model you are trying to use is not available. For local providers like Ollama, you can use `ollama list` to see the models you have downloaded. Make sure the model name in your Owlen configuration matches one of the available models.
|
||||||
|
|
||||||
|
## Terminal Compatibility Issues
|
||||||
|
|
||||||
|
Owlen is built with `ratatui`, which supports most modern terminals. However, if you are experiencing rendering issues, please check the following:
|
||||||
|
|
||||||
|
- Your terminal supports Unicode.
|
||||||
|
- You are using a font that includes the characters being displayed.
|
||||||
|
- Try a different terminal emulator to see if the issue persists.
|
||||||
|
|
||||||
|
## Configuration File Problems
|
||||||
|
|
||||||
|
If Owlen is not behaving as you expect, there might be an issue with your configuration file.
|
||||||
|
|
||||||
|
- **Location:** The configuration file is typically located at `~/.config/owlen/config.toml`.
|
||||||
|
- **Syntax:** The configuration file is in TOML format. Make sure the syntax is correct.
|
||||||
|
- **Values:** Check that the values for your models, providers, and other settings are correct.
|
||||||
|
|
||||||
|
## Performance Tuning
|
||||||
|
|
||||||
|
If you are experiencing performance issues, you can try the following:
|
||||||
|
|
||||||
|
- **Reduce context size:** A smaller context size will result in faster responses from the LLM.
|
||||||
|
- **Use a less resource-intensive model:** Some models are faster but less capable than others.
|
||||||
|
|
||||||
|
If you are still having trouble, please [open an issue](https://github.com/Owlibou/owlen/issues) on our GitHub repository.
|
||||||
30
examples/basic_chat.rs
Normal file
30
examples/basic_chat.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// This example demonstrates a basic chat interaction without the TUI.
|
||||||
|
|
||||||
|
use owlen_core::model::Model;
|
||||||
|
use owlen_core::provider::Provider;
|
||||||
|
use owlen_core::session::Session;
|
||||||
|
use owlen_ollama::OllamaProvider; // Assuming you have an Ollama provider
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), anyhow::Error> {
|
||||||
|
// This example requires a running Ollama instance.
|
||||||
|
// Make sure you have a model available, e.g., `ollama pull llama2`
|
||||||
|
|
||||||
|
let provider = OllamaProvider;
|
||||||
|
let model = Model::new("llama2"); // Change to a model you have
|
||||||
|
let mut session = Session::new("basic-chat-session");
|
||||||
|
|
||||||
|
println!("Starting basic chat with model: {}", model.name);
|
||||||
|
|
||||||
|
let user_message = "What is the capital of France?";
|
||||||
|
session.add_message("user", user_message);
|
||||||
|
println!("User: {}", user_message);
|
||||||
|
|
||||||
|
// Send the chat to the provider
|
||||||
|
let response = provider.chat(&session, &model).await?;
|
||||||
|
|
||||||
|
session.add_message("bot", &response);
|
||||||
|
println!("Bot: {}", response);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
45
examples/custom_provider.rs
Normal file
45
examples/custom_provider.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// This example demonstrates how to implement a custom provider.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use owlen_core::model::Model;
|
||||||
|
use owlen_core::provider::Provider;
|
||||||
|
use owlen_core::session::Session;
|
||||||
|
|
||||||
|
// Define a struct for your custom provider.
|
||||||
|
pub struct MyCustomProvider;
|
||||||
|
|
||||||
|
// Implement the `Provider` trait for your struct.
|
||||||
|
#[async_trait]
|
||||||
|
impl Provider for MyCustomProvider {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"custom-provider"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
|
||||||
|
println!(
|
||||||
|
"Custom provider received chat request for model: {}",
|
||||||
|
model.name
|
||||||
|
);
|
||||||
|
// In a real implementation, you would send the session data to an API.
|
||||||
|
let message_count = session.get_messages().len();
|
||||||
|
Ok(format!(
|
||||||
|
"This is a custom response. You have {} messages in your session.",
|
||||||
|
message_count
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), anyhow::Error> {
|
||||||
|
let provider = MyCustomProvider;
|
||||||
|
let model = Model::new("custom-model");
|
||||||
|
let mut session = Session::new("custom-session");
|
||||||
|
|
||||||
|
session.add_message("user", "Hello, custom provider!");
|
||||||
|
|
||||||
|
let response = provider.chat(&session, &model).await?;
|
||||||
|
|
||||||
|
println!("Provider response: {}", response);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
28
examples/custom_theme.rs
Normal file
28
examples/custom_theme.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// This example demonstrates how to create a custom theme programmatically.
|
||||||
|
|
||||||
|
use owlen_core::theme::Theme;
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
|
||||||
|
fn create_custom_theme() -> Theme {
|
||||||
|
Theme {
|
||||||
|
name: "My Custom Theme".to_string(),
|
||||||
|
author: "Your Name".to_string(),
|
||||||
|
comment: "A simple custom theme".to_string(),
|
||||||
|
base: Style::default().fg(Color::White).bg(Color::Black),
|
||||||
|
user_chat: Style::default().fg(Color::Green),
|
||||||
|
bot_chat: Style::default().fg(Color::Cyan),
|
||||||
|
error: Style::default().fg(Color::Red),
|
||||||
|
info: Style::default().fg(Color::Yellow),
|
||||||
|
border: Style::default().fg(Color::Gray),
|
||||||
|
input: Style::default().fg(Color::White),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let custom_theme = create_custom_theme();
|
||||||
|
|
||||||
|
println!("Created custom theme: {}", custom_theme.name);
|
||||||
|
println!("Author: {}", custom_theme.author);
|
||||||
|
println!("User chat color: {:?}", custom_theme.user_chat.fg);
|
||||||
|
}
|
||||||
30
examples/session_management.rs
Normal file
30
examples/session_management.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// This example demonstrates how to use the session controller.
|
||||||
|
|
||||||
|
use owlen_core::session::Session;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Create a new session.
|
||||||
|
let mut session = Session::new("my-session");
|
||||||
|
println!("Created new session: {}", session.name);
|
||||||
|
|
||||||
|
// Add messages to the session.
|
||||||
|
session.add_message("user", "Hello, Owlen!");
|
||||||
|
session.add_message("bot", "Hello, user! How can I help you today?");
|
||||||
|
|
||||||
|
// Get the messages from the session.
|
||||||
|
let messages = session.get_messages();
|
||||||
|
println!("\nMessages in session:");
|
||||||
|
for message in messages {
|
||||||
|
println!(" {}: {}", message.role, message.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the session.
|
||||||
|
session.clear_messages();
|
||||||
|
println!("\nSession cleared.");
|
||||||
|
|
||||||
|
let messages_after_clear = session.get_messages();
|
||||||
|
println!(
|
||||||
|
"Messages in session after clear: {}",
|
||||||
|
messages_after_clear.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
89
themes/README.md
Normal file
89
themes/README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# OWLEN Built-in Themes
|
||||||
|
|
||||||
|
This directory contains the built-in themes that are embedded into the OWLEN binary.
|
||||||
|
|
||||||
|
## Available Themes
|
||||||
|
|
||||||
|
- **default_dark** - High-contrast dark theme (default)
|
||||||
|
- **default_light** - Clean light theme
|
||||||
|
- **gruvbox** - Popular retro color scheme with warm tones
|
||||||
|
- **dracula** - Dark theme with vibrant purple and cyan colors
|
||||||
|
- **solarized** - Precision colors for optimal 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
|
||||||
|
|
||||||
|
## Theme File Format
|
||||||
|
|
||||||
|
Each theme is defined in TOML format with the following structure:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
name = "theme-name"
|
||||||
|
|
||||||
|
# Text colors
|
||||||
|
text = "#ffffff" # Main text color
|
||||||
|
placeholder = "#808080" # Placeholder/muted text
|
||||||
|
|
||||||
|
# Background colors
|
||||||
|
background = "#000000" # Main background
|
||||||
|
command_bar_background = "#111111"
|
||||||
|
status_background = "#111111"
|
||||||
|
|
||||||
|
# Border colors
|
||||||
|
focused_panel_border = "#ff00ff" # Active panel border
|
||||||
|
unfocused_panel_border = "#800080" # Inactive panel border
|
||||||
|
|
||||||
|
# Message role colors
|
||||||
|
user_message_role = "#00ffff" # User messages
|
||||||
|
assistant_message_role = "#ffff00" # Assistant messages
|
||||||
|
thinking_panel_title = "#ff00ff" # Thinking panel title
|
||||||
|
|
||||||
|
# Mode indicator colors (status bar)
|
||||||
|
mode_normal = "#00ffff"
|
||||||
|
mode_editing = "#00ff00"
|
||||||
|
mode_model_selection = "#ffff00"
|
||||||
|
mode_provider_selection = "#00ffff"
|
||||||
|
mode_help = "#ff00ff"
|
||||||
|
mode_visual = "#ff0080"
|
||||||
|
mode_command = "#ffff00"
|
||||||
|
|
||||||
|
# Selection and cursor
|
||||||
|
selection_bg = "#0000ff" # Selection background
|
||||||
|
selection_fg = "#ffffff" # Selection foreground
|
||||||
|
cursor = "#ff0080" # Cursor color
|
||||||
|
|
||||||
|
# Status colors
|
||||||
|
error = "#ff0000" # Error messages
|
||||||
|
info = "#00ff00" # Info/success messages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Color Format
|
||||||
|
|
||||||
|
Colors can be specified in two formats:
|
||||||
|
|
||||||
|
1. **Hex RGB**: `#rrggbb` (e.g., `#ff0000` for red, `#ff8800` for orange)
|
||||||
|
2. **Named colors** (case-insensitive):
|
||||||
|
- **Basic**: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`
|
||||||
|
- **Gray variants**: `gray`, `grey`, `darkgray`, `darkgrey`
|
||||||
|
- **Light variants**: `lightred`, `lightgreen`, `lightyellow`, `lightblue`, `lightmagenta`, `lightcyan`
|
||||||
|
|
||||||
|
**Note**: For colors not in the named list (like orange, purple, brown), use hex RGB format.
|
||||||
|
|
||||||
|
OWLEN will display an error message on startup if a custom theme has invalid colors.
|
||||||
|
|
||||||
|
## Creating Custom Themes
|
||||||
|
|
||||||
|
To create your own theme:
|
||||||
|
|
||||||
|
1. Copy one of these files to `~/.config/owlen/themes/`
|
||||||
|
2. Rename and modify the colors
|
||||||
|
3. Set `theme = "your-theme-name"` in `~/.config/owlen/config.toml`
|
||||||
|
4. Or use `:theme your-theme-name` in OWLEN to switch
|
||||||
|
|
||||||
|
## Embedding in Binary
|
||||||
|
|
||||||
|
These theme files are embedded into the OWLEN binary at compile time using Rust's `include_str!()` macro. This ensures they're always available, even if the files are deleted from disk.
|
||||||
|
|
||||||
|
Custom themes placed in `~/.config/owlen/themes/` will override built-in themes with the same name.
|
||||||
23
themes/default_dark.toml
Normal file
23
themes/default_dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "default_dark"
|
||||||
|
text = "white"
|
||||||
|
background = "black"
|
||||||
|
focused_panel_border = "lightmagenta"
|
||||||
|
unfocused_panel_border = "#5f1487"
|
||||||
|
user_message_role = "lightblue"
|
||||||
|
assistant_message_role = "yellow"
|
||||||
|
thinking_panel_title = "lightmagenta"
|
||||||
|
command_bar_background = "black"
|
||||||
|
status_background = "black"
|
||||||
|
mode_normal = "lightblue"
|
||||||
|
mode_editing = "lightgreen"
|
||||||
|
mode_model_selection = "lightyellow"
|
||||||
|
mode_provider_selection = "lightcyan"
|
||||||
|
mode_help = "lightmagenta"
|
||||||
|
mode_visual = "magenta"
|
||||||
|
mode_command = "yellow"
|
||||||
|
selection_bg = "lightblue"
|
||||||
|
selection_fg = "black"
|
||||||
|
cursor = "magenta"
|
||||||
|
placeholder = "darkgray"
|
||||||
|
error = "red"
|
||||||
|
info = "lightgreen"
|
||||||
23
themes/default_light.toml
Normal file
23
themes/default_light.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "default_light"
|
||||||
|
text = "black"
|
||||||
|
background = "white"
|
||||||
|
focused_panel_border = "#4a90e2"
|
||||||
|
unfocused_panel_border = "#dddddd"
|
||||||
|
user_message_role = "#0055a4"
|
||||||
|
assistant_message_role = "#8e44ad"
|
||||||
|
thinking_panel_title = "#8e44ad"
|
||||||
|
command_bar_background = "white"
|
||||||
|
status_background = "white"
|
||||||
|
mode_normal = "#0055a4"
|
||||||
|
mode_editing = "#2e8b57"
|
||||||
|
mode_model_selection = "#b58900"
|
||||||
|
mode_provider_selection = "#008b8b"
|
||||||
|
mode_help = "#8e44ad"
|
||||||
|
mode_visual = "#8e44ad"
|
||||||
|
mode_command = "#b58900"
|
||||||
|
selection_bg = "#a4c8f0"
|
||||||
|
selection_fg = "black"
|
||||||
|
cursor = "#d95f02"
|
||||||
|
placeholder = "gray"
|
||||||
|
error = "#c0392b"
|
||||||
|
info = "green"
|
||||||
23
themes/dracula.toml
Normal file
23
themes/dracula.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "dracula"
|
||||||
|
text = "#f8f8f2"
|
||||||
|
background = "#282a36"
|
||||||
|
focused_panel_border = "#ff79c6"
|
||||||
|
unfocused_panel_border = "#44475a"
|
||||||
|
user_message_role = "#8be9fd"
|
||||||
|
assistant_message_role = "#ff79c6"
|
||||||
|
thinking_panel_title = "#bd93f9"
|
||||||
|
command_bar_background = "#44475a"
|
||||||
|
status_background = "#44475a"
|
||||||
|
mode_normal = "#8be9fd"
|
||||||
|
mode_editing = "#50fa7b"
|
||||||
|
mode_model_selection = "#f1fa8c"
|
||||||
|
mode_provider_selection = "#8be9fd"
|
||||||
|
mode_help = "#bd93f9"
|
||||||
|
mode_visual = "#ff79c6"
|
||||||
|
mode_command = "#f1fa8c"
|
||||||
|
selection_bg = "#44475a"
|
||||||
|
selection_fg = "#f8f8f2"
|
||||||
|
cursor = "#ff79c6"
|
||||||
|
placeholder = "#6272a4"
|
||||||
|
error = "#ff5555"
|
||||||
|
info = "#50fa7b"
|
||||||
23
themes/gruvbox.toml
Normal file
23
themes/gruvbox.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "gruvbox"
|
||||||
|
text = "#ebdbb2"
|
||||||
|
background = "#282828"
|
||||||
|
focused_panel_border = "#fe8019"
|
||||||
|
unfocused_panel_border = "#7c6f64"
|
||||||
|
user_message_role = "#b8bb26"
|
||||||
|
assistant_message_role = "#83a598"
|
||||||
|
thinking_panel_title = "#d3869b"
|
||||||
|
command_bar_background = "#3c3836"
|
||||||
|
status_background = "#3c3836"
|
||||||
|
mode_normal = "#83a598"
|
||||||
|
mode_editing = "#b8bb26"
|
||||||
|
mode_model_selection = "#fabd2f"
|
||||||
|
mode_provider_selection = "#8ec07c"
|
||||||
|
mode_help = "#d3869b"
|
||||||
|
mode_visual = "#fe8019"
|
||||||
|
mode_command = "#fabd2f"
|
||||||
|
selection_bg = "#504945"
|
||||||
|
selection_fg = "#ebdbb2"
|
||||||
|
cursor = "#fe8019"
|
||||||
|
placeholder = "#665c54"
|
||||||
|
error = "#fb4934"
|
||||||
|
info = "#b8bb26"
|
||||||
23
themes/material-dark.toml
Normal file
23
themes/material-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "material-dark"
|
||||||
|
text = "#eeffff"
|
||||||
|
background = "#263238"
|
||||||
|
focused_panel_border = "#80cbc4"
|
||||||
|
unfocused_panel_border = "#546e7a"
|
||||||
|
user_message_role = "#82aaff"
|
||||||
|
assistant_message_role = "#c792ea"
|
||||||
|
thinking_panel_title = "#ffcb6b"
|
||||||
|
command_bar_background = "#212b30"
|
||||||
|
status_background = "#212b30"
|
||||||
|
mode_normal = "#82aaff"
|
||||||
|
mode_editing = "#c3e88d"
|
||||||
|
mode_model_selection = "#ffcb6b"
|
||||||
|
mode_provider_selection = "#80cbc4"
|
||||||
|
mode_help = "#c792ea"
|
||||||
|
mode_visual = "#f07178"
|
||||||
|
mode_command = "#ffcb6b"
|
||||||
|
selection_bg = "#546e7a"
|
||||||
|
selection_fg = "#eeffff"
|
||||||
|
cursor = "#ffcc00"
|
||||||
|
placeholder = "#546e7a"
|
||||||
|
error = "#f07178"
|
||||||
|
info = "#c3e88d"
|
||||||
23
themes/material-light.toml
Normal file
23
themes/material-light.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "material-light"
|
||||||
|
text = "#212121"
|
||||||
|
background = "#eceff1"
|
||||||
|
focused_panel_border = "#009688"
|
||||||
|
unfocused_panel_border = "#b0bec5"
|
||||||
|
user_message_role = "#448aff"
|
||||||
|
assistant_message_role = "#7c4dff"
|
||||||
|
thinking_panel_title = "#f57c00"
|
||||||
|
command_bar_background = "#ffffff"
|
||||||
|
status_background = "#ffffff"
|
||||||
|
mode_normal = "#448aff"
|
||||||
|
mode_editing = "#388e3c"
|
||||||
|
mode_model_selection = "#f57c00"
|
||||||
|
mode_provider_selection = "#009688"
|
||||||
|
mode_help = "#7c4dff"
|
||||||
|
mode_visual = "#d32f2f"
|
||||||
|
mode_command = "#f57c00"
|
||||||
|
selection_bg = "#b0bec5"
|
||||||
|
selection_fg = "#212121"
|
||||||
|
cursor = "#c2185b"
|
||||||
|
placeholder = "#90a4ae"
|
||||||
|
error = "#d32f2f"
|
||||||
|
info = "#388e3c"
|
||||||
23
themes/midnight-ocean.toml
Normal file
23
themes/midnight-ocean.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "midnight-ocean"
|
||||||
|
text = "#c0caf5"
|
||||||
|
background = "#0d1117"
|
||||||
|
focused_panel_border = "#58a6ff"
|
||||||
|
unfocused_panel_border = "#30363d"
|
||||||
|
user_message_role = "#79c0ff"
|
||||||
|
assistant_message_role = "#89ddff"
|
||||||
|
thinking_panel_title = "#9ece6a"
|
||||||
|
command_bar_background = "#161b22"
|
||||||
|
status_background = "#161b22"
|
||||||
|
mode_normal = "#79c0ff"
|
||||||
|
mode_editing = "#9ece6a"
|
||||||
|
mode_model_selection = "#ffd43b"
|
||||||
|
mode_provider_selection = "#89ddff"
|
||||||
|
mode_help = "#ff739d"
|
||||||
|
mode_visual = "#f68cf5"
|
||||||
|
mode_command = "#ffd43b"
|
||||||
|
selection_bg = "#388bfd"
|
||||||
|
selection_fg = "#0d1117"
|
||||||
|
cursor = "#f68cf5"
|
||||||
|
placeholder = "#6e7681"
|
||||||
|
error = "#f85149"
|
||||||
|
info = "#9ece6a"
|
||||||
23
themes/monokai.toml
Normal file
23
themes/monokai.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "monokai"
|
||||||
|
text = "#f8f8f2"
|
||||||
|
background = "#272822"
|
||||||
|
focused_panel_border = "#f92672"
|
||||||
|
unfocused_panel_border = "#75715e"
|
||||||
|
user_message_role = "#66d9ef"
|
||||||
|
assistant_message_role = "#ae81ff"
|
||||||
|
thinking_panel_title = "#e6db74"
|
||||||
|
command_bar_background = "#272822"
|
||||||
|
status_background = "#272822"
|
||||||
|
mode_normal = "#66d9ef"
|
||||||
|
mode_editing = "#a6e22e"
|
||||||
|
mode_model_selection = "#e6db74"
|
||||||
|
mode_provider_selection = "#66d9ef"
|
||||||
|
mode_help = "#ae81ff"
|
||||||
|
mode_visual = "#f92672"
|
||||||
|
mode_command = "#e6db74"
|
||||||
|
selection_bg = "#75715e"
|
||||||
|
selection_fg = "#f8f8f2"
|
||||||
|
cursor = "#f92672"
|
||||||
|
placeholder = "#75715e"
|
||||||
|
error = "#f92672"
|
||||||
|
info = "#a6e22e"
|
||||||
23
themes/rose-pine.toml
Normal file
23
themes/rose-pine.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "rose-pine"
|
||||||
|
text = "#e0def4"
|
||||||
|
background = "#191724"
|
||||||
|
focused_panel_border = "#eb6f92"
|
||||||
|
unfocused_panel_border = "#26233a"
|
||||||
|
user_message_role = "#31748f"
|
||||||
|
assistant_message_role = "#9ccfd8"
|
||||||
|
thinking_panel_title = "#c4a7e7"
|
||||||
|
command_bar_background = "#26233a"
|
||||||
|
status_background = "#26233a"
|
||||||
|
mode_normal = "#9ccfd8"
|
||||||
|
mode_editing = "#ebbcba"
|
||||||
|
mode_model_selection = "#f6c177"
|
||||||
|
mode_provider_selection = "#31748f"
|
||||||
|
mode_help = "#c4a7e7"
|
||||||
|
mode_visual = "#eb6f92"
|
||||||
|
mode_command = "#f6c177"
|
||||||
|
selection_bg = "#403d52"
|
||||||
|
selection_fg = "#e0def4"
|
||||||
|
cursor = "#eb6f92"
|
||||||
|
placeholder = "#6e6a86"
|
||||||
|
error = "#eb6f92"
|
||||||
|
info = "#9ccfd8"
|
||||||
23
themes/solarized.toml
Normal file
23
themes/solarized.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "solarized"
|
||||||
|
text = "#839496"
|
||||||
|
background = "#002b36"
|
||||||
|
focused_panel_border = "#268bd2"
|
||||||
|
unfocused_panel_border = "#073642"
|
||||||
|
user_message_role = "#2aa198"
|
||||||
|
assistant_message_role = "#cb4b16"
|
||||||
|
thinking_panel_title = "#6c71c4"
|
||||||
|
command_bar_background = "#073642"
|
||||||
|
status_background = "#073642"
|
||||||
|
mode_normal = "#268bd2"
|
||||||
|
mode_editing = "#859900"
|
||||||
|
mode_model_selection = "#b58900"
|
||||||
|
mode_provider_selection = "#2aa198"
|
||||||
|
mode_help = "#6c71c4"
|
||||||
|
mode_visual = "#d33682"
|
||||||
|
mode_command = "#b58900"
|
||||||
|
selection_bg = "#073642"
|
||||||
|
selection_fg = "#93a1a1"
|
||||||
|
cursor = "#d33682"
|
||||||
|
placeholder = "#586e75"
|
||||||
|
error = "#dc322f"
|
||||||
|
info = "#859900"
|
||||||
Reference in New Issue
Block a user