diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb9d14..1c18868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/crates/owlen-core/src/config.rs b/crates/owlen-core/src/config.rs index c0f9970..6e2447c 100644 --- a/crates/owlen-core/src/config.rs +++ b/crates/owlen-core/src/config.rs @@ -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(), } } } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 128f8a2..e4e6c8c 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -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 { + 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 => { diff --git a/crates/owlen-tui/src/slash.rs b/crates/owlen-tui/src/slash.rs index 3112344..022b28e 100644 --- a/crates/owlen-tui/src/slash.rs +++ b/crates/owlen-tui/src/slash.rs @@ -179,6 +179,14 @@ pub fn parse(input: &str) -> Result, SlashError> { mod tests { use super::*; + fn registry_guard() -> std::sync::MutexGuard<'static, ()> { + static GUARD: std::sync::OnceLock> = 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)]); diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 0ce728e..a440a60 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -14,14 +14,14 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::chat_app::{ - AdaptiveLayout, ChatApp, ContextUsage, GaugeKey, HELP_TAB_COUNT, LayoutSnapshot, + AdaptiveLayout, ChatApp, ContextUsage, GaugeKey, GuidanceOverlay, LayoutSnapshot, MIN_MESSAGE_CARD_WIDTH, MessageRenderContext, PanePulse, }; use crate::glass::{GlassPalette, blend_color, gradient_color}; use crate::highlight; use crate::state::{ - CodePane, EditorTab, FileFilterMode, FileNode, KeymapProfile, LayoutNode, PaletteGroup, PaneId, - RepoSearchRowKind, SplitAxis, VisibleFileEntry, + CodePane, EditorTab, FileFilterMode, FileNode, KeymapBindingDescription, KeymapProfile, + LayoutNode, PaletteGroup, PaneId, RepoSearchRowKind, SplitAxis, VisibleFileEntry, }; use crate::toast::{Toast, ToastLevel}; use crate::widgets::model_picker::render_model_picker; @@ -31,7 +31,6 @@ use owlen_core::usage::{UsageBand, UsageSnapshot, UsageWindow}; use owlen_core::{config::LayerSettings, theme::Theme}; use textwrap::wrap; -const PRIVACY_TAB_INDEX: usize = HELP_TAB_COUNT - 1; const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -4140,79 +4139,637 @@ fn render_consent_dialog(frame: &mut Frame<'_>, app: &ChatApp) { frame.render_widget(paragraph, area); } -fn render_privacy_settings(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { - let theme = app.theme(); - let reduced = app.is_reduced_chrome(); - let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - let config = app.config(); - if area.width == 0 || area.height == 0 { - return; - } - - frame.render_widget(Clear, area); - - let remote_search_enabled = - config.privacy.enable_remote_search && config.tools.web_search.enabled; - let code_exec_enabled = config.tools.code_exec.enabled; - let history_days = config.privacy.retain_history_days; - let cache_results = config.privacy.cache_web_results; - let consent_required = config.privacy.require_consent_per_session; - let encryption_enabled = config.privacy.encrypt_local_data; - - let status_line = |label: &str, enabled: bool| { - let status_text = if enabled { "Enabled" } else { "Disabled" }; - let status_style = if enabled { - Style::default().fg(theme.selection_fg) - } else { - Style::default().fg(theme.error) - }; - Line::from(vec![ - Span::raw(format!(" {label}: ")), - Span::styled(status_text, status_style), +fn render_guidance_onboarding( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: GlassPalette, + theme: &Theme, +) { + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(2), ]) - }; + .split(area); - let mut lines = Vec::new(); - lines.push(Line::from(vec![Span::styled( - "Privacy Configuration", - Style::default().fg(theme.info).add_modifier(Modifier::BOLD), - )])); - lines.push(Line::raw("")); - lines.push(Line::from("Network Access:")); - lines.push(status_line("Web Search", remote_search_enabled)); - lines.push(status_line("Code Execution", code_exec_enabled)); - lines.push(Line::raw("")); - lines.push(Line::from("Data Retention:")); - lines.push(Line::from(format!( - " History retention: {} day(s)", - history_days - ))); - lines.push(Line::from(format!( - " Cache web results: {}", - if cache_results { "Yes" } else { "No" } - ))); - lines.push(Line::raw("")); - lines.push(Line::from("Safeguards:")); - lines.push(status_line("Consent required", consent_required)); - lines.push(status_line("Encrypted storage", encryption_enabled)); - lines.push(Line::raw("")); - lines.push(Line::from("Commands:")); - lines.push(Line::from(" :privacy-enable - Enable tool")); - lines.push(Line::from(" :privacy-disable - Disable tool")); - lines.push(Line::from(" :privacy-clear - Clear all data")); + let step = app.onboarding_step(); + let total = app.onboarding_step_count(); + let bindings = app.keymap_bindings(); + let leader = app.keymap_leader().to_string(); + let (title, body_lines) = onboarding_section(app, theme, &bindings, &leader); - let paragraph = Paragraph::new(lines) - .wrap(Wrap { trim: true }) - .style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_widget(paragraph, area); + let header = Paragraph::new(Line::from(vec![ + Span::styled( + format!("Getting started · Step {} of {}", step + 1, total), + Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + title, + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + ])) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .alignment(Alignment::Center); + frame.render_widget(header, layout[0]); + + let content = Paragraph::new(body_lines) + .style(Style::default().bg(palette.active).fg(palette.label)) + .wrap(Wrap { trim: true }); + frame.render_widget(content, layout[1]); + + let mut footer_spans = vec![ + Span::raw(" "), + Span::styled( + "Enter", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + Span::raw("/"), + Span::styled( + "→", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" Next "), + ]; + if step > 0 { + footer_spans.push(Span::styled( + "Shift+Tab/←", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + )); + footer_spans.push(Span::raw(" Back ")); + } + footer_spans.push(Span::styled( + "Esc", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + )); + footer_spans.push(Span::raw(" Skip")); + + let footer = Paragraph::new(Line::from(footer_spans)) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .alignment(Alignment::Center); + frame.render_widget(footer, layout[2]); } +fn onboarding_section( + app: &ChatApp, + theme: &Theme, + bindings: &[KeymapBindingDescription], + leader: &str, +) -> (String, Vec>) { + let profile = app.current_keymap_profile(); + let profile_label = match profile { + KeymapProfile::Vim => "Vim", + KeymapProfile::Emacs => "Emacs", + KeymapProfile::Custom => "Custom", + }; + let step = app.onboarding_step(); + + let mut lines = Vec::new(); + + match step { + 0 => { + let title = format!("Focus & movement ({profile_label})"); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Focus shortcuts", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + for (label, command) in [ + ("Chat timeline", "focus.chat"), + ("Input editor", "focus.input"), + ("Files panel", "focus.files"), + ("Thinking panel", "focus.thinking"), + ("Code view", "focus.code"), + ] { + if let Some(line) = binding_line( + label, + binding_pair_string(bindings, command, Some(InputMode::Normal), leader), + theme, + ) { + lines.push(line); + } + } + lines.push(Line::from( + " Tab / Shift+Tab → cycle panels forward/backward", + )); + lines.push(Line::from(" Esc → return to Normal mode")); + lines.push(Line::from(" g g / Shift+G → jump to top / bottom")); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + "Tip", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.user_message_role), + ), + Span::raw(": press Ctrl/Alt+5 to jump back to the input field."), + ])); + (title, lines) + } + 1 => { + let title = format!("Leader actions (leader = {leader})"); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Model & provider", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + for (label, command) in [ + ("Model picker", "model.open_all"), + ("Command palette", "palette.open"), + ("Switch provider", "provider.switch"), + ("Command mode", "mode.command"), + ] { + if let Some(line) = binding_line( + label, + binding_pair_string(bindings, command, Some(InputMode::Normal), leader), + theme, + ) { + lines.push(line); + } + } + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Layout", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + for (label, command) in [ + ("Split horizontal", "workspace.split_horizontal"), + ("Split vertical", "workspace.split_vertical"), + ("Focus left", "workspace.focus_left"), + ("Focus right", "workspace.focus_right"), + ("Focus up", "workspace.focus_up"), + ("Focus down", "workspace.focus_down"), + ] { + if let Some(line) = binding_line( + label, + binding_pair_string(bindings, command, Some(InputMode::Normal), leader), + theme, + ) { + lines.push(line); + } + } + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + "Tip", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.user_message_role), + ), + Span::raw(": use :keymap show to inspect every mapped chord."), + ])); + (title, lines) + } + _ => { + let title = "Search & next steps".to_string(); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Search shortcuts", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + lines.push(Line::from(" Ctrl+Shift+F → project search (ripgrep)")); + lines.push(Line::from(" Ctrl+Shift+P → symbol search across files")); + lines.push(Line::from( + " Ctrl+Shift+R → reveal active file in the tree", + )); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Commands", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + lines.push(Line::from( + " :tutorial → replay onboarding coach marks", + )); + lines.push(Line::from(" :keymap show → print the active bindings")); + lines.push(Line::from(" :limits → refresh usage & quotas")); + lines.push(Line::from( + " :privacy-enable / :privacy-disable ", + )); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + "Reminder", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.user_message_role), + ), + Span::raw(": press ? anytime for the cheat sheet."), + ])); + (title, lines) + } + } +} + +fn render_guidance_cheatsheet( + frame: &mut Frame<'_>, + area: Rect, + app: &ChatApp, + palette: GlassPalette, + theme: &Theme, +) { + let bindings = app.keymap_bindings(); + let leader = app.keymap_leader().to_string(); + let sections = build_cheatsheet_sections(app, theme, &bindings, &leader); + let tabs = ["Focus & Modes", "Leader Actions", "Search & Commands"]; + let tab_index = app.help_tab_index().min(tabs.len().saturating_sub(1)); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(2), + ]) + .split(area); + + let mut tab_spans = Vec::new(); + for (index, title) in tabs.iter().enumerate() { + if index == tab_index { + tab_spans.push(Span::styled( + format!(" {} ", title), + Style::default() + .fg(theme.selection_fg) + .bg(theme.selection_bg) + .add_modifier(Modifier::BOLD), + )); + } else { + tab_spans.push(Span::styled( + format!(" {} ", title), + Style::default().fg(palette.label), + )); + } + if index < tabs.len() - 1 { + tab_spans.push(Span::raw(" │ ")); + } + } + let tabs_para = Paragraph::new(Line::from(tab_spans)) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .alignment(Alignment::Center); + frame.render_widget(tabs_para, layout[0]); + + let content = sections.get(tab_index).cloned().unwrap_or_default(); + let content_para = Paragraph::new(content) + .style(Style::default().bg(palette.active).fg(palette.label)) + .wrap(Wrap { trim: true }); + frame.render_widget(content_para, layout[1]); + + let nav_hint = Line::from(vec![ + Span::raw(" "), + Span::styled( + "Tab/→", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + Span::raw(":Next "), + Span::styled( + "Shift+Tab/←", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + Span::raw(":Prev "), + Span::styled( + format!("1-{}", tabs.len()), + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + Span::raw(":Jump "), + Span::styled( + "Esc", + Style::default() + .fg(palette.label) + .add_modifier(Modifier::BOLD), + ), + Span::raw(":Close"), + ]); + let nav_para = Paragraph::new(nav_hint) + .style(Style::default().bg(palette.highlight).fg(palette.label)) + .alignment(Alignment::Center); + frame.render_widget(nav_para, layout[2]); +} + +fn build_cheatsheet_sections( + app: &ChatApp, + theme: &Theme, + bindings: &[KeymapBindingDescription], + leader: &str, +) -> Vec>> { + let profile = app.current_keymap_profile(); + let profile_label = match profile { + KeymapProfile::Vim => "Vim", + KeymapProfile::Emacs => "Emacs", + KeymapProfile::Custom => "Custom", + }; + + let mut focus = Vec::new(); + focus.push(Line::from("")); + focus.push(Line::from(vec![Span::styled( + format!("Active keymap · {profile_label}"), + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + focus.push(Line::from(vec![Span::styled( + format!("Leader key · {leader}"), + Style::default().fg(theme.placeholder), + )])); + focus.push(Line::from("")); + for (label, command) in [ + ("Files panel", "focus.files"), + ("Chat timeline", "focus.chat"), + ("Thinking panel", "focus.thinking"), + ("Code view", "focus.code"), + ("Input editor", "focus.input"), + ] { + if let Some(line) = binding_line( + label, + binding_pair_string(bindings, command, Some(InputMode::Normal), leader), + theme, + ) { + focus.push(line); + } + } + focus.push(Line::from( + " Tab / Shift+Tab → cycle panels forward/backward", + )); + focus.push(Line::from(" Esc → return to Normal mode")); + focus.push(Line::from(" g g / Shift+G → jump to top / bottom")); + if let Some(line) = binding_line( + "Send message", + binding_primary_string( + bindings, + "composer.submit", + Some(InputMode::Editing), + leader, + ), + theme, + ) { + focus.push(Line::from("")); + focus.push(Line::from(vec![Span::styled( + "Editing", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + focus.push(line); + } + + let mut leader_lines = Vec::new(); + leader_lines.push(Line::from("")); + leader_lines.push(Line::from(vec![Span::styled( + "Model & provider", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + for (label, command) in [ + ("Model picker", "model.open_all"), + ("Command palette", "palette.open"), + ("Switch provider", "provider.switch"), + ("Command mode", "mode.command"), + ] { + if let Some(line) = binding_line( + label, + binding_pair_string(bindings, command, Some(InputMode::Normal), leader), + theme, + ) { + leader_lines.push(line); + } + } + leader_lines.push(Line::from("")); + leader_lines.push(Line::from(vec![Span::styled( + "Layout", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + for (label, command) in [ + ("Split horizontal", "workspace.split_horizontal"), + ("Split vertical", "workspace.split_vertical"), + ("Focus left", "workspace.focus_left"), + ("Focus right", "workspace.focus_right"), + ("Focus up", "workspace.focus_up"), + ("Focus down", "workspace.focus_down"), + ] { + if let Some(line) = binding_line( + label, + binding_pair_string(bindings, command, Some(InputMode::Normal), leader), + theme, + ) { + leader_lines.push(line); + } + } + + let mut search_lines = vec![ + Line::from(""), + Line::from(vec![Span::styled( + "Search shortcuts", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(" Ctrl+Shift+F → project search (ripgrep)"), + Line::from(" Ctrl+Shift+P → symbol search across files"), + Line::from(" Ctrl+Shift+R → reveal active file in the tree"), + Line::from(""), + Line::from(vec![Span::styled( + "Slash commands", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(" :tutorial → replay onboarding coach marks"), + Line::from(" :keymap show → print the active bindings"), + Line::from(" :limits → refresh usage & quotas"), + Line::from(" :privacy-enable / :privacy-disable "), + Line::from(""), + ]; + + let privacy_snapshot = { + let cfg = app.config(); + ( + cfg.privacy.enable_remote_search && cfg.tools.web_search.enabled, + cfg.tools.code_exec.enabled, + cfg.privacy.cache_web_results, + cfg.privacy.require_consent_per_session, + cfg.privacy.encrypt_local_data, + cfg.privacy.retain_history_days, + ) + }; + let ( + remote_search_enabled, + code_exec_enabled, + cache_results, + consent_required, + encryption_enabled, + history_days, + ) = privacy_snapshot; + search_lines.push(Line::from(vec![Span::styled( + "Privacy overview", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )])); + search_lines.push(Line::from(format!( + " Web search → {}", + status_label(remote_search_enabled) + ))); + search_lines.push(Line::from(format!( + " Code execution → {}", + status_label(code_exec_enabled) + ))); + search_lines.push(Line::from(format!( + " Cache web results→ {}", + if cache_results { "Yes" } else { "No" } + ))); + search_lines.push(Line::from(format!( + " Consent required → {}", + status_label(consent_required) + ))); + search_lines.push(Line::from(format!( + " Encrypted storage→ {}", + status_label(encryption_enabled) + ))); + search_lines.push(Line::from(format!( + " History retention→ {} day(s)", + history_days + ))); + search_lines.push(Line::from("")); + search_lines.push(Line::from( + " :privacy-clear → clear encrypted data", + )); + search_lines.push(Line::from("")); + search_lines.push(Line::from(vec![ + Span::styled( + "Reminder", + Style::default() + .add_modifier(Modifier::BOLD) + .fg(theme.user_message_role), + ), + Span::raw(": press ? anytime to reopen this cheat sheet."), + ])); + + vec![focus, leader_lines, search_lines] +} + +fn binding_line(label: &str, binding: Option, theme: &Theme) -> Option> { + binding.map(|shortcut| { + Line::from(vec![ + Span::styled( + format!("{:<20}", label), + Style::default().fg(theme.text).add_modifier(Modifier::BOLD), + ), + Span::raw(" → "), + Span::styled(shortcut, Style::default().fg(theme.info)), + ]) + }) +} + +fn status_label(enabled: bool) -> &'static str { + if enabled { "Enabled" } else { "Disabled" } +} + +fn binding_pair_string( + bindings: &[KeymapBindingDescription], + command: &str, + preferred_mode: Option, + leader: &str, +) -> Option { + let (direct, leader_binding) = binding_variants(bindings, command, preferred_mode, leader); + match (direct, leader_binding) { + (Some(ref d), Some(ref l)) if d != l => Some(format!("{d} / {l}")), + (Some(value), _) => Some(value), + (_, Some(value)) => Some(value), + _ => None, + } +} + +fn binding_primary_string( + bindings: &[KeymapBindingDescription], + command: &str, + preferred_mode: Option, + leader: &str, +) -> Option { + let (direct, leader_binding) = binding_variants(bindings, command, preferred_mode, leader); + direct.or(leader_binding) +} + +fn binding_variants( + bindings: &[KeymapBindingDescription], + command: &str, + preferred_mode: Option, + leader: &str, +) -> (Option, Option) { + let mut candidates: Vec<&KeymapBindingDescription> = bindings + .iter() + .filter(|desc| desc.command == command) + .collect(); + if candidates.is_empty() { + return (None, None); + } + if let Some(mode) = preferred_mode { + candidates.sort_by_key(|desc| if desc.mode == mode { 0 } else { 1 }); + } + let mut direct = None; + let mut leader_binding = None; + for desc in &candidates { + let seq = format_sequence(&desc.sequence); + let starts_with_leader = desc + .sequence + .first() + .map(|token| token.eq_ignore_ascii_case(leader)) + .unwrap_or(false); + if starts_with_leader { + if leader_binding.is_none() { + leader_binding = Some(seq.clone()); + } + } else if direct.is_none() { + direct = Some(seq.clone()); + } + if direct.is_some() && leader_binding.is_some() { + break; + } + } + if direct.is_none() { + if let Some(desc) = candidates.first() { + let seq = format_sequence(&desc.sequence); + if !desc + .sequence + .first() + .map(|token| token.eq_ignore_ascii_case(leader)) + .unwrap_or(false) + { + direct = Some(seq.clone()); + } + } + } + if leader_binding.is_none() { + if let Some(desc) = candidates.iter().find(|candidate| { + candidate + .sequence + .first() + .map(|token| token.eq_ignore_ascii_case(leader)) + .unwrap_or(false) + }) { + leader_binding = Some(format_sequence(&desc.sequence)); + } + } + (direct, leader_binding) +} + +fn format_sequence(sequence: &[String]) -> String { + sequence.join(" ") +} + +#[allow(unreachable_code)] fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let reduced = app.is_reduced_chrome(); let palette = GlassPalette::for_theme_with_mode(theme, reduced, app.layer_settings()); - let profile = app.current_keymap_profile(); - let leader = app.keymap_leader(); let area = centered_rect(75, 70, frame.area()); if area.width == 0 || area.height == 0 { return; @@ -4249,694 +4806,12 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { return; } - let tab_index = app.help_tab_index(); - let tabs = [ - "Navigation", - "Editing", - "Visual", - "Commands", - "Sessions", - "Browsers", - "Privacy", - ]; - - // Build tab line - let mut tab_spans = Vec::new(); - for (i, tab_name) in tabs.iter().enumerate() { - if i == tab_index { - tab_spans.push(Span::styled( - format!(" {} ", tab_name), - Style::default() - .fg(theme.selection_fg) - .bg(theme.selection_bg) - .add_modifier(Modifier::BOLD), - )); - } else { - tab_spans.push(Span::styled( - format!(" {} ", tab_name), - Style::default().fg(theme.placeholder), - )); - } - if i < tabs.len() - 1 { - tab_spans.push(Span::raw(" │ ")); - } - } - - let mut help_text = match tab_index { - 0 => { - let mut lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - "PANEL FOCUS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - ]; - - match profile { - KeymapProfile::Emacs => { - lines.push(Line::from( - " Alt+1 → focus Files (opens when available)", - )); - lines.push(Line::from(" Alt+2 → focus Chat timeline")); - lines.push(Line::from( - " Alt+3 → focus Code view (requires open file)", - )); - lines.push(Line::from( - " Alt+4 → focus Thinking / Agent Actions", - )); - lines.push(Line::from(" Alt+5 → focus Input editor")); - lines.push(Line::from( - " Alt+O → cycle panels forward (Tab also works)", - )); - lines.push(Line::from(" Alt+Shift+O → cycle panels backward")); - lines.push(Line::from(" Ctrl+X Ctrl+F → expand Files panel")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "WINDOW & PROVIDER", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(" Ctrl+X 2 → split horizontal")); - lines.push(Line::from(" Ctrl+X 3 → split vertical")); - lines.push(Line::from(" Ctrl+X o → focus next window")); - lines.push(Line::from(" Ctrl+X Shift+O → focus previous window")); - lines.push(Line::from(" Ctrl+X Ctrl+P → open provider switcher")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "JUMPS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(" Alt+G g → jump to top")); - lines.push(Line::from(" Alt+G Shift+G → jump to bottom")); - } - _ => { - lines.push(Line::from( - " Ctrl/Alt+1 → focus Files (opens when available)", - )); - lines.push(Line::from(" Ctrl/Alt+2 → focus Chat timeline")); - lines.push(Line::from( - " Ctrl/Alt+3 → focus Code view (requires open file)", - )); - lines.push(Line::from( - " Ctrl/Alt+4 → focus Thinking / Agent Actions", - )); - lines.push(Line::from(" Ctrl/Alt+5 → focus Input editor")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - format!("LEADER ({leader})"), - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(format!( - " {leader} m → open model picker" - ))); - lines.push(Line::from(format!( - " {leader} m l → focus local models" - ))); - lines.push(Line::from(format!( - " {leader} m c → focus cloud models" - ))); - lines.push(Line::from(format!( - " {leader} p → provider switcher" - ))); - lines.push(Line::from(format!( - " {leader} t → open command palette" - ))); - lines.push(Line::from(format!( - " {leader} l s → split horizontal" - ))); - lines.push(Line::from(format!(" {leader} l v → split vertical"))); - lines.push(Line::from(format!( - " {leader} l h/j/k/l → focus left/down/up/right" - ))); - } - } - - lines.push(Line::from( - " Tab / Shift+Tab → cycle panels forward/backward", - )); - if matches!(profile, KeymapProfile::Vim) { - lines.push(Line::from( - " g then t → expand files panel and focus it", - )); - } - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "VISIBLE CUES", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from( - " ▌ beacon highlights the active row; brighter when focused", - )); - lines.push(Line::from( - " Status bar shows MODE · workspace · focus target + shortcut", - )); - lines.push(Line::from( - " Agent badge flips between 🤖 RUN and 🤖 ARM when automation changes", - )); - lines.push(Line::from( - " Use :themes to swap palettes—default_dark is tuned for contrast", - )); - lines.push(Line::from( - " :accessibility cycles high-contrast and reduced chrome presets", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "LAYOUT CONTROLS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(" Ctrl+←/→ → resize files panel")); - lines.push(Line::from(" Ctrl+↑/↓ → adjust chat ↔ thinking split")); - lines.push(Line::from(" Alt+←/→/↑/↓ → resize focused code pane")); - lines.push(Line::from(" F12 → toggle debug log panel")); - lines.push(Line::from(" F1 or ? → toggle this help overlay")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "SCROLLING & MOVEMENT", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(" h/← l/→ → move left/right by character")); - lines.push(Line::from(" j/↓ k/↑ → move down/up by line")); - lines.push(Line::from( - " w / e / b → jump by words (start / end / previous)", - )); - lines.push(Line::from( - " 0 / ^ / $ → line start / first non-blank / line end", - )); - lines.push(Line::from(" gg / G → jump to top / bottom")); - lines.push(Line::from(" Ctrl+d / Ctrl+u → half-page scroll down/up")); - lines.push(Line::from(" Ctrl+f / Ctrl+b → full-page scroll down/up")); - lines.push(Line::from(" PageUp / PageDown → full-page scroll")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "ACCESSIBILITY", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from( - " High-contrast defaults keep text legible in low-light terminals", - )); - lines.push(Line::from( - " Focus shortcuts avoid chords—great with screen readers", - )); - lines.push(Line::from( - " Thinking and Agent Actions share the Ctrl/Alt+4 focus key", - )); - lines - } - 1 => { - let send_line = match profile { - KeymapProfile::Emacs => { - " Ctrl+Enter or Ctrl+X Ctrl+S → send message (Enter inserts newline first)" - } - _ => " Enter → send message (slash commands run before send)", - }; - let newline_primary = match profile { - KeymapProfile::Emacs => { - " Enter → insert newline without leaving edit mode" - } - _ => " Shift+Enter → insert newline without leaving edit mode", - }; - let newline_secondary = match profile { - KeymapProfile::Emacs => " Shift+Enter → insert newline (alternate)", - _ => " Ctrl+J → insert newline (multiline compose)", - }; - let palette_binding: String = match profile { - KeymapProfile::Emacs => { - " Ctrl+Space → open command palette (also works in Normal)".to_string() - } - _ => { - format!(" Ctrl+P / {leader} t → open command palette (also works in Normal)") - } - }; - - let mut lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - "ENTERING EDIT MODE", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" i or Enter → focus input and begin editing at cursor"), - Line::from( - " a / A / I → append after cursor · append at end · insert at start", - ), - Line::from(" o / O → open new line below / above and edit"), - Line::from(" Ctrl/Alt+5 → jump to the input panel from any view"), - Line::from(""), - Line::from(vec![Span::styled( - "SENDING & NEWLINES", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(send_line), - Line::from(newline_primary), - Line::from(newline_secondary), - Line::from(" Esc / Ctrl+[ → return to normal mode"), - Line::from(""), - Line::from(vec![Span::styled( - "EDITING UTILITIES", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Ctrl+↑ / Ctrl+↓ → cycle input history"), - Line::from(" Ctrl+A / Ctrl+E → jump to start / end of line"), - Line::from(" Ctrl+W / Ctrl+B → move cursor by words forward/back"), - Line::from(palette_binding.clone()), - Line::from(" Ctrl+C → cancel streaming response and exit editing"), - Line::from(""), - ]; - - if matches!(profile, KeymapProfile::Emacs) { - lines.push(Line::from(vec![Span::styled( - "KILL RING", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(" Ctrl+Y → yank last kill into buffer")); - lines.push(Line::from(" Alt+W → copy previous word")); - lines.push(Line::from(" Ctrl+W → kill previous word")); - lines.push(Line::from(" Ctrl+K → kill to end of line")); - lines.push(Line::from("")); - } - - lines.push(Line::from(vec![Span::styled( - "NORMAL MODE SHORTCUTS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )])); - lines.push(Line::from(" dd → clear input buffer")); - lines.push(Line::from(" p → paste clipboard into input")); - lines.push(Line::from(palette_binding.clone())); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "TIPS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from( - " • Slash commands (e.g. :clear, :open) are parsed before sending", - )); - lines.push(Line::from( - " • After sending, focus returns to Normal—press i or Ctrl/Alt+5 to edit", - )); - - lines - } - 2 => vec![ - // Visual - Line::from(""), - Line::from(vec![Span::styled( - "VISUAL MODE", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" v → enter visual mode at cursor (from normal mode)"), - Line::from(" Ctrl/Alt+4 → focus Thinking/Agent panels before selecting"), - Line::from(" Ctrl/Alt+5 → return focus to Input after selection work"), - Line::from(""), - Line::from(vec![Span::styled( - "SELECTION MOVEMENT", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" h/j/k/l → extend selection left/down/up/right"), - Line::from(" w → extend to next word start"), - Line::from(" e → extend to word end"), - Line::from(" b → extend backward to previous word"), - Line::from(" 0 → extend to line start"), - Line::from(" ^ → extend to first non-blank"), - Line::from(" $ → extend to line end"), - Line::from(""), - Line::from(vec![Span::styled( - "VISUAL MODE OPERATIONS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" y → yank (copy) selection to clipboard"), - Line::from(" d / Delete → cut selection (yank from read-only panels)"), - Line::from(" v / Esc → exit visual mode"), - Line::from(""), - Line::from(vec![Span::styled( - "NOTES", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - Line::from(" • Visual mode works across all panels (Chat, Thinking, Input)"), - Line::from(" • Yanked text is available for paste with 'p' in normal mode"), - Line::from(" • Read-only panels (Chat/Thinking) always keep data intact; yank copies"), - ], - 3 => { - let mut lines = vec![ - Line::from(""), - Line::from(vec![Span::styled( - "COMMAND MODE", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Press ':' to enter command mode, then type one of:"), - ]; - - if matches!(profile, KeymapProfile::Emacs) { - lines.push(Line::from( - " Alt+x → enter command mode (Emacs M-x)", - )); - } - - lines.extend([ - Line::from(""), - Line::from(" :keymap [vim|emacs] → switch keymap profile"), - Line::from(" :keymap → display the active keymap"), - Line::from(""), - Line::from(vec![Span::styled( - "KEYBINDINGS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - ]); - lines.push(Line::from(" Enter → execute command")); - lines.push(Line::from(" Esc → exit command mode")); - lines.push(Line::from(" Tab → autocomplete suggestion")); - lines.push(Line::from(" ↑/↓ → navigate suggestions")); - lines.push(Line::from(" Backspace → delete character")); - lines.push(Line::from(" Ctrl+P → open command palette")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "GENERAL", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from(" :h, :help → show this help")); - lines.push(Line::from(" F1 or ? → toggle help overlay")); - lines.push(Line::from(" F12 → toggle debug log panel")); - lines.push(Line::from(" :files, :explorer → toggle files panel")); - lines.push(Line::from( - " :markdown [on|off] → toggle markdown rendering", - )); - lines.push(Line::from(" Ctrl+←/→ → resize files panel")); - lines.push(Line::from( - " Ctrl+↑/↓ → resize chat/thinking split", - )); - lines.push(Line::from(" :quit → quit application")); - lines.push(Line::from(" Ctrl+C twice → quit application")); - lines.push(Line::from( - " :reload → reload configuration and themes", - )); - lines.push(Line::from( - " :layout save/load → persist or restore pane layout", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "CONVERSATION", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from(" :n, :new → start new conversation")); - lines.push(Line::from( - " :c, :clear → clear current conversation", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "MODEL & THEME", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from(" :m, :model → open model selector")); - lines.push(Line::from(" :themes → open theme selector")); - lines.push(Line::from( - " :theme → switch to a specific theme", - )); - lines.push(Line::from( - " :provider [auto|local|cloud] → switch provider or set mode", - )); - lines.push(Line::from( - " :models --local | --cloud → focus models by scope", - )); - lines.push(Line::from( - " :cloud setup [--force-cloud-base-url] → configure Ollama Cloud", - )); - lines.push(Line::from( - " :web on|off|status → manage web_search availability", - )); - lines.push(Line::from( - " :limits → show hourly/weekly usage totals", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "SESSION MANAGEMENT", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from( - " :session save [name] → save current session (optional name)", - )); - lines.push(Line::from( - " :load, :o → browse and load saved sessions", - )); - lines.push(Line::from(" :sessions, :ls → browse saved sessions")); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "AGENT", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from( - " :agent start → arm the agent for the next request", - )); - lines.push(Line::from( - " :agent stop → stop or disarm the agent", - )); - lines.push(Line::from( - " :agent status → show current agent state", - )); - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "CODE VIEW", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )])); - lines.push(Line::from( - " :open → open file in code side panel", - )); - lines.push(Line::from( - " :create → create file (makes parent directories)", - )); - lines.push(Line::from( - " :close → close the code side panel", - )); - lines.push(Line::from( - " :w[!] [path] → write active file (optionally to path)", - )); - lines.push(Line::from( - " :q[!] → close active file (append ! to discard)", - )); - lines.push(Line::from( - " :wq[!] [path] → save then close active file", - )); - lines.push(Line::from( - " :code → switch to code mode (CLI: owlen --code)", - )); - lines.push(Line::from( - " :mode → change current mode explicitly", - )); - lines.push(Line::from( - " :tools install/audit → manage MCP tool presets", - )); - lines.push(Line::from( - " :agent status → show agent configuration and iteration info", - )); - lines.push(Line::from( - " :stop-agent → abort a running ReAct agent loop", - )); - - lines - } - 4 => vec![ - // Sessions - Line::from(""), - Line::from(vec![Span::styled( - "SESSION MANAGEMENT", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(""), - Line::from(vec![Span::styled( - "SAVING SESSIONS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - Line::from(" :session save → save with auto-generated name"), - Line::from(" :session save my-session → save with custom name"), - Line::from(" • AI generates description automatically (configurable)"), - Line::from(" • Sessions stored in platform-specific directories"), - Line::from(""), - Line::from(vec![Span::styled( - "LOADING SESSIONS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - Line::from(" :load, :o → browse and select session"), - Line::from(" :sessions, :ls → browse saved sessions"), - Line::from(""), - Line::from(vec![Span::styled( - "SESSION BROWSER KEYS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - Line::from(" j/k or ↑/↓ → navigate sessions"), - Line::from(" Enter → load selected session"), - Line::from(" d → delete selected session"), - Line::from(" Esc → close browser"), - Line::from(""), - Line::from(vec![Span::styled( - "STORAGE LOCATIONS", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.user_message_role), - )]), - Line::from(" Linux → ~/.local/share/owlen/sessions"), - Line::from(" Windows → %APPDATA%\\owlen\\sessions"), - Line::from(" macOS → ~/Library/Application Support/owlen/sessions"), - Line::from(""), - Line::from(vec![Span::styled( - "CONTEXT PRESERVATION", - Style::default() - .add_modifier(Modifier::BOLD) - .fg(theme.assistant_message_role), - )]), - Line::from(" • Full conversation history is preserved when saving"), - Line::from(" • All context is restored when loading a session"), - Line::from(" • Continue conversations seamlessly across restarts"), - ], - 5 => vec![ - // Browsers - Line::from(""), - Line::from(vec![Span::styled( - "PROVIDER & MODEL PICKERS", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" m or :model → open provider selector (if multiple providers)"), - Line::from(" ↑/↓ or j/k → navigate providers"), - Line::from(" Enter → confirm provider and open model list"), - Line::from(" Space / ←/→ → collapse or expand provider groups"), - Line::from(" i / r → show or refresh model info side panel"), - Line::from(" q / Esc → close selector"), - Line::from(""), - Line::from(vec![Span::styled( - "THEME BROWSER (:themes)", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" ↑/↓ or j/k → navigate themes"), - Line::from(" g / G / Home / End → jump to top or bottom"), - Line::from(" Enter → apply highlighted theme"), - Line::from(" Esc / q → close browser"), - Line::from(""), - Line::from(vec![Span::styled( - "COMMAND PALETTE (Ctrl+P or ':')", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Tab / Shift+Tab → accept suggestion / cycle backwards"), - Line::from(" ↑/↓ → navigate grouped suggestions"), - Line::from(" Enter → run highlighted command"), - Line::from(" Esc → cancel"), - Line::from(""), - Line::from(vec![Span::styled( - "REPO SEARCH (Ctrl+Shift+F)", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Type pattern → update ripgrep query"), - Line::from(" Enter → run search or open highlighted match"), - Line::from(" Alt+Enter → send matches to scratch buffer"), - Line::from(" ↑/↓ or j/k → move between matches · PageUp/Down jump pages"), - Line::from(" Esc → close search"), - Line::from(""), - Line::from(vec![Span::styled( - "SYMBOL SEARCH (Ctrl+Shift+P)", - Style::default().add_modifier(Modifier::BOLD).fg(theme.info), - )]), - Line::from(" Type to filter → fuzzy search indexed symbols"), - Line::from(" Enter → jump to symbol in code view"), - Line::from(" ↑/↓ or j/k → navigate · Esc closes"), - ], - 6 => vec![], - - _ => vec![], - }; - - help_text.insert( - 0, - Line::from(vec![ - Span::styled( - "Current Theme: ", - Style::default() - .fg(theme.placeholder) - .add_modifier(Modifier::ITALIC), - ), - Span::styled( - theme.name.clone(), - Style::default() - .fg(theme.mode_model_selection) - .add_modifier(Modifier::BOLD), - ), - ]), - ); - help_text.insert(1, Line::from("")); - - // Create layout for tabs and content - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Tab bar - Constraint::Min(0), // Content - Constraint::Length(2), // Navigation hint - ]) - .split(inner); - - // Render tabs - let tabs_para = Paragraph::new(Line::from(tab_spans)) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Center); - frame.render_widget(tabs_para, layout[0]); - - // Render content - if tab_index == PRIVACY_TAB_INDEX { - render_privacy_settings(frame, layout[1], app); + if matches!(app.guidance_overlay(), GuidanceOverlay::Onboarding) { + render_guidance_onboarding(frame, inner, app, palette, theme); } else { - let content_para = - Paragraph::new(help_text).style(Style::default().bg(palette.active).fg(palette.label)); - frame.render_widget(content_para, layout[1]); + render_guidance_cheatsheet(frame, inner, app, palette, theme); } - - // Render navigation hint - let nav_hint = Line::from(vec![ - Span::raw(" "), - Span::styled( - "Tab/h/l", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Switch "), - Span::styled( - format!("1-{}", HELP_TAB_COUNT), - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Jump "), - Span::styled( - "Esc", - Style::default() - .fg(palette.label) - .add_modifier(Modifier::BOLD), - ), - Span::raw(":Close "), - ]); - let nav_para = Paragraph::new(nav_hint) - .style(Style::default().bg(palette.highlight).fg(palette.label)) - .alignment(Alignment::Center); - frame.render_widget(nav_para, layout[2]); } - fn render_session_browser(frame: &mut Frame<'_>, app: &ChatApp) { let theme = app.theme(); let area = centered_rect(70, 70, frame.area()); diff --git a/crates/owlen-tui/tests/chat_snapshots.rs b/crates/owlen-tui/tests/chat_snapshots.rs index 95ef880..8fd7516 100644 --- a/crates/owlen-tui/tests/chat_snapshots.rs +++ b/crates/owlen-tui/tests/chat_snapshots.rs @@ -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> { - 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 { - 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 { - 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(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 = Arc::new(StubProvider); - let ui: Arc = 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); + }); +} diff --git a/crates/owlen-tui/tests/common/mod.rs b/crates/owlen-tui/tests/common/mod.rs new file mode 100644 index 0000000..bfc4a42 --- /dev/null +++ b/crates/owlen-tui/tests/common/mod.rs @@ -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> { + 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 { + 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 { + 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(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 = Arc::new(StubProvider); + let ui: Arc = 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 +} diff --git a/crates/owlen-tui/tests/guidance_persistence.rs b/crates/owlen-tui/tests/guidance_persistence.rs new file mode 100644 index 0000000..fe9fc4a --- /dev/null +++ b/crates/owlen-tui/tests/guidance_persistence.rs @@ -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, +} + +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" + ); +} diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap new file mode 100644 index 0000000..148070c --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab1-100x24.snap @@ -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:✓ " +" " +" " +" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap new file mode 100644 index 0000000..59c3d0f --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_cheatsheet@tab2-100x24.snap @@ -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:✓ " +" " +" " +" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap new file mode 100644 index 0000000..0cf2229 --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step1-80x24.snap @@ -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 " +" " +" " diff --git a/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap new file mode 100644 index 0000000..801df97 --- /dev/null +++ b/crates/owlen-tui/tests/snapshots/chat_snapshots__guidance_onboarding@step2-100x24.snap @@ -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:✓ " +" " +" " +" "