feat(guidance): inline cheat-sheets & onboarding
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1822,6 +1822,8 @@ pub struct UiSettings {
|
||||
pub layers: LayerSettings,
|
||||
#[serde(default)]
|
||||
pub animations: AnimationSettings,
|
||||
#[serde(default)]
|
||||
pub guidance: GuidanceSettings,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct LayerSettings {
|
||||
#[serde(default = "LayerSettings::default_shadow_elevation")]
|
||||
@@ -2095,6 +2117,7 @@ impl Default for UiSettings {
|
||||
accessibility: AccessibilitySettings::default(),
|
||||
layers: LayerSettings::default(),
|
||||
animations: AnimationSettings::default(),
|
||||
guidance: GuidanceSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,19 +56,20 @@ use crate::model_info_panel::ModelInfoPanel;
|
||||
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
||||
use crate::state::{
|
||||
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
|
||||
FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapOverrides, KeymapProfile,
|
||||
KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest,
|
||||
RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState,
|
||||
WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, spawn_symbol_search_task,
|
||||
FileNode, FileTreeState, Keymap, KeymapBindingDescription, KeymapEventResult, KeymapOverrides,
|
||||
KeymapProfile, KeymapState, 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_token_short, format_tool_output};
|
||||
use crate::widgets::model_picker::FilterMode;
|
||||
use crate::{commands, highlight};
|
||||
use owlen_core::config::{
|
||||
AnimationSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV,
|
||||
LayerSettings, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY,
|
||||
OLLAMA_MODE_KEY,
|
||||
AnimationSettings, GuidanceSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV,
|
||||
LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, LayerSettings, OLLAMA_API_KEY_ENV,
|
||||
OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
|
||||
};
|
||||
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
||||
// 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_SYSTEM_STATUS: &str =
|
||||
"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;
|
||||
|
||||
@@ -271,6 +273,12 @@ impl PaneAnimations {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GuidanceOverlay {
|
||||
CheatSheet,
|
||||
Onboarding,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct LayoutSnapshot {
|
||||
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 {
|
||||
controller: SessionController,
|
||||
@@ -797,6 +805,9 @@ pub struct ChatApp {
|
||||
active_layout: AdaptiveLayout,
|
||||
gauge_animations: GaugeAnimations,
|
||||
pane_animations: PaneAnimations,
|
||||
guidance_overlay: GuidanceOverlay,
|
||||
onboarding_step: usize,
|
||||
guidance_settings: GuidanceSettings,
|
||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||
_execution_budget: usize,
|
||||
/// Agent mode enabled
|
||||
@@ -962,6 +973,7 @@ impl ChatApp {
|
||||
let accessibility = config_guard.ui.accessibility.clone();
|
||||
let layer_settings = config_guard.ui.layers.clone();
|
||||
let animation_settings = config_guard.ui.animations.clone();
|
||||
let guidance_settings = config_guard.ui.guidance.clone();
|
||||
drop(config_guard);
|
||||
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
|
||||
let keymap = {
|
||||
@@ -1000,7 +1012,11 @@ impl ChatApp {
|
||||
|
||||
let mut app = Self {
|
||||
controller,
|
||||
mode: InputMode::Normal,
|
||||
mode: if show_onboarding {
|
||||
InputMode::Help
|
||||
} else {
|
||||
InputMode::Normal
|
||||
},
|
||||
mode_flash_until: None,
|
||||
status: if show_onboarding {
|
||||
ONBOARDING_STATUS_LINE.to_string()
|
||||
@@ -1114,6 +1130,13 @@ impl ChatApp {
|
||||
active_layout: AdaptiveLayout::default(),
|
||||
gauge_animations: GaugeAnimations::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;
|
||||
@@ -1133,16 +1156,6 @@ impl ChatApp {
|
||||
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?;
|
||||
|
||||
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) {
|
||||
let context_window = self
|
||||
.active_context_window()
|
||||
@@ -2176,6 +2209,7 @@ impl ChatApp {
|
||||
}
|
||||
|
||||
pub fn show_tutorial(&mut self) {
|
||||
self.open_guidance_overlay(GuidanceOverlay::Onboarding);
|
||||
self.error = None;
|
||||
self.status = TUTORIAL_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 }));
|
||||
}
|
||||
|
||||
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 {
|
||||
self.mode_flash_until
|
||||
.map(|deadline| Instant::now() < deadline)
|
||||
@@ -6064,13 +6176,17 @@ impl ChatApp {
|
||||
|
||||
if matches!(key.code, KeyCode::F(1)) {
|
||||
if matches!(self.mode, InputMode::Help) {
|
||||
self.set_input_mode(InputMode::Normal);
|
||||
self.help_tab_index = 0;
|
||||
self.reset_status();
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
self.set_input_mode(InputMode::Help);
|
||||
self.status = "Help".to_string();
|
||||
self.error = None;
|
||||
self.open_guidance_overlay(GuidanceOverlay::CheatSheet);
|
||||
}
|
||||
return Ok(AppState::Running);
|
||||
}
|
||||
@@ -6114,9 +6230,24 @@ impl ChatApp {
|
||||
return Ok(AppState::Running);
|
||||
}
|
||||
|
||||
if is_question_mark && matches!(self.mode, InputMode::Normal) {
|
||||
self.set_input_mode(InputMode::Help);
|
||||
self.status = "Help".to_string();
|
||||
if is_question_mark {
|
||||
match self.mode {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -8676,32 +8807,51 @@ impl ChatApp {
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
InputMode::Help => match key.code {
|
||||
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();
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
// Next tab
|
||||
if self.help_tab_index + 1 < HELP_TAB_COUNT {
|
||||
self.help_tab_index += 1;
|
||||
InputMode::Help => match self.guidance_overlay {
|
||||
GuidanceOverlay::Onboarding => match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::F(1) => {
|
||||
self.finish_onboarding(false);
|
||||
}
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
// Previous tab
|
||||
if self.help_tab_index > 0 {
|
||||
self.help_tab_index -= 1;
|
||||
KeyCode::Enter
|
||||
| KeyCode::Char(' ')
|
||||
| KeyCode::Right
|
||||
| KeyCode::Char('l')
|
||||
| KeyCode::Tab => {
|
||||
self.advance_onboarding_step();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) if ch.is_ascii_digit() => {
|
||||
if let Some(idx) = ch.to_digit(10) {
|
||||
if idx >= 1 && (idx as usize) <= HELP_TAB_COUNT {
|
||||
self.help_tab_index = (idx - 1) as usize;
|
||||
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) => {
|
||||
self.reset_status();
|
||||
self.set_input_mode(InputMode::Normal);
|
||||
}
|
||||
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
||||
if HELP_TAB_COUNT > 0 {
|
||||
if self.help_tab_index + 1 < HELP_TAB_COUNT {
|
||||
self.help_tab_index += 1;
|
||||
} else {
|
||||
self.help_tab_index = HELP_TAB_COUNT - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
||||
if self.help_tab_index > 0 {
|
||||
self.help_tab_index -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) if ch.is_ascii_digit() => {
|
||||
if let Some(idx) = ch.to_digit(10) {
|
||||
if idx >= 1 && (idx as usize) <= HELP_TAB_COUNT {
|
||||
self.help_tab_index = (idx - 1) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
},
|
||||
InputMode::SessionBrowser => match key.code {
|
||||
KeyCode::Esc => {
|
||||
|
||||
@@ -179,6 +179,14 @@ pub fn parse(input: &str) -> Result<Option<SlashCommand>, SlashError> {
|
||||
mod tests {
|
||||
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]
|
||||
fn ignores_non_command_input() {
|
||||
let result = parse("hello world").unwrap();
|
||||
@@ -202,6 +210,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parses_registered_mcp_command() {
|
||||
let _registry = registry_guard();
|
||||
set_mcp_commands(Vec::new());
|
||||
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
|
||||
|
||||
@@ -219,6 +228,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_mcp_command_with_arguments() {
|
||||
let _registry = registry_guard();
|
||||
set_mcp_commands(Vec::new());
|
||||
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use insta::{assert_snapshot, with_settings};
|
||||
use owlen_core::{
|
||||
Config, Mode, Provider,
|
||||
session::SessionController,
|
||||
storage::StorageManager,
|
||||
types::{Message, ToolCall},
|
||||
ui::{NoOpUiController, UiController},
|
||||
};
|
||||
use owlen_core::types::{Message, ToolCall};
|
||||
use owlen_tui::ChatApp;
|
||||
use owlen_tui::events::Event;
|
||||
use owlen_tui::ui::render_chat;
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
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(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
|
||||
}
|
||||
}
|
||||
use common::build_chat_app;
|
||||
|
||||
fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
||||
let mut output = String::new();
|
||||
@@ -80,56 +25,6 @@ fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
||||
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 {
|
||||
let backend = TestBackend::new(width, height);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[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);
|
||||
});
|
||||
}
|
||||
|
||||
110
crates/owlen-tui/tests/common/mod.rs
Normal file
110
crates/owlen-tui/tests/common/mod.rs
Normal 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
|
||||
}
|
||||
84
crates/owlen-tui/tests/guidance_persistence.rs
Normal file
84
crates/owlen-tui/tests/guidance_persistence.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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:✓ "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -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:✓ "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
@@ -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:✓ "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
Reference in New Issue
Block a user