4 Commits

Author SHA1 Message Date
d86888704f chore(release): bump version to 0.1.11
Some checks failed
ci/someci/push/woodpecker Pipeline is pending approval
macos-check / cargo check (macOS) (push) Has been cancelled
Update pkgver in PKGBUILD, version badge in README, and workspace package version in Cargo.toml. Add changelog entry for 0.1.11 reflecting the metadata bump.
2025-10-18 03:34:57 +02:00
de6b6e20a5 docs(readme): quick start matrices + platform notes 2025-10-18 03:25:10 +02:00
1e8a5e08ed docs(tui): MVU migration guide + module map 2025-10-18 03:20:32 +02:00
218ebbf32f feat(tui): debug log panel toggle 2025-10-18 03:18:34 +02:00
15 changed files with 635 additions and 19 deletions

View File

@@ -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
### Added

View File

@@ -16,7 +16,7 @@ members = [
exclude = []
[workspace.package]
version = "0.1.9"
version = "0.1.11"
edition = "2024"
authors = ["Owlibou"]
license = "AGPL-3.0"

View File

@@ -1,6 +1,6 @@
# Maintainer: vikingowl <christian@nachtigall.dev>
pkgname=owlen
pkgver=0.1.9
pkgver=0.1.11
pkgrel=1
pkgdesc="Terminal User Interface LLM client for Ollama with chat and code assistance features"
arch=('x86_64')

View File

@@ -3,7 +3,7 @@
> Terminal-native assistant for running local language models with a comfortable TUI.
![Status](https://img.shields.io/badge/status-alpha-yellow)
![Version](https://img.shields.io/badge/version-0.1.9-blue)
![Version](https://img.shields.io/badge/version-0.1.11-blue)
![Rust](https://img.shields.io/badge/made_with-Rust-ffc832?logo=rust&logoColor=white)
![License](https://img.shields.io/badge/license-AGPL--3.0-blue)
@@ -57,20 +57,28 @@ Owlen is designed to keep data local by default while still allowing controlled
### Installation
#### Linux & macOS
The recommended way to install on Linux and macOS is to clone the repository and install using `cargo`.
Pick the option that matches your platform and appetite for source builds:
| 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
git clone https://github.com/Owlibou/owlen.git
cd owlen
cargo install --path crates/owlen-cli
# Windows compatibility smoke test (GNU toolchain)
scripts/check-windows.sh
# 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
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).
> **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.
### Running OWLEN

View File

@@ -31,6 +31,7 @@ syntect = "5.3"
once_cell = "1.19"
owlen-markdown = { path = "../owlen-markdown" }
shellexpand = { workspace = true }
regex = { workspace = true }
# Async runtime
tokio = { workspace = true }

View File

@@ -72,3 +72,28 @@ command = "composer.submit"
mode = "normal"
keys = ["Ctrl+;"]
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"

View File

@@ -48,10 +48,11 @@ use crate::events::Event;
use crate::model_info_panel::ModelInfoPanel;
use crate::slash::{self, McpSlashCommand, SlashCommand};
use crate::state::{
CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState,
Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest,
RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState,
WorkspaceSnapshot, spawn_repo_search_task, spawn_symbol_search_task,
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
FileNode, FileTreeState, Keymap, ModelPaletteEntry, PaletteSuggestion, PaneDirection,
PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage,
SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task,
spawn_symbol_search_task,
};
use crate::toast::{Toast, ToastLevel, ToastManager};
use crate::ui::format_tool_output;
@@ -75,6 +76,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use dirs::{config_dir, data_local_dir};
use log::Level;
use serde_json::{Value, json};
const ONBOARDING_STATUS_LINE: &str =
@@ -481,6 +483,7 @@ pub struct ChatApp {
queued_consents: VecDeque<ConsentDialogState>, // Backlog of consent requests
system_status: String, // System/status messages (tool execution, status, etc)
toasts: ToastManager,
debug_log: DebugLogState,
/// Simple execution budget: maximum number of tool calls allowed per session.
_execution_budget: usize,
/// Agent mode enabled
@@ -655,6 +658,8 @@ impl ChatApp {
let file_tree = FileTreeState::new(workspace_root);
let file_icons = FileIconResolver::from_mode(icon_mode);
install_global_logger();
let mut app = Self {
controller,
mode: InputMode::Normal,
@@ -748,6 +753,7 @@ impl ChatApp {
String::new()
},
toasts: ToastManager::new(),
debug_log: DebugLogState::new(),
_execution_budget: 50,
agent_mode: false,
agent_running: false,
@@ -1866,6 +1872,25 @@ impl ChatApp {
&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> {
self.toasts.iter()
}
@@ -1878,6 +1903,56 @@ impl ChatApp {
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 {
let config = self.controller.config();
config.ui.input_max_rows.max(1)
@@ -3264,6 +3339,11 @@ impl ChatApp {
self.handle_app_effects(effects).await?;
Ok(true)
}
AppCommand::ToggleDebugLog => {
self.pending_key = None;
self.toggle_debug_log_panel();
Ok(true)
}
}
}
@@ -4877,6 +4957,7 @@ impl ChatApp {
Event::Tick => {
self.poll_repo_search();
self.poll_symbol_search();
self.poll_debug_log_updates();
self.prune_toasts();
// Future: update streaming timers
}

View File

@@ -243,6 +243,10 @@ const COMMANDS: &[CommandSpec] = &[
keyword: "explorer",
description: "Alias for files",
},
CommandSpec {
keyword: "debug log",
description: "Toggle the debug log panel",
},
];
/// Return the static catalog of commands.

View File

@@ -13,6 +13,7 @@ pub enum AppCommand {
FocusPanel(FocusedPanel),
ComposerSubmit,
EnterCommandMode,
ToggleDebugLog,
}
#[derive(Debug)]
@@ -65,6 +66,7 @@ impl CommandRegistry {
);
commands.insert("composer.submit".to_string(), AppCommand::ComposerSubmit);
commands.insert("mode.command".to_string(), AppCommand::EnterCommandMode);
commands.insert("debug.toggle".to_string(), AppCommand::ToggleDebugLog);
Self { commands }
}

View 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: &regex::Captures<'_>| {
format!("{}{}<redacted>", &caps[1], &caps[2])
});
let step = GENERIC_SECRET.replace_all(&step, |caps: &regex::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);
}
}

View File

@@ -284,7 +284,6 @@ fn normalize_modifiers(modifiers: KeyModifiers) -> KeyModifiers {
#[cfg(test)]
mod tests {
use super::*;
use crate::widgets::model_picker::FilterMode;
use crossterm::event::{KeyCode, KeyModifiers};
#[test]

View File

@@ -6,6 +6,7 @@
//! to test in isolation.
mod command_palette;
mod debug_log;
mod file_icons;
mod file_tree;
mod keymap;
@@ -13,6 +14,7 @@ mod search;
mod workspace;
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_tree::{
FileNode, FileTreeState, FilterMode as FileFilterMode, GitDecoration, VisibleFileEntry,

View File

@@ -1,3 +1,4 @@
use log::Level;
use pathdiff::diff_paths;
use ratatui::Frame;
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);
}
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);
}
@@ -1964,6 +1979,134 @@ fn render_system_output(frame: &mut Frame<'_>, area: Rect, app: &ChatApp, messag
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
where
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(" Alt+←/→/↑/↓ → resize focused code pane"),
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(""),
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(" F1 or ? → toggle help overlay"),
Line::from(" F12 → toggle debug log panel"),
Line::from(" :files, :explorer → toggle files panel"),
Line::from(" :markdown [on|off] → toggle markdown rendering"),
Line::from(" Ctrl+←/→ → resize files panel"),

View File

@@ -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.
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 `ModelViewUpdate` core is documented in [`docs/tui-mvu-migration.md`](tui-mvu-migration.md).

109
docs/tui-mvu-migration.md Normal file
View File

@@ -0,0 +1,109 @@
# TUI MVU Migration Guide
This guide explains how we are migrating the Owlen terminal UI to a predictable **ModelViewUpdate (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.