18 Commits

Author SHA1 Message Date
5fd4abb0b2 Merge pull request 'dev' (#31) from dev into main
Reviewed-on: #31
2025-10-05 02:33:02 +02:00
4d7ad2c330 Refactor codebase for consistency and readability
- Standardize array and vector formatting for clarity.
- Adjust spacing and indentation in examples and TUI code.
- Ensure proper newline usage across files (e.g., LICENSE, TOML files, etc.).
- Simplify `.to_string()` and `.ok()` calls for brevity.
2025-10-05 02:31:53 +02:00
13af046eff Introduce pre-commit hooks and update contribution guidelines
- Add `.pre-commit-config.yaml` with hooks for formatting, linting, and general file checks.
- Update `CONTRIBUTING.md` to include pre-commit setup instructions and emphasize automated checks during commits.
- Provide detailed steps for installing and running pre-commit hooks.
2025-10-05 02:30:19 +02:00
ff5ea1ded9 Merge pull request 'Add comprehensive documentation and examples for Owlen architecture and usage' (#30) from dev into main
Reviewed-on: #30
2025-10-05 02:24:13 +02:00
5b202fed4f Add comprehensive documentation and examples for Owlen architecture and usage
- Include detailed architecture overview in `docs/architecture.md`.
- Add `docs/configuration.md`, detailing configuration file structure and settings.
- Provide a step-by-step provider implementation guide in `docs/provider-implementation.md`.
- Add frequently asked questions (FAQ) document in `docs/faq.md`.
- Create `docs/migration-guide.md` for future breaking changes and version upgrades.
- Introduce new examples in `examples/` showcasing basic chat, custom providers, and theming.
- Add a changelog (`CHANGELOG.md`) for tracking significant changes.
- Provide contribution guidelines (`CONTRIBUTING.md`) and a Code of Conduct (`CODE_OF_CONDUCT.md`).
2025-10-05 02:23:32 +02:00
979347bf53 Merge pull request 'Update Woodpecker CI: fix typo in cross-compilation target name' (#29) from dev into main
Reviewed-on: #29
2025-10-03 07:58:19 +02:00
76b55ccff5 Update Woodpecker CI: fix typo in cross-compilation target name
All checks were successful
ci/someci/tag/woodpecker/1 Pipeline was successful
ci/someci/tag/woodpecker/2 Pipeline was successful
ci/someci/tag/woodpecker/3 Pipeline was successful
ci/someci/tag/woodpecker/4 Pipeline was successful
ci/someci/tag/woodpecker/5 Pipeline was successful
ci/someci/tag/woodpecker/6 Pipeline was successful
ci/someci/tag/woodpecker/7 Pipeline was successful
2025-10-03 07:57:53 +02:00
f0e162d551 Merge pull request 'Add built-in theme support with various pre-defined themes' (#28) from theming into main
Reviewed-on: #28
2025-10-03 07:48:18 +02:00
6c4571804f Merge branch 'main' into theming 2025-10-03 07:48:10 +02:00
a0cdcfdf6c Merge pull request 'Update .gitignore: add .agents/, .env files, and refine .env.example handling' (#27) from dev into main
Reviewed-on: #27
2025-10-03 07:44:46 +02:00
96e2482782 Add built-in theme support with various pre-defined themes
Some checks failed
ci/someci/tag/woodpecker/5 Pipeline is pending
ci/someci/tag/woodpecker/6 Pipeline is pending
ci/someci/tag/woodpecker/7 Pipeline is pending
ci/someci/tag/woodpecker/1 Pipeline failed
ci/someci/tag/woodpecker/2 Pipeline failed
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
- Introduce multiple built-in themes (`default_dark`, `default_light`, `gruvbox`, `dracula`, `solarized`, `midnight-ocean`, `rose-pine`, `monokai`, `material-dark`, `material-light`).
- Implement theming system with customizable color schemes for all UI components in the TUI.
- Include documentation for themes in `themes/README.md`.
- Add fallback mechanisms for default themes in case of parsing errors.
- Support custom themes with overrides via configuration.
2025-10-03 07:44:11 +02:00
6a3f44f911 Update .gitignore: add .agents/, .env files, and refine .env.example handling 2025-10-03 05:55:32 +02:00
e0e5a2a83d Merge pull request 'dev' (#26) from dev into main
Reviewed-on: #26
2025-10-02 15:28:25 +02:00
23e86591d1 Update README: add installation instructions for Linux and macOS using Cargo 2025-10-02 15:27:27 +02:00
b60a317788 Update README: document command autocompletion and bump version to 0.1.8 2025-10-02 03:11:51 +02:00
2788e8b7e2 Update Woodpecker CI: fix typo in target name and add zip package installation step 2025-10-02 03:07:44 +02:00
7c186882dc Merge pull request 'dev' (#25) from dev into main
Reviewed-on: #25
2025-10-02 03:00:29 +02:00
e58032deae Merge pull request 'dev' (#24) from dev into main
Reviewed-on: #24
2025-10-02 02:11:06 +02:00
53 changed files with 2933 additions and 517 deletions

5
.gitignore vendored
View File

@@ -4,6 +4,10 @@
debug/ debug/
target/ target/
dev/ dev/
.agents/
.env
.env.*
!.env.example
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
@@ -100,4 +104,3 @@ fabric.properties
# Android studio 3.1+ serialized cache file # Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser .idea/caches/build_file_checksums.ser

34
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Pre-commit hooks configuration
# See https://pre-commit.com for more information
repos:
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=1000']
- id: mixed-line-ending
# Rust formatting
- repo: https://github.com/doublify/pre-commit-rust
rev: v1.0
hooks:
- id: fmt
name: cargo fmt
description: Format Rust code with rustfmt
- id: cargo-check
name: cargo check
description: Check Rust code compilation
- id: clippy
name: cargo clippy
description: Lint Rust code with clippy
args: ['--all-features', '--', '-D', 'warnings']
# Optional: run on all files when config changes
default_install_hook_types: [pre-commit, pre-push]

View File

@@ -95,6 +95,7 @@ steps:
- name: package - name: package
image: *rust_image image: *rust_image
commands: commands:
- apt-get update && apt-get install -y zip
- mkdir -p dist - mkdir -p dist
- | - |
if [ "${PLATFORM}" = "windows" ]; then if [ "${PLATFORM}" = "windows" ]; then

81
CHANGELOG.md Normal file
View File

@@ -0,0 +1,81 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Comprehensive documentation suite including guides for architecture, configuration, testing, and more.
- Rustdoc examples for core components like `Provider` and `SessionController`.
- Module-level documentation for `owlen-tui`.
### Changed
- The main `README.md` has been updated to be more concise and link to the new documentation.
---
## [0.1.10] - 2025-10-03
### Added
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
### Fixed
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
### Changed
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
## [0.1.9] - 2025-10-03
*This version corresponds to the release tagged v0.1.10 in the source repository.*
### Added
- **Material Light Theme**: A new built-in theme, `material-light`, has been added.
### Fixed
- **UI Readability**: Fixed a bug causing unreadable text in light themes.
- **Visual Selection**: The visual selection mode now correctly colors unselected text portions.
### Changed
- **Theme Colors**: The color palettes for `gruvbox`, `rose-pine`, and `monokai` have been corrected.
- **In-App Help**: The `:help` menu has been significantly expanded and updated.
## [0.1.8] - 2025-10-02
### Added
- **Command Autocompletion**: Implemented intelligent command suggestions and Tab completion in command mode.
### Changed
- **Build & CI**: Fixed cross-compilation for ARM64, ARMv7, and Windows.
## [0.1.7] - 2025-10-02
### Added
- **Tabbed Help System**: The help menu is now organized into five tabs for easier navigation.
- **Command Aliases**: Added `:o` as a short alias for `:load` / `:open`.
### Changed
- **Session Management**: Improved AI-generated session descriptions.
## [0.1.6] - 2025-10-02
### Added
- **Platform-Specific Storage**: Sessions are now saved to platform-appropriate directories (e.g., `~/.local/share/owlen` on Linux).
- **AI-Generated Session Descriptions**: Conversations can be automatically summarized on save.
### Changed
- **Migration**: Users on older versions can manually move their sessions from `~/.config/owlen/sessions` to the new platform-specific directory.
## [0.1.4] - 2025-10-01
### Added
- **Multi-Platform Builds**: Pre-built binaries are now provided for Linux (x86_64, aarch64, armv7) and Windows (x86_64).
- **AUR Package**: Owlen is now available on the Arch User Repository.
### Changed
- **Build System**: Switched from OpenSSL to rustls for better cross-platform compatibility.

121
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,121 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that are welcoming, open, and respectful.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[security@owlibou.com](mailto:security@owlibou.com). All complaints will be
reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interaction in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html

121
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,121 @@
# Contributing to Owlen
First off, thank you for considering contributing to Owlen! It's people like you that make Owlen such a great tool.
Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests.
## Code of Conduct
This project and everyone participating in it is governed by the [Owlen Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior.
## How Can I Contribute?
### Reporting Bugs
This is one of the most helpful ways you can contribute. Before creating a bug report, please check a few things:
1. **Check the [troubleshooting guide](docs/troubleshooting.md).** Your issue might be a common one with a known solution.
2. **Search the existing issues.** It's possible someone has already reported the same bug. If so, add a comment to the existing issue instead of creating a new one.
When you are creating a bug report, please include as many details as possible. Fill out the required template, the information it asks for helps us resolve issues faster.
### Suggesting Enhancements
If you have an idea for a new feature or an improvement to an existing one, we'd love to hear about it. Please provide as much context as you can about what you're trying to achieve.
### Your First Code Contribution
Unsure where to begin contributing to Owlen? You can start by looking through `good first issue` and `help wanted` issues.
### Pull Requests
The process for submitting a pull request is as follows:
1. **Fork the repository** and create your branch from `main`.
2. **Set up pre-commit hooks** (see [Development Setup](#development-setup) above). This will automatically format and lint your code.
3. **Make your changes.**
4. **Run the tests.**
- `cargo test --all`
5. **Commit your changes.** The pre-commit hooks will automatically run `cargo fmt`, `cargo check`, and `cargo clippy`. If you need to bypass the hooks (not recommended), use `git commit --no-verify`.
6. **Add a clear, concise commit message.** We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
7. **Push to your fork** and submit a pull request to Owlen's `main` branch.
8. **Include a clear description** of the problem and solution. Include the relevant issue number if applicable.
## Development Setup
To get started with the codebase, you'll need to have Rust installed. Then, you can clone the repository and build the project:
```sh
git clone https://github.com/Owlibou/owlen.git
cd owlen
cargo build
```
### Pre-commit Hooks
We use [pre-commit](https://pre-commit.com/) to automatically run formatting and linting checks before each commit. This helps maintain code quality and consistency.
**Install pre-commit:**
```sh
# Arch Linux
sudo pacman -S pre-commit
# Other Linux/macOS
pip install pre-commit
# Verify installation
pre-commit --version
```
**Setup the hooks:**
```sh
cd owlen
pre-commit install
```
Once installed, the hooks will automatically run on every commit. You can also run them manually:
```sh
# Run on all files
pre-commit run --all-files
# Run on staged files only
pre-commit run
```
The pre-commit hooks will check:
- Code formatting (`cargo fmt`)
- Compilation (`cargo check`)
- Linting (`cargo clippy --all-features`)
- General file hygiene (trailing whitespace, EOF newlines, etc.)
## Coding Style
- We use `cargo fmt` for automated code formatting. Please run it before committing your changes.
- We use `cargo clippy` for linting. Your code should be free of any clippy warnings.
## Commit Message Conventions
We use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for our commit messages. This allows for automated changelog generation and makes the project history easier to read.
The basic format is:
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
**Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`.
**Example:**
```
feat(provider): add support for Gemini Pro
```
Thank you for your contribution!

View File

@@ -9,7 +9,7 @@ members = [
exclude = [] exclude = []
[workspace.package] [workspace.package]
version = "0.1.8" version = "0.1.9"
edition = "2021" edition = "2021"
authors = ["Owlibou"] authors = ["Owlibou"]
license = "AGPL-3.0" license = "AGPL-3.0"

View File

@@ -659,4 +659,3 @@ specific requirements.
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev> # Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlen pkgname=owlen
pkgver=0.1.8 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') arch=('x86_64')
@@ -40,5 +40,10 @@ package() {
# 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
}

296
README.md
View File

@@ -3,17 +3,10 @@
> Terminal-native assistant for running local language models with a comfortable TUI. > Terminal-native assistant for running local language models with a comfortable TUI.
![Status](https://img.shields.io/badge/status-alpha-yellow) ![Status](https://img.shields.io/badge/status-alpha-yellow)
![Version](https://img.shields.io/badge/version-0.1.8-blue) ![Version](https://img.shields.io/badge/version-0.1.9-blue)
![Rust](https://img.shields.io/badge/made_with-Rust-ffc832?logo=rust&logoColor=white) ![Rust](https://img.shields.io/badge/made_with-Rust-ffc832?logo=rust&logoColor=white)
![License](https://img.shields.io/badge/license-AGPL--3.0-blue) ![License](https://img.shields.io/badge/license-AGPL--3.0-blue)
## Alpha Status
- This project is currently in **alpha** (v0.1.8) and under active development.
- Core features are functional but expect occasional bugs and missing polish.
- Breaking changes may occur between releases as we refine the API.
- Feedback, bug reports, and contributions are very welcome!
## What Is OWLEN? ## What Is OWLEN?
OWLEN is a Rust-powered, terminal-first interface for interacting with local large OWLEN is a Rust-powered, terminal-first interface for interacting with local large
@@ -21,287 +14,90 @@ language models. It provides a responsive chat workflow that runs against
[Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation, [Ollama](https://ollama.com/) with a focus on developer productivity, vim-style navigation,
and seamless session management—all without leaving your terminal. and seamless session management—all without leaving your terminal.
## Alpha Status
This project is currently in **alpha** and under active development. Core features are functional, but expect occasional bugs and breaking changes. Feedback, bug reports, and contributions are very welcome!
## Screenshots ## Screenshots
### Initial Layout
![OWLEN TUI Layout](images/layout.png) ![OWLEN TUI Layout](images/layout.png)
The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory including: The OWLEN interface features a clean, multi-panel layout with vim-inspired navigation. See more screenshots in the [`images/`](images/) directory.
- Full chat conversations (`chat_view.png`)
- Help menu (`help.png`)
- Model selection (`model_select.png`)
- Visual selection mode (`select_mode.png`)
## Features ## Features
### Chat Client (`owlen`) - **Vim-style Navigation**: Normal, editing, visual, and command modes.
- **Vim-style Navigation** - Normal, editing, visual, and command modes - **Streaming Responses**: Real-time token streaming from Ollama.
- **Streaming Responses** - Real-time token streaming from Ollama - **Advanced Text Editing**: Multi-line input, history, and clipboard support.
- **Multi-Panel Interface** - Separate panels for chat, thinking content, and input - **Session Management**: Save, load, and manage conversations.
- **Advanced Text Editing** - Multi-line input with `tui-textarea`, history navigation - **Theming System**: 10 built-in themes and support for custom themes.
- **Visual Selection & Clipboard** - Yank/paste text across panels - **Modular Architecture**: Extensible provider system (currently Ollama).
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
- **Model Management** - Interactive model and provider selection (press `m`)
- **Session Persistence** - Save and load conversations to/from disk
- **AI-Generated Descriptions** - Automatic short summaries for saved sessions
- **Session Management** - Start new conversations, clear history, browse saved sessions
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
- **Bracketed Paste** - Safe paste handling for multi-line content
### Code Client (`owlen-code`) [Experimental]
- All chat client features
- Optimized system prompt for programming assistance
- Foundation for future code-specific features
### Core Infrastructure
- **Modular Architecture** - Separated core logic, TUI components, and providers
- **Provider System** - Extensible provider trait (currently: Ollama)
- **Session Controller** - Unified conversation and state management
- **Configuration Management** - TOML-based config with sensible defaults
- **Message Formatting** - Markdown rendering, thinking content extraction
- **Async Runtime** - Built on Tokio for efficient streaming
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
- Rust 1.75+ and Cargo (`rustup` recommended) - Rust 1.75+ and Cargo.
- A running Ollama instance with at least one model pulled - A running Ollama instance.
(defaults to `http://localhost:11434`) - A terminal that supports 256 colors.
- A terminal that supports 256 colors
### Clone and Build ### Installation
#### Linux & macOS
The recommended way to install on Linux and macOS is to clone the repository and install using `cargo`.
```bash ```bash
git clone https://somegit.dev/Owlibou/owlen.git git clone https://github.com/Owlibou/owlen.git
cd owlen cd owlen
cargo build --release cargo install --path crates/owlen-cli
``` ```
**Note for macOS**: While this method works, official binary releases for macOS are planned for the future.
### Run the Chat Client #### Windows
The Windows build has not been thoroughly tested yet. Installation is possible via the same `cargo install` method, but it is considered experimental at this time.
Make sure Ollama is running, then launch: ### Running OWLEN
Make sure Ollama is running, then launch the application:
```bash
owlen
```
If you built from source without installing, you can run it with:
```bash ```bash
./target/release/owlen ./target/release/owlen
# or during development:
cargo run --bin owlen
```
### (Optional) Try the Code Client
The coding-focused TUI is experimental:
```bash
cargo build --release --bin owlen-code --features code-client
./target/release/owlen-code
``` ```
## Using the TUI ## Using the TUI
### Mode System (Vim-inspired) OWLEN uses a modal, vim-inspired interface. Press `?` in Normal mode to view the help screen with all keybindings.
**Normal Mode** (default): - **Normal Mode**: Navigate with `h/j/k/l`, `w/b`, `gg/G`.
- `i` / `Enter` - Enter editing mode - **Editing Mode**: Enter with `i` or `a`. Send messages with `Enter`.
- `a` - Append (move right and enter editing mode) - **Command Mode**: Enter with `:`. Access commands like `:quit`, `:save`, `:theme`.
- `A` - Append at end of line
- `I` - Insert at start of line
- `o` - Insert new line below
- `O` - Insert new line above
- `v` - Enter visual mode (text selection)
- `:` - Enter command mode
- `h/j/k/l` - Navigate left/down/up/right
- `w/b/e` - Word navigation
- `0/$` - Jump to line start/end
- `gg` - Jump to top
- `G` - Jump to bottom
- `Ctrl-d/u` - Half-page scroll
- `Ctrl-f/b` - Full-page scroll
- `Tab` - Cycle focus between panels
- `p` - Paste from clipboard
- `dd` - Clear input buffer
- `q` - Quit
**Editing Mode**: ## Documentation
- `Esc` - Return to normal mode
- `Enter` - Send message and return to normal mode
- `Ctrl-J` / `Shift-Enter` - Insert newline
- `Ctrl-↑/↓` - Navigate input history
- Paste events handled automatically
**Visual Mode**: For more detailed information, please refer to the following documents:
- `j/k/h/l` - Extend selection
- `w/b/e` - Word-based selection
- `y` - Yank (copy) selection
- `d` - Cut selection (Input panel only)
- `Esc` - Cancel selection
**Command Mode**: - **[CONTRIBUTING.md](CONTRIBUTING.md)**: Guidelines for contributing to the project.
- `:q` / `:quit` - Quit application - **[CHANGELOG.md](CHANGELOG.md)**: A log of changes for each version.
- `:c` / `:clear` - Clear conversation - **[docs/architecture.md](docs/architecture.md)**: An overview of the project's architecture.
- `:m` / `:model` - Open model selector - **[docs/troubleshooting.md](docs/troubleshooting.md)**: Help with common issues.
- `:n` / `:new` - Start new conversation - **[docs/provider-implementation.md](docs/provider-implementation.md)**: A guide for adding new providers.
- `:h` / `:help` - Show help
- `:save [name]` / `:w [name]` - Save current conversation
- `:load` / `:open` - Browse and load saved sessions
- `:sessions` / `:ls` - List saved sessions
**Session Browser** (accessed via `:load` or `:sessions`):
- `j` / `k` / `↑` / `↓` - Navigate sessions
- `Enter` - Load selected session
- `d` - Delete selected session
- `Esc` - Close browser
### Panel Management
- Three panels: Chat, Thinking, and Input
- `Tab` / `Shift-Tab` - Cycle focus forward/backward
- Focused panel receives scroll and navigation commands
- Thinking panel appears when extended reasoning is available
## Configuration ## Configuration
OWLEN stores configuration in `~/.config/owlen/config.toml`. The file is created OWLEN stores its configuration in `~/.config/owlen/config.toml`. This file is created on the first run and can be customized. You can also add custom themes in `~/.config/owlen/themes/`.
on first run and can be edited to customize behavior:
```toml See the [themes/README.md](themes/README.md) for more details on theming.
[general]
default_model = "llama3.2:latest"
default_provider = "ollama"
enable_streaming = true
project_context_file = "OWLEN.md"
[providers.ollama]
provider_type = "ollama"
base_url = "http://localhost:11434"
timeout = 300
```
### Storage Settings
Sessions are saved to platform-specific directories by default:
- **Linux**: `~/.local/share/owlen/sessions`
- **Windows**: `%APPDATA%\owlen\sessions`
- **macOS**: `~/Library/Application Support/owlen/sessions`
You can customize this in your config:
```toml
[storage]
# conversation_dir = "~/custom/path" # Optional: override default location
max_saved_sessions = 25
generate_descriptions = true # AI-generated summaries for saved sessions
```
Configuration is automatically saved when you change models or providers.
## Repository Layout
```
owlen/
├── crates/
│ ├── owlen-core/ # Core types, session management, shared UI components
│ ├── owlen-ollama/ # Ollama provider implementation
│ ├── owlen-tui/ # TUI components (chat_app, code_app, rendering)
│ └── owlen-cli/ # Binary entry points (owlen, owlen-code)
├── LICENSE # AGPL-3.0 License
├── Cargo.toml # Workspace configuration
└── README.md
```
### Architecture Highlights
- **owlen-core**: Provider-agnostic core with session controller, UI primitives (AutoScroll, InputMode, FocusedPanel), and shared utilities
- **owlen-tui**: Ratatui-based UI implementation with vim-style modal editing
- **Separation of Concerns**: Clean boundaries between business logic, presentation, and provider implementations
## Development
### Building
```bash
# Debug build
cargo build
# Release build
cargo build --release
# Build with all features
cargo build --all-features
# Run tests
cargo test
# Check code
cargo clippy
cargo fmt
```
### Development Notes
- Standard Rust workflows apply (`cargo fmt`, `cargo clippy`, `cargo test`)
- Codebase uses async Rust (`tokio`) for event handling and streaming
- Configuration is cached in `~/.config/owlen` (wipe to reset)
- UI components are extensively tested in `owlen-core/src/ui.rs`
## Roadmap ## Roadmap
### Completed ✓ We are actively working on enhancing the code client, adding more providers (OpenAI, Anthropic), and improving the overall user experience. See the [Roadmap section in the old README](https://github.com/Owlibou/owlen/blob/main/README.md?plain=1#L295) for more details.
- [x] Streaming responses with real-time display
- [x] Autoscroll and viewport management
- [x] Push user message before loading LLM response
- [x] Thinking mode support with dedicated panel
- [x] Vim-style modal editing (Normal, Visual, Command modes)
- [x] Multi-panel focus management
- [x] Text selection and clipboard functionality
- [x] Comprehensive keyboard navigation
- [x] Bracketed paste support
### In Progress
- [x] Session persistence (save/load conversations)
- [ ] Theming options and color customization
- [ ] Enhanced configuration UX (in-app settings)
- [ ] Conversation export (Markdown, JSON, plain text)
### Planned
- [ ] Code Client Enhancement
- [ ] In-project code navigation
- [ ] Syntax highlighting for code blocks
- [ ] File tree browser integration
- [ ] Project-aware context management
- [ ] Code snippets and templates
- [ ] Additional LLM Providers
- [ ] OpenAI API support
- [ ] Anthropic Claude support
- [ ] Local model providers (llama.cpp, etc.)
- [ ] Advanced Features
- [ ] Conversation search and filtering
- [ ] Multi-session management
- [ ] Export conversations (Markdown, JSON)
- [ ] Custom keybindings
- [ ] Plugin system
## Contributing ## Contributing
Contributions are welcome! Here's how to get started: Contributions are highly welcome! Please see our **[Contributing Guide](CONTRIBUTING.md)** for details on how to get started, including our code style, commit conventions, and pull request process.
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Make your changes and add tests
4. Run `cargo fmt` and `cargo clippy`
5. Commit your changes (`git commit -m 'Add amazing feature'`)
6. Push to the branch (`git push origin feature/amazing-feature`)
7. Open a Pull Request
Please open an issue first for significant changes to discuss the approach.
## License ## License
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) - see the [LICENSE](LICENSE) file for details. This project is licensed under the GNU Affero General Public License v3.0. See the [LICENSE](LICENSE) file for details.
## Acknowledgments
Built with:
- [ratatui](https://ratatui.rs/) - Terminal UI framework
- [crossterm](https://github.com/crossterm-rs/crossterm) - Cross-platform terminal manipulation
- [tokio](https://tokio.rs/) - Async runtime
- [Ollama](https://ollama.com/) - Local LLM runtime
---
**Status**: Alpha v0.1.0 | **License**: AGPL-3.0 | **Made with Rust** 🦀

19
SECURITY.md Normal file
View File

@@ -0,0 +1,19 @@
# Security Policy
## Supported Versions
We are currently in a pre-release phase, so only the latest version is actively supported. As we move towards a 1.0 release, this policy will be updated with specific version support.
| Version | Supported |
| ------- | ------------------ |
| < 1.0 | :white_check_mark: |
## Reporting a Vulnerability
The Owlen team and community take all security vulnerabilities seriously. Thank you for improving the security of our project. We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
To report a security vulnerability, please email the project lead at [security@owlibou.com](mailto:security@owlibou.com) with a detailed description of the issue, the steps to reproduce it, and any affected versions.
You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible, depending on the complexity of the issue.
Please do not report security vulnerabilities through public GitHub issues.

View File

@@ -0,0 +1,5 @@
# Owlen Anthropic
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Anthropic (Claude) API.
This provider is not yet implemented. Contributions are welcome!

View File

@@ -0,0 +1,15 @@
# Owlen CLI
This crate is the command-line entry point for the Owlen application.
It is responsible for:
- Parsing command-line arguments.
- Loading the configuration.
- Initializing the providers.
- Starting the `owlen-tui` application.
There are two binaries:
- `owlen`: The main chat application.
- `owlen-code`: A specialized version for code-related tasks.

View File

@@ -24,6 +24,7 @@ async-trait = "0.1.73"
toml = "0.8.0" toml = "0.8.0"
shellexpand = "3.1.0" shellexpand = "3.1.0"
dirs = "5.0" dirs = "5.0"
ratatui = { workspace = true }
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }

View File

@@ -0,0 +1,12 @@
# Owlen Core
This crate provides the core abstractions and data structures for the Owlen ecosystem.
It defines the essential traits and types that enable communication with various LLM providers, manage sessions, and handle configuration.
## Key Components
- **`Provider` trait**: The fundamental abstraction for all LLM providers. Implement this trait to add support for a new provider.
- **`Session`**: Represents a single conversation, managing message history and context.
- **`Model`**: Defines the structure for LLM models, including their names and properties.
- **Configuration**: Handles loading and parsing of the application's configuration.

View File

@@ -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 {
@@ -388,7 +388,9 @@ mod tests {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// macOS should use ~/Library/Application Support // macOS should use ~/Library/Application Support
assert!(path.to_string_lossy().contains("Library/Application Support")); assert!(path
.to_string_lossy()
.contains("Library/Application Support"));
} }
println!("Config conversation path: {}", path.display()); println!("Config conversation path: {}", path.display());

View File

@@ -277,20 +277,26 @@ impl ConversationManager {
&self, &self,
storage: &StorageManager, storage: &StorageManager,
name: Option<String>, name: Option<String>,
description: Option<String> description: Option<String>,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
storage.save_conversation_with_description(&self.active, name, description) storage.save_conversation_with_description(&self.active, name, description)
} }
/// Load a conversation from disk and make it active /// Load a conversation from disk and make it active
pub fn load_from_disk(&mut self, storage: &StorageManager, path: impl AsRef<Path>) -> Result<()> { pub fn load_from_disk(
&mut self,
storage: &StorageManager,
path: impl AsRef<Path>,
) -> Result<()> {
let conversation = storage.load_conversation(path)?; let conversation = storage.load_conversation(path)?;
self.load(conversation); self.load(conversation);
Ok(()) Ok(())
} }
/// List all saved sessions /// List all saved sessions
pub fn list_saved_sessions(storage: &StorageManager) -> Result<Vec<crate::storage::SessionMeta>> { pub fn list_saved_sessions(
storage: &StorageManager,
) -> Result<Vec<crate::storage::SessionMeta>> {
storage.list_sessions() storage.list_sessions()
} }
} }

View File

@@ -12,6 +12,7 @@ pub mod provider;
pub mod router; pub mod router;
pub mod session; pub mod session;
pub mod storage; 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;
@@ -24,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>;

View File

@@ -9,6 +9,72 @@ use std::sync::Arc;
pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>; pub type ChatStream = Pin<Box<dyn Stream<Item = Result<ChatResponse>> + Send>>;
/// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.) /// Trait for LLM providers (Ollama, OpenAI, Anthropic, etc.)
///
/// # Example
///
/// ```
/// use std::pin::Pin;
/// use std::sync::Arc;
/// use futures::Stream;
/// use owlen_core::provider::{Provider, ProviderRegistry, ChatStream};
/// use owlen_core::types::{ChatRequest, ChatResponse, ModelInfo, Message};
/// use owlen_core::Result;
///
/// // 1. Create a mock provider
/// struct MockProvider;
///
/// #[async_trait::async_trait]
/// impl Provider for MockProvider {
/// fn name(&self) -> &str {
/// "mock"
/// }
///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> {
/// Ok(vec![ModelInfo {
/// provider: "mock".to_string(),
/// name: "mock-model".to_string(),
/// ..Default::default()
/// }])
/// }
///
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// let content = format!("Response to: {}", request.messages.last().unwrap().content);
/// Ok(ChatResponse {
/// model: request.model,
/// message: Message { role: "assistant".to_string(), content, ..Default::default() },
/// ..Default::default()
/// })
/// }
///
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> {
/// unimplemented!();
/// }
///
/// async fn health_check(&self) -> Result<()> {
/// Ok(())
/// }
/// }
///
/// // 2. Use the provider with a registry
/// #[tokio::main]
/// async fn main() {
/// let mut registry = ProviderRegistry::new();
/// registry.register(MockProvider);
///
/// let provider = registry.get("mock").unwrap();
/// let models = provider.list_models().await.unwrap();
/// assert_eq!(models[0].name, "mock-model");
///
/// let request = ChatRequest {
/// model: "mock-model".to_string(),
/// messages: vec![Message { role: "user".to_string(), content: "Hello".to_string(), ..Default::default() }],
/// ..Default::default()
/// };
///
/// let response = provider.chat(request).await.unwrap();
/// assert_eq!(response.message.content, "Response to: Hello");
/// }
/// ```
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Provider: Send + Sync { pub trait Provider: Send + Sync {
/// Get the name of this provider /// Get the name of this provider

View File

@@ -20,7 +20,61 @@ pub enum SessionOutcome {
}, },
} }
/// High-level controller encapsulating session state and provider interactions /// High-level controller encapsulating session state and provider interactions.
///
/// This is the main entry point for managing conversations and interacting with LLM providers.
///
/// # Example
///
/// ```
/// use std::sync::Arc;
/// use owlen_core::config::Config;
/// use owlen_core::provider::{Provider, ChatStream};
/// use owlen_core::session::{SessionController, SessionOutcome};
/// use owlen_core::types::{ChatRequest, ChatResponse, ChatParameters, Message, ModelInfo};
/// use owlen_core::Result;
///
/// // Mock provider for the example
/// struct MockProvider;
/// #[async_trait::async_trait]
/// impl Provider for MockProvider {
/// fn name(&self) -> &str { "mock" }
/// async fn list_models(&self) -> Result<Vec<ModelInfo>> { Ok(vec![]) }
/// async fn chat(&self, request: ChatRequest) -> Result<ChatResponse> {
/// Ok(ChatResponse {
/// model: request.model,
/// message: Message::assistant("Hello back!".to_string()),
/// ..Default::default()
/// })
/// }
/// async fn chat_stream(&self, request: ChatRequest) -> Result<ChatStream> { unimplemented!() }
/// async fn health_check(&self) -> Result<()> { Ok(()) }
/// }
///
/// #[tokio::main]
/// async fn main() {
/// let provider = Arc::new(MockProvider);
/// let config = Config::default();
/// let mut session_controller = SessionController::new(provider, config);
///
/// // Send a message
/// let outcome = session_controller.send_message(
/// "Hello".to_string(),
/// ChatParameters { stream: false, ..Default::default() }
/// ).await.unwrap();
///
/// // Check the response
/// if let SessionOutcome::Complete(response) = outcome {
/// assert_eq!(response.message.content, "Hello back!");
/// }
///
/// // The conversation now contains both messages
/// let messages = session_controller.conversation().messages.clone();
/// assert_eq!(messages.len(), 2);
/// assert_eq!(messages[0].content, "Hello");
/// assert_eq!(messages[1].content, "Hello back!");
/// }
/// ```
pub struct SessionController { pub struct SessionController {
provider: Arc<dyn Provider>, provider: Arc<dyn Provider>,
conversation: ConversationManager, conversation: ConversationManager,
@@ -231,7 +285,15 @@ impl SessionController {
if conv.messages.len() == 1 { if conv.messages.len() == 1 {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })); return Ok(format!(
"{}{}",
preview,
if first_msg.content.len() > 50 {
"..."
} else {
""
}
));
} }
// Build a summary prompt from the first few and last few messages // Build a summary prompt from the first few and last few messages
@@ -240,7 +302,8 @@ impl SessionController {
// Add system message to guide the description // Add system message to guide the description
summary_messages.push(crate::types::Message::system( summary_messages.push(crate::types::Message::system(
"Summarize this conversation in 1-2 short sentences (max 100 characters). \ "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() Focus on the main topic or question being discussed. Be concise and descriptive."
.to_string(),
)); ));
// Include first message // Include first message
@@ -283,7 +346,15 @@ impl SessionController {
if description.is_empty() { if description.is_empty() {
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })); return Ok(format!(
"{}{}",
preview,
if first_msg.content.len() > 50 {
"..."
} else {
""
}
));
} }
// Truncate if too long // Truncate if too long
@@ -298,7 +369,15 @@ impl SessionController {
// Fallback to simple description if AI generation fails // Fallback to simple description if AI generation fails
let first_msg = &conv.messages[0]; let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>(); let preview = first_msg.content.chars().take(50).collect::<String>();
Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" })) Ok(format!(
"{}{}",
preview,
if first_msg.content.len() > 50 {
"..."
} else {
""
}
))
} }
} }
} }

View File

@@ -45,10 +45,7 @@ impl StorageManager {
// Ensure the directory exists // Ensure the directory exists
if !sessions_dir.exists() { if !sessions_dir.exists() {
fs::create_dir_all(&sessions_dir).map_err(|e| { fs::create_dir_all(&sessions_dir).map_err(|e| {
Error::Storage(format!( Error::Storage(format!("Failed to create sessions directory: {}", e))
"Failed to create sessions directory: {}",
e
))
})?; })?;
} }
@@ -66,7 +63,11 @@ impl StorageManager {
} }
/// Save a conversation to disk /// Save a conversation to disk
pub fn save_conversation(&self, conversation: &Conversation, name: Option<String>) -> Result<PathBuf> { pub fn save_conversation(
&self,
conversation: &Conversation,
name: Option<String>,
) -> Result<PathBuf> {
self.save_conversation_with_description(conversation, name, None) self.save_conversation_with_description(conversation, name, None)
} }
@@ -75,7 +76,7 @@ impl StorageManager {
&self, &self,
conversation: &Conversation, conversation: &Conversation,
name: Option<String>, name: Option<String>,
description: Option<String> description: Option<String>,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
let filename = if let Some(ref session_name) = name { let filename = if let Some(ref session_name) = name {
// Use provided name, sanitized // Use provided name, sanitized
@@ -101,26 +102,22 @@ impl StorageManager {
save_conv.description = description; save_conv.description = description;
} }
let json = serde_json::to_string_pretty(&save_conv).map_err(|e| { let json = serde_json::to_string_pretty(&save_conv)
Error::Storage(format!("Failed to serialize conversation: {}", e)) .map_err(|e| Error::Storage(format!("Failed to serialize conversation: {}", e)))?;
})?;
fs::write(&path, json).map_err(|e| { fs::write(&path, json)
Error::Storage(format!("Failed to write session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to write session file: {}", e)))?;
})?;
Ok(path) Ok(path)
} }
/// Load a conversation from disk /// Load a conversation from disk
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> { pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| { let content = fs::read_to_string(path.as_ref())
Error::Storage(format!("Failed to read session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to read session file: {}", e)))?;
})?;
let conversation: Conversation = serde_json::from_str(&content).map_err(|e| { let conversation: Conversation = serde_json::from_str(&content)
Error::Storage(format!("Failed to parse session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to parse session file: {}", e)))?;
})?;
Ok(conversation) Ok(conversation)
} }
@@ -129,14 +126,12 @@ impl StorageManager {
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> { pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
let entries = fs::read_dir(&self.sessions_dir).map_err(|e| { let entries = fs::read_dir(&self.sessions_dir)
Error::Storage(format!("Failed to read sessions directory: {}", e)) .map_err(|e| Error::Storage(format!("Failed to read sessions directory: {}", e)))?;
})?;
for entry in entries { for entry in entries {
let entry = entry.map_err(|e| { let entry = entry
Error::Storage(format!("Failed to read directory entry: {}", e)) .map_err(|e| Error::Storage(format!("Failed to read directory entry: {}", e)))?;
})?;
let path = entry.path(); let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") { if path.extension().and_then(|s| s.to_str()) != Some("json") {
@@ -172,9 +167,8 @@ impl StorageManager {
/// Delete a saved session /// Delete a saved session
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> { pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
fs::remove_file(path.as_ref()).map_err(|e| { fs::remove_file(path.as_ref())
Error::Storage(format!("Failed to delete session file: {}", e)) .map_err(|e| Error::Storage(format!("Failed to delete session file: {}", e)))
})
} }
/// Get the sessions directory path /// Get the sessions directory path
@@ -237,7 +231,9 @@ mod tests {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
// macOS should use ~/Library/Application Support // macOS should use ~/Library/Application Support
assert!(path.to_string_lossy().contains("Library/Application Support")); assert!(path
.to_string_lossy()
.contains("Library/Application Support"));
} }
println!("Default sessions directory: {}", path.display()); println!("Default sessions directory: {}", path.display());
@@ -257,10 +253,13 @@ mod tests {
let mut conv = Conversation::new("test-model".to_string()); let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user("Hello".to_string())); conv.messages.push(Message::user("Hello".to_string()));
conv.messages.push(Message::assistant("Hi there!".to_string())); conv.messages
.push(Message::assistant("Hi there!".to_string()));
// Save conversation // Save conversation
let path = storage.save_conversation(&conv, Some("test_session".to_string())).unwrap(); let path = storage
.save_conversation(&conv, Some("test_session".to_string()))
.unwrap();
assert!(path.exists()); assert!(path.exists());
// Load conversation // Load conversation
@@ -280,7 +279,9 @@ mod tests {
for i in 0..3 { for i in 0..3 {
let mut conv = Conversation::new("test-model".to_string()); let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user(format!("Message {}", i))); conv.messages.push(Message::user(format!("Message {}", i)));
storage.save_conversation(&conv, Some(format!("session_{}", i))).unwrap(); storage
.save_conversation(&conv, Some(format!("session_{}", i)))
.unwrap();
} }
// List sessions // List sessions

View 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"));
}
}

View File

@@ -23,6 +23,7 @@ pub enum InputMode {
Visual, Visual,
Command, Command,
SessionBrowser, SessionBrowser,
ThemeBrowser,
} }
impl fmt::Display for InputMode { impl fmt::Display for InputMode {
@@ -36,6 +37,7 @@ impl fmt::Display for InputMode {
InputMode::Visual => "Visual", InputMode::Visual => "Visual",
InputMode::Command => "Command", InputMode::Command => "Command",
InputMode::SessionBrowser => "Sessions", InputMode::SessionBrowser => "Sessions",
InputMode::ThemeBrowser => "Themes",
}; };
f.write_str(label) f.write_str(label)
} }

View File

@@ -0,0 +1,5 @@
# Owlen Gemini
This crate is a placeholder for a future `owlen-core::Provider` implementation for the Google Gemini API.
This provider is not yet implemented. Contributions are welcome!

View File

@@ -0,0 +1,9 @@
# Owlen Ollama
This crate provides an implementation of the `owlen-core::Provider` trait for the [Ollama](https://ollama.ai) backend.
It allows Owlen to communicate with a local Ollama instance, sending requests and receiving responses from locally-run large language models.
## Configuration
To use this provider, you need to have Ollama installed and running. The default address is `http://localhost:11434`. You can configure this in your `config.toml` if your Ollama instance is running elsewhere.

View File

@@ -0,0 +1,5 @@
# Owlen OpenAI
This crate is a placeholder for a future `owlen-core::Provider` implementation for the OpenAI API.
This provider is not yet implemented. Contributions are welcome!

View File

@@ -0,0 +1,12 @@
# Owlen TUI
This crate contains all the logic for the terminal user interface (TUI) of Owlen.
It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for rendering the chat interface, handling user input, and managing the application state.
## Features
- **Chat View**: A scrollable view of the conversation history.
- **Input Box**: A text input area for composing messages.
- **Model Selection**: An interface for switching between different models.
- **Event Handling**: A system for managing keyboard events and asynchronous operations.

View File

@@ -2,6 +2,7 @@ use anyhow::Result;
use owlen_core::{ use owlen_core::{
session::{SessionController, SessionOutcome}, session::{SessionController, SessionOutcome},
storage::{SessionMeta, StorageManager}, 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},
}; };
@@ -61,8 +62,10 @@ pub struct ChatApp {
storage: StorageManager, // Storage manager for session persistence storage: StorageManager, // Storage manager for session persistence
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
selected_session_index: usize, // Index of selected session in browser selected_session_index: usize, // Index of selected session in browser
save_name_buffer: String, // Buffer for entering save name
help_tab_index: usize, // Currently selected help tab (0-4) 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 {
@@ -77,6 +80,13 @@ impl ChatApp {
.expect("Failed to create fallback storage") .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,
@@ -112,8 +122,10 @@ impl ChatApp {
storage, storage,
saved_sessions: Vec::new(), saved_sessions: Vec::new(),
selected_session_index: 0, selected_session_index: 0,
save_name_buffer: String::new(),
help_tab_index: 0, help_tab_index: 0,
theme,
available_themes: Vec::new(),
selected_theme_index: 0,
}; };
(app, session_rx) (app, session_rx)
@@ -238,6 +250,9 @@ impl ChatApp {
("m", "Alias for model"), ("m", "Alias for model"),
("new", "Start a new conversation"), ("new", "Start a new conversation"),
("n", "Alias for new"), ("n", "Alias for new"),
("theme", "Switch theme"),
("themes", "List available themes"),
("reload", "Reload configuration and themes"),
] ]
} }
@@ -312,6 +327,39 @@ impl ChatApp {
self.help_tab_index 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 => {
@@ -1086,9 +1134,7 @@ impl ChatApp {
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => { (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
// Navigate up in suggestions // Navigate up in suggestions
if !self.command_suggestions.is_empty() { if !self.command_suggestions.is_empty() {
self.selected_suggestion = self self.selected_suggestion = self.selected_suggestion.saturating_sub(1);
.selected_suggestion
.saturating_sub(1);
} }
} }
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => { (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
@@ -1122,18 +1168,24 @@ impl ChatApp {
}; };
// Generate description if enabled in config // Generate description if enabled in config
let description = if self.controller.config().storage.generate_descriptions { let description =
self.status = "Generating description...".to_string(); if self.controller.config().storage.generate_descriptions {
match self.controller.generate_conversation_description().await { self.status = "Generating description...".to_string();
Ok(desc) => Some(desc), (self.controller.generate_conversation_description().await)
Err(_) => None, .ok()
} } else {
} else { None
None };
};
// Save the conversation with description // Save the conversation with description
match self.controller.conversation_mut().save_active_with_description(&self.storage, name.clone(), description) { match self
.controller
.conversation_mut()
.save_active_with_description(
&self.storage,
name.clone(),
description,
) {
Ok(path) => { Ok(path) => {
self.status = format!("Session saved: {}", path.display()); self.status = format!("Session saved: {}", path.display());
self.error = None; self.error = None;
@@ -1155,7 +1207,8 @@ impl ChatApp {
return Ok(AppState::Running); return Ok(AppState::Running);
} }
Err(e) => { Err(e) => {
self.error = Some(format!("Failed to list sessions: {}", e)); self.error =
Some(format!("Failed to list sessions: {}", e));
} }
} }
} }
@@ -1171,7 +1224,8 @@ impl ChatApp {
return Ok(AppState::Running); return Ok(AppState::Running);
} }
Err(e) => { Err(e) => {
self.error = Some(format!("Failed to list sessions: {}", e)); self.error =
Some(format!("Failed to list sessions: {}", e));
} }
} }
} }
@@ -1192,6 +1246,70 @@ impl ChatApp {
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));
} }
@@ -1290,7 +1408,7 @@ impl ChatApp {
} }
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
// Next tab // Next tab
if self.help_tab_index < 4 { if self.help_tab_index < 5 {
self.help_tab_index += 1; self.help_tab_index += 1;
} }
} }
@@ -1305,6 +1423,7 @@ impl ChatApp {
KeyCode::Char('3') => self.help_tab_index = 2, KeyCode::Char('3') => self.help_tab_index = 2,
KeyCode::Char('4') => self.help_tab_index = 3, KeyCode::Char('4') => self.help_tab_index = 3,
KeyCode::Char('5') => self.help_tab_index = 4, KeyCode::Char('5') => self.help_tab_index = 4,
KeyCode::Char('6') => self.help_tab_index = 5,
_ => {} _ => {}
}, },
InputMode::SessionBrowser => match key.code { InputMode::SessionBrowser => match key.code {
@@ -1313,10 +1432,18 @@ impl ChatApp {
} }
KeyCode::Enter => { KeyCode::Enter => {
// Load selected session // Load selected session
if let Some(session) = self.saved_sessions.get(self.selected_session_index) { if let Some(session) = self.saved_sessions.get(self.selected_session_index)
match self.controller.conversation_mut().load_from_disk(&self.storage, &session.path) { {
match self
.controller
.conversation_mut()
.load_from_disk(&self.storage, &session.path)
{
Ok(_) => { Ok(_) => {
self.status = format!("Loaded session: {}", session.name.as_deref().unwrap_or("Unnamed")); self.status = format!(
"Loaded session: {}",
session.name.as_deref().unwrap_or("Unnamed")
);
self.error = None; self.error = None;
// Update thinking panel // Update thinking panel
self.update_thinking_from_last_message(); self.update_thinking_from_last_message();
@@ -1340,11 +1467,14 @@ impl ChatApp {
} }
KeyCode::Char('d') => { KeyCode::Char('d') => {
// Delete selected session // Delete selected session
if let Some(session) = self.saved_sessions.get(self.selected_session_index) { if let Some(session) = self.saved_sessions.get(self.selected_session_index)
{
match self.storage.delete_session(&session.path) { match self.storage.delete_session(&session.path) {
Ok(_) => { Ok(_) => {
self.saved_sessions.remove(self.selected_session_index); self.saved_sessions.remove(self.selected_session_index);
if self.selected_session_index >= self.saved_sessions.len() && !self.saved_sessions.is_empty() { if self.selected_session_index >= self.saved_sessions.len()
&& !self.saved_sessions.is_empty()
{
self.selected_session_index = self.saved_sessions.len() - 1; self.selected_session_index = self.saved_sessions.len() - 1;
} }
self.status = "Session deleted".to_string(); self.status = "Session deleted".to_string();
@@ -1357,6 +1487,48 @@ impl ChatApp {
} }
_ => {} _ => {}
}, },
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;
}
}
_ => {}
},
}, },
_ => {} _ => {}
} }
@@ -1602,7 +1774,7 @@ impl ChatApp {
stream, stream,
}) => { }) => {
// Step 3: Model loaded, now generating response // Step 3: Model loaded, now generating response
self.status = format!("Model loaded. Generating response... (streaming)"); self.status = "Model loaded. Generating response... (streaming)".to_string();
self.spawn_stream(response_id, stream); self.spawn_stream(response_id, stream);
match self.controller.mark_stream_placeholder(response_id, "") { match self.controller.mark_stream_placeholder(response_id, "") {

View File

@@ -1,3 +1,17 @@
//! # Owlen TUI
//!
//! This crate contains all the logic for the terminal user interface (TUI) of Owlen.
//!
//! It is built using the excellent [`ratatui`](https://ratatui.rs) library and is responsible for
//! rendering the chat interface, handling user input, and managing the application state.
//!
//! ## Modules
//! - `chat_app`: The main application logic for the chat client.
//! - `code_app`: The main application logic for the experimental code client.
//! - `config`: TUI-specific configuration.
//! - `events`: Event handling for user input and other asynchronous actions.
//! - `ui`: The rendering logic for all TUI components.
pub mod chat_app; pub mod chat_app;
pub mod code_app; pub mod code_app;
pub mod config; pub mod config;

File diff suppressed because it is too large Load Diff

71
docs/architecture.md Normal file
View File

@@ -0,0 +1,71 @@
# Owlen Architecture
This document provides a high-level overview of the Owlen architecture. Its purpose is to help developers understand how the different parts of the application fit together.
## Core Concepts
The architecture is designed to be modular and extensible, centered around a few key concepts:
- **Providers**: Connect to various LLM APIs (Ollama, OpenAI, etc.).
- **Session**: Manages the conversation history and state.
- **TUI**: The terminal user interface, built with `ratatui`.
- **Events**: A system for handling user input and other events.
## Component Interaction
A simplified diagram of how components interact:
```
[User Input] -> [Event Loop] -> [Session Controller] -> [Provider]
^ |
| v
[TUI Renderer] <------------------------------------ [API Response]
```
1. **User Input**: The user interacts with the TUI, generating events (e.g., key presses).
2. **Event Loop**: The main event loop in `owlen-tui` captures these events.
3. **Session Controller**: The event is processed, and if it's a prompt, the session controller sends a request to the current provider.
4. **Provider**: The provider formats the request for the specific LLM API and sends it.
5. **API Response**: The LLM API returns a response.
6. **TUI Renderer**: The response is processed, the session state is updated, and the TUI is re-rendered to display the new information.
## Crate Breakdown
- `owlen-core`: Defines the core traits and data structures, like `Provider` and `Session`.
- `owlen-tui`: Contains all the logic for the terminal user interface, including event handling and rendering.
- `owlen-cli`: The command-line entry point, responsible for parsing arguments and starting the TUI.
- `owlen-ollama` / `owlen-openai` / etc.: Implementations of the `Provider` trait for specific services.
## Session Management
The session management system is responsible for tracking the state of a conversation. The two main structs are:
- **`Conversation`**: Found in `owlen-core`, this struct holds the messages of a single conversation, the model being used, and other metadata. It is a simple data container.
- **`SessionController`**: This is the high-level controller that manages the active conversation. It handles:
- Storing and retrieving conversation history via the `ConversationManager`.
- Managing the context that is sent to the LLM provider.
- Switching between different models.
- Sending requests to the provider and handling the responses (both streaming and complete).
When a user sends a message, the `SessionController` adds the message to the current `Conversation`, sends the updated message list to the `Provider`, and then adds the provider's response to the `Conversation`.
## Event Flow
The event flow is managed by the `EventHandler` in `owlen-tui`. It operates in a loop, waiting for events and dispatching them to the active application (`ChatApp` or `CodeApp`).
1. **Event Source**: Events are primarily generated by `crossterm` from user keyboard input. Asynchronous events, like responses from a `Provider`, are also fed into the event system via a `tokio::mpsc` channel.
2. **`EventHandler::next()`**: The main application loop calls this method to wait for the next event.
3. **Event Enum**: Events are defined in the `owlen_tui::events::Event` enum. This includes `Key` events, `Tick` events (for UI updates), and `Message` events (for async provider data).
4. **Dispatch**: The application's `run` method matches on the `Event` type and calls the appropriate handler function (e.g., `dispatch_key_event`).
5. **State Update**: The handler function updates the application state based on the event. For example, a key press might change the `InputMode` or modify the text in the input buffer.
6. **Re-render**: After the state is updated, the UI is re-rendered to reflect the changes.
## TUI Rendering Pipeline
The TUI is rendered on each iteration of the main application loop in `owlen-tui`. The process is as follows:
1. **`tui.draw()`**: The main loop calls this method, passing the current application state.
2. **`Terminal::draw()`**: This method, from `ratatui`, takes a closure that receives a `Frame`.
3. **UI Composition**: Inside the closure, the UI is built by composing `ratatui` widgets. The root UI is defined in `owlen_tui::ui::render`, which builds the main layout and calls other functions to render specific components (like the chat panel, input box, etc.).
4. **State-Driven Rendering**: Each rendering function takes the current application state as an argument. It uses this state to decide what and how to render. For example, the border color of a panel might change if it is focused.
5. **Buffer and Diff**: `ratatui` does not draw directly to the terminal. Instead, it renders the widgets to an in-memory buffer. It then compares this buffer to the previous buffer and only sends the necessary changes to the terminal. This is highly efficient and prevents flickering.

118
docs/configuration.md Normal file
View File

@@ -0,0 +1,118 @@
# Owlen Configuration
Owlen uses a TOML file for configuration, allowing you to customize its behavior to your liking. This document details all the available options.
## File Location
By default, Owlen looks for its configuration file at `~/.config/owlen/config.toml`.
A default configuration file is created on the first run if one doesn't exist.
## Configuration Precedence
Configuration values are resolved in the following order:
1. **Defaults**: The application has hard-coded default values for all settings.
2. **Configuration File**: Any values set in `config.toml` will override the defaults.
3. **Command-Line Arguments / In-App Changes**: Any settings changed during runtime (e.g., via the `:theme` or `:model` commands) will override the configuration file for the current session. Some of these changes (like theme and model) are automatically saved back to the configuration file.
---
## General Settings (`[general]`)
These settings control the core behavior of the application.
- `default_provider` (string, default: `"ollama"`)
The name of the provider to use by default.
- `default_model` (string, optional, default: `"llama3.2:latest"`)
The default model to use for new conversations.
- `enable_streaming` (boolean, default: `true`)
Whether to stream responses from the provider by default.
- `project_context_file` (string, optional, default: `"OWLEN.md"`)
Path to a file whose content will be automatically injected as a system prompt. This is useful for providing project-specific context.
- `model_cache_ttl_secs` (integer, default: `60`)
Time-to-live in seconds for the cached list of available models.
## UI Settings (`[ui]`)
These settings customize the look and feel of the terminal interface.
- `theme` (string, default: `"default_dark"`)
The name of the theme to use. See the [Theming Guide](https://github.com/Owlibou/owlen/blob/main/themes/README.md) for available themes.
- `word_wrap` (boolean, default: `true`)
Whether to wrap long lines in the chat view.
- `max_history_lines` (integer, default: `2000`)
The maximum number of lines to keep in the scrollback buffer for the chat history.
- `show_role_labels` (boolean, default: `true`)
Whether to show the `user` and `bot` role labels next to messages.
- `wrap_column` (integer, default: `100`)
The column at which to wrap text if `word_wrap` is enabled.
## Storage Settings (`[storage]`)
These settings control how conversations are saved and loaded.
- `conversation_dir` (string, optional, default: platform-specific)
The directory where conversation sessions are saved. If not set, a default directory is used:
- **Linux**: `~/.local/share/owlen/sessions`
- **Windows**: `%APPDATA%\owlen\sessions`
- **macOS**: `~/Library/Application Support/owlen/sessions`
- `auto_save_sessions` (boolean, default: `true`)
Whether to automatically save the session when the application exits.
- `max_saved_sessions` (integer, default: `25`)
The maximum number of saved sessions to keep.
- `session_timeout_minutes` (integer, default: `120`)
The number of minutes of inactivity before a session is considered for auto-saving as a new session.
- `generate_descriptions` (boolean, default: `true`)
Whether to automatically generate a short summary of a conversation when saving it.
## Input Settings (`[input]`)
These settings control the behavior of the text input area.
- `multiline` (boolean, default: `true`)
Whether to allow multi-line input.
- `history_size` (integer, default: `100`)
The number of sent messages to keep in the input history (accessible with `Ctrl-Up/Down`).
- `tab_width` (integer, default: `4`)
The number of spaces to insert when the `Tab` key is pressed.
- `confirm_send` (boolean, default: `false`)
If true, requires an additional confirmation before sending a message.
## Provider Settings (`[providers]`)
This section contains a table for each provider you want to configure. The key is the provider name (e.g., `ollama`).
```toml
[providers.ollama]
provider_type = "ollama"
base_url = "http://localhost:11434"
# api_key = "..."
```
- `provider_type` (string, required)
The type of the provider. Currently, only `"ollama"` is built-in.
- `base_url` (string, optional)
The base URL of the provider's API.
- `api_key` (string, optional)
The API key to use for authentication, if required.
- `extra` (table, optional)
Any additional, provider-specific parameters can be added here.

42
docs/faq.md Normal file
View File

@@ -0,0 +1,42 @@
# Frequently Asked Questions (FAQ)
### What is the difference between `owlen` and `owlen-code`?
- `owlen` is the general-purpose chat client.
- `owlen-code` is an experimental client with a system prompt that is optimized for programming and code-related questions. In the future, it will include more code-specific features like file context and syntax highlighting.
### How do I use Owlen with a different terminal?
Owlen is designed to work with most modern terminals that support 256 colors and Unicode. If you experience rendering issues, you might try:
- **WezTerm**: Excellent cross-platform, GPU-accelerated terminal.
- **Alacritty**: Another fast, GPU-accelerated terminal.
- **Kitty**: A feature-rich terminal emulator.
If issues persist, please open an issue and let us know what terminal you are using.
### What is the setup for Windows?
The Windows build is currently experimental. However, you can install it from source using `cargo` if you have the Rust toolchain installed.
1. Install Rust from [rustup.rs](https://rustup.rs).
2. Install Git for Windows.
3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git`
4. Install: `cd owlen && cargo install --path crates/owlen-cli`
Official binary releases for Windows are planned for the future.
### What is the setup for macOS?
Similar to Windows, the recommended installation method for macOS is to build from source using `cargo`.
1. Install the Xcode command-line tools: `xcode-select --install`
2. Install Rust from [rustup.rs](https://rustup.rs).
3. Clone the repository: `git clone https://github.com/Owlibou/owlen.git`
4. Install: `cd owlen && cargo install --path crates/owlen-cli`
Official binary releases for macOS are planned.
### I'm getting connection failures to Ollama.
Please see the [Troubleshooting Guide](troubleshooting.md#connection-failures-to-ollama) for help with this common issue.

34
docs/migration-guide.md Normal file
View File

@@ -0,0 +1,34 @@
# Migration Guide
This guide documents breaking changes between versions of Owlen and provides instructions on how to migrate your configuration or usage.
As Owlen is currently in its alpha phase (pre-v1.0), breaking changes may occur more frequently. We will do our best to document them here.
---
## Migrating from v0.1.x to v0.2.x (Example)
*This is a template for a future migration. No breaking changes have occurred yet.*
Version 0.2.0 introduces a new configuration structure for providers.
### Configuration File Changes
Previously, your `config.toml` might have looked like this:
```toml
# old config.toml (pre-v0.2.0)
ollama_base_url = "http://localhost:11434"
```
In v0.2.0, all provider settings are now nested under a `[providers]` table. You will need to update your `config.toml` to the new format:
```toml
# new config.toml (v0.2.0+)
[providers.ollama]
base_url = "http://localhost:11434"
```
### Action Required
Update your `~/.config/owlen/config.toml` to match the new structure. If you do not, Owlen will fall back to its default provider configuration.

View File

@@ -0,0 +1,75 @@
# Provider Implementation Guide
This guide explains how to implement a new provider for Owlen. Providers are the components that connect to different LLM APIs.
## The `Provider` Trait
The core of the provider system is the `Provider` trait, located in `owlen-core`. Any new provider must implement this trait.
Here is a simplified version of the trait:
```rust
use async_trait::async_trait;
use owlen_core::model::Model;
use owlen_core::session::Session;
#[async_trait]
pub trait Provider {
/// Returns the name of the provider.
fn name(&self) -> &str;
/// Sends the session to the provider and returns the response.
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error>;
}
```
## Creating a New Crate
1. **Create a new crate** in the `crates/` directory. For example, `owlen-myprovider`.
2. **Add dependencies** to your new crate's `Cargo.toml`. You will need `owlen-core`, `async-trait`, `tokio`, and any crates required for interacting with the new API (e.g., `reqwest`).
3. **Add the new crate to the workspace** in the root `Cargo.toml`.
## Implementing the Trait
In your new crate's `lib.rs`, you will define a struct for your provider and implement the `Provider` trait for it.
```rust
use async_trait::async_trait;
use owlen_core::model::Model;
use owlen_core::provider::Provider;
use owlen_core::session::Session;
pub struct MyProvider;
#[async_trait]
impl Provider for MyProvider {
fn name(&self) -> &str {
"my-provider"
}
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
// 1. Get the conversation history from the session.
let history = session.get_messages();
// 2. Format the request for your provider's API.
// This might involve creating a JSON body with the messages.
// 3. Send the request to the API using a client like reqwest.
// 4. Parse the response from the API.
// 5. Return the content of the response as a String.
Ok("Hello from my provider!".to_string())
}
}
```
## Integrating with Owlen
Once your provider is implemented, you will need to integrate it into the main Owlen application.
1. **Add your provider crate** as a dependency to `owlen-cli`.
2. **In `owlen-cli`, modify the provider registration** to include your new provider. This will likely involve adding it to a list of available providers that the user can select from in the configuration.
This guide provides a basic outline. For more detailed examples, you can look at the existing provider implementations, such as `owlen-ollama`.

58
docs/testing.md Normal file
View File

@@ -0,0 +1,58 @@
# Testing Guide
This guide provides instructions on how to run existing tests and how to write new tests for Owlen.
## Running Tests
The entire test suite can be run from the root of the repository using the standard `cargo test` command.
```sh
# Run all tests in the workspace
cargo test --all
# Run tests for a specific crate
cargo test -p owlen-core
```
We use `cargo clippy` for linting and `cargo fmt` for formatting. Please run these before submitting a pull request.
```sh
cargo clippy --all -- -D warnings
cargo fmt --all -- --check
```
## Writing New Tests
Tests are located in the `tests/` directory within each crate, or in a `tests` module at the bottom of the file they are testing. We follow standard Rust testing practices.
### Unit Tests
For testing specific functions or components in isolation, use unit tests. These should be placed in a `#[cfg(test)]` module in the same file as the code being tested.
```rust
// in src/my_module.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
}
```
### Integration Tests
For testing how different parts of the application work together, use integration tests. These should be placed in the `tests/` directory of the crate.
For example, to test the `SessionController`, you might create a mock `Provider` and simulate sending messages, as seen in the `SessionController` documentation example.
### TUI and UI Component Tests
Testing TUI components can be challenging. For UI logic in `owlen-core` (like `wrap_cursor`), we have detailed unit tests that manipulate the component's state and assert the results. For higher-level TUI components in `owlen-tui`, the focus is on testing the state management logic rather than the visual output.

40
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,40 @@
# Troubleshooting Guide
This guide is intended to help you with common issues you might encounter while using Owlen.
## Connection Failures to Ollama
If you are unable to connect to a local Ollama instance, here are a few things to check:
1. **Is Ollama running?** Make sure the Ollama service is active. You can usually check this with `ollama list`.
2. **Is the address correct?** By default, Owlen tries to connect to `http://localhost:11434`. If your Ollama instance is running on a different address or port, you will need to configure it in your `config.toml` file.
3. **Firewall issues:** Ensure that your firewall is not blocking the connection.
## Model Not Found Errors
If you get a "model not found" error, it means that the model you are trying to use is not available. For local providers like Ollama, you can use `ollama list` to see the models you have downloaded. Make sure the model name in your Owlen configuration matches one of the available models.
## Terminal Compatibility Issues
Owlen is built with `ratatui`, which supports most modern terminals. However, if you are experiencing rendering issues, please check the following:
- Your terminal supports Unicode.
- You are using a font that includes the characters being displayed.
- Try a different terminal emulator to see if the issue persists.
## Configuration File Problems
If Owlen is not behaving as you expect, there might be an issue with your configuration file.
- **Location:** The configuration file is typically located at `~/.config/owlen/config.toml`.
- **Syntax:** The configuration file is in TOML format. Make sure the syntax is correct.
- **Values:** Check that the values for your models, providers, and other settings are correct.
## Performance Tuning
If you are experiencing performance issues, you can try the following:
- **Reduce context size:** A smaller context size will result in faster responses from the LLM.
- **Use a less resource-intensive model:** Some models are faster but less capable than others.
If you are still having trouble, please [open an issue](https://github.com/Owlibou/owlen/issues) on our GitHub repository.

30
examples/basic_chat.rs Normal file
View File

@@ -0,0 +1,30 @@
// This example demonstrates a basic chat interaction without the TUI.
use owlen_core::model::Model;
use owlen_core::provider::Provider;
use owlen_core::session::Session;
use owlen_ollama::OllamaProvider; // Assuming you have an Ollama provider
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// This example requires a running Ollama instance.
// Make sure you have a model available, e.g., `ollama pull llama2`
let provider = OllamaProvider;
let model = Model::new("llama2"); // Change to a model you have
let mut session = Session::new("basic-chat-session");
println!("Starting basic chat with model: {}", model.name);
let user_message = "What is the capital of France?";
session.add_message("user", user_message);
println!("User: {}", user_message);
// Send the chat to the provider
let response = provider.chat(&session, &model).await?;
session.add_message("bot", &response);
println!("Bot: {}", response);
Ok(())
}

View File

@@ -0,0 +1,45 @@
// This example demonstrates how to implement a custom provider.
use async_trait::async_trait;
use owlen_core::model::Model;
use owlen_core::provider::Provider;
use owlen_core::session::Session;
// Define a struct for your custom provider.
pub struct MyCustomProvider;
// Implement the `Provider` trait for your struct.
#[async_trait]
impl Provider for MyCustomProvider {
fn name(&self) -> &str {
"custom-provider"
}
async fn chat(&self, session: &Session, model: &Model) -> Result<String, anyhow::Error> {
println!(
"Custom provider received chat request for model: {}",
model.name
);
// In a real implementation, you would send the session data to an API.
let message_count = session.get_messages().len();
Ok(format!(
"This is a custom response. You have {} messages in your session.",
message_count
))
}
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let provider = MyCustomProvider;
let model = Model::new("custom-model");
let mut session = Session::new("custom-session");
session.add_message("user", "Hello, custom provider!");
let response = provider.chat(&session, &model).await?;
println!("Provider response: {}", response);
Ok(())
}

28
examples/custom_theme.rs Normal file
View File

@@ -0,0 +1,28 @@
// This example demonstrates how to create a custom theme programmatically.
use owlen_core::theme::Theme;
use ratatui::style::{Color, Style};
fn create_custom_theme() -> Theme {
Theme {
name: "My Custom Theme".to_string(),
author: "Your Name".to_string(),
comment: "A simple custom theme".to_string(),
base: Style::default().fg(Color::White).bg(Color::Black),
user_chat: Style::default().fg(Color::Green),
bot_chat: Style::default().fg(Color::Cyan),
error: Style::default().fg(Color::Red),
info: Style::default().fg(Color::Yellow),
border: Style::default().fg(Color::Gray),
input: Style::default().fg(Color::White),
..Default::default()
}
}
fn main() {
let custom_theme = create_custom_theme();
println!("Created custom theme: {}", custom_theme.name);
println!("Author: {}", custom_theme.author);
println!("User chat color: {:?}", custom_theme.user_chat.fg);
}

View File

@@ -0,0 +1,30 @@
// This example demonstrates how to use the session controller.
use owlen_core::session::Session;
fn main() {
// Create a new session.
let mut session = Session::new("my-session");
println!("Created new session: {}", session.name);
// Add messages to the session.
session.add_message("user", "Hello, Owlen!");
session.add_message("bot", "Hello, user! How can I help you today?");
// Get the messages from the session.
let messages = session.get_messages();
println!("\nMessages in session:");
for message in messages {
println!(" {}: {}", message.role, message.content);
}
// Clear the session.
session.clear_messages();
println!("\nSession cleared.");
let messages_after_clear = session.get_messages();
println!(
"Messages in session after clear: {}",
messages_after_clear.len()
);
}

89
themes/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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"

View 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"

View 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
View 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
View 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
View 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"