12 Commits

Author SHA1 Message Date
c4a6bb1c0f Merge pull request 'dev' (#23) from dev into main
Some checks failed
ci/someci/tag/woodpecker/1 Pipeline was successful
ci/someci/tag/woodpecker/2 Pipeline was successful
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
ci/someci/tag/woodpecker/5 Pipeline failed
ci/someci/tag/woodpecker/6 Pipeline failed
ci/someci/tag/woodpecker/7 Pipeline failed
Reviewed-on: #23
2025-10-02 01:38:22 +02:00
dcbfe6ef06 Update README: bump version to 0.1.5 in Alpha Status section 2025-10-02 01:37:44 +02:00
e468658d63 Bump version to 0.1.5 in Cargo.toml 2025-10-02 01:37:15 +02:00
2ad801f0c1 Remove release workflow: delete .gitea/workflows/release.yml 2025-10-02 01:36:59 +02:00
1bfc6e5956 Merge pull request 'Add session persistence and browser functionality' (#22) from dev into main
Reviewed-on: #22
2025-10-02 01:35:31 +02:00
6b8774f0aa Add session persistence and browser functionality
Some checks failed
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build aarch64-unknown-linux-musl (push) Has been cancelled
Release / Build armv7-unknown-linux-gnueabihf (push) Has been cancelled
Release / Build armv7-unknown-linux-musleabihf (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-unknown-linux-musl (push) Has been cancelled
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build aarch64-pc-windows-msvc (push) Has been cancelled
Release / Build x86_64-pc-windows-msvc (push) Has been cancelled
Release / Create Release (push) Has been cancelled
- Implement `StorageManager` for saving, loading, and managing sessions.
- Introduce platform-specific session directories for persistence.
- Add session browser UI for listing, loading, and deleting saved sessions.
- Enable AI-generated descriptions for session summaries.
- Update configurations to support storage settings and description generation.
- Extend README and tests to document and validate new functionality.
2025-10-02 01:33:49 +02:00
ec6876727f Update PKGBUILD: bump version to 0.1.4, adjust maintainer details, refine build steps, and improve compatibility settings 2025-10-01 23:59:46 +02:00
e3eb4d7a04 Update PKGBUILD: add sha256 checksum for source archive 2025-10-01 20:52:10 +02:00
7234021014 Add Windows support to builds and enhance multi-platform configuration
Some checks failed
ci/someci/tag/woodpecker/1 Pipeline was successful
ci/someci/tag/woodpecker/2 Pipeline was successful
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
ci/someci/tag/woodpecker/5 Pipeline failed
ci/someci/tag/woodpecker/6 Pipeline failed
ci/someci/tag/woodpecker/7 Pipeline failed
- Introduce `.cargo/config.toml` with platform-specific linker and flags.
- Update Woodpecker CI to include Windows target, adjust build and packaging steps.
- Modify `Cargo.toml` to use `reqwest` with `rustls-tls` for TLS support.
2025-10-01 20:46:27 +02:00
662d5bd919 Remove Gitea release workflow: deprecate unused configuration and scripts.
Some checks failed
ci/someci/tag/woodpecker/1 Pipeline was successful
ci/someci/tag/woodpecker/2 Pipeline failed
ci/someci/tag/woodpecker/3 Pipeline failed
ci/someci/tag/woodpecker/4 Pipeline failed
ci/someci/tag/woodpecker/5 Pipeline failed
ci/someci/tag/woodpecker/6 Pipeline failed
2025-10-01 20:10:13 +02:00
263b629257 Add Woodpecker CI and PKGBUILD 2025-10-01 20:08:13 +02:00
ff90b20baa Merge pull request 'Add PKGBUILD and release workflow for package distribution' (#21) from dev into main
Reviewed-on: #21
2025-10-01 20:04:35 +02:00
17 changed files with 968 additions and 197 deletions

20
.cargo/config.toml Normal file
View File

@@ -0,0 +1,20 @@
[target.x86_64-unknown-linux-musl]
linker = "x86_64-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
[target.aarch64-unknown-linux-musl]
linker = "aarch64-linux-gnu-gcc"
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
[target.armv7-unknown-linux-gnueabihf]
linker = "arm-linux-gnueabihf-gcc"
[target.armv7-unknown-linux-musleabihf]
linker = "arm-linux-gnueabihf-gcc"
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
[target.x86_64-pc-windows-gnu]
linker = "x86_64-w64-mingw32-gcc"

View File

@@ -1,149 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# Linux
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: owlen-linux-x86_64-gnu
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: owlen-linux-x86_64-musl
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact_name: owlen-linux-aarch64-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: owlen-linux-aarch64-musl
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
artifact_name: owlen-linux-armv7-gnu
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
artifact_name: owlen-linux-armv7-musl
# Windows
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: owlen-windows-x86_64
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: owlen-windows-aarch64
# macOS
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: owlen-macos-x86_64
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: owlen-macos-aarch64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust
uses: https://github.com/dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
- name: Build
shell: bash
run: |
case "${{ matrix.target }}" in
aarch64-unknown-linux-gnu)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
;;
aarch64-unknown-linux-musl)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
export CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc
;;
armv7-unknown-linux-gnueabihf)
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
;;
armv7-unknown-linux-musleabihf)
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
export CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc
;;
esac
cargo build --release --all-features --target ${{ matrix.target }}
- name: Package binaries (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/owlen dist/owlen
cp target/${{ matrix.target }}/release/owlen-code dist/owlen-code
cd dist
tar czf ${{ matrix.artifact_name }}.tar.gz owlen owlen-code
cd ..
mv dist/${{ matrix.artifact_name }}.tar.gz .
- name: Package binaries (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
mkdir -p dist
cp target/${{ matrix.target }}/release/owlen.exe dist/owlen.exe
cp target/${{ matrix.target }}/release/owlen-code.exe dist/owlen-code.exe
cd dist
7z a -tzip ../${{ matrix.artifact_name }}.zip owlen.exe owlen-code.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
${{ matrix.artifact_name }}.tar.gz
${{ matrix.artifact_name }}.zip
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create source tarball
run: |
git archive --format=tar.gz --prefix=owlen/ -o owlen-${{ github.ref_name }}.tar.gz ${{ github.ref_name }}
- name: Generate checksums
shell: bash
run: |
cd artifacts
find . -name "*.tar.gz" -exec mv {} . \; 2>/dev/null || true
find . -name "*.zip" -exec mv {} . \; 2>/dev/null || true
cd ..
mv artifacts/*.tar.gz . 2>/dev/null || true
mv artifacts/*.zip . 2>/dev/null || true
sha256sum *.tar.gz *.zip > checksums.txt 2>/dev/null || sha256sum * > checksums.txt
- name: Create Release
uses: https://gitea.com/gitea/release-action@main
with:
files: |
*.tar.gz
*.zip
checksums.txt
api_key: ${{ secrets.RELEASE_TOKEN }}

109
.woodpecker.yml Normal file
View File

@@ -0,0 +1,109 @@
when:
event: tag
tag: v*
variables:
- &rust_image 'rust:1.83'
matrix:
include:
# Linux
- TARGET: x86_64-unknown-linux-gnu
ARTIFACT: owlen-linux-x86_64-gnu
PLATFORM: linux
EXT: ""
- TARGET: x86_64-unknown-linux-musl
ARTIFACT: owlen-linux-x86_64-musl
PLATFORM: linux
EXT: ""
- TARGET: aarch64-unknown-linux-gnu
ARTIFACT: owlen-linux-aarch64-gnu
PLATFORM: linux
EXT: ""
- TARGET: aarch64-unknown-linux-musl
ARTIFACT: owlen-linux-aarch64-musl
PLATFORM: linux
EXT: ""
- TARGET: armv7-unknown-linux-gnueabihf
ARTIFACT: owlen-linux-armv7-gnu
PLATFORM: linux
EXT: ""
- TARGET: armv7-unknown-linux-musleabihf
ARTIFACT: owlen-linux-armv7-musl
PLATFORM: linux
EXT: ""
# Windows
- TARGET: x86_64-pc-windows-gnu
ARTIFACT: owlen-windows-x86_64
PLATFORM: windows
EXT: ".exe"
steps:
- name: install-deps
image: *rust_image
commands:
- apt-get update
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 zip
- name: build
image: *rust_image
commands:
- rustup target add ${TARGET}
- |
case "${TARGET}" in
aarch64-unknown-linux-gnu)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
;;
aarch64-unknown-linux-musl)
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc
export CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc
;;
armv7-unknown-linux-gnueabihf)
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc
;;
armv7-unknown-linux-musleabihf)
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
export CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc
;;
x86_64-pc-windows-gnu)
export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
;;
esac
- cargo build --release --all-features --target ${TARGET}
- name: package
image: *rust_image
commands:
- mkdir -p dist
- |
if [ "${PLATFORM}" = "windows" ]; then
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
cd dist
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
cd ..
mv dist/${ARTIFACT}.zip .
sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256
else
cp target/${TARGET}/release/owlen dist/owlen
cp target/${TARGET}/release/owlen-code dist/owlen-code
cd dist
tar czf ${ARTIFACT}.tar.gz owlen owlen-code
cd ..
mv dist/${ARTIFACT}.tar.gz .
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
fi
- name: release
image: plugins/gitea-release
settings:
api_key:
from_secret: gitea_token
base_url: https://somegit.dev
files:
- ${ARTIFACT}.tar.gz
- ${ARTIFACT}.tar.gz.sha256
- ${ARTIFACT}.zip
- ${ARTIFACT}.zip.sha256
title: Release ${CI_COMMIT_TAG}
note: "Release ${CI_COMMIT_TAG}"

View File

@@ -9,7 +9,7 @@ members = [
exclude = []
[workspace.package]
version = "0.1.0"
version = "0.1.5"
edition = "2021"
authors = ["Owlibou"]
license = "AGPL-3.0"
@@ -32,7 +32,7 @@ crossterm = "0.28"
tui-textarea = "0.6"
# HTTP client and JSON handling
reqwest = { version = "0.12", features = ["json", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@@ -1,45 +1,44 @@
# Maintainer: Owlibou
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlen
pkgver=0.1.0
pkgver=0.1.4
pkgrel=1
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
arch=('x86_64' 'aarch64')
arch=('x86_64')
url="https://somegit.dev/Owlibou/owlen"
license=('AGPL-3.0-only')
license=('AGPL-3.0-or-later')
depends=('gcc-libs')
makedepends=('cargo' 'git')
source=("${pkgname}-${pkgver}.tar.gz::https://somegit.dev/Owlibou/owlen/archive/v${pkgver}.tar.gz")
sha256sums=('SKIP') # Update this after first release
options=(!lto) # avoid LTO-linked ring symbol drop with lld
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec')
prepare() {
cd "$pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
cd $pkgname
cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "$pkgname"
export RUSTUP_TOOLCHAIN=stable
cd $pkgname
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
export CARGO_PROFILE_RELEASE_LTO=false
export CARGO_TARGET_DIR=target
cargo build --frozen --release --all-features
}
check() {
cd "$pkgname"
export RUSTUP_TOOLCHAIN=stable
cd $pkgname
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
cargo test --frozen --all-features
}
package() {
cd "$pkgname"
cd $pkgname
# Install binaries
install -Dm755 "target/release/owlen" "$pkgdir/usr/bin/owlen"
install -Dm755 "target/release/owlen-code" "$pkgdir/usr/bin/owlen-code"
# Install license
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen"
install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code"
# Install documentation
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
}

View File

@@ -9,7 +9,7 @@
## Alpha Status
- This project is currently in **alpha** (v0.1.0) and under active development.
- This project is currently in **alpha** (v0.1.5) and under active development.
- Core features are functional but expect occasional bugs and missing polish.
- Breaking changes may occur between releases as we refine the API.
- Feedback, bug reports, and contributions are very welcome!
@@ -42,7 +42,9 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig
- **Visual Selection & Clipboard** - Yank/paste text across panels
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
- **Model Management** - Interactive model and provider selection (press `m`)
- **Session Management** - Start new conversations, clear history
- **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
@@ -139,6 +141,15 @@ cargo build --release --bin owlen-code --features code-client
- `:m` / `:model` - Open model selector
- `:n` / `:new` - Start new conversation
- `:h` / `:help` - Show help
- `:save [name]` / `:w [name]` - Save current conversation
- `:load` / `:open` - Browse and load saved sessions
- `:sessions` / `:ls` - List saved sessions
**Session Browser** (accessed via `:load` or `:sessions`):
- `j` / `k` / `↑` / `↓` - Navigate sessions
- `Enter` - Load selected session
- `d` - Delete selected session
- `Esc` - Close browser
### Panel Management
- Three panels: Chat, Thinking, and Input
@@ -164,6 +175,22 @@ 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
@@ -226,9 +253,10 @@ cargo fmt
- [x] Bracketed paste support
### In Progress
- [x] Session persistence (save/load conversations)
- [ ] Theming options and color customization
- [ ] Enhanced configuration UX (in-app settings)
- [ ] Chat history management (save/load/export)
- [ ] Conversation export (Markdown, JSON, plain text)
### Planned
- [ ] Code Client Enhancement

View File

@@ -23,6 +23,8 @@ futures = "0.3.28"
async-trait = "0.1.73"
toml = "0.8.0"
shellexpand = "3.1.0"
dirs = "5.0"
[dev-dependencies]
tokio-test = { workspace = true }
tempfile = { workspace = true }

View File

@@ -238,18 +238,20 @@ impl Default for UiSettings {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageSettings {
#[serde(default = "StorageSettings::default_conversation_dir")]
pub conversation_dir: String,
pub conversation_dir: Option<String>,
#[serde(default = "StorageSettings::default_auto_save")]
pub auto_save_sessions: bool,
#[serde(default = "StorageSettings::default_max_sessions")]
pub max_saved_sessions: usize,
#[serde(default = "StorageSettings::default_session_timeout")]
pub session_timeout_minutes: u64,
#[serde(default = "StorageSettings::default_generate_descriptions")]
pub generate_descriptions: bool,
}
impl StorageSettings {
fn default_conversation_dir() -> String {
"~/.local/share/owlen/conversations".to_string()
fn default_conversation_dir() -> Option<String> {
None
}
fn default_auto_save() -> bool {
@@ -264,19 +266,35 @@ impl StorageSettings {
120
}
fn default_generate_descriptions() -> bool {
true
}
/// Resolve storage directory path
/// Uses platform-specific data directory if not explicitly configured:
/// - Linux: ~/.local/share/owlen/sessions
/// - Windows: %APPDATA%\owlen\sessions
/// - macOS: ~/Library/Application Support/owlen/sessions
pub fn conversation_path(&self) -> PathBuf {
PathBuf::from(shellexpand::tilde(&self.conversation_dir).as_ref())
if let Some(ref dir) = self.conversation_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else {
// Use platform-specific data directory
dirs::data_local_dir()
.map(|d| d.join("owlen").join("sessions"))
.unwrap_or_else(|| PathBuf::from("./owlen_sessions"))
}
}
}
impl Default for StorageSettings {
fn default() -> Self {
Self {
conversation_dir: Self::default_conversation_dir(),
conversation_dir: None, // Use platform-specific defaults
auto_save_sessions: Self::default_auto_save(),
max_saved_sessions: Self::default_max_sessions(),
session_timeout_minutes: Self::default_session_timeout(),
generate_descriptions: Self::default_generate_descriptions(),
}
}
}
@@ -340,3 +358,48 @@ pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
pub fn session_timeout(config: &Config) -> Duration {
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_storage_platform_specific_paths() {
let config = Config::default();
let path = config.storage.conversation_path();
// Verify it contains owlen/sessions
assert!(path.to_string_lossy().contains("owlen"));
assert!(path.to_string_lossy().contains("sessions"));
// Platform-specific checks
#[cfg(target_os = "linux")]
{
// Linux should use ~/.local/share/owlen/sessions
assert!(path.to_string_lossy().contains(".local/share"));
}
#[cfg(target_os = "windows")]
{
// Windows should use AppData
assert!(path.to_string_lossy().contains("AppData"));
}
#[cfg(target_os = "macos")]
{
// macOS should use ~/Library/Application Support
assert!(path.to_string_lossy().contains("Library/Application Support"));
}
println!("Config conversation path: {}", path.display());
}
#[test]
fn test_storage_custom_path() {
let mut config = Config::default();
config.storage.conversation_dir = Some("~/custom/path".to_string());
let path = config.storage.conversation_path();
assert!(path.to_string_lossy().contains("custom/path"));
}
}

View File

@@ -1,7 +1,9 @@
use crate::storage::StorageManager;
use crate::types::{Conversation, Message};
use crate::Result;
use serde_json::{Number, Value};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use uuid::Uuid;
@@ -47,8 +49,8 @@ impl ConversationManager {
&self.active
}
/// Mutable access to the active conversation (auto refreshing indexes afterwards)
fn active_mut(&mut self) -> &mut Conversation {
/// Public mutable access to the active conversation
pub fn active_mut(&mut self) -> &mut Conversation {
&mut self.active
}
@@ -264,6 +266,33 @@ impl ConversationManager {
fn stream_reset(&mut self) {
self.streaming.clear();
}
/// Save the active conversation to disk
pub fn save_active(&self, storage: &StorageManager, name: Option<String>) -> Result<PathBuf> {
storage.save_conversation(&self.active, name)
}
/// Save the active conversation to disk with a description
pub fn save_active_with_description(
&self,
storage: &StorageManager,
name: Option<String>,
description: Option<String>
) -> Result<PathBuf> {
storage.save_conversation_with_description(&self.active, name, description)
}
/// Load a conversation from disk and make it active
pub fn load_from_disk(&mut self, storage: &StorageManager, path: impl AsRef<Path>) -> Result<()> {
let conversation = storage.load_conversation(path)?;
self.load(conversation);
Ok(())
}
/// List all saved sessions
pub fn list_saved_sessions(storage: &StorageManager) -> Result<Vec<crate::storage::SessionMeta>> {
storage.list_sessions()
}
}
impl StreamingMetadata {

View File

@@ -11,6 +11,7 @@ pub mod model;
pub mod provider;
pub mod router;
pub mod session;
pub mod storage;
pub mod types;
pub mod ui;
pub mod wrap_cursor;
@@ -54,6 +55,9 @@ pub enum Error {
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Storage error: {0}")]
Storage(String),
#[error("Unknown error: {0}")]
Unknown(String),
}

View File

@@ -87,9 +87,8 @@ impl ProviderRegistry {
for provider in self.providers.values() {
match provider.list_models().await {
Ok(mut models) => all_models.append(&mut models),
Err(e) => {
// Log error but continue with other providers
eprintln!("Failed to get models from {}: {}", provider.name(), e);
Err(_) => {
// Continue with other providers
}
}
}

View File

@@ -218,4 +218,88 @@ impl SessionController {
pub fn clear(&mut self) {
self.conversation.clear();
}
/// Generate a short AI description for the current conversation
pub async fn generate_conversation_description(&self) -> Result<String> {
let conv = self.conversation.active();
// If conversation is empty or very short, return a simple description
if conv.messages.is_empty() {
return Ok("Empty conversation".to_string());
}
if conv.messages.len() == 1 {
let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" }));
}
// Build a summary prompt from the first few and last few messages
let mut summary_messages = Vec::new();
// Add system message to guide the description
summary_messages.push(crate::types::Message::system(
"Summarize this conversation in 1-2 short sentences (max 100 characters). \
Focus on the main topic or question being discussed. Be concise and descriptive.".to_string()
));
// Include first message
if let Some(first) = conv.messages.first() {
summary_messages.push(first.clone());
}
// Include a middle message if conversation is long enough
if conv.messages.len() > 4 {
if let Some(mid) = conv.messages.get(conv.messages.len() / 2) {
summary_messages.push(mid.clone());
}
}
// Include last message
if let Some(last) = conv.messages.last() {
if conv.messages.len() > 1 {
summary_messages.push(last.clone());
}
}
// Create a summarization request
let request = crate::types::ChatRequest {
model: conv.model.clone(),
messages: summary_messages,
parameters: crate::types::ChatParameters {
temperature: Some(0.3), // Lower temperature for more focused summaries
max_tokens: Some(50), // Keep it short
stream: false,
extra: std::collections::HashMap::new(),
},
};
// Get the summary from the provider
match self.provider.chat(request).await {
Ok(response) => {
let description = response.message.content.trim().to_string();
// If description is empty, use fallback
if description.is_empty() {
let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>();
return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" }));
}
// Truncate if too long
let truncated = if description.len() > 100 {
format!("{}...", description.chars().take(97).collect::<String>())
} else {
description
};
Ok(truncated)
}
Err(_e) => {
// Fallback to simple description if AI generation fails
let first_msg = &conv.messages[0];
let preview = first_msg.content.chars().take(50).collect::<String>();
Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" }))
}
}
}
}

View File

@@ -0,0 +1,308 @@
//! Session persistence and storage management
use crate::types::Conversation;
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Metadata about a saved session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
/// Session file path
pub path: PathBuf,
/// Conversation ID
pub id: uuid::Uuid,
/// Optional session name
pub name: Option<String>,
/// Optional AI-generated description
pub description: Option<String>,
/// Number of messages in the conversation
pub message_count: usize,
/// Model used
pub model: String,
/// When the session was created
pub created_at: SystemTime,
/// When the session was last updated
pub updated_at: SystemTime,
}
/// Storage manager for persisting conversations
pub struct StorageManager {
sessions_dir: PathBuf,
}
impl StorageManager {
/// Create a new storage manager with the default sessions directory
pub fn new() -> Result<Self> {
let sessions_dir = Self::default_sessions_dir()?;
Self::with_directory(sessions_dir)
}
/// Create a storage manager with a custom sessions directory
pub fn with_directory(sessions_dir: PathBuf) -> Result<Self> {
// Ensure the directory exists
if !sessions_dir.exists() {
fs::create_dir_all(&sessions_dir).map_err(|e| {
Error::Storage(format!(
"Failed to create sessions directory: {}",
e
))
})?;
}
Ok(Self { sessions_dir })
}
/// Get the default sessions directory
/// - Linux: ~/.local/share/owlen/sessions
/// - Windows: %APPDATA%\owlen\sessions
/// - macOS: ~/Library/Application Support/owlen/sessions
pub fn default_sessions_dir() -> Result<PathBuf> {
let data_dir = dirs::data_local_dir()
.ok_or_else(|| Error::Storage("Could not determine data directory".to_string()))?;
Ok(data_dir.join("owlen").join("sessions"))
}
/// Save a conversation to disk
pub fn save_conversation(&self, conversation: &Conversation, name: Option<String>) -> Result<PathBuf> {
self.save_conversation_with_description(conversation, name, None)
}
/// Save a conversation to disk with an optional description
pub fn save_conversation_with_description(
&self,
conversation: &Conversation,
name: Option<String>,
description: Option<String>
) -> Result<PathBuf> {
let filename = if let Some(ref session_name) = name {
// Use provided name, sanitized
let sanitized = sanitize_filename(session_name);
format!("{}_{}.json", conversation.id, sanitized)
} else {
// Use conversation ID and timestamp
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}_{}.json", conversation.id, timestamp)
};
let path = self.sessions_dir.join(filename);
// Create a saveable version with the name and description
let mut save_conv = conversation.clone();
if name.is_some() {
save_conv.name = name;
}
if description.is_some() {
save_conv.description = description;
}
let json = serde_json::to_string_pretty(&save_conv).map_err(|e| {
Error::Storage(format!("Failed to serialize conversation: {}", e))
})?;
fs::write(&path, json).map_err(|e| {
Error::Storage(format!("Failed to write session file: {}", e))
})?;
Ok(path)
}
/// Load a conversation from disk
pub fn load_conversation(&self, path: impl AsRef<Path>) -> Result<Conversation> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
Error::Storage(format!("Failed to read session file: {}", e))
})?;
let conversation: Conversation = serde_json::from_str(&content).map_err(|e| {
Error::Storage(format!("Failed to parse session file: {}", e))
})?;
Ok(conversation)
}
/// List all saved sessions with metadata
pub fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
let mut sessions = Vec::new();
let entries = fs::read_dir(&self.sessions_dir).map_err(|e| {
Error::Storage(format!("Failed to read sessions directory: {}", e))
})?;
for entry in entries {
let entry = entry.map_err(|e| {
Error::Storage(format!("Failed to read directory entry: {}", e))
})?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
// Try to load the conversation to extract metadata
match self.load_conversation(&path) {
Ok(conv) => {
sessions.push(SessionMeta {
path: path.clone(),
id: conv.id,
name: conv.name.clone(),
description: conv.description.clone(),
message_count: conv.messages.len(),
model: conv.model.clone(),
created_at: conv.created_at,
updated_at: conv.updated_at,
});
}
Err(_) => {
// Skip files that can't be parsed
continue;
}
}
}
// Sort by updated_at, most recent first
sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
Ok(sessions)
}
/// Delete a saved session
pub fn delete_session(&self, path: impl AsRef<Path>) -> Result<()> {
fs::remove_file(path.as_ref()).map_err(|e| {
Error::Storage(format!("Failed to delete session file: {}", e))
})
}
/// Get the sessions directory path
pub fn sessions_dir(&self) -> &Path {
&self.sessions_dir
}
}
impl Default for StorageManager {
fn default() -> Self {
Self::new().expect("Failed to create default storage manager")
}
}
/// Sanitize a filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else if c.is_whitespace() {
'_'
} else {
'-'
}
})
.collect::<String>()
.chars()
.take(50) // Limit length
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Message;
use tempfile::TempDir;
#[test]
fn test_platform_specific_default_path() {
let path = StorageManager::default_sessions_dir().unwrap();
// Verify it contains owlen/sessions
assert!(path.to_string_lossy().contains("owlen"));
assert!(path.to_string_lossy().contains("sessions"));
// Platform-specific checks
#[cfg(target_os = "linux")]
{
// Linux should use ~/.local/share/owlen/sessions
assert!(path.to_string_lossy().contains(".local/share"));
}
#[cfg(target_os = "windows")]
{
// Windows should use AppData
assert!(path.to_string_lossy().contains("AppData"));
}
#[cfg(target_os = "macos")]
{
// macOS should use ~/Library/Application Support
assert!(path.to_string_lossy().contains("Library/Application Support"));
}
println!("Default sessions directory: {}", path.display());
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("Hello World"), "Hello_World");
assert_eq!(sanitize_filename("test/path\\file"), "test-path-file");
assert_eq!(sanitize_filename("file:name?"), "file-name-");
}
#[test]
fn test_save_and_load_conversation() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user("Hello".to_string()));
conv.messages.push(Message::assistant("Hi there!".to_string()));
// Save conversation
let path = storage.save_conversation(&conv, Some("test_session".to_string())).unwrap();
assert!(path.exists());
// Load conversation
let loaded = storage.load_conversation(&path).unwrap();
assert_eq!(loaded.id, conv.id);
assert_eq!(loaded.model, conv.model);
assert_eq!(loaded.messages.len(), 2);
assert_eq!(loaded.name, Some("test_session".to_string()));
}
#[test]
fn test_list_sessions() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
// Create multiple sessions
for i in 0..3 {
let mut conv = Conversation::new("test-model".to_string());
conv.messages.push(Message::user(format!("Message {}", i)));
storage.save_conversation(&conv, Some(format!("session_{}", i))).unwrap();
}
// List sessions
let sessions = storage.list_sessions().unwrap();
assert_eq!(sessions.len(), 3);
// Check that sessions are sorted by updated_at (most recent first)
for i in 0..sessions.len() - 1 {
assert!(sessions[i].updated_at >= sessions[i + 1].updated_at);
}
}
#[test]
fn test_delete_session() {
let temp_dir = TempDir::new().unwrap();
let storage = StorageManager::with_directory(temp_dir.path().to_path_buf()).unwrap();
let conv = Conversation::new("test-model".to_string());
let path = storage.save_conversation(&conv, None).unwrap();
assert!(path.exists());
storage.delete_session(&path).unwrap();
assert!(!path.exists());
}
}

View File

@@ -50,6 +50,9 @@ pub struct Conversation {
pub id: Uuid,
/// Optional name/title for the conversation
pub name: Option<String>,
/// Optional AI-generated description of the conversation
#[serde(default)]
pub description: Option<String>,
/// Messages in chronological order
pub messages: Vec<Message>,
/// Model used for this conversation
@@ -167,6 +170,7 @@ impl Conversation {
Self {
id: Uuid::new_v4(),
name: None,
description: None,
messages: Vec::new(),
model,
created_at: now,

View File

@@ -22,6 +22,7 @@ pub enum InputMode {
Help,
Visual,
Command,
SessionBrowser,
}
impl fmt::Display for InputMode {
@@ -34,6 +35,7 @@ impl fmt::Display for InputMode {
InputMode::Help => "Help",
InputMode::Visual => "Visual",
InputMode::Command => "Command",
InputMode::SessionBrowser => "Sessions",
};
f.write_str(label)
}

View File

@@ -1,6 +1,7 @@
use anyhow::Result;
use owlen_core::{
session::{SessionController, SessionOutcome},
storage::{SessionMeta, StorageManager},
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
ui::{AppState, AutoScroll, FocusedPanel, InputMode},
};
@@ -55,6 +56,10 @@ pub struct ChatApp {
focused_panel: FocusedPanel, // Currently focused panel for scrolling
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
storage: StorageManager, // Storage manager for session persistence
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
selected_session_index: usize, // Index of selected session in browser
save_name_buffer: String, // Buffer for entering save name
}
impl ChatApp {
@@ -63,6 +68,12 @@ impl ChatApp {
let mut textarea = TextArea::default();
configure_textarea_defaults(&mut textarea);
let storage = StorageManager::new().unwrap_or_else(|e| {
eprintln!("Warning: Failed to initialize storage: {}", e);
StorageManager::with_directory(std::path::PathBuf::from("/tmp/owlen_sessions"))
.expect("Failed to create fallback storage")
});
let app = Self {
controller,
mode: InputMode::Normal,
@@ -93,6 +104,10 @@ impl ChatApp {
focused_panel: FocusedPanel::Input,
chat_cursor: (0, 0),
thinking_cursor: (0, 0),
storage,
saved_sessions: Vec::new(),
selected_session_index: 0,
save_name_buffer: String::new(),
};
(app, session_rx)
@@ -209,6 +224,14 @@ impl ChatApp {
self.thinking_cursor
}
pub fn saved_sessions(&self) -> &[SessionMeta] {
&self.saved_sessions
}
pub fn selected_session_index(&self) -> usize {
self.selected_session_index
}
pub fn cycle_focus_forward(&mut self) {
self.focused_panel = match self.focused_panel {
FocusedPanel::Chat => {
@@ -976,7 +999,11 @@ impl ChatApp {
(KeyCode::Enter, _) => {
// Execute command
let cmd = self.command_buffer.trim();
match cmd {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let command = parts.first().copied().unwrap_or("");
let args = &parts[1..];
match command {
"q" | "quit" => {
return Ok(AppState::Quit);
}
@@ -984,9 +1011,65 @@ impl ChatApp {
self.controller.clear();
self.status = "Conversation cleared".to_string();
}
"w" | "write" => {
// Could implement saving conversation here
self.status = "Conversation saved".to_string();
"w" | "write" | "save" => {
// Save current conversation with AI-generated description
let name = if !args.is_empty() {
Some(args.join(" "))
} else {
None
};
// Generate description if enabled in config
let description = if self.controller.config().storage.generate_descriptions {
self.status = "Generating description...".to_string();
match self.controller.generate_conversation_description().await {
Ok(desc) => Some(desc),
Err(_) => None,
}
} else {
None
};
// Save the conversation with description
match self.controller.conversation_mut().save_active_with_description(&self.storage, name.clone(), description) {
Ok(path) => {
self.status = format!("Session saved: {}", path.display());
self.error = None;
}
Err(e) => {
self.error = Some(format!("Failed to save session: {}", e));
}
}
}
"load" | "open" => {
// Load saved sessions and enter browser mode
match self.storage.list_sessions() {
Ok(sessions) => {
self.saved_sessions = sessions;
self.selected_session_index = 0;
self.mode = InputMode::SessionBrowser;
self.command_buffer.clear();
return Ok(AppState::Running);
}
Err(e) => {
self.error = Some(format!("Failed to list sessions: {}", e));
}
}
}
"sessions" | "ls" => {
// List saved sessions
match self.storage.list_sessions() {
Ok(sessions) => {
self.saved_sessions = sessions;
self.selected_session_index = 0;
self.mode = InputMode::SessionBrowser;
self.command_buffer.clear();
return Ok(AppState::Running);
}
Err(e) => {
self.error = Some(format!("Failed to list sessions: {}", e));
}
}
}
"h" | "help" => {
self.mode = InputMode::Help;
@@ -1097,6 +1180,56 @@ impl ChatApp {
}
_ => {}
},
InputMode::SessionBrowser => match key.code {
KeyCode::Esc => {
self.mode = InputMode::Normal;
}
KeyCode::Enter => {
// Load selected session
if let Some(session) = self.saved_sessions.get(self.selected_session_index) {
match self.controller.conversation_mut().load_from_disk(&self.storage, &session.path) {
Ok(_) => {
self.status = format!("Loaded session: {}", session.name.as_deref().unwrap_or("Unnamed"));
self.error = None;
// Update thinking panel
self.update_thinking_from_last_message();
}
Err(e) => {
self.error = Some(format!("Failed to load session: {}", e));
}
}
}
self.mode = InputMode::Normal;
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_session_index > 0 {
self.selected_session_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_session_index + 1 < self.saved_sessions.len() {
self.selected_session_index += 1;
}
}
KeyCode::Char('d') => {
// Delete selected session
if let Some(session) = self.saved_sessions.get(self.selected_session_index) {
match self.storage.delete_session(&session.path) {
Ok(_) => {
self.saved_sessions.remove(self.selected_session_index);
if self.selected_session_index >= self.saved_sessions.len() && !self.saved_sessions.is_empty() {
self.selected_session_index = self.saved_sessions.len() - 1;
}
self.status = "Session deleted".to_string();
}
Err(e) => {
self.error = Some(format!("Failed to delete session: {}", e));
}
}
}
}
_ => {}
},
},
_ => {}
}
@@ -1342,7 +1475,7 @@ impl ChatApp {
stream,
}) => {
// Step 3: Model loaded, now generating response
self.status = "Generating response...".to_string();
self.status = format!("Model loaded. Generating response... (streaming)");
self.spawn_stream(response_id, stream);
match self.controller.mark_stream_placeholder(response_id, "") {

View File

@@ -81,6 +81,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
InputMode::ProviderSelection => render_provider_selector(frame, app),
InputMode::ModelSelection => render_model_selector(frame, app),
InputMode::Help => render_help(frame),
InputMode::SessionBrowser => render_session_browser(frame, app),
_ => {}
}
}
@@ -943,14 +944,13 @@ fn render_status(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
InputMode::Help => (" HELP", Color::LightMagenta),
InputMode::Visual => (" VISUAL", Color::Magenta),
InputMode::Command => (" COMMAND", Color::Yellow),
InputMode::SessionBrowser => (" SESSIONS", Color::Yellow),
};
let status_message = if app.streaming_count() > 0 {
format!("Streaming... ({})", app.streaming_count())
} else if let Some(error) = app.error_message() {
let status_message = if let Some(error) = app.error_message() {
format!("Error: {}", error)
} else {
"Ready".to_string()
app.status_message().to_string()
};
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
@@ -1145,11 +1145,13 @@ fn render_help(frame: &mut Frame<'_>) {
Line::from(" v / Esc → exit visual mode"),
Line::from(""),
Line::from("COMMANDS (press : then type):"),
Line::from(" :h, :help → show this help"),
Line::from(" :m, :model → select model"),
Line::from(" :n, :new → start new conversation"),
Line::from(" :c, :clear → clear current conversation"),
Line::from(" :q, :quit → quit application"),
Line::from(" :h, :help → show this help"),
Line::from(" :m, :model → select model"),
Line::from(" :n, :new → start new conversation"),
Line::from(" :c, :clear → clear current conversation"),
Line::from(" :save [name] → save current session"),
Line::from(" :load, :sessions → browse/load saved sessions"),
Line::from(" :q, :quit → quit application"),
Line::from(""),
Line::from("QUICK KEYS:"),
Line::from(" q → quit (from normal mode)"),
@@ -1173,6 +1175,140 @@ fn render_help(frame: &mut Frame<'_>) {
frame.render_widget(paragraph, area);
}
fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) {
let area = centered_rect(70, 70, frame.area());
frame.render_widget(Clear, area);
let sessions = app.saved_sessions();
if sessions.is_empty() {
let text = vec![
Line::from(""),
Line::from("No saved sessions found."),
Line::from(""),
Line::from("Save your current session with :save [name]"),
Line::from(""),
Line::from("Press Esc to close."),
];
let paragraph = Paragraph::new(text)
.block(
Block::default()
.title(Span::styled(
" Saved Sessions ",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center);
frame.render_widget(paragraph, area);
return;
}
let items: Vec<ListItem> = sessions
.iter()
.enumerate()
.map(|(idx, session)| {
let name = session
.name
.as_deref()
.unwrap_or("Unnamed session");
let created = session.created_at
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let age_hours = (now - created) / 3600;
let age_str = if age_hours < 1 {
"< 1h ago".to_string()
} else if age_hours < 24 {
format!("{}h ago", age_hours)
} else {
format!("{}d ago", age_hours / 24)
};
let info = format!(
"{} messages · {} · {}",
session.message_count,
session.model,
age_str
);
let is_selected = idx == app.selected_session_index();
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
let info_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default().fg(Color::Gray)
};
let desc_style = if is_selected {
Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::ITALIC)
} else {
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC)
};
let mut lines = vec![
Line::from(Span::styled(name, style)),
];
// Add description if available and not empty
if let Some(description) = &session.description {
if !description.is_empty() {
lines.push(Line::from(Span::styled(format!(" \"{}\"", description), desc_style)));
}
}
// Add metadata line
lines.push(Line::from(Span::styled(format!(" {}", info), info_style)));
ListItem::new(lines)
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(
format!(" Saved Sessions ({}) ", sessions.len()),
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
);
let footer = Paragraph::new(vec![
Line::from(""),
Line::from("↑/↓ or j/k: Navigate · Enter: Load · d: Delete · Esc: Cancel"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Gray));
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(3),
])
.split(area);
frame.render_widget(list, layout[0]);
frame.render_widget(footer, layout[1]);
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)