Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c59539120 | |||
| c725bb1ce6 | |||
| c4a6bb1c0f | |||
| dcbfe6ef06 | |||
| e468658d63 | |||
| 2ad801f0c1 | |||
| 1bfc6e5956 | |||
| 6b8774f0aa | |||
| ec6876727f | |||
| e3eb4d7a04 | |||
| 7234021014 |
20
.cargo/config.toml
Normal file
20
.cargo/config.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[target.x86_64-unknown-linux-musl]
|
||||||
|
linker = "x86_64-linux-gnu-gcc"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||||
|
|
||||||
|
[target.aarch64-unknown-linux-gnu]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
|
|
||||||
|
[target.aarch64-unknown-linux-musl]
|
||||||
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||||
|
|
||||||
|
[target.armv7-unknown-linux-gnueabihf]
|
||||||
|
linker = "arm-linux-gnueabihf-gcc"
|
||||||
|
|
||||||
|
[target.armv7-unknown-linux-musleabihf]
|
||||||
|
linker = "arm-linux-gnueabihf-gcc"
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static", "-C", "link-arg=-lgcc"]
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "x86_64-w64-mingw32-gcc"
|
||||||
@@ -11,28 +11,39 @@ matrix:
|
|||||||
- TARGET: x86_64-unknown-linux-gnu
|
- TARGET: x86_64-unknown-linux-gnu
|
||||||
ARTIFACT: owlen-linux-x86_64-gnu
|
ARTIFACT: owlen-linux-x86_64-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: x86_64-unknown-linux-musl
|
- TARGET: x86_64-unknown-linux-musl
|
||||||
ARTIFACT: owlen-linux-x86_64-musl
|
ARTIFACT: owlen-linux-x86_64-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: aarch64-unknown-linux-gnu
|
- TARGET: aarch64-unknown-linux-gnu
|
||||||
ARTIFACT: owlen-linux-aarch64-gnu
|
ARTIFACT: owlen-linux-aarch64-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: aarch64-unknown-linux-musl
|
- TARGET: aarch64-unknown-linux-musl
|
||||||
ARTIFACT: owlen-linux-aarch64-musl
|
ARTIFACT: owlen-linux-aarch64-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: armv7-unknown-linux-gnueabihf
|
- TARGET: armv7-unknown-linux-gnueabihf
|
||||||
ARTIFACT: owlen-linux-armv7-gnu
|
ARTIFACT: owlen-linux-armv7-gnu
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
- TARGET: armv7-unknown-linux-musleabihf
|
- TARGET: armv7-unknown-linux-musleabihf
|
||||||
ARTIFACT: owlen-linux-armv7-musl
|
ARTIFACT: owlen-linux-armv7-musl
|
||||||
PLATFORM: linux
|
PLATFORM: linux
|
||||||
|
EXT: ""
|
||||||
|
# Windows
|
||||||
|
- TARGET: x86_64-pc-windows-gnu
|
||||||
|
ARTIFACT: owlen-windows-x86_64
|
||||||
|
PLATFORM: windows
|
||||||
|
EXT: ".exe"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: install-deps
|
- name: install-deps
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
- apt-get update
|
- apt-get update
|
||||||
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf
|
- apt-get install -y musl-tools gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-mingw-w64-x86-64 zip
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
image: *rust_image
|
image: *rust_image
|
||||||
@@ -54,6 +65,9 @@ steps:
|
|||||||
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc
|
||||||
export CC_armv7_unknown_linux_musleabihf=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
|
esac
|
||||||
- cargo build --release --all-features --target ${TARGET}
|
- cargo build --release --all-features --target ${TARGET}
|
||||||
|
|
||||||
@@ -61,13 +75,24 @@ steps:
|
|||||||
image: *rust_image
|
image: *rust_image
|
||||||
commands:
|
commands:
|
||||||
- mkdir -p dist
|
- mkdir -p dist
|
||||||
- cp target/${TARGET}/release/owlen dist/owlen
|
- |
|
||||||
- cp target/${TARGET}/release/owlen-code dist/owlen-code
|
if [ "${PLATFORM}" = "windows" ]; then
|
||||||
- cd dist
|
cp target/${TARGET}/release/owlen.exe dist/owlen.exe
|
||||||
- tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
cp target/${TARGET}/release/owlen-code.exe dist/owlen-code.exe
|
||||||
- cd ..
|
cd dist
|
||||||
- mv dist/${ARTIFACT}.tar.gz .
|
zip -9 ${ARTIFACT}.zip owlen.exe owlen-code.exe
|
||||||
- sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
cd ..
|
||||||
|
mv dist/${ARTIFACT}.zip .
|
||||||
|
sha256sum ${ARTIFACT}.zip > ${ARTIFACT}.zip.sha256
|
||||||
|
else
|
||||||
|
cp target/${TARGET}/release/owlen dist/owlen
|
||||||
|
cp target/${TARGET}/release/owlen-code dist/owlen-code
|
||||||
|
cd dist
|
||||||
|
tar czf ${ARTIFACT}.tar.gz owlen owlen-code
|
||||||
|
cd ..
|
||||||
|
mv dist/${ARTIFACT}.tar.gz .
|
||||||
|
sha256sum ${ARTIFACT}.tar.gz > ${ARTIFACT}.tar.gz.sha256
|
||||||
|
fi
|
||||||
|
|
||||||
- name: release
|
- name: release
|
||||||
image: plugins/gitea-release
|
image: plugins/gitea-release
|
||||||
@@ -78,5 +103,7 @@ steps:
|
|||||||
files:
|
files:
|
||||||
- ${ARTIFACT}.tar.gz
|
- ${ARTIFACT}.tar.gz
|
||||||
- ${ARTIFACT}.tar.gz.sha256
|
- ${ARTIFACT}.tar.gz.sha256
|
||||||
|
- ${ARTIFACT}.zip
|
||||||
|
- ${ARTIFACT}.zip.sha256
|
||||||
title: Release ${CI_COMMIT_TAG}
|
title: Release ${CI_COMMIT_TAG}
|
||||||
note: "Release ${CI_COMMIT_TAG}"
|
note: "Release ${CI_COMMIT_TAG}"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["Owlibou"]
|
authors = ["Owlibou"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
@@ -32,7 +32,7 @@ crossterm = "0.28"
|
|||||||
tui-textarea = "0.6"
|
tui-textarea = "0.6"
|
||||||
|
|
||||||
# HTTP client and JSON handling
|
# HTTP client and JSON handling
|
||||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
|||||||
37
PKGBUILD
37
PKGBUILD
@@ -1,45 +1,44 @@
|
|||||||
# Maintainer: Owlibou
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlen
|
pkgname=owlen
|
||||||
pkgver=0.1.0
|
pkgver=0.1.7
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64')
|
||||||
url="https://somegit.dev/Owlibou/owlen"
|
url="https://somegit.dev/Owlibou/owlen"
|
||||||
license=('AGPL-3.0-only')
|
license=('AGPL-3.0-or-later')
|
||||||
depends=('gcc-libs')
|
depends=('gcc-libs')
|
||||||
makedepends=('cargo' 'git')
|
makedepends=('cargo' 'git')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz::https://somegit.dev/Owlibou/owlen/archive/v${pkgver}.tar.gz")
|
options=(!lto) # avoid LTO-linked ring symbol drop with lld
|
||||||
sha256sums=('SKIP') # Update this after first release
|
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
|
||||||
|
sha256sums=('cabb1cfdfc247b5d008c6c5f94e13548bcefeba874aae9a9d45aa95ae1c085ec')
|
||||||
|
|
||||||
prepare() {
|
prepare() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
cargo fetch --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||||
|
export CARGO_PROFILE_RELEASE_LTO=false
|
||||||
export CARGO_TARGET_DIR=target
|
export CARGO_TARGET_DIR=target
|
||||||
cargo build --frozen --release --all-features
|
cargo build --frozen --release --all-features
|
||||||
}
|
}
|
||||||
|
|
||||||
check() {
|
check() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-Wl,--no-as-needed"
|
||||||
cargo test --frozen --all-features
|
cargo test --frozen --all-features
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$pkgname"
|
cd $pkgname
|
||||||
|
|
||||||
# Install binaries
|
# Install binaries
|
||||||
install -Dm755 "target/release/owlen" "$pkgdir/usr/bin/owlen"
|
install -Dm755 target/release/owlen "$pkgdir/usr/bin/owlen"
|
||||||
install -Dm755 "target/release/owlen-code" "$pkgdir/usr/bin/owlen-code"
|
install -Dm755 target/release/owlen-code "$pkgdir/usr/bin/owlen-code"
|
||||||
|
|
||||||
# Install license
|
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
|
||||||
|
|
||||||
# Install documentation
|
# Install documentation
|
||||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -3,13 +3,13 @@
|
|||||||
> Terminal-native assistant for running local language models with a comfortable TUI.
|
> Terminal-native assistant for running local language models with a comfortable TUI.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Alpha Status
|
## Alpha Status
|
||||||
|
|
||||||
- This project is currently in **alpha** (v0.1.0) and under active development.
|
- This project is currently in **alpha** (v0.1.7) and under active development.
|
||||||
- Core features are functional but expect occasional bugs and missing polish.
|
- Core features are functional but expect occasional bugs and missing polish.
|
||||||
- Breaking changes may occur between releases as we refine the API.
|
- Breaking changes may occur between releases as we refine the API.
|
||||||
- Feedback, bug reports, and contributions are very welcome!
|
- Feedback, bug reports, and contributions are very welcome!
|
||||||
@@ -42,7 +42,9 @@ The OWLEN interface features a clean, multi-panel layout with vim-inspired navig
|
|||||||
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
- **Visual Selection & Clipboard** - Yank/paste text across panels
|
||||||
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
- **Flexible Scrolling** - Half-page, full-page, and cursor-based navigation
|
||||||
- **Model Management** - Interactive model and provider selection (press `m`)
|
- **Model Management** - Interactive model and provider selection (press `m`)
|
||||||
- **Session Management** - Start new conversations, clear history
|
- **Session Persistence** - Save and load conversations to/from disk
|
||||||
|
- **AI-Generated Descriptions** - Automatic short summaries for saved sessions
|
||||||
|
- **Session Management** - Start new conversations, clear history, browse saved sessions
|
||||||
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
- **Thinking Mode Support** - Dedicated panel for extended reasoning content
|
||||||
- **Bracketed Paste** - Safe paste handling for multi-line content
|
- **Bracketed Paste** - Safe paste handling for multi-line content
|
||||||
|
|
||||||
@@ -139,6 +141,15 @@ cargo build --release --bin owlen-code --features code-client
|
|||||||
- `:m` / `:model` - Open model selector
|
- `:m` / `:model` - Open model selector
|
||||||
- `:n` / `:new` - Start new conversation
|
- `:n` / `:new` - Start new conversation
|
||||||
- `:h` / `:help` - Show help
|
- `:h` / `:help` - Show help
|
||||||
|
- `:save [name]` / `:w [name]` - Save current conversation
|
||||||
|
- `:load` / `:open` - Browse and load saved sessions
|
||||||
|
- `:sessions` / `:ls` - List saved sessions
|
||||||
|
|
||||||
|
**Session Browser** (accessed via `:load` or `:sessions`):
|
||||||
|
- `j` / `k` / `↑` / `↓` - Navigate sessions
|
||||||
|
- `Enter` - Load selected session
|
||||||
|
- `d` - Delete selected session
|
||||||
|
- `Esc` - Close browser
|
||||||
|
|
||||||
### Panel Management
|
### Panel Management
|
||||||
- Three panels: Chat, Thinking, and Input
|
- Three panels: Chat, Thinking, and Input
|
||||||
@@ -164,6 +175,22 @@ base_url = "http://localhost:11434"
|
|||||||
timeout = 300
|
timeout = 300
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Storage Settings
|
||||||
|
|
||||||
|
Sessions are saved to platform-specific directories by default:
|
||||||
|
- **Linux**: `~/.local/share/owlen/sessions`
|
||||||
|
- **Windows**: `%APPDATA%\owlen\sessions`
|
||||||
|
- **macOS**: `~/Library/Application Support/owlen/sessions`
|
||||||
|
|
||||||
|
You can customize this in your config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[storage]
|
||||||
|
# conversation_dir = "~/custom/path" # Optional: override default location
|
||||||
|
max_saved_sessions = 25
|
||||||
|
generate_descriptions = true # AI-generated summaries for saved sessions
|
||||||
|
```
|
||||||
|
|
||||||
Configuration is automatically saved when you change models or providers.
|
Configuration is automatically saved when you change models or providers.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
@@ -226,9 +253,10 @@ cargo fmt
|
|||||||
- [x] Bracketed paste support
|
- [x] Bracketed paste support
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
|
- [x] Session persistence (save/load conversations)
|
||||||
- [ ] Theming options and color customization
|
- [ ] Theming options and color customization
|
||||||
- [ ] Enhanced configuration UX (in-app settings)
|
- [ ] Enhanced configuration UX (in-app settings)
|
||||||
- [ ] Chat history management (save/load/export)
|
- [ ] Conversation export (Markdown, JSON, plain text)
|
||||||
|
|
||||||
### Planned
|
### Planned
|
||||||
- [ ] Code Client Enhancement
|
- [ ] Code Client Enhancement
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ futures = "0.3.28"
|
|||||||
async-trait = "0.1.73"
|
async-trait = "0.1.73"
|
||||||
toml = "0.8.0"
|
toml = "0.8.0"
|
||||||
shellexpand = "3.1.0"
|
shellexpand = "3.1.0"
|
||||||
|
dirs = "5.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio-test = { workspace = true }
|
tokio-test = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -238,18 +238,20 @@ impl Default for UiSettings {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StorageSettings {
|
pub struct StorageSettings {
|
||||||
#[serde(default = "StorageSettings::default_conversation_dir")]
|
#[serde(default = "StorageSettings::default_conversation_dir")]
|
||||||
pub conversation_dir: String,
|
pub conversation_dir: Option<String>,
|
||||||
#[serde(default = "StorageSettings::default_auto_save")]
|
#[serde(default = "StorageSettings::default_auto_save")]
|
||||||
pub auto_save_sessions: bool,
|
pub auto_save_sessions: bool,
|
||||||
#[serde(default = "StorageSettings::default_max_sessions")]
|
#[serde(default = "StorageSettings::default_max_sessions")]
|
||||||
pub max_saved_sessions: usize,
|
pub max_saved_sessions: usize,
|
||||||
#[serde(default = "StorageSettings::default_session_timeout")]
|
#[serde(default = "StorageSettings::default_session_timeout")]
|
||||||
pub session_timeout_minutes: u64,
|
pub session_timeout_minutes: u64,
|
||||||
|
#[serde(default = "StorageSettings::default_generate_descriptions")]
|
||||||
|
pub generate_descriptions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StorageSettings {
|
impl StorageSettings {
|
||||||
fn default_conversation_dir() -> String {
|
fn default_conversation_dir() -> Option<String> {
|
||||||
"~/.local/share/owlen/conversations".to_string()
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_auto_save() -> bool {
|
fn default_auto_save() -> bool {
|
||||||
@@ -264,19 +266,35 @@ impl StorageSettings {
|
|||||||
120
|
120
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_generate_descriptions() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve storage directory path
|
/// Resolve storage directory path
|
||||||
|
/// Uses platform-specific data directory if not explicitly configured:
|
||||||
|
/// - Linux: ~/.local/share/owlen/sessions
|
||||||
|
/// - Windows: %APPDATA%\owlen\sessions
|
||||||
|
/// - macOS: ~/Library/Application Support/owlen/sessions
|
||||||
pub fn conversation_path(&self) -> PathBuf {
|
pub fn conversation_path(&self) -> PathBuf {
|
||||||
PathBuf::from(shellexpand::tilde(&self.conversation_dir).as_ref())
|
if let Some(ref dir) = self.conversation_dir {
|
||||||
|
PathBuf::from(shellexpand::tilde(dir).as_ref())
|
||||||
|
} else {
|
||||||
|
// Use platform-specific data directory
|
||||||
|
dirs::data_local_dir()
|
||||||
|
.map(|d| d.join("owlen").join("sessions"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("./owlen_sessions"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for StorageSettings {
|
impl Default for StorageSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
conversation_dir: Self::default_conversation_dir(),
|
conversation_dir: None, // Use platform-specific defaults
|
||||||
auto_save_sessions: Self::default_auto_save(),
|
auto_save_sessions: Self::default_auto_save(),
|
||||||
max_saved_sessions: Self::default_max_sessions(),
|
max_saved_sessions: Self::default_max_sessions(),
|
||||||
session_timeout_minutes: Self::default_session_timeout(),
|
session_timeout_minutes: Self::default_session_timeout(),
|
||||||
|
generate_descriptions: Self::default_generate_descriptions(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,3 +358,48 @@ pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
|
|||||||
pub fn session_timeout(config: &Config) -> Duration {
|
pub fn session_timeout(config: &Config) -> Duration {
|
||||||
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
|
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_storage_platform_specific_paths() {
|
||||||
|
let config = Config::default();
|
||||||
|
let path = config.storage.conversation_path();
|
||||||
|
|
||||||
|
// Verify it contains owlen/sessions
|
||||||
|
assert!(path.to_string_lossy().contains("owlen"));
|
||||||
|
assert!(path.to_string_lossy().contains("sessions"));
|
||||||
|
|
||||||
|
// Platform-specific checks
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
// Linux should use ~/.local/share/owlen/sessions
|
||||||
|
assert!(path.to_string_lossy().contains(".local/share"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Windows should use AppData
|
||||||
|
assert!(path.to_string_lossy().contains("AppData"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
// macOS should use ~/Library/Application Support
|
||||||
|
assert!(path.to_string_lossy().contains("Library/Application Support"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Config conversation path: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_storage_custom_path() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.storage.conversation_dir = Some("~/custom/path".to_string());
|
||||||
|
|
||||||
|
let path = config.storage.conversation_path();
|
||||||
|
assert!(path.to_string_lossy().contains("custom/path"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
use crate::storage::StorageManager;
|
||||||
use crate::types::{Conversation, Message};
|
use crate::types::{Conversation, Message};
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use serde_json::{Number, Value};
|
use serde_json::{Number, Value};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -47,8 +49,8 @@ impl ConversationManager {
|
|||||||
&self.active
|
&self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutable access to the active conversation (auto refreshing indexes afterwards)
|
/// Public mutable access to the active conversation
|
||||||
fn active_mut(&mut self) -> &mut Conversation {
|
pub fn active_mut(&mut self) -> &mut Conversation {
|
||||||
&mut self.active
|
&mut self.active
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,6 +266,33 @@ impl ConversationManager {
|
|||||||
fn stream_reset(&mut self) {
|
fn stream_reset(&mut self) {
|
||||||
self.streaming.clear();
|
self.streaming.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Save the active conversation to disk
|
||||||
|
pub fn save_active(&self, storage: &StorageManager, name: Option<String>) -> Result<PathBuf> {
|
||||||
|
storage.save_conversation(&self.active, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the active conversation to disk with a description
|
||||||
|
pub fn save_active_with_description(
|
||||||
|
&self,
|
||||||
|
storage: &StorageManager,
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
storage.save_conversation_with_description(&self.active, name, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a conversation from disk and make it active
|
||||||
|
pub fn load_from_disk(&mut self, storage: &StorageManager, path: impl AsRef<Path>) -> Result<()> {
|
||||||
|
let conversation = storage.load_conversation(path)?;
|
||||||
|
self.load(conversation);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all saved sessions
|
||||||
|
pub fn list_saved_sessions(storage: &StorageManager) -> Result<Vec<crate::storage::SessionMeta>> {
|
||||||
|
storage.list_sessions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamingMetadata {
|
impl StreamingMetadata {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod model;
|
|||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod storage;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
pub mod wrap_cursor;
|
pub mod wrap_cursor;
|
||||||
@@ -54,6 +55,9 @@ pub enum Error {
|
|||||||
#[error("Serialization error: {0}")]
|
#[error("Serialization error: {0}")]
|
||||||
Serialization(#[from] serde_json::Error),
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Storage error: {0}")]
|
||||||
|
Storage(String),
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
#[error("Unknown error: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,8 @@ impl ProviderRegistry {
|
|||||||
for provider in self.providers.values() {
|
for provider in self.providers.values() {
|
||||||
match provider.list_models().await {
|
match provider.list_models().await {
|
||||||
Ok(mut models) => all_models.append(&mut models),
|
Ok(mut models) => all_models.append(&mut models),
|
||||||
Err(e) => {
|
Err(_) => {
|
||||||
// Log error but continue with other providers
|
// Continue with other providers
|
||||||
eprintln!("Failed to get models from {}: {}", provider.name(), e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,4 +218,88 @@ impl SessionController {
|
|||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
self.conversation.clear();
|
self.conversation.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a short AI description for the current conversation
|
||||||
|
pub async fn generate_conversation_description(&self) -> Result<String> {
|
||||||
|
let conv = self.conversation.active();
|
||||||
|
|
||||||
|
// If conversation is empty or very short, return a simple description
|
||||||
|
if conv.messages.is_empty() {
|
||||||
|
return Ok("Empty conversation".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if conv.messages.len() == 1 {
|
||||||
|
let first_msg = &conv.messages[0];
|
||||||
|
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||||
|
return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a summary prompt from the first few and last few messages
|
||||||
|
let mut summary_messages = Vec::new();
|
||||||
|
|
||||||
|
// Add system message to guide the description
|
||||||
|
summary_messages.push(crate::types::Message::system(
|
||||||
|
"Summarize this conversation in 1-2 short sentences (max 100 characters). \
|
||||||
|
Focus on the main topic or question being discussed. Be concise and descriptive.".to_string()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Include first message
|
||||||
|
if let Some(first) = conv.messages.first() {
|
||||||
|
summary_messages.push(first.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include a middle message if conversation is long enough
|
||||||
|
if conv.messages.len() > 4 {
|
||||||
|
if let Some(mid) = conv.messages.get(conv.messages.len() / 2) {
|
||||||
|
summary_messages.push(mid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include last message
|
||||||
|
if let Some(last) = conv.messages.last() {
|
||||||
|
if conv.messages.len() > 1 {
|
||||||
|
summary_messages.push(last.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a summarization request
|
||||||
|
let request = crate::types::ChatRequest {
|
||||||
|
model: conv.model.clone(),
|
||||||
|
messages: summary_messages,
|
||||||
|
parameters: crate::types::ChatParameters {
|
||||||
|
temperature: Some(0.3), // Lower temperature for more focused summaries
|
||||||
|
max_tokens: Some(50), // Keep it short
|
||||||
|
stream: false,
|
||||||
|
extra: std::collections::HashMap::new(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the summary from the provider
|
||||||
|
match self.provider.chat(request).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let description = response.message.content.trim().to_string();
|
||||||
|
|
||||||
|
// If description is empty, use fallback
|
||||||
|
if description.is_empty() {
|
||||||
|
let first_msg = &conv.messages[0];
|
||||||
|
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||||
|
return Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate if too long
|
||||||
|
let truncated = if description.len() > 100 {
|
||||||
|
format!("{}...", description.chars().take(97).collect::<String>())
|
||||||
|
} else {
|
||||||
|
description
|
||||||
|
};
|
||||||
|
Ok(truncated)
|
||||||
|
}
|
||||||
|
Err(_e) => {
|
||||||
|
// Fallback to simple description if AI generation fails
|
||||||
|
let first_msg = &conv.messages[0];
|
||||||
|
let preview = first_msg.content.chars().take(50).collect::<String>();
|
||||||
|
Ok(format!("{}{}", preview, if first_msg.content.len() > 50 { "..." } else { "" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
308
crates/owlen-core/src/storage.rs
Normal file
308
crates/owlen-core/src/storage.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ pub struct Conversation {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
/// Optional name/title for the conversation
|
/// Optional name/title for the conversation
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
/// Optional AI-generated description of the conversation
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
/// Messages in chronological order
|
/// Messages in chronological order
|
||||||
pub messages: Vec<Message>,
|
pub messages: Vec<Message>,
|
||||||
/// Model used for this conversation
|
/// Model used for this conversation
|
||||||
@@ -167,6 +170,7 @@ impl Conversation {
|
|||||||
Self {
|
Self {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: None,
|
name: None,
|
||||||
|
description: None,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
model,
|
model,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub enum InputMode {
|
|||||||
Help,
|
Help,
|
||||||
Visual,
|
Visual,
|
||||||
Command,
|
Command,
|
||||||
|
SessionBrowser,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for InputMode {
|
impl fmt::Display for InputMode {
|
||||||
@@ -34,6 +35,7 @@ impl fmt::Display for InputMode {
|
|||||||
InputMode::Help => "Help",
|
InputMode::Help => "Help",
|
||||||
InputMode::Visual => "Visual",
|
InputMode::Visual => "Visual",
|
||||||
InputMode::Command => "Command",
|
InputMode::Command => "Command",
|
||||||
|
InputMode::SessionBrowser => "Sessions",
|
||||||
};
|
};
|
||||||
f.write_str(label)
|
f.write_str(label)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use owlen_core::{
|
use owlen_core::{
|
||||||
session::{SessionController, SessionOutcome},
|
session::{SessionController, SessionOutcome},
|
||||||
|
storage::{SessionMeta, StorageManager},
|
||||||
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
||||||
ui::{AppState, AutoScroll, FocusedPanel, InputMode},
|
ui::{AppState, AutoScroll, FocusedPanel, InputMode},
|
||||||
};
|
};
|
||||||
@@ -55,6 +56,11 @@ pub struct ChatApp {
|
|||||||
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
focused_panel: FocusedPanel, // Currently focused panel for scrolling
|
||||||
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
|
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
|
||||||
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
|
||||||
|
storage: StorageManager, // Storage manager for session persistence
|
||||||
|
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
|
||||||
|
selected_session_index: usize, // Index of selected session in browser
|
||||||
|
save_name_buffer: String, // Buffer for entering save name
|
||||||
|
help_tab_index: usize, // Currently selected help tab (0-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatApp {
|
impl ChatApp {
|
||||||
@@ -63,6 +69,12 @@ impl ChatApp {
|
|||||||
let mut textarea = TextArea::default();
|
let mut textarea = TextArea::default();
|
||||||
configure_textarea_defaults(&mut textarea);
|
configure_textarea_defaults(&mut textarea);
|
||||||
|
|
||||||
|
let storage = StorageManager::new().unwrap_or_else(|e| {
|
||||||
|
eprintln!("Warning: Failed to initialize storage: {}", e);
|
||||||
|
StorageManager::with_directory(std::path::PathBuf::from("/tmp/owlen_sessions"))
|
||||||
|
.expect("Failed to create fallback storage")
|
||||||
|
});
|
||||||
|
|
||||||
let app = Self {
|
let app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: InputMode::Normal,
|
mode: InputMode::Normal,
|
||||||
@@ -93,6 +105,11 @@ impl ChatApp {
|
|||||||
focused_panel: FocusedPanel::Input,
|
focused_panel: FocusedPanel::Input,
|
||||||
chat_cursor: (0, 0),
|
chat_cursor: (0, 0),
|
||||||
thinking_cursor: (0, 0),
|
thinking_cursor: (0, 0),
|
||||||
|
storage,
|
||||||
|
saved_sessions: Vec::new(),
|
||||||
|
selected_session_index: 0,
|
||||||
|
save_name_buffer: String::new(),
|
||||||
|
help_tab_index: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
(app, session_rx)
|
(app, session_rx)
|
||||||
@@ -209,6 +226,18 @@ impl ChatApp {
|
|||||||
self.thinking_cursor
|
self.thinking_cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn saved_sessions(&self) -> &[SessionMeta] {
|
||||||
|
&self.saved_sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_session_index(&self) -> usize {
|
||||||
|
self.selected_session_index
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn help_tab_index(&self) -> usize {
|
||||||
|
self.help_tab_index
|
||||||
|
}
|
||||||
|
|
||||||
pub fn 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 => {
|
||||||
@@ -976,7 +1005,11 @@ impl ChatApp {
|
|||||||
(KeyCode::Enter, _) => {
|
(KeyCode::Enter, _) => {
|
||||||
// Execute command
|
// Execute command
|
||||||
let cmd = self.command_buffer.trim();
|
let cmd = self.command_buffer.trim();
|
||||||
match cmd {
|
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||||||
|
let command = parts.first().copied().unwrap_or("");
|
||||||
|
let args = &parts[1..];
|
||||||
|
|
||||||
|
match command {
|
||||||
"q" | "quit" => {
|
"q" | "quit" => {
|
||||||
return Ok(AppState::Quit);
|
return Ok(AppState::Quit);
|
||||||
}
|
}
|
||||||
@@ -984,9 +1017,65 @@ impl ChatApp {
|
|||||||
self.controller.clear();
|
self.controller.clear();
|
||||||
self.status = "Conversation cleared".to_string();
|
self.status = "Conversation cleared".to_string();
|
||||||
}
|
}
|
||||||
"w" | "write" => {
|
"w" | "write" | "save" => {
|
||||||
// Could implement saving conversation here
|
// Save current conversation with AI-generated description
|
||||||
self.status = "Conversation saved".to_string();
|
let name = if !args.is_empty() {
|
||||||
|
Some(args.join(" "))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate description if enabled in config
|
||||||
|
let description = if self.controller.config().storage.generate_descriptions {
|
||||||
|
self.status = "Generating description...".to_string();
|
||||||
|
match self.controller.generate_conversation_description().await {
|
||||||
|
Ok(desc) => Some(desc),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the conversation with description
|
||||||
|
match self.controller.conversation_mut().save_active_with_description(&self.storage, name.clone(), description) {
|
||||||
|
Ok(path) => {
|
||||||
|
self.status = format!("Session saved: {}", path.display());
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.error = Some(format!("Failed to save session: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"load" | "open" | "o" => {
|
||||||
|
// Load saved sessions and enter browser mode
|
||||||
|
match self.storage.list_sessions() {
|
||||||
|
Ok(sessions) => {
|
||||||
|
self.saved_sessions = sessions;
|
||||||
|
self.selected_session_index = 0;
|
||||||
|
self.mode = InputMode::SessionBrowser;
|
||||||
|
self.command_buffer.clear();
|
||||||
|
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" => {
|
"h" | "help" => {
|
||||||
self.mode = InputMode::Help;
|
self.mode = InputMode::Help;
|
||||||
@@ -1092,8 +1181,76 @@ impl ChatApp {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
InputMode::Help => match key.code {
|
InputMode::Help => match key.code {
|
||||||
KeyCode::Esc | KeyCode::Enter => {
|
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
|
||||||
self.mode = InputMode::Normal;
|
self.mode = InputMode::Normal;
|
||||||
|
self.help_tab_index = 0; // Reset to first tab
|
||||||
|
}
|
||||||
|
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||||
|
// Next tab
|
||||||
|
if self.help_tab_index < 4 {
|
||||||
|
self.help_tab_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||||
|
// Previous tab
|
||||||
|
if self.help_tab_index > 0 {
|
||||||
|
self.help_tab_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('1') => self.help_tab_index = 0,
|
||||||
|
KeyCode::Char('2') => self.help_tab_index = 1,
|
||||||
|
KeyCode::Char('3') => self.help_tab_index = 2,
|
||||||
|
KeyCode::Char('4') => self.help_tab_index = 3,
|
||||||
|
KeyCode::Char('5') => self.help_tab_index = 4,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
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 +1499,7 @@ impl ChatApp {
|
|||||||
stream,
|
stream,
|
||||||
}) => {
|
}) => {
|
||||||
// Step 3: Model loaded, now generating response
|
// Step 3: Model loaded, now generating response
|
||||||
self.status = "Generating response...".to_string();
|
self.status = format!("Model loaded. Generating response... (streaming)");
|
||||||
|
|
||||||
self.spawn_stream(response_id, stream);
|
self.spawn_stream(response_id, stream);
|
||||||
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
match self.controller.mark_stream_placeholder(response_id, "▌") {
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
match app.mode() {
|
match app.mode() {
|
||||||
InputMode::ProviderSelection => render_provider_selector(frame, app),
|
InputMode::ProviderSelection => render_provider_selector(frame, app),
|
||||||
InputMode::ModelSelection => render_model_selector(frame, app),
|
InputMode::ModelSelection => render_model_selector(frame, app),
|
||||||
InputMode::Help => render_help(frame),
|
InputMode::Help => render_help(frame, app),
|
||||||
|
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::Help => (" HELP", Color::LightMagenta),
|
||||||
InputMode::Visual => (" VISUAL", Color::Magenta),
|
InputMode::Visual => (" VISUAL", Color::Magenta),
|
||||||
InputMode::Command => (" COMMAND", Color::Yellow),
|
InputMode::Command => (" COMMAND", Color::Yellow),
|
||||||
|
InputMode::SessionBrowser => (" SESSIONS", Color::Yellow),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status_message = if app.streaming_count() > 0 {
|
let status_message = if let Some(error) = app.error_message() {
|
||||||
format!("Streaming... ({})", app.streaming_count())
|
|
||||||
} else if let Some(error) = app.error_message() {
|
|
||||||
format!("Error: {}", error)
|
format!("Error: {}", error)
|
||||||
} else {
|
} else {
|
||||||
"Ready".to_string()
|
app.status_message().to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
|
let help_text = "i:Input :m:Model :n:New :c:Clear :h:Help q:Quit";
|
||||||
@@ -1083,94 +1083,385 @@ fn render_model_selector(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
frame.render_stateful_widget(list, area, &mut state);
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_help(frame: &mut Frame<'_>) {
|
fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
||||||
let area = centered_rect(70, 60, frame.area());
|
let area = centered_rect(75, 70, frame.area());
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
let help_text = vec![
|
let tab_index = app.help_tab_index();
|
||||||
Line::from("MODES:"),
|
let tabs = vec!["Navigation", "Editing", "Visual", "Commands", "Sessions"];
|
||||||
Line::from(" Normal → default mode for navigation"),
|
|
||||||
Line::from(" Insert → editing input text"),
|
|
||||||
Line::from(" Visual → selecting text"),
|
|
||||||
Line::from(" Command → executing commands (: prefix)"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("PANEL NAVIGATION:"),
|
|
||||||
Line::from(" Tab → cycle panels forward"),
|
|
||||||
Line::from(" Shift+Tab → cycle panels backward"),
|
|
||||||
Line::from(" (Panels: Chat, Thinking, Input)"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("CURSOR MOVEMENT (Normal mode, Chat/Thinking panels):"),
|
|
||||||
Line::from(" h/← l/→ → move left/right by character"),
|
|
||||||
Line::from(" j/↓ k/↑ → move down/up by line"),
|
|
||||||
Line::from(" w → forward to next word start"),
|
|
||||||
Line::from(" e → forward to word end"),
|
|
||||||
Line::from(" b → backward to previous word"),
|
|
||||||
Line::from(" 0 / Home → start of line"),
|
|
||||||
Line::from(" ^ → first non-blank character"),
|
|
||||||
Line::from(" $ / End → end of line"),
|
|
||||||
Line::from(" gg → jump to top"),
|
|
||||||
Line::from(" G → jump to bottom"),
|
|
||||||
Line::from(" Ctrl+d/u → scroll half page down/up"),
|
|
||||||
Line::from(" Ctrl+f/b → scroll full page down/up"),
|
|
||||||
Line::from(" PageUp/Down → scroll full page"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("EDITING (Normal mode):"),
|
|
||||||
Line::from(" i / Enter → enter insert mode at cursor"),
|
|
||||||
Line::from(" a → append after cursor"),
|
|
||||||
Line::from(" A → append at end of line"),
|
|
||||||
Line::from(" I → insert at start of line"),
|
|
||||||
Line::from(" o → insert line below and enter insert mode"),
|
|
||||||
Line::from(" O → insert line above and enter insert mode"),
|
|
||||||
Line::from(" dd → clear input buffer"),
|
|
||||||
Line::from(" p → paste from clipboard to input"),
|
|
||||||
Line::from(" Esc → return to normal mode"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("INSERT MODE:"),
|
|
||||||
Line::from(" Enter → send message"),
|
|
||||||
Line::from(" Ctrl+J → insert newline"),
|
|
||||||
Line::from(" Ctrl+↑/↓ → navigate input history"),
|
|
||||||
Line::from(" Ctrl+A → start of line"),
|
|
||||||
Line::from(" Ctrl+E → end of line"),
|
|
||||||
Line::from(" Ctrl+W → word forward"),
|
|
||||||
Line::from(" Ctrl+B → word backward"),
|
|
||||||
Line::from(" Esc → return to normal mode"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("VISUAL MODE (all panels):"),
|
|
||||||
Line::from(" v → enter visual mode at cursor"),
|
|
||||||
Line::from(" h/j/k/l → extend selection left/down/up/right"),
|
|
||||||
Line::from(" w / e / b → extend by word (start/end/back)"),
|
|
||||||
Line::from(" 0 / ^ / $ → extend to line start/first char/end"),
|
|
||||||
Line::from(" y → yank (copy) selection"),
|
|
||||||
Line::from(" d → yank selection (delete in Input)"),
|
|
||||||
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(""),
|
|
||||||
Line::from("QUICK KEYS:"),
|
|
||||||
Line::from(" q → quit (from normal mode)"),
|
|
||||||
Line::from(" Ctrl+C → quit"),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from("Press Esc or Enter to close this help."),
|
|
||||||
];
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(help_text).block(
|
// Build tab line
|
||||||
|
let mut tab_spans = Vec::new();
|
||||||
|
for (i, tab_name) in tabs.iter().enumerate() {
|
||||||
|
if i == tab_index {
|
||||||
|
tab_spans.push(Span::styled(
|
||||||
|
format!(" {} ", tab_name),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::LightMagenta)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
tab_spans.push(Span::styled(
|
||||||
|
format!(" {} ", tab_name),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if i < tabs.len() - 1 {
|
||||||
|
tab_spans.push(Span::raw(" │ "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let help_text = match tab_index {
|
||||||
|
0 => vec![ // Navigation
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("PANEL NAVIGATION", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" Tab → cycle panels forward"),
|
||||||
|
Line::from(" Shift+Tab → cycle panels backward"),
|
||||||
|
Line::from(" (Panels: Chat, Thinking, Input)"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("CURSOR MOVEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" h/← l/→ → move left/right by character"),
|
||||||
|
Line::from(" j/↓ k/↑ → move down/up by line"),
|
||||||
|
Line::from(" w → forward to next word start"),
|
||||||
|
Line::from(" e → forward to word end"),
|
||||||
|
Line::from(" b → backward to previous word"),
|
||||||
|
Line::from(" 0 / Home → start of line"),
|
||||||
|
Line::from(" ^ → first non-blank character"),
|
||||||
|
Line::from(" $ / End → end of line"),
|
||||||
|
Line::from(" gg → jump to top"),
|
||||||
|
Line::from(" G → jump to bottom"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("SCROLLING", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" Ctrl+d/u → scroll half page down/up"),
|
||||||
|
Line::from(" Ctrl+f/b → scroll full page down/up"),
|
||||||
|
Line::from(" PageUp/Down → scroll full page"),
|
||||||
|
],
|
||||||
|
1 => vec![ // Editing
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("ENTERING INSERT MODE", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" i / Enter → enter insert mode at cursor"),
|
||||||
|
Line::from(" a → append after cursor"),
|
||||||
|
Line::from(" A → append at end of line"),
|
||||||
|
Line::from(" I → insert at start of line"),
|
||||||
|
Line::from(" o → insert line below and enter insert mode"),
|
||||||
|
Line::from(" O → insert line above and enter insert mode"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("INSERT MODE KEYS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" Enter → send message"),
|
||||||
|
Line::from(" Ctrl+J → insert newline (multiline message)"),
|
||||||
|
Line::from(" Ctrl+↑/↓ → navigate input history"),
|
||||||
|
Line::from(" Ctrl+A → jump to start of line"),
|
||||||
|
Line::from(" Ctrl+E → jump to end of line"),
|
||||||
|
Line::from(" Ctrl+W → word forward"),
|
||||||
|
Line::from(" Ctrl+B → word backward"),
|
||||||
|
Line::from(" Esc → return to normal mode"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("NORMAL MODE OPERATIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" dd → clear input buffer"),
|
||||||
|
Line::from(" p → paste from clipboard to input"),
|
||||||
|
],
|
||||||
|
2 => vec![ // Visual
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("VISUAL MODE", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" v → enter visual mode at cursor"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("SELECTION MOVEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" h/j/k/l → extend selection left/down/up/right"),
|
||||||
|
Line::from(" w → extend to next word start"),
|
||||||
|
Line::from(" e → extend to word end"),
|
||||||
|
Line::from(" b → extend backward to previous word"),
|
||||||
|
Line::from(" 0 → extend to line start"),
|
||||||
|
Line::from(" ^ → extend to first non-blank"),
|
||||||
|
Line::from(" $ → extend to line end"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("VISUAL MODE OPERATIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" y → yank (copy) selection to clipboard"),
|
||||||
|
Line::from(" d → cut selection (Input panel only)"),
|
||||||
|
Line::from(" v / Esc → exit visual mode"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("NOTES", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" • Visual mode works across all panels (Chat, Thinking, Input)"),
|
||||||
|
Line::from(" • Yanked text is available for paste with 'p' in normal mode"),
|
||||||
|
],
|
||||||
|
3 => vec![ // Commands
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("COMMAND MODE", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(" Press ':' to enter command mode, then type one of:"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("HELP & NAVIGATION", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" :h, :help → show this help"),
|
||||||
|
Line::from(" :q, :quit → quit application"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("MODEL MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" :m, :model → open model selector"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("CONVERSATION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" :n, :new → start new conversation"),
|
||||||
|
Line::from(" :c, :clear → clear current conversation"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" :save [name] → save current session (with optional name)"),
|
||||||
|
Line::from(" :w [name] → alias for :save"),
|
||||||
|
Line::from(" :load, :o, :open → browse and load saved sessions"),
|
||||||
|
Line::from(" :sessions, :ls → browse saved sessions"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("QUICK SHORTCUTS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" q (normal mode) → quit without :"),
|
||||||
|
Line::from(" Ctrl+C → quit immediately"),
|
||||||
|
],
|
||||||
|
4 => vec![ // Sessions
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("SESSION MANAGEMENT", Style::default().add_modifier(Modifier::BOLD).fg(Color::Yellow))
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("SAVING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" :save → save with auto-generated name"),
|
||||||
|
Line::from(" :save my-session → save with custom name"),
|
||||||
|
Line::from(" • AI generates description automatically (configurable)"),
|
||||||
|
Line::from(" • Sessions stored in platform-specific directories"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("LOADING SESSIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" :load, :o, :open → browse and select session"),
|
||||||
|
Line::from(" :sessions, :ls → browse saved sessions"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("SESSION BROWSER KEYS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" j/k or ↑/↓ → navigate sessions"),
|
||||||
|
Line::from(" Enter → load selected session"),
|
||||||
|
Line::from(" d → delete selected session"),
|
||||||
|
Line::from(" Esc → close browser"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("STORAGE LOCATIONS", Style::default().add_modifier(Modifier::BOLD).fg(Color::Cyan))
|
||||||
|
]),
|
||||||
|
Line::from(" Linux → ~/.local/share/owlen/sessions"),
|
||||||
|
Line::from(" Windows → %APPDATA%\\owlen\\sessions"),
|
||||||
|
Line::from(" macOS → ~/Library/Application Support/owlen/sessions"),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("CONTEXT PRESERVATION", Style::default().add_modifier(Modifier::BOLD).fg(Color::Green))
|
||||||
|
]),
|
||||||
|
Line::from(" • Full conversation history is preserved when saving"),
|
||||||
|
Line::from(" • All context is restored when loading a session"),
|
||||||
|
Line::from(" • Continue conversations seamlessly across restarts"),
|
||||||
|
],
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create layout for tabs and content
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Tab bar
|
||||||
|
Constraint::Min(0), // Content
|
||||||
|
Constraint::Length(2), // Navigation hint
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Render tabs
|
||||||
|
let tabs_block = Block::default()
|
||||||
|
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
|
||||||
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
|
||||||
|
let tabs_para = Paragraph::new(Line::from(tab_spans)).block(tabs_block);
|
||||||
|
frame.render_widget(tabs_para, layout[0]);
|
||||||
|
|
||||||
|
// Render content
|
||||||
|
let content_block = Block::default()
|
||||||
|
.borders(Borders::LEFT | Borders::RIGHT)
|
||||||
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
|
||||||
|
let content_para = Paragraph::new(help_text).block(content_block);
|
||||||
|
frame.render_widget(content_para, layout[1]);
|
||||||
|
|
||||||
|
// Render navigation hint
|
||||||
|
let nav_hint = Line::from(vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("Tab/h/l", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(":Switch "),
|
||||||
|
Span::styled("1-5", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(":Jump "),
|
||||||
|
Span::styled("Esc/q", Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(":Close "),
|
||||||
|
]);
|
||||||
|
let nav_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
|
||||||
|
let nav_para = Paragraph::new(nav_hint)
|
||||||
|
.block(nav_block)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
frame.render_widget(nav_para, layout[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
Block::default()
|
||||||
.title(Span::styled(
|
.title(Span::styled(
|
||||||
"Help",
|
format!(" Saved Sessions ({}) ", sessions.len()),
|
||||||
Style::default()
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
.fg(Color::LightMagenta)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
))
|
))
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))),
|
.border_style(Style::default().fg(Color::Yellow)),
|
||||||
);
|
);
|
||||||
|
|
||||||
frame.render_widget(paragraph, area);
|
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 {
|
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
|||||||
Reference in New Issue
Block a user