Compare commits
4 Commits
c49e7f4b22
...
d86888704f
| Author | SHA1 | Date | |
|---|---|---|---|
| d86888704f | |||
| de6b6e20a5 | |||
| 1e8a5e08ed | |||
| 218ebbf32f |
@@ -45,6 +45,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.1.11] - 2025-10-18
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Bump workspace packages and distribution metadata to version `0.1.11`.
|
||||||
|
|
||||||
## [0.1.10] - 2025-10-03
|
## [0.1.10] - 2025-10-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ members = [
|
|||||||
exclude = []
|
exclude = []
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.9"
|
version = "0.1.11"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Owlibou"]
|
authors = ["Owlibou"]
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
|||||||
2
PKGBUILD
2
PKGBUILD
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: vikingowl <christian@nachtigall.dev>
|
# Maintainer: vikingowl <christian@nachtigall.dev>
|
||||||
pkgname=owlen
|
pkgname=owlen
|
||||||
pkgver=0.1.9
|
pkgver=0.1.11
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -3,7 +3,7 @@
|
|||||||
> Terminal-native assistant for running local language models with a comfortable TUI.
|
> Terminal-native assistant for running local language models with a comfortable TUI.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
@@ -57,20 +57,28 @@ Owlen is designed to keep data local by default while still allowing controlled
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
#### Linux & macOS
|
Pick the option that matches your platform and appetite for source builds:
|
||||||
The recommended way to install on Linux and macOS is to clone the repository and install using `cargo`.
|
|
||||||
|
| Platform | Package / Command | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Arch Linux | `yay -S owlen-git` | Builds from the latest `dev` branch via AUR. |
|
||||||
|
| Other Linux | `cargo install --path crates/owlen-cli --locked --force` | Requires Rust 1.75+ and a running Ollama daemon. |
|
||||||
|
| macOS | `cargo install --path crates/owlen-cli --locked --force` | macOS 12+ tested. Install Ollama separately (`brew install ollama`). The binary links against the system OpenSSL – ensure Command Line Tools are installed. |
|
||||||
|
| Windows (experimental) | `cargo install --path crates/owlen-cli --locked --force` | Enable the GNU toolchain (`rustup target add x86_64-pc-windows-gnu`) and install Ollama for Windows preview builds. Some optional tools (e.g., Docker-based code execution) are currently disabled. |
|
||||||
|
|
||||||
|
If you prefer containerised builds, use the provided `Dockerfile` as a base image and copy out `target/release/owlen`.
|
||||||
|
|
||||||
|
Run the helper scripts to sanity-check platform coverage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Owlibou/owlen.git
|
# Windows compatibility smoke test (GNU toolchain)
|
||||||
cd owlen
|
scripts/check-windows.sh
|
||||||
cargo install --path crates/owlen-cli
|
|
||||||
|
# Reproduce CI packaging locally (choose a target from .woodpecker.yml)
|
||||||
|
dev/local_build.sh x86_64-unknown-linux-gnu
|
||||||
```
|
```
|
||||||
**Note for macOS**: While this method works, official binary releases for macOS are planned for the future.
|
|
||||||
|
|
||||||
#### Windows
|
> **Tip (macOS):** On the first launch macOS Gatekeeper may quarantine the binary. Clear the attribute (`xattr -d com.apple.quarantine $(which owlen)`) or build from source locally to avoid notarisation prompts.
|
||||||
The Windows build has not been thoroughly tested yet. Installation is possible via the same `cargo install` method, but it is considered experimental at this time.
|
|
||||||
|
|
||||||
From Unix hosts you can run `scripts/check-windows.sh` to ensure the code base still compiles for Windows (`rustup` will install the required target automatically).
|
|
||||||
|
|
||||||
### Running OWLEN
|
### Running OWLEN
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ syntect = "5.3"
|
|||||||
once_cell = "1.19"
|
once_cell = "1.19"
|
||||||
owlen-markdown = { path = "../owlen-markdown" }
|
owlen-markdown = { path = "../owlen-markdown" }
|
||||||
shellexpand = { workspace = true }
|
shellexpand = { workspace = true }
|
||||||
|
regex = { workspace = true }
|
||||||
|
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
@@ -72,3 +72,28 @@ command = "composer.submit"
|
|||||||
mode = "normal"
|
mode = "normal"
|
||||||
keys = ["Ctrl+;"]
|
keys = ["Ctrl+;"]
|
||||||
command = "mode.command"
|
command = "mode.command"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
mode = "normal"
|
||||||
|
keys = ["F12"]
|
||||||
|
command = "debug.toggle"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
mode = "editing"
|
||||||
|
keys = ["F12"]
|
||||||
|
command = "debug.toggle"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
mode = "visual"
|
||||||
|
keys = ["F12"]
|
||||||
|
command = "debug.toggle"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
mode = "command"
|
||||||
|
keys = ["F12"]
|
||||||
|
command = "debug.toggle"
|
||||||
|
|
||||||
|
[[binding]]
|
||||||
|
mode = "help"
|
||||||
|
keys = ["F12"]
|
||||||
|
command = "debug.toggle"
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ use crate::events::Event;
|
|||||||
use crate::model_info_panel::ModelInfoPanel;
|
use crate::model_info_panel::ModelInfoPanel;
|
||||||
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState,
|
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
|
||||||
Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest,
|
FileNode, FileTreeState, Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection,
|
||||||
RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState,
|
PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage,
|
||||||
WorkspaceSnapshot, spawn_repo_search_task, spawn_symbol_search_task,
|
SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task,
|
||||||
|
spawn_symbol_search_task,
|
||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||||
use crate::ui::format_tool_output;
|
use crate::ui::format_tool_output;
|
||||||
@@ -75,6 +76,7 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant, SystemTime};
|
use std::time::{Duration, Instant, SystemTime};
|
||||||
|
|
||||||
use dirs::{config_dir, data_local_dir};
|
use dirs::{config_dir, data_local_dir};
|
||||||
|
use log::Level;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
const ONBOARDING_STATUS_LINE: &str =
|
const ONBOARDING_STATUS_LINE: &str =
|
||||||
@@ -481,6 +483,7 @@ pub struct ChatApp {
|
|||||||
queued_consents: VecDeque<ConsentDialogState>, // Backlog of consent requests
|
queued_consents: VecDeque<ConsentDialogState>, // Backlog of consent requests
|
||||||
system_status: String, // System/status messages (tool execution, status, etc)
|
system_status: String, // System/status messages (tool execution, status, etc)
|
||||||
toasts: ToastManager,
|
toasts: ToastManager,
|
||||||
|
debug_log: DebugLogState,
|
||||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||||
_execution_budget: usize,
|
_execution_budget: usize,
|
||||||
/// Agent mode enabled
|
/// Agent mode enabled
|
||||||
@@ -655,6 +658,8 @@ impl ChatApp {
|
|||||||
let file_tree = FileTreeState::new(workspace_root);
|
let file_tree = FileTreeState::new(workspace_root);
|
||||||
let file_icons = FileIconResolver::from_mode(icon_mode);
|
let file_icons = FileIconResolver::from_mode(icon_mode);
|
||||||
|
|
||||||
|
install_global_logger();
|
||||||
|
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: InputMode::Normal,
|
mode: InputMode::Normal,
|
||||||
@@ -748,6 +753,7 @@ impl ChatApp {
|
|||||||
String::new()
|
String::new()
|
||||||
},
|
},
|
||||||
toasts: ToastManager::new(),
|
toasts: ToastManager::new(),
|
||||||
|
debug_log: DebugLogState::new(),
|
||||||
_execution_budget: 50,
|
_execution_budget: 50,
|
||||||
agent_mode: false,
|
agent_mode: false,
|
||||||
agent_running: false,
|
agent_running: false,
|
||||||
@@ -1866,6 +1872,25 @@ impl ChatApp {
|
|||||||
&self.theme
|
&self.theme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_debug_log_visible(&self) -> bool {
|
||||||
|
self.debug_log.is_visible()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_debug_log_panel(&mut self) {
|
||||||
|
let now_visible = self.debug_log.toggle_visible();
|
||||||
|
if now_visible {
|
||||||
|
self.status = "Debug log open — F12 to hide".to_string();
|
||||||
|
self.error = None;
|
||||||
|
} else {
|
||||||
|
self.status = "Debug log hidden".to_string();
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn debug_log_entries(&self) -> Vec<DebugLogEntry> {
|
||||||
|
self.debug_log.entries()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn toasts(&self) -> impl Iterator<Item = &Toast> {
|
pub fn toasts(&self) -> impl Iterator<Item = &Toast> {
|
||||||
self.toasts.iter()
|
self.toasts.iter()
|
||||||
}
|
}
|
||||||
@@ -1878,6 +1903,56 @@ impl ChatApp {
|
|||||||
self.toasts.retain_active();
|
self.toasts.retain_active();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn poll_debug_log_updates(&mut self) {
|
||||||
|
let new_entries = self.debug_log.take_unseen();
|
||||||
|
if new_entries.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut latest_summary: Option<(Level, String)> = None;
|
||||||
|
|
||||||
|
for entry in new_entries.iter() {
|
||||||
|
let toast_level = match entry.level {
|
||||||
|
Level::Error => ToastLevel::Error,
|
||||||
|
Level::Warn => ToastLevel::Warning,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let summary = format!("{}: {}", entry.target, entry.message);
|
||||||
|
let clipped = Self::ellipsize(&summary, 120);
|
||||||
|
self.push_toast(toast_level, clipped.clone());
|
||||||
|
latest_summary = Some((entry.level, clipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.debug_log.is_visible() {
|
||||||
|
if let Some((level, message)) = latest_summary {
|
||||||
|
let level_label = match level {
|
||||||
|
Level::Error => "Error",
|
||||||
|
Level::Warn => "Warning",
|
||||||
|
_ => "Log",
|
||||||
|
};
|
||||||
|
self.status = format!("{level_label}: {message} (F12 to open debug log)");
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ellipsize(message: &str, max_len: usize) -> String {
|
||||||
|
if message.chars().count() <= max_len {
|
||||||
|
return message.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut truncated = String::new();
|
||||||
|
for (idx, ch) in message.chars().enumerate() {
|
||||||
|
if idx + 1 >= max_len {
|
||||||
|
truncated.push('…');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
truncated.push(ch);
|
||||||
|
}
|
||||||
|
truncated
|
||||||
|
}
|
||||||
|
|
||||||
pub fn input_max_rows(&self) -> u16 {
|
pub fn input_max_rows(&self) -> u16 {
|
||||||
let config = self.controller.config();
|
let config = self.controller.config();
|
||||||
config.ui.input_max_rows.max(1)
|
config.ui.input_max_rows.max(1)
|
||||||
@@ -3264,6 +3339,11 @@ impl ChatApp {
|
|||||||
self.handle_app_effects(effects).await?;
|
self.handle_app_effects(effects).await?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
AppCommand::ToggleDebugLog => {
|
||||||
|
self.pending_key = None;
|
||||||
|
self.toggle_debug_log_panel();
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4877,6 +4957,7 @@ impl ChatApp {
|
|||||||
Event::Tick => {
|
Event::Tick => {
|
||||||
self.poll_repo_search();
|
self.poll_repo_search();
|
||||||
self.poll_symbol_search();
|
self.poll_symbol_search();
|
||||||
|
self.poll_debug_log_updates();
|
||||||
self.prune_toasts();
|
self.prune_toasts();
|
||||||
// Future: update streaming timers
|
// Future: update streaming timers
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,10 @@ const COMMANDS: &[CommandSpec] = &[
|
|||||||
keyword: "explorer",
|
keyword: "explorer",
|
||||||
description: "Alias for files",
|
description: "Alias for files",
|
||||||
},
|
},
|
||||||
|
CommandSpec {
|
||||||
|
keyword: "debug log",
|
||||||
|
description: "Toggle the debug log panel",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Return the static catalog of commands.
|
/// Return the static catalog of commands.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub enum AppCommand {
|
|||||||
FocusPanel(FocusedPanel),
|
FocusPanel(FocusedPanel),
|
||||||
ComposerSubmit,
|
ComposerSubmit,
|
||||||
EnterCommandMode,
|
EnterCommandMode,
|
||||||
|
ToggleDebugLog,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -65,6 +66,7 @@ impl CommandRegistry {
|
|||||||
);
|
);
|
||||||
commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit);
|
commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit);
|
||||||
commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode);
|
commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode);
|
||||||
|
commands.insert("debug.toggle".to_string(), AppCommand::ToggleDebugLog);
|
||||||
|
|
||||||
Self { commands }
|
Self { commands }
|
||||||
}
|
}
|
||||||
|
|||||||
235
crates/owlen-tui/src/state/debug_log.rs
Normal file
235
crates/owlen-tui/src/state/debug_log.rs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use log::{Level, LevelFilter, Metadata, Record};
|
||||||
|
use once_cell::sync::{Lazy, OnceCell};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Maximum number of entries to retain in the in-memory ring buffer.
|
||||||
|
const MAX_ENTRIES: usize = 256;
|
||||||
|
|
||||||
|
/// Global access handle for the debug log store.
|
||||||
|
static STORE: Lazy<DebugLogStore> = Lazy::new(DebugLogStore::default);
|
||||||
|
static LOGGER: OnceCell<()> = OnceCell::new();
|
||||||
|
static DEBUG_LOGGER: DebugLogger = DebugLogger;
|
||||||
|
|
||||||
|
/// Install the in-process logger that feeds the debug log ring buffer.
|
||||||
|
pub fn install_global_logger() {
|
||||||
|
LOGGER.get_or_init(|| {
|
||||||
|
if log::set_logger(&DEBUG_LOGGER).is_ok() {
|
||||||
|
log::set_max_level(LevelFilter::Trace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-application state for presenting and acknowledging debug log entries.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DebugLogState {
|
||||||
|
visible: bool,
|
||||||
|
last_seen_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DebugLogState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let last_seen_id = STORE.latest_id();
|
||||||
|
Self {
|
||||||
|
visible: false,
|
||||||
|
last_seen_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_visible(&mut self) -> bool {
|
||||||
|
self.visible = !self.visible;
|
||||||
|
if self.visible {
|
||||||
|
self.mark_seen();
|
||||||
|
}
|
||||||
|
self.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_visible(&mut self, visible: bool) {
|
||||||
|
self.visible = visible;
|
||||||
|
if visible {
|
||||||
|
self.mark_seen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_visible(&self) -> bool {
|
||||||
|
self.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> Vec<DebugLogEntry> {
|
||||||
|
STORE.snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_unseen(&mut self) -> Vec<DebugLogEntry> {
|
||||||
|
let entries = STORE.entries_since(self.last_seen_id);
|
||||||
|
if let Some(entry) = entries.last() {
|
||||||
|
self.last_seen_id = entry.id;
|
||||||
|
}
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_unseen(&self) -> bool {
|
||||||
|
STORE.latest_id() > self.last_seen_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_seen(&mut self) {
|
||||||
|
self.last_seen_id = STORE.latest_id();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DebugLogState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata describing a single debug log entry.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DebugLogEntry {
|
||||||
|
pub id: u64,
|
||||||
|
pub timestamp: DateTime<Local>,
|
||||||
|
pub level: Level,
|
||||||
|
pub target: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct DebugLogStore {
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Inner {
|
||||||
|
entries: VecDeque<DebugLogEntry>,
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DebugLogStore {
|
||||||
|
fn snapshot(&self) -> Vec<DebugLogEntry> {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
inner.entries.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_id(&self) -> u64 {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
inner.next_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entries_since(&self, last_seen_id: u64) -> Vec<DebugLogEntry> {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
inner
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.id > last_seen_id)
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, level: Level, target: &str, message: &str) -> DebugLogEntry {
|
||||||
|
let sanitized = sanitize_message(message);
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
inner.next_id = inner.next_id.saturating_add(1);
|
||||||
|
let entry = DebugLogEntry {
|
||||||
|
id: inner.next_id,
|
||||||
|
timestamp: Local::now(),
|
||||||
|
level,
|
||||||
|
target: target.to_string(),
|
||||||
|
message: sanitized,
|
||||||
|
};
|
||||||
|
inner.entries.push_back(entry.clone());
|
||||||
|
while inner.entries.len() > MAX_ENTRIES {
|
||||||
|
inner.entries.pop_front();
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DebugLogger;
|
||||||
|
|
||||||
|
impl log::Log for DebugLogger {
|
||||||
|
fn enabled(&self, metadata: &Metadata) -> bool {
|
||||||
|
metadata.level() <= LevelFilter::Trace
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self, record: &Record) {
|
||||||
|
if !self.enabled(record.metadata()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only persist warnings and errors in the in-memory buffer.
|
||||||
|
if record.level() < Level::Warn {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = record.args().to_string();
|
||||||
|
let entry = STORE.push(record.level(), record.target(), &message);
|
||||||
|
|
||||||
|
if record.level() == Level::Error {
|
||||||
|
eprintln!(
|
||||||
|
"[owlen:error][{}] {}",
|
||||||
|
entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
entry.message
|
||||||
|
);
|
||||||
|
} else if record.level() == Level::Warn {
|
||||||
|
eprintln!(
|
||||||
|
"[owlen:warn][{}] {}",
|
||||||
|
entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
entry.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_message(message: &str) -> String {
|
||||||
|
static AUTH_HEADER: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"(?i)\b(authorization)(\s*[:=]\s*)([^\r\n]+)").unwrap());
|
||||||
|
static GENERIC_SECRET: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"(?i)\b(api[_-]?key|token)(\s*[:=]\s*)([^,\s;]+)").unwrap());
|
||||||
|
static BEARER_TOKEN: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._\-+/=]+").unwrap());
|
||||||
|
|
||||||
|
let step = AUTH_HEADER.replace_all(message, |caps: ®ex::Captures<'_>| {
|
||||||
|
format!("{}{}<redacted>", &caps[1], &caps[2])
|
||||||
|
});
|
||||||
|
|
||||||
|
let step = GENERIC_SECRET.replace_all(&step, |caps: ®ex::Captures<'_>| {
|
||||||
|
format!("{}{}<redacted>", &caps[1], &caps[2])
|
||||||
|
});
|
||||||
|
|
||||||
|
BEARER_TOKEN
|
||||||
|
.replace_all(&step, "Bearer <redacted>")
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_masks_common_tokens() {
|
||||||
|
let input =
|
||||||
|
"Authorization: Bearer abc123 token=xyz456 KEY=value Authorization=Token secretStuff";
|
||||||
|
let sanitized = sanitize_message(input);
|
||||||
|
assert!(!sanitized.contains("abc123"));
|
||||||
|
assert!(!sanitized.contains("xyz456"));
|
||||||
|
assert!(!sanitized.contains("secretStuff"));
|
||||||
|
assert_eq!(sanitized, "Authorization: <redacted>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ring_buffer_discards_old_entries() {
|
||||||
|
install_global_logger();
|
||||||
|
let initial_latest = STORE.latest_id();
|
||||||
|
for idx in 0..(MAX_ENTRIES as u64 + 10) {
|
||||||
|
let message = format!("warn #{idx}");
|
||||||
|
STORE.push(Level::Warn, "test", &message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = STORE.snapshot();
|
||||||
|
assert_eq!(entries.len(), MAX_ENTRIES);
|
||||||
|
assert!(entries.first().unwrap().id > initial_latest);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,7 +284,6 @@ fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::widgets::model_picker::FilterMode;
|
|
||||||
use crossterm::event::{KeyCode, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyModifiers};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! to test in isolation.
|
//! to test in isolation.
|
||||||
|
|
||||||
mod command_palette;
|
mod command_palette;
|
||||||
|
mod debug_log;
|
||||||
mod file_icons;
|
mod file_icons;
|
||||||
mod file_tree;
|
mod file_tree;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
@@ -13,6 +14,7 @@ mod search;
|
|||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion};
|
pub use command_palette::{CommandPalette, ModelPaletteEntry, PaletteGroup, PaletteSuggestion};
|
||||||
|
pub use debug_log::{DebugLogEntry, DebugLogState, install_global_logger};
|
||||||
pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
|
pub use file_icons::{FileIconResolver, FileIconSet, IconDetection};
|
||||||
pub use file_tree::{
|
pub use file_tree::{
|
||||||
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use log::Level;
|
||||||
use pathdiff::diff_paths;
|
use pathdiff::diff_paths;
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
@@ -366,6 +367,20 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
|
|||||||
render_code_workspace(frame, area, app);
|
render_code_workspace(frame, area, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.is_debug_log_visible() {
|
||||||
|
let min_height = 6;
|
||||||
|
let computed_height = content_area.height.saturating_div(3).max(min_height);
|
||||||
|
let panel_height = computed_height.min(content_area.height);
|
||||||
|
|
||||||
|
if panel_height >= 4 {
|
||||||
|
let y = content_area
|
||||||
|
.y
|
||||||
|
.saturating_add(content_area.height.saturating_sub(panel_height));
|
||||||
|
let log_area = Rect::new(content_area.x, y, content_area.width, panel_height);
|
||||||
|
render_debug_log_panel(frame, log_area, app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render_toasts(frame, app, content_area);
|
render_toasts(frame, app, content_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1964,6 +1979,134 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag
|
|||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_debug_log_panel(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
|
||||||
|
let theme = app.theme();
|
||||||
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
|
let title = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" Debug log ",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.pane_header_active)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
"warnings & errors",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.pane_hint_text)
|
||||||
|
.add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(theme.focused_panel_border))
|
||||||
|
.style(Style::default().bg(theme.background).fg(theme.text))
|
||||||
|
.title(title);
|
||||||
|
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
if inner.width == 0 || inner.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = app.debug_log_entries();
|
||||||
|
let available_rows = inner.height as usize;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
"No warnings captured this session.",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.pane_hint_text)
|
||||||
|
.add_modifier(Modifier::DIM),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let total_entries = entries.len();
|
||||||
|
let mut subset: Vec<_> = entries.into_iter().rev().take(available_rows).collect();
|
||||||
|
subset.reverse();
|
||||||
|
|
||||||
|
if total_entries > subset.len() && subset.len() == available_rows && !subset.is_empty() {
|
||||||
|
subset.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let overflow = total_entries.saturating_sub(subset.len());
|
||||||
|
if overflow > 0 {
|
||||||
|
lines.push(Line::styled(
|
||||||
|
format!("… {overflow} older entries not shown"),
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.pane_hint_text)
|
||||||
|
.add_modifier(Modifier::DIM),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in subset {
|
||||||
|
let (label, badge_style, message_style) = debug_level_styles(entry.level, theme);
|
||||||
|
let timestamp = entry.timestamp.format("%H:%M:%S");
|
||||||
|
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(format!(" {label} "), badge_style),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
timestamp.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.pane_hint_text)
|
||||||
|
.add_modifier(Modifier::DIM),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if !entry.target.is_empty() {
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(Span::styled(
|
||||||
|
entry.target,
|
||||||
|
Style::default().fg(theme.pane_header_active),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.push(Span::raw(" "));
|
||||||
|
spans.push(Span::styled(entry.message, message_style));
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines)
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.alignment(Alignment::Left)
|
||||||
|
.style(Style::default().bg(theme.background));
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_level_styles(level: Level, theme: &Theme) -> (&'static str, Style, Style) {
|
||||||
|
match level {
|
||||||
|
Level::Error => (
|
||||||
|
"ERR",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.background)
|
||||||
|
.bg(theme.error)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
Style::default().fg(theme.error),
|
||||||
|
),
|
||||||
|
Level::Warn => (
|
||||||
|
"WARN",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.background)
|
||||||
|
.bg(theme.agent_action)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
Style::default().fg(theme.agent_action),
|
||||||
|
),
|
||||||
|
_ => (
|
||||||
|
"INFO",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.background)
|
||||||
|
.bg(theme.info)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
Style::default().fg(theme.text),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = &'a str>,
|
I: IntoIterator<Item = &'a str>,
|
||||||
@@ -2944,6 +3087,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split"),
|
Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split"),
|
||||||
Line::from(" Alt+←/→/↑/↓ → resize focused code pane"),
|
Line::from(" Alt+←/→/↑/↓ → resize focused code pane"),
|
||||||
Line::from(" g then t → expand files panel and focus it"),
|
Line::from(" g then t → expand files panel and focus it"),
|
||||||
|
Line::from(" F12 → toggle debug log panel"),
|
||||||
Line::from(" F1 or ? → toggle this help overlay"),
|
Line::from(" F1 or ? → toggle this help overlay"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(vec![Span::styled(
|
Line::from(vec![Span::styled(
|
||||||
@@ -3086,6 +3230,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
|
|||||||
)]),
|
)]),
|
||||||
Line::from(" :h, :help → show this help"),
|
Line::from(" :h, :help → show this help"),
|
||||||
Line::from(" F1 or ? → toggle help overlay"),
|
Line::from(" F1 or ? → toggle help overlay"),
|
||||||
|
Line::from(" F12 → toggle debug log panel"),
|
||||||
Line::from(" :files, :explorer → toggle files panel"),
|
Line::from(" :files, :explorer → toggle files panel"),
|
||||||
Line::from(" :markdown [on|off] → toggle markdown rendering"),
|
Line::from(" :markdown [on|off] → toggle markdown rendering"),
|
||||||
Line::from(" Ctrl+←/→ → resize files panel"),
|
Line::from(" Ctrl+←/→ → resize files panel"),
|
||||||
|
|||||||
@@ -175,4 +175,4 @@ The TUI is rendered on each iteration of the main application loop in `owlen-tui
|
|||||||
4. **State-Driven Rendering**: Each rendering function takes the current application state as an argument. It uses this state to decide what and how to render. For example, the border color of a panel might change if it is focused.
|
4. **State-Driven Rendering**: Each rendering function takes the current application state as an argument. It uses this state to decide what and how to render. For example, the border color of a panel might change if it is focused.
|
||||||
5. **Buffer and Diff**: `ratatui` does not draw directly to the terminal. Instead, it renders the widgets to an in-memory buffer. It then compares this buffer to the previous buffer and only sends the necessary changes to the terminal. This is highly efficient and prevents flickering.
|
5. **Buffer and Diff**: `ratatui` does not draw directly to the terminal. Instead, it renders the widgets to an in-memory buffer. It then compares this buffer to the previous buffer and only sends the necessary changes to the terminal. This is highly efficient and prevents flickering.
|
||||||
|
|
||||||
The command palette and other modal helpers expose lightweight state structs in `owlen_tui::state`. These components keep business logic (suggestion filtering, selection state, etc.) independent from rendering, which in turn makes them straightforward to unit test.
|
The command palette and other modal helpers expose lightweight state structs in `owlen_tui::state`. These components keep business logic (suggestion filtering, selection state, etc.) independent from rendering, which in turn makes them straightforward to unit test. The ongoing migration of more features into the `Model–View–Update` core is documented in [`docs/tui-mvu-migration.md`](tui-mvu-migration.md).
|
||||||
|
|||||||
109
docs/tui-mvu-migration.md
Normal file
109
docs/tui-mvu-migration.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# TUI MVU Migration Guide
|
||||||
|
|
||||||
|
This guide explains how we are migrating the Owlen terminal UI to a predictable **Model–View–Update (MVU)** architecture. Use it to understand the current layout, decide where new logic belongs, and track which features have already moved to the MVU core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Make UI state transitions pure and testable.
|
||||||
|
- Reduce duplicated control flow inside `chat_app.rs`.
|
||||||
|
- Keep rendering functions dumb; they should depend on read-only view models.
|
||||||
|
- Ensure new features land in MVU-first form so the imperative paths shrink over time.
|
||||||
|
|
||||||
|
Adopt the checklist below whenever you touch a feature that still lives in the imperative code path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module Map (owlen-tui)
|
||||||
|
|
||||||
|
| Area | Path | Responsibility | MVU Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Core state | `src/app/mvu.rs` | Shared `AppModel`, `AppEvent`, `AppEffect` definitions | **Ready** – composer + consent events implemented |
|
||||||
|
| Legacy app | `src/chat_app.rs` | Orchestrates IO, manages pending tasks, renders via ratatui | **Transitioning** – increasingly delegates to MVU |
|
||||||
|
| Event loop | `src/app/handler.rs` | Converts session messages into app updates | Needs cleanup once message flow is MVU aware |
|
||||||
|
| Rendering | `src/ui.rs` + `src/widgets/*` | Pure rendering helpers that pull data from `ChatApp` | Already read-only; keep that invariant |
|
||||||
|
| Commands | `src/commands/*` | Keymap and palette command registry | Candidate for MVU once palette state migrates |
|
||||||
|
| Shared state | `src/state/*` | Small state helpers (command palette, file tree, etc.) | Each module can become an MVU sub-model |
|
||||||
|
|
||||||
|
Use the table to find the right starting point before adding new events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Event Taxonomy
|
||||||
|
|
||||||
|
Current events live in `app/mvu.rs`.
|
||||||
|
|
||||||
|
- `AppEvent::Composer` – covers draft changes, mode switches, submissions.
|
||||||
|
- `AppEvent::ToolPermission` – bridges consent dialog choices back to the controller.
|
||||||
|
|
||||||
|
`AppEffect` represents side effects the imperative shell must execute:
|
||||||
|
|
||||||
|
- `SetStatus` – surface validation failures.
|
||||||
|
- `RequestSubmit` – hand control back to the async send pipeline.
|
||||||
|
- `ResolveToolConsent` – notify the session controller of user decisions.
|
||||||
|
|
||||||
|
### Adding a new feature
|
||||||
|
|
||||||
|
1. Extend `AppModel` with the new view state.
|
||||||
|
2. Create a dedicated event enum (e.g. `PaletteEvent`) and nest it under `AppEvent`.
|
||||||
|
3. Add pure update logic that mutates the model and returns zero or more effects.
|
||||||
|
4. Handle emitted effects inside `ChatApp::handle_app_effects`.
|
||||||
|
|
||||||
|
Keep the event names UI-centric. Provider-side actions should remain in `owlen-core`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Migration Checklist
|
||||||
|
|
||||||
|
| Feature | Scope | MVU tasks | Status |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Composer (input buffer) | Draft text, submission workflow | ✅ `ComposerModel`, `ComposerEvent`, `SubmissionOutcome` | ✅ Complete |
|
||||||
|
| Tool consent dialog | Approval / denial flow | ✅ `AppEvent::ToolPermission`, `AppEffect::ResolveToolConsent` | ✅ Complete |
|
||||||
|
| Chat timeline | Message ordering, cursor, scrollback | Model struct for timeline + events for history updates | ☐ TODO |
|
||||||
|
| Thinking pane | Agent reasoning text, auto-scroll | Model + event to toggle visibility and append lines | ☐ TODO |
|
||||||
|
| Model picker | Filters, search, selection | Convert `ModelSelectorItem` list + search metadata into MVU | ☐ TODO |
|
||||||
|
| Command palette | Suggestions, history, apply actions | Move palette state into `AppModel` and surface events | ☐ TODO |
|
||||||
|
| File workspace | Pane layout, file tree focus | Represent pane tree in MVU, drive focus + resize events | ☐ TODO |
|
||||||
|
| Toasts & status bar | Transient notifications | Consider MVU-managed queue with explicit events | ☐ TODO |
|
||||||
|
|
||||||
|
When you pick up one of the TODO rows, document the plan in the PR description and link back to this table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Playbook
|
||||||
|
|
||||||
|
1. **Inventory state** – list every field in `ChatApp` that your feature touches.
|
||||||
|
2. **Define view model** – move the persistent state into `AppModel` (or a new sub-struct).
|
||||||
|
3. **Write events** – describe all user intents and background updates as `AppEvent` variants.
|
||||||
|
4. **Translate side effects** – whenever the update logic needs to call into async code, emit an `AppEffect`. Handle it inside `handle_app_effects`.
|
||||||
|
5. **Refactor call sites** – replace direct mutations with `apply_app_event` calls.
|
||||||
|
6. **Write tests** – cover the pure update function with table-driven unit tests.
|
||||||
|
7. **Remove duplicates** – once the MVU path handles everything, delete the legacy branch in `chat_app.rs`.
|
||||||
|
|
||||||
|
This flow keeps commits reviewable and avoids breaking the live UI during migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Guidance
|
||||||
|
|
||||||
|
- **Unit tests** – cover the pure update functions inside `app/mvu.rs`.
|
||||||
|
- **Integration tests** – add scenarios to `crates/owlen-tui/tests/agent_flow_ui.rs` when side effects change.
|
||||||
|
- **Golden behaviour** – ensure the ratatui renderers still consume read-only data; add lightweight snapshot tests if needed.
|
||||||
|
- **Manual verification** – run `cargo run -p owlen-cli -- --help` to open the TUI and confirm the migrated feature behaves as expected.
|
||||||
|
|
||||||
|
Every new MVU feature should land with unit tests plus a note about manual validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracking TODOs
|
||||||
|
|
||||||
|
- Keep this file up to date when you migrate a feature.
|
||||||
|
- Add inline `// TODO(mvu)` tags in code with a short description so they are easy to grep.
|
||||||
|
- Use the `docs/` folder for design notes; avoid long comment blocks inside the code.
|
||||||
|
|
||||||
|
Future contributors should be able to glance at this document, see what is done, and understand where to continue the migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Questions? Reach out in the Owlen discussion board or drop a note in the relevant PR thread. Consistent updates here will keep MVU adoption predictable for everyone.
|
||||||
Reference in New Issue
Block a user