feat(guidance): inline cheat-sheets & onboarding

This commit is contained in:
2025-10-25 21:00:36 +02:00
parent 124db19e68
commit d7066d7d37
12 changed files with 1226 additions and 911 deletions

View File

@@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Adaptive TUI layout with responsive 80/120-column breakpoints, refreshed glass/neon theming, and animated focus rings for pane transitions. - Adaptive TUI layout with responsive 80/120-column breakpoints, refreshed glass/neon theming, and animated focus rings for pane transitions.
- Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations. - Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations.
- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching. - Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching.
- Inline guidance overlay adds a three-step onboarding tour, keymap-aware cheat sheets (F1 / `?`), and persists completion state via `ui.guidance`.
- Cloud usage tracker persists hourly/weekly token totals, adds a `:limits` command, shows live header badges, and raises toast warnings at 80%/95% of the configured quotas. - Cloud usage tracker persists hourly/weekly token totals, adds a `:limits` command, shows live header badges, and raises toast warnings at 80%/95% of the configured quotas.
- Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions. - Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions.
- Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse. - Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse.

View File

@@ -1822,6 +1822,8 @@ pub struct UiSettings {
pub layers: LayerSettings, pub layers: LayerSettings,
#[serde(default)] #[serde(default)]
pub animations: AnimationSettings, pub animations: AnimationSettings,
#[serde(default)]
pub guidance: GuidanceSettings,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1851,6 +1853,26 @@ impl Default for AccessibilitySettings {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuidanceSettings {
#[serde(default = "GuidanceSettings::default_coach_marks_complete")]
pub coach_marks_complete: bool,
}
impl GuidanceSettings {
const fn default_coach_marks_complete() -> bool {
false
}
}
impl Default for GuidanceSettings {
fn default() -> Self {
Self {
coach_marks_complete: Self::default_coach_marks_complete(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerSettings { pub struct LayerSettings {
#[serde(default = "LayerSettings::default_shadow_elevation")] #[serde(default = "LayerSettings::default_shadow_elevation")]
@@ -2095,6 +2117,7 @@ impl Default for UiSettings {
accessibility: AccessibilitySettings::default(), accessibility: AccessibilitySettings::default(),
layers: LayerSettings::default(), layers: LayerSettings::default(),
animations: AnimationSettings::default(), animations: AnimationSettings::default(),
guidance: GuidanceSettings::default(),
} }
} }
} }

View File

@@ -56,19 +56,20 @@ 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, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver, CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapOverrides, KeymapProfile, FileNode, FileTreeState, Keymap, KeymapBindingDescription, KeymapEventResult, KeymapOverrides,
KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, KeymapProfile, KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection,
RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage,
WorkspaceSnapshot, install_global_logger, 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_token_short, format_tool_output}; use crate::ui::{format_token_short, format_tool_output};
use crate::widgets::model_picker::FilterMode; use crate::widgets::model_picker::FilterMode;
use crate::{commands, highlight}; use crate::{commands, highlight};
use owlen_core::config::{ use owlen_core::config::{
AnimationSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, AnimationSettings, GuidanceSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV,
LayerSettings, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, LayerSettings, OLLAMA_API_KEY_ENV,
OLLAMA_MODE_KEY, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
}; };
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID}; use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly // Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
@@ -98,6 +99,7 @@ const ONBOARDING_SYSTEM_STATUS: &str =
const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer."; const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer.";
const TUTORIAL_SYSTEM_STATUS: &str = const TUTORIAL_SYSTEM_STATUS: &str =
"Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter"; "Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter";
const ONBOARDING_STEP_COUNT: usize = 3;
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL; const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
@@ -271,6 +273,12 @@ impl PaneAnimations {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GuidanceOverlay {
CheatSheet,
Onboarding,
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(crate) struct LayoutSnapshot { pub(crate) struct LayoutSnapshot {
pub(crate) frame: Rect, pub(crate) frame: Rect,
@@ -691,7 +699,7 @@ pub enum SessionEvent {
}, },
} }
pub const HELP_TAB_COUNT: usize = 7; pub const HELP_TAB_COUNT: usize = 3;
pub struct ChatApp { pub struct ChatApp {
controller: SessionController, controller: SessionController,
@@ -797,6 +805,9 @@ pub struct ChatApp {
active_layout: AdaptiveLayout, active_layout: AdaptiveLayout,
gauge_animations: GaugeAnimations, gauge_animations: GaugeAnimations,
pane_animations: PaneAnimations, pane_animations: PaneAnimations,
guidance_overlay: GuidanceOverlay,
onboarding_step: usize,
guidance_settings: GuidanceSettings,
/// 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
@@ -962,6 +973,7 @@ impl ChatApp {
let accessibility = config_guard.ui.accessibility.clone(); let accessibility = config_guard.ui.accessibility.clone();
let layer_settings = config_guard.ui.layers.clone(); let layer_settings = config_guard.ui.layers.clone();
let animation_settings = config_guard.ui.animations.clone(); let animation_settings = config_guard.ui.animations.clone();
let guidance_settings = config_guard.ui.guidance.clone();
drop(config_guard); drop(config_guard);
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw); let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
let keymap = { let keymap = {
@@ -1000,7 +1012,11 @@ impl ChatApp {
let mut app = Self { let mut app = Self {
controller, controller,
mode: InputMode::Normal, mode: if show_onboarding {
InputMode::Help
} else {
InputMode::Normal
},
mode_flash_until: None, mode_flash_until: None,
status: if show_onboarding { status: if show_onboarding {
ONBOARDING_STATUS_LINE.to_string() ONBOARDING_STATUS_LINE.to_string()
@@ -1114,6 +1130,13 @@ impl ChatApp {
active_layout: AdaptiveLayout::default(), active_layout: AdaptiveLayout::default(),
gauge_animations: GaugeAnimations::default(), gauge_animations: GaugeAnimations::default(),
pane_animations: PaneAnimations::default(), pane_animations: PaneAnimations::default(),
guidance_overlay: if show_onboarding {
GuidanceOverlay::Onboarding
} else {
GuidanceOverlay::CheatSheet
},
onboarding_step: 0,
guidance_settings,
}; };
app.mvu_model.composer.mode = InputMode::Normal; app.mvu_model.composer.mode = InputMode::Normal;
@@ -1133,16 +1156,6 @@ impl ChatApp {
eprintln!("Warning: failed to restore workspace layout: {err}"); eprintln!("Warning: failed to restore workspace layout: {err}");
} }
if show_onboarding {
let mut cfg = app.controller.config_mut();
if cfg.ui.show_onboarding {
cfg.ui.show_onboarding = false;
if let Err(err) = config::save_config(&cfg) {
eprintln!("Warning: Failed to persist onboarding preference: {err}");
}
}
}
app.refresh_usage_summary().await?; app.refresh_usage_summary().await?;
Ok((app, session_rx)) Ok((app, session_rx))
@@ -1281,6 +1294,26 @@ impl ChatApp {
} }
} }
pub(crate) fn guidance_overlay(&self) -> GuidanceOverlay {
self.guidance_overlay
}
pub fn onboarding_step(&self) -> usize {
self.onboarding_step
}
pub fn onboarding_step_count(&self) -> usize {
ONBOARDING_STEP_COUNT
}
pub fn coach_marks_complete(&self) -> bool {
self.guidance_settings.coach_marks_complete
}
pub fn keymap_bindings(&self) -> Vec<KeymapBindingDescription> {
self.keymap.describe_bindings()
}
fn update_context_usage(&mut self, usage: &TokenUsage) { fn update_context_usage(&mut self, usage: &TokenUsage) {
let context_window = self let context_window = self
.active_context_window() .active_context_window()
@@ -2176,6 +2209,7 @@ impl ChatApp {
} }
pub fn show_tutorial(&mut self) { pub fn show_tutorial(&mut self) {
self.open_guidance_overlay(GuidanceOverlay::Onboarding);
self.error = None; self.error = None;
self.status = TUTORIAL_STATUS.to_string(); self.status = TUTORIAL_STATUS.to_string();
self.system_status = TUTORIAL_SYSTEM_STATUS.to_string(); self.system_status = TUTORIAL_SYSTEM_STATUS.to_string();
@@ -2219,6 +2253,84 @@ impl ChatApp {
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode })); let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
} }
fn open_guidance_overlay(&mut self, overlay: GuidanceOverlay) {
self.guidance_overlay = overlay;
if matches!(overlay, GuidanceOverlay::CheatSheet) && HELP_TAB_COUNT > 0 {
self.help_tab_index = self.help_tab_index.min(HELP_TAB_COUNT - 1);
}
if matches!(overlay, GuidanceOverlay::Onboarding) {
self.onboarding_step = 0;
self.status = format!("Owlen onboarding · Step 1 of {}", ONBOARDING_STEP_COUNT);
} else {
self.status = "Owlen cheat sheet".to_string();
}
self.error = None;
self.set_input_mode(InputMode::Help);
}
fn advance_onboarding_step(&mut self) {
if !matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) {
return;
}
if self.onboarding_step + 1 < ONBOARDING_STEP_COUNT {
self.onboarding_step += 1;
self.status = format!(
"Owlen onboarding · Step {} of {}",
self.onboarding_step + 1,
ONBOARDING_STEP_COUNT
);
} else {
self.finish_onboarding(true);
}
}
fn regress_onboarding_step(&mut self) {
if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) && self.onboarding_step > 0
{
self.onboarding_step -= 1;
self.status = format!(
"Owlen onboarding · Step {} of {}",
self.onboarding_step + 1,
ONBOARDING_STEP_COUNT
);
}
}
fn finish_onboarding(&mut self, completed: bool) {
self.guidance_overlay = GuidanceOverlay::CheatSheet;
self.onboarding_step = 0;
{
let mut cfg = self.controller.config_mut();
let mut dirty = false;
if cfg.ui.show_onboarding {
cfg.ui.show_onboarding = false;
dirty = true;
}
if completed && !cfg.ui.guidance.coach_marks_complete {
cfg.ui.guidance.coach_marks_complete = true;
dirty = true;
}
self.guidance_settings = cfg.ui.guidance.clone();
if dirty {
if let Err(err) = config::save_config(&cfg) {
eprintln!("Warning: Failed to persist guidance settings: {err}");
}
}
}
if completed {
self.status = "Cheat sheet ready — press Esc when done".to_string();
self.error = None;
if HELP_TAB_COUNT > 0 {
self.help_tab_index = 0;
}
self.set_input_mode(InputMode::Help);
} else {
self.reset_status();
self.set_input_mode(InputMode::Normal);
}
}
pub fn mode_flash_active(&self) -> bool { pub fn mode_flash_active(&self) -> bool {
self.mode_flash_until self.mode_flash_until
.map(|deadline| Instant::now() < deadline) .map(|deadline| Instant::now() < deadline)
@@ -6064,13 +6176,17 @@ impl ChatApp {
if matches!(key.code, KeyCode::F(1)) { if matches!(key.code, KeyCode::F(1)) {
if matches!(self.mode, InputMode::Help) { if matches!(self.mode, InputMode::Help) {
self.set_input_mode(InputMode::Normal); if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) {
self.help_tab_index = 0; self.finish_onboarding(false);
self.reset_status();
} else { } else {
self.set_input_mode(InputMode::Help); if HELP_TAB_COUNT > 0 {
self.status = "Help".to_string(); self.help_tab_index = 0;
self.error = None; }
self.reset_status();
self.set_input_mode(InputMode::Normal);
}
} else {
self.open_guidance_overlay(GuidanceOverlay::CheatSheet);
} }
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -6114,9 +6230,24 @@ impl ChatApp {
return Ok(AppState::Running); return Ok(AppState::Running);
} }
if is_question_mark && matches!(self.mode, InputMode::Normal) { if is_question_mark {
self.set_input_mode(InputMode::Help); match self.mode {
self.status = "Help".to_string(); InputMode::Normal => {
self.open_guidance_overlay(GuidanceOverlay::CheatSheet);
}
InputMode::Help => {
if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) {
self.finish_onboarding(false);
} else {
if HELP_TAB_COUNT > 0 {
self.help_tab_index = 0;
}
self.reset_status();
self.set_input_mode(InputMode::Normal);
}
}
_ => {}
}
return Ok(AppState::Running); return Ok(AppState::Running);
} }
@@ -8676,20 +8807,38 @@ impl ChatApp {
} }
_ => {} _ => {}
}, },
InputMode::Help => match key.code { InputMode::Help => match self.guidance_overlay {
GuidanceOverlay::Onboarding => match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::F(1) => {
self.finish_onboarding(false);
}
KeyCode::Enter
| KeyCode::Char(' ')
| KeyCode::Right
| KeyCode::Char('l')
| KeyCode::Tab => {
self.advance_onboarding_step();
}
KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => {
self.regress_onboarding_step();
}
_ => {}
},
GuidanceOverlay::CheatSheet => match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::F(1) => { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::F(1) => {
self.set_input_mode(InputMode::Normal);
self.help_tab_index = 0; // Reset to first tab
self.reset_status(); self.reset_status();
self.set_input_mode(InputMode::Normal);
} }
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
// Next tab if HELP_TAB_COUNT > 0 {
if self.help_tab_index + 1 < HELP_TAB_COUNT { if self.help_tab_index + 1 < HELP_TAB_COUNT {
self.help_tab_index += 1; self.help_tab_index += 1;
} else {
self.help_tab_index = HELP_TAB_COUNT - 1;
}
} }
} }
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
// Previous tab
if self.help_tab_index > 0 { if self.help_tab_index > 0 {
self.help_tab_index -= 1; self.help_tab_index -= 1;
} }
@@ -8703,6 +8852,7 @@ impl ChatApp {
} }
_ => {} _ => {}
}, },
},
InputMode::SessionBrowser => match key.code { InputMode::SessionBrowser => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
self.set_input_mode(InputMode::Normal); self.set_input_mode(InputMode::Normal);

View File

@@ -179,6 +179,14 @@ pub fn parse(input: &str) -> Result<Option<SlashCommand>, SlashError> {
mod tests { mod tests {
use super::*; use super::*;
fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
static GUARD: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
GUARD
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.expect("registry test mutex poisoned")
}
#[test] #[test]
fn ignores_non_command_input() { fn ignores_non_command_input() {
let result = parse("hello world").unwrap(); let result = parse("hello world").unwrap();
@@ -202,6 +210,7 @@ mod tests {
#[test] #[test]
fn parses_registered_mcp_command() { fn parses_registered_mcp_command() {
let _registry = registry_guard();
set_mcp_commands(Vec::new()); set_mcp_commands(Vec::new());
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]); set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
@@ -219,6 +228,7 @@ mod tests {
#[test] #[test]
fn rejects_mcp_command_with_arguments() { fn rejects_mcp_command_with_arguments() {
let _registry = registry_guard();
set_mcp_commands(Vec::new()); set_mcp_commands(Vec::new());
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]); set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +1,14 @@
use std::sync::Arc; mod common;
use async_trait::async_trait;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use insta::{assert_snapshot, with_settings}; use insta::{assert_snapshot, with_settings};
use owlen_core::{ use owlen_core::types::{Message, ToolCall};
Config, Mode, Provider,
session::SessionController,
storage::StorageManager,
types::{Message, ToolCall},
ui::{NoOpUiController, UiController},
};
use owlen_tui::ChatApp; use owlen_tui::ChatApp;
use owlen_tui::events::Event; use owlen_tui::events::Event;
use owlen_tui::ui::render_chat; use owlen_tui::ui::render_chat;
use ratatui::{Terminal, backend::TestBackend}; use ratatui::{Terminal, backend::TestBackend};
use tempfile::tempdir;
use tokio::sync::mpsc;
struct StubProvider; use common::build_chat_app;
#[async_trait]
impl Provider for StubProvider {
fn name(&self) -> &str {
"stub-provider"
}
async fn list_models(&self) -> owlen_core::Result<Vec<owlen_core::types::ModelInfo>> {
Ok(vec![owlen_core::types::ModelInfo {
id: "stub-model".into(),
name: "Stub Model".into(),
description: Some("Stub model for golden snapshot tests".into()),
provider: self.name().into(),
context_window: Some(8192),
capabilities: vec!["chat".into(), "tool-use".into()],
supports_tools: true,
}])
}
async fn send_prompt(
&self,
_request: owlen_core::types::ChatRequest,
) -> owlen_core::Result<owlen_core::types::ChatResponse> {
Ok(owlen_core::types::ChatResponse {
message: Message::assistant("stub completion".into()),
usage: None,
is_streaming: false,
is_final: true,
})
}
async fn stream_prompt(
&self,
_request: owlen_core::types::ChatRequest,
) -> owlen_core::Result<owlen_core::ChatStream> {
Ok(Box::pin(futures_util::stream::empty()))
}
async fn health_check(&self) -> owlen_core::Result<()> {
Ok(())
}
fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) {
self
}
}
fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String { fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
let mut output = String::new(); let mut output = String::new();
@@ -80,56 +25,6 @@ fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
output output
} }
async fn build_chat_app<C, F>(configure_config: C, configure_session: F) -> ChatApp
where
C: FnOnce(&mut Config),
F: FnOnce(&mut SessionController),
{
let temp_dir = tempdir().expect("temp dir");
let storage =
StorageManager::with_database_path(temp_dir.path().join("owlen-tui-snapshots.db"))
.await
.expect("storage");
let storage = Arc::new(storage);
let mut config = Config::default();
configure_config(&mut config);
config.general.default_model = Some("stub-model".into());
config.general.enable_streaming = true;
config.privacy.encrypt_local_data = false;
config.privacy.require_consent_per_session = false;
config.ui.show_onboarding = false;
config.ui.show_timestamps = false;
let provider: Arc<dyn Provider> = Arc::new(StubProvider);
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
let (event_tx, controller_event_rx) = mpsc::unbounded_channel();
let mut session = SessionController::new(
Arc::clone(&provider),
config,
Arc::clone(&storage),
ui,
true,
Some(event_tx),
)
.await
.expect("session controller");
session
.set_operating_mode(Mode::Chat)
.await
.expect("chat mode");
configure_session(&mut session);
let (app, mut session_rx) = ChatApp::new(session, controller_event_rx)
.await
.expect("chat app");
session_rx.close();
app
}
fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String { fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String {
let backend = TestBackend::new(width, height); let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("terminal"); let mut terminal = Terminal::new(backend).expect("terminal");
@@ -263,3 +158,58 @@ async fn render_command_palette_focus_snapshot() {
assert_snapshot!("command_palette_focus", snapshot); assert_snapshot!("command_palette_focus", snapshot);
}); });
} }
#[tokio::test(flavor = "multi_thread")]
async fn render_guidance_onboarding_snapshot() {
let mut app = build_chat_app(
|cfg| {
cfg.ui.show_onboarding = true;
cfg.ui.guidance.coach_marks_complete = false;
},
|_| {},
)
.await;
with_settings!({ snapshot_suffix => "step1-80x24" }, {
let snapshot = render_snapshot(&mut app, 80, 24);
assert_snapshot!("guidance_onboarding", snapshot);
});
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)))
.await
.expect("advance onboarding to step 2");
with_settings!({ snapshot_suffix => "step2-100x24" }, {
let snapshot = render_snapshot(&mut app, 100, 24);
assert_snapshot!("guidance_onboarding", snapshot);
});
}
#[tokio::test(flavor = "multi_thread")]
async fn render_guidance_cheatsheet_snapshot() {
let mut app = build_chat_app(|cfg| cfg.ui.guidance.coach_marks_complete = true, |_| {}).await;
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Char('?'),
KeyModifiers::NONE,
)))
.await
.expect("open guidance overlay");
with_settings!({ snapshot_suffix => "tab1-100x24" }, {
let snapshot = render_snapshot(&mut app, 100, 24);
assert_snapshot!("guidance_cheatsheet", snapshot);
});
app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)))
.await
.expect("advance guidance tab");
with_settings!({ snapshot_suffix => "tab2-100x24" }, {
let snapshot = render_snapshot(&mut app, 100, 24);
assert_snapshot!("guidance_cheatsheet", snapshot);
});
}

View File

@@ -0,0 +1,110 @@
use std::sync::Arc;
use async_trait::async_trait;
use owlen_core::{
Config, Mode, Provider,
session::SessionController,
storage::StorageManager,
types::Message,
ui::{NoOpUiController, UiController},
};
use owlen_tui::ChatApp;
use tempfile::tempdir;
use tokio::sync::mpsc;
struct StubProvider;
#[async_trait]
impl Provider for StubProvider {
fn name(&self) -> &str {
"stub-provider"
}
async fn list_models(&self) -> owlen_core::Result<Vec<owlen_core::types::ModelInfo>> {
Ok(vec![owlen_core::types::ModelInfo {
id: "stub-model".into(),
name: "Stub Model".into(),
description: Some("Stub model for golden snapshot tests".into()),
provider: self.name().into(),
context_window: Some(8_192),
capabilities: vec!["chat".into(), "tool-use".into()],
supports_tools: true,
}])
}
async fn send_prompt(
&self,
_request: owlen_core::types::ChatRequest,
) -> owlen_core::Result<owlen_core::types::ChatResponse> {
Ok(owlen_core::types::ChatResponse {
message: Message::assistant("stub completion".into()),
usage: None,
is_streaming: false,
is_final: true,
})
}
async fn stream_prompt(
&self,
_request: owlen_core::types::ChatRequest,
) -> owlen_core::Result<owlen_core::ChatStream> {
Ok(Box::pin(futures_util::stream::empty()))
}
async fn health_check(&self) -> owlen_core::Result<()> {
Ok(())
}
fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) {
self
}
}
pub async fn build_chat_app<C, F>(configure_config: C, configure_session: F) -> ChatApp
where
C: FnOnce(&mut Config),
F: FnOnce(&mut SessionController),
{
let temp_dir = tempdir().expect("temp dir");
let storage = StorageManager::with_database_path(temp_dir.path().join("owlen-tui-tests.db"))
.await
.expect("storage");
let storage = Arc::new(storage);
let mut config = Config::default();
config.general.default_model = Some("stub-model".into());
config.general.enable_streaming = true;
config.privacy.encrypt_local_data = false;
config.privacy.require_consent_per_session = false;
config.ui.show_onboarding = false;
config.ui.show_timestamps = false;
configure_config(&mut config);
let provider: Arc<dyn Provider> = Arc::new(StubProvider);
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
let (event_tx, controller_event_rx) = mpsc::unbounded_channel();
let mut session = SessionController::new(
Arc::clone(&provider),
config,
Arc::clone(&storage),
ui,
true,
Some(event_tx),
)
.await
.expect("session controller");
session
.set_operating_mode(Mode::Chat)
.await
.expect("chat mode");
configure_session(&mut session);
let (app, mut session_rx) = ChatApp::new(session, controller_event_rx)
.await
.expect("chat app");
session_rx.close();
app
}

View File

@@ -0,0 +1,84 @@
mod common;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use owlen_core::config::Config;
use owlen_tui::events::Event;
use tempfile::tempdir;
use common::build_chat_app;
struct XdgConfigGuard {
previous: Option<std::ffi::OsString>,
}
impl XdgConfigGuard {
fn set(path: &std::path::Path) -> Self {
let previous = std::env::var_os("XDG_CONFIG_HOME");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", path);
}
Self { previous }
}
}
impl Drop for XdgConfigGuard {
fn drop(&mut self) {
if let Some(prev) = self.previous.take() {
unsafe {
std::env::set_var("XDG_CONFIG_HOME", prev);
}
} else {
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn onboarding_completion_persists_config() {
let temp_dir = tempdir().expect("temp config dir");
let _guard = XdgConfigGuard::set(temp_dir.path());
let mut app = build_chat_app(
|cfg| {
cfg.ui.show_onboarding = true;
cfg.ui.guidance.coach_marks_complete = false;
},
|_| {},
)
.await;
for _ in 0..3 {
app.handle_event(Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
)))
.await
.expect("advance onboarding");
}
assert!(
app.coach_marks_complete(),
"coach marks flag should be recorded in memory"
);
drop(app);
let persisted_path = temp_dir.path().join("owlen").join("config.toml");
assert!(
persisted_path.exists(),
"expected persisted config at {:?}",
persisted_path
);
let persisted = Config::load(Some(&persisted_path)).expect("load persisted config snapshot");
assert!(
!persisted.ui.show_onboarding,
"onboarding flag should be false in persisted config"
);
assert!(
persisted.ui.guidance.coach_marks_complete,
"coach marks flag should be true in persisted config"
);
}

View File

@@ -0,0 +1,28 @@
---
source: crates/owlen-tui/tests/chat_snapshots.rs
expression: snapshot
---
" "
" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model "
" "
" Context metrics not available Cloud usage pending "
" "
" Focus & Modes │ Leader Actions │ Search & Commands "
" ▌ Chat · st "
" "
" No messag "
" Active keymap · Vim "
" Leader key · Space "
" "
" Files panel → Ctrl+1 / Space f 1 "
" Chat timeline → Ctrl+2 / Space f 2 "
" Input Pr Thinking panel → Ctrl+4 / Space f 4 "
" Code view → Ctrl+3 / Space f 3 "
" Input editor → Ctrl+5 / Space f 5 "
" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close "
" "
" "
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
" "
" "
" "

View File

@@ -0,0 +1,28 @@
---
source: crates/owlen-tui/tests/chat_snapshots.rs
expression: snapshot
---
" "
" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model "
" "
" Context metrics not available Cloud usage pending "
" "
" Focus & Modes │ Leader Actions │ Search & Commands "
" ▌ Chat · st "
" "
" No messag "
" Model & provider "
" Model picker → m / Space m "
" Command palette → Ctrl+P / Space t "
" Switch provider → Space p "
" Command mode → Ctrl+; / Space : "
" Input Pr "
" Layout "
" Split horizontal → Ctrl+W S / Space l s "
" System/Sta Tab/→:Next Shift+Tab/←:Prev 1-3:Jump Esc:Close "
" "
" "
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
" "
" "
" "

View File

@@ -0,0 +1,28 @@
---
source: crates/owlen-tui/tests/chat_snapshots.rs
expression: snapshot
---
" "
" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model "
" Context metrics not available Cloud usage pending "
" "
" ▌ Chat · cus "
" Getting started · Step 1 of 3 Focus & movement (Vim) "
" No mess "
" "
" "
" Focus shortcuts "
" Chat timeline → Ctrl+2 / Space f 2 "
" Input editor → Ctrl+5 / Space f 5 "
" Files panel → Ctrl+1 / Space f 1 "
" Thinking panel → Ctrl+4 / Space f 4 "
" Input Code view → Ctrl+3 / Space f 3 "
" Tab / Shift+Tab → cycle panels forward/backward "
" Esc → return to Normal mode "
" System/S Enter/→ Next Esc Skip "
" "
" Normal F1/? | "
" "
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model "
" "
" "

View File

@@ -0,0 +1,28 @@
---
source: crates/owlen-tui/tests/chat_snapshots.rs
expression: snapshot
---
" "
" 🦉 OWLEN v0.2.0 · Mode Chat · Focus Input ollama_local · stub-model "
" "
" Context metrics not available Cloud usage pending "
" "
" Getting started · Step 2 of 3 Leader actions (leader = Space) "
" ▌ Chat · st "
" "
" No messag "
" Model & provider "
" Model picker → m / Space m "
" Command palette → Ctrl+P / Space t "
" Switch provider → Space p "
" Command mode → Ctrl+; / Space : "
" Input Pr "
" Layout "
" Split horizontal → Ctrl+W S / Space l s "
" System/Sta Enter/→ Next Shift+Tab/← Back Esc Skip "
" "
" "
" HELP │ CHAT │ INPUT owlen-tui · 1:1 · UTF-8 ollama_local ▸ stub-model · LSP:✓ "
" "
" "
" "