Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 662d5bd919 | |||
| 263b629257 | |||
| ff90b20baa |
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"
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
name: Build ${{ matrix.target }}
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
# Linux
|
|
||||||
- os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
artifact_name: owlen-linux-x86_64-gnu
|
|
||||||
- os: ubuntu-latest
|
|
||||||
target: x86_64-unknown-linux-musl
|
|
||||||
artifact_name: owlen-linux-x86_64-musl
|
|
||||||
- os: ubuntu-latest
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
artifact_name: owlen-linux-aarch64-gnu
|
|
||||||
- os: ubuntu-latest
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
artifact_name: owlen-linux-aarch64-musl
|
|
||||||
- os: ubuntu-latest
|
|
||||||
target: armv7-unknown-linux-gnueabihf
|
|
||||||
artifact_name: owlen-linux-armv7-gnu
|
|
||||||
- os: ubuntu-latest
|
|
||||||
target: armv7-unknown-linux-musleabihf
|
|
||||||
artifact_name: owlen-linux-armv7-musl
|
|
||||||
# Windows
|
|
||||||
- os: windows-latest
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
artifact_name: owlen-windows-x86_64
|
|
||||||
- os: windows-latest
|
|
||||||
target: aarch64-pc-windows-msvc
|
|
||||||
artifact_name: owlen-windows-aarch64
|
|
||||||
# macOS
|
|
||||||
- os: macos-latest
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
artifact_name: owlen-macos-x86_64
|
|
||||||
- os: macos-latest
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
artifact_name: owlen-macos-aarch64
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: https://github.com/dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Install cross-compilation tools (Linux)
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
case "${{ matrix.target }}" in
|
|
||||||
aarch64-unknown-linux-gnu)
|
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
|
|
||||||
;;
|
|
||||||
aarch64-unknown-linux-musl)
|
|
||||||
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
|
|
||||||
export CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc
|
|
||||||
;;
|
|
||||||
armv7-unknown-linux-gnueabihf)
|
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
|
||||||
;;
|
|
||||||
armv7-unknown-linux-musleabihf)
|
|
||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
|
||||||
export CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
cargo build --release --all-features --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Package binaries (Unix)
|
|
||||||
if: runner.os != 'Windows'
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
cp target/${{ matrix.target }}/release/owlen dist/owlen
|
|
||||||
cp target/${{ matrix.target }}/release/owlen-code dist/owlen-code
|
|
||||||
cd dist
|
|
||||||
tar czf ${{ matrix.artifact_name }}.tar.gz owlen owlen-code
|
|
||||||
cd ..
|
|
||||||
mv dist/${{ matrix.artifact_name }}.tar.gz .
|
|
||||||
|
|
||||||
- name: Package binaries (Windows)
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
cp target/${{ matrix.target }}/release/owlen.exe dist/owlen.exe
|
|
||||||
cp target/${{ matrix.target }}/release/owlen-code.exe dist/owlen-code.exe
|
|
||||||
cd dist
|
|
||||||
7z a -tzip ../${{ matrix.artifact_name }}.zip owlen.exe owlen-code.exe
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ matrix.artifact_name }}
|
|
||||||
path: |
|
|
||||||
${{ matrix.artifact_name }}.tar.gz
|
|
||||||
${{ matrix.artifact_name }}.zip
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Create Release
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Create source tarball
|
|
||||||
run: |
|
|
||||||
git archive --format=tar.gz --prefix=owlen/ -o owlen-${{ github.ref_name }}.tar.gz ${{ github.ref_name }}
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd artifacts
|
|
||||||
find . -name "*.tar.gz" -exec mv {} . \; 2>/dev/null || true
|
|
||||||
find . -name "*.zip" -exec mv {} . \; 2>/dev/null || true
|
|
||||||
cd ..
|
|
||||||
mv artifacts/*.tar.gz . 2>/dev/null || true
|
|
||||||
mv artifacts/*.zip . 2>/dev/null || true
|
|
||||||
sha256sum *.tar.gz *.zip > checksums.txt 2>/dev/null || sha256sum * > checksums.txt
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: https://gitea.com/gitea/release-action@main
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
*.tar.gz
|
|
||||||
*.zip
|
|
||||||
checksums.txt
|
|
||||||
api_key: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
131
.woodpecker.yml
Normal file
131
.woodpecker.yml
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
tag: v*
|
||||||
|
|
||||||
|
variables:
|
||||||
|
- &rust_image 'rust:1.83'
|
||||||
|
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# Linux
|
||||||
|
- TARGET: x86_64-unknown-linux-gnu
|
||||||
|
ARTIFACT: owlen-linux-x86_64-gnu
|
||||||
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
- TARGET: x86_64-unknown-linux-musl
|
||||||
|
ARTIFACT: owlen-linux-x86_64-musl
|
||||||
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
- TARGET: aarch64-unknown-linux-gnu
|
||||||
|
ARTIFACT: owlen-linux-aarch64-gnu
|
||||||
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
- TARGET: aarch64-unknown-linux-musl
|
||||||
|
ARTIFACT: owlen-linux-aarch64-musl
|
||||||
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
- TARGET: armv7-unknown-linux-gnueabihf
|
||||||
|
ARTIFACT: owlen-linux-armv7-gnu
|
||||||
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
- TARGET: armv7-unknown-linux-musleabihf
|
||||||
|
ARTIFACT: owlen-linux-armv7-musl
|
||||||
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
# Windows
|
||||||
|
- TARGET: x86_64-pc-windows-gnu
|
||||||
|
ARTIFACT: owlen-windows-x86_64
|
||||||
|
PLATFORM: windows
|
||||||
|
EXT: ".exe"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: *rust_image
|
||||||
|
commands:
|
||||||
|
# Install cross-compilation tools
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y musl-tools gcc-aarch64-linux-gnu g++-aarch64-linux-gnu gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf mingw-w64 zip
|
||||||
|
|
||||||
|
# Verify cross-compilers are installed
|
||||||
|
- which aarch64-linux-gnu-gcc || echo "aarch64-linux-gnu-gcc not found!"
|
||||||
|
- which arm-linux-gnueabihf-gcc || echo "arm-linux-gnueabihf-gcc not found!"
|
||||||
|
- which x86_64-w64-mingw32-gcc || echo "x86_64-w64-mingw32-gcc not found!"
|
||||||
|
|
||||||
|
# Add rust target
|
||||||
|
- rustup target add ${TARGET}
|
||||||
|
|
||||||
|
# Set up cross-compilation environment variables and build
|
||||||
|
- |
|
||||||
|
case "${TARGET}" in
|
||||||
|
aarch64-unknown-linux-gnu)
|
||||||
|
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CC_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CXX_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-g++
|
||||||
|
export AR_aarch64_unknown_linux_gnu=/usr/bin/aarch64-linux-gnu-ar
|
||||||
|
;;
|
||||||
|
aarch64-unknown-linux-musl)
|
||||||
|
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CC_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-gcc
|
||||||
|
export CXX_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-g++
|
||||||
|
export AR_aarch64_unknown_linux_musl=/usr/bin/aarch64-linux-gnu-ar
|
||||||
|
;;
|
||||||
|
armv7-unknown-linux-gnueabihf)
|
||||||
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CC_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CXX_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-g++
|
||||||
|
export AR_armv7_unknown_linux_gnueabihf=/usr/bin/arm-linux-gnueabihf-ar
|
||||||
|
;;
|
||||||
|
armv7-unknown-linux-musleabihf)
|
||||||
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CC_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-gcc
|
||||||
|
export CXX_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-g++
|
||||||
|
export AR_armv7_unknown_linux_musleabihf=/usr/bin/arm-linux-gnueabihf-ar
|
||||||
|
;;
|
||||||
|
x86_64-pc-windows-gnu)
|
||||||
|
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=/usr/bin/x86_64-w64-mingw32-gcc
|
||||||
|
export CC_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-gcc
|
||||||
|
export CXX_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-g++
|
||||||
|
export AR_x86_64_pc_windows_gnu=/usr/bin/x86_64-w64-mingw32-ar
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
cargo build --release --all-features --target ${TARGET}
|
||||||
|
|
||||||
|
- name: package
|
||||||
|
image: *rust_image
|
||||||
|
commands:
|
||||||
|
- apt-get update && apt-get install -y zip
|
||||||
|
- mkdir -p dist
|
||||||
|
- |
|
||||||
|
if [ "${PLATFORM}" = "windows" ]; then
|
||||||
|
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
|
||||||
|
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
|
||||||
|
cd dist
|
||||||
|
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
|
||||||
|
cd ..
|
||||||
|
mv dist/${ARTIFACT}.zip .
|
||||||
|
sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256
|
||||||
|
else
|
||||||
|
cp target/${TARGET}/release/owlen dist/owlen
|
||||||
|
cp target/${TARGET}/release/owlen-code dist/owlen-code
|
||||||
|
cd dist
|
||||||
|
tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
||||||
|
cd ..
|
||||||
|
mv dist/${ARTIFACT}.tar.gz .
|
||||||
|
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: release
|
||||||
|
image: plugins/gitea-release
|
||||||
|
settings:
|
||||||
|
api_key:
|
||||||
|
from_secret: gitea_token
|
||||||
|
base_url: https://somegit.dev
|
||||||
|
files:
|
||||||
|
- ${ARTIFACT}.tar.gz
|
||||||
|
- ${ARTIFACT}.tar.gz.sha256
|
||||||
|
- ${ARTIFACT}.zip
|
||||||
|
- ${ARTIFACT}.zip.sha256
|
||||||
|
title: Release ${CI_COMMIT_TAG}
|
||||||
|
note: "Release ${CI_COMMIT_TAG}"
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
43
PKGBUILD
43
PKGBUILD
@@ -1,45 +1,50 @@
|
|||||||
# 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
125
README.md
125
README.md
@@ -3,13 +3,13 @@
|
|||||||
> 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
|
## Alpha Status
|
||||||
|
|
||||||
- This project is currently in **alpha** (v0.1.0) and under active development.
|
- This project is currently in **alpha** (v0.1.9) and under active development.
|
||||||
- Core features are functional but expect occasional bugs and missing polish.
|
- Core features are functional but expect occasional bugs and missing polish.
|
||||||
- Breaking changes may occur between releases as we refine the API.
|
- Breaking changes may occur between releases as we refine the API.
|
||||||
- Feedback, bug reports, and contributions are very welcome!
|
- Feedback, bug reports, and contributions are very welcome!
|
||||||
@@ -42,9 +42,13 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig
|
|||||||
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
||||||
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
||||||
- **Model Management** - Interactive model and provider selection (press `m`)
|
- **Model Management** - Interactive model and provider selection (press `m`)
|
||||||
- **Session Management** - Start new conversations, clear history
|
- **Command Autocompletion** - Intelligent Tab completion and suggestions in command mode
|
||||||
|
- **Session Persistence** - Save and load conversations to/from disk
|
||||||
|
- **AI-Generated Descriptions** - Automatic short summaries for saved sessions
|
||||||
|
- **Session Management** - Start new conversations, clear history, browse saved sessions
|
||||||
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
||||||
- **Bracketed Paste** - Safe paste handling for multi-line content
|
- **Bracketed Paste** - Safe paste handling for multi-line content
|
||||||
|
- **Theming System** - 10 built-in themes plus custom theme support
|
||||||
|
|
||||||
### Code Client (`owlen-code`) [Experimental]
|
### Code Client (`owlen-code`) [Experimental]
|
||||||
- All chat client features
|
- All chat client features
|
||||||
@@ -67,6 +71,19 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig
|
|||||||
(defaults to `http://localhost:11434`)
|
(defaults to `http://localhost:11434`)
|
||||||
- A terminal that supports 256 colors
|
- A terminal that supports 256 colors
|
||||||
|
|
||||||
|
### Installation from Source on Linux and macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://somegit.dev/Owlibou/owlen.git
|
||||||
|
cd owlen
|
||||||
|
cargo install --path crates/owlen-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Make sure `~/.cargo/bin` is in your PATH to run the installed binary:
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
|
```
|
||||||
|
|
||||||
### Clone and Build
|
### Clone and Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -124,21 +141,45 @@ cargo build --release --bin owlen-code --features code-client
|
|||||||
- `Enter` - Send message and return to normal mode
|
- `Enter` - Send message and return to normal mode
|
||||||
- `Ctrl-J` / `Shift-Enter` - Insert newline
|
- `Ctrl-J` / `Shift-Enter` - Insert newline
|
||||||
- `Ctrl-↑/↓` - Navigate input history
|
- `Ctrl-↑/↓` - Navigate input history
|
||||||
|
- `Ctrl-A` / `Ctrl-E` - Jump to start/end of line
|
||||||
|
- `Ctrl-W` / `Ctrl-B` - Word movement
|
||||||
|
- `Ctrl-R` - Redo
|
||||||
- Paste events handled automatically
|
- Paste events handled automatically
|
||||||
|
|
||||||
**Visual Mode**:
|
**Visual Mode**:
|
||||||
- `j/k/h/l` - Extend selection
|
- `j/k/h/l` - Extend selection
|
||||||
- `w/b/e` - Word-based selection
|
- `w/b/e` - Word-based selection
|
||||||
- `y` - Yank (copy) selection
|
- `y` - Yank (copy) selection
|
||||||
- `d` - Cut selection (Input panel only)
|
- `d` / `Delete` - Cut selection (Input panel only)
|
||||||
- `Esc` - Cancel selection
|
- `Esc` / `v` - Cancel selection
|
||||||
|
|
||||||
**Command Mode**:
|
**Command Mode**:
|
||||||
|
- `Tab` - Autocomplete selected command suggestion
|
||||||
|
- `↑` / `↓` or `Ctrl-k` / `Ctrl-j` - Navigate command suggestions
|
||||||
- `:q` / `:quit` - Quit application
|
- `:q` / `:quit` - Quit application
|
||||||
- `:c` / `:clear` - Clear conversation
|
- `:c` / `:clear` - Clear conversation
|
||||||
- `:m` / `:model` - Open model selector
|
- `:m` / `:model` - Open model selector
|
||||||
- `:n` / `:new` - Start new conversation
|
- `:n` / `:new` - Start new conversation
|
||||||
- `:h` / `:help` - Show help
|
- `:h` / `:help` - Show help
|
||||||
|
- `:save [name]` / `:w [name]` - Save current conversation
|
||||||
|
- `:load` / `:open` - Browse and load saved sessions
|
||||||
|
- `:sessions` / `:ls` - List saved sessions
|
||||||
|
- `:theme <name>` - Switch theme (saved to config)
|
||||||
|
- `:themes` - Browse themes in interactive modal
|
||||||
|
- `:reload` - Reload configuration and themes
|
||||||
|
- *Commands show real-time suggestions as you type*
|
||||||
|
|
||||||
|
**Theme Browser** (accessed via `:themes`):
|
||||||
|
- `j` / `k` / `↑` / `↓` - Navigate themes
|
||||||
|
- `Enter` - Apply selected theme
|
||||||
|
- `g` / `G` - Jump to top/bottom
|
||||||
|
- `Esc` / `q` - Close browser
|
||||||
|
|
||||||
|
**Session Browser** (accessed via `:load` or `:sessions`):
|
||||||
|
- `j` / `k` / `↑` / `↓` - Navigate sessions
|
||||||
|
- `Enter` - Load selected session
|
||||||
|
- `d` - Delete selected session
|
||||||
|
- `Esc` - Close browser
|
||||||
|
|
||||||
### Panel Management
|
### Panel Management
|
||||||
- Three panels: Chat, Thinking, and Input
|
- Three panels: Chat, Thinking, and Input
|
||||||
@@ -164,17 +205,81 @@ base_url = "http://localhost:11434"
|
|||||||
timeout = 300
|
timeout = 300
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Storage Settings
|
||||||
|
|
||||||
|
Sessions are saved to platform-specific directories by default:
|
||||||
|
- **Linux**: `~/.local/share/owlen/sessions`
|
||||||
|
- **Windows**: `%APPDATA%\owlen\sessions`
|
||||||
|
- **macOS**: `~/Library/Application Support/owlen/sessions`
|
||||||
|
|
||||||
|
You can customize this in your config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[storage]
|
||||||
|
# conversation_dir = "~/custom/path" # Optional: override default location
|
||||||
|
max_saved_sessions = 25
|
||||||
|
generate_descriptions = true # AI-generated summaries for saved sessions
|
||||||
|
```
|
||||||
|
|
||||||
Configuration is automatically saved when you change models or providers.
|
Configuration is automatically saved when you change models or providers.
|
||||||
|
|
||||||
|
### Theming
|
||||||
|
|
||||||
|
OWLEN includes 10 built-in themes that are embedded in the binary. You can also create custom themes.
|
||||||
|
|
||||||
|
**Built-in themes:**
|
||||||
|
- `default_dark` (default) - High-contrast dark theme
|
||||||
|
- `default_light` - Clean light theme
|
||||||
|
- `gruvbox` - Retro warm color scheme
|
||||||
|
- `dracula` - Vibrant purple and cyan
|
||||||
|
- `solarized` - Precision colors for readability
|
||||||
|
- `midnight-ocean` - Deep blue oceanic theme
|
||||||
|
- `rose-pine` - Soho vibes with muted pastels
|
||||||
|
- `monokai` - Classic code editor theme
|
||||||
|
- `material-dark` - Google's Material Design dark variant
|
||||||
|
- `material-light` - Google's Material Design light variant
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
- `:theme <name>` - Switch theme instantly (automatically saved to config)
|
||||||
|
- `:themes` - Browse and select themes in an interactive modal
|
||||||
|
- `:reload` - Reload configuration and themes
|
||||||
|
|
||||||
|
**Setting default theme:**
|
||||||
|
```toml
|
||||||
|
[ui]
|
||||||
|
theme = "gruvbox" # or any built-in/custom theme name
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creating custom themes:**
|
||||||
|
|
||||||
|
Create a `.toml` file in `~/.config/owlen/themes/`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# ~/.config/owlen/themes/my-theme.toml
|
||||||
|
name = "my-theme"
|
||||||
|
text = "#ffffff"
|
||||||
|
background = "#000000"
|
||||||
|
focused_panel_border = "#ff00ff"
|
||||||
|
unfocused_panel_border = "#800080"
|
||||||
|
user_message_role = "#00ffff"
|
||||||
|
assistant_message_role = "#ffff00"
|
||||||
|
# ... see themes/README.md for full schema
|
||||||
|
```
|
||||||
|
|
||||||
|
**Colors** can be hex RGB (`#rrggbb`) or named colors (`red`, `blue`, `lightgreen`, etc.). See `themes/README.md` for the complete list of supported color names.
|
||||||
|
|
||||||
|
For reference theme files and detailed documentation, see the `themes/` directory in the repository or `/usr/share/owlen/themes/` after installation.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
owlen/
|
owlen/
|
||||||
├── crates/
|
├── crates/
|
||||||
│ ├── owlen-core/ # Core types, session management, shared UI components
|
│ ├── owlen-core/ # Core types, session management, theming, shared UI components
|
||||||
│ ├── owlen-ollama/ # Ollama provider implementation
|
│ ├── owlen-ollama/ # Ollama provider implementation
|
||||||
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
|
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
|
||||||
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
|
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
|
||||||
|
├── themes/ # Built-in theme definitions (embedded in binary)
|
||||||
├── LICENSE # AGPL-3.0 License
|
├── LICENSE # AGPL-3.0 License
|
||||||
├── Cargo.toml # Workspace configuration
|
├── Cargo.toml # Workspace configuration
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -224,11 +329,13 @@ cargo fmt
|
|||||||
- [x] Text selection and clipboard functionality
|
- [x] Text selection and clipboard functionality
|
||||||
- [x] Comprehensive keyboard navigation
|
- [x] Comprehensive keyboard navigation
|
||||||
- [x] Bracketed paste support
|
- [x] Bracketed paste support
|
||||||
|
- [x] Command autocompletion with Tab completion
|
||||||
|
- [x] Session persistence (save/load conversations)
|
||||||
|
- [x] Theming system with 9 built-in themes and custom theme support
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
- [ ] Theming options and color customization
|
|
||||||
- [ ] Enhanced configuration UX (in-app settings)
|
- [ ] Enhanced configuration UX (in-app settings)
|
||||||
- [ ] Chat history management (save/load/export)
|
- [ ] Conversation export (Markdown, JSON, plain text)
|
||||||
|
|
||||||
### Planned
|
### Planned
|
||||||
- [ ] Code Client Enhancement
|
- [ ] Code Client Enhancement
|
||||||
@@ -276,4 +383,4 @@ Built with:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Status**: Alpha v0.1.0 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
**Status**: Alpha v0.1.9 | **License**: AGPL-3.0 | **Made with Rust** 🦀
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,4 +218,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,180 @@ 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();
|
||||||
|
match self
|
||||||
|
.controller
|
||||||
|
.generate_conversation_description()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(desc) => Some(desc),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} 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 +1408,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 +1780,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 = format!("Model loaded. Generating response... (streaming)");
|
||||||
|
|
||||||
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, "▌") {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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