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.
|
- Adaptive TUI layout with responsive 80/120-column breakpoints, refreshed glass/neon theming, and animated focus rings for pane transitions.
|
||||||
- Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations.
|
- Configurable `ui.layers` and `ui.animations` settings to tune glass elevation, neon intensity, and opt-in micro-animations.
|
||||||
- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching.
|
- Command palette offers fuzzy `:model` filtering and `:provider` completions for fast switching.
|
||||||
|
- Inline guidance overlay adds a three-step onboarding tour, keymap-aware cheat sheets (F1 / `?`), and persists completion state via `ui.guidance`.
|
||||||
- Cloud usage tracker persists hourly/weekly token totals, adds a `:limits` command, shows live header badges, and raises toast warnings at 80 %/95 % of the configured quotas.
|
- Cloud usage tracker persists hourly/weekly token totals, adds a `:limits` command, shows live header badges, and raises toast warnings at 80 %/95 % of the configured quotas.
|
||||||
- Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions.
|
- Message rendering caches wrapped lines and throttles streaming redraws to keep the TUI responsive on long sessions.
|
||||||
- Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse.
|
- Model picker badges now inspect provider capabilities so vision/audio/thinking models surface the correct icons even when descriptions are sparse.
|
||||||
|
|||||||
@@ -1822,6 +1822,8 @@ pub struct UiSettings {
|
|||||||
pub layers: LayerSettings,
|
pub layers: LayerSettings,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub animations: AnimationSettings,
|
pub animations: AnimationSettings,
|
||||||
|
#[serde(default)]
|
||||||
|
pub guidance: GuidanceSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -1851,6 +1853,26 @@ impl Default for AccessibilitySettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GuidanceSettings {
|
||||||
|
#[serde(default = "GuidanceSettings::default_coach_marks_complete")]
|
||||||
|
pub coach_marks_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuidanceSettings {
|
||||||
|
const fn default_coach_marks_complete() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GuidanceSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
coach_marks_complete: Self::default_coach_marks_complete(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct LayerSettings {
|
pub struct LayerSettings {
|
||||||
#[serde(default = "LayerSettings::default_shadow_elevation")]
|
#[serde(default = "LayerSettings::default_shadow_elevation")]
|
||||||
@@ -2095,6 +2117,7 @@ impl Default for UiSettings {
|
|||||||
accessibility: AccessibilitySettings::default(),
|
accessibility: AccessibilitySettings::default(),
|
||||||
layers: LayerSettings::default(),
|
layers: LayerSettings::default(),
|
||||||
animations: AnimationSettings::default(),
|
animations: AnimationSettings::default(),
|
||||||
|
guidance: GuidanceSettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,19 +56,20 @@ use crate::model_info_panel::ModelInfoPanel;
|
|||||||
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
|
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
|
||||||
FileNode, FileTreeState, Keymap, KeymapEventResult, KeymapOverrides, KeymapProfile,
|
FileNode, FileTreeState, Keymap, KeymapBindingDescription, KeymapEventResult, KeymapOverrides,
|
||||||
KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest,
|
KeymapProfile, KeymapState, ModelPaletteEntry, PaletteSuggestion, PaneDirection,
|
||||||
RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState,
|
PaneRestoreRequest, RepoSearchMessage, RepoSearchState, SplitAxis, SymbolSearchMessage,
|
||||||
WorkspaceSnapshot, install_global_logger, spawn_repo_search_task, spawn_symbol_search_task,
|
SymbolSearchState, WorkspaceSnapshot, install_global_logger, spawn_repo_search_task,
|
||||||
|
spawn_symbol_search_task,
|
||||||
};
|
};
|
||||||
use crate::toast::{Toast, ToastLevel, ToastManager};
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
||||||
use crate::ui::{format_token_short, format_tool_output};
|
use crate::ui::{format_token_short, format_tool_output};
|
||||||
use crate::widgets::model_picker::FilterMode;
|
use crate::widgets::model_picker::FilterMode;
|
||||||
use crate::{commands, highlight};
|
use crate::{commands, highlight};
|
||||||
use owlen_core::config::{
|
use owlen_core::config::{
|
||||||
AnimationSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV,
|
AnimationSettings, GuidanceSettings, LEGACY_OLLAMA_CLOUD_API_KEY_ENV,
|
||||||
LayerSettings, OLLAMA_API_KEY_ENV, OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY,
|
LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, LayerSettings, OLLAMA_API_KEY_ENV,
|
||||||
OLLAMA_MODE_KEY,
|
OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_ENDPOINT_KEY, OLLAMA_MODE_KEY,
|
||||||
};
|
};
|
||||||
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
use owlen_core::credentials::{ApiCredentials, OLLAMA_CLOUD_CREDENTIAL_ID};
|
||||||
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
// Agent executor moved to separate binary `owlen-agent`. The TUI no longer directly
|
||||||
@@ -98,6 +99,7 @@ const ONBOARDING_SYSTEM_STATUS: &str =
|
|||||||
const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer.";
|
const TUTORIAL_STATUS: &str = "Tutorial loaded. Review quick tips in the footer.";
|
||||||
const TUTORIAL_SYSTEM_STATUS: &str =
|
const TUTORIAL_SYSTEM_STATUS: &str =
|
||||||
"Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter";
|
"Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/? • Send ▸ Enter";
|
||||||
|
const ONBOARDING_STEP_COUNT: usize = 3;
|
||||||
|
|
||||||
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
|
const DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
|
||||||
|
|
||||||
@@ -271,6 +273,12 @@ impl PaneAnimations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum GuidanceOverlay {
|
||||||
|
CheatSheet,
|
||||||
|
Onboarding,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub(crate) struct LayoutSnapshot {
|
pub(crate) struct LayoutSnapshot {
|
||||||
pub(crate) frame: Rect,
|
pub(crate) frame: Rect,
|
||||||
@@ -691,7 +699,7 @@ pub enum SessionEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const HELP_TAB_COUNT: usize = 7;
|
pub const HELP_TAB_COUNT: usize = 3;
|
||||||
|
|
||||||
pub struct ChatApp {
|
pub struct ChatApp {
|
||||||
controller: SessionController,
|
controller: SessionController,
|
||||||
@@ -797,6 +805,9 @@ pub struct ChatApp {
|
|||||||
active_layout: AdaptiveLayout,
|
active_layout: AdaptiveLayout,
|
||||||
gauge_animations: GaugeAnimations,
|
gauge_animations: GaugeAnimations,
|
||||||
pane_animations: PaneAnimations,
|
pane_animations: PaneAnimations,
|
||||||
|
guidance_overlay: GuidanceOverlay,
|
||||||
|
onboarding_step: usize,
|
||||||
|
guidance_settings: GuidanceSettings,
|
||||||
/// Simple execution budget: maximum number of tool calls allowed per session.
|
/// Simple execution budget: maximum number of tool calls allowed per session.
|
||||||
_execution_budget: usize,
|
_execution_budget: usize,
|
||||||
/// Agent mode enabled
|
/// Agent mode enabled
|
||||||
@@ -962,6 +973,7 @@ impl ChatApp {
|
|||||||
let accessibility = config_guard.ui.accessibility.clone();
|
let accessibility = config_guard.ui.accessibility.clone();
|
||||||
let layer_settings = config_guard.ui.layers.clone();
|
let layer_settings = config_guard.ui.layers.clone();
|
||||||
let animation_settings = config_guard.ui.animations.clone();
|
let animation_settings = config_guard.ui.animations.clone();
|
||||||
|
let guidance_settings = config_guard.ui.guidance.clone();
|
||||||
drop(config_guard);
|
drop(config_guard);
|
||||||
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
|
let keymap_overrides = KeymapOverrides::new(keymap_leader_raw);
|
||||||
let keymap = {
|
let keymap = {
|
||||||
@@ -1000,7 +1012,11 @@ impl ChatApp {
|
|||||||
|
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
controller,
|
controller,
|
||||||
mode: InputMode::Normal,
|
mode: if show_onboarding {
|
||||||
|
InputMode::Help
|
||||||
|
} else {
|
||||||
|
InputMode::Normal
|
||||||
|
},
|
||||||
mode_flash_until: None,
|
mode_flash_until: None,
|
||||||
status: if show_onboarding {
|
status: if show_onboarding {
|
||||||
ONBOARDING_STATUS_LINE.to_string()
|
ONBOARDING_STATUS_LINE.to_string()
|
||||||
@@ -1114,6 +1130,13 @@ impl ChatApp {
|
|||||||
active_layout: AdaptiveLayout::default(),
|
active_layout: AdaptiveLayout::default(),
|
||||||
gauge_animations: GaugeAnimations::default(),
|
gauge_animations: GaugeAnimations::default(),
|
||||||
pane_animations: PaneAnimations::default(),
|
pane_animations: PaneAnimations::default(),
|
||||||
|
guidance_overlay: if show_onboarding {
|
||||||
|
GuidanceOverlay::Onboarding
|
||||||
|
} else {
|
||||||
|
GuidanceOverlay::CheatSheet
|
||||||
|
},
|
||||||
|
onboarding_step: 0,
|
||||||
|
guidance_settings,
|
||||||
};
|
};
|
||||||
|
|
||||||
app.mvu_model.composer.mode = InputMode::Normal;
|
app.mvu_model.composer.mode = InputMode::Normal;
|
||||||
@@ -1133,16 +1156,6 @@ impl ChatApp {
|
|||||||
eprintln!("Warning: failed to restore workspace layout: {err}");
|
eprintln!("Warning: failed to restore workspace layout: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if show_onboarding {
|
|
||||||
let mut cfg = app.controller.config_mut();
|
|
||||||
if cfg.ui.show_onboarding {
|
|
||||||
cfg.ui.show_onboarding = false;
|
|
||||||
if let Err(err) = config::save_config(&cfg) {
|
|
||||||
eprintln!("Warning: Failed to persist onboarding preference: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.refresh_usage_summary().await?;
|
app.refresh_usage_summary().await?;
|
||||||
|
|
||||||
Ok((app, session_rx))
|
Ok((app, session_rx))
|
||||||
@@ -1281,6 +1294,26 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn guidance_overlay(&self) -> GuidanceOverlay {
|
||||||
|
self.guidance_overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn onboarding_step(&self) -> usize {
|
||||||
|
self.onboarding_step
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn onboarding_step_count(&self) -> usize {
|
||||||
|
ONBOARDING_STEP_COUNT
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coach_marks_complete(&self) -> bool {
|
||||||
|
self.guidance_settings.coach_marks_complete
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn keymap_bindings(&self) -> Vec<KeymapBindingDescription> {
|
||||||
|
self.keymap.describe_bindings()
|
||||||
|
}
|
||||||
|
|
||||||
fn update_context_usage(&mut self, usage: &TokenUsage) {
|
fn update_context_usage(&mut self, usage: &TokenUsage) {
|
||||||
let context_window = self
|
let context_window = self
|
||||||
.active_context_window()
|
.active_context_window()
|
||||||
@@ -2176,6 +2209,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn show_tutorial(&mut self) {
|
pub fn show_tutorial(&mut self) {
|
||||||
|
self.open_guidance_overlay(GuidanceOverlay::Onboarding);
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.status = TUTORIAL_STATUS.to_string();
|
self.status = TUTORIAL_STATUS.to_string();
|
||||||
self.system_status = TUTORIAL_SYSTEM_STATUS.to_string();
|
self.system_status = TUTORIAL_SYSTEM_STATUS.to_string();
|
||||||
@@ -2219,6 +2253,84 @@ impl ChatApp {
|
|||||||
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
|
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_guidance_overlay(&mut self, overlay: GuidanceOverlay) {
|
||||||
|
self.guidance_overlay = overlay;
|
||||||
|
if matches!(overlay, GuidanceOverlay::CheatSheet) && HELP_TAB_COUNT > 0 {
|
||||||
|
self.help_tab_index = self.help_tab_index.min(HELP_TAB_COUNT - 1);
|
||||||
|
}
|
||||||
|
if matches!(overlay, GuidanceOverlay::Onboarding) {
|
||||||
|
self.onboarding_step = 0;
|
||||||
|
self.status = format!("Owlen onboarding · Step 1 of {}", ONBOARDING_STEP_COUNT);
|
||||||
|
} else {
|
||||||
|
self.status = "Owlen cheat sheet".to_string();
|
||||||
|
}
|
||||||
|
self.error = None;
|
||||||
|
self.set_input_mode(InputMode::Help);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_onboarding_step(&mut self) {
|
||||||
|
if !matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.onboarding_step + 1 < ONBOARDING_STEP_COUNT {
|
||||||
|
self.onboarding_step += 1;
|
||||||
|
self.status = format!(
|
||||||
|
"Owlen onboarding · Step {} of {}",
|
||||||
|
self.onboarding_step + 1,
|
||||||
|
ONBOARDING_STEP_COUNT
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.finish_onboarding(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regress_onboarding_step(&mut self) {
|
||||||
|
if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) && self.onboarding_step > 0
|
||||||
|
{
|
||||||
|
self.onboarding_step -= 1;
|
||||||
|
self.status = format!(
|
||||||
|
"Owlen onboarding · Step {} of {}",
|
||||||
|
self.onboarding_step + 1,
|
||||||
|
ONBOARDING_STEP_COUNT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_onboarding(&mut self, completed: bool) {
|
||||||
|
self.guidance_overlay = GuidanceOverlay::CheatSheet;
|
||||||
|
self.onboarding_step = 0;
|
||||||
|
{
|
||||||
|
let mut cfg = self.controller.config_mut();
|
||||||
|
let mut dirty = false;
|
||||||
|
if cfg.ui.show_onboarding {
|
||||||
|
cfg.ui.show_onboarding = false;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
if completed && !cfg.ui.guidance.coach_marks_complete {
|
||||||
|
cfg.ui.guidance.coach_marks_complete = true;
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
self.guidance_settings = cfg.ui.guidance.clone();
|
||||||
|
if dirty {
|
||||||
|
if let Err(err) = config::save_config(&cfg) {
|
||||||
|
eprintln!("Warning: Failed to persist guidance settings: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if completed {
|
||||||
|
self.status = "Cheat sheet ready — press Esc when done".to_string();
|
||||||
|
self.error = None;
|
||||||
|
if HELP_TAB_COUNT > 0 {
|
||||||
|
self.help_tab_index = 0;
|
||||||
|
}
|
||||||
|
self.set_input_mode(InputMode::Help);
|
||||||
|
} else {
|
||||||
|
self.reset_status();
|
||||||
|
self.set_input_mode(InputMode::Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mode_flash_active(&self) -> bool {
|
pub fn mode_flash_active(&self) -> bool {
|
||||||
self.mode_flash_until
|
self.mode_flash_until
|
||||||
.map(|deadline| Instant::now() < deadline)
|
.map(|deadline| Instant::now() < deadline)
|
||||||
@@ -6064,13 +6176,17 @@ impl ChatApp {
|
|||||||
|
|
||||||
if matches!(key.code, KeyCode::F(1)) {
|
if matches!(key.code, KeyCode::F(1)) {
|
||||||
if matches!(self.mode, InputMode::Help) {
|
if matches!(self.mode, InputMode::Help) {
|
||||||
self.set_input_mode(InputMode::Normal);
|
if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) {
|
||||||
self.help_tab_index = 0;
|
self.finish_onboarding(false);
|
||||||
self.reset_status();
|
} else {
|
||||||
|
if HELP_TAB_COUNT > 0 {
|
||||||
|
self.help_tab_index = 0;
|
||||||
|
}
|
||||||
|
self.reset_status();
|
||||||
|
self.set_input_mode(InputMode::Normal);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.set_input_mode(InputMode::Help);
|
self.open_guidance_overlay(GuidanceOverlay::CheatSheet);
|
||||||
self.status = "Help".to_string();
|
|
||||||
self.error = None;
|
|
||||||
}
|
}
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
@@ -6114,9 +6230,24 @@ impl ChatApp {
|
|||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_question_mark && matches!(self.mode, InputMode::Normal) {
|
if is_question_mark {
|
||||||
self.set_input_mode(InputMode::Help);
|
match self.mode {
|
||||||
self.status = "Help".to_string();
|
InputMode::Normal => {
|
||||||
|
self.open_guidance_overlay(GuidanceOverlay::CheatSheet);
|
||||||
|
}
|
||||||
|
InputMode::Help => {
|
||||||
|
if matches!(self.guidance_overlay, GuidanceOverlay::Onboarding) {
|
||||||
|
self.finish_onboarding(false);
|
||||||
|
} else {
|
||||||
|
if HELP_TAB_COUNT > 0 {
|
||||||
|
self.help_tab_index = 0;
|
||||||
|
}
|
||||||
|
self.reset_status();
|
||||||
|
self.set_input_mode(InputMode::Normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
return Ok(AppState::Running);
|
return Ok(AppState::Running);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8676,32 +8807,51 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
InputMode::Help => match key.code {
|
InputMode::Help => match self.guidance_overlay {
|
||||||
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') | KeyCode::F(1) => {
|
GuidanceOverlay::Onboarding => match key.code {
|
||||||
self.set_input_mode(InputMode::Normal);
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::F(1) => {
|
||||||
self.help_tab_index = 0; // Reset to first tab
|
self.finish_onboarding(false);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Enter
|
||||||
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
| KeyCode::Char(' ')
|
||||||
// Previous tab
|
| KeyCode::Right
|
||||||
if self.help_tab_index > 0 {
|
| KeyCode::Char('l')
|
||||||
self.help_tab_index -= 1;
|
| KeyCode::Tab => {
|
||||||
|
self.advance_onboarding_step();
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Left | KeyCode::Char('h') | KeyCode::BackTab => {
|
||||||
KeyCode::Char(ch) if ch.is_ascii_digit() => {
|
self.regress_onboarding_step();
|
||||||
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;
|
},
|
||||||
|
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 {
|
InputMode::SessionBrowser => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ pub fn parse(input: &str) -> Result<Option<SlashCommand>, SlashError> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
static GUARD: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||||
|
GUARD
|
||||||
|
.get_or_init(|| std::sync::Mutex::new(()))
|
||||||
|
.lock()
|
||||||
|
.expect("registry test mutex poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_non_command_input() {
|
fn ignores_non_command_input() {
|
||||||
let result = parse("hello world").unwrap();
|
let result = parse("hello world").unwrap();
|
||||||
@@ -202,6 +210,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_registered_mcp_command() {
|
fn parses_registered_mcp_command() {
|
||||||
|
let _registry = registry_guard();
|
||||||
set_mcp_commands(Vec::new());
|
set_mcp_commands(Vec::new());
|
||||||
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
|
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
|
||||||
|
|
||||||
@@ -219,6 +228,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_mcp_command_with_arguments() {
|
fn rejects_mcp_command_with_arguments() {
|
||||||
|
let _registry = registry_guard();
|
||||||
set_mcp_commands(Vec::new());
|
set_mcp_commands(Vec::new());
|
||||||
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
|
set_mcp_commands(vec![McpSlashCommand::new("github", "list_prs", None)]);
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,69 +1,14 @@
|
|||||||
use std::sync::Arc;
|
mod common;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use insta::{assert_snapshot, with_settings};
|
use insta::{assert_snapshot, with_settings};
|
||||||
use owlen_core::{
|
use owlen_core::types::{Message, ToolCall};
|
||||||
Config, Mode, Provider,
|
|
||||||
session::SessionController,
|
|
||||||
storage::StorageManager,
|
|
||||||
types::{Message, ToolCall},
|
|
||||||
ui::{NoOpUiController, UiController},
|
|
||||||
};
|
|
||||||
use owlen_tui::ChatApp;
|
use owlen_tui::ChatApp;
|
||||||
use owlen_tui::events::Event;
|
use owlen_tui::events::Event;
|
||||||
use owlen_tui::ui::render_chat;
|
use owlen_tui::ui::render_chat;
|
||||||
use ratatui::{Terminal, backend::TestBackend};
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
use tempfile::tempdir;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
struct StubProvider;
|
use common::build_chat_app;
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Provider for StubProvider {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"stub-provider"
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_models(&self) -> owlen_core::Result<Vec<owlen_core::types::ModelInfo>> {
|
|
||||||
Ok(vec![owlen_core::types::ModelInfo {
|
|
||||||
id: "stub-model".into(),
|
|
||||||
name: "Stub Model".into(),
|
|
||||||
description: Some("Stub model for golden snapshot tests".into()),
|
|
||||||
provider: self.name().into(),
|
|
||||||
context_window: Some(8192),
|
|
||||||
capabilities: vec!["chat".into(), "tool-use".into()],
|
|
||||||
supports_tools: true,
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_prompt(
|
|
||||||
&self,
|
|
||||||
_request: owlen_core::types::ChatRequest,
|
|
||||||
) -> owlen_core::Result<owlen_core::types::ChatResponse> {
|
|
||||||
Ok(owlen_core::types::ChatResponse {
|
|
||||||
message: Message::assistant("stub completion".into()),
|
|
||||||
usage: None,
|
|
||||||
is_streaming: false,
|
|
||||||
is_final: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stream_prompt(
|
|
||||||
&self,
|
|
||||||
_request: owlen_core::types::ChatRequest,
|
|
||||||
) -> owlen_core::Result<owlen_core::ChatStream> {
|
|
||||||
Ok(Box::pin(futures_util::stream::empty()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn health_check(&self) -> owlen_core::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_any(&self) -> &(dyn std::any::Any + Send + Sync) {
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
@@ -80,56 +25,6 @@ fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
|
|||||||
output
|
output
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_chat_app<C, F>(configure_config: C, configure_session: F) -> ChatApp
|
|
||||||
where
|
|
||||||
C: FnOnce(&mut Config),
|
|
||||||
F: FnOnce(&mut SessionController),
|
|
||||||
{
|
|
||||||
let temp_dir = tempdir().expect("temp dir");
|
|
||||||
let storage =
|
|
||||||
StorageManager::with_database_path(temp_dir.path().join("owlen-tui-snapshots.db"))
|
|
||||||
.await
|
|
||||||
.expect("storage");
|
|
||||||
let storage = Arc::new(storage);
|
|
||||||
|
|
||||||
let mut config = Config::default();
|
|
||||||
configure_config(&mut config);
|
|
||||||
config.general.default_model = Some("stub-model".into());
|
|
||||||
config.general.enable_streaming = true;
|
|
||||||
config.privacy.encrypt_local_data = false;
|
|
||||||
config.privacy.require_consent_per_session = false;
|
|
||||||
config.ui.show_onboarding = false;
|
|
||||||
config.ui.show_timestamps = false;
|
|
||||||
let provider: Arc<dyn Provider> = Arc::new(StubProvider);
|
|
||||||
let ui: Arc<dyn UiController> = Arc::new(NoOpUiController);
|
|
||||||
let (event_tx, controller_event_rx) = mpsc::unbounded_channel();
|
|
||||||
|
|
||||||
let mut session = SessionController::new(
|
|
||||||
Arc::clone(&provider),
|
|
||||||
config,
|
|
||||||
Arc::clone(&storage),
|
|
||||||
ui,
|
|
||||||
true,
|
|
||||||
Some(event_tx),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("session controller");
|
|
||||||
|
|
||||||
session
|
|
||||||
.set_operating_mode(Mode::Chat)
|
|
||||||
.await
|
|
||||||
.expect("chat mode");
|
|
||||||
|
|
||||||
configure_session(&mut session);
|
|
||||||
|
|
||||||
let (app, mut session_rx) = ChatApp::new(session, controller_event_rx)
|
|
||||||
.await
|
|
||||||
.expect("chat app");
|
|
||||||
session_rx.close();
|
|
||||||
|
|
||||||
app
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String {
|
fn render_snapshot(app: &mut ChatApp, width: u16, height: u16) -> String {
|
||||||
let backend = TestBackend::new(width, height);
|
let backend = TestBackend::new(width, height);
|
||||||
let mut terminal = Terminal::new(backend).expect("terminal");
|
let mut terminal = Terminal::new(backend).expect("terminal");
|
||||||
@@ -263,3 +158,58 @@ async fn render_command_palette_focus_snapshot() {
|
|||||||
assert_snapshot!("command_palette_focus", snapshot);
|
assert_snapshot!("command_palette_focus", snapshot);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn render_guidance_onboarding_snapshot() {
|
||||||
|
let mut app = build_chat_app(
|
||||||
|
|cfg| {
|
||||||
|
cfg.ui.show_onboarding = true;
|
||||||
|
cfg.ui.guidance.coach_marks_complete = false;
|
||||||
|
},
|
||||||
|
|_| {},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
with_settings!({ snapshot_suffix => "step1-80x24" }, {
|
||||||
|
let snapshot = render_snapshot(&mut app, 80, 24);
|
||||||
|
assert_snapshot!("guidance_onboarding", snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.handle_event(Event::Key(KeyEvent::new(
|
||||||
|
KeyCode::Enter,
|
||||||
|
KeyModifiers::NONE,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.expect("advance onboarding to step 2");
|
||||||
|
|
||||||
|
with_settings!({ snapshot_suffix => "step2-100x24" }, {
|
||||||
|
let snapshot = render_snapshot(&mut app, 100, 24);
|
||||||
|
assert_snapshot!("guidance_onboarding", snapshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn render_guidance_cheatsheet_snapshot() {
|
||||||
|
let mut app = build_chat_app(|cfg| cfg.ui.guidance.coach_marks_complete = true, |_| {}).await;
|
||||||
|
|
||||||
|
app.handle_event(Event::Key(KeyEvent::new(
|
||||||
|
KeyCode::Char('?'),
|
||||||
|
KeyModifiers::NONE,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.expect("open guidance overlay");
|
||||||
|
|
||||||
|
with_settings!({ snapshot_suffix => "tab1-100x24" }, {
|
||||||
|
let snapshot = render_snapshot(&mut app, 100, 24);
|
||||||
|
assert_snapshot!("guidance_cheatsheet", snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)))
|
||||||
|
.await
|
||||||
|
.expect("advance guidance tab");
|
||||||
|
|
||||||
|
with_settings!({ snapshot_suffix => "tab2-100x24" }, {
|
||||||
|
let snapshot = render_snapshot(&mut app, 100, 24);
|
||||||
|
assert_snapshot!("guidance_cheatsheet", snapshot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
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