Files
owlen/crates/owlen-tui/src/chat_app.rs
vikingowl 1994367a2e feat(mcp): add tool presets and audit commands
- Introduce reference MCP presets with installation/audit helpers and remove legacy connector lists.
- Add CLI `owlen tools` commands to install presets or audit configuration, with optional pruning.
- Extend the TUI :tools command to support listing presets, installing them, and auditing current configuration.
- Document the preset workflow and provide regression tests for preset application.
2025-10-25 05:39:58 +02:00

13175 lines
528 KiB
Rust

use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use chrono::{DateTime, Local, Utc};
use crossterm::{
event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
terminal::{disable_raw_mode, enable_raw_mode},
};
use owlen_core::Error as CoreError;
use owlen_core::consent::ConsentScope;
use owlen_core::facade::llm_client::LlmClient;
use owlen_core::mcp::presets::{self, PresetTier};
use owlen_core::mcp::remote_client::RemoteMcpClient;
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
use owlen_core::provider::{
AnnotatedModelInfo, ModelInfo as ProviderModelInfo, ProviderErrorKind, ProviderMetadata,
ProviderStatus, ProviderType,
};
use owlen_core::tools::WEB_SEARCH_TOOL_NAME;
use owlen_core::{
ProviderConfig,
config::McpResourceConfig,
model::DetailedModelInfo,
oauth::{DeviceAuthorization, DevicePollState},
session::{ControllerEvent, SessionController, SessionOutcome, ToolConsentResolution},
storage::SessionMeta,
theme::Theme,
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role, TokenUsage},
ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay},
usage::{UsageBand, UsageSnapshot, UsageWindow, WindowMetrics},
};
use owlen_markdown::from_str;
use pathdiff::diff_paths;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
};
use textwrap::{Options, WordSeparator, wrap};
use tokio::{
sync::mpsc,
task::{self, JoinHandle},
};
use tui_textarea::{CursorMove, Input, TextArea};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use uuid::Uuid;
use crate::app::{
MessageState, UiRuntime,
mvu::{self, AppEffect, AppEvent, AppModel, ComposerEvent, SubmissionOutcome},
};
use crate::commands::{AppCommand, CommandRegistry};
use crate::config;
use crate::events::Event;
use crate::model_info_panel::ModelInfoPanel;
use crate::slash::{self, McpSlashCommand, SlashCommand};
use crate::state::{
CodeWorkspace, CommandPalette, DebugLogEntry, DebugLogState, FileFilterMode, FileIconResolver,
FileNode, FileTreeState, Keymap, KeymapProfile, 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::{
LEGACY_OLLAMA_CLOUD_API_KEY_ENV, LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV, 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
// imports `AgentExecutor` to avoid a circular dependency on `owlen-cli`.
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::env;
use std::fs;
use std::fs::OpenOptions;
use std::hash::{Hash, Hasher};
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use dirs::{config_dir, data_local_dir};
use log::Level;
use serde_json::{Value, json};
const ONBOARDING_STATUS_LINE: &str =
"Welcome to Owlen! Press F1 for help or type :tutorial for keybinding tips.";
const ONBOARDING_SYSTEM_STATUS: &str =
"Normal ▸ h/j/k/l • Insert ▸ i,a • Visual ▸ v • Command ▸ : • Help ▸ F1/?";
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 DEFAULT_CLOUD_ENDPOINT: &str = OLLAMA_CLOUD_BASE_URL;
const FOCUS_CHORD_TIMEOUT: Duration = Duration::from_millis(1200);
const RESIZE_DOUBLE_TAP_WINDOW: Duration = Duration::from_millis(450);
const RESIZE_STEP: f32 = 0.05;
const RESIZE_SNAP_VALUES: [f32; 3] = [0.5, 0.75, 0.25];
const DOUBLE_CTRL_C_WINDOW: Duration = Duration::from_millis(1500);
pub(crate) const MIN_MESSAGE_CARD_WIDTH: usize = 14;
const MOUSE_SCROLL_STEP: isize = 3;
const DEFAULT_CONTEXT_WINDOW_TOKENS: u32 = 8_192;
#[derive(Clone, Copy, Debug, Default)]
pub struct ContextUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub context_window: u32,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct LayoutSnapshot {
pub(crate) frame: Rect,
pub(crate) content: Rect,
pub(crate) header_panel: Option<Rect>,
pub(crate) file_panel: Option<Rect>,
pub(crate) chat_panel: Option<Rect>,
pub(crate) thinking_panel: Option<Rect>,
pub(crate) actions_panel: Option<Rect>,
pub(crate) input_panel: Option<Rect>,
pub(crate) system_panel: Option<Rect>,
pub(crate) status_panel: Option<Rect>,
pub(crate) code_panel: Option<Rect>,
pub(crate) model_info_panel: Option<Rect>,
}
impl LayoutSnapshot {
pub(crate) fn new(frame: Rect, content: Rect) -> Self {
Self {
frame,
content,
header_panel: None,
file_panel: None,
chat_panel: None,
thinking_panel: None,
actions_panel: None,
input_panel: None,
system_panel: None,
status_panel: None,
code_panel: None,
model_info_panel: None,
}
}
fn contains(rect: Rect, column: u16, row: u16) -> bool {
let x_end = rect.x.saturating_add(rect.width);
let y_end = rect.y.saturating_add(rect.height);
column >= rect.x && column < x_end && row >= rect.y && row < y_end
}
fn region_at(&self, column: u16, row: u16) -> Option<UiRegion> {
if let Some(rect) = self.model_info_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::ModelInfo);
}
}
if let Some(rect) = self.header_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Header);
}
}
if let Some(rect) = self.code_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Code);
}
}
if let Some(rect) = self.file_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::FileTree);
}
}
if let Some(rect) = self.input_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Input);
}
}
if let Some(rect) = self.system_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::System);
}
}
if let Some(rect) = self.status_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Status);
}
}
if let Some(rect) = self.actions_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Actions);
}
}
if let Some(rect) = self.thinking_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Thinking);
}
}
if let Some(rect) = self.chat_panel {
if Self::contains(rect, column, row) {
return Some(UiRegion::Chat);
}
}
if Self::contains(self.content, column, row) {
Some(UiRegion::Content)
} else if Self::contains(self.frame, column, row) {
Some(UiRegion::Frame)
} else {
None
}
}
}
impl Default for LayoutSnapshot {
fn default() -> Self {
Self::new(Rect::new(0, 0, 0, 0), Rect::new(0, 0, 0, 0))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UiRegion {
Header,
Frame,
Content,
FileTree,
Chat,
Thinking,
Actions,
Input,
System,
Status,
Code,
ModelInfo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlashOutcome {
NotCommand,
Consumed,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SaveStatus {
Saved,
NoChanges,
Failed,
}
#[derive(Clone, Debug)]
pub(crate) struct ModelSelectorItem {
kind: ModelSelectorItemKind,
}
#[derive(Clone, Debug)]
pub(crate) struct HighlightMask {
bits: Vec<bool>,
}
impl HighlightMask {
fn new(bits: Vec<bool>) -> Self {
Self { bits }
}
pub(crate) fn is_marked(&self) -> bool {
self.bits.iter().any(|b| *b)
}
pub(crate) fn bits(&self) -> &[bool] {
&self.bits
}
pub(crate) fn truncated(&self, len: usize) -> Self {
let len = len.min(self.bits.len());
Self::new(self.bits[..len].to_vec())
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ModelSearchInfo {
pub(crate) score: (usize, usize),
pub(crate) name: Option<HighlightMask>,
pub(crate) id: Option<HighlightMask>,
pub(crate) provider: Option<HighlightMask>,
pub(crate) description: Option<HighlightMask>,
}
#[derive(Clone, Debug)]
pub(crate) enum ModelSelectorItemKind {
Header {
provider: String,
expanded: bool,
status: ProviderStatus,
provider_type: ProviderType,
},
Scope {
provider: String,
label: String,
scope: ModelScope,
status: ModelAvailabilityState,
},
Model {
provider: String,
model_index: usize,
},
Empty {
provider: String,
message: Option<String>,
status: Option<ModelAvailabilityState>,
},
}
impl ModelSelectorItem {
fn header(
provider: impl Into<String>,
expanded: bool,
status: ProviderStatus,
provider_type: ProviderType,
) -> Self {
Self {
kind: ModelSelectorItemKind::Header {
provider: provider.into(),
expanded,
status,
provider_type,
},
}
}
fn scope(
provider: impl Into<String>,
label: impl Into<String>,
scope: ModelScope,
status: ModelAvailabilityState,
) -> Self {
Self {
kind: ModelSelectorItemKind::Scope {
provider: provider.into(),
label: label.into(),
scope,
status,
},
}
}
fn model(provider: impl Into<String>, model_index: usize) -> Self {
Self {
kind: ModelSelectorItemKind::Model {
provider: provider.into(),
model_index,
},
}
}
fn empty(
provider: impl Into<String>,
message: Option<String>,
status: Option<ModelAvailabilityState>,
) -> Self {
Self {
kind: ModelSelectorItemKind::Empty {
provider: provider.into(),
message,
status,
},
}
}
fn is_model(&self) -> bool {
matches!(self.kind, ModelSelectorItemKind::Model { .. })
}
fn model_index(&self) -> Option<usize> {
match &self.kind {
ModelSelectorItemKind::Model { model_index, .. } => Some(*model_index),
_ => None,
}
}
fn provider_if_header(&self) -> Option<&str> {
match &self.kind {
ModelSelectorItemKind::Header { provider, .. }
| ModelSelectorItemKind::Scope { provider, .. } => Some(provider),
_ => None,
}
}
pub(crate) fn kind(&self) -> &ModelSelectorItemKind {
&self.kind
}
}
fn collect_lower_graphemes(text: &str) -> (Vec<&str>, Vec<String>) {
let graphemes: Vec<&str> = UnicodeSegmentation::graphemes(text, true).collect();
let lower: Vec<String> = graphemes.iter().map(|g| g.to_lowercase()).collect();
(graphemes, lower)
}
fn subsequence_highlight(candidate: &[String], query: &[String]) -> Option<Vec<bool>> {
if query.is_empty() {
return None;
}
let mut mask = vec![false; candidate.len()];
let mut q_idx = 0usize;
for (idx, g) in candidate.iter().enumerate() {
if q_idx < query.len() && g == &query[q_idx] {
mask[idx] = true;
q_idx += 1;
}
}
if q_idx == query.len() {
Some(mask)
} else {
None
}
}
fn search_candidate(candidate: &str, query: &str) -> Option<((usize, usize), HighlightMask)> {
let candidate = candidate.trim();
let query = query.trim();
if candidate.is_empty() || query.is_empty() {
return None;
}
let (original_graphemes, lower_graphemes) = collect_lower_graphemes(candidate);
let candidate_lower = lower_graphemes.join("");
let query_lower = query.to_lowercase();
let query_graphemes: Vec<String> = UnicodeSegmentation::graphemes(query_lower.as_str(), true)
.map(|g| g.to_string())
.collect();
let query_len = query_graphemes.len();
let mut mask = vec![false; original_graphemes.len()];
if candidate_lower == query_lower {
mask.fill(true);
return Some(((0, candidate.len()), HighlightMask::new(mask)));
}
if candidate_lower.starts_with(&query_lower) {
for idx in 0..query_len.min(mask.len()) {
mask[idx] = true;
}
return Some(((1, 0), HighlightMask::new(mask)));
}
if let Some(start_byte) = candidate_lower.find(&query_lower) {
let mut collected_bytes = 0usize;
let mut start_index = 0usize;
for (idx, grapheme) in lower_graphemes.iter().enumerate() {
if collected_bytes == start_byte {
start_index = idx;
break;
}
collected_bytes += grapheme.len();
}
for idx in start_index..(start_index + query_len).min(mask.len()) {
mask[idx] = true;
}
return Some(((2, start_byte), HighlightMask::new(mask)));
}
if let Some(subsequence_mask) = subsequence_highlight(&lower_graphemes, &query_graphemes) {
if subsequence_mask.iter().any(|b| *b) {
return Some(((3, candidate.len()), HighlightMask::new(subsequence_mask)));
}
}
None
}
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub(crate) enum ModelScope {
Local,
Cloud,
Other(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ModelAvailabilityState {
Unknown,
Available,
Unavailable,
}
impl Default for ModelAvailabilityState {
fn default() -> Self {
Self::Unknown
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct ScopeStatusEntry {
pub state: ModelAvailabilityState,
pub message: Option<String>,
pub last_checked_secs: Option<u64>,
pub last_success_secs: Option<u64>,
pub is_stale: bool,
}
pub(crate) type ProviderScopeStatus = BTreeMap<ModelScope, ScopeStatusEntry>;
/// Messages emitted by asynchronous streaming tasks
#[derive(Debug)]
pub enum SessionEvent {
StreamChunk {
message_id: Uuid,
response: ChatResponse,
},
StreamError {
message_id: Option<Uuid>,
message: String,
},
ToolExecutionNeeded {
message_id: Uuid,
tool_calls: Vec<owlen_core::types::ToolCall>,
},
/// Agent iteration update (shows THOUGHT/ACTION/OBSERVATION)
AgentUpdate { content: String },
/// Agent execution completed with final answer
AgentCompleted { answer: String },
/// Agent execution failed
AgentFailed { error: String },
/// Poll the OAuth device authorization flow for the given server
OAuthPoll {
server: String,
authorization: DeviceAuthorization,
},
}
pub const HELP_TAB_COUNT: usize = 7;
pub struct ChatApp {
controller: SessionController,
pub mode: InputMode,
mode_flash_until: Option<Instant>,
pub status: String,
pub error: Option<String>,
models: Vec<ModelInfo>, // All models fetched
annotated_models: Vec<AnnotatedModelInfo>, // Models annotated with provider metadata
provider_scope_status: HashMap<String, ProviderScopeStatus>,
pub available_providers: Vec<String>, // Unique providers from models
pub selected_provider: String, // The currently selected provider
pub selected_provider_index: usize, // Index into the available_providers list
pub selected_model_item: Option<usize>, // Index into the flattened model selector list
model_selector_items: Vec<ModelSelectorItem>, // Flattened provider/model list for selector
model_filter_mode: FilterMode, // Active filter applied to the model list
model_filter_memory: FilterMode, // Last user-selected filter mode
model_search_query: String, // Active fuzzy search query for the picker
model_search_hits: HashMap<usize, ModelSearchInfo>, // Cached search metadata per model index
provider_search_hits: HashMap<String, HighlightMask>, // Cached search highlight per provider
visible_model_count: usize, // Number of visible models in current selector view
model_info_panel: ModelInfoPanel, // Dedicated model information viewer
model_details_cache: HashMap<String, DetailedModelInfo>, // Cached detailed metadata per model
show_model_info: bool, // Whether the model info panel is visible
model_info_viewport_height: usize, // Cached viewport height for the info panel
expanded_provider: Option<String>, // Which provider group is currently expanded
current_provider: String, // Provider backing the active session
message_line_cache: HashMap<Uuid, MessageCacheEntry>, // Cached rendered lines per message
show_cursor_outside_insert: bool, // Configurable cursor visibility flag
syntax_highlighting: bool, // Whether syntax highlighting is enabled
render_markdown: bool, // Whether markdown rendering is enabled
show_message_timestamps: bool, // Whether to render timestamps in chat headers
auto_scroll: AutoScroll, // Auto-scroll state for message rendering
thinking_scroll: AutoScroll, // Auto-scroll state for thinking panel
viewport_height: usize, // Track the height of the messages viewport
thinking_viewport_height: usize, // Track the height of the thinking viewport
content_width: usize, // Track the content width for line wrapping calculations
session_tx: mpsc::UnboundedSender<SessionEvent>,
streaming: HashSet<Uuid>,
stream_tasks: HashMap<Uuid, JoinHandle<()>>,
textarea: TextArea<'static>, // Advanced text input widget
mvu_model: AppModel,
keymap: Keymap,
current_keymap_profile: KeymapProfile,
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
pending_llm_request: bool, // Flag to indicate LLM request needs to be processed
pending_tool_execution: Option<(Uuid, Vec<owlen_core::types::ToolCall>)>, // Pending tool execution (message_id, tool_calls)
loading_animation_frame: usize, // Frame counter for loading animation
is_loading: bool, // Whether we're currently loading a response
current_thinking: Option<String>, // Current thinking content from last assistant message
// Holds the latest formatted Agentic ReAct actions (thought/action/observation)
agent_actions: Option<String>,
pending_key: Option<char>, // For multi-key sequences like gg, dd
clipboard: String, // Vim-style clipboard for yank/paste
pending_file_action: Option<FileActionPrompt>, // Active file action prompt
command_palette: CommandPalette, // Command mode state (buffer + suggestions)
resource_catalog: Vec<McpResourceConfig>, // Configured MCP resources for autocompletion
pending_resource_refs: Vec<String>, // Resource references to resolve before send
oauth_flows: HashMap<String, DeviceAuthorization>, // Active OAuth device flows by server
repo_search: RepoSearchState, // Repository search overlay state
repo_search_task: Option<JoinHandle<()>>,
repo_search_rx: Option<mpsc::UnboundedReceiver<RepoSearchMessage>>,
repo_search_file_map: HashMap<PathBuf, usize>,
symbol_search: SymbolSearchState, // Symbol search overlay state
symbol_search_task: Option<JoinHandle<()>>,
symbol_search_rx: Option<mpsc::UnboundedReceiver<SymbolSearchMessage>>,
visual_start: Option<(usize, usize)>, // Visual mode selection start (row, col) for Input panel
visual_end: Option<(usize, usize)>, // Visual mode selection end (row, col) for scrollable panels
focused_panel: FocusedPanel, // Currently focused panel for scrolling
chat_cursor: (usize, usize), // Cursor position in Chat panel (row, col)
chat_line_offset: usize, // Number of leading lines trimmed for scrollback
thinking_cursor: (usize, usize), // Cursor position in Thinking panel (row, col)
code_workspace: CodeWorkspace, // Code views with tabs/splits
pending_focus_chord: Option<Instant>, // Tracks Ctrl+K focus chord timeout
last_resize_tap: Option<(PaneDirection, Instant)>, // For Alt+arrow double-tap detection
resize_snap_index: usize, // Cycles through 25/50/75 snaps
last_snap_direction: Option<PaneDirection>,
last_ctrl_c: Option<Instant>, // Track timing for double Ctrl+C quit
file_tree: FileTreeState, // Workspace file tree state
file_icons: FileIconResolver, // Icon resolver with Nerd/ASCII fallback
file_panel_collapsed: bool, // Whether the file panel is collapsed
file_panel_width: u16, // Cached file panel width
saved_sessions: Vec<SessionMeta>, // Cached list of saved sessions
selected_session_index: usize, // Index of selected session in browser
help_tab_index: usize, // Currently selected help tab (0-(HELP_TAB_COUNT-1))
theme: Theme, // Current theme
available_themes: Vec<String>, // Cached list of theme names
selected_theme_index: usize, // Index of selected theme in browser
pending_consent: Option<ConsentDialogState>, // Pending consent request
queued_consents: VecDeque<ConsentDialogState>, // Backlog of consent requests
system_status: String, // System/status messages (tool execution, status, etc)
toasts: ToastManager,
debug_log: DebugLogState,
usage_snapshot: Option<UsageSnapshot>,
usage_thresholds: HashMap<(String, UsageWindow), UsageBand>,
context_usage: Option<ContextUsage>,
last_layout: LayoutSnapshot,
/// Simple execution budget: maximum number of tool calls allowed per session.
_execution_budget: usize,
/// Agent mode enabled
agent_mode: bool,
/// Agent running flag
agent_running: bool,
/// Operating mode (Chat or Code)
operating_mode: owlen_core::mode::Mode,
/// Flag indicating new messages arrived while scrolled away from tail
new_message_alert: bool,
}
#[derive(Clone, Debug)]
pub struct ConsentDialogState {
pub request_id: Uuid,
pub tool_name: String,
pub data_types: Vec<String>,
pub endpoints: Vec<String>,
pub message_id: Uuid,
pub tool_calls: Vec<owlen_core::types::ToolCall>,
}
#[derive(Clone)]
struct MessageCacheEntry {
theme_name: String,
wrap_width: usize,
role_label_mode: RoleLabelDisplay,
syntax_highlighting: bool,
render_markdown: bool,
show_timestamps: bool,
content_hash: u64,
lines: Vec<Line<'static>>,
metrics: MessageLayoutMetrics,
}
#[derive(Clone, Debug, Default)]
struct MessageLayoutMetrics {
line_count: usize,
body_width: usize,
card_width: usize,
}
pub(crate) struct MessageRenderContext<'a> {
formatter: &'a mut owlen_core::formatting::MessageFormatter,
role_label_mode: RoleLabelDisplay,
body_width: usize,
card_width: usize,
is_streaming: bool,
loading_indicator: &'a str,
theme: &'a Theme,
syntax_highlighting: bool,
render_markdown: bool,
}
impl<'a> MessageRenderContext<'a> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
formatter: &'a mut owlen_core::formatting::MessageFormatter,
role_label_mode: RoleLabelDisplay,
body_width: usize,
card_width: usize,
is_streaming: bool,
loading_indicator: &'a str,
theme: &'a Theme,
syntax_highlighting: bool,
render_markdown: bool,
) -> Self {
Self {
formatter,
role_label_mode,
body_width,
card_width,
is_streaming,
loading_indicator,
theme,
syntax_highlighting,
render_markdown,
}
}
}
#[derive(Debug, Clone)]
enum MessageSegment {
Text {
lines: Vec<String>,
},
CodeBlock {
language: Option<String>,
lines: Vec<String>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FileOpenDisposition {
Primary,
SplitHorizontal,
SplitVertical,
Tab,
}
#[derive(Debug, Clone)]
struct FileActionPrompt {
kind: FileActionKind,
buffer: String,
}
#[derive(Debug, Clone)]
enum FileActionKind {
CreateFile { base: PathBuf },
CreateFolder { base: PathBuf },
Rename { original: PathBuf },
Move { original: PathBuf },
Delete { target: PathBuf, confirm: String },
}
impl FileActionPrompt {
fn new(kind: FileActionKind, initial: impl Into<String>) -> Self {
Self {
kind,
buffer: initial.into(),
}
}
fn push_char(&mut self, ch: char) {
self.buffer.push(ch);
}
fn pop_char(&mut self) {
self.buffer.pop();
}
fn set_buffer(&mut self, buffer: impl Into<String>) {
self.buffer = buffer.into();
}
fn is_destructive(&self) -> bool {
matches!(self.kind, FileActionKind::Delete { .. })
}
}
impl ChatApp {
pub async fn new(
controller: SessionController,
controller_event_rx: mpsc::UnboundedReceiver<ControllerEvent>,
) -> Result<(Self, mpsc::UnboundedReceiver<SessionEvent>)> {
let (session_tx, session_rx) = mpsc::unbounded_channel();
let mut textarea = TextArea::default();
configure_textarea_defaults(&mut textarea);
// Load theme and provider based on config before moving `controller`.
let config_guard = controller.config_async().await;
let theme_name = config_guard.ui.theme.clone();
let current_provider = Self::canonical_provider_id(&config_guard.general.default_provider);
let show_onboarding = config_guard.ui.show_onboarding;
let show_cursor_outside_insert = config_guard.ui.show_cursor_outside_insert;
let syntax_highlighting = config_guard.ui.syntax_highlighting;
let render_markdown = config_guard.ui.render_markdown;
let show_timestamps = config_guard.ui.show_timestamps;
let icon_mode = config_guard.ui.icon_mode;
let keymap_path = config_guard.ui.keymap_path.clone();
let keymap_profile = config_guard.ui.keymap_profile.clone();
drop(config_guard);
let keymap = {
let registry = CommandRegistry::default();
Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), &registry)
};
let current_keymap_profile = keymap.profile();
let theme = owlen_core::theme::get_theme(&theme_name).unwrap_or_else(|| {
eprintln!("Warning: Theme '{}' not found, using default", theme_name);
Theme::default()
});
let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let file_tree = FileTreeState::new(workspace_root);
let file_icons = FileIconResolver::from_mode(icon_mode);
install_global_logger();
let mut app = Self {
controller,
mode: InputMode::Normal,
mode_flash_until: None,
status: if show_onboarding {
ONBOARDING_STATUS_LINE.to_string()
} else {
"Normal mode • Press F1 for help".to_string()
},
error: None,
models: Vec::new(),
annotated_models: Vec::new(),
provider_scope_status: HashMap::new(),
available_providers: Vec::new(),
selected_provider: current_provider.clone(),
selected_provider_index: 0,
selected_model_item: None,
model_selector_items: Vec::new(),
model_filter_mode: FilterMode::All,
model_filter_memory: FilterMode::All,
model_search_query: String::new(),
model_search_hits: HashMap::new(),
provider_search_hits: HashMap::new(),
visible_model_count: 0,
model_info_panel: ModelInfoPanel::new(),
model_details_cache: HashMap::new(),
show_model_info: false,
model_info_viewport_height: 0,
expanded_provider: None,
current_provider,
message_line_cache: HashMap::new(),
auto_scroll: AutoScroll::default(),
thinking_scroll: AutoScroll::default(),
viewport_height: 10, // Default viewport height, will be updated during rendering
thinking_viewport_height: 4, // Default thinking viewport height
content_width: 80, // Default content width, will be updated during rendering
session_tx,
streaming: std::collections::HashSet::new(),
stream_tasks: HashMap::new(),
textarea,
mvu_model: AppModel::default(),
keymap,
current_keymap_profile,
controller_event_rx,
pending_llm_request: false,
pending_tool_execution: None,
loading_animation_frame: 0,
is_loading: false,
current_thinking: None,
agent_actions: None,
pending_key: None,
clipboard: String::new(),
pending_file_action: None,
command_palette: CommandPalette::new(),
resource_catalog: Vec::new(),
pending_resource_refs: Vec::new(),
oauth_flows: HashMap::new(),
repo_search: RepoSearchState::new(),
repo_search_task: None,
repo_search_rx: None,
repo_search_file_map: HashMap::new(),
symbol_search: SymbolSearchState::new(),
symbol_search_task: None,
symbol_search_rx: None,
visual_start: None,
visual_end: None,
focused_panel: FocusedPanel::Input,
chat_cursor: (0, 0),
chat_line_offset: 0,
thinking_cursor: (0, 0),
code_workspace: CodeWorkspace::new(),
pending_focus_chord: None,
last_resize_tap: None,
resize_snap_index: 0,
last_snap_direction: None,
last_ctrl_c: None,
file_tree,
file_icons,
file_panel_collapsed: true,
file_panel_width: 32,
saved_sessions: Vec::new(),
selected_session_index: 0,
help_tab_index: 0,
theme,
available_themes: Vec::new(),
selected_theme_index: 0,
pending_consent: None,
queued_consents: VecDeque::new(),
system_status: if show_onboarding {
ONBOARDING_SYSTEM_STATUS.to_string()
} else {
String::new()
},
toasts: ToastManager::new(),
debug_log: DebugLogState::new(),
usage_snapshot: None,
usage_thresholds: HashMap::new(),
context_usage: None,
last_layout: LayoutSnapshot::default(),
_execution_budget: 50,
agent_mode: false,
agent_running: false,
operating_mode: owlen_core::mode::Mode::default(),
new_message_alert: false,
show_cursor_outside_insert,
syntax_highlighting,
render_markdown,
show_message_timestamps: show_timestamps,
};
app.mvu_model.composer.mode = InputMode::Normal;
app.mvu_model.composer.draft = app.controller.input_buffer().text().to_string();
app.append_system_status(&format!(
"Icons: {} ({})",
app.file_icons.status_label(),
app.file_icons.detection_label()
));
app.update_command_palette_catalog();
app.refresh_resource_catalog().await?;
app.refresh_mcp_slash_commands().await?;
if let Err(err) = app.restore_workspace_layout().await {
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))
}
/// Check if consent dialog is currently shown
pub fn has_pending_consent(&self) -> bool {
self.pending_consent.is_some()
}
/// Get the current consent dialog state
pub fn consent_dialog(&self) -> Option<&ConsentDialogState> {
self.pending_consent.as_ref()
}
fn enqueue_consent_request(&mut self, consent: ConsentDialogState) {
if self.pending_consent.is_none() {
self.pending_consent = Some(consent);
} else {
self.queued_consents.push_back(consent);
}
}
fn advance_consent_queue(&mut self) {
if self.pending_consent.is_some() {
return;
}
if let Some(next) = self.queued_consents.pop_front() {
self.pending_consent = Some(next);
}
}
fn handle_controller_event(&mut self, event: ControllerEvent) -> Result<()> {
match event {
ControllerEvent::ToolRequested {
request_id,
message_id,
tool_name,
data_types,
endpoints,
tool_calls,
} => {
self.enqueue_consent_request(ConsentDialogState {
request_id,
message_id,
tool_name,
data_types,
endpoints,
tool_calls,
});
}
}
Ok(())
}
fn apply_tool_consent_resolution(&mut self, resolution: ToolConsentResolution) -> Result<()> {
let ToolConsentResolution {
message_id,
tool_name,
scope,
tool_calls,
..
} = resolution;
match scope {
ConsentScope::Denied => {
self.pending_tool_execution = None;
self.status = format!("✗ Consent denied for {}", tool_name);
self.set_system_status(format!("✗ Consent denied: {}", tool_name));
self.error = Some(format!("Tool {} was blocked by user", tool_name));
self.controller
.conversation_mut()
.push_assistant_message(format!(
"I could not execute `{tool_name}` because consent was denied. \
Replying without running the tool."
));
self.notify_new_activity();
}
ConsentScope::Once | ConsentScope::Session | ConsentScope::Permanent => {
let scope_label = match scope {
ConsentScope::Once => "once",
ConsentScope::Session => "session",
ConsentScope::Permanent => "permanent",
ConsentScope::Denied => unreachable!("handled above"),
};
self.status = format!("✓ Consent granted ({scope_label}) for {}", tool_name);
self.set_system_status(format!("✓ Consent granted ({scope_label}): {}", tool_name));
self.error = None;
self.pending_tool_execution = Some((message_id, tool_calls));
}
}
self.pending_consent = None;
self.advance_consent_queue();
Ok(())
}
pub fn status_message(&self) -> &str {
&self.status
}
pub fn error_message(&self) -> Option<&String> {
self.error.as_ref()
}
pub fn mode(&self) -> InputMode {
self.mode
}
pub fn conversation(&self) -> &Conversation {
self.controller.conversation()
}
pub fn selected_model(&self) -> &str {
self.controller.selected_model()
}
pub fn current_provider(&self) -> &str {
&self.current_provider
}
pub fn usage_snapshot(&self) -> Option<&UsageSnapshot> {
self.usage_snapshot.as_ref()
}
pub fn context_usage_with_fallback(&self) -> Option<ContextUsage> {
if let Some(usage) = self.context_usage {
Some(usage)
} else {
self.active_context_window().map(|window| ContextUsage {
prompt_tokens: 0,
completion_tokens: 0,
context_window: window,
})
}
}
fn update_context_usage(&mut self, usage: &TokenUsage) {
let context_window = self
.active_context_window()
.unwrap_or(DEFAULT_CONTEXT_WINDOW_TOKENS);
self.context_usage = Some(ContextUsage {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
context_window,
});
}
fn active_context_window(&self) -> Option<u32> {
let current_model = self.controller.selected_model();
self.models.iter().find_map(|model| {
if model.id == current_model || model.name == current_model {
model.context_window
} else {
None
}
})
}
pub fn should_show_code_view(&self) -> bool {
if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) {
return false;
}
if let Some(pane) = self.code_workspace.active_pane() {
return pane.display_path().is_some() || !pane.lines.is_empty();
}
false
}
pub fn code_view_path(&self) -> Option<&str> {
self.code_workspace
.active_pane()
.and_then(|pane| pane.display_path())
}
pub fn is_code_mode(&self) -> bool {
matches!(self.operating_mode, owlen_core::mode::Mode::Code)
}
pub fn code_view_lines(&self) -> &[String] {
self.code_workspace
.active_pane()
.map(|pane| pane.lines.as_slice())
.unwrap_or(&[])
}
pub fn code_view_scroll(&self) -> Option<&AutoScroll> {
self.code_workspace.active_pane().map(|pane| &pane.scroll)
}
pub fn code_view_scroll_mut(&mut self) -> Option<&mut AutoScroll> {
self.code_workspace
.active_tab_mut()
.and_then(|tab| tab.active_pane_mut())
.map(|pane| &mut pane.scroll)
}
pub fn set_code_view_viewport_height(&mut self, height: usize) {
self.code_workspace.set_active_viewport_height(height);
}
pub fn repo_search(&self) -> &RepoSearchState {
&self.repo_search
}
pub fn repo_search_mut(&mut self) -> &mut RepoSearchState {
&mut self.repo_search
}
pub fn symbol_search(&self) -> &SymbolSearchState {
&self.symbol_search
}
pub fn symbol_search_mut(&mut self) -> &mut SymbolSearchState {
&mut self.symbol_search
}
fn repo_search_display_path(&self, absolute: &Path) -> String {
if let Some(relative) = diff_paths(absolute, self.file_tree().root()) {
if relative.as_os_str().is_empty() {
".".to_string()
} else {
relative.to_string_lossy().into_owned()
}
} else {
absolute.to_string_lossy().into_owned()
}
}
fn ensure_repo_search_file_index(&mut self, path: &Path) -> usize {
if let Some(index) = self.repo_search_file_map.get(path).copied() {
return index;
}
let display = self.repo_search_display_path(path);
let idx = self
.repo_search
.ensure_file_entry(path.to_path_buf(), display);
self.repo_search_file_map.insert(path.to_path_buf(), idx);
idx
}
fn cancel_repo_search_process(&mut self) {
if let Some(handle) = self.repo_search_task.take() {
handle.abort();
}
self.repo_search_rx = None;
}
fn poll_repo_search(&mut self) {
if let Some(mut rx) = self.repo_search_rx.take() {
use tokio::sync::mpsc::error::TryRecvError;
let mut keep_receiver = true;
loop {
match rx.try_recv() {
Ok(message) => {
if !self.handle_repo_search_message(message) {
keep_receiver = false;
break;
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.repo_search_task = None;
keep_receiver = false;
break;
}
}
}
if keep_receiver {
self.repo_search_rx = Some(rx);
}
}
}
fn handle_repo_search_message(&mut self, message: RepoSearchMessage) -> bool {
match message {
RepoSearchMessage::File { path } => {
self.ensure_repo_search_file_index(&path);
true
}
RepoSearchMessage::Match {
path,
line_number,
column,
preview,
matched,
} => {
let idx = self.ensure_repo_search_file_index(&path);
self.repo_search
.add_match(idx, line_number, column, preview, matched);
true
}
RepoSearchMessage::Done { matches } => {
self.repo_search.finish(matches);
self.repo_search_task = None;
self.repo_search_rx = None;
self.status = if matches == 0 {
"Repo search: no matches".to_string()
} else {
format!("Repo search: {matches} match(es)")
};
false
}
RepoSearchMessage::Error(err) => {
self.repo_search.mark_error(err.clone());
self.repo_search_task = None;
self.repo_search_rx = None;
self.error = Some(err.clone());
self.status = format!("Repo search failed: {err}");
false
}
}
}
async fn start_repo_search(&mut self) -> Result<()> {
let Some(query) = self.repo_search.prepare_run() else {
if self.repo_search.query_input().is_empty() {
self.status = "Enter a search query".to_string();
}
return Ok(());
};
self.cancel_repo_search_process();
self.repo_search_file_map.clear();
let root = self.file_tree().root().to_path_buf();
match spawn_repo_search_task(root, query.clone()) {
Ok((handle, rx)) => {
self.repo_search_task = Some(handle);
self.repo_search_rx = Some(rx);
self.status = format!("Searching for \"{query}\"");
self.error = None;
}
Err(err) => {
let message = err.to_string();
self.repo_search.mark_error(message.clone());
self.error = Some(message.clone());
self.status = format!("Failed to start search: {message}");
}
}
Ok(())
}
async fn open_repo_search_match(&mut self) -> Result<()> {
let Some((file_index, match_index)) = self.repo_search.selected_indices() else {
self.status = "Select a match to open".to_string();
return Ok(());
};
if !self.is_code_mode() {
self.status = "Switch to code mode to open repository matches".to_string();
self.error = None;
return Ok(());
}
let (absolute, display, line_number, column) = {
let file = &self.repo_search.files()[file_index];
let m = &file.matches[match_index];
(
file.absolute.clone(),
file.display.clone(),
m.line_number,
m.column,
)
};
let root = self.file_tree().root().to_path_buf();
let request_path = if absolute.starts_with(&root) {
diff_paths(&absolute, &root)
.filter(|rel| !rel.as_os_str().is_empty())
.map(|rel| rel.to_string_lossy().into_owned())
.unwrap_or_else(|| absolute.to_string_lossy().into_owned())
} else {
absolute.to_string_lossy().into_owned()
};
if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) {
self.set_mode(owlen_core::mode::Mode::Code).await;
}
match self.controller.read_file_with_tools(&request_path).await {
Ok(content) => {
self.prepare_code_view_target(FileOpenDisposition::Primary);
self.set_code_view_content(display.clone(), Some(absolute.clone()), content);
if let Some(pane) = self.code_workspace.active_pane_mut() {
pane.scroll.stick_to_bottom = false;
let target_line = line_number.saturating_sub(1) as usize;
let viewport = pane.viewport_height.max(1);
let scroll = target_line.saturating_sub(viewport / 2);
pane.scroll.scroll = scroll;
}
self.file_tree_mut().reveal(&absolute);
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.set_input_mode(InputMode::Normal);
self.status = format!("Opened {}:{}:{column}", display, line_number);
self.error = None;
}
Err(err) => {
let message = format!("Failed to open {}: {}", display, err);
self.error = Some(message.clone());
self.status = message;
}
}
Ok(())
}
async fn open_repo_search_scratch(&mut self) -> Result<()> {
if !self.repo_search.has_results() {
self.status = "No matches to open".to_string();
return Ok(());
}
if !self.is_code_mode() {
self.status = "Switch to code mode to open repository matches".to_string();
self.error = None;
return Ok(());
}
let mut buffer = String::new();
for file in self.repo_search.files() {
if file.matches.is_empty() {
continue;
}
buffer.push_str(&format!("{}\n", file.display));
for m in &file.matches {
buffer.push_str(&format!(
" {:>6}:{:<3} {}\n",
m.line_number, m.column, m.preview
));
}
buffer.push('\n');
}
let title = if let Some(query) = self.repo_search.last_query() {
format!("Search results: {query}")
} else {
"Search results".to_string()
};
if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) {
self.set_mode(owlen_core::mode::Mode::Code).await;
}
self.code_workspace.open_new_tab();
self.set_code_view_content(title.clone(), None::<PathBuf>, buffer);
if let Some(pane) = self.code_workspace.active_pane_mut() {
pane.is_dirty = false;
pane.is_staged = false;
}
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.set_input_mode(InputMode::Normal);
self.status = format!("Opened scratch buffer for {title}");
Ok(())
}
fn cancel_symbol_search_process(&mut self) {
if let Some(handle) = self.symbol_search_task.take() {
handle.abort();
}
self.symbol_search_rx = None;
}
fn poll_symbol_search(&mut self) {
if let Some(mut rx) = self.symbol_search_rx.take() {
use tokio::sync::mpsc::error::TryRecvError;
let mut keep_receiver = true;
loop {
match rx.try_recv() {
Ok(message) => {
if !self.handle_symbol_search_message(message) {
keep_receiver = false;
break;
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.symbol_search_task = None;
keep_receiver = false;
break;
}
}
}
if keep_receiver {
self.symbol_search_rx = Some(rx);
}
}
}
fn handle_symbol_search_message(&mut self, message: SymbolSearchMessage) -> bool {
match message {
SymbolSearchMessage::Symbols(batch) => {
self.symbol_search.add_symbols(batch);
true
}
SymbolSearchMessage::Done => {
self.symbol_search.finish();
self.symbol_search_task = None;
self.symbol_search_rx = None;
self.status = "Symbol index ready".to_string();
false
}
SymbolSearchMessage::Error(err) => {
self.symbol_search.mark_error(err.clone());
self.symbol_search_task = None;
self.symbol_search_rx = None;
self.error = Some(err.clone());
self.status = format!("Symbol search failed: {err}");
false
}
}
}
async fn start_symbol_search(&mut self) -> Result<()> {
self.cancel_symbol_search_process();
self.symbol_search.begin_index();
let root = self.file_tree().root().to_path_buf();
match spawn_symbol_search_task(root) {
Ok((handle, rx)) => {
self.symbol_search_task = Some(handle);
self.symbol_search_rx = Some(rx);
self.status = "Indexing symbols…".to_string();
self.error = None;
}
Err(err) => {
let message = err.to_string();
self.symbol_search.mark_error(message.clone());
self.error = Some(message.clone());
self.status = format!("Unable to start symbol search: {message}");
}
}
Ok(())
}
async fn open_symbol_search_entry(&mut self) -> Result<()> {
let Some(entry) = self.symbol_search.selected_entry().cloned() else {
self.status = "Select a symbol".to_string();
return Ok(());
};
let root = self.file_tree().root().to_path_buf();
let request_path = if entry.file.starts_with(&root) {
diff_paths(&entry.file, &root)
.filter(|rel| !rel.as_os_str().is_empty())
.map(|rel| rel.to_string_lossy().into_owned())
.unwrap_or_else(|| entry.file.to_string_lossy().into_owned())
} else {
entry.file.to_string_lossy().into_owned()
};
if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) {
self.set_mode(owlen_core::mode::Mode::Code).await;
}
match self.controller.read_file_with_tools(&request_path).await {
Ok(content) => {
self.prepare_code_view_target(FileOpenDisposition::Primary);
self.set_code_view_content(
entry.display_path.clone(),
Some(entry.file.clone()),
content,
);
if let Some(pane) = self.code_workspace.active_pane_mut() {
pane.scroll.stick_to_bottom = false;
let target_line = entry.line.saturating_sub(1) as usize;
let viewport = pane.viewport_height.max(1);
let scroll = target_line.saturating_sub(viewport / 2);
pane.scroll.scroll = scroll;
}
self.file_tree_mut().reveal(&entry.file);
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.set_input_mode(InputMode::Normal);
self.status = format!(
"Jumped to {} {}:{}",
entry.kind.label(),
entry.display_path,
entry.line
);
self.error = None;
}
Err(err) => {
let message = format!("Failed to open {}: {}", entry.display_path, err);
self.error = Some(message.clone());
self.status = message;
}
}
Ok(())
}
pub fn code_view_viewport_height(&self) -> usize {
self.code_workspace
.active_pane()
.map(|pane| pane.viewport_height)
.unwrap_or(0)
}
pub fn has_loaded_code_view(&self) -> bool {
self.code_workspace
.active_pane()
.map(|pane| pane.display_path().is_some() || !pane.lines.is_empty())
.unwrap_or(false)
}
pub fn file_tree(&self) -> &FileTreeState {
&self.file_tree
}
pub fn file_tree_mut(&mut self) -> &mut FileTreeState {
&mut self.file_tree
}
pub fn file_icons(&self) -> &FileIconResolver {
&self.file_icons
}
pub fn workspace(&self) -> &CodeWorkspace {
&self.code_workspace
}
pub fn workspace_mut(&mut self) -> &mut CodeWorkspace {
&mut self.code_workspace
}
pub fn is_file_panel_collapsed(&self) -> bool {
self.file_panel_collapsed
}
pub fn set_file_panel_collapsed(&mut self, collapsed: bool) {
self.file_panel_collapsed = collapsed;
}
pub fn file_panel_width(&self) -> u16 {
self.file_panel_width
}
pub fn set_file_panel_width(&mut self, width: u16) -> u16 {
const MIN_WIDTH: u16 = 24;
const MAX_WIDTH: u16 = 80;
let clamped = width.clamp(MIN_WIDTH, MAX_WIDTH);
self.file_panel_width = clamped;
clamped
}
pub fn expand_file_panel(&mut self) {
if !self.is_code_mode() {
self.status = "Switch to code mode to use the file explorer".to_string();
self.error = None;
return;
}
if self.file_panel_collapsed {
self.file_panel_collapsed = false;
self.focused_panel = FocusedPanel::Files;
self.ensure_focus_valid();
}
}
pub fn collapse_file_panel(&mut self) {
if !self.file_panel_collapsed {
self.file_panel_collapsed = true;
if matches!(self.focused_panel, FocusedPanel::Files) {
self.focused_panel = FocusedPanel::Chat;
}
self.ensure_focus_valid();
}
}
pub fn toggle_file_panel(&mut self) {
if !self.is_code_mode() {
self.status = "File explorer is available in code mode".to_string();
self.error = None;
return;
}
if self.file_panel_collapsed {
self.expand_file_panel();
} else {
self.collapse_file_panel();
}
}
// Synchronous access for UI rendering and other callers that expect an immediate Config.
pub fn config(&self) -> tokio::sync::MutexGuard<'_, owlen_core::config::Config> {
self.controller.config()
}
// Asynchronous version retained for places that already await the config.
pub async fn config_async(&self) -> tokio::sync::MutexGuard<'_, owlen_core::config::Config> {
self.controller.config_async().await
}
/// Get the current operating mode
pub fn get_mode(&self) -> owlen_core::mode::Mode {
self.operating_mode
}
/// Set the operating mode
pub async fn set_mode(&mut self, mode: owlen_core::mode::Mode) {
if let Err(err) = self.controller.set_operating_mode(mode).await {
self.error = Some(format!("Failed to switch mode: {}", err));
return;
}
if !matches!(mode, owlen_core::mode::Mode::Code) {
self.collapse_file_panel();
self.close_code_view();
self.set_system_status(String::new());
}
self.operating_mode = mode;
self.status = format!("Switched to {} mode", mode);
self.error = None;
}
async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> {
self.controller
.set_tool_enabled(WEB_SEARCH_TOOL_NAME, enabled)
.await
.map_err(|err| anyhow!(err))?;
config::save_config(&self.controller.config())?;
self.refresh_usage_summary().await?;
Ok(())
}
/// Override the status line with a custom message.
pub fn set_status_message<S: Into<String>>(&mut self, status: S) {
self.status = status.into();
}
pub(crate) fn model_selector_items(&self) -> &[ModelSelectorItem] {
&self.model_selector_items
}
pub(crate) fn annotated_models(&self) -> &[AnnotatedModelInfo] {
&self.annotated_models
}
pub(crate) fn model_filter_mode(&self) -> FilterMode {
self.model_filter_mode
}
pub(crate) fn model_search_query(&self) -> &str {
&self.model_search_query
}
pub(crate) fn model_search_info(&self, index: usize) -> Option<&ModelSearchInfo> {
self.model_search_hits.get(&index)
}
pub(crate) fn provider_search_highlight(&self, provider: &str) -> Option<&HighlightMask> {
self.provider_search_hits.get(provider)
}
pub(crate) fn visible_model_count(&self) -> usize {
self.visible_model_count
}
fn update_model_filter_mode(&mut self, mode: FilterMode) {
if self.model_filter_mode != mode {
self.model_filter_mode = mode;
self.model_filter_memory = mode;
self.rebuild_model_selector_items();
} else if !self.model_search_query.is_empty() {
// Refresh search results against current filter
self.rebuild_model_selector_items();
}
}
fn push_model_search_char(&mut self, ch: char) {
if ch.is_control() {
return;
}
self.model_search_query.push(ch);
self.rebuild_model_selector_items();
self.update_model_search_status();
}
fn pop_model_search_char(&mut self) {
self.model_search_query.pop();
self.rebuild_model_selector_items();
self.update_model_search_status();
}
fn clear_model_search_query(&mut self) {
if self.model_search_query.is_empty() {
return;
}
self.model_search_query.clear();
self.rebuild_model_selector_items();
self.update_model_search_status();
}
fn reset_model_picker_state(&mut self) {
if !self.model_search_query.is_empty() {
self.model_search_query.clear();
}
self.model_search_hits.clear();
self.provider_search_hits.clear();
self.visible_model_count = 0;
}
fn update_model_search_status(&mut self) {
if !matches!(
self.mode,
InputMode::ModelSelection | InputMode::ProviderSelection
) {
return;
}
if self.model_search_query.is_empty() {
self.status = "Select a model to use".to_string();
} else {
let count = self.visible_model_count();
if count == 1 {
self.status = format!("Search \"{}\" → 1 match", self.model_search_query.trim());
} else {
self.status = format!(
"Search \"{}\"{} matches",
self.model_search_query.trim(),
count
);
}
}
}
pub fn selected_model_item(&self) -> Option<usize> {
self.selected_model_item
}
pub(crate) fn model_info_by_index(&self, index: usize) -> Option<&ModelInfo> {
self.models.get(index)
}
pub fn cached_model_detail(&self, model_name: &str) -> Option<&DetailedModelInfo> {
self.model_details_cache.get(model_name)
}
pub fn model_info_panel_mut(&mut self) -> &mut ModelInfoPanel {
&mut self.model_info_panel
}
pub fn is_model_info_visible(&self) -> bool {
self.show_model_info
}
pub fn set_model_info_visible(&mut self, visible: bool) {
self.show_model_info = visible;
if !visible {
self.model_info_panel.reset_scroll();
self.model_info_viewport_height = 0;
}
}
pub fn set_model_info_viewport_height(&mut self, height: usize) {
self.model_info_viewport_height = height;
}
pub fn model_info_viewport_height(&self) -> usize {
self.model_info_viewport_height
}
pub async fn ensure_model_details(
&mut self,
model_name: &str,
force_refresh: bool,
) -> Result<()> {
if !force_refresh
&& self.show_model_info
&& self
.model_info_panel
.current_model_name()
.is_some_and(|name| name == model_name)
{
self.set_model_info_visible(false);
self.status = "Closed model info panel".to_string();
self.error = None;
return Ok(());
}
if !force_refresh {
if let Some(info) = self.model_details_cache.get(model_name).cloned() {
self.model_info_panel.set_model_info(info);
self.set_model_info_visible(true);
self.status = format!("Showing model info for {}", model_name);
self.error = None;
return Ok(());
}
} else {
self.model_details_cache.remove(model_name);
self.controller.invalidate_model_details(model_name).await;
}
match self
.controller
.model_details(model_name, force_refresh)
.await
{
Ok(details) => {
self.model_details_cache
.insert(model_name.to_string(), details.clone());
self.model_info_panel.set_model_info(details);
self.set_model_info_visible(true);
self.status = if force_refresh {
format!("Refreshed model info for {}", model_name)
} else {
format!("Showing model info for {}", model_name)
};
self.error = None;
Ok(())
}
Err(err) => {
self.error = Some(format!("Failed to load model info: {}", err));
Err(err.into())
}
}
}
pub async fn prefetch_all_model_details(&mut self, force_refresh: bool) -> Result<()> {
if force_refresh {
self.controller.clear_model_details_cache().await;
}
match self.controller.all_model_details(force_refresh).await {
Ok(details) => {
if force_refresh {
self.model_details_cache.clear();
}
for info in details {
self.model_details_cache.insert(info.name.clone(), info);
}
if let Some(current) = self
.model_info_panel
.current_model_name()
.map(|s| s.to_string())
{
if let Some(updated) = self.model_details_cache.get(&current).cloned() {
self.model_info_panel.set_model_info(updated);
}
}
let total = self.model_details_cache.len();
self.status = format!("Cached model details for {} model(s)", total);
self.error = None;
Ok(())
}
Err(err) => {
self.error = Some(format!("Failed to prefetch model info: {}", err));
Err(err.into())
}
}
}
pub fn auto_scroll(&self) -> &AutoScroll {
&self.auto_scroll
}
pub fn auto_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.auto_scroll
}
pub fn scroll(&self) -> usize {
self.auto_scroll.scroll
}
pub fn thinking_scroll(&self) -> &AutoScroll {
&self.thinking_scroll
}
pub fn thinking_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.thinking_scroll
}
pub fn thinking_scroll_position(&self) -> usize {
self.thinking_scroll.scroll
}
pub fn message_count(&self) -> usize {
self.controller.conversation().messages.len()
}
pub fn streaming_count(&self) -> usize {
self.streaming.len()
}
pub fn formatter(&self) -> &owlen_core::formatting::MessageFormatter {
self.controller.formatter()
}
pub fn input_buffer(&self) -> &owlen_core::input::InputBuffer {
self.controller.input_buffer()
}
pub fn input_buffer_mut(&mut self) -> &mut owlen_core::input::InputBuffer {
self.controller.input_buffer_mut()
}
pub fn textarea(&self) -> &TextArea<'static> {
&self.textarea
}
pub fn textarea_mut(&mut self) -> &mut TextArea<'static> {
&mut self.textarea
}
pub fn system_status(&self) -> &str {
&self.system_status
}
pub fn set_system_status(&mut self, status: String) {
self.system_status = status;
}
pub fn append_system_status(&mut self, status: &str) {
if !self.system_status.is_empty() {
self.system_status.push_str(" | ");
}
self.system_status.push_str(status);
}
pub fn clear_system_status(&mut self) {
self.system_status.clear();
}
pub fn show_tutorial(&mut self) {
self.error = None;
self.status = TUTORIAL_STATUS.to_string();
self.system_status = TUTORIAL_SYSTEM_STATUS.to_string();
let tutorial_body = concat!(
"Keybindings overview:\n",
" • Movement: h/j/k/l, gg/G, w/b\n",
" • Insert text: i or a (Esc to exit)\n",
" • Visual select: v (Esc to exit)\n",
" • Command mode: : (press Enter to run, Esc to cancel)\n",
" • Send message: Enter in Insert mode\n",
" • Help overlay: F1 or ?\n"
);
self.controller
.conversation_mut()
.push_system_message(tutorial_body.to_string());
}
pub fn command_buffer(&self) -> &str {
self.command_palette.buffer()
}
pub fn command_suggestions(&self) -> &[PaletteSuggestion] {
self.command_palette.suggestions()
}
fn set_input_mode(&mut self, mode: InputMode) {
if self.mode != mode {
self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240));
}
if !matches!(
mode,
InputMode::ModelSelection | InputMode::ProviderSelection
) && matches!(
self.mode,
InputMode::ModelSelection | InputMode::ProviderSelection
) {
self.reset_model_picker_state();
}
self.mode = mode;
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::ModeChanged { mode }));
}
pub fn mode_flash_active(&self) -> bool {
self.mode_flash_until
.map(|deadline| Instant::now() < deadline)
.unwrap_or(false)
}
pub fn selected_suggestion(&self) -> usize {
self.command_palette.selected_index()
}
/// Returns all available commands with their aliases
/// Complete the current command with the selected suggestion
fn complete_command(&mut self) {
if let Some(suggestion) = self.command_palette.apply_selected() {
self.status = format!(":{}", suggestion);
}
}
pub fn focused_panel(&self) -> FocusedPanel {
self.focused_panel
}
pub fn visual_selection(&self) -> Option<((usize, usize), (usize, usize))> {
if let (Some(start), Some(end)) = (self.visual_start, self.visual_end) {
Some((start, end))
} else {
None
}
}
pub fn chat_cursor(&self) -> (usize, usize) {
self.chat_cursor
}
pub fn thinking_cursor(&self) -> (usize, usize) {
self.thinking_cursor
}
pub fn saved_sessions(&self) -> &[SessionMeta] {
&self.saved_sessions
}
pub fn selected_session_index(&self) -> usize {
self.selected_session_index
}
pub fn help_tab_index(&self) -> usize {
self.help_tab_index
}
pub fn available_themes(&self) -> &[String] {
&self.available_themes
}
pub fn selected_theme_index(&self) -> usize {
self.selected_theme_index
}
pub fn theme(&self) -> &Theme {
&self.theme
}
pub(crate) fn set_layout_snapshot(&mut self, snapshot: LayoutSnapshot) {
self.last_layout = snapshot;
}
fn region_for_position(&self, column: u16, row: u16) -> Option<UiRegion> {
self.last_layout.region_at(column, row)
}
pub fn current_keymap_profile(&self) -> KeymapProfile {
self.current_keymap_profile
}
fn reload_keymap_from_config(&mut self) -> Result<()> {
let registry = CommandRegistry::default();
let config = self.controller.config();
let keymap_path = config.ui.keymap_path.clone();
let keymap_profile = config.ui.keymap_profile.clone();
drop(config);
self.keymap = Keymap::load(keymap_path.as_deref(), keymap_profile.as_deref(), &registry);
self.current_keymap_profile = self.keymap.profile();
Ok(())
}
async fn switch_keymap_profile(&mut self, profile: KeymapProfile) -> Result<()> {
if self.current_keymap_profile == profile {
self.status = format!("Keymap already set to {}", profile.label());
self.error = None;
return Ok(());
}
{
let mut cfg = self.controller.config_mut();
cfg.ui.keymap_profile = Some(profile.config_value().to_string());
cfg.ui.keymap_path = None;
config::save_config(&cfg)?;
}
self.reload_keymap_from_config()?;
self.status = format!("Keymap switched to {}", profile.label());
self.error = None;
Ok(())
}
pub fn is_debug_log_visible(&self) -> bool {
self.debug_log.is_visible()
}
pub fn toggle_debug_log_panel(&mut self) {
let now_visible = self.debug_log.toggle_visible();
if now_visible {
self.status = "Debug log open — F12 to hide".to_string();
self.error = None;
} else {
self.status = "Debug log hidden".to_string();
self.error = None;
}
}
pub fn debug_log_entries(&self) -> Vec<DebugLogEntry> {
self.debug_log.entries()
}
pub fn toasts(&self) -> impl Iterator<Item = &Toast> {
self.toasts.iter()
}
pub fn push_toast(&mut self, level: ToastLevel, message: impl Into<String>) {
self.toasts.push(message, level);
}
fn prune_toasts(&mut self) {
self.toasts.retain_active();
}
fn poll_debug_log_updates(&mut self) {
let new_entries = self.debug_log.take_unseen();
if new_entries.is_empty() {
return;
}
let mut latest_summary: Option<(Level, String)> = None;
for entry in new_entries.iter() {
let toast_level = match entry.level {
Level::Error => ToastLevel::Error,
Level::Warn => ToastLevel::Warning,
_ => continue,
};
let summary = format!("{}: {}", entry.target, entry.message);
let clipped = Self::ellipsize(&summary, 120);
self.push_toast(toast_level, clipped.clone());
latest_summary = Some((entry.level, clipped));
}
if !self.debug_log.is_visible() {
if let Some((level, message)) = latest_summary {
let level_label = match level {
Level::Error => "Error",
Level::Warn => "Warning",
_ => "Log",
};
self.status = format!("{level_label}: {message} (F12 to open debug log)");
self.error = None;
}
}
}
fn ellipsize(message: &str, max_len: usize) -> String {
if message.chars().count() <= max_len {
return message.to_string();
}
let mut truncated = String::new();
for (idx, ch) in message.chars().enumerate() {
if idx + 1 >= max_len {
truncated.push('…');
break;
}
truncated.push(ch);
}
truncated
}
pub fn input_max_rows(&self) -> u16 {
let config = self.controller.config();
config.ui.input_max_rows.max(1)
}
pub fn active_model_label(&self) -> String {
let active_id = self.controller.selected_model();
if let Some(model) = self
.models
.iter()
.find(|m| m.id == active_id || m.name == active_id)
{
Self::display_name_for_model(model)
} else {
active_id.to_string()
}
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn is_streaming(&self) -> bool {
!self.streaming.is_empty()
}
pub fn scrollback_limit(&self) -> usize {
let limit = {
let config = self.controller.config();
config.ui.scrollback_lines
};
if limit == 0 { usize::MAX } else { limit }
}
pub fn has_new_message_alert(&self) -> bool {
self.new_message_alert
}
pub fn clear_new_message_alert(&mut self) {
self.new_message_alert = false;
}
fn notify_new_activity(&mut self) {
if !self.auto_scroll.stick_to_bottom {
self.new_message_alert = true;
}
}
fn update_new_message_alert_after_scroll(&mut self) {
if self.auto_scroll.stick_to_bottom {
self.clear_new_message_alert();
}
}
fn model_palette_entries(&self) -> Vec<ModelPaletteEntry> {
self.models
.iter()
.map(|model| ModelPaletteEntry {
id: model.id.clone(),
name: model.name.clone(),
provider: model.provider.clone(),
})
.collect()
}
fn update_command_palette_catalog(&mut self) {
let providers = self.available_providers.clone();
let models = self.model_palette_entries();
self.command_palette
.update_dynamic_sources(models, providers);
}
async fn refresh_resource_catalog(&mut self) -> Result<()> {
let mut resources = self.controller.configured_resources().await;
resources.sort_by(|a, b| a.server.cmp(&b.server).then(a.uri.cmp(&b.uri)));
self.resource_catalog = resources;
Ok(())
}
async fn refresh_mcp_slash_commands(&mut self) -> Result<()> {
let mut commands = Vec::new();
for (server, descriptor) in self.controller.list_mcp_tools().await {
if !Self::tool_supports_slash(&descriptor) {
continue;
}
let description = if descriptor.description.trim().is_empty() {
None
} else {
Some(descriptor.description.clone())
};
commands.push(McpSlashCommand::new(
server,
descriptor.name.clone(),
description,
));
}
slash::set_mcp_commands(commands);
Ok(())
}
fn tool_supports_slash(descriptor: &McpToolDescriptor) -> bool {
if descriptor.name.trim().is_empty() {
return false;
}
Self::tool_allows_empty_arguments(&descriptor.input_schema)
}
fn tool_allows_empty_arguments(schema: &Value) -> bool {
match schema {
Value::Object(map) => {
if let Some(Value::Array(required)) = map.get("required") {
!required
.iter()
.any(|entry| entry.as_str().is_some_and(|s| !s.is_empty()))
} else {
true
}
}
_ => true,
}
}
fn format_mcp_slash_message(server: &str, tool: &str, response: &McpToolResponse) -> String {
let status = if response.success { "" } else { "" };
let payload = if response.success {
Self::extract_mcp_primary_text(&response.output)
} else {
Self::extract_mcp_error(&response.output)
.or_else(|| Self::extract_mcp_primary_text(&response.output))
}
.unwrap_or_else(|| Self::pretty_print_value(&response.output));
if payload.trim().is_empty() {
return format!("MCP {server}::{tool} {status}");
}
if payload.contains('\n') {
format!("MCP {server}::{tool} {status}\n```json\n{payload}\n```")
} else {
format!("MCP {server}::{tool} {status}\n{payload}")
}
}
fn extract_mcp_primary_text(value: &Value) -> Option<String> {
if let Some(text) = value.as_str().filter(|text| !text.trim().is_empty()) {
return Some(text.to_string());
}
if let Value::Object(map) = value {
const CANDIDATES: [&str; 6] =
["rendered", "text", "content", "value", "message", "body"];
for key in CANDIDATES {
if let Some(Value::String(text)) = map.get(key) {
if !text.trim().is_empty() {
return Some(text.clone());
}
}
}
if let Some(Value::Array(items)) = map.get("lines") {
let mut collected = Vec::new();
for item in items {
if let Some(segment) = item.as_str() {
if !segment.trim().is_empty() {
collected.push(segment.trim());
}
}
}
if !collected.is_empty() {
return Some(collected.join("\n"));
}
}
}
None
}
fn extract_mcp_error(value: &Value) -> Option<String> {
if let Value::Object(map) = value {
if let Some(Value::String(message)) = map.get("error") {
if !message.trim().is_empty() {
return Some(message.clone());
}
}
}
None
}
fn pretty_print_value(value: &Value) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
}
async fn resolve_pending_resource_references(&mut self) -> Result<()> {
if self.pending_resource_refs.is_empty() {
return Ok(());
}
let mut resolved = 0usize;
let references: Vec<String> = self.pending_resource_refs.drain(..).collect();
for reference in references {
match self.controller.resolve_resource_reference(&reference).await {
Ok(Some(content)) => {
let message = format!("Resource @{}:\n{}", reference, content);
self.controller
.conversation_mut()
.push_system_message(message);
resolved += 1;
}
Ok(None) => {
self.push_toast(
ToastLevel::Warning,
format!(
"Resource @{} is not defined in the current project.",
reference
),
);
}
Err(err) => {
self.push_toast(
ToastLevel::Error,
format!("Failed to load resource @{}: {}", reference, err),
);
}
}
}
if resolved > 0 {
self.status = format!("Inserted {resolved} resource snippet(s).");
}
Ok(())
}
fn complete_resource_reference(&mut self) -> bool {
if self.resource_catalog.is_empty() {
return false;
}
let (row, col) = self.textarea.cursor();
let lines = self.textarea.lines().to_vec();
if row >= lines.len() {
return false;
}
let line = &lines[row];
let chars: Vec<char> = line.chars().collect();
if col > chars.len() {
return false;
}
let mut start = col;
while start > 0 {
let ch = chars[start - 1];
if ch == '@' {
start -= 1;
break;
}
if ch.is_whitespace() {
return false;
}
start -= 1;
}
if start >= col || chars.get(start) != Some(&'@') {
return false;
}
if chars[start + 1..col].iter().any(|ch| ch.is_whitespace()) {
return false;
}
let mut end = col;
while end < chars.len() {
let ch = chars[end];
if ch.is_whitespace() {
break;
}
end += 1;
}
let typed_prefix: String = chars[start + 1..col].iter().collect();
let trailing_segment: String = chars[col..end].iter().collect();
let lower_prefix = typed_prefix.to_ascii_lowercase();
let lower_full = format!("{}{}", typed_prefix, trailing_segment).to_ascii_lowercase();
let mut matches: Vec<&McpResourceConfig> = self
.resource_catalog
.iter()
.filter(|resource| {
let reference = format!("{}:{}", resource.server, resource.uri);
let lower_reference = reference.to_ascii_lowercase();
lower_reference.starts_with(&lower_full)
|| lower_reference.starts_with(&lower_prefix)
|| resource
.title
.as_ref()
.map(|title| title.to_ascii_lowercase().starts_with(&lower_prefix))
.unwrap_or(false)
})
.collect();
if matches.is_empty() {
return false;
}
matches.sort_by(|a, b| a.server.cmp(&b.server).then(a.uri.cmp(&b.uri)));
let (selected_server, selected_uri, selected_title) = {
let selected = matches[0];
(
selected.server.clone(),
selected.uri.clone(),
selected.title.clone(),
)
};
let replacement = format!("@{}:{}", selected_server, selected_uri);
let mut new_line = String::new();
new_line.extend(chars[..start].iter());
new_line.push_str(&replacement);
new_line.extend(chars[end..].iter());
let mut new_lines = lines;
new_lines[row] = new_line;
self.textarea = TextArea::new(new_lines);
configure_textarea_defaults(&mut self.textarea);
let new_col = start + replacement.len();
self.textarea
.move_cursor(CursorMove::Jump(row as u16, new_col as u16));
self.sync_textarea_to_buffer();
if let Some(title) = selected_title.as_deref() {
self.status = format!("Inserted resource {} ({title}).", replacement);
} else {
self.status = format!("Inserted resource {}.", replacement);
}
self.error = None;
true
}
fn extract_resource_references(text: &str) -> Vec<String> {
let mut references = Vec::new();
let mut current = String::new();
let mut in_reference = false;
for ch in text.chars() {
if in_reference {
if ch.is_whitespace() || matches!(ch, ',' | ';' | ')' | '(' | '.' | '!' | '?') {
if current.contains(':') {
references.push(current.clone());
}
current.clear();
in_reference = false;
} else {
current.push(ch);
}
} else if ch == '@' {
in_reference = true;
current.clear();
}
}
if in_reference && current.contains(':') {
references.push(current);
}
references
}
pub(crate) fn display_name_for_model(model: &ModelInfo) -> String {
let base = {
let trimmed = model.name.trim();
if trimmed.is_empty() {
model.id.as_str()
} else {
trimmed
}
};
let scope = Self::model_scope_from_capabilities(model);
let scope_suffix = match &scope {
ModelScope::Local => "local".to_string(),
ModelScope::Cloud => "cloud".to_string(),
ModelScope::Other(other) => other.trim().to_ascii_lowercase(),
};
if scope_suffix.is_empty() {
base.to_string()
} else {
let lower = base.to_ascii_lowercase();
if lower.contains(&format!("· {}", scope_suffix)) {
base.to_string()
} else {
format!("{base} · {scope_suffix}")
}
}
}
fn role_style(theme: &Theme, role: &Role) -> Style {
match role {
Role::User => Style::default().fg(theme.user_message_role),
Role::Assistant => Style::default().fg(theme.assistant_message_role),
Role::System => Style::default().fg(theme.mode_command),
Role::Tool => Style::default().fg(theme.info),
}
}
fn message_border_style(theme: &Theme, role: &Role) -> Style {
let base_color = match role {
Role::User => theme.user_message_role,
Role::Assistant => theme.assistant_message_role,
Role::System => theme.mode_command,
Role::Tool => theme.info,
};
let dimmed = Self::dim_color(base_color);
Style::default().fg(dimmed).add_modifier(Modifier::DIM)
}
fn content_style(theme: &Theme, role: &Role) -> Style {
if matches!(role, Role::Tool) {
Style::default().fg(theme.tool_output)
} else {
Style::default()
}
}
fn dim_color(color: Color) -> Color {
match color {
Color::Reset | Color::Indexed(_) => color,
_ => {
if let Some((r, g, b)) = Self::color_to_rgb(color) {
let dim_component = |component: u8| -> u8 {
let value = ((component as u16) * 2) / 5;
value as u8
};
Color::Rgb(dim_component(r), dim_component(g), dim_component(b))
} else {
color
}
}
}
}
fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
match color {
Color::Black => Some((0, 0, 0)),
Color::Red => Some((205, 0, 0)),
Color::Green => Some((0, 205, 0)),
Color::Yellow => Some((205, 205, 0)),
Color::Blue => Some((0, 0, 205)),
Color::Magenta => Some((205, 0, 205)),
Color::Cyan => Some((0, 205, 205)),
Color::Gray => Some((170, 170, 170)),
Color::DarkGray => Some((85, 85, 85)),
Color::LightRed => Some((255, 85, 85)),
Color::LightGreen => Some((85, 255, 85)),
Color::LightYellow => Some((255, 255, 85)),
Color::LightBlue => Some((85, 85, 255)),
Color::LightMagenta => Some((255, 85, 255)),
Color::LightCyan => Some((85, 255, 255)),
Color::White => Some((255, 255, 255)),
Color::Rgb(r, g, b) => Some((r, g, b)),
Color::Reset | Color::Indexed(_) => None,
}
}
fn message_content_hash(role: &Role, content: &str, tool_signature: &str) -> u64 {
let mut hasher = DefaultHasher::new();
role.to_string().hash(&mut hasher);
content.hash(&mut hasher);
tool_signature.hash(&mut hasher);
hasher.finish()
}
fn invalidate_message_cache(&mut self, id: &Uuid) {
self.message_line_cache.remove(id);
}
fn sync_ui_preferences_from_config(&mut self) {
let (show_cursor, role_label_mode, syntax_highlighting, render_markdown, show_timestamps) = {
let guard = self.controller.config();
(
guard.ui.show_cursor_outside_insert,
guard.ui.role_label_mode,
guard.ui.syntax_highlighting,
guard.ui.render_markdown,
guard.ui.show_timestamps,
)
};
self.show_cursor_outside_insert = show_cursor;
self.syntax_highlighting = syntax_highlighting;
self.render_markdown = render_markdown;
self.show_message_timestamps = show_timestamps;
self.controller.set_role_label_mode(role_label_mode);
self.message_line_cache.clear();
}
pub fn cursor_should_be_visible(&self) -> bool {
if matches!(self.mode, InputMode::Editing) {
true
} else {
self.show_cursor_outside_insert
}
}
pub fn should_highlight_code(&self) -> bool {
true
}
pub fn render_markdown_enabled(&self) -> bool {
self.render_markdown
}
pub fn set_render_markdown(&mut self, enabled: bool) {
if self.render_markdown == enabled {
self.status = if enabled {
"Markdown rendering already enabled".to_string()
} else {
"Markdown rendering already disabled".to_string()
};
self.error = None;
return;
}
self.render_markdown = enabled;
self.message_line_cache.clear();
{
let mut guard = self.controller.config_mut();
guard.ui.render_markdown = enabled;
}
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {}", err));
} else {
self.error = None;
}
self.status = if enabled {
"Markdown rendering enabled".to_string()
} else {
"Markdown rendering disabled".to_string()
};
}
pub(crate) fn render_message_lines_cached(
&mut self,
message_index: usize,
ctx: MessageRenderContext<'_>,
) -> Vec<Line<'static>> {
let MessageRenderContext {
formatter,
role_label_mode,
body_width,
card_width,
is_streaming,
loading_indicator,
theme,
syntax_highlighting,
render_markdown,
} = ctx;
let (message_id, role, raw_content, timestamp, tool_calls, tool_result_id) = {
let conversation = self.conversation();
let message = &conversation.messages[message_index];
(
message.id,
message.role.clone(),
message.content.clone(),
message.timestamp,
message.tool_calls.clone(),
message
.metadata
.get("tool_call_id")
.and_then(|value| value.as_str())
.map(|value| value.to_string()),
)
};
let display_content = if matches!(role, Role::Assistant) {
formatter.extract_thinking(&raw_content).0
} else if matches!(role, Role::Tool) {
format_tool_output(&raw_content)
} else {
raw_content
};
let normalized_content = display_content.replace("\r\n", "\n");
let trimmed = normalized_content.trim();
let content = trimmed.to_string();
let segments = parse_message_segments(trimmed, render_markdown);
let tool_signature = tool_calls
.as_ref()
.map(|calls| {
let mut names: Vec<&str> = calls.iter().map(|call| call.name.as_str()).collect();
names.sort_unstable();
names.join("|")
})
.unwrap_or_default();
let content_hash = Self::message_content_hash(&role, &content, &tool_signature);
if !is_streaming {
if let Some(entry) = self.message_line_cache.get(&message_id) {
if entry.wrap_width == card_width
&& entry.role_label_mode == role_label_mode
&& entry.syntax_highlighting == syntax_highlighting
&& entry.render_markdown == render_markdown
&& entry.theme_name == theme.name
&& entry.show_timestamps == self.show_message_timestamps
&& entry.metrics.body_width == body_width
&& entry.metrics.card_width == card_width
&& entry.content_hash == content_hash
{
return entry.lines.clone();
}
}
}
let mut rendered: Vec<Line<'static>> = Vec::new();
let content_style = Self::content_style(theme, &role);
let mut indicator_target: Option<usize> = None;
let indicator_span = if is_streaming {
Some(Span::styled(
format!(" {}", streaming_indicator_symbol(loading_indicator)),
Style::default().fg(theme.cursor),
))
} else {
None
};
let mut append_segments = |segments: &[MessageSegment],
indent: &str,
available_width: usize,
indicator_target: &mut Option<usize>,
code_width: usize| {
if segments.is_empty() {
let line_text = if indent.is_empty() {
String::new()
} else {
indent.to_string()
};
rendered.push(Line::from(vec![Span::styled(line_text, content_style)]));
*indicator_target = Some(rendered.len() - 1);
return;
}
for segment in segments {
match segment {
MessageSegment::Text { lines } => {
if render_markdown {
let block = lines.join("\n");
let markdown_lines = render_markdown_lines(
&block,
indent,
available_width,
content_style,
);
for line in markdown_lines {
rendered.push(line);
*indicator_target = Some(rendered.len() - 1);
}
} else {
for line_text in lines {
let mut chunks = wrap_unicode(line_text.as_str(), available_width);
if chunks.is_empty() {
chunks.push(String::new());
}
for chunk in chunks {
let mut spans: Vec<Span<'static>> = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), content_style));
}
let inline_spans =
inline_code_spans_from_text(&chunk, theme, content_style);
spans.extend(inline_spans);
rendered.push(Line::from(spans));
*indicator_target = Some(rendered.len() - 1);
}
}
}
}
MessageSegment::CodeBlock { language, lines } => {
append_code_block_lines(
&mut rendered,
indent,
code_width,
language.as_deref(),
lines,
theme,
syntax_highlighting,
indicator_target,
);
}
}
}
};
match role_label_mode {
RoleLabelDisplay::Above => {
let indent = " ";
let indent_width = UnicodeWidthStr::width(indent);
let available_width = body_width.saturating_sub(indent_width).max(1);
append_segments(
&segments,
indent,
available_width,
&mut indicator_target,
body_width.saturating_sub(indent_width),
);
}
RoleLabelDisplay::Inline | RoleLabelDisplay::None => {
let indent = "";
let available_width = body_width.max(1);
append_segments(
&segments,
indent,
available_width,
&mut indicator_target,
body_width,
);
}
}
if let Some(indicator) = indicator_span {
if let Some(idx) = indicator_target {
if let Some(line) = rendered.get_mut(idx) {
line.spans.push(indicator);
} else {
rendered.push(Line::from(vec![indicator]));
}
} else {
rendered.push(Line::from(vec![indicator]));
}
}
let markers =
Self::message_tool_markers(&role, tool_calls.as_ref(), tool_result_id.as_deref());
let formatted_timestamp = if self.show_message_timestamps {
Some(Self::format_message_timestamp(timestamp))
} else {
None
};
let card_lines = Self::wrap_message_in_card(
rendered,
&role,
formatted_timestamp.as_deref(),
&markers,
card_width,
theme,
);
let metrics = MessageLayoutMetrics {
line_count: card_lines.len(),
body_width,
card_width,
};
debug_assert_eq!(metrics.line_count, card_lines.len());
if !is_streaming {
self.message_line_cache.insert(
message_id,
MessageCacheEntry {
theme_name: theme.name.clone(),
wrap_width: card_width,
role_label_mode,
syntax_highlighting,
render_markdown,
show_timestamps: self.show_message_timestamps,
content_hash,
lines: card_lines.clone(),
metrics: metrics.clone(),
},
);
}
card_lines
}
fn message_tool_markers(
role: &Role,
tool_calls: Option<&Vec<owlen_core::types::ToolCall>>,
tool_result_id: Option<&str>,
) -> Vec<String> {
let mut markers = Vec::new();
match role {
Role::Assistant => {
if let Some(calls) = tool_calls {
const MAX_VISIBLE: usize = 3;
for call in calls.iter().take(MAX_VISIBLE) {
markers.push(format!("[Tool: {}]", call.name));
}
if calls.len() > MAX_VISIBLE {
markers.push(format!("[+{}]", calls.len() - MAX_VISIBLE));
}
}
}
Role::Tool => {
if let Some(id) = tool_result_id {
markers.push(format!("[Result: {id}]"));
} else {
markers.push("[Result]".to_string());
}
}
_ => {}
}
markers
}
fn format_message_timestamp(timestamp: SystemTime) -> String {
let datetime: DateTime<Local> = timestamp.into();
datetime.format("%H:%M").to_string()
}
fn wrap_message_in_card(
mut lines: Vec<Line<'static>>,
role: &Role,
timestamp: Option<&str>,
markers: &[String],
card_width: usize,
theme: &Theme,
) -> Vec<Line<'static>> {
if card_width < MIN_MESSAGE_CARD_WIDTH {
return Self::wrap_message_compact(lines, role, timestamp, markers, theme);
}
let inner_width = card_width.saturating_sub(4).max(1);
let mut card_lines = Vec::with_capacity(lines.len() + 2);
card_lines.push(Self::build_card_header(
role, timestamp, markers, card_width, theme,
));
if lines.is_empty() {
lines.push(Line::from(String::new()));
}
for line in lines {
card_lines.push(Self::wrap_card_body_line(line, inner_width, theme, role));
}
card_lines.push(Self::build_card_footer(card_width, theme, role));
card_lines
}
fn wrap_message_compact(
lines: Vec<Line<'static>>,
role: &Role,
timestamp: Option<&str>,
markers: &[String],
theme: &Theme,
) -> Vec<Line<'static>> {
let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD);
let meta_style = Style::default().fg(theme.placeholder);
let tool_style = Style::default()
.fg(theme.tool_output)
.add_modifier(Modifier::BOLD);
let (emoji, title) = role_label_parts(role);
let mut header_spans: Vec<Span<'static>> =
vec![Span::styled(format!("{emoji} {title}"), role_style)];
if let Some(ts) = timestamp {
header_spans.push(Span::styled(" · ".to_string(), meta_style));
header_spans.push(Span::styled(ts.to_string(), meta_style));
}
for marker in markers {
header_spans.push(Span::styled(" ".to_string(), meta_style));
header_spans.push(Span::styled(marker.clone(), tool_style));
}
let mut compact_lines = Vec::with_capacity(lines.len() + 2);
compact_lines.push(Line::from(header_spans));
if lines.is_empty() {
compact_lines.push(Line::from(vec![Span::raw("")]));
} else {
compact_lines.extend(lines);
}
compact_lines.push(Line::from(vec![Span::raw("")]));
compact_lines
}
fn build_card_header(
role: &Role,
timestamp: Option<&str>,
markers: &[String],
card_width: usize,
theme: &Theme,
) -> Line<'static> {
let border_style = Self::message_border_style(theme, role);
let role_style = Self::role_style(theme, role).add_modifier(Modifier::BOLD);
let meta_style = Style::default().fg(theme.placeholder);
let tool_style = Style::default()
.fg(theme.tool_output)
.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("", border_style));
let mut consumed = 1usize;
spans.push(Span::styled(" ", border_style));
consumed += 1;
let (emoji, title) = role_label_parts(role);
let label_text = format!("{emoji} {title}");
let label_width = UnicodeWidthStr::width(label_text.as_str());
spans.push(Span::styled(label_text, role_style));
consumed += label_width;
if let Some(ts) = timestamp {
let separator = "";
let separator_width = UnicodeWidthStr::width(separator);
let ts_width = UnicodeWidthStr::width(ts);
if consumed + separator_width + ts_width + 1 < card_width {
spans.push(Span::styled(separator.to_string(), border_style));
consumed += separator_width;
spans.push(Span::styled(ts.to_string(), meta_style));
consumed += ts_width;
}
}
for marker in markers {
let spacer_width = 2usize;
let marker_width = UnicodeWidthStr::width(marker.as_str());
if consumed + spacer_width + marker_width + 1 > card_width {
break;
}
spans.push(Span::styled(" ".to_string(), border_style));
spans.push(Span::styled(marker.clone(), tool_style));
consumed += spacer_width + marker_width;
}
if consumed + 1 < card_width {
spans.push(Span::styled(" ", border_style));
consumed += 1;
}
let remaining = card_width.saturating_sub(consumed + 1);
if remaining > 0 {
spans.push(Span::styled("".repeat(remaining), border_style));
}
spans.push(Span::styled("", border_style));
Line::from(spans)
}
fn build_card_footer(card_width: usize, theme: &Theme, role: &Role) -> Line<'static> {
let border_style = Self::message_border_style(theme, role);
let mut spans = Vec::new();
spans.push(Span::styled("", border_style));
let horizontal = card_width.saturating_sub(2);
if horizontal > 0 {
spans.push(Span::styled("".repeat(horizontal), border_style));
}
spans.push(Span::styled("", border_style));
Line::from(spans)
}
fn wrap_card_body_line(
line: Line<'static>,
inner_width: usize,
theme: &Theme,
role: &Role,
) -> Line<'static> {
let border_style = Self::message_border_style(theme, role);
let mut spans = Vec::new();
spans.push(Span::styled("", border_style));
let content_width = Self::line_display_width(&line).min(inner_width);
let mut body_spans = line.spans;
spans.append(&mut body_spans);
if content_width < inner_width {
spans.push(Span::styled(
" ".repeat(inner_width - content_width),
Style::default(),
));
}
spans.push(Span::styled("", border_style));
Line::from(spans)
}
fn build_card_header_plain(
role: &Role,
timestamp: Option<&str>,
markers: &[String],
card_width: usize,
) -> String {
let mut result = String::new();
let mut consumed = 0usize;
result.push('┌');
consumed += 1;
result.push(' ');
consumed += 1;
let (emoji, title) = role_label_parts(role);
let label_text = format!("{emoji} {title}");
result.push_str(&label_text);
consumed += UnicodeWidthStr::width(label_text.as_str());
if let Some(ts) = timestamp {
let separator = "";
let separator_width = UnicodeWidthStr::width(separator);
let ts_width = UnicodeWidthStr::width(ts);
if consumed + separator_width + ts_width + 1 < card_width {
result.push_str(separator);
result.push_str(ts);
consumed += separator_width + ts_width;
}
}
for marker in markers {
let spacer_width = 2usize;
let marker_width = UnicodeWidthStr::width(marker.as_str());
if consumed + spacer_width + marker_width + 1 >= card_width {
break;
}
result.push_str(" ");
result.push_str(marker);
consumed += spacer_width + marker_width;
}
let remaining = card_width.saturating_sub(consumed + 1);
if remaining > 0 {
result.push_str(&"".repeat(remaining));
}
result.push('┐');
result
}
fn wrap_card_body_line_plain(line: &str, inner_width: usize) -> String {
let mut result = String::from("");
result.push_str(line);
let content_width = UnicodeWidthStr::width(line);
if content_width < inner_width {
result.push_str(&" ".repeat(inner_width - content_width));
}
result.push_str("");
result
}
fn build_card_footer_plain(card_width: usize) -> String {
let mut result = String::new();
result.push('└');
let horizontal = card_width.saturating_sub(2);
if horizontal > 0 {
result.push_str(&"".repeat(horizontal));
}
result.push('┘');
result
}
fn line_display_width(line: &Line<'_>) -> usize {
line.spans
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
pub fn apply_chat_scrollback_trim(&mut self, removed: usize, remaining: usize) {
if removed == 0 {
self.chat_line_offset = 0;
self.chat_cursor.0 = self.chat_cursor.0.min(remaining.saturating_sub(1));
return;
}
self.chat_line_offset = removed;
self.auto_scroll.scroll = self.auto_scroll.scroll.saturating_sub(removed);
self.auto_scroll.content_len = remaining;
if let Some((row, _)) = &mut self.visual_start {
if *row < removed {
self.visual_start = None;
} else {
*row -= removed;
}
}
if let Some((row, _)) = &mut self.visual_end {
if *row < removed {
self.visual_end = None;
} else {
*row -= removed;
}
}
self.chat_cursor.0 = self.chat_cursor.0.saturating_sub(removed);
if remaining == 0 {
self.chat_cursor = (0, 0);
} else if self.chat_cursor.0 >= remaining {
self.chat_cursor.0 = remaining - 1;
}
let max_scroll = remaining.saturating_sub(self.viewport_height);
if self.auto_scroll.scroll > max_scroll {
self.auto_scroll.scroll = max_scroll;
}
if self.auto_scroll.stick_to_bottom {
self.auto_scroll.on_viewport(self.viewport_height);
}
self.update_new_message_alert_after_scroll();
}
pub fn set_theme(&mut self, theme: Theme) {
self.theme = theme;
self.message_line_cache.clear();
}
pub fn switch_theme(&mut self, theme_name: &str) -> Result<()> {
if let Some(theme) = owlen_core::theme::get_theme(theme_name) {
self.theme = theme;
self.message_line_cache.clear();
// Save theme to config
self.controller.config_mut().ui.theme = theme_name.to_string();
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save theme config: {}", err));
} else {
self.status = format!("Switched to theme: {}", theme_name);
}
Ok(())
} else {
self.error = Some(format!("Theme '{}' not found", theme_name));
Err(anyhow::anyhow!("Theme '{}' not found", theme_name))
}
}
fn focus_sequence(&self) -> Vec<FocusedPanel> {
let mut order = Vec::new();
if !self.file_panel_collapsed {
order.push(FocusedPanel::Files);
}
order.push(FocusedPanel::Chat);
if self.should_show_code_view() {
order.push(FocusedPanel::Code);
}
if self.current_thinking.is_some() {
order.push(FocusedPanel::Thinking);
}
order.push(FocusedPanel::Input);
order
}
fn ensure_focus_valid(&mut self) {
let order = self.focus_sequence();
if order.is_empty() {
self.focused_panel = FocusedPanel::Chat;
} else if !order.contains(&self.focused_panel) {
self.focused_panel = order[0];
}
if let FocusedPanel::Thinking = self.focused_panel {
// Ensure the vertical split favours thinking panel if chat collapsed entirely
if let Some(tab) = self.workspace_mut().active_tab_mut() {
tab.root.ensure_ratio_bounds();
}
}
}
pub fn cycle_focus_forward(&mut self) {
let order = self.focus_sequence();
if order.is_empty() {
self.focused_panel = FocusedPanel::Chat;
return;
}
if !order.contains(&self.focused_panel) {
self.focused_panel = order[0];
}
let current_index = order
.iter()
.position(|panel| *panel == self.focused_panel)
.unwrap_or(0);
let next_index = (current_index + 1) % order.len();
self.focused_panel = order[next_index];
}
pub fn cycle_focus_backward(&mut self) {
let order = self.focus_sequence();
if order.is_empty() {
self.focused_panel = FocusedPanel::Chat;
return;
}
if !order.contains(&self.focused_panel) {
self.focused_panel = order[0];
}
let current_index = order
.iter()
.position(|panel| *panel == self.focused_panel)
.unwrap_or(0);
let prev_index = if current_index == 0 {
order.len().saturating_sub(1)
} else {
current_index - 1
};
self.focused_panel = order[prev_index];
}
pub fn focus_panel(&mut self, target: FocusedPanel) -> bool {
match target {
FocusedPanel::Files => {
if self.file_panel_collapsed {
self.expand_file_panel();
if self.file_panel_collapsed {
return false;
}
}
}
FocusedPanel::Code => {
if !self.should_show_code_view() {
return false;
}
}
FocusedPanel::Thinking => {
if self.current_thinking.is_none() && self.agent_actions.is_none() {
return false;
}
}
FocusedPanel::Chat | FocusedPanel::Input => {}
}
let order = self.focus_sequence();
if !order.contains(&target) {
return false;
}
self.focused_panel = target;
self.ensure_focus_valid();
true
}
/// Sync textarea content to input buffer
fn sync_textarea_to_buffer(&mut self) {
let text = self.textarea.lines().join("\n");
self.input_buffer_mut().set_text(text.clone());
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged {
content: text,
}));
}
/// Sync input buffer content to textarea
fn sync_buffer_to_textarea(&mut self) {
let text = self.input_buffer().text().to_string();
let lines: Vec<String> = text.lines().map(|s| s.to_string()).collect();
self.textarea = TextArea::new(lines);
configure_textarea_defaults(&mut self.textarea);
let _ = self.apply_app_event(AppEvent::Composer(ComposerEvent::DraftChanged {
content: text,
}));
}
fn apply_app_event(&mut self, event: AppEvent) -> Vec<AppEffect> {
mvu::update(&mut self.mvu_model, event)
}
async fn handle_app_effects(&mut self, effects: Vec<AppEffect>) -> Result<()> {
let mut pending = effects;
while let Some(effect) = pending.pop() {
match effect {
AppEffect::SetStatus(message) => {
self.error = Some(message.clone());
self.status = message;
}
AppEffect::RequestSubmit => {
let outcome = self.process_composer_submission().await?;
let mut follow_up = self.apply_app_event(AppEvent::Composer(
ComposerEvent::SubmissionHandled { result: outcome },
));
pending.append(&mut follow_up);
match outcome {
SubmissionOutcome::MessageSent | SubmissionOutcome::CommandExecuted => {
self.sync_buffer_to_textarea();
self.set_input_mode(InputMode::Normal);
}
SubmissionOutcome::Failed => {
self.sync_buffer_to_textarea();
}
}
}
AppEffect::ResolveToolConsent { request_id, scope } => {
let resolution = self.controller.resolve_tool_consent(request_id, scope)?;
self.apply_tool_consent_resolution(resolution)?;
}
}
}
Ok(())
}
async fn process_composer_submission(&mut self) -> Result<SubmissionOutcome> {
match self.process_slash_submission().await? {
SlashOutcome::NotCommand => {
self.send_user_message_and_request_response();
Ok(SubmissionOutcome::MessageSent)
}
SlashOutcome::Consumed => Ok(SubmissionOutcome::CommandExecuted),
SlashOutcome::Error => Ok(SubmissionOutcome::Failed),
}
}
async fn try_execute_command(&mut self, key: &KeyEvent) -> Result<bool> {
if let Some(command) = self.keymap.resolve(self.mode, key) {
if self.execute_command(command).await? {
return Ok(true);
}
}
Ok(false)
}
async fn execute_command(&mut self, command: AppCommand) -> Result<bool> {
match command {
AppCommand::OpenModelPicker(filter) => {
self.pending_key = None;
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
}
if let Err(err) = self.show_model_picker(filter).await {
self.error = Some(err.to_string());
}
Ok(true)
}
AppCommand::OpenCommandPalette | AppCommand::EnterCommandMode => {
self.pending_key = None;
if !matches!(
self.mode,
InputMode::Normal | InputMode::Editing | InputMode::Command
) {
return Ok(false);
}
self.set_input_mode(InputMode::Command);
self.command_palette.clear();
self.command_palette.ensure_suggestions();
self.status = ":".to_string();
self.error = None;
Ok(true)
}
AppCommand::CycleFocusForward => {
self.pending_key = None;
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
}
self.cycle_focus_forward();
self.status = format!("Focus: {}", Self::panel_label(self.focused_panel));
self.error = None;
Ok(true)
}
AppCommand::CycleFocusBackward => {
self.pending_key = None;
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
}
self.cycle_focus_backward();
self.status = format!("Focus: {}", Self::panel_label(self.focused_panel));
self.error = None;
Ok(true)
}
AppCommand::FocusPanel(target) => {
self.pending_key = None;
if !matches!(self.mode, InputMode::Normal) {
return Ok(false);
}
if self.focus_panel(target) {
self.status = match target {
FocusedPanel::Input => "Focus: Input — press i to edit".to_string(),
_ => format!("Focus: {}", Self::panel_label(target)),
};
self.error = None;
} else {
self.status = match target {
FocusedPanel::Files => {
if self.is_code_mode() {
"Files panel is collapsed — use :files to reopen".to_string()
} else {
"Unable to focus Files panel".to_string()
}
}
FocusedPanel::Code => "Open a file to focus the code workspace".to_string(),
FocusedPanel::Thinking => "No reasoning panel to focus yet".to_string(),
FocusedPanel::Chat => "Unable to focus Chat panel".to_string(),
FocusedPanel::Input => "Unable to focus Input panel".to_string(),
};
}
Ok(true)
}
AppCommand::ComposerSubmit => {
if !matches!(self.mode, InputMode::Editing) {
return Ok(false);
}
self.pending_key = None;
self.sync_textarea_to_buffer();
let effects = self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
self.handle_app_effects(effects).await?;
Ok(true)
}
AppCommand::ToggleDebugLog => {
self.pending_key = None;
self.toggle_debug_log_panel();
Ok(true)
}
AppCommand::SetKeymap(profile) => {
self.pending_key = None;
if profile.is_builtin() {
self.switch_keymap_profile(profile).await?;
}
Ok(true)
}
}
}
fn panel_label(panel: FocusedPanel) -> &'static str {
match panel {
FocusedPanel::Files => "Files",
FocusedPanel::Chat => "Chat",
FocusedPanel::Thinking => "Thinking",
FocusedPanel::Input => "Input",
FocusedPanel::Code => "Code",
}
}
pub fn adjust_vertical_split(&mut self, delta: f32) {
if let Some(tab) = self.workspace_mut().active_tab_mut() {
tab.root.nudge_ratio(delta);
}
}
async fn process_slash_submission(&mut self) -> Result<SlashOutcome> {
let raw = self.controller.input_buffer().text().to_string();
if raw.trim().is_empty() {
return Ok(SlashOutcome::NotCommand);
}
match slash::parse(&raw) {
Ok(None) => Ok(SlashOutcome::NotCommand),
Ok(Some(command)) => match self.execute_slash_command(command).await {
Ok(()) => {
self.input_buffer_mut().push_history_entry(raw.clone());
self.controller.input_buffer_mut().clear();
Ok(SlashOutcome::Consumed)
}
Err(err) => {
self.error = Some(err.to_string());
self.status = "Slash command failed".to_string();
self.controller.input_buffer_mut().set_text(raw);
Ok(SlashOutcome::Error)
}
},
Err(err) => {
self.error = Some(err.to_string());
self.status = "Slash command error".to_string();
Ok(SlashOutcome::Error)
}
}
}
async fn execute_slash_command(&mut self, command: SlashCommand) -> Result<()> {
match command {
SlashCommand::Summarize { count } => {
let prompt = if let Some(count) = count {
format!(
"Summarize the last {count} messages in this conversation. Highlight key decisions, open questions, and follow-up tasks."
)
} else {
"Summarize the conversation so far, calling out major decisions, blockers, and immediate next steps.".to_string()
};
self.status = "Summarizing conversation...".to_string();
self.dispatch_user_prompt(prompt);
}
SlashCommand::Explain { snippet } => {
let prompt = format!(
"Explain the following code snippet. Cover what it does and call out any potential issues or improvements:\n```\n{}\n```",
snippet
);
self.status = "Explaining snippet...".to_string();
self.dispatch_user_prompt(prompt);
}
SlashCommand::Refactor { path } => {
let trimmed = path.trim();
if trimmed.is_empty() {
anyhow::bail!("usage: /refactor <relative/path/to/file>");
}
let source = self.controller.read_file(trimmed).await?;
let prompt = format!(
"Refactor the file `{}`. Provide specific improvements for readability, safety, and maintainability. Include updated code where relevant.\n\n```text\n{}\n```",
trimmed, source
);
self.status = format!("Refactor review for {trimmed}...");
self.dispatch_user_prompt(prompt);
}
SlashCommand::TestPlan => {
let prompt = "Generate a comprehensive test plan for this repository. Outline critical test suites, coverage gaps, and prioritized steps to reach confident automation.".to_string();
self.status = "Generating test plan...".to_string();
self.dispatch_user_prompt(prompt);
}
SlashCommand::Compact => {
let prompt = "Compress our conversation history to its essentials. Summarize previous exchanges, preserve critical context, and indicate what state can be safely forgotten.".to_string();
self.status = "Compacting conversation...".to_string();
self.dispatch_user_prompt(prompt);
}
SlashCommand::McpTool { server, tool } => {
self.status = format!("Running MCP tool {server}::{tool}...");
let response = self
.controller
.call_mcp_tool(&server, &tool, json!({}))
.await
.map_err(|err| {
anyhow!("Failed to invoke MCP tool {}::{}: {}", server, tool, err)
})?;
let content = Self::format_mcp_slash_message(&server, &tool, &response);
self.controller
.conversation_mut()
.push_system_message(content);
self.auto_scroll.stick_to_bottom = true;
self.new_message_alert = true;
if response.success {
self.status = format!("MCP {server}::{tool} result added to chat.");
self.push_toast(ToastLevel::Info, format!("MCP {server}::{tool} completed."));
} else {
self.status = format!("MCP {server}::{tool} reported an error (see chat).");
self.push_toast(
ToastLevel::Warning,
format!("MCP {server}::{tool} reported an error."),
);
}
self.error = None;
}
}
Ok(())
}
fn schedule_oauth_poll(
&self,
server: String,
authorization: DeviceAuthorization,
delay: Duration,
) {
let sender = self.session_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let _ = sender.send(SessionEvent::OAuthPoll {
server,
authorization,
});
});
}
async fn start_oauth_login(&mut self, server: &str) -> Result<()> {
if self.oauth_flows.contains_key(server) {
self.error = Some(format!("OAuth flow for '{server}' is already in progress."));
return Ok(());
}
let authorization = match self.controller.start_oauth_device_flow(server).await {
Ok(auth) => auth,
Err(err) => {
self.error = Some(format!("Failed to start OAuth for '{server}': {err}"));
return Ok(());
}
};
self.oauth_flows
.insert(server.to_string(), authorization.clone());
let link = authorization
.verification_uri_complete
.clone()
.unwrap_or_else(|| authorization.verification_uri.clone());
let status = format!(
"Authorize '{server}' via {} (code {}).",
link, authorization.user_code
);
self.status = status;
self.error = None;
let mut message = format!(
"OAuth authorization required for `{server}`.\nVisit:\n{}\nEnter code: `{}`",
link, authorization.user_code
);
if let Some(hint) = &authorization.message
&& !hint.trim().is_empty()
{
message.push_str("\n\n");
message.push_str(hint);
}
if authorization.expires_at > Utc::now() {
message.push_str(&format!(
"\n\nThis code expires at {}.",
authorization
.expires_at
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
));
}
self.controller
.conversation_mut()
.push_system_message(message);
self.auto_scroll.stick_to_bottom = true;
self.notify_new_activity();
self.push_toast(
ToastLevel::Warning,
format!("Authorize {server}: code {}", authorization.user_code),
);
let delay = authorization.interval;
self.schedule_oauth_poll(server.to_string(), authorization.clone(), delay);
Ok(())
}
fn dispatch_user_prompt(&mut self, prompt: String) {
if prompt.trim().is_empty() {
self.error = Some("Slash command generated an empty request".to_string());
return;
}
self.controller.conversation_mut().push_user_message(prompt);
self.auto_scroll.stick_to_bottom = true;
self.pending_llm_request = true;
self.set_system_status(String::new());
self.error = None;
}
fn set_code_view_content(
&mut self,
display_path: impl Into<String>,
absolute: Option<PathBuf>,
content: String,
) {
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
if content.ends_with('\n') {
lines.push(String::new());
}
let display = display_path.into();
self.code_workspace
.set_active_contents(absolute, Some(display), lines);
self.ensure_focus_valid();
}
fn repo_layout_slug(&self) -> String {
self.file_tree()
.repo_name()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect()
}
fn workspace_layout_path(&self) -> Result<PathBuf> {
let base = data_local_dir().or_else(config_dir).ok_or_else(|| {
anyhow!("Unable to determine configuration directory for layout persistence")
})?;
let mut dir = base.join("owlen").join("layouts");
fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create layout directory at {}", dir.display()))?;
let mut hasher = DefaultHasher::new();
self.file_tree().root().to_string_lossy().hash(&mut hasher);
let slug = self.repo_layout_slug();
dir.push(format!("{}-{}.toml", slug, hasher.finish()));
Ok(dir)
}
fn persist_workspace_layout(&mut self) {
if self.code_workspace.tabs().is_empty() {
return;
}
let snapshot = self.code_workspace.snapshot();
match (self.workspace_layout_path(), toml::to_string(&snapshot)) {
(Ok(path), Ok(serialized)) => {
if let Err(err) = fs::write(&path, serialized) {
eprintln!(
"Warning: failed to write workspace layout {}: {}",
path.display(),
err
);
}
}
(Err(err), _) => {
eprintln!("Warning: unable to determine layout path: {err}");
}
(_, Err(err)) => {
eprintln!("Warning: failed to serialize workspace layout: {err}");
}
}
}
fn restore_pane_from_request(&mut self, request: PaneRestoreRequest) -> Result<()> {
let Some(absolute) = request.absolute_path.as_ref() else {
return Ok(());
};
let content = fs::read_to_string(absolute)
.with_context(|| format!("Failed to read restored file {}", absolute.display()))?;
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
if content.ends_with('\n') {
lines.push(String::new());
}
let display = request.display_path.clone().or_else(|| {
diff_paths(absolute, self.file_tree().root()).map(|path| {
if path.as_os_str().is_empty() {
".".to_string()
} else {
path.to_string_lossy().into_owned()
}
})
});
if self.code_workspace.set_pane_contents(
request.pane_id,
Some(absolute.clone()),
display,
lines,
) {
self.code_workspace
.restore_scroll(request.pane_id, &request.scroll);
}
Ok(())
}
async fn restore_workspace_layout(&mut self) -> Result<bool> {
let path = match self.workspace_layout_path() {
Ok(path) => path,
Err(_) => return Ok(false),
};
if !path.exists() {
return Ok(false);
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read workspace layout {}", path.display()))?;
let snapshot: WorkspaceSnapshot = toml::from_str(&contents)
.with_context(|| format!("Failed to parse workspace layout {}", path.display()))?;
let requests = self.code_workspace.apply_snapshot(snapshot);
let mut restored_any = false;
for request in requests {
if let Err(err) = self.restore_pane_from_request(request) {
eprintln!("Warning: failed to restore pane from layout: {err}");
} else {
restored_any = true;
}
}
if restored_any {
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.status = "Workspace layout restored".to_string();
}
Ok(restored_any)
}
fn direction_label(direction: PaneDirection) -> &'static str {
match direction {
PaneDirection::Left => "",
PaneDirection::Right => "",
PaneDirection::Up => "",
PaneDirection::Down => "",
}
}
fn handle_workspace_focus_move(&mut self, direction: PaneDirection) {
self.pending_focus_chord = None;
if self.code_workspace.move_focus(direction) {
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
if let Some(share) = self.code_workspace.active_share() {
self.status = format!(
"Focused pane {} · {:.0}% share",
Self::direction_label(direction),
(share * 100.0).round()
);
} else {
self.status = format!("Focused pane {}", Self::direction_label(direction));
}
self.error = None;
self.persist_workspace_layout();
} else {
self.status = "No pane in that direction".to_string();
}
}
fn handle_workspace_resize(&mut self, direction: PaneDirection) {
self.pending_focus_chord = None;
let now = Instant::now();
let is_double = self
.last_resize_tap
.map(|(prev_dir, instant)| {
prev_dir == direction && now.duration_since(instant) <= RESIZE_DOUBLE_TAP_WINDOW
})
.unwrap_or(false);
let share_opt = if is_double {
if self.last_snap_direction != Some(direction) {
self.resize_snap_index = 0;
}
let snap = RESIZE_SNAP_VALUES[self.resize_snap_index % RESIZE_SNAP_VALUES.len()];
let result = self.code_workspace.snap_active_share(direction, snap);
if result.is_some() {
self.last_snap_direction = Some(direction);
self.resize_snap_index = (self.resize_snap_index + 1) % RESIZE_SNAP_VALUES.len();
}
result
} else {
self.last_snap_direction = None;
self.resize_snap_index = 0;
self.code_workspace
.resize_active_step(direction, RESIZE_STEP)
};
match share_opt {
Some(share) => {
if is_double {
self.status = format!(
"Pane snapped {} · {:.0}% share",
Self::direction_label(direction),
(share * 100.0).round()
);
} else {
self.status = format!(
"Pane resized {} · {:.0}% share",
Self::direction_label(direction),
(share * 100.0).round()
);
}
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.error = None;
self.persist_workspace_layout();
if is_double {
self.last_resize_tap = None;
} else {
self.last_resize_tap = Some((direction, now));
}
}
None => {
self.status = "No adjacent split to resize".to_string();
self.last_resize_tap = Some((direction, now));
self.last_snap_direction = None;
}
}
}
fn prepare_code_view_target(&mut self, disposition: FileOpenDisposition) -> bool {
match disposition {
FileOpenDisposition::Primary => true,
FileOpenDisposition::SplitHorizontal => self
.code_workspace
.split_active(SplitAxis::Horizontal)
.is_some(),
FileOpenDisposition::SplitVertical => self
.code_workspace
.split_active(SplitAxis::Vertical)
.is_some(),
FileOpenDisposition::Tab => {
self.code_workspace.open_new_tab();
true
}
}
}
fn display_label_for_absolute(&self, absolute: &Path) -> String {
let root = self.file_tree().root();
if let Some(relative) = diff_paths(absolute, root) {
let rel_str = relative.to_string_lossy().into_owned();
if rel_str.is_empty() {
".".to_string()
} else {
rel_str
}
} else {
absolute.to_string_lossy().into_owned()
}
}
fn buffer_label(&self, display: Option<&str>, absolute: Option<&Path>) -> String {
if let Some(display) = display {
let trimmed = display.trim();
if trimmed.is_empty() {
"untitled buffer".to_string()
} else {
trimmed.to_string()
}
} else if let Some(absolute) = absolute {
self.display_label_for_absolute(absolute)
} else {
"untitled buffer".to_string()
}
}
async fn save_active_code_buffer(
&mut self,
path_arg: Option<String>,
force: bool,
) -> Result<SaveStatus> {
let pane_snapshot = if let Some(pane) = self.code_workspace.active_pane() {
(
pane.lines.join("\n"),
pane.absolute_path().map(Path::to_path_buf),
pane.display_path().map(|s| s.to_string()),
pane.is_dirty,
)
} else {
self.status = "No active file to save".to_string();
self.error = Some("Open a file before saving".to_string());
return Ok(SaveStatus::Failed);
};
let (content, existing_absolute, existing_display, was_dirty) = pane_snapshot;
if !was_dirty && path_arg.is_none() && !force {
let label =
self.buffer_label(existing_display.as_deref(), existing_absolute.as_deref());
self.status = format!("No changes to write ({label})");
self.error = None;
return Ok(SaveStatus::NoChanges);
}
let (request_path, target_absolute, target_display) = if let Some(path_arg) = path_arg {
let trimmed = path_arg.trim();
if trimmed.is_empty() {
self.status = "Save aborted: empty path".to_string();
self.error = Some("Provide a path to save this buffer".to_string());
return Ok(SaveStatus::Failed);
}
let provided_path = PathBuf::from(trimmed);
let absolute = self.absolute_tree_path(&provided_path);
let request = if provided_path.is_absolute() {
provided_path.to_string_lossy().into_owned()
} else {
trimmed.to_string()
};
let display = self.display_label_for_absolute(&absolute);
(request, absolute, display)
} else if let Some(display) = existing_display.clone() {
let path = PathBuf::from(&display);
let absolute = if path.is_absolute() {
path.clone()
} else {
self.absolute_tree_path(&path)
};
let display_label = self.display_label_for_absolute(&absolute);
(display, absolute, display_label)
} else if let Some(absolute) = existing_absolute.clone() {
let request = absolute.to_string_lossy().into_owned();
let display = self.display_label_for_absolute(&absolute);
(request, absolute, display)
} else {
self.status = "No path associated with buffer".to_string();
self.error = Some("Use :w <path> to save this buffer".to_string());
return Ok(SaveStatus::Failed);
};
match self.controller.write_file(&request_path, &content).await {
Ok(()) => {
if let Some(tab) = self.code_workspace.active_tab_mut() {
if let Some(pane) = tab.active_pane_mut() {
pane.update_paths(
Some(target_absolute.clone()),
Some(target_display.clone()),
);
pane.is_dirty = false;
pane.is_staged = false;
}
tab.update_title_from_active();
}
match self.file_tree_mut().refresh() {
Ok(()) => {
self.file_tree_mut().reveal(&target_absolute);
self.ensure_focus_valid();
}
Err(err) => {
self.error = Some(format!(
"Saved {} but failed to refresh tree: {}",
target_display, err
));
}
}
self.status = format!("Wrote {}", target_display);
if self.error.is_none() {
self.set_system_status(format!("Saved {}", target_display));
}
Ok(SaveStatus::Saved)
}
Err(err) => {
self.error = Some(format!("Failed to save {}: {}", target_display, err));
self.status = format!("Failed to save {}", target_display);
Ok(SaveStatus::Failed)
}
}
}
fn close_active_code_buffer(&mut self, force: bool) -> bool {
let snapshot = if let Some(pane) = self.code_workspace.active_pane() {
(
pane.display_path().map(|s| s.to_string()),
pane.absolute_path().map(Path::to_path_buf),
pane.is_dirty,
)
} else {
self.status = "No active file to close".to_string();
self.error = Some("Open a file before closing it".to_string());
return false;
};
let (display_path, absolute_path, is_dirty) = snapshot;
if is_dirty && !force {
let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref());
self.status = format!("Unsaved changes in {label} — use :w to save or :q! to discard");
self.error = Some(format!("Unsaved changes detected in {}", label));
return false;
}
let label = self.buffer_label(display_path.as_deref(), absolute_path.as_deref());
self.close_code_view();
self.status = format!("Closed {}", label);
self.error = None;
self.set_system_status(String::new());
true
}
fn split_active_pane(&mut self, axis: SplitAxis) {
let Some(snapshot) = self.code_workspace.active_pane().cloned() else {
self.status = "No pane to split".to_string();
return;
};
if self.code_workspace.split_active(axis).is_some() {
let lines = snapshot.lines.clone();
let absolute = snapshot.absolute_path.clone();
let display = snapshot.display_path.clone();
self.code_workspace
.set_active_contents(absolute, display, lines);
if let Some(pane) = self.code_workspace.active_pane_mut() {
pane.is_dirty = snapshot.is_dirty;
pane.is_staged = snapshot.is_staged;
pane.viewport_height = snapshot.viewport_height;
pane.scroll = snapshot.scroll.clone();
}
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.status = match axis {
SplitAxis::Horizontal => "Split pane horizontally".to_string(),
SplitAxis::Vertical => "Split pane vertically".to_string(),
};
self.error = None;
self.persist_workspace_layout();
} else {
self.status = "Unable to split pane".to_string();
self.error = Some("Unable to split pane".to_string());
}
}
fn close_code_view(&mut self) {
self.code_workspace.clear_active_pane();
if matches!(self.focused_panel, FocusedPanel::Code) {
self.focused_panel = FocusedPanel::Chat;
}
self.ensure_focus_valid();
self.persist_workspace_layout();
}
fn absolute_tree_path(&self, path: &Path) -> PathBuf {
if path.as_os_str().is_empty() {
self.file_tree().root().to_path_buf()
} else if path.is_absolute() {
path.to_path_buf()
} else {
self.file_tree().root().join(path)
}
}
fn relative_tree_display(&self, path: &Path) -> String {
if path.as_os_str().is_empty() {
".".to_string()
} else {
path.to_string_lossy().into_owned()
}
}
async fn open_selected_file_from_tree(
&mut self,
disposition: FileOpenDisposition,
) -> Result<()> {
if !self.is_code_mode() {
self.status = "Switch to code mode to open files".to_string();
self.error = None;
return Ok(());
}
let selected_opt = {
let tree = self.file_tree();
tree.selected_node().cloned()
};
let Some(selected) = selected_opt else {
self.status = "No file selected".to_string();
return Ok(());
};
if selected.is_dir {
let was_expanded = selected.is_expanded;
self.file_tree_mut().toggle_expand();
let label = self.relative_tree_display(&selected.path);
self.status = if was_expanded {
format!("Collapsed {}", label)
} else {
format!("Expanded {}", label)
};
return Ok(());
}
if selected.path.as_os_str().is_empty() {
return Ok(());
}
let relative_display = self.relative_tree_display(&selected.path);
let absolute_path = self.absolute_tree_path(&selected.path);
let request_path = if selected.path.is_absolute() {
selected.path.to_string_lossy().into_owned()
} else {
relative_display.clone()
};
match self.controller.read_file_with_tools(&request_path).await {
Ok(content) => {
let prepared = self.prepare_code_view_target(disposition);
self.set_code_view_content(
relative_display.clone(),
Some(absolute_path.clone()),
content,
);
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.file_tree_mut().reveal(&absolute_path);
if !prepared {
self.error =
Some("Unable to create requested split; opened in active pane".to_string());
} else {
self.error = None;
}
self.status = match (disposition, prepared) {
(FileOpenDisposition::Primary, _) => format!("Opened {}", relative_display),
(FileOpenDisposition::SplitHorizontal, true) => {
format!("Opened {} in horizontal split", relative_display)
}
(FileOpenDisposition::SplitVertical, true) => {
format!("Opened {} in vertical split", relative_display)
}
(FileOpenDisposition::Tab, true) => {
format!("Opened {} in new tab", relative_display)
}
(FileOpenDisposition::SplitHorizontal, false)
| (FileOpenDisposition::SplitVertical, false) => {
format!("Opened {} (split unavailable)", relative_display)
}
(FileOpenDisposition::Tab, false) => {
format!("Opened {} (tab unavailable)", relative_display)
}
};
self.set_system_status(format!("Viewing {}", relative_display));
self.persist_workspace_layout();
}
Err(err) => {
self.error = Some(format!("Failed to open {}: {}", relative_display, err));
}
}
Ok(())
}
fn copy_selected_path(&mut self, relative: bool) {
let selected_opt = {
let tree = self.file_tree();
tree.selected_node().cloned()
};
let Some(selected) = selected_opt else {
self.status = "No file selected".to_string();
return;
};
let path_string = if relative {
self.relative_tree_display(&selected.path)
} else {
let abs = self.absolute_tree_path(&selected.path);
abs.to_string_lossy().into_owned()
};
self.clipboard = path_string.clone();
self.status = if relative {
format!("Copied relative path: {}", path_string)
} else {
format!("Copied path: {}", path_string)
};
self.error = None;
}
fn selected_file_node(&self) -> Option<FileNode> {
let tree = self.file_tree();
tree.selected_node().cloned()
}
fn mutate_file_filter<F>(&mut self, mutate: F)
where
F: FnOnce(&mut String),
{
let mut query = {
let tree = self.file_tree();
tree.filter_query().to_string()
};
mutate(&mut query);
let query_is_empty = query.is_empty();
{
let tree = self.file_tree_mut();
if query_is_empty {
tree.set_filter_mode(FileFilterMode::Glob);
}
tree.set_filter_query(query.clone());
}
if query_is_empty {
self.status = "Filter cleared".to_string();
} else {
let mode = match self.file_tree().filter_mode() {
FileFilterMode::Glob => "glob",
FileFilterMode::Fuzzy => "fuzzy",
};
self.status = format!("Filter ({mode}): {}", query);
}
self.error = None;
}
fn backspace_file_filter(&mut self) {
self.mutate_file_filter(|query| {
query.pop();
});
}
fn clear_file_filter(&mut self) {
self.mutate_file_filter(|query| {
query.clear();
});
}
fn append_file_filter_char(&mut self, ch: char) {
self.mutate_file_filter(|query| {
query.push(ch);
});
}
fn toggle_hidden_files(&mut self) {
match self.file_tree_mut().toggle_hidden() {
Ok(()) => {
let show_hidden = self.file_tree().show_hidden();
self.status = if show_hidden {
"Hidden files visible".to_string()
} else {
"Hidden files hidden".to_string()
};
self.error = None;
}
Err(err) => {
self.error = Some(format!("Failed to toggle hidden files: {}", err));
}
}
}
fn create_file_from_command(&mut self, path: &str) -> Result<String> {
if !self.is_code_mode() {
return Err(anyhow!("File creation is only available in code mode"));
}
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(anyhow!("File path cannot be empty"));
}
let relative = PathBuf::from(trimmed);
validate_relative_path(&relative, true)?;
let file_name = relative
.file_name()
.ok_or_else(|| anyhow!("File path must include a file name"))?
.to_string_lossy()
.into_owned();
let base = relative
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(|parent| parent.to_path_buf())
.unwrap_or_else(PathBuf::new);
let prompt = FileActionPrompt::new(FileActionKind::CreateFile { base }, file_name);
let message = self.perform_file_action(prompt)?;
self.expand_file_panel();
Ok(message)
}
pub fn file_panel_prompt_text(&self) -> Option<(String, bool)> {
self.pending_file_action.as_ref().map(|prompt| {
(
self.describe_file_action_prompt(prompt),
prompt.is_destructive(),
)
})
}
fn describe_file_action_prompt(&self, prompt: &FileActionPrompt) -> String {
let buffer_display = if prompt.buffer.trim().is_empty() {
"<name>".to_string()
} else {
prompt.buffer.clone()
};
let base_message = match &prompt.kind {
FileActionKind::CreateFile { base } => {
let base_display = self.relative_tree_display(base);
format!("Create file in {}{}", base_display, buffer_display)
}
FileActionKind::CreateFolder { base } => {
let base_display = self.relative_tree_display(base);
format!("Create folder in {}{}", base_display, buffer_display)
}
FileActionKind::Rename { original } => {
let current_display = self.relative_tree_display(original);
format!("Rename {}{}", current_display, buffer_display)
}
FileActionKind::Move { original } => {
let current_display = self.relative_tree_display(original);
format!("Move {}{}", current_display, buffer_display)
}
FileActionKind::Delete { target, .. } => {
let target_display = self.relative_tree_display(target);
format!(
"Delete {} — type filename to confirm ▸ {}",
target_display, buffer_display
)
}
};
format!("{base_message} (Enter to apply · Esc to cancel)")
}
fn begin_file_action(&mut self, kind: FileActionKind, initial: impl Into<String>) {
if !self.is_code_mode() {
self.status = "Switch to code mode to manage files".to_string();
self.error = None;
return;
}
let prompt = FileActionPrompt::new(kind, initial);
self.status = self.describe_file_action_prompt(&prompt);
self.error = None;
self.pending_file_action = Some(prompt);
}
fn refresh_file_action_status(&mut self) {
if let Some(prompt) = self.pending_file_action.as_ref() {
self.status = self.describe_file_action_prompt(prompt);
}
}
fn cancel_file_action(&mut self) {
self.pending_file_action = None;
self.status = "File action cancelled".to_string();
self.error = None;
}
fn handle_file_action_prompt(&mut self, key: &crossterm::event::KeyEvent) -> Result<bool> {
use crossterm::event::{KeyCode, KeyModifiers};
if self.pending_file_action.is_none() {
return Ok(false);
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Enter if !ctrl && !alt => {
self.apply_pending_file_action()?;
return Ok(true);
}
KeyCode::Esc if !ctrl && !alt => {
self.cancel_file_action();
return Ok(true);
}
KeyCode::Backspace if !ctrl && !alt => {
if let Some(prompt) = self.pending_file_action.as_mut() {
prompt.pop_char();
}
self.refresh_file_action_status();
self.error = None;
return Ok(true);
}
KeyCode::Char(c) if !ctrl && !alt => {
if let Some(prompt) = self.pending_file_action.as_mut() {
prompt.push_char(c);
}
self.refresh_file_action_status();
self.error = None;
return Ok(true);
}
KeyCode::Tab if !ctrl && !alt => {
if let Some(prompt) = self.pending_file_action.as_mut() {
prompt.push_char('\t');
}
self.refresh_file_action_status();
self.error = None;
return Ok(true);
}
KeyCode::Delete if !ctrl && !alt => {
if let Some(prompt) = self.pending_file_action.as_mut() {
prompt.set_buffer(String::new());
}
self.refresh_file_action_status();
self.error = None;
return Ok(true);
}
_ => {}
}
Ok(false)
}
fn apply_pending_file_action(&mut self) -> Result<()> {
let Some(prompt) = self.pending_file_action.take() else {
return Ok(());
};
let cloned_prompt = prompt.clone();
match self.perform_file_action(prompt) {
Ok(message) => {
self.status = message;
self.error = None;
Ok(())
}
Err(err) => {
self.pending_file_action = Some(cloned_prompt);
self.error = Some(err.to_string());
Err(err)
}
}
}
async fn launch_external_editor(&mut self) -> Result<()> {
if !self.is_code_mode() {
self.status = "Switch to code mode to launch the external editor".to_string();
self.error = None;
return Ok(());
}
let Some(selected) = self.selected_file_node() else {
self.status = "No file selected".to_string();
return Ok(());
};
let relative = selected.path.clone();
let absolute = self.absolute_tree_path(&relative);
let editor = env::var("EDITOR")
.or_else(|_| env::var("VISUAL"))
.unwrap_or_else(|_| "vi".to_string());
self.status = format!("Launching {} {}", editor, absolute.display());
self.error = None;
let editor_cmd = editor.clone();
let path_arg = absolute.clone();
let raw_mode_disabled = disable_raw_mode().is_ok();
let join_result =
task::spawn_blocking(move || Command::new(&editor_cmd).arg(&path_arg).status()).await;
if raw_mode_disabled {
let _ = enable_raw_mode();
}
let join_result = join_result.context("Editor task failed to join")?;
match join_result {
Ok(status) => {
if status.success() {
self.status = format!(
"Closed {} for {}",
editor,
self.relative_tree_display(&relative)
);
self.error = None;
} else {
let code = status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
self.error = Some(format!("{} exited with status {}", editor, code));
}
}
Err(err) => {
self.error = Some(format!("Failed to launch {}: {}", editor, err));
}
}
match self.file_tree_mut().refresh() {
Ok(()) => {
self.file_tree_mut().reveal(&absolute);
self.ensure_focus_valid();
}
Err(err) => {
self.error = Some(format!("Failed to refresh file tree: {}", err));
}
}
Ok(())
}
fn perform_file_action(&mut self, prompt: FileActionPrompt) -> Result<String> {
if !self.is_code_mode() {
return Err(anyhow!("File actions are only available in code mode"));
}
match prompt.kind {
FileActionKind::CreateFile { base } => {
let name = prompt.buffer.trim();
if name.is_empty() {
return Err(anyhow!("File name cannot be empty"));
}
let name_path = PathBuf::from(name);
validate_relative_path(&name_path, true)?;
let relative = if base.as_os_str().is_empty() {
name_path
} else {
base.join(name_path)
};
let absolute = self.absolute_tree_path(&relative);
if absolute.exists() {
return Err(anyhow!("{} already exists", absolute.display()));
}
if let Some(parent) = absolute.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create parent directories for {}",
absolute.display()
)
})?;
}
OpenOptions::new()
.create_new(true)
.write(true)
.open(&absolute)
.with_context(|| format!("Failed to create {}", absolute.display()))?;
self.file_tree_mut()
.refresh()
.context("Failed to refresh file tree")?;
self.file_tree_mut().reveal(&absolute);
self.ensure_focus_valid();
Ok(format!(
"Created file {}",
self.relative_tree_display(&relative)
))
}
FileActionKind::CreateFolder { base } => {
let name = prompt.buffer.trim();
if name.is_empty() {
return Err(anyhow!("Folder name cannot be empty"));
}
let name_path = PathBuf::from(name);
validate_relative_path(&name_path, true)?;
let relative = if base.as_os_str().is_empty() {
name_path
} else {
base.join(name_path)
};
let absolute = self.absolute_tree_path(&relative);
if absolute.exists() {
return Err(anyhow!("{} already exists", absolute.display()));
}
fs::create_dir_all(&absolute)
.with_context(|| format!("Failed to create {}", absolute.display()))?;
self.file_tree_mut()
.refresh()
.context("Failed to refresh file tree")?;
self.file_tree_mut().reveal(&absolute);
self.ensure_focus_valid();
Ok(format!(
"Created folder {}",
self.relative_tree_display(&relative)
))
}
FileActionKind::Rename { original } => {
if original.as_os_str().is_empty() {
return Err(anyhow!("Cannot rename workspace root"));
}
let name = prompt.buffer.trim();
if name.is_empty() {
return Err(anyhow!("New name cannot be empty"));
}
validate_relative_path(Path::new(name), false)?;
let new_relative = original
.parent()
.map(|parent| {
if parent.as_os_str().is_empty() {
PathBuf::from(name)
} else {
parent.join(name)
}
})
.unwrap_or_else(|| PathBuf::from(name));
let source_abs = self.absolute_tree_path(&original);
let target_abs = self.absolute_tree_path(&new_relative);
if target_abs.exists() {
return Err(anyhow!("{} already exists", target_abs.display()));
}
fs::rename(&source_abs, &target_abs).with_context(|| {
format!(
"Failed to rename {} to {}",
source_abs.display(),
target_abs.display()
)
})?;
self.file_tree_mut()
.refresh()
.context("Failed to refresh file tree")?;
self.file_tree_mut().reveal(&target_abs);
self.ensure_focus_valid();
Ok(format!(
"Renamed {} to {}",
self.relative_tree_display(&original),
self.relative_tree_display(&new_relative)
))
}
FileActionKind::Move { original } => {
if original.as_os_str().is_empty() {
return Err(anyhow!("Cannot move workspace root"));
}
let target = prompt.buffer.trim();
if target.is_empty() {
return Err(anyhow!("Target path cannot be empty"));
}
let target_relative = PathBuf::from(target);
validate_relative_path(&target_relative, true)?;
let source_abs = self.absolute_tree_path(&original);
let target_abs = self.absolute_tree_path(&target_relative);
if target_abs.exists() {
return Err(anyhow!("{} already exists", target_abs.display()));
}
if let Some(parent) = target_abs.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create parent directories for {}",
target_abs.display()
)
})?;
}
fs::rename(&source_abs, &target_abs).with_context(|| {
format!(
"Failed to move {} to {}",
source_abs.display(),
target_abs.display()
)
})?;
self.file_tree_mut()
.refresh()
.context("Failed to refresh file tree")?;
self.file_tree_mut().reveal(&target_abs);
self.ensure_focus_valid();
Ok(format!(
"Moved {} to {}",
self.relative_tree_display(&original),
self.relative_tree_display(&target_relative)
))
}
FileActionKind::Delete { target, confirm } => {
if target.as_os_str().is_empty() {
return Err(anyhow!("Cannot delete workspace root"));
}
let typed = prompt.buffer.trim();
if typed != confirm {
return Err(anyhow!("Type '{}' to confirm deletion", confirm));
}
let absolute = self.absolute_tree_path(&target);
if absolute.is_dir() {
fs::remove_dir_all(&absolute).with_context(|| {
format!("Failed to delete directory {}", absolute.display())
})?;
} else if absolute.exists() {
fs::remove_file(&absolute)
.with_context(|| format!("Failed to delete file {}", absolute.display()))?;
} else {
return Err(anyhow!("{} does not exist", absolute.display()));
}
self.file_tree_mut()
.refresh()
.context("Failed to refresh file tree")?;
if let Some(parent) = target.parent() {
let parent_abs = if parent.as_os_str().is_empty() {
self.file_tree().root().to_path_buf()
} else {
self.absolute_tree_path(parent)
};
self.file_tree_mut().reveal(&parent_abs);
}
self.ensure_focus_valid();
Ok(format!("Deleted {}", self.relative_tree_display(&target)))
}
}
}
fn reveal_path_in_file_tree(&mut self, path: &Path) {
if !self.is_code_mode() {
self.status = "Switch to code mode to reveal files".to_string();
self.error = None;
return;
}
let absolute = self.absolute_tree_path(path);
self.expand_file_panel();
self.file_tree_mut().reveal(&absolute);
self.focused_panel = FocusedPanel::Files;
self.ensure_focus_valid();
let display = absolute
.strip_prefix(self.file_tree().root())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| absolute.to_string_lossy().into_owned());
self.status = format!("Revealed {}", display);
}
fn reveal_active_file(&mut self) {
let path_opt = self.code_workspace.active_pane().and_then(|pane| {
pane.absolute_path().map(Path::to_path_buf).or_else(|| {
pane.display_path()
.map(|display| PathBuf::from(display.to_string()))
})
});
match path_opt {
Some(path) => self.reveal_path_in_file_tree(&path),
None => {
self.status = "No active file to reveal".to_string();
}
}
}
async fn handle_file_panel_key(&mut self, key: &crossterm::event::KeyEvent) -> Result<bool> {
use crossterm::event::{KeyCode, KeyModifiers};
if !self.is_code_mode() {
return Ok(false);
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let no_modifiers = key.modifiers.is_empty();
if self.pending_file_action.is_some() && self.handle_file_action_prompt(key)? {
return Ok(true);
}
match key.code {
KeyCode::Enter => {
self.open_selected_file_from_tree(FileOpenDisposition::Primary)
.await?;
return Ok(true);
}
KeyCode::Char('o') if no_modifiers => {
self.open_selected_file_from_tree(FileOpenDisposition::SplitHorizontal)
.await?;
return Ok(true);
}
KeyCode::Char('O') if shift && !ctrl && !alt => {
self.open_selected_file_from_tree(FileOpenDisposition::SplitVertical)
.await?;
return Ok(true);
}
KeyCode::Char('t') if no_modifiers => {
self.open_selected_file_from_tree(FileOpenDisposition::Tab)
.await?;
return Ok(true);
}
KeyCode::Char('y') if no_modifiers => {
self.copy_selected_path(false);
return Ok(true);
}
KeyCode::Char('Y') if shift && !ctrl && !alt => {
self.copy_selected_path(true);
return Ok(true);
}
KeyCode::Char('A') if shift && !ctrl && !alt => {
if let Some(selected) = self.selected_file_node() {
let base = if selected.is_dir {
selected.path.clone()
} else {
selected
.path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(PathBuf::new)
};
self.begin_file_action(FileActionKind::CreateFolder { base }, String::new());
} else {
self.status = "No file selected".to_string();
}
return Ok(true);
}
KeyCode::Char('r') if no_modifiers => {
if let Some(selected) = self.selected_file_node() {
if selected.path.as_os_str().is_empty() {
self.error = Some("Cannot rename workspace root".to_string());
} else {
let initial = selected
.path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
self.begin_file_action(
FileActionKind::Rename {
original: selected.path.clone(),
},
initial,
);
}
} else {
self.status = "No file selected".to_string();
}
return Ok(true);
}
KeyCode::Char('m') if no_modifiers => {
if let Some(selected) = self.selected_file_node() {
if selected.path.as_os_str().is_empty() {
self.error = Some("Cannot move workspace root".to_string());
} else {
let initial = self.relative_tree_display(&selected.path);
self.begin_file_action(
FileActionKind::Move {
original: selected.path.clone(),
},
initial,
);
}
} else {
self.status = "No file selected".to_string();
}
return Ok(true);
}
KeyCode::Char('d') if no_modifiers => {
if let Some(selected) = self.selected_file_node() {
if selected.path.as_os_str().is_empty() {
self.error = Some("Cannot delete workspace root".to_string());
} else {
let confirm = selected
.path
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
if confirm.is_empty() {
self.error =
Some("Unable to determine file name for confirmation".to_string());
} else {
self.begin_file_action(
FileActionKind::Delete {
target: selected.path.clone(),
confirm,
},
String::new(),
);
}
}
} else {
self.status = "No file selected".to_string();
}
return Ok(true);
}
KeyCode::Char('.') if no_modifiers => {
self.launch_external_editor().await?;
return Ok(true);
}
KeyCode::Char('/') if !ctrl && !alt => {
if self.file_tree().filter_query().is_empty() {
{
let tree = self.file_tree_mut();
tree.set_filter_mode(FileFilterMode::Fuzzy);
tree.set_filter_query(String::new());
}
let mode = match self.file_tree().filter_mode() {
FileFilterMode::Glob => "glob",
FileFilterMode::Fuzzy => "fuzzy",
};
self.status = format!("Filter ({mode}): type to search");
self.error = None;
} else {
self.append_file_filter_char('/');
}
return Ok(true);
}
KeyCode::Backspace if !ctrl && !alt => {
self.backspace_file_filter();
return Ok(true);
}
KeyCode::Esc if !ctrl && !alt => {
if !self.file_tree().filter_query().is_empty() {
self.clear_file_filter();
return Ok(true);
}
}
KeyCode::Char(' ') if !ctrl && !alt => {
self.file_tree_mut().toggle_expand();
return Ok(true);
}
KeyCode::Char(c) if !ctrl && !alt => {
let reserved = matches!(
(c, shift),
('o', false)
| ('O', true)
| ('t', false)
| ('y', false)
| ('Y', true)
| ('g', _)
| ('d', _)
| ('m', _)
| ('A', true)
| ('r', _)
| ('/', _)
| ('.', _)
);
if !reserved && !c.is_control() {
self.append_file_filter_char(c);
return Ok(true);
}
}
_ => {}
}
Ok(false)
}
fn handle_resize(&mut self, width: u16, _height: u16) {
let approx_content_width = usize::from(width.saturating_sub(6));
self.content_width = approx_content_width.max(1);
self.auto_scroll.stick_to_bottom = true;
self.thinking_scroll.stick_to_bottom = true;
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.stick_to_bottom = false;
}
}
pub async fn initialize_models(&mut self) -> Result<()> {
let config_model_name = self.controller.config().general.default_model.clone();
let config_model_provider = self.controller.config().general.default_provider.clone();
let (all_models, errors, scope_status) = self.collect_models_from_all_providers().await;
self.models = all_models;
self.provider_scope_status = scope_status;
self.model_details_cache.clear();
self.model_info_panel.clear();
self.show_model_info = false;
self.recompute_available_providers();
if self.available_providers.is_empty() {
self.available_providers.push("ollama_local".to_string());
}
if !config_model_provider.is_empty() {
self.selected_provider = Self::canonical_provider_id(&config_model_provider);
} else {
self.selected_provider = self.available_providers[0].clone();
}
self.expanded_provider = Some(self.selected_provider.clone());
self.update_selected_provider_index();
self.sync_selected_model_index().await;
// Ensure the default model is set in the controller and config (async)
self.controller.ensure_default_model(&self.models).await;
let current_model_name = self.controller.selected_model().to_string();
let current_model_provider = self.controller.config().general.default_provider.clone();
if config_model_name.as_deref() != Some(&current_model_name)
|| config_model_provider != current_model_provider
{
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
if !errors.is_empty() {
self.error = Some(errors.join("; "));
}
self.update_command_palette_catalog();
Ok(())
}
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
use crossterm::event::{KeyCode, KeyModifiers};
if let Some(last) = self.last_ctrl_c
&& last.elapsed() > DOUBLE_CTRL_C_WINDOW
{
self.last_ctrl_c = None;
}
match event {
Event::Tick => {
self.poll_repo_search();
self.poll_symbol_search();
self.poll_debug_log_updates();
self.prune_toasts();
// Future: update streaming timers
}
Event::Resize(width, height) => {
self.handle_resize(width, height);
}
Event::Paste(text) => {
// Handle paste events - insert text directly without triggering sends
if matches!(self.mode, InputMode::Editing | InputMode::Visual)
&& self.textarea.insert_str(&text)
{
self.sync_textarea_to_buffer();
}
// Ignore paste events in other modes
}
Event::Mouse(mouse) => {
return self.handle_mouse_event(mouse);
}
Event::Key(key) => {
let is_ctrl_c = matches!(
(key.code, key.modifiers),
(KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL)
);
if !is_ctrl_c {
self.last_ctrl_c = None;
}
// Handle consent dialog first (highest priority)
if let Some(consent_state) = &self.pending_consent {
let scope = match key.code {
KeyCode::Char('1') => Some(ConsentScope::Once),
KeyCode::Char('2') => Some(ConsentScope::Session),
KeyCode::Char('3') => Some(ConsentScope::Permanent),
KeyCode::Char('4') | KeyCode::Esc => Some(ConsentScope::Denied),
_ => None,
};
if let Some(scope) = scope {
let request_id = consent_state.request_id;
let effects =
self.apply_app_event(AppEvent::ToolPermission { request_id, scope });
self.handle_app_effects(effects).await?;
}
return Ok(AppState::Running);
}
if self.try_execute_command(&key).await? {
return Ok(AppState::Running);
}
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();
} else {
self.set_input_mode(InputMode::Help);
self.status = "Help".to_string();
self.error = None;
}
return Ok(AppState::Running);
}
let is_question_mark = matches!(
(key.code, key.modifiers),
(KeyCode::Char('?'), KeyModifiers::NONE | KeyModifiers::SHIFT)
);
let is_reveal_active = key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& matches!(key.code, KeyCode::Char('r') | KeyCode::Char('R'));
let is_repo_search = key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& matches!(key.code, KeyCode::Char('f') | KeyCode::Char('F'));
let is_symbol_search_key = key.modifiers.contains(KeyModifiers::CONTROL)
&& key.modifiers.contains(KeyModifiers::SHIFT)
&& matches!(key.code, KeyCode::Char('p') | KeyCode::Char('P'));
let is_resize_left = key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(
key.code,
KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('H')
);
let is_resize_right = key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(
key.code,
KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('L')
);
let is_resize_up = key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(
key.code,
KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K')
);
let is_resize_down = key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(
key.code,
KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J')
);
if is_reveal_active && matches!(self.mode, InputMode::Normal) {
self.reveal_active_file();
return Ok(AppState::Running);
}
if is_question_mark && matches!(self.mode, InputMode::Normal) {
self.set_input_mode(InputMode::Help);
self.status = "Help".to_string();
return Ok(AppState::Running);
}
if is_repo_search && matches!(self.mode, InputMode::Normal) {
self.set_input_mode(InputMode::RepoSearch);
if self.repo_search.query_input().is_empty() {
*self.repo_search.status_mut() =
Some("Type a pattern · Enter runs ripgrep".to_string());
}
self.status = "Repo search active".to_string();
return Ok(AppState::Running);
}
if (is_resize_left || is_resize_right)
&& matches!(self.mode, InputMode::Normal)
&& !self.is_file_panel_collapsed()
{
let current = self.file_panel_width();
let delta: i16 = if is_resize_left { -2 } else { 2 };
let candidate = current.saturating_add_signed(delta);
let adjusted = self.set_file_panel_width(candidate);
if adjusted != current {
self.status = format!("Files panel width: {} cols", adjusted);
self.error = None;
} else {
self.status = "Files panel width unchanged".to_string();
}
return Ok(AppState::Running);
}
if (is_resize_up || is_resize_down) && matches!(self.mode, InputMode::Normal) {
let delta = if is_resize_up { -0.05 } else { 0.05 };
self.adjust_vertical_split(delta);
self.status = format!(
"Vertical split adjusted ({})",
if delta.is_sign_positive() {
"down"
} else {
"up"
}
);
self.error = None;
return Ok(AppState::Running);
}
if is_symbol_search_key && matches!(self.mode, InputMode::Normal) {
self.set_input_mode(InputMode::SymbolSearch);
self.symbol_search.clear_query();
self.status = "Symbol search active".to_string();
self.start_symbol_search().await?;
return Ok(AppState::Running);
}
match self.mode {
InputMode::Normal => {
// Handle multi-key sequences first
if self.show_model_info
&& matches!(
(key.code, key.modifiers),
(KeyCode::Esc, KeyModifiers::NONE)
)
{
self.set_model_info_visible(false);
self.status = "Closed model info panel".to_string();
return Ok(AppState::Running);
}
if let Some(started) = self.pending_focus_chord {
if started.elapsed() > FOCUS_CHORD_TIMEOUT {
self.pending_focus_chord = None;
} else if key.modifiers.is_empty() {
let direction = match key.code {
KeyCode::Left => Some(PaneDirection::Left),
KeyCode::Right => Some(PaneDirection::Right),
KeyCode::Up => Some(PaneDirection::Up),
KeyCode::Down => Some(PaneDirection::Down),
_ => None,
};
if let Some(direction) = direction {
self.handle_workspace_focus_move(direction);
return Ok(AppState::Running);
} else {
self.pending_focus_chord = None;
}
} else {
self.pending_focus_chord = None;
}
}
if let Some(pending) = self.pending_key {
self.pending_key = None;
match (pending, key.code) {
('g', KeyCode::Char('g')) => {
self.jump_to_top();
}
('g', KeyCode::Char('T')) | ('g', KeyCode::Char('t')) => {
self.expand_file_panel();
self.focused_panel = FocusedPanel::Files;
self.status = "Files panel focused".to_string();
}
('g', KeyCode::Char('h')) | ('g', KeyCode::Char('H')) => {
if matches!(self.focused_panel, FocusedPanel::Files) {
self.toggle_hidden_files();
} else {
self.status =
"Toggle hidden files from the Files panel".to_string();
}
}
('W', KeyCode::Char('s')) | ('W', KeyCode::Char('S')) => {
self.split_active_pane(SplitAxis::Horizontal);
}
('W', KeyCode::Char('v')) | ('W', KeyCode::Char('V')) => {
self.split_active_pane(SplitAxis::Vertical);
}
('d', KeyCode::Char('d')) => {
// Clear input buffer
self.input_buffer_mut().clear();
self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea);
self.status = "Input buffer cleared".to_string();
}
_ => {
// Invalid sequence, ignore
}
}
return Ok(AppState::Running);
}
if matches!(self.focused_panel, FocusedPanel::Files)
&& self.handle_file_panel_key(&key).await?
{
return Ok(AppState::Running);
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('w') | KeyCode::Char('W'))
{
self.pending_key = Some('W');
self.status =
"Split layout: press s for horizontal, v for vertical".to_string();
return Ok(AppState::Running);
}
match (key.code, key.modifiers) {
(KeyCode::Left, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
self.handle_workspace_resize(PaneDirection::Left);
return Ok(AppState::Running);
}
(KeyCode::Right, modifiers)
if modifiers.contains(KeyModifiers::ALT) =>
{
self.handle_workspace_resize(PaneDirection::Right);
return Ok(AppState::Running);
}
(KeyCode::Up, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
self.handle_workspace_resize(PaneDirection::Up);
return Ok(AppState::Running);
}
(KeyCode::Down, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
self.handle_workspace_resize(PaneDirection::Down);
return Ok(AppState::Running);
}
(KeyCode::Char('c'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
if self.cancel_active_generation()? {
self.last_ctrl_c = None;
return Ok(AppState::Running);
}
let now = Instant::now();
if let Some(last) = self.last_ctrl_c
&& now.duration_since(last) <= DOUBLE_CTRL_C_WINDOW
{
self.status = "Exiting…".to_string();
self.set_system_status(String::new());
self.last_ctrl_c = None;
return Ok(AppState::Quit);
}
self.last_ctrl_c = Some(now);
self.status = "Press Ctrl+C again to quit".to_string();
self.set_system_status(
"Press Ctrl+C again to quit OWLEN".to_string(),
);
return Ok(AppState::Running);
}
(KeyCode::Char('j'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
if self.show_model_info && self.model_info_viewport_height > 0 {
self.model_info_panel
.scroll_down(self.model_info_viewport_height);
}
}
(KeyCode::Char('k'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.pending_focus_chord = Some(Instant::now());
self.status = "Pane focus pending — use ←/→/↑/↓".to_string();
if self.show_model_info && self.model_info_viewport_height > 0 {
self.model_info_panel.scroll_up();
}
return Ok(AppState::Running);
}
// Mode switches
(KeyCode::Char('v'), KeyModifiers::NONE) => {
if matches!(self.focused_panel, FocusedPanel::Code) {
self.status =
"Code view is read-only; yank text with :open and copy manually."
.to_string();
return Ok(AppState::Running);
}
self.set_input_mode(InputMode::Visual);
match self.focused_panel {
FocusedPanel::Input => {
// Sync buffer to textarea before entering visual mode
self.sync_buffer_to_textarea();
// Set a visible selection style
let selection_style = Style::default()
.bg(self.theme.selection_bg)
.fg(self.theme.selection_fg);
self.textarea.set_selection_style(selection_style);
// Start visual selection at current cursor position
self.textarea.start_selection();
self.visual_start = Some(self.textarea.cursor());
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// For scrollable panels, start selection at cursor position
let cursor =
if matches!(self.focused_panel, FocusedPanel::Chat) {
self.chat_cursor
} else {
self.thinking_cursor
};
self.visual_start = Some(cursor);
self.visual_end = Some(cursor);
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
self.status =
"-- VISUAL -- (move with j/k, yank with y)".to_string();
}
(KeyCode::Char(':'), KeyModifiers::NONE) => {
self.set_input_mode(InputMode::Command);
self.command_palette.clear();
self.command_palette.ensure_suggestions();
self.status = ":".to_string();
}
(KeyCode::Char('p'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.set_input_mode(InputMode::Command);
self.command_palette.clear();
self.command_palette.ensure_suggestions();
self.status = ":".to_string();
return Ok(AppState::Running);
}
// Enter editing mode
(KeyCode::Enter, KeyModifiers::NONE)
| (KeyCode::Char('i'), KeyModifiers::NONE) => {
self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea();
}
(KeyCode::Char('a'), KeyModifiers::NONE) => {
// Append - move right and enter insert mode
self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Forward);
}
(KeyCode::Char('A'), KeyModifiers::SHIFT) => {
// Append at end of line
self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
(KeyCode::Char('I'), KeyModifiers::SHIFT) => {
// Insert at start of line
self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
(KeyCode::Char('o'), KeyModifiers::NONE) => {
// Insert newline below and enter edit mode
self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::End);
self.textarea.insert_newline();
}
(KeyCode::Char('O'), KeyModifiers::NONE) => {
// Insert newline above and enter edit mode
self.set_input_mode(InputMode::Editing);
self.sync_buffer_to_textarea();
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
self.textarea.insert_newline();
self.textarea.move_cursor(tui_textarea::CursorMove::Up);
}
// Basic scrolling and cursor movement
(KeyCode::Up, KeyModifiers::NONE)
| (KeyCode::Char('k'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Chat => {
if self.chat_cursor.0 > 0 {
self.chat_cursor.0 -= 1;
// Scroll if cursor moves above viewport
if self.chat_cursor.0 < self.auto_scroll.scroll {
self.on_scroll(-1);
}
}
}
FocusedPanel::Thinking => {
if self.thinking_cursor.0 > 0 {
self.thinking_cursor.0 -= 1;
if self.thinking_cursor.0 < self.thinking_scroll.scroll
{
self.on_scroll(-1);
}
}
}
FocusedPanel::Files => {
self.file_tree_mut().move_cursor(-1);
}
FocusedPanel::Code => {
let viewport = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut()
&& scroll.scroll > 0
{
scroll.on_user_scroll(-1, viewport);
}
}
FocusedPanel::Input => {
self.on_scroll(-1);
}
}
}
(KeyCode::Down, KeyModifiers::NONE)
| (KeyCode::Char('j'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Chat => {
let max_lines = self.auto_scroll.content_len;
if self.chat_cursor.0 + 1 < max_lines {
self.chat_cursor.0 += 1;
// Scroll if cursor moves below viewport
let viewport_bottom =
self.auto_scroll.scroll + self.viewport_height;
if self.chat_cursor.0 >= viewport_bottom {
self.on_scroll(1);
}
}
}
FocusedPanel::Thinking => {
let max_lines = self.thinking_scroll.content_len;
if self.thinking_cursor.0 + 1 < max_lines {
self.thinking_cursor.0 += 1;
let viewport_bottom = self.thinking_scroll.scroll
+ self.thinking_viewport_height;
if self.thinking_cursor.0 >= viewport_bottom {
self.on_scroll(1);
}
}
}
FocusedPanel::Files => {
self.file_tree_mut().move_cursor(1);
}
FocusedPanel::Code => {
let viewport = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
let max_lines = scroll.content_len;
if scroll.scroll + viewport < max_lines {
scroll.on_user_scroll(1, viewport);
}
}
}
FocusedPanel::Input => {
self.on_scroll(1);
}
}
}
// Horizontal cursor movement
(KeyCode::Left, KeyModifiers::NONE)
| (KeyCode::Char('h'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Chat => {
if self.chat_cursor.1 > 0 {
self.chat_cursor.1 -= 1;
}
}
FocusedPanel::Thinking => {
if self.thinking_cursor.1 > 0 {
self.thinking_cursor.1 -= 1;
}
}
FocusedPanel::Code => {}
_ => {}
}
}
(KeyCode::Right, KeyModifiers::NONE)
| (KeyCode::Char('l'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Chat => {
if let Some(line) = self.get_line_at_row(self.chat_cursor.0)
{
let max_col = line.chars().count();
if self.chat_cursor.1 < max_col {
self.chat_cursor.1 += 1;
}
}
}
FocusedPanel::Thinking => {
if let Some(line) =
self.get_line_at_row(self.thinking_cursor.0)
{
let max_col = line.chars().count();
if self.thinking_cursor.1 < max_col {
self.thinking_cursor.1 += 1;
}
}
}
FocusedPanel::Code => {}
_ => {}
}
}
// Word movement
(KeyCode::Char('w'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(new_col) = self.find_next_word_boundary(
self.chat_cursor.0,
self.chat_cursor.1,
) {
self.chat_cursor.1 = new_col;
}
}
FocusedPanel::Thinking => {
if let Some(new_col) = self.find_next_word_boundary(
self.thinking_cursor.0,
self.thinking_cursor.1,
) {
self.thinking_cursor.1 = new_col;
}
}
FocusedPanel::Code => {}
_ => {}
},
(KeyCode::Char('e'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(new_col) =
self.find_word_end(self.chat_cursor.0, self.chat_cursor.1)
{
self.chat_cursor.1 = new_col;
}
}
FocusedPanel::Thinking => {
if let Some(new_col) = self.find_word_end(
self.thinking_cursor.0,
self.thinking_cursor.1,
) {
self.thinking_cursor.1 = new_col;
}
}
FocusedPanel::Code => {}
_ => {}
},
(KeyCode::Char('b'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(new_col) = self.find_prev_word_boundary(
self.chat_cursor.0,
self.chat_cursor.1,
) {
self.chat_cursor.1 = new_col;
}
}
FocusedPanel::Thinking => {
if let Some(new_col) = self.find_prev_word_boundary(
self.thinking_cursor.0,
self.thinking_cursor.1,
) {
self.thinking_cursor.1 = new_col;
}
}
FocusedPanel::Code => {}
_ => {}
},
(KeyCode::Char('^'), KeyModifiers::SHIFT) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
let first_non_blank = line
.chars()
.position(|c| !c.is_whitespace())
.unwrap_or(0);
self.chat_cursor.1 = first_non_blank;
}
}
FocusedPanel::Thinking => {
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0)
{
let first_non_blank = line
.chars()
.position(|c| !c.is_whitespace())
.unwrap_or(0);
self.thinking_cursor.1 = first_non_blank;
}
}
FocusedPanel::Code => {}
_ => {}
},
// Line start/end navigation
(KeyCode::Char('0'), KeyModifiers::NONE)
| (KeyCode::Home, KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
self.chat_cursor.1 = 0;
}
FocusedPanel::Thinking => {
self.thinking_cursor.1 = 0;
}
FocusedPanel::Code => {}
_ => {}
},
(KeyCode::Char('$'), KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
if let Some(line) = self.get_line_at_row(self.chat_cursor.0) {
self.chat_cursor.1 = line.chars().count();
}
}
FocusedPanel::Thinking => {
if let Some(line) = self.get_line_at_row(self.thinking_cursor.0)
{
self.thinking_cursor.1 = line.chars().count();
}
}
FocusedPanel::Code => {}
_ => {}
},
(KeyCode::End, KeyModifiers::NONE) => match self.focused_panel {
FocusedPanel::Chat => {
self.jump_to_bottom();
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.jump_to_bottom(viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().jump_to_bottom();
}
FocusedPanel::Code => {
let viewport = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.jump_to_bottom(viewport);
}
}
FocusedPanel::Input => {}
},
// Half-page scrolling
(KeyCode::Char('d'), KeyModifiers::CONTROL) => {
self.scroll_half_page_down();
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => {
self.scroll_half_page_up();
}
// Full-page scrolling
(KeyCode::Char('f'), KeyModifiers::CONTROL)
| (KeyCode::PageDown, KeyModifiers::NONE) => {
self.scroll_full_page_down();
}
(KeyCode::Char('b'), KeyModifiers::CONTROL)
| (KeyCode::PageUp, KeyModifiers::NONE) => {
self.scroll_full_page_up();
}
// Jump to top/bottom
(KeyCode::Char('G'), KeyModifiers::SHIFT) => {
self.jump_to_bottom();
}
// Multi-key sequences
(KeyCode::Char('g'), KeyModifiers::NONE) => {
self.pending_key = Some('g');
self.status = "g".to_string();
}
(KeyCode::Char('d'), KeyModifiers::NONE) => {
self.pending_key = Some('d');
self.status = "d".to_string();
}
// Yank/paste (works from any panel)
(KeyCode::Char('p'), KeyModifiers::NONE) => {
if !self.clipboard.is_empty() {
// Always paste into Input panel
let current_lines = self.textarea.lines().to_vec();
let clipboard_lines: Vec<String> =
self.clipboard.lines().map(|s| s.to_string()).collect();
// Append clipboard content to current input
let mut new_lines = current_lines;
if new_lines.is_empty() || new_lines == vec![String::new()] {
new_lines = clipboard_lines;
} else {
// Add newline and append
new_lines.push(String::new());
new_lines.extend(clipboard_lines);
}
self.textarea = TextArea::new(new_lines);
configure_textarea_defaults(&mut self.textarea);
self.sync_textarea_to_buffer();
self.status = "Pasted into input".to_string();
}
}
// Panel switching
(KeyCode::Tab, KeyModifiers::NONE) => {
self.cycle_focus_forward();
let panel_name = match self.focused_panel {
FocusedPanel::Files => "Files",
FocusedPanel::Chat => "Chat",
FocusedPanel::Thinking => "Thinking",
FocusedPanel::Input => "Input",
FocusedPanel::Code => "Code",
};
self.status = format!("Focus: {}", panel_name);
}
(KeyCode::BackTab, KeyModifiers::SHIFT) => {
self.cycle_focus_backward();
let panel_name = match self.focused_panel {
FocusedPanel::Files => "Files",
FocusedPanel::Chat => "Chat",
FocusedPanel::Thinking => "Thinking",
FocusedPanel::Input => "Input",
FocusedPanel::Code => "Code",
};
self.status = format!("Focus: {}", panel_name);
}
(KeyCode::Char('1'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Files) {
self.status = "Focus: Files (Ctrl+1)".to_string();
self.error = None;
} else if self.is_code_mode() {
self.status = "Files panel is collapsed — use :files to reopen"
.to_string();
}
return Ok(AppState::Running);
}
(KeyCode::Char('2'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Chat) {
self.status = "Focus: Chat (Ctrl+2)".to_string();
self.error = None;
}
return Ok(AppState::Running);
}
(KeyCode::Char('3'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Code) {
self.status = "Focus: Code (Ctrl+3)".to_string();
self.error = None;
} else {
self.status =
"Open a file to focus the code workspace".to_string();
}
return Ok(AppState::Running);
}
(KeyCode::Char('4'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Thinking) {
self.status = "Focus: Thinking (Ctrl+4)".to_string();
self.error = None;
} else {
self.status = "No reasoning panel to focus yet".to_string();
}
return Ok(AppState::Running);
}
(KeyCode::Char('5'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL)
|| modifiers.contains(KeyModifiers::ALT) =>
{
if self.focus_panel(FocusedPanel::Input) {
self.status =
"Focus: Input (Ctrl+5) — press i to edit".to_string();
self.error = None;
}
return Ok(AppState::Running);
}
(KeyCode::Char('m'), KeyModifiers::NONE) => {
if let Err(err) = self.show_model_picker(None).await {
self.error = Some(err.to_string());
}
return Ok(AppState::Running);
}
(KeyCode::Esc, KeyModifiers::NONE) => {
self.pending_key = None;
self.set_input_mode(InputMode::Normal);
}
_ => {
self.pending_key = None;
}
}
}
InputMode::RepoSearch => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
self.set_input_mode(InputMode::Normal);
self.status = "Normal mode".to_string();
}
(KeyCode::Enter, modifiers) if modifiers.contains(KeyModifiers::ALT) => {
self.open_repo_search_scratch().await?;
}
(KeyCode::Enter, _) => {
if self.repo_search.running() {
self.status = "Search already running".to_string();
} else if self.repo_search.dirty() || !self.repo_search.has_results() {
self.start_repo_search().await?;
} else {
self.open_repo_search_match().await?;
}
}
(KeyCode::Backspace, modifiers)
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.repo_search.pop_query_char();
*self.repo_search.status_mut() =
Some("Press Enter to search".to_string());
self.status = format!("Query: {}", self.repo_search.query_input());
}
(KeyCode::Char('u'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.repo_search.clear_query();
*self.repo_search.status_mut() = Some("Query cleared".to_string());
self.status = "Query cleared".to_string();
}
(KeyCode::Delete, _) => {
self.repo_search.clear_query();
*self.repo_search.status_mut() = Some("Query cleared".to_string());
self.status = "Query cleared".to_string();
}
(KeyCode::Char(c), modifiers)
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT)
&& !c.is_control() =>
{
self.repo_search.push_query_char(c);
*self.repo_search.status_mut() =
Some("Press Enter to search".to_string());
self.status = format!("Query: {}", self.repo_search.query_input());
}
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
self.repo_search.move_selection(-1);
}
(KeyCode::Down, _)
| (KeyCode::Char('j'), KeyModifiers::NONE)
| (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
self.repo_search.move_selection(1);
}
(KeyCode::PageUp, _) => {
self.repo_search.page(-1);
}
(KeyCode::PageDown, _) => {
self.repo_search.page(1);
}
(KeyCode::Home, _) => {
self.repo_search.scroll_to(0);
}
(KeyCode::End, _) => {
let max = self.repo_search.max_scroll();
self.repo_search.scroll_to(max);
}
_ => {}
},
InputMode::SymbolSearch => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
self.set_input_mode(InputMode::Normal);
self.status = "Normal mode".to_string();
}
(KeyCode::Enter, _) => {
if self.symbol_search.is_running() {
self.status = "Symbol index still building".to_string();
} else {
self.open_symbol_search_entry().await?;
}
}
(KeyCode::Backspace, modifiers)
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.symbol_search.pop_query_char();
self.status = format!("Symbol filter: {}", self.symbol_search.query());
}
(KeyCode::Char('u'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.symbol_search.clear_query();
self.status = "Symbol query cleared".to_string();
}
(KeyCode::Delete, _) => {
self.symbol_search.clear_query();
self.status = "Symbol query cleared".to_string();
}
(KeyCode::Char(c), modifiers)
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT)
&& !c.is_control() =>
{
self.symbol_search.push_query_char(c);
self.status = format!("Symbol filter: {}", self.symbol_search.query());
}
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
self.symbol_search.move_selection(-1);
}
(KeyCode::Down, _)
| (KeyCode::Char('j'), KeyModifiers::NONE)
| (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
self.symbol_search.move_selection(1);
}
(KeyCode::PageUp, _) => {
self.symbol_search.page(-1);
}
(KeyCode::PageDown, _) => {
self.symbol_search.page(1);
}
_ => {}
},
InputMode::Editing => match (key.code, key.modifiers) {
(KeyCode::Char('p'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Command);
self.command_palette.clear();
self.command_palette.ensure_suggestions();
self.status = ":".to_string();
}
(KeyCode::Char('c'), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
let _ = self.cancel_active_generation()?;
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Esc, KeyModifiers::NONE) => {
// Sync textarea content to input buffer before leaving edit mode
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Char('['), modifiers)
if modifiers.contains(KeyModifiers::CONTROL) =>
{
self.sync_textarea_to_buffer();
self.set_input_mode(InputMode::Normal);
self.reset_status();
}
(KeyCode::Char('j' | 'J'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.insert_newline();
}
(KeyCode::Enter, KeyModifiers::NONE) => {
self.sync_textarea_to_buffer();
let effects =
self.apply_app_event(AppEvent::Composer(ComposerEvent::Submit));
self.handle_app_effects(effects).await?;
return Ok(AppState::Running);
}
(KeyCode::Enter, _) => {
// Any Enter with modifiers keeps editing and inserts a newline via tui-textarea
self.textarea.input(Input::from(key));
}
// History navigation
(KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_previous();
self.sync_buffer_to_textarea();
}
(KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
// Vim-style navigation with Ctrl
(KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
(KeyCode::Char('e'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
(KeyCode::Char('w'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordForward);
}
(KeyCode::Char('b'), m) if m.contains(KeyModifiers::CONTROL) => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordBack);
}
(KeyCode::Tab, m) if m.is_empty() => {
if !self.complete_resource_reference() {
self.textarea.input(Input::from(key));
}
}
(KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => {
// Redo - history next
self.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
}
_ => {
// Let tui-textarea handle all other input
self.textarea.input(Input::from(key));
}
},
InputMode::Visual => match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('v'), KeyModifiers::NONE) => {
// Cancel selection and return to normal mode
if matches!(self.focused_panel, FocusedPanel::Input) {
self.textarea.cancel_selection();
}
self.set_input_mode(InputMode::Normal);
self.visual_start = None;
self.visual_end = None;
self.reset_status();
}
(KeyCode::Char('y'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
// Yank selected text using tui-textarea's copy
self.textarea.copy();
// Get the yanked text from textarea's internal clipboard
let yanked = self.textarea.yank_text();
if !yanked.is_empty() {
self.clipboard = yanked;
self.status =
format!("Yanked {} chars", self.clipboard.len());
} else {
// Fall back to yanking current line if no selection
let (row, _) = self.textarea.cursor();
if let Some(line) = self.textarea.lines().get(row) {
self.clipboard = line.clone();
self.status = format!(
"Yanked line ({} chars)",
self.clipboard.len()
);
}
}
self.textarea.cancel_selection();
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Yank selected lines from scrollable panels
if let Some(yanked) = self.yank_from_panel() {
self.clipboard = yanked;
self.status =
format!("Yanked {} chars", self.clipboard.len());
} else {
self.status = "Nothing to yank".to_string();
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
self.set_input_mode(InputMode::Normal);
self.visual_start = None;
self.visual_end = None;
}
(KeyCode::Char('d'), KeyModifiers::NONE) | (KeyCode::Delete, _) => {
match self.focused_panel {
FocusedPanel::Input => {
// Cut (delete) selected text using tui-textarea's cut
if self.textarea.cut() {
// Get the cut text
let cut_text = self.textarea.yank_text();
self.clipboard = cut_text;
self.sync_textarea_to_buffer();
self.status = format!("Cut {} chars", self.clipboard.len());
} else {
self.status = "Nothing to cut".to_string();
}
self.textarea.cancel_selection();
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Can't delete from read-only panels, just yank
if let Some(yanked) = self.yank_from_panel() {
self.clipboard = yanked;
self.status = format!(
"Yanked {} chars (read-only panel)",
self.clipboard.len()
);
} else {
self.status = "Nothing to yank".to_string();
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
self.set_input_mode(InputMode::Normal);
self.visual_start = None;
self.visual_end = None;
}
// Movement keys to extend selection
(KeyCode::Left, _) | (KeyCode::Char('h'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Back);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection left (decrease column)
if let Some((row, col)) = self.visual_end
&& col > 0
{
self.visual_end = Some((row, col - 1));
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Right, _) | (KeyCode::Char('l'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Forward);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection right (increase column)
if let Some((row, col)) = self.visual_end {
self.visual_end = Some((row, col + 1));
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Up);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection up (decrease end row)
if let Some((row, col)) = self.visual_end
&& row > 0
{
self.visual_end = Some((row - 1, col));
// Scroll if needed to keep selection visible
self.on_scroll(-1);
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Down);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection down (increase end row)
if let Some((row, col)) = self.visual_end {
// Get max lines for the current panel
let max_lines =
if matches!(self.focused_panel, FocusedPanel::Chat) {
self.auto_scroll.content_len
} else {
self.thinking_scroll.content_len
};
if row + 1 < max_lines {
self.visual_end = Some((row + 1, col));
// Scroll if needed to keep selection visible
self.on_scroll(1);
}
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Char('w'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordForward);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection forward by word
if let Some((row, col)) = self.visual_end {
if let Some(new_col) =
self.find_next_word_boundary(row, col)
{
self.visual_end = Some((row, new_col));
}
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Char('b'), KeyModifiers::NONE) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea
.move_cursor(tui_textarea::CursorMove::WordBack);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection backward by word
if let Some((row, col)) = self.visual_end {
if let Some(new_col) =
self.find_prev_word_boundary(row, col)
{
self.visual_end = Some((row, new_col));
}
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Char('0'), KeyModifiers::NONE) | (KeyCode::Home, _) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::Head);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection to start of line
if let Some((row, _)) = self.visual_end {
self.visual_end = Some((row, 0));
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
(KeyCode::Char('$'), KeyModifiers::NONE) | (KeyCode::End, _) => {
match self.focused_panel {
FocusedPanel::Input => {
self.textarea.move_cursor(tui_textarea::CursorMove::End);
}
FocusedPanel::Chat | FocusedPanel::Thinking => {
// Move selection to end of line
if let Some((row, _)) = self.visual_end {
if let Some(line) = self.get_line_at_row(row) {
let line_len = line.chars().count();
self.visual_end = Some((row, line_len));
}
}
}
FocusedPanel::Files => {}
FocusedPanel::Code => {}
}
}
_ => {
// Ignore all other input in visual mode (no typing allowed)
}
},
InputMode::Command => match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
self.reset_status();
}
(KeyCode::Tab, _) => {
// Tab completion
self.complete_command();
}
(KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
// Navigate up in suggestions
self.command_palette.select_previous();
}
(KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
// Navigate down in suggestions
self.command_palette.select_next();
}
(KeyCode::Enter, _) => {
// Execute command
let cmd_owned = self.command_palette.buffer().trim().to_string();
let parts: Vec<&str> = cmd_owned.split_whitespace().collect();
let command_raw = parts.first().copied().unwrap_or("");
let args = &parts[1..];
if !cmd_owned.is_empty() {
self.command_palette.remember(&cmd_owned);
}
let bare_command = command_raw.trim_end_matches('!');
let force = bare_command.len() != command_raw.len();
match bare_command {
"" => {}
"wq" | "x" => {
let path_arg = if args.is_empty() {
None
} else {
Some(args.join(" "))
};
let result =
self.save_active_code_buffer(path_arg, force).await?;
if matches!(result, SaveStatus::Saved | SaveStatus::NoChanges) {
self.close_active_code_buffer(force);
}
}
"w" | "write" | "save" => {
let path_arg = if args.is_empty() {
None
} else {
Some(args.join(" "))
};
let _ = self.save_active_code_buffer(path_arg, force).await?;
}
"q" => {
if matches!(self.focused_panel, FocusedPanel::Files)
&& !self.is_file_panel_collapsed()
{
self.collapse_file_panel();
self.status = "Files panel hidden".to_string();
self.error = None;
} else {
self.close_active_code_buffer(force);
}
}
"quit" => {
if matches!(self.focused_panel, FocusedPanel::Files)
&& !self.is_file_panel_collapsed()
{
self.collapse_file_panel();
self.status = "Files panel hidden".to_string();
self.error = None;
} else {
return Ok(AppState::Quit);
}
}
"create" => {
if !self.is_code_mode() {
self.status = "File operations are available in code mode"
.to_string();
self.error = None;
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
if args.is_empty() {
self.error = Some("Usage: :create <path>".to_string());
} else {
let path_arg = args.join(" ");
match self.create_file_from_command(&path_arg) {
Ok(message) => {
self.status = message;
self.error = None;
}
Err(err) => {
self.status = "File creation failed".to_string();
self.error = Some(err.to_string());
}
}
}
}
"files" | "explorer" => {
if !self.is_code_mode() {
self.status =
"File explorer is available in code mode".to_string();
self.error = None;
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
let was_collapsed = self.is_file_panel_collapsed();
self.toggle_file_panel();
let now_collapsed = self.is_file_panel_collapsed();
self.error = None;
if was_collapsed && !now_collapsed {
self.status = "Files panel shown".to_string();
} else if !was_collapsed && now_collapsed {
self.status = "Files panel hidden".to_string();
} else {
self.status = "Files panel unchanged".to_string();
}
}
"markdown" => {
let desired = if let Some(arg) = args.first() {
match arg.to_ascii_lowercase().as_str() {
"on" | "enable" | "enabled" | "true" => Some(true),
"off" | "disable" | "disabled" | "false" => Some(false),
"toggle" => None,
other => {
self.error = Some(format!(
"Unknown markdown option '{}'. Use on, off, or toggle.",
other
));
self.status =
"Usage: :markdown [on|off|toggle]".to_string();
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
}
} else {
None
};
let enable =
desired.unwrap_or_else(|| !self.render_markdown_enabled());
self.set_render_markdown(enable);
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
"c" | "clear" => {
self.controller.clear();
self.chat_line_offset = 0;
self.auto_scroll = AutoScroll::default();
self.clear_new_message_alert();
self.status = "Conversation cleared".to_string();
}
"session" => {
if let Some(subcommand) = args.first() {
match subcommand.to_ascii_lowercase().as_str() {
"save" => {
let name = if args.len() > 1 {
Some(args[1..].join(" "))
} else {
None
};
let description = if self
.controller
.config()
.storage
.generate_descriptions
{
self.status =
"Generating description...".to_string();
(self
.controller
.generate_conversation_description()
.await)
.ok()
} else {
None
};
match self
.controller
.save_active_session(name.clone(), description)
.await
{
Ok(id) => {
self.status = if let Some(name) = name {
format!("Session saved: {name} ({id})")
} else {
format!("Session saved with id {id}")
};
self.error = None;
}
Err(e) => {
self.error = Some(format!(
"Failed to save session: {}",
e
));
}
}
}
other => {
self.error = Some(format!(
"Unknown session subcommand: {}",
other
));
}
}
} else {
self.status =
"Session commands: :session save [name]".to_string();
self.error = None;
}
}
"oauth" => {
if args.is_empty() {
let pending = self.controller.pending_oauth_servers();
if pending.is_empty() {
self.status =
"No OAuth-enabled MCP servers require authorization."
.to_string();
} else {
self.status = format!(
"Pending OAuth servers: {}",
pending.join(", ")
);
}
self.error = None;
} else if args.len() == 1 {
self.start_oauth_login(args[0]).await?;
} else if args.len() == 2
&& args[0].eq_ignore_ascii_case("login")
{
self.start_oauth_login(args[1]).await?;
} else {
self.error =
Some("Usage: :oauth [login] <server>".to_string());
}
}
"load" | "o" => {
// Load saved sessions and enter browser mode
match self.controller.list_saved_sessions().await {
Ok(sessions) => {
self.saved_sessions = sessions;
self.selected_session_index = 0;
self.set_input_mode(InputMode::SessionBrowser);
self.command_palette.clear();
return Ok(AppState::Running);
}
Err(e) => {
self.error =
Some(format!("Failed to list sessions: {}", e));
}
}
}
"open" => {
if let Some(path) = args.first() {
if !matches!(
self.operating_mode,
owlen_core::mode::Mode::Code
) {
self.error = Some(
"Code view requires code mode. Run :mode code first."
.to_string(),
);
} else {
match self.controller.read_file_with_tools(path).await {
Ok(content) => {
let absolute =
self.absolute_tree_path(Path::new(path));
self.set_code_view_content(
path.to_string(),
Some(absolute),
content,
);
self.focused_panel = FocusedPanel::Code;
self.ensure_focus_valid();
self.status = format!("Opened {}", path);
self.set_system_status(format!(
"Viewing {}",
path
));
self.error = None;
}
Err(e) => {
self.error =
Some(format!("Failed to open file: {}", e));
}
}
}
} else {
self.error = Some("Usage: :open <path>".to_string());
}
}
"close" => {
if self.has_loaded_code_view() {
self.close_code_view();
self.status = "Closed code view".to_string();
self.set_system_status(String::new());
self.error = None;
} else {
self.status = "No code view active".to_string();
}
}
"sessions" => {
// List saved sessions
match self.controller.list_saved_sessions().await {
Ok(sessions) => {
self.saved_sessions = sessions;
self.selected_session_index = 0;
self.set_input_mode(InputMode::SessionBrowser);
self.command_palette.clear();
return Ok(AppState::Running);
}
Err(e) => {
self.error =
Some(format!("Failed to list sessions: {}", e));
}
}
}
"mode" => {
// Switch mode with argument: :mode chat or :mode code
if args.is_empty() {
self.status = format!(
"Current mode: {}. Usage: :mode <chat|code>",
self.operating_mode
);
} else {
let mode_str = args[0];
match mode_str.parse::<owlen_core::mode::Mode>() {
Ok(new_mode) => {
self.set_mode(new_mode).await;
}
Err(err) => {
self.error = Some(err);
}
}
}
}
"code" => {
// Shortcut to switch to code mode
self.set_mode(owlen_core::mode::Mode::Code).await;
}
"chat" => {
// Shortcut to switch to chat mode
self.set_mode(owlen_core::mode::Mode::Chat).await;
}
"tools" => {
if args.is_empty() {
// List available tools in current mode and available presets
let available_tools: Vec<String> = {
let config = self.config_async().await;
vec![
WEB_SEARCH_TOOL_NAME.to_string(),
"code_exec".to_string(),
"file_write".to_string(),
]
.into_iter()
.filter(|tool| {
config
.modes
.is_tool_allowed(self.operating_mode, tool)
})
.collect()
}; // config dropped here
let presets = presets::tier_descriptions();
let preset_help: Vec<String> = presets
.iter()
.map(|(tier, description)| {
format!("{}: {}", tier.as_str(), description)
})
.collect();
if available_tools.is_empty() {
self.status = format!(
"No tools available in {} mode. Presets: {}",
self.operating_mode,
preset_help.join(" | ")
);
} else {
self.status = format!(
"Tools in {} mode: {} · Presets: {}",
self.operating_mode,
available_tools.join(", "),
preset_help.join(" | ")
);
}
} else {
match args[0].to_lowercase().as_str() {
"install" => {
if args.len() < 2 {
self.error = Some(
"Usage: :tools install <preset> [--prune]"
.to_string(),
);
} else {
let preset_name = &args[1];
let prune = args
.iter()
.skip(2)
.any(|arg| *arg == "--prune");
match self
.install_tool_preset(preset_name, prune)
.await
{
Ok(message) => {
self.status = message;
self.error = None;
}
Err(err) => {
self.error = Some(err.to_string());
self.status =
"Preset installation failed"
.to_string();
}
}
}
}
"audit" => {
let preset_name = args.get(1).map(|s| *s);
match self.audit_tool_preset(preset_name).await {
Ok(message) => {
self.status = message;
self.error = None;
}
Err(err) => {
self.error = Some(err.to_string());
self.status =
"Preset audit failed".to_string();
}
}
}
other => {
self.error = Some(format!(
"Unknown :tools subcommand '{}'.",
other
));
}
}
}
}
"h" | "help" => {
self.set_input_mode(InputMode::Help);
self.status = "Help".to_string();
self.error = None;
self.command_palette.clear();
return Ok(AppState::Running);
}
"m" | "model" => {
if args.is_empty() {
if let Err(err) = self.show_model_picker(None).await {
self.error = Some(err.to_string());
}
self.command_palette.clear();
return Ok(AppState::Running);
}
let subcommand = args[0].to_lowercase();
match subcommand.as_str() {
"info" | "details" | "refresh" => {
let outcome: Result<()> = match subcommand.as_str() {
"info" => {
let target = if args.len() > 1 {
args[1..].join(" ")
} else {
self.controller.selected_model().to_string()
};
if target.trim().is_empty() {
Err(anyhow!("Usage: :model info <name>"))
} else {
self.ensure_model_details(&target, false)
.await
}
}
"details" => {
let target = self
.controller
.selected_model()
.to_string();
if target.trim().is_empty() {
Err(anyhow!(
"No active model set. Use :model to choose one first"
))
} else {
self.ensure_model_details(&target, false)
.await
}
}
_ => {
let target = if args.len() > 1 {
args[1..].join(" ")
} else {
self.controller.selected_model().to_string()
};
if target.trim().is_empty() {
Err(anyhow!("Usage: :model refresh <name>"))
} else {
self.ensure_model_details(&target, true)
.await
}
}
};
match outcome {
Ok(_) => self.error = None,
Err(err) => self.error = Some(err.to_string()),
}
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
_ => {
let filter = args.join(" ");
match self.select_model_with_filter(&filter).await {
Ok(_) => self.error = None,
Err(err) => {
self.status = err.to_string();
self.error = Some(err.to_string());
}
}
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
}
}
"provider" => {
if args.is_empty() {
self.error = Some("Usage: :provider <name>".to_string());
self.status = "Usage: :provider <name>".to_string();
} else {
let provider_query = args[0].to_string();
let mode_arg = args.get(1).map(|value| value.to_string());
if let Some(mode_value) = mode_arg {
if let Some(provider) =
self.best_provider_match(&provider_query)
{
match self
.apply_provider_mode(&provider, &mode_value)
.await
{
Ok(_) => {
self.selected_provider = provider.clone();
self.update_selected_provider_index();
}
Err(err) => {
self.error = Some(err.to_string());
self.status = err.to_string();
}
}
} else {
self.error = Some(format!(
"No provider matching '{}'",
provider_query
));
self.status = format!(
"No provider matching '{}'",
provider_query.trim()
);
}
} else {
if self.available_providers.is_empty() {
if let Err(err) = self.refresh_models().await {
self.error = Some(format!(
"Failed to refresh providers: {}",
err
));
self.status =
"Unable to refresh providers".to_string();
}
}
let filter = provider_query;
if let Some(provider) =
self.best_provider_match(&filter)
{
match self.switch_to_provider(&provider).await {
Ok(_) => {
self.selected_provider = provider.clone();
self.update_selected_provider_index();
self.controller
.config_mut()
.general
.default_provider = provider.clone();
match config::save_config(
&self.controller.config(),
) {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!(
"Provider switched but config save failed: {}",
err
));
self.status =
"Provider switch saved with warnings"
.to_string();
}
}
self.status = format!(
"Active provider: {}",
provider
);
if let Err(err) =
self.refresh_models().await
{
self.error = Some(format!(
"Provider switched but refreshing models failed: {}",
err
));
self.status =
"Provider switched; failed to refresh models"
.to_string();
}
self.context_usage = None;
self.refresh_usage_summary().await?;
}
Err(err) => {
self.error = Some(format!(
"Failed to switch provider: {}",
err
));
self.status =
"Provider switch failed".to_string();
}
}
} else {
self.error = Some(format!(
"No provider matching '{}'",
filter
));
self.status = format!(
"No provider matching '{}'",
filter.trim()
);
}
}
}
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
"limits" => {
self.show_usage_limits().await?;
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
"models" => {
if args.is_empty() {
if let Err(err) = self.show_model_picker(None).await {
self.error = Some(err.to_string());
}
self.command_palette.clear();
return Ok(AppState::Running);
}
match args[0] {
"--local" => {
if let Err(err) = self
.show_model_picker(Some(FilterMode::LocalOnly))
.await
{
self.error = Some(err.to_string());
} else if !self
.focus_first_model_in_scope(&ModelScope::Local)
{
self.status =
"No local models available".to_string();
} else {
self.status = "Showing local models".to_string();
self.error = None;
}
self.command_palette.clear();
return Ok(AppState::Running);
}
"--cloud" => {
if let Err(err) = self
.show_model_picker(Some(FilterMode::CloudOnly))
.await
{
self.error = Some(err.to_string());
} else if !self
.focus_first_model_in_scope(&ModelScope::Cloud)
{
self.status =
"No cloud models available".to_string();
} else {
self.status = "Showing cloud models".to_string();
self.error = None;
}
self.command_palette.clear();
return Ok(AppState::Running);
}
"--available" => {
if let Err(err) = self
.show_model_picker(Some(FilterMode::Available))
.await
{
self.error = Some(err.to_string());
} else if !self.focus_first_available_model() {
self.status =
"No available models right now".to_string();
} else {
self.status =
"Showing available models".to_string();
self.error = None;
}
self.command_palette.clear();
return Ok(AppState::Running);
}
"info" => {
let force_refresh = args
.get(1)
.map(|flag| {
matches!(*flag, "refresh" | "-r" | "--refresh")
})
.unwrap_or(false);
let outcome = self
.prefetch_all_model_details(force_refresh)
.await;
match outcome {
Ok(_) => self.error = None,
Err(err) => self.error = Some(err.to_string()),
}
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
_ => {
self.error = Some(
"Usage: :models [--local|--cloud|info]".to_string(),
);
self.status =
"Usage: :models [--local|--cloud|info]".to_string();
}
}
self.set_input_mode(InputMode::Normal);
self.command_palette.clear();
return Ok(AppState::Running);
}
// "run-agent" command removed to break circular dependency on owlen-cli.
"agent" => {
if let Some(subcommand) = args.first() {
match subcommand.to_lowercase().as_str() {
"status" => {
let armed =
if self.agent_mode { "armed" } else { "idle" };
let running = if self.agent_running {
"running"
} else {
"stopped"
};
self.status =
format!("Agent status: {armed} · {running}");
self.error = None;
}
"start" | "arm" => {
if self.agent_running {
self.status =
"Agent is already running".to_string();
} else {
self.agent_mode = true;
self.status = "Agent armed. Next message will be processed by the agent.".to_string();
self.error = None;
}
}
"stop" => {
if self.agent_running {
self.agent_running = false;
self.agent_mode = false;
self.agent_actions = None;
self.status =
"Agent execution stopped".to_string();
self.error = None;
} else if self.agent_mode {
self.agent_mode = false;
self.agent_actions = None;
self.status = "Agent disarmed".to_string();
self.error = None;
} else {
self.status =
"No agent is currently running".to_string();
}
}
other => {
self.error =
Some(format!("Unknown agent command: {other}"));
}
}
} else if self.agent_running {
self.status = "Agent is already running".to_string();
} else {
self.agent_mode = true;
self.status = "Agent mode enabled. Next message will be processed by agent.".to_string();
self.error = None;
}
}
"stop-agent" => {
if self.agent_running {
self.agent_running = false;
self.agent_mode = false;
self.agent_actions = None;
self.status = "Agent execution stopped".to_string();
self.error = None;
} else if self.agent_mode {
self.agent_mode = false;
self.agent_actions = None;
self.status = "Agent disarmed".to_string();
self.error = None;
} else {
self.status = "No agent is currently running".to_string();
}
}
"n" | "new" => {
self.controller.start_new_conversation(None, None);
self.reset_after_new_conversation()?;
self.status = "Started new conversation".to_string();
self.error = None;
}
"e" | "edit" => {
if let Some(path) = args.first() {
match self.controller.read_file(path).await {
Ok(content) => {
let message = format!(
"The content of file `{}` is:\n```\n{}\n```",
path, content
);
self.controller
.conversation_mut()
.push_user_message(message);
self.pending_llm_request = true;
}
Err(e) => {
self.error =
Some(format!("Failed to read file: {}", e));
}
}
} else {
self.error = Some("Usage: :e <path>".to_string());
}
}
"ls" => {
let path = args.first().copied().unwrap_or(".");
match self.controller.list_dir(path).await {
Ok(entries) => {
let message = format!(
"Directory listing for `{}`:\n```\n{}\n```",
path,
entries.join("\n")
);
self.controller
.conversation_mut()
.push_user_message(message);
}
Err(e) => {
self.error =
Some(format!("Failed to list directory: {}", e));
}
}
}
"theme" => {
if args.is_empty() {
self.error = Some("Usage: :theme <name>".to_string());
} else {
let theme_name = args.join(" ");
match self.switch_theme(&theme_name) {
Ok(_) => {
// Success message already set by switch_theme
}
Err(_) => {
// Error message already set by switch_theme
}
}
}
}
"tutorial" => {
self.show_tutorial();
}
"themes" => {
// Load all themes and enter browser mode
let themes = owlen_core::theme::load_all_themes();
let mut theme_list: Vec<String> =
themes.keys().cloned().collect();
theme_list.sort();
self.available_themes = theme_list;
// Set selected index to current theme
let current_theme = &self.theme.name;
self.selected_theme_index = self
.available_themes
.iter()
.position(|name| name == current_theme)
.unwrap_or(0);
self.set_input_mode(InputMode::ThemeBrowser);
self.command_palette.clear();
return Ok(AppState::Running);
}
"layout" => {
if let Some(subcommand) = args.first() {
match subcommand.to_lowercase().as_str() {
"save" => {
if self.code_workspace.tabs().is_empty() {
self.status =
"No open panes to save".to_string();
self.error = None;
self.push_toast(
ToastLevel::Warning,
"Open a pane before saving layout.",
);
} else {
self.persist_workspace_layout();
self.status =
"Workspace layout saved".to_string();
self.error = None;
self.push_toast(
ToastLevel::Success,
"Workspace layout saved.",
);
}
}
"load" => match self.restore_workspace_layout().await {
Ok(true) => {
self.status =
"Workspace layout restored".to_string();
self.error = None;
self.push_toast(
ToastLevel::Success,
"Workspace layout restored.",
);
}
Ok(false) => {
self.status =
"No saved layout to restore".to_string();
self.error = None;
self.push_toast(
ToastLevel::Info,
"No saved layout was found.",
);
}
Err(err) => {
let message = format!(
"Failed to restore workspace layout: {}",
err
);
self.error = Some(message.clone());
self.status =
"Failed to restore workspace layout"
.to_string();
self.push_toast(ToastLevel::Error, message);
}
},
other => {
self.status =
format!("Unknown layout command: {other}");
self.error = Some(format!(
"Unknown layout subcommand: {other}"
));
}
}
} else {
self.status = "Usage: :layout <save|load>".to_string();
}
}
"reload" => {
// Reload config
match owlen_core::config::Config::load(None) {
Ok(new_config) => {
// Update controller config
*self.controller.config_mut() = new_config.clone();
// Reload theme based on updated config
let theme_name = &new_config.ui.theme;
if let Some(new_theme) =
owlen_core::theme::get_theme(theme_name)
{
self.theme = new_theme;
self.status = format!(
"Configuration and theme reloaded (theme: {})",
theme_name
);
} else {
self.status = "Configuration reloaded, but theme not found. Using current theme.".to_string();
}
self.error = None;
self.sync_ui_preferences_from_config();
self.update_command_palette_catalog();
if let Err(err) = self.refresh_resource_catalog().await
{
self.push_toast(
ToastLevel::Error,
format!(
"Failed to refresh MCP resources: {}",
err
),
);
}
if let Err(err) =
self.refresh_mcp_slash_commands().await
{
self.push_toast(
ToastLevel::Error,
format!(
"Failed to refresh MCP slash commands: {}",
err
),
);
}
}
Err(e) => {
self.error =
Some(format!("Failed to reload config: {}", e));
}
}
}
"cloud" => {
match self.handle_cloud_command(args).await {
Ok(_) => {
if self.error.is_some() {
// leave existing error
} else {
self.error = None;
}
}
Err(err) => {
let message = err.to_string();
if self.status.trim().is_empty() {
self.status = message.clone();
}
self.error = Some(message);
}
}
self.command_palette.clear();
self.set_input_mode(InputMode::Normal);
return Ok(AppState::Running);
}
"web" => {
let action =
args.first().map(|value| value.to_ascii_lowercase());
match action.as_deref() {
Some("on") | Some("enable") => {
match self.set_web_tool_enabled(true).await {
Ok(_) => {
self.status = "Web search enabled; remote lookups allowed."
.to_string();
self.error = None;
self.push_toast(
ToastLevel::Info,
"Web search enabled; remote lookups allowed.",
);
}
Err(err) => {
self.status =
"Failed to enable web search".to_string();
self.error = Some(format!(
"Failed to enable web search: {}",
err
));
}
}
}
Some("off") | Some("disable") => {
match self.set_web_tool_enabled(false).await {
Ok(_) => {
self.status =
"Web search disabled; staying local."
.to_string();
self.error = None;
self.push_toast(
ToastLevel::Warning,
"Web search disabled; staying local.",
);
}
Err(err) => {
self.status =
"Failed to disable web search".to_string();
self.error = Some(format!(
"Failed to disable web search: {}",
err
));
}
}
}
Some("status") | None => {
{
let config = self.controller.config();
let enabled = config.tools.web_search.enabled
&& config.privacy.enable_remote_search;
if enabled {
self.status =
"Web search is enabled.".to_string();
} else {
self.status =
"Web search is disabled.".to_string();
}
}
self.error = None;
}
_ => {
self.status = "Usage: :web <on|off|status>".to_string();
self.error =
Some("Usage: :web <on|off|status>".to_string());
}
}
self.command_palette.clear();
self.set_input_mode(InputMode::Normal);
return Ok(AppState::Running);
}
"privacy-enable" => {
if let Some(tool) = args.first() {
match self.controller.set_tool_enabled(tool, true).await {
Ok(_) => {
if let Err(err) =
config::save_config(&self.controller.config())
{
self.error = Some(format!(
"Enabled {tool}, but failed to save config: {err}"
));
} else {
self.status = format!("Enabled tool: {tool}");
self.error = None;
}
}
Err(e) => {
self.error =
Some(format!("Failed to enable tool: {}", e));
}
}
} else {
self.error =
Some("Usage: :privacy-enable <tool>".to_string());
}
}
"privacy-disable" => {
if let Some(tool) = args.first() {
match self.controller.set_tool_enabled(tool, false).await {
Ok(_) => {
if let Err(err) =
config::save_config(&self.controller.config())
{
self.error = Some(format!(
"Disabled {tool}, but failed to save config: {err}"
));
} else {
self.status = format!("Disabled tool: {tool}");
self.error = None;
}
}
Err(e) => {
self.error =
Some(format!("Failed to disable tool: {}", e));
}
}
} else {
self.error =
Some("Usage: :privacy-disable <tool>".to_string());
}
}
"privacy-clear" => {
match self.controller.clear_secure_data().await {
Ok(_) => {
self.status = "Cleared secure stored data".to_string();
self.error = None;
}
Err(e) => {
self.error =
Some(format!("Failed to clear secure data: {}", e));
}
}
}
"keymap" => {
if let Some(arg) = args.first() {
match KeymapProfile::from_str(arg) {
Some(profile) if profile.is_builtin() => {
self.switch_keymap_profile(profile).await?;
}
Some(_) => {
self.error = Some(
"Custom keymaps must be configured via keymap_path".to_string(),
);
}
None => {
self.error = Some(format!(
"Unknown keymap profile: {}",
arg
));
}
}
} else {
self.status = format!(
"Active keymap: {}",
self.current_keymap_profile().label()
);
self.error = None;
}
}
_ => {
self.error = Some(format!("Unknown command: {}", cmd_owned));
}
}
self.command_palette.clear();
self.set_input_mode(InputMode::Normal);
}
(KeyCode::Char(c), KeyModifiers::NONE)
| (KeyCode::Char(c), KeyModifiers::SHIFT) => {
self.command_palette.push_char(c);
self.status = format!(":{}", self.command_palette.buffer());
}
(KeyCode::Backspace, _) => {
self.command_palette.pop_char();
self.status = format!(":{}", self.command_palette.buffer());
}
_ => {}
},
InputMode::ProviderSelection => match key.code {
KeyCode::Esc => {
self.set_input_mode(InputMode::Normal);
}
KeyCode::Enter => {
if let Some(provider) =
self.available_providers.get(self.selected_provider_index)
{
self.selected_provider = provider.clone();
// Update model selection based on new provider (await async)
self.sync_selected_model_index().await; // Update model selection based on new provider
self.set_input_mode(InputMode::ModelSelection);
}
}
KeyCode::Up => {
if self.selected_provider_index > 0 {
self.selected_provider_index -= 1;
}
}
KeyCode::Down => {
if self.selected_provider_index + 1 < self.available_providers.len() {
self.selected_provider_index += 1;
}
}
_ => {}
},
InputMode::ModelSelection => match key.code {
KeyCode::Esc => {
if self.show_model_info {
self.set_model_info_visible(false);
self.status = "Closed model info panel".to_string();
} else {
self.set_input_mode(InputMode::Normal);
}
}
KeyCode::Enter => {
if let Some(item) = self.current_model_selector_item() {
match item.kind() {
ModelSelectorItemKind::Header {
provider, expanded, ..
} => {
if *expanded {
let provider_name = provider.clone();
self.collapse_provider(&provider_name);
self.status =
format!("Collapsed provider: {}", provider_name);
} else {
let provider_name = provider.clone();
self.expand_provider(&provider_name, true);
self.status =
format!("Expanded provider: {}", provider_name);
}
self.error = None;
}
ModelSelectorItemKind::Scope { provider, .. } => {
let provider_name = provider.clone();
self.expand_provider(&provider_name, false);
self.status =
format!("Expanded provider: {}", provider_name);
self.error = None;
}
ModelSelectorItemKind::Model { .. } => {
if let Some(model) = self.selected_model_info().cloned() {
if self.apply_model_selection(model).await.is_err() {
// apply_model_selection already sets status/error
}
} else {
self.error = Some(
"No model available for the selected provider"
.to_string(),
);
}
}
ModelSelectorItemKind::Empty { provider, .. } => {
let provider_name = provider.clone();
self.collapse_provider(&provider_name);
self.status =
format!("Collapsed provider: {}", provider_name);
self.error = None;
}
}
}
}
KeyCode::Char('q') => {
if self.show_model_info {
self.set_model_info_visible(false);
self.status = "Closed model info panel".to_string();
} else {
self.set_input_mode(InputMode::Normal);
}
}
KeyCode::Char('i') => {
if let Some(model) = self.selected_model_info() {
let model_id = model.id.clone();
if let Err(err) = self.ensure_model_details(&model_id, false).await
{
self.error =
Some(format!("Failed to load model info: {}", err));
}
}
}
KeyCode::Char('r') => {
if let Some(model) = self.selected_model_info() {
let model_id = model.id.clone();
if let Err(err) = self.ensure_model_details(&model_id, true).await {
self.error =
Some(format!("Failed to refresh model info: {}", err));
} else {
self.error = None;
}
}
}
KeyCode::Char('j') => {
if self.show_model_info && self.model_info_viewport_height > 0 {
self.model_info_panel
.scroll_down(self.model_info_viewport_height);
} else {
self.move_model_selection(1);
}
}
KeyCode::Char('k') => {
if self.show_model_info && self.model_info_viewport_height > 0 {
self.model_info_panel.scroll_up();
} else {
self.move_model_selection(-1);
}
}
KeyCode::Up => {
self.move_model_selection(-1);
}
KeyCode::Down => {
self.move_model_selection(1);
}
KeyCode::Left => {
if let Some(item) = self.current_model_selector_item() {
match item.kind() {
ModelSelectorItemKind::Header {
provider, expanded, ..
} => {
if *expanded {
let provider_name = provider.clone();
self.collapse_provider(&provider_name);
self.status =
format!("Collapsed provider: {}", provider_name);
self.error = None;
}
}
ModelSelectorItemKind::Scope { provider, .. } => {
let provider_name = provider.clone();
self.collapse_provider(&provider_name);
self.status =
format!("Collapsed provider: {}", provider_name);
self.error = None;
}
ModelSelectorItemKind::Model { provider, .. } => {
if let Some(idx) = self.index_of_header(provider) {
self.set_selected_model_item(idx);
}
}
ModelSelectorItemKind::Empty { provider, .. } => {
let provider_name = provider.clone();
self.collapse_provider(&provider_name);
self.status =
format!("Collapsed provider: {}", provider_name);
self.error = None;
}
}
}
}
KeyCode::Right => {
if let Some(item) = self.current_model_selector_item() {
match item.kind() {
ModelSelectorItemKind::Header {
provider, expanded, ..
} => {
if !expanded {
let provider_name = provider.clone();
self.expand_provider(&provider_name, true);
self.status =
format!("Expanded provider: {}", provider_name);
self.error = None;
}
}
ModelSelectorItemKind::Empty { provider, .. } => {
let provider_name = provider.clone();
self.expand_provider(&provider_name, false);
self.status =
format!("Expanded provider: {}", provider_name);
self.error = None;
}
_ => {}
}
}
}
KeyCode::Char(' ') => {
if let Some(item) = self.current_model_selector_item() {
if let ModelSelectorItemKind::Header {
provider, expanded, ..
} = item.kind()
{
if *expanded {
let provider_name = provider.clone();
self.collapse_provider(&provider_name);
self.status =
format!("Collapsed provider: {}", provider_name);
} else {
let provider_name = provider.clone();
self.expand_provider(&provider_name, true);
self.status =
format!("Expanded provider: {}", provider_name);
}
self.error = None;
}
}
}
KeyCode::Backspace => {
self.pop_model_search_char();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.clear_model_search_query();
}
KeyCode::Char(c)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
self.push_model_search_char(c);
}
_ => {}
},
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;
}
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
// Previous tab
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 => {
self.set_input_mode(InputMode::Normal);
}
KeyCode::Enter => {
// Load selected session
if let Some(session) =
self.saved_sessions.get(self.selected_session_index)
{
match self.controller.load_saved_session(session.id).await {
Ok(_) => {
self.status = format!(
"Loaded session: {}",
session.name.as_deref().unwrap_or("Unnamed"),
);
self.error = None;
self.update_thinking_from_last_message();
self.message_line_cache.clear();
self.chat_line_offset = 0;
}
Err(e) => {
self.error = Some(format!("Failed to load session: {}", e));
}
}
}
self.set_input_mode(InputMode::Normal);
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_session_index > 0 {
self.selected_session_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_session_index + 1 < self.saved_sessions.len() {
self.selected_session_index += 1;
}
}
KeyCode::Char('d') => {
// Delete selected session
if let Some(session) =
self.saved_sessions.get(self.selected_session_index)
{
match self.controller.delete_session(session.id).await {
Ok(_) => {
self.saved_sessions.remove(self.selected_session_index);
if self.selected_session_index >= self.saved_sessions.len()
&& !self.saved_sessions.is_empty()
{
self.selected_session_index =
self.saved_sessions.len() - 1;
}
self.status = "Session deleted".to_string();
}
Err(e) => {
self.error =
Some(format!("Failed to delete session: {}", e));
}
}
}
}
_ => {}
},
InputMode::ThemeBrowser => match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.set_input_mode(InputMode::Normal);
}
KeyCode::Enter => {
// Apply selected theme
if let Some(theme_name) = self
.available_themes
.get(self.selected_theme_index)
.cloned()
{
match self.switch_theme(&theme_name) {
Ok(_) => {
// Success message already set by switch_theme
}
Err(_) => {
// Error message already set by switch_theme
}
}
}
self.set_input_mode(InputMode::Normal);
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_theme_index > 0 {
self.selected_theme_index -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_theme_index + 1 < self.available_themes.len() {
self.selected_theme_index += 1;
}
}
KeyCode::Home | KeyCode::Char('g') => {
self.selected_theme_index = 0;
}
KeyCode::End | KeyCode::Char('G') => {
if !self.available_themes.is_empty() {
self.selected_theme_index = self.available_themes.len() - 1;
}
}
_ => {}
},
}
}
}
Ok(AppState::Running)
}
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<AppState> {
if self.has_pending_consent() {
return Ok(AppState::Running);
}
let region = self.region_for_position(mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::ScrollUp => {
if let Some(region) = region {
self.handle_mouse_scroll(region, -MOUSE_SCROLL_STEP);
}
}
MouseEventKind::ScrollDown => {
if let Some(region) = region {
self.handle_mouse_scroll(region, MOUSE_SCROLL_STEP);
}
}
MouseEventKind::Down(MouseButton::Left) => {
self.pending_key = None;
if let Some(region) = region {
self.handle_mouse_click(region, mouse.column, mouse.row);
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if matches!(region, Some(UiRegion::Input)) {
self.pending_key = None;
self.handle_mouse_click(UiRegion::Input, mouse.column, mouse.row);
}
}
_ => {}
}
Ok(AppState::Running)
}
fn handle_mouse_scroll(&mut self, region: UiRegion, amount: isize) {
if amount == 0 {
return;
}
match region {
UiRegion::FileTree => {
if self.focus_panel(FocusedPanel::Files) {
self.file_tree_mut().move_cursor(amount);
}
}
UiRegion::Thinking | UiRegion::Actions => {
if self.focus_panel(FocusedPanel::Thinking) {
let viewport = self.thinking_viewport_height.max(1);
self.thinking_scroll.on_user_scroll(amount, viewport);
}
}
UiRegion::Code => {
if self.focus_panel(FocusedPanel::Code) {
let viewport = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.on_user_scroll(amount, viewport);
}
}
}
UiRegion::ModelInfo => {
self.scroll_model_info(amount);
}
UiRegion::Input => {}
UiRegion::System
| UiRegion::Status
| UiRegion::Chat
| UiRegion::Content
| UiRegion::Frame
| UiRegion::Header => {
if self.focus_panel(FocusedPanel::Chat) {
self.auto_scroll
.on_user_scroll(amount, self.viewport_height);
self.update_new_message_alert_after_scroll();
}
}
}
}
fn handle_mouse_click(&mut self, region: UiRegion, column: u16, row: u16) {
match region {
UiRegion::FileTree => {
self.focus_panel(FocusedPanel::Files);
self.set_input_mode(InputMode::Normal);
}
UiRegion::Thinking | UiRegion::Actions => {
if self.focus_panel(FocusedPanel::Thinking) {
self.set_input_mode(InputMode::Normal);
}
}
UiRegion::Code => {
if self.focus_panel(FocusedPanel::Code) {
self.set_input_mode(InputMode::Normal);
}
}
UiRegion::Input => {
self.focus_panel(FocusedPanel::Input);
self.set_input_mode(InputMode::Editing);
if let Some(rect) = self.last_layout.input_panel {
if let Some((line, column)) = self.input_cursor_from_point(rect, column, row) {
let line = line.min(u16::MAX as usize) as u16;
let column = column.min(u16::MAX as usize) as u16;
self.textarea.move_cursor(CursorMove::Jump(line, column));
}
}
}
UiRegion::ModelInfo => {
self.set_input_mode(InputMode::Normal);
}
UiRegion::System
| UiRegion::Status
| UiRegion::Chat
| UiRegion::Content
| UiRegion::Frame
| UiRegion::Header => {
self.focus_panel(FocusedPanel::Chat);
self.set_input_mode(InputMode::Normal);
}
}
}
fn input_cursor_from_point(&self, rect: Rect, column: u16, row: u16) -> Option<(usize, usize)> {
let lines = self.textarea.lines();
if lines.is_empty() {
return Some((0, 0));
}
let inner_x = usize::from(column.saturating_sub(rect.x.saturating_add(1)));
let inner_y = usize::from(row.saturating_sub(rect.y.saturating_add(1)));
let max_line = lines.len().saturating_sub(1);
let line_index = inner_y.min(max_line);
let column_index = Self::grapheme_index_for_visual_offset(&lines[line_index], inner_x);
Some((line_index, column_index))
}
fn scroll_model_info(&mut self, amount: isize) {
if amount == 0 {
return;
}
let steps = amount.unsigned_abs();
let viewport = self.model_info_viewport_height.max(1);
if amount.is_positive() {
for _ in 0..steps {
self.model_info_panel.scroll_down(viewport);
}
} else {
for _ in 0..steps {
self.model_info_panel.scroll_up();
}
}
}
fn grapheme_index_for_visual_offset(line: &str, offset: usize) -> usize {
let mut width = 0usize;
for (idx, grapheme) in line.graphemes(true).enumerate() {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if width + grapheme_width > offset {
return idx;
}
width += grapheme_width;
}
line.graphemes(true).count()
}
/// Call this when processing scroll up/down keys
pub fn on_scroll(&mut self, delta: isize) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.on_user_scroll(delta, self.viewport_height);
self.update_new_message_alert_after_scroll();
}
FocusedPanel::Thinking => {
// Ensure we have a valid viewport height
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.on_user_scroll(delta, viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().move_cursor(delta);
}
FocusedPanel::Code => {
let viewport_height = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.on_user_scroll(delta, viewport_height);
}
}
FocusedPanel::Input => {
// Input panel doesn't scroll
}
}
}
/// Scroll down half page
pub fn scroll_half_page_down(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_half_page_down(self.viewport_height);
self.update_new_message_alert_after_scroll();
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_half_page_down(viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().page_down();
}
FocusedPanel::Code => {
let viewport_height = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.scroll_half_page_down(viewport_height);
}
}
FocusedPanel::Input => {}
}
}
/// Scroll up half page
pub fn scroll_half_page_up(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_half_page_up(self.viewport_height);
self.update_new_message_alert_after_scroll();
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_half_page_up(viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().page_up();
}
FocusedPanel::Code => {
let viewport_height = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.scroll_half_page_up(viewport_height);
}
}
FocusedPanel::Input => {}
}
}
/// Scroll down full page
pub fn scroll_full_page_down(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_full_page_down(self.viewport_height);
self.update_new_message_alert_after_scroll();
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_full_page_down(viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().page_down();
}
FocusedPanel::Code => {
let viewport_height = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.scroll_full_page_down(viewport_height);
}
}
FocusedPanel::Input => {}
}
}
/// Scroll up full page
pub fn scroll_full_page_up(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.scroll_full_page_up(self.viewport_height);
self.update_new_message_alert_after_scroll();
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.scroll_full_page_up(viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().page_up();
}
FocusedPanel::Code => {
let viewport_height = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.scroll_full_page_up(viewport_height);
}
}
FocusedPanel::Input => {}
}
}
/// Jump to top of focused panel
pub fn jump_to_top(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.jump_to_top();
self.chat_cursor = (0, 0);
}
FocusedPanel::Thinking => {
self.thinking_scroll.jump_to_top();
}
FocusedPanel::Files => {
self.file_tree_mut().jump_to_top();
}
FocusedPanel::Code => {
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.jump_to_top();
}
}
FocusedPanel::Input => {}
}
}
/// Jump to bottom of focused panel
pub fn jump_to_bottom(&mut self) {
match self.focused_panel {
FocusedPanel::Chat => {
self.auto_scroll.jump_to_bottom(self.viewport_height);
self.update_new_message_alert_after_scroll();
let rendered = self.get_rendered_lines();
if rendered.is_empty() {
self.chat_cursor = (0, 0);
} else {
let last_index = rendered.len().saturating_sub(1);
let last_col = rendered
.last()
.map(|line| line.chars().count())
.unwrap_or(0);
self.chat_cursor = (last_index, last_col);
}
}
FocusedPanel::Thinking => {
let viewport_height = self.thinking_viewport_height.max(1);
self.thinking_scroll.jump_to_bottom(viewport_height);
}
FocusedPanel::Files => {
self.file_tree_mut().jump_to_bottom();
}
FocusedPanel::Code => {
let viewport_height = self.code_view_viewport_height().max(1);
if let Some(scroll) = self.code_view_scroll_mut() {
scroll.jump_to_bottom(viewport_height);
}
}
FocusedPanel::Input => {}
}
}
pub async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
match event {
SessionEvent::StreamChunk {
message_id,
response,
} => {
self.controller.apply_stream_chunk(message_id, &response)?;
self.invalidate_message_cache(&message_id);
// Update thinking content in real-time during streaming
self.update_thinking_from_last_message();
self.notify_new_activity();
// Auto-scroll will handle this in the render loop
if response.is_final {
let recorded_snapshot = match response.usage.as_ref() {
Some(usage) => {
self.update_context_usage(usage);
self.controller.record_usage_sample(usage).await
}
None => None,
};
if let Some(snapshot) = recorded_snapshot {
self.usage_snapshot = Some(snapshot.clone());
self.update_usage_toasts(&snapshot);
} else {
self.refresh_usage_summary().await?;
}
self.streaming.remove(&message_id);
self.stream_tasks.remove(&message_id);
self.stop_loading_animation();
// Check if the completed stream has tool calls that need execution
if let Some(tool_calls) = self.controller.check_streaming_tool_calls(message_id)
{
// Trigger tool execution via event
let sender = self.session_tx.clone();
let _ = sender.send(SessionEvent::ToolExecutionNeeded {
message_id,
tool_calls,
});
} else {
self.status = "Ready".to_string();
}
}
}
SessionEvent::StreamError {
message_id,
message,
} => {
self.stop_loading_animation();
if let Some(id) = message_id {
self.streaming.remove(&id);
self.stream_tasks.remove(&id);
self.invalidate_message_cache(&id);
} else {
self.streaming.clear();
self.stream_tasks.clear();
self.message_line_cache.clear();
}
self.error = Some(message);
}
SessionEvent::ToolExecutionNeeded {
message_id,
tool_calls,
} => {
// Store tool execution for async processing on next event loop iteration
self.pending_tool_execution = Some((message_id, tool_calls));
}
SessionEvent::AgentUpdate { content } => {
// Update agent actions panel with latest ReAct iteration
self.set_agent_actions(content);
}
SessionEvent::AgentCompleted { answer } => {
// Agent finished, add final answer to conversation
self.controller
.conversation_mut()
.push_assistant_message(answer);
self.notify_new_activity();
self.agent_running = false;
self.agent_mode = false;
self.agent_actions = None;
self.status = "Agent completed successfully".to_string();
self.stop_loading_animation();
}
SessionEvent::AgentFailed { error } => {
// Agent failed, show error
self.error = Some(format!("Agent failed: {}", error));
self.agent_running = false;
self.agent_actions = None;
self.stop_loading_animation();
}
SessionEvent::OAuthPoll {
server,
authorization,
} => {
match self
.controller
.poll_oauth_device_flow(&server, &authorization)
.await
{
Ok(DevicePollState::Pending { retry_in }) => {
self.oauth_flows
.insert(server.clone(), authorization.clone());
let server_name = server.clone();
self.schedule_oauth_poll(server, authorization, retry_in);
self.status = format!("Waiting for OAuth approval for {server_name}...");
}
Ok(DevicePollState::Complete(_token)) => {
self.oauth_flows.remove(&server);
self.push_toast(
ToastLevel::Success,
format!("OAuth authorization complete for {server}."),
);
self.status = format!("OAuth authorization complete for {server}.");
if let Err(err) = self.refresh_resource_catalog().await {
self.push_toast(
ToastLevel::Error,
format!("Failed to refresh MCP resources: {err}"),
);
}
if let Err(err) = self.refresh_mcp_slash_commands().await {
self.push_toast(
ToastLevel::Error,
format!("Failed to refresh MCP slash commands: {err}"),
);
}
}
Err(err) => {
self.oauth_flows.remove(&server);
self.error = Some(format!("OAuth flow for '{server}' failed: {err}"));
self.push_toast(
ToastLevel::Error,
format!("OAuth failure for {server}: {err}"),
);
}
}
}
}
Ok(())
}
fn reset_status(&mut self) {
self.status = "Normal mode • Press F1 for help".to_string();
self.error = None;
}
async fn refresh_usage_summary(&mut self) -> Result<()> {
if let Some(snapshot) = self.controller.current_usage_snapshot().await {
self.usage_snapshot = Some(snapshot.clone());
self.update_usage_toasts(&snapshot);
} else {
self.usage_snapshot = None;
}
Ok(())
}
fn update_usage_toasts(&mut self, snapshot: &UsageSnapshot) {
for window in [UsageWindow::Hour, UsageWindow::Week] {
let key = (snapshot.provider.clone(), window);
let metrics = snapshot.window(window);
let quota = match metrics.quota_tokens {
Some(value) if value > 0 => value,
_ => {
self.usage_thresholds.remove(&key);
continue;
}
};
let previous = self
.usage_thresholds
.get(&key)
.copied()
.unwrap_or(UsageBand::Normal);
let current = metrics.band();
if current > previous {
if let Some(percent_ratio) = metrics.percent_of_quota() {
let percent_value = percent_ratio * 100.0;
let percent_text = Self::format_percent_value(percent_value.min(999.9));
let quota_text = format_token_short(quota);
let used_text = format_token_short(metrics.total_tokens);
let provider_display = Self::provider_display_name(&snapshot.provider);
let window_label = Self::usage_window_label(window);
let message = format!(
"{} {} usage at {}% ({}/{})",
provider_display, window_label, percent_text, used_text, quota_text
);
let level = if current == UsageBand::Critical {
ToastLevel::Error
} else {
ToastLevel::Warning
};
self.push_toast(level, message);
}
} else if current == UsageBand::Normal && previous != UsageBand::Normal {
self.usage_thresholds.insert(key.clone(), UsageBand::Normal);
}
self.usage_thresholds.insert(key, current);
}
}
async fn install_tool_preset(&mut self, preset: &str, prune: bool) -> Result<String> {
let tier = PresetTier::from_str(preset).map_err(|err| anyhow!(err.to_string()))?;
let report = {
let mut cfg = self.controller.config_mut();
presets::apply_preset(&mut cfg, tier, prune)?
};
config::save_config(&self.controller.config())
.context("failed to persist configuration after installing preset")?;
self.controller.reload_mcp_clients().await?;
Ok(format!(
"Installed '{}' preset (added {}, updated {}, removed {}).",
tier.as_str(),
report.added.len(),
report.updated.len(),
report.removed.len()
))
}
async fn audit_tool_preset(&mut self, preset: Option<&str>) -> Result<String> {
let tier = if let Some(name) = preset {
PresetTier::from_str(name).map_err(|err| anyhow!(err.to_string()))?
} else {
PresetTier::Full
};
let audit = {
let cfg = self.controller.config();
presets::audit_preset(&cfg, tier)
};
let mut sections = Vec::new();
if !audit.missing.is_empty() {
let names = audit
.missing
.iter()
.map(|connector| connector.name)
.collect::<Vec<_>>()
.join(", ");
sections.push(format!("missing {names}"));
}
if !audit.mismatched.is_empty() {
let names = audit
.mismatched
.iter()
.map(|(expected, _)| expected.name)
.collect::<Vec<_>>()
.join(", ");
sections.push(format!("mismatched {names}"));
}
if !audit.extra.is_empty() {
let names = audit
.extra
.iter()
.map(|server| server.name.as_str())
.collect::<Vec<_>>()
.join(", ");
sections.push(format!("extra {names}"));
}
if sections.is_empty() {
Ok(format!("Preset '{}' audit passed.", tier.as_str()))
} else {
Ok(format!(
"Preset '{}' audit findings: {}",
tier.as_str(),
sections.join("; ")
))
}
}
async fn show_usage_limits(&mut self) -> Result<()> {
let snapshots = self.controller.usage_overview().await;
if snapshots.is_empty() {
let message = "Usage: no data recorded yet.".to_string();
self.status = message.clone();
self.error = None;
self.push_toast(ToastLevel::Info, message);
return Ok(());
}
let mut parts = Vec::new();
let mut current_snapshot: Option<UsageSnapshot> = None;
for snapshot in snapshots.iter() {
if snapshot.provider == self.current_provider {
current_snapshot = Some(snapshot.clone());
}
self.update_usage_toasts(snapshot);
let provider_display = Self::provider_display_name(&snapshot.provider);
let hour = Self::summarize_usage_window("hour", snapshot.window(UsageWindow::Hour));
let week = Self::summarize_usage_window("week", snapshot.window(UsageWindow::Week));
parts.push(format!("{provider_display}: {hour}; {week}"));
}
if let Some(snapshot) = current_snapshot {
self.usage_snapshot = Some(snapshot);
}
let message = parts.join(" | ");
self.status = format!("Usage • {message}");
self.error = None;
self.push_toast(ToastLevel::Info, message.clone());
Ok(())
}
fn summarize_usage_window(label: &str, metrics: &WindowMetrics) -> String {
let used = format_token_short(metrics.total_tokens);
if let Some(quota) = metrics.quota_tokens {
if quota == 0 {
return format!("{label} {used} tokens");
}
let quota_text = format_token_short(quota);
let percent = metrics
.percent_of_quota()
.map(|ratio| ratio * 100.0)
.unwrap_or(0.0);
let percent_text = Self::format_percent_value(percent.min(999.9));
format!("{label} {used}/{quota_text} ({percent_text}%)")
} else {
format!("{label} {used} tokens")
}
}
fn usage_window_label(window: UsageWindow) -> &'static str {
match window {
UsageWindow::Hour => "hourly",
UsageWindow::Week => "weekly",
}
}
fn format_percent_value(percent: f64) -> String {
if percent >= 10.0 || percent == 0.0 {
format!("{percent:.0}")
} else {
format!("{percent:.1}")
}
}
async fn collect_models_from_all_providers(
&self,
) -> (
Vec<ModelInfo>,
Vec<String>,
HashMap<String, ProviderScopeStatus>,
) {
let provider_entries = {
let config = self.controller.config();
let entries: Vec<(String, ProviderConfig)> = config
.providers
.iter()
.map(|(name, cfg)| (name.clone(), cfg.clone()))
.collect();
entries
};
let mut models = Vec::new();
let mut errors = Vec::new();
let mut scope_status_map: HashMap<String, ProviderScopeStatus> = HashMap::new();
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.ok();
let server_binary = workspace_root.as_ref().and_then(|root| {
let candidates = [
"target/debug/owlen-mcp-llm-server",
"target/release/owlen-mcp-llm-server",
];
candidates
.iter()
.map(|rel| root.join(rel))
.find(|p| p.exists())
.map(|p| p.to_string_lossy().into_owned())
});
for (name, provider_cfg) in provider_entries {
let provider_type = provider_cfg.provider_type.to_ascii_lowercase();
if provider_type != "ollama" && provider_type != "ollama_cloud" {
continue;
}
if !provider_cfg.enabled {
continue;
}
let canonical_name = Self::canonical_provider_id(&name);
// All providers communicate via MCP LLM server (Phase 10).
// Select provider by name via OWLEN_PROVIDER so per-provider settings apply.
let mut env_vars = HashMap::new();
env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone());
let client_result = if let Some(binary_path) = server_binary.as_ref() {
use owlen_core::config::McpServerConfig;
let config = McpServerConfig {
name: format!("provider::{canonical_name}"),
command: binary_path.clone(),
args: Vec::new(),
transport: "stdio".to_string(),
env: env_vars.clone(),
oauth: None,
};
RemoteMcpClient::new_with_config(&config)
} else {
// Fallback to legacy discovery: temporarily set env vars while spawning.
Self::with_temp_env_vars(&env_vars, RemoteMcpClient::new)
};
match client_result {
Ok(client) => {
let client: Arc<dyn LlmClient> = Arc::new(client);
match client.list_models().await {
Ok(mut provider_models) => {
for model in &mut provider_models {
model.provider = canonical_name.clone();
}
let statuses = Self::extract_scope_status(&provider_models);
Self::accumulate_scope_errors(&mut errors, &canonical_name, &statuses);
scope_status_map.insert(canonical_name.clone(), statuses);
models.extend(provider_models);
}
Err(err) => {
scope_status_map
.insert(canonical_name.clone(), ProviderScopeStatus::default());
errors.push(format!("{}: {}", name, err))
}
}
}
Err(err) => {
scope_status_map.insert(canonical_name.clone(), ProviderScopeStatus::default());
errors.push(format!("{}: {}", canonical_name, err));
}
}
}
// Sort models alphabetically by name for a predictable UI order
models.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
(models, errors, scope_status_map)
}
fn scope_from_keyword(value: &str) -> ModelScope {
match value {
"local" => ModelScope::Local,
"cloud" => ModelScope::Cloud,
other => ModelScope::Other(other.to_string()),
}
}
fn extract_scope_status(models: &[ModelInfo]) -> ProviderScopeStatus {
let mut statuses: ProviderScopeStatus = BTreeMap::new();
for model in models {
for capability in &model.capabilities {
if let Some(rest) = capability.strip_prefix("scope-status:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let state_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let scope = Self::scope_from_keyword(&scope_key);
let state = match state_key.as_str() {
"available" => ModelAvailabilityState::Available,
"unavailable" => ModelAvailabilityState::Unavailable,
_ => ModelAvailabilityState::Unknown,
};
let entry = statuses.entry(scope).or_default();
if state > entry.state || entry.state == ModelAvailabilityState::Unknown {
entry.state = state;
}
} else if let Some(rest) = capability.strip_prefix("scope-status-message:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let message = parts.collect::<Vec<_>>().join(":");
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
if entry.message.is_none() && !message.trim().is_empty() {
entry.message = Some(message.trim().to_string());
}
} else if let Some(rest) = capability.strip_prefix("scope-status-age:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let value = parts.next().unwrap_or_default();
if let Ok(age) = value.parse::<u64>() {
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
entry.last_checked_secs = Some(age);
}
} else if let Some(rest) = capability.strip_prefix("scope-status-success-age:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let value = parts.next().unwrap_or_default();
if let Ok(age) = value.parse::<u64>() {
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
entry.last_success_secs = Some(age);
}
} else if let Some(rest) = capability.strip_prefix("scope-status-stale:") {
let mut parts = rest.split(':');
let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase();
let value = parts.next().unwrap_or_default();
let scope = Self::scope_from_keyword(&scope_key);
let entry = statuses.entry(scope).or_default();
entry.is_stale = matches!(value, "1" | "true" | "True" | "TRUE");
}
}
}
statuses
}
fn accumulate_scope_errors(
errors: &mut Vec<String>,
provider: &str,
statuses: &ProviderScopeStatus,
) {
for (scope, entry) in statuses {
if entry.state == ModelAvailabilityState::Unavailable {
let scope_name = Self::scope_display_name(scope);
if let Some(summary) = Self::scope_status_summary(entry) {
errors.push(format!("{provider}: {scope_name} {summary}"));
} else {
errors.push(format!("{provider}: {scope_name} unavailable"));
}
} else if entry.state == ModelAvailabilityState::Available && entry.is_stale {
let scope_name = Self::scope_display_name(scope);
let summary = Self::scope_status_summary(entry)
.unwrap_or_else(|| "using cached results".to_string());
errors.push(format!("{provider}: {scope_name} degraded ({summary})"));
}
}
}
pub(crate) fn model_scope_from_capabilities(model: &ModelInfo) -> ModelScope {
for capability in &model.capabilities {
if let Some(tag) = capability.strip_prefix("scope:") {
return match tag {
"local" => ModelScope::Local,
"cloud" => ModelScope::Cloud,
other => ModelScope::Other(other.to_string()),
};
}
}
ModelScope::Other("unknown".to_string())
}
pub(crate) fn scope_icon(scope: &ModelScope) -> &'static str {
match scope {
ModelScope::Local => "🖥️",
ModelScope::Cloud => "",
ModelScope::Other(_) => "",
}
}
pub(crate) fn scope_display_name(scope: &ModelScope) -> String {
match scope {
ModelScope::Local => "Local".to_string(),
ModelScope::Cloud => "Cloud".to_string(),
ModelScope::Other(other) => capitalize_first(other),
}
}
fn format_duration_short(seconds: u64) -> String {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
if seconds < MINUTE {
format!("{seconds}s")
} else if seconds < HOUR {
format!("{}m", seconds / MINUTE)
} else if seconds < DAY {
let hours = seconds / HOUR;
let minutes = (seconds % HOUR) / MINUTE;
if minutes == 0 {
format!("{hours}h")
} else {
format!("{hours}h{minutes}m")
}
} else {
format!("{}d", seconds / DAY)
}
}
fn scope_status_summary(status: &ScopeStatusEntry) -> Option<String> {
let mut segments: Vec<String> = Vec::new();
if let Some(message) = status.message.as_ref() {
if !message.is_empty() {
segments.push(message.clone());
}
} else if status.state == ModelAvailabilityState::Unavailable {
segments.push("Unavailable".to_string());
} else if status.state == ModelAvailabilityState::Available && status.is_stale {
segments.push("Using cached results".to_string());
}
if let Some(age) = status.last_checked_secs {
segments.push(format!("checked {} ago", Self::format_duration_short(age)));
}
if let Some(success_age) = status.last_success_secs {
if status.state == ModelAvailabilityState::Unavailable {
segments.push(format!(
"last ok {} ago",
Self::format_duration_short(success_age)
));
} else if status.state == ModelAvailabilityState::Available && status.is_stale {
segments.push(format!(
"last refresh {} ago",
Self::format_duration_short(success_age)
));
}
}
if segments.is_empty() {
None
} else {
Some(segments.join(" · "))
}
}
fn scope_header_label(
scope: &ModelScope,
status: &ScopeStatusEntry,
filter: FilterMode,
) -> String {
let icon = Self::scope_icon(scope);
let scope_name = Self::scope_display_name(scope);
let mut label = format!("{icon} {scope_name}");
match status.state {
ModelAvailabilityState::Available => {
label.push_str(" · ✓");
if status.is_stale {
label.push_str(" · ⚠");
}
}
ModelAvailabilityState::Unavailable => {
if status.last_success_secs.is_some() {
label.push_str(" · ⚠");
} else {
label.push_str(" · ✗");
}
}
ModelAvailabilityState::Unknown => label.push_str(" · ⚙"),
}
if let Some(age) = status.last_checked_secs {
label.push_str(&format!(" · {}", Self::format_duration_short(age)));
}
if matches!(filter, FilterMode::Available) {
label.push_str(" · available only");
}
label
}
fn deduplicate_models_for_scope<'a>(
entries: Vec<(usize, &'a ModelInfo)>,
provider_lower: &str,
scope: &ModelScope,
) -> Vec<(usize, &'a ModelInfo)> {
let mut best_by_canonical: HashMap<String, (i8, (usize, &'a ModelInfo))> = HashMap::new();
for (idx, model) in entries {
let canonical = model.id.to_string();
let is_cloud_id = model.id.ends_with("-cloud");
let priority = if matches!(
provider_lower,
"ollama" | "ollama_local" | "ollama-cloud" | "ollama_cloud"
) {
match scope {
ModelScope::Local => {
if is_cloud_id {
1
} else {
2
}
}
ModelScope::Cloud => {
if is_cloud_id {
2
} else {
1
}
}
ModelScope::Other(_) => 1,
}
} else {
1
};
best_by_canonical
.entry(canonical)
.and_modify(|entry| {
if priority > entry.0 || (priority == entry.0 && model.id < entry.1.1.id) {
*entry = (priority, (idx, model));
}
})
.or_insert((priority, (idx, model)));
}
let mut matches: Vec<(usize, &'a ModelInfo)> = best_by_canonical
.into_values()
.map(|entry| entry.1)
.collect();
matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id));
matches
}
fn recompute_available_providers(&mut self) {
let mut providers: BTreeSet<String> = self
.controller
.config()
.providers
.iter()
.filter(|(_, cfg)| cfg.enabled)
.map(|(name, _)| Self::canonical_provider_id(name))
.collect();
providers.extend(self.models.iter().map(|m| m.provider.clone()));
if providers.is_empty() {
providers.insert(self.selected_provider.clone());
}
if providers.is_empty() {
providers.insert("ollama_local".to_string());
}
self.available_providers = providers.into_iter().collect();
}
fn canonical_provider_id(provider: &str) -> String {
let normalized = provider.trim().to_ascii_lowercase();
if normalized.is_empty() {
return "ollama_local".to_string();
}
match normalized.replace('-', "_").as_str() {
"ollama" => "ollama_local".to_string(),
"ollama_local" => "ollama_local".to_string(),
"ollama_cloud" => "ollama_cloud".to_string(),
other => other.to_string(),
}
}
fn with_temp_env_vars<T, F>(env_vars: &HashMap<String, String>, action: F) -> T
where
F: FnOnce() -> T,
{
let backups: Vec<(String, Option<String>)> = env_vars
.keys()
.map(|key| (key.clone(), std::env::var(key).ok()))
.collect();
for (key, value) in env_vars {
// Safety: environment mutations are scoped to this synchronous call and restored
// immediately afterwards, so no other threads observe inconsistent state.
unsafe {
std::env::set_var(key, value);
}
}
let result = action();
for (key, original) in backups {
unsafe {
if let Some(value) = original {
std::env::set_var(&key, value);
} else {
std::env::remove_var(&key);
}
}
}
result
}
fn rebuild_annotated_models(&mut self) {
let mut annotated = Vec::with_capacity(self.models.len());
for model in &self.models {
let provider_id = model.provider.clone();
let scope = Self::model_scope_from_capabilities(model);
let scope_state = self.provider_scope_state(provider_id.as_str(), &scope);
let provider_status = Self::provider_status_from_state(scope_state);
let provider_type = Self::infer_provider_type(&provider_id, &scope);
let mut provider_metadata = ProviderMetadata::new(
provider_id.clone(),
Self::provider_display_name(&provider_id),
provider_type,
matches!(provider_type, ProviderType::Cloud),
);
provider_metadata.metadata.insert(
"scope".to_string(),
Value::String(Self::scope_display_name(&scope)),
);
provider_metadata.metadata.insert(
"provider_tag".to_string(),
Value::String(Self::provider_tag(&provider_id)),
);
let mut model_metadata = HashMap::new();
model_metadata.insert(
"display_name".to_string(),
Value::String(Self::display_name_for_model(model)),
);
if let Some(ctx) = model.context_window {
model_metadata.insert("context_window".to_string(), Value::from(ctx));
}
model_metadata.insert(
"provider_tag".to_string(),
Value::String(Self::provider_tag(&provider_id)),
);
let provider_model = ProviderModelInfo {
name: model.id.clone(),
size_bytes: None,
capabilities: model.capabilities.clone(),
description: model.description.clone(),
provider: provider_metadata,
metadata: model_metadata,
};
annotated.push(AnnotatedModelInfo {
provider_id,
provider_status,
model: provider_model,
});
}
self.annotated_models = annotated;
}
fn rebuild_model_selector_items(&mut self) {
let mut items = Vec::new();
self.model_search_hits.clear();
self.provider_search_hits.clear();
self.visible_model_count = 0;
if self.available_providers.is_empty() {
items.push(ModelSelectorItem::header(
"ollama_local",
false,
ProviderStatus::RequiresSetup,
ProviderType::Local,
));
self.model_selector_items = items;
return;
}
let search_query = self.model_search_query.trim().to_string();
let search_active = !search_query.is_empty();
let force_expand = search_active;
let expanded = self.expanded_provider.clone();
for provider in &self.available_providers {
let provider_lower = provider.to_ascii_lowercase();
let provider_display = Self::provider_display_name(provider);
let provider_status = self.provider_overall_status(provider);
let provider_type = self.provider_type_for(provider);
let provider_highlight = if search_active {
search_candidate(provider_display.as_str(), &search_query).map(|(_, mask)| mask)
} else {
None
};
if let Some(mask) = provider_highlight.clone() {
self.provider_search_hits.insert(provider.clone(), mask);
}
let is_expanded =
force_expand || expanded.as_ref().map(|p| p == provider).unwrap_or(false);
let mut provider_block = Vec::new();
provider_block.push(ModelSelectorItem::header(
provider.clone(),
is_expanded,
provider_status,
provider_type,
));
if !is_expanded {
items.extend(provider_block);
continue;
}
let status_map = self.provider_scope_status.get(provider);
let mut scoped: BTreeMap<ModelScope, Vec<(usize, &ModelInfo)>> = BTreeMap::new();
for (idx, model) in self.models.iter().enumerate() {
if &model.provider == provider {
let scope = Self::model_scope_from_capabilities(model);
scoped.entry(scope).or_default().push((idx, model));
}
}
let mut scopes_to_render: BTreeSet<ModelScope> = BTreeSet::new();
scopes_to_render.extend(scoped.keys().cloned());
if let Some(statuses) = status_map {
scopes_to_render.extend(statuses.keys().cloned());
}
let mut rendered_scope = false;
let mut rendered_body = false;
let mut provider_has_models = false;
for scope in scopes_to_render {
if !self.filter_allows_scope(&scope) {
continue;
}
let entries = scoped.get(&scope).cloned().unwrap_or_default();
let deduped = Self::deduplicate_models_for_scope(entries, &provider_lower, &scope);
let status_entry = status_map
.and_then(|map| map.get(&scope))
.cloned()
.unwrap_or_default();
let mut filtered: Vec<(usize, &ModelInfo)> = Vec::new();
for (idx, model) in deduped {
let search_info = if search_active {
self.evaluate_model_search(provider, model, &search_query)
} else {
None
};
if let Some(info) = search_info {
self.model_search_hits.insert(idx, info);
filtered.push((idx, model));
} else if !search_active {
filtered.push((idx, model));
}
}
if search_active && filtered.is_empty() {
continue;
}
rendered_scope = true;
let label = Self::scope_header_label(&scope, &status_entry, self.model_filter_mode);
provider_block.push(ModelSelectorItem::scope(
provider.clone(),
label,
scope.clone(),
status_entry.state,
));
if status_entry.state != ModelAvailabilityState::Available
|| status_entry.is_stale
|| status_entry.message.is_some()
{
if let Some(summary) = Self::scope_status_summary(&status_entry) {
provider_block.push(ModelSelectorItem::empty(
provider.clone(),
Some(summary),
Some(status_entry.state),
));
rendered_body = true;
}
}
let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry);
if filtered.is_empty() {
if !scope_allowed {
if let Some(msg) = self.scope_filter_message(&scope, &status_entry) {
provider_block.push(ModelSelectorItem::empty(
provider.clone(),
Some(msg),
Some(status_entry.state),
));
rendered_body = true;
}
} else if !search_active {
let message = match status_entry.state {
ModelAvailabilityState::Unavailable => {
format!("{} unavailable", Self::scope_display_name(&scope))
}
ModelAvailabilityState::Available => {
format!("No {} models found", Self::scope_display_name(&scope))
}
ModelAvailabilityState::Unknown => "No models configured".to_string(),
};
provider_block.push(ModelSelectorItem::empty(
provider.clone(),
Some(message),
Some(status_entry.state),
));
rendered_body = true;
}
continue;
}
if !scope_allowed {
if let Some(msg) = self.scope_filter_message(&scope, &status_entry) {
provider_block.push(ModelSelectorItem::empty(
provider.clone(),
Some(msg),
Some(status_entry.state),
));
rendered_body = true;
}
continue;
}
rendered_body = true;
provider_has_models = true;
for (idx, _) in filtered {
provider_block.push(ModelSelectorItem::model(provider.clone(), idx));
}
}
if !provider_has_models && search_active && provider_highlight.is_some() {
provider_block.push(ModelSelectorItem::empty(
provider.clone(),
Some(format!(
"Provider matches '{}' but no models found",
search_query
)),
None,
));
rendered_body = true;
}
if !rendered_scope && !rendered_body {
if !search_active {
provider_block.push(ModelSelectorItem::empty(provider.clone(), None, None));
} else if provider_highlight.is_some() {
provider_block.push(ModelSelectorItem::empty(
provider.clone(),
Some(format!("No models matching '{}'", search_query)),
None,
));
} else {
continue;
}
}
items.extend(provider_block);
}
if items.is_empty() {
items.push(ModelSelectorItem::empty(
"providers",
Some(if search_active {
format!("No models matching '{}'", search_query)
} else {
"No providers configured".to_string()
}),
None,
));
}
self.visible_model_count = items
.iter()
.filter(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. }))
.count();
self.model_selector_items = items;
self.ensure_valid_model_selection();
if search_active {
let current_is_model = self
.current_model_selector_item()
.map(|item| matches!(item.kind(), ModelSelectorItemKind::Model { .. }))
.unwrap_or(false);
if !current_is_model {
if let Some((idx, _)) = self
.model_selector_items
.iter()
.enumerate()
.find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Model { .. }))
{
self.set_selected_model_item(idx);
}
}
}
}
fn evaluate_model_search(
&self,
provider: &str,
model: &ModelInfo,
query: &str,
) -> Option<ModelSearchInfo> {
let mut info = ModelSearchInfo::default();
let mut best: Option<(usize, usize)> = None;
let mut consider = |candidate: Option<&str>, target: &mut Option<HighlightMask>| {
if let Some(text) = candidate {
if let Some((score, mask)) = search_candidate(text, query) {
let replace = best.is_none_or(|current| score < current);
if replace {
best = Some(score);
}
*target = Some(mask);
}
}
};
let display_name = Self::display_name_for_model(model);
consider(Some(display_name.as_str()), &mut info.name);
consider(Some(model.id.as_str()), &mut info.id);
let provider_display = Self::provider_display_name(provider);
consider(Some(provider_display.as_str()), &mut info.provider);
if let Some(desc) = model.description.as_deref() {
consider(Some(desc), &mut info.description);
}
if let Some(score) = best {
info.score = score;
Some(info)
} else {
None
}
}
fn provider_scope_state(&self, provider: &str, scope: &ModelScope) -> ModelAvailabilityState {
self.provider_scope_status
.get(provider)
.and_then(|map| map.get(scope))
.map(|entry| entry.state)
.unwrap_or(ModelAvailabilityState::Unknown)
}
fn provider_overall_status(&self, provider: &str) -> ProviderStatus {
if let Some(status_map) = self.provider_scope_status.get(provider) {
let mut saw_unknown = false;
for entry in status_map.values() {
match entry.state {
ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable,
ModelAvailabilityState::Unknown => saw_unknown = true,
ModelAvailabilityState::Available => {
if entry.is_stale {
saw_unknown = true;
}
}
}
}
if saw_unknown {
ProviderStatus::RequiresSetup
} else {
ProviderStatus::Available
}
} else {
self.annotated_models
.iter()
.find(|m| m.provider_id == provider)
.map(|m| m.provider_status)
.unwrap_or(ProviderStatus::RequiresSetup)
}
}
fn provider_type_for(&self, provider: &str) -> ProviderType {
self.annotated_models
.iter()
.find(|m| m.provider_id == provider)
.map(|m| m.model.provider.provider_type)
.unwrap_or_else(|| {
if provider.to_ascii_lowercase().contains("cloud") {
ProviderType::Cloud
} else {
ProviderType::Local
}
})
}
fn filter_allows_scope(&self, scope: &ModelScope) -> bool {
match self.model_filter_mode {
FilterMode::All => true,
FilterMode::LocalOnly => matches!(scope, ModelScope::Local),
FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud),
FilterMode::Available => true,
}
}
fn filter_scope_allows_models(&self, scope: &ModelScope, status: &ScopeStatusEntry) -> bool {
match self.model_filter_mode {
FilterMode::Available => {
status.state == ModelAvailabilityState::Available && !status.is_stale
}
FilterMode::LocalOnly => matches!(scope, ModelScope::Local),
FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud),
FilterMode::All => true,
}
}
fn scope_filter_message(
&self,
scope: &ModelScope,
status: &ScopeStatusEntry,
) -> Option<String> {
match self.model_filter_mode {
FilterMode::Available => {
if status.state == ModelAvailabilityState::Available && !status.is_stale {
return None;
}
Self::scope_status_summary(status).or_else(|| match status.state {
ModelAvailabilityState::Unavailable => {
Some(format!("{} unavailable", Self::scope_display_name(scope)))
}
ModelAvailabilityState::Unknown => Some(format!(
"{} setup required",
Self::scope_display_name(scope)
)),
ModelAvailabilityState::Available => Some(format!(
"{} cached results",
Self::scope_display_name(scope)
)),
})
}
FilterMode::LocalOnly | FilterMode::CloudOnly => {
if status.state == ModelAvailabilityState::Unavailable {
Self::scope_status_summary(status).or_else(|| {
Some(format!("{} unavailable", Self::scope_display_name(scope)))
})
} else {
None
}
}
FilterMode::All => None,
}
}
pub(crate) fn provider_display_name(provider: &str) -> String {
if provider.trim().is_empty() {
return "Provider".to_string();
}
let normalized = provider.replace(['_', '-'], " ");
capitalize_first(normalized.as_str())
}
fn provider_tag(provider: &str) -> String {
match provider.trim().to_ascii_lowercase().as_str() {
"ollama" | "ollama_local" => "ollama".to_string(),
"ollama-cloud" | "ollama_cloud" => "ollama-cloud".to_string(),
other => other.to_string(),
}
}
fn infer_provider_type(provider: &str, scope: &ModelScope) -> ProviderType {
match scope {
ModelScope::Local => ProviderType::Local,
ModelScope::Cloud => ProviderType::Cloud,
ModelScope::Other(_) => {
if provider.to_ascii_lowercase().contains("cloud") {
ProviderType::Cloud
} else {
ProviderType::Local
}
}
}
}
fn provider_status_from_state(state: ModelAvailabilityState) -> ProviderStatus {
match state {
ModelAvailabilityState::Available => ProviderStatus::Available,
ModelAvailabilityState::Unavailable => ProviderStatus::Unavailable,
ModelAvailabilityState::Unknown => ProviderStatus::RequiresSetup,
}
}
fn first_model_item_index(&self) -> Option<usize> {
self.model_selector_items
.iter()
.enumerate()
.find(|(_, item)| item.is_model())
.map(|(idx, _)| idx)
}
fn index_of_header(&self, provider: &str) -> Option<usize> {
self.model_selector_items
.iter()
.enumerate()
.find(|(_, item)| item.provider_if_header() == Some(provider))
.map(|(idx, _)| idx)
}
fn index_of_first_model_for_provider(&self, provider: &str) -> Option<usize> {
self.model_selector_items
.iter()
.enumerate()
.find(|(_, item)| {
matches!(
item.kind(),
ModelSelectorItemKind::Model { provider: p, .. } if p == provider
)
})
.map(|(idx, _)| idx)
}
fn index_of_model_id(&self, model_id: &str) -> Option<usize> {
self.model_selector_items
.iter()
.enumerate()
.find(|(_, item)| {
item.model_index()
.and_then(|idx| self.models.get(idx))
.map(|model| model.id == model_id)
.unwrap_or(false)
})
.map(|(idx, _)| idx)
}
fn selected_model_info(&self) -> Option<&ModelInfo> {
self.selected_model_item
.and_then(|idx| self.model_selector_items.get(idx))
.and_then(|item| item.model_index())
.and_then(|model_index| self.models.get(model_index))
}
fn current_model_selector_item(&self) -> Option<&ModelSelectorItem> {
self.selected_model_item
.and_then(|idx| self.model_selector_items.get(idx))
}
fn set_selected_model_item(&mut self, index: usize) {
if self.model_selector_items.is_empty() {
self.selected_model_item = None;
return;
}
let clamped = index.min(self.model_selector_items.len().saturating_sub(1));
self.selected_model_item = Some(clamped);
if let Some(item) = self.model_selector_items.get(clamped) {
match item.kind() {
ModelSelectorItemKind::Header { provider, .. }
| ModelSelectorItemKind::Scope { provider, .. }
| ModelSelectorItemKind::Model { provider, .. }
| ModelSelectorItemKind::Empty { provider, .. } => {
self.selected_provider = provider.clone();
self.update_selected_provider_index();
}
}
}
}
fn focus_first_model_in_scope(&mut self, scope: &ModelScope) -> bool {
if self.model_selector_items.is_empty() {
return false;
}
let scope_index = self
.model_selector_items
.iter()
.enumerate()
.find(|(_, item)| matches!(item.kind(), ModelSelectorItemKind::Scope { scope: s, .. } if s == scope))
.map(|(idx, _)| idx);
let Some(scope_idx) = scope_index else {
return false;
};
self.set_selected_model_item(scope_idx);
let len = self.model_selector_items.len();
let mut cursor = scope_idx + 1;
while cursor < len {
match self.model_selector_items[cursor].kind() {
ModelSelectorItemKind::Model { .. } => {
self.set_selected_model_item(cursor);
return true;
}
ModelSelectorItemKind::Scope { .. } | ModelSelectorItemKind::Header { .. } => break,
_ => {}
}
cursor += 1;
}
true
}
fn focus_first_available_model(&mut self) -> bool {
if self.model_selector_items.is_empty() {
return false;
}
if let Some(idx) = self.first_model_item_index() {
self.set_selected_model_item(idx);
true
} else {
false
}
}
fn ensure_valid_model_selection(&mut self) {
if self.model_selector_items.is_empty() {
self.selected_model_item = None;
return;
}
let needs_reset = self
.selected_model_item
.map(|idx| idx >= self.model_selector_items.len())
.unwrap_or(true);
if needs_reset {
self.set_selected_model_item(0);
} else if let Some(idx) = self.selected_model_item {
self.set_selected_model_item(idx);
}
}
fn move_model_selection(&mut self, direction: i32) {
if self.model_selector_items.is_empty() {
self.selected_model_item = None;
return;
}
let len = self.model_selector_items.len() as isize;
let mut idx = self.selected_model_item.unwrap_or(0) as isize + direction as isize;
if idx < 0 {
idx = 0;
} else if idx >= len {
idx = len - 1;
}
self.set_selected_model_item(idx as usize);
}
fn update_selected_provider_index(&mut self) {
if let Some(idx) = self
.available_providers
.iter()
.position(|p| p == &self.selected_provider)
{
self.selected_provider_index = idx;
} else if !self.available_providers.is_empty() {
self.selected_provider_index = 0;
self.selected_provider = self.available_providers[0].clone();
} else {
self.selected_provider_index = 0;
}
}
fn expand_provider(&mut self, provider: &str, focus_first_model: bool) {
let provider_owned = provider.to_string();
let needs_rebuild = self.expanded_provider.as_deref() != Some(provider);
self.selected_provider = provider_owned.clone();
self.expanded_provider = Some(provider_owned.clone());
if needs_rebuild {
self.rebuild_model_selector_items();
}
self.ensure_valid_model_selection();
if focus_first_model {
if let Some(idx) = self.index_of_first_model_for_provider(&provider_owned) {
self.set_selected_model_item(idx);
} else if let Some(idx) = self.index_of_header(&provider_owned) {
self.set_selected_model_item(idx);
}
} else if let Some(idx) = self.index_of_header(&provider_owned) {
self.set_selected_model_item(idx);
}
}
fn collapse_provider(&mut self, provider: &str) {
if self.expanded_provider.as_deref() == Some(provider) {
self.expanded_provider = None;
self.rebuild_model_selector_items();
if let Some(idx) = self.index_of_header(provider) {
self.set_selected_model_item(idx);
}
}
}
async fn switch_to_provider(&mut self, provider_name: &str) -> Result<()> {
let canonical_name = Self::canonical_provider_id(provider_name);
if Self::canonical_provider_id(&self.current_provider) == canonical_name {
return Ok(());
}
use owlen_core::config::McpServerConfig;
use std::collections::HashMap;
if self.controller.config().provider(&canonical_name).is_none() {
let mut guard = self.controller.config_mut();
config::ensure_provider_config(&mut guard, &canonical_name);
}
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.ok();
let server_binary = workspace_root.as_ref().and_then(|root| {
[
"target/debug/owlen-mcp-llm-server",
"target/release/owlen-mcp-llm-server",
]
.iter()
.map(|rel| root.join(rel))
.find(|p| p.exists())
});
let mut env_vars = HashMap::new();
env_vars.insert("OWLEN_PROVIDER".to_string(), canonical_name.clone());
let provider: Arc<dyn owlen_core::Provider> = if let Some(path) = server_binary {
let config = McpServerConfig {
name: canonical_name.clone(),
command: path.to_string_lossy().into_owned(),
args: Vec::new(),
transport: "stdio".to_string(),
env: env_vars,
oauth: None,
};
Arc::new(RemoteMcpClient::new_with_config(&config)?)
} else {
Arc::new(Self::with_temp_env_vars(&env_vars, RemoteMcpClient::new)?)
};
self.controller.switch_provider(provider).await?;
self.current_provider = canonical_name;
self.model_details_cache.clear();
self.model_info_panel.clear();
self.set_model_info_visible(false);
self.update_command_palette_catalog();
Ok(())
}
async fn populate_model_details_cache_from_session(&mut self) {
self.model_details_cache.clear();
let mut populated = false;
if let Ok(details) = self.controller.all_model_details(false).await {
for info in details {
self.model_details_cache.insert(info.name.clone(), info);
}
populated = !self.model_details_cache.is_empty();
}
if !populated {
let cached = self.controller.cached_model_details().await;
for info in cached {
self.model_details_cache.insert(info.name.clone(), info);
}
}
}
async fn refresh_models(&mut self) -> Result<()> {
let config_model_name = self.controller.config().general.default_model.clone();
let config_model_provider = self.controller.config().general.default_provider.clone();
let (all_models, errors, scope_status) = self.collect_models_from_all_providers().await;
if all_models.is_empty() {
self.error = if errors.is_empty() {
Some("No models available".to_string())
} else {
Some(errors.join("; "))
};
self.models.clear();
self.provider_scope_status.clear();
self.model_details_cache.clear();
self.model_info_panel.clear();
self.set_model_info_visible(false);
self.recompute_available_providers();
if self.available_providers.is_empty() {
self.available_providers.push("ollama_local".to_string());
}
self.rebuild_model_selector_items();
self.selected_model_item = None;
self.status = "No models available".to_string();
self.update_selected_provider_index();
self.update_command_palette_catalog();
return Ok(());
}
self.models = all_models;
self.provider_scope_status = scope_status;
self.rebuild_annotated_models();
self.model_info_panel.clear();
self.set_model_info_visible(false);
self.populate_model_details_cache_from_session().await;
self.recompute_available_providers();
if self.available_providers.is_empty() {
self.available_providers.push("ollama_local".to_string());
}
if !config_model_provider.is_empty() {
self.selected_provider = config_model_provider.clone();
} else {
self.selected_provider = self.available_providers[0].clone();
}
self.expanded_provider = Some(self.selected_provider.clone());
self.update_selected_provider_index();
// Ensure the default model is set after refreshing models (async)
self.controller.ensure_default_model(&self.models).await;
self.sync_selected_model_index().await;
let current_model_name = self.controller.selected_model().to_string();
let current_model_provider = self.controller.config().general.default_provider.clone();
if config_model_name.as_deref() != Some(&current_model_name)
|| config_model_provider != current_model_provider
{
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
if !errors.is_empty() {
self.error = Some(errors.join("; "));
} else {
self.error = None;
}
self.status = format!(
"Loaded {} models across {} provider(s)",
self.models.len(),
self.available_providers.len()
);
self.rebuild_model_selector_items();
self.update_command_palette_catalog();
Ok(())
}
async fn apply_model_selection(&mut self, model: ModelInfo) -> Result<()> {
let model_id = model.id.clone();
let model_label = Self::display_name_for_model(&model);
if let Err(err) = self.switch_to_provider(&model.provider).await {
self.error = Some(format!("Failed to switch provider: {}", err));
self.status = "Provider switch failed".to_string();
return Err(err);
}
self.selected_provider = model.provider.clone();
self.update_selected_provider_index();
self.controller.set_model(model_id.clone()).await;
self.status = format!(
"Using model: {} (provider: {})",
model_label, self.selected_provider
);
self.controller.config_mut().general.default_model = Some(model_id.clone());
self.controller.config_mut().general.default_provider = self.selected_provider.clone();
match config::save_config(&self.controller.config()) {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!("Failed to save config: {}", err));
}
}
self.set_input_mode(InputMode::Normal);
self.set_model_info_visible(false);
Ok(())
}
async fn apply_provider_mode(&mut self, provider: &str, mode: &str) -> Result<()> {
let normalized = match mode.trim().to_ascii_lowercase().as_str() {
"local" | "cloud" | "auto" => mode.trim().to_ascii_lowercase(),
other => {
return Err(anyhow!(
"Unknown provider mode '{other}'. Expected local, cloud, or auto"
));
}
};
{
let mut config = self.controller.config_mut();
config::ensure_provider_config(&mut config, provider);
}
{
let mut config = self.controller.config_mut();
if let Some(entry) = config.providers.get_mut(provider) {
entry.extra.insert(
OLLAMA_MODE_KEY.to_string(),
serde_json::Value::String(normalized.clone()),
);
} else {
return Err(anyhow!("Provider '{provider}' is not configured"));
}
}
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save provider mode: {err}"));
return Err(err);
}
if provider.eq_ignore_ascii_case(&self.selected_provider) {
if let Err(err) = self.refresh_models().await {
self.error = Some(format!(
"Provider mode updated but refreshing models failed: {}",
err
));
return Err(err);
}
}
self.error = None;
self.status = format!(
"Provider {} mode set to {}",
provider,
normalized.to_ascii_uppercase()
);
Ok(())
}
async fn handle_cloud_command(&mut self, args: &[&str]) -> Result<()> {
if args.is_empty() {
return Err(anyhow!(
"Usage: :cloud <setup|status|models|logout> [options]"
));
}
match args[0] {
"setup" => self.cloud_setup(&args[1..]).await,
"status" | "models" | "logout" => Err(anyhow!(
":cloud {} is not implemented in the TUI. Run `owlen cloud {}` from the shell.",
args[0],
args[0]
)),
other => Err(anyhow!("Unknown :cloud subcommand: {other}")),
}
}
async fn cloud_setup(&mut self, args: &[&str]) -> Result<()> {
let options = CloudSetupOptions::parse(args)?;
let mut stored_securely = false;
let (existing_plain_api_key, normalized_endpoint, encryption_enabled, base_was_overridden) = {
let mut config = self.controller.config_mut();
config::ensure_provider_config(&mut config, &options.provider);
let (existing_plain_api_key, normalized_endpoint_local, base_overridden_local) =
if let Some(entry) = config.providers.get_mut(&options.provider) {
let existing = entry.api_key.clone();
entry.enabled = true;
entry.provider_type = "ollama_cloud".to_string();
let should_update_env = match entry.api_key_env.as_deref() {
None => true,
Some(value) => {
value.eq_ignore_ascii_case(LEGACY_OLLAMA_CLOUD_API_KEY_ENV)
|| value.eq_ignore_ascii_case(LEGACY_OWLEN_OLLAMA_CLOUD_API_KEY_ENV)
}
};
if should_update_env {
entry.api_key_env = Some(OLLAMA_API_KEY_ENV.to_string());
}
let requested = options
.endpoint
.clone()
.unwrap_or_else(|| DEFAULT_CLOUD_ENDPOINT.to_string());
let normalized_endpoint_local = normalize_cloud_endpoint(&requested);
entry.extra.insert(
OLLAMA_CLOUD_ENDPOINT_KEY.to_string(),
Value::String(normalized_endpoint_local.clone()),
);
let should_override = options.force_cloud_base_url
|| entry
.base_url
.as_ref()
.map(|value| value.trim().is_empty())
.unwrap_or(true);
let mut base_overridden_local = false;
if should_override {
entry.base_url = Some(normalized_endpoint_local.clone());
base_overridden_local = true;
}
(existing, normalized_endpoint_local, base_overridden_local)
} else {
return Err(anyhow!("Provider '{}' is not configured", options.provider));
};
let encryption_enabled = config.privacy.encrypt_local_data;
(
existing_plain_api_key,
normalized_endpoint_local,
encryption_enabled,
base_overridden_local,
)
};
let base_overridden = base_was_overridden;
let credential_manager = self.controller.credential_manager();
let mut resolved_api_key = options
.api_key
.clone()
.filter(|value| !value.trim().is_empty());
if resolved_api_key.is_none() {
if let Some(existing) = existing_plain_api_key.as_ref() {
if !existing.trim().is_empty() {
resolved_api_key = Some(existing.clone());
}
}
}
if resolved_api_key.is_none() && credential_manager.is_some() {
if let Some(manager) = credential_manager.clone() {
if let Some(credentials) = manager
.get_credentials(OLLAMA_CLOUD_CREDENTIAL_ID)
.await
.with_context(|| "Failed to load stored Ollama Cloud credentials")?
{
if !credentials.api_key.trim().is_empty() {
resolved_api_key = Some(credentials.api_key);
}
}
}
}
if resolved_api_key.is_none() {
if let Ok(env_key) = std::env::var("OLLAMA_API_KEY") {
if !env_key.trim().is_empty() {
resolved_api_key = Some(env_key);
}
}
}
if resolved_api_key.is_none() {
return Err(anyhow!(
"No API key provided. Pass `--api-key <value>` or export OLLAMA_API_KEY."
));
}
let api_key = resolved_api_key
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow!("Ollama Cloud API key cannot be blank"))?;
if encryption_enabled {
if let Some(manager) = credential_manager.clone() {
let credentials = ApiCredentials {
api_key: api_key.clone(),
endpoint: normalized_endpoint.clone(),
};
manager
.store_credentials(OLLAMA_CLOUD_CREDENTIAL_ID, &credentials)
.await
.with_context(|| "Failed to store Ollama Cloud credentials securely")?;
stored_securely = true;
let mut config = self.controller.config_mut();
if let Some(entry) = config.providers.get_mut(&options.provider) {
entry.api_key = None;
}
} else {
self.push_toast(
ToastLevel::Warning,
"Secure credential vault unavailable; storing API key in configuration.",
);
let mut config = self.controller.config_mut();
if let Some(entry) = config.providers.get_mut(&options.provider) {
entry.api_key = Some(api_key.clone());
}
}
} else {
let mut config = self.controller.config_mut();
if let Some(entry) = config.providers.get_mut(&options.provider) {
entry.api_key = Some(api_key.clone());
}
}
if let Err(err) = config::save_config(&self.controller.config()) {
return Err(anyhow!("Failed to save configuration: {}", err));
}
if let Err(err) = self.refresh_models().await {
self.push_toast(
ToastLevel::Warning,
format!("Cloud setup saved, but refreshing models failed: {err}"),
);
}
let mut status_parts = Vec::new();
status_parts.push(format!(
"Configured {} for Ollama Cloud ({})",
options.provider, normalized_endpoint
));
if stored_securely {
status_parts.push("API key stored securely".to_string());
} else {
status_parts.push("API key stored in configuration".to_string());
}
if !base_overridden && !options.force_cloud_base_url {
status_parts.push("Local base URL preserved".to_string());
}
self.status = status_parts.join(" · ");
self.error = None;
Ok(())
}
async fn show_model_picker(&mut self, filter: Option<FilterMode>) -> Result<()> {
self.refresh_models().await?;
if self.models.is_empty() {
return Ok(());
}
// Respect caller-specified filter or fall back to the last-used mode.
if let Some(mode) = filter {
self.model_filter_memory = mode;
self.update_model_filter_mode(mode);
} else {
let remembered = self.model_filter_memory;
self.update_model_filter_mode(remembered);
}
// Reset transient search state when opening the picker.
self.reset_model_picker_state();
self.rebuild_model_selector_items();
self.update_model_search_status();
if self.available_providers.len() <= 1 {
self.set_input_mode(InputMode::ModelSelection);
self.ensure_valid_model_selection();
} else {
self.set_input_mode(InputMode::ProviderSelection);
}
self.status = "Select a model to use".to_string();
Ok(())
}
fn best_model_match_index(&self, query: &str) -> Option<usize> {
let query = query.trim();
if query.is_empty() {
return None;
}
let mut best: Option<(usize, usize, usize)> = None;
for (idx, model) in self.models.iter().enumerate() {
let mut candidates = Vec::new();
candidates.push(commands::match_score(model.id.as_str(), query));
if !model.name.is_empty() {
candidates.push(commands::match_score(model.name.as_str(), query));
}
candidates.push(commands::match_score(
format!("{} {}", model.provider, model.id).as_str(),
query,
));
if !model.name.is_empty() {
candidates.push(commands::match_score(
format!("{} {}", model.provider, model.name).as_str(),
query,
));
}
candidates.push(commands::match_score(
format!("{}::{}", model.provider, model.id).as_str(),
query,
));
if let Some(score) = candidates.into_iter().flatten().min() {
let entry = (score.0, score.1, idx);
let replace = match best.as_ref() {
Some(current) => entry < *current,
None => true,
};
if replace {
best = Some(entry);
}
}
}
best.map(|(_, _, idx)| idx)
}
fn best_provider_match(&self, query: &str) -> Option<String> {
let query = query.trim();
if query.is_empty() {
return None;
}
let mut best: Option<(usize, usize, &String)> = None;
for provider in &self.available_providers {
if let Some(score) = commands::match_score(provider.as_str(), query) {
let entry = (score.0, score.1, provider);
let replace = match best.as_ref() {
Some(current) => entry < *current,
None => true,
};
if replace {
best = Some(entry);
}
}
}
best.map(|(_, _, provider)| provider.clone())
}
async fn select_model_with_filter(&mut self, filter: &str) -> Result<()> {
let query = filter.trim();
if query.is_empty() {
return Err(anyhow!(
"Provide a model filter (e.g. :model llama3) or omit arguments to open the picker."
));
}
self.refresh_models().await?;
if self.models.is_empty() {
return Err(anyhow!(
"No models available. Use :model to refresh once a provider is reachable."
));
}
if let Some(idx) = self.best_model_match_index(query) {
if let Some(model) = self.models.get(idx).cloned() {
self.apply_model_selection(model).await?;
return Ok(());
}
}
Err(anyhow!(format!(
"No model matching '{}'. Use :model to browse available models.",
filter
)))
}
fn send_user_message_and_request_response(&mut self) {
let content = self.controller.input_buffer().text().trim().to_string();
if content.is_empty() {
self.error = Some("Cannot send empty message".to_string());
return;
}
// Step 1: Add user message to conversation immediately (synchronous)
let message = self.controller.input_buffer_mut().commit_to_history();
let mut references = Self::extract_resource_references(&message);
references.sort();
references.dedup();
self.pending_resource_refs = references;
self.controller
.conversation_mut()
.push_user_message(message.clone());
// Auto-scroll to bottom when sending a message
self.auto_scroll.stick_to_bottom = true;
// Step 2: Set flag to process LLM request on next event loop iteration
self.pending_llm_request = true;
self.status = "Message sent".to_string();
self.error = None;
}
pub fn has_active_generation(&self) -> bool {
self.pending_llm_request || !self.streaming.is_empty()
}
pub fn cancel_active_generation(&mut self) -> Result<bool> {
let mut cancelled = false;
if self.pending_llm_request {
self.pending_llm_request = false;
cancelled = true;
}
let mut cancel_error: Option<String> = None;
if !self.streaming.is_empty() {
let active_ids: Vec<Uuid> = self.streaming.iter().copied().collect();
for message_id in active_ids {
if let Some(handle) = self.stream_tasks.remove(&message_id) {
handle.abort();
}
if let Err(err) = self
.controller
.cancel_stream(message_id, "Generation cancelled by user.")
{
cancel_error = Some(err.to_string());
}
self.streaming.remove(&message_id);
self.invalidate_message_cache(&message_id);
cancelled = true;
}
}
if cancelled {
if let Some(err) = cancel_error {
self.error = Some(format!("Failed to finalize cancelled stream: {}", err));
} else {
self.error = None;
}
self.stop_loading_animation();
self.pending_tool_execution = None;
self.pending_consent = None;
self.queued_consents.clear();
self.current_thinking = None;
self.agent_actions = None;
self.status = "Generation cancelled".to_string();
self.set_system_status("Generation cancelled".to_string());
self.update_thinking_from_last_message();
}
Ok(cancelled)
}
fn reset_after_new_conversation(&mut self) -> Result<()> {
let _ = self.cancel_active_generation()?;
self.close_code_view();
self.set_system_status(String::new());
self.pending_llm_request = false;
self.pending_tool_execution = None;
self.pending_consent = None;
self.queued_consents.clear();
self.pending_key = None;
self.visual_start = None;
self.visual_end = None;
self.clipboard.clear();
{
let buffer = self.controller.input_buffer_mut();
buffer.clear();
buffer.clear_history();
}
self.textarea = TextArea::default();
configure_textarea_defaults(&mut self.textarea);
self.auto_scroll = AutoScroll::default();
self.thinking_scroll = AutoScroll::default();
self.chat_cursor = (0, 0);
self.thinking_cursor = (0, 0);
self.current_thinking = None;
self.agent_actions = None;
self.agent_mode = false;
self.agent_running = false;
self.is_loading = false;
self.message_line_cache.clear();
// Ensure no orphaned stream tasks remain
for (_, handle) in self.stream_tasks.drain() {
handle.abort();
}
self.streaming.clear();
self.focused_panel = FocusedPanel::Input;
self.ensure_focus_valid();
Ok(())
}
pub async fn process_pending_llm_request(&mut self) -> Result<()> {
if !self.pending_llm_request {
return Ok(());
}
self.pending_llm_request = false;
self.resolve_pending_resource_references().await?;
// Check if agent mode is enabled
if self.agent_mode {
return self.process_agent_request().await;
}
// Step 1: Show loading model status and start animation
self.status = format!("Loading model '{}'...", self.controller.selected_model());
self.start_loading_animation();
let parameters = ChatParameters {
stream: self.controller.config().general.enable_streaming,
..Default::default()
};
// Add a timeout to prevent indefinite blocking
let request_future = self
.controller
.send_request_with_current_conversation(parameters);
let timeout_duration = std::time::Duration::from_secs(30);
match tokio::time::timeout(timeout_duration, request_future).await {
Ok(Ok(SessionOutcome::Complete(response))) => {
if let Some(usage) = response.usage.as_ref() {
self.update_context_usage(usage);
}
self.stop_loading_animation();
self.status = "Ready".to_string();
self.error = None;
self.refresh_usage_summary().await?;
Ok(())
}
Ok(Ok(SessionOutcome::Streaming {
response_id,
stream,
})) => {
self.status = "Model loaded. Generating response... (streaming)".to_string();
self.spawn_stream(response_id, stream);
match self.controller.mark_stream_placeholder(response_id, "") {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!("Could not set response placeholder: {}", err));
}
}
Ok(())
}
Ok(Err(err)) => {
self.stop_loading_animation();
if self.handle_provider_error(&err).await? {
return Ok(());
}
let message = err.to_string();
if message.to_lowercase().contains("not found") {
self.error = Some(
"Model not available. Press 'm' to pick another installed model."
.to_string(),
);
self.status = "Model unavailable".to_string();
let _ = self.refresh_models().await;
self.set_input_mode(InputMode::ProviderSelection);
} else {
self.error = Some(message);
self.status = "Request failed".to_string();
}
Ok(())
}
Err(_) => {
self.error = Some("Request timed out. Check if Ollama is running.".to_string());
self.status = "Request timed out".to_string();
self.stop_loading_animation();
Ok(())
}
}
}
async fn handle_cloud_unauthorized(
&mut self,
summary: impl Into<String>,
detail: Option<&str>,
) -> Result<bool> {
let summary = summary.into();
let error_text = detail
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(|detail| format!("{summary}: {detail}"))
.unwrap_or(summary.clone());
self.push_toast(
ToastLevel::Error,
"Cloud key invalid; using local provider.",
);
let switch_result = self.switch_to_provider("ollama_local").await;
if let Err(switch_err) = switch_result {
let detail_message = format!("{summary}; local fallback failed: {}", switch_err);
self.error = Some(detail_message.clone());
self.status = "Cloud authentication failed".to_string();
self.push_toast(ToastLevel::Error, detail_message);
} else {
self.selected_provider = "ollama_local".to_string();
self.expanded_provider = Some("ollama_local".to_string());
self.update_selected_provider_index();
{
let mut cfg = self.controller.config_mut();
cfg.general.default_provider = "ollama_local".to_string();
}
let save_result = {
let cfg = self.controller.config();
config::save_config(&cfg)
};
if let Err(save_err) = save_result {
self.push_toast(
ToastLevel::Warning,
format!(
"Fell back to local provider, but failed to save config: {}",
save_err
),
);
}
if let Err(refresh_err) = self.refresh_models().await {
self.push_toast(
ToastLevel::Warning,
format!("Failed to refresh local models: {}", refresh_err),
);
}
self.status = "Cloud authentication failed; using local provider instead.".to_string();
self.error = Some(error_text);
self.push_toast(ToastLevel::Info, "Switched back to local provider.");
}
Ok(true)
}
async fn handle_provider_error(&mut self, err: &CoreError) -> Result<bool> {
let current_provider = Self::canonical_provider_id(&self.current_provider);
if current_provider != "ollama_cloud" {
return Ok(false);
}
if let CoreError::ProviderFailure(failure) = err {
match failure.kind {
ProviderErrorKind::Unauthorized => {
let detail = failure.detail.as_deref();
let message = if detail.is_some() {
failure.message.clone()
} else {
"Cloud key invalid".to_string()
};
return self.handle_cloud_unauthorized(message, detail).await;
}
ProviderErrorKind::RateLimited => {
let toast = failure.message.clone();
self.error = Some(
failure
.detail
.as_ref()
.filter(|d| !d.trim().is_empty())
.map(|detail| format!("{toast}: {detail}"))
.unwrap_or(toast.clone()),
);
self.status = "Cloud rate limit hit".to_string();
self.push_toast(ToastLevel::Warning, toast);
return Ok(true);
}
ProviderErrorKind::ModelNotFound => {
self.error = Some(failure.message.clone());
self.status = "Model unavailable".to_string();
let _ = self.refresh_models().await;
self.set_input_mode(InputMode::ProviderSelection);
return Ok(true);
}
ProviderErrorKind::Timeout => {
self.error = Some(failure.message.clone());
self.status = "Request timed out".to_string();
self.push_toast(ToastLevel::Warning, failure.message.clone());
return Ok(true);
}
ProviderErrorKind::Unavailable => {
self.error = Some(failure.message.clone());
self.status = "Cloud provider unavailable".to_string();
self.push_toast(ToastLevel::Warning, failure.message.clone());
return Ok(true);
}
ProviderErrorKind::Network => {
self.error = Some(
failure
.detail
.clone()
.unwrap_or_else(|| failure.message.clone()),
);
self.status = "Network error".to_string();
self.push_toast(ToastLevel::Warning, failure.message.clone());
return Ok(true);
}
_ => {
self.error = Some(
failure
.detail
.clone()
.unwrap_or_else(|| failure.message.clone()),
);
self.status = "Request failed".to_string();
return Ok(true);
}
}
}
match err {
CoreError::Auth(message) => {
self.handle_cloud_unauthorized("Cloud key invalid", Some(message.as_str()))
.await
}
CoreError::Network(message) => {
self.error = Some(message.clone());
self.status = "Network error".to_string();
self.push_toast(ToastLevel::Warning, "Network error talking to provider.");
Ok(true)
}
_ => Ok(false),
}
}
async fn process_agent_request(&mut self) -> Result<()> {
use owlen_core::agent::{AgentConfig, AgentExecutor};
use owlen_core::mcp::remote_client::RemoteMcpClient;
use std::sync::Arc;
self.agent_running = true;
self.status = "Agent is running...".to_string();
self.start_loading_animation();
// Get the last user message
let user_message = self
.controller
.conversation()
.messages
.iter()
.rev()
.find(|m| m.role == owlen_core::types::Role::User)
.map(|m| m.content.clone())
.unwrap_or_default();
// Create agent config
let config = AgentConfig {
max_iterations: 10,
model: self.controller.selected_model().to_string(),
temperature: Some(0.7),
max_tokens: None,
};
// Get the provider
let provider = self.controller.provider().clone();
// Create MCP client
let mcp_client = match RemoteMcpClient::new() {
Ok(client) => Arc::new(client),
Err(e) => {
self.error = Some(format!("Failed to initialize MCP client: {}", e));
self.agent_running = false;
self.agent_mode = false;
self.stop_loading_animation();
return Ok(());
}
};
// Create agent executor
let executor = AgentExecutor::new(provider, mcp_client, config);
// Run agent
match executor.run(user_message).await {
Ok(result) => {
self.controller
.conversation_mut()
.push_assistant_message(result.answer);
self.agent_running = false;
self.agent_mode = false;
self.agent_actions = None;
self.status = format!("Agent completed in {} iterations", result.iterations);
self.stop_loading_animation();
Ok(())
}
Err(e) => {
self.error = Some(format!("Agent failed: {}", e));
self.agent_running = false;
self.agent_mode = false;
self.agent_actions = None;
self.stop_loading_animation();
Ok(())
}
}
}
pub async fn process_pending_tool_execution(&mut self) -> Result<()> {
let Some((message_id, tool_calls)) = self.pending_tool_execution.take() else {
return Ok(());
};
// If a consent dialog is active, keep the execution queued until it resolves
if self.pending_consent.is_some() {
self.pending_tool_execution = Some((message_id, tool_calls));
return Ok(());
}
// Check if consent is needed for any of these tools
let consent_needed = self.controller.check_tools_consent_needed(&tool_calls);
if !consent_needed.is_empty() {
// Re-queue the execution and ensure a controller event is emitted
self.pending_tool_execution = Some((message_id, tool_calls));
self.controller.check_streaming_tool_calls(message_id);
return Ok(());
}
// Show tool execution status
self.status = format!("🔧 Executing {} tool(s)...", tool_calls.len());
// Show tool names in system output
let tool_names: Vec<String> = tool_calls.iter().map(|tc| tc.name.clone()).collect();
self.set_system_status(format!("🔧 Executing tools: {}", tool_names.join(", ")));
self.start_loading_animation();
// Execute tools and get the result
match self
.controller
.execute_streaming_tools(message_id, tool_calls)
.await
{
Ok(SessionOutcome::Streaming {
response_id,
stream,
}) => {
// Tool execution succeeded, spawn stream handler for continuation
self.status = "Tool results sent. Generating response...".to_string();
self.set_system_status("✓ Tools executed successfully".to_string());
self.spawn_stream(response_id, stream);
match self.controller.mark_stream_placeholder(response_id, "") {
Ok(_) => self.error = None,
Err(err) => {
self.error = Some(format!("Could not set response placeholder: {}", err));
}
}
Ok(())
}
Ok(SessionOutcome::Complete(_response)) => {
// Tool execution complete without streaming (shouldn't happen in streaming mode)
self.stop_loading_animation();
self.status = "✓ Tool execution complete".to_string();
self.set_system_status("✓ Tool execution complete".to_string());
self.error = None;
Ok(())
}
Err(err) => {
self.stop_loading_animation();
self.status = "Tool execution failed".to_string();
self.set_system_status(format!("❌ Tool execution failed: {}", err));
self.error = Some(format!("Tool execution failed: {}", err));
Ok(())
}
}
}
// Updated to async to allow awaiting async controller calls
async fn sync_selected_model_index(&mut self) {
self.expanded_provider = Some(self.selected_provider.clone());
self.rebuild_model_selector_items();
let current_model_id = self.controller.selected_model().to_string();
let mut config_updated = false;
if let Some(idx) = self.index_of_model_id(&current_model_id) {
self.set_selected_model_item(idx);
} else {
if let Some(idx) = self.index_of_first_model_for_provider(&self.selected_provider) {
self.set_selected_model_item(idx);
} else if let Some(idx) = self.index_of_header(&self.selected_provider) {
self.set_selected_model_item(idx);
} else if let Some(idx) = self.first_model_item_index() {
self.set_selected_model_item(idx);
} else {
self.ensure_valid_model_selection();
}
if let Some(model) = self.selected_model_info().cloned() {
self.selected_provider = model.provider.clone();
// Set the selected model asynchronously
self.controller.set_model(model.id.clone()).await;
self.controller.config_mut().general.default_model = Some(model.id.clone());
self.controller.config_mut().general.default_provider =
self.selected_provider.clone();
config_updated = true;
}
}
self.update_selected_provider_index();
if config_updated {
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
}
pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) {
self.viewport_height = height;
self.content_width = content_width;
}
pub fn set_thinking_viewport_height(&mut self, height: usize) {
self.thinking_viewport_height = height;
}
pub fn start_loading_animation(&mut self) {
self.is_loading = true;
self.loading_animation_frame = 0;
}
pub fn stop_loading_animation(&mut self) {
self.is_loading = false;
}
pub fn advance_loading_animation(&mut self) {
if self.is_loading {
self.loading_animation_frame = (self.loading_animation_frame + 1) % 8;
// 8-frame animation
}
}
pub fn get_loading_indicator(&self) -> &'static str {
if !self.is_loading {
return "";
}
match self.loading_animation_frame {
0 => "",
1 => "",
2 => "",
3 => "",
4 => "",
5 => "",
6 => "",
7 => "",
_ => "",
}
}
pub fn current_thinking(&self) -> Option<&String> {
self.current_thinking.as_ref()
}
/// Get a reference to the latest agent actions, if any.
pub fn agent_actions(&self) -> Option<&String> {
self.agent_actions.as_ref()
}
/// Set the current agent actions content.
pub fn set_agent_actions(&mut self, actions: String) {
self.agent_actions = Some(actions);
}
/// Check if agent mode is enabled
pub fn is_agent_mode(&self) -> bool {
self.agent_mode
}
/// Check if agent is currently running
pub fn is_agent_running(&self) -> bool {
self.agent_running
}
pub fn get_rendered_lines(&self) -> Vec<String> {
match self.focused_panel {
FocusedPanel::Chat => {
let conversation = self.conversation();
let mut formatter = self.formatter().clone();
let body_width = self.content_width.max(1);
let mut card_width = body_width.saturating_add(4);
let mut compact_cards = false;
if card_width < MIN_MESSAGE_CARD_WIDTH {
card_width = body_width.saturating_add(2).max(1);
compact_cards = true;
}
let inner_width = if compact_cards {
card_width.saturating_sub(2).max(1)
} else {
card_width.saturating_sub(4).max(1)
};
formatter.set_wrap_width(body_width);
let role_label_mode = formatter.role_label_mode();
let mut lines = Vec::new();
for message in conversation.messages.iter() {
let role = &message.role;
let content_to_display = if matches!(role, Role::Assistant) {
let (content_without_think, _) =
formatter.extract_thinking(&message.content);
content_without_think
} else if matches!(role, Role::Tool) {
format_tool_output(&message.content)
} else {
message.content.clone()
};
let is_streaming = message
.metadata
.get("streaming")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let normalized_content = content_to_display.replace("\r\n", "\n");
let trimmed = normalized_content.trim();
let segments = parse_message_segments(trimmed, self.render_markdown);
let mut body_lines: Vec<String> = Vec::new();
let mut indicator_target: Option<usize> = None;
let mut append_segments_plain =
|segments: &[MessageSegment],
indent: &str,
available_width: usize,
indicator_target: &mut Option<usize>,
code_width: usize| {
if segments.is_empty() {
let line_text = if indent.is_empty() {
String::new()
} else {
indent.to_string()
};
body_lines.push(line_text);
*indicator_target = Some(body_lines.len() - 1);
return;
}
for segment in segments {
match segment {
MessageSegment::Text { lines: seg_lines } => {
for line_text in seg_lines {
let mut chunks =
wrap_unicode(line_text.as_str(), available_width);
if chunks.is_empty() {
chunks.push(String::new());
}
for chunk in chunks {
let text = if indent.is_empty() {
chunk.clone()
} else {
format!("{indent}{chunk}")
};
body_lines.push(text);
*indicator_target = Some(body_lines.len() - 1);
}
}
}
MessageSegment::CodeBlock {
language,
lines: code_lines,
} => {
append_code_block_lines_plain(
&mut body_lines,
indent,
code_width,
language.as_deref(),
code_lines,
indicator_target,
);
}
}
}
};
match role_label_mode {
RoleLabelDisplay::Above => {
let indent = " ";
let indent_width = UnicodeWidthStr::width(indent);
let available_width = body_width.saturating_sub(indent_width).max(1);
append_segments_plain(
&segments,
indent,
available_width,
&mut indicator_target,
body_width.saturating_sub(indent_width),
);
}
RoleLabelDisplay::Inline | RoleLabelDisplay::None => {
let indent = "";
let available_width = body_width.max(1);
append_segments_plain(
&segments,
indent,
available_width,
&mut indicator_target,
body_width,
);
}
}
let loading_indicator = self.get_loading_indicator();
if is_streaming && !loading_indicator.is_empty() {
let spinner_symbol = streaming_indicator_symbol(loading_indicator);
if let Some(idx) = indicator_target {
if let Some(line) = body_lines.get_mut(idx) {
if !line.is_empty() {
line.push(' ');
}
line.push_str(spinner_symbol);
}
} else if let Some(line) = body_lines.last_mut() {
if !line.is_empty() {
line.push(' ');
}
line.push_str(spinner_symbol);
} else {
body_lines.push(spinner_symbol.to_string());
}
}
let formatted_timestamp = if self.show_message_timestamps {
Some(Self::format_message_timestamp(message.timestamp))
} else {
None
};
let markers = Self::message_tool_markers(
role,
message.tool_calls.as_ref(),
message
.metadata
.get("tool_call_id")
.and_then(|value| value.as_str()),
);
if compact_cards {
let (emoji, title) = role_label_parts(role);
let mut header = format!("{emoji} {title}");
if let Some(ts) = formatted_timestamp.as_deref() {
header.push_str(" · ");
header.push_str(ts);
}
for marker in &markers {
header.push(' ');
header.push_str(marker);
}
lines.push(header);
if body_lines.is_empty() {
lines.push(String::new());
} else {
lines.extend(body_lines);
}
lines.push(String::new());
} else {
lines.push(Self::build_card_header_plain(
role,
formatted_timestamp.as_deref(),
&markers,
card_width,
));
if body_lines.is_empty() {
lines.push(Self::wrap_card_body_line_plain("", inner_width));
} else {
for body_line in body_lines {
lines
.push(Self::wrap_card_body_line_plain(&body_line, inner_width));
}
}
lines.push(Self::build_card_footer_plain(card_width));
}
}
let last_message_is_user = conversation
.messages
.last()
.map(|msg| matches!(msg.role, Role::User))
.unwrap_or(true);
if !self.get_loading_indicator().is_empty() && last_message_is_user {
lines.push(format!("🤖 Assistant: {}", self.get_loading_indicator()));
}
if self.chat_line_offset > 0 {
let skip = self.chat_line_offset.min(lines.len());
lines = lines.into_iter().skip(skip).collect();
}
if lines.is_empty() {
lines.push("No messages yet. Press 'i' to start typing.".to_string());
}
lines
}
FocusedPanel::Thinking => {
if let Some(thinking) = &self.current_thinking {
thinking.lines().map(|s| s.to_string()).collect()
} else {
Vec::new()
}
}
FocusedPanel::Files => Vec::new(),
FocusedPanel::Code => {
if self.has_loaded_code_view() {
self.code_view_lines()
.iter()
.enumerate()
.map(|(idx, line)| format!("{:>4} {}", idx + 1, line))
.collect()
} else {
Vec::new()
}
}
FocusedPanel::Input => Vec::new(),
}
}
fn get_line_at_row(&self, row: usize) -> Option<String> {
self.get_rendered_lines().get(row).cloned()
}
fn find_next_word_boundary(&self, row: usize, col: usize) -> Option<usize> {
let line = self.get_line_at_row(row)?;
owlen_core::ui::find_next_word_boundary(&line, col)
}
fn find_word_end(&self, row: usize, col: usize) -> Option<usize> {
let line = self.get_line_at_row(row)?;
owlen_core::ui::find_word_end(&line, col)
}
fn find_prev_word_boundary(&self, row: usize, col: usize) -> Option<usize> {
let line = self.get_line_at_row(row)?;
owlen_core::ui::find_prev_word_boundary(&line, col)
}
fn yank_from_panel(&self) -> Option<String> {
let (start_pos, end_pos) = if let (Some(s), Some(e)) = (self.visual_start, self.visual_end)
{
// Normalize selection
if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) {
(s, e)
} else {
(e, s)
}
} else {
return None;
};
let lines = self.get_rendered_lines();
owlen_core::ui::extract_text_from_selection(&lines, start_pos, end_pos)
}
pub fn update_thinking_from_last_message(&mut self) {
// Extract thinking from the last assistant message
if let Some(last_msg) = self
.conversation()
.messages
.iter()
.rev()
.find(|m| matches!(m.role, Role::Assistant))
{
let (_, thinking) = self.formatter().extract_thinking(&last_msg.content);
// Only set stick_to_bottom if content actually changed (to enable auto-scroll during streaming)
let content_changed = self.current_thinking != thinking;
self.current_thinking = thinking;
if content_changed {
// Auto-scroll thinking panel to bottom when content updates
self.thinking_scroll.stick_to_bottom = true;
}
} else {
self.current_thinking = None;
// If thinking panel was focused but thinking disappeared, switch to Chat
if matches!(self.focused_panel, FocusedPanel::Thinking) {
self.focused_panel = FocusedPanel::Chat;
}
}
}
fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::ChatStream) {
let sender = self.session_tx.clone();
self.streaming.insert(message_id);
let handle = tokio::spawn(async move {
use futures_util::StreamExt;
while let Some(item) = stream.next().await {
match item {
Ok(response) => {
if sender
.send(SessionEvent::StreamChunk {
message_id,
response,
})
.is_err()
{
break;
}
}
Err(e) => {
let _ = sender.send(SessionEvent::StreamError {
message_id: Some(message_id),
message: e.to_string(),
});
break;
}
}
}
});
self.stream_tasks.insert(message_id, handle);
}
}
fn capitalize_first(input: &str) -> String {
let mut chars = input.chars();
if let Some(first) = chars.next() {
let mut result = first.to_uppercase().collect::<String>();
result.push_str(chars.as_str());
result
} else {
String::new()
}
}
pub(crate) fn role_label_parts(role: &Role) -> (&'static str, &'static str) {
match role {
Role::User => ("👤", "You"),
Role::Assistant => ("🤖", "Assistant"),
Role::System => ("⚙️", "System"),
Role::Tool => ("🔧", "Tool"),
}
}
pub(crate) fn max_inline_label_width() -> usize {
[
("👤", "You"),
("🤖", "Assistant"),
("⚙️", "System"),
("🔧", "Tool"),
]
.iter()
.map(|(emoji, title)| {
let measure = format!("{emoji} {title}:");
UnicodeWidthStr::width(measure.as_str())
})
.max()
.unwrap_or(0)
}
pub(crate) fn streaming_indicator_symbol(indicator: &str) -> &str {
if indicator.is_empty() {
""
} else {
indicator
}
}
fn parse_message_segments(content: &str, markdown_enabled: bool) -> Vec<MessageSegment> {
if !markdown_enabled {
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
if lines.is_empty() {
lines.push(String::new());
}
return vec![MessageSegment::Text { lines }];
}
let mut segments = Vec::new();
let mut text_lines: Vec<String> = Vec::new();
let mut lines = content.lines();
while let Some(line) = lines.next() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
if !text_lines.is_empty() {
segments.push(MessageSegment::Text {
lines: std::mem::take(&mut text_lines),
});
}
let language = trimmed
.trim_start_matches("```")
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
let mut code_lines = Vec::new();
for code_line in lines.by_ref() {
if code_line.trim_start().starts_with("```") {
break;
}
code_lines.push(code_line.to_string());
}
segments.push(MessageSegment::CodeBlock {
language: if language.is_empty() {
None
} else {
Some(language)
},
lines: code_lines,
});
} else {
text_lines.push(line.to_string());
}
}
if !text_lines.is_empty() {
segments.push(MessageSegment::Text { lines: text_lines });
} else if segments.is_empty() {
segments.push(MessageSegment::Text {
lines: vec![String::new()],
});
}
segments
}
fn wrap_code(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let options = Options::new(width)
.word_separator(WordSeparator::UnicodeBreakProperties)
.break_words(true);
let mut wrapped: Vec<String> = wrap(text, options)
.into_iter()
.map(|segment| segment.into_owned())
.collect();
if wrapped.is_empty() {
wrapped.push(String::new());
}
wrapped
}
fn render_markdown_lines(
markdown: &str,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
let width = available_width.max(1);
let lines: Vec<&str> = if markdown.is_empty() {
Vec::new()
} else {
markdown.lines().collect()
};
if lines.is_empty() {
return render_markdown_text_block(markdown, indent, width, base_style);
}
let mut output: Vec<Line<'static>> = Vec::new();
let mut buffer: Vec<&str> = Vec::new();
let mut index = 0usize;
while index < lines.len() {
if let Some((table, next_index)) = parse_markdown_table(&lines, index) {
if !buffer.is_empty() {
let block = buffer.join("\n");
output.extend(render_markdown_text_block(
&block, indent, width, base_style,
));
buffer.clear();
}
output.extend(render_markdown_table(&table, indent, width, base_style));
index = next_index;
} else {
buffer.push(lines[index]);
index += 1;
}
}
if !buffer.is_empty() {
let block = buffer.join("\n");
output.extend(render_markdown_text_block(
&block, indent, width, base_style,
));
}
if output.is_empty() {
output.extend(render_markdown_text_block("", indent, width, base_style));
}
output
}
fn render_markdown_text_block(
markdown: &str,
indent: &str,
width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
let mut text = from_str(markdown);
let mut output: Vec<Line<'static>> = Vec::new();
if text.lines.is_empty() {
let wrapped = wrap_markdown_spans(Vec::new(), indent, width, base_style);
output.extend(wrapped);
} else {
for line in text.lines.drain(..) {
let spans_owned = line
.spans
.into_iter()
.map(|span| {
let owned = span.content.into_owned();
Span::styled(owned, span.style)
})
.collect::<Vec<_>>();
let wrapped = wrap_markdown_spans(spans_owned, indent, width, base_style);
output.extend(wrapped);
}
}
if output.is_empty() {
output.push(blank_line(indent, base_style));
}
output
}
#[derive(Debug)]
struct ParsedTable {
headers: Vec<String>,
rows: Vec<Vec<String>>,
alignments: Vec<TableAlignment>,
}
#[derive(Clone, Copy, Debug)]
enum TableAlignment {
Left,
Center,
Right,
}
fn parse_markdown_table(lines: &[&str], start: usize) -> Option<(ParsedTable, usize)> {
if start + 1 >= lines.len() {
return None;
}
let header_line = lines[start].trim();
let alignment_line = lines[start + 1].trim();
if header_line.is_empty() || !header_line.contains('|') {
return None;
}
let headers = split_table_row(header_line);
if headers.is_empty() {
return None;
}
let alignments = parse_alignment_row(alignment_line, headers.len())?;
let mut rows = Vec::new();
let mut index = start + 2;
while index < lines.len() {
let raw = lines[index];
let trimmed = raw.trim();
if trimmed.is_empty() || !trimmed.contains('|') {
break;
}
if trimmed
.chars()
.all(|ch| matches!(ch, '-' | ':' | '|' | ' '))
{
break;
}
let mut cells = split_table_row(trimmed);
if cells.iter().all(|cell| cell.is_empty()) {
break;
}
if cells.len() < headers.len() {
cells.resize(headers.len(), String::new());
} else if cells.len() > headers.len() {
cells.truncate(headers.len());
}
rows.push(cells);
index += 1;
}
Some((
ParsedTable {
headers,
rows,
alignments,
},
index,
))
}
fn split_table_row(line: &str) -> Vec<String> {
let trimmed = line.trim();
if trimmed.is_empty() {
return vec![String::new()];
}
let mut chars = trimmed.chars().peekable();
// Discard a single leading pipe if present.
if matches!(chars.peek(), Some('|')) {
chars.next();
}
let mut cells = Vec::new();
let mut current = String::new();
let mut escape = false;
for ch in chars {
if escape {
current.push(ch);
escape = false;
continue;
}
match ch {
'\\' => {
escape = true;
}
'|' => {
cells.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
if escape {
current.push('\\');
}
if !trimmed.ends_with('|') || !current.trim().is_empty() {
cells.push(current.trim().to_string());
}
if cells.is_empty() {
cells.push(String::new());
}
cells
}
fn parse_alignment_row(line: &str, expected_columns: usize) -> Option<Vec<TableAlignment>> {
let trimmed = line.trim();
if trimmed.is_empty() || !trimmed.contains('-') {
return None;
}
let raw_cells = split_table_row(trimmed);
if raw_cells.len() != expected_columns {
return None;
}
let mut alignments = Vec::with_capacity(expected_columns);
for cell in raw_cells {
let cell_trimmed = cell.trim();
if cell_trimmed.is_empty() {
alignments.push(TableAlignment::Left);
continue;
}
if !cell_trimmed.chars().all(|ch| matches!(ch, '-' | ':' | ' ')) {
return None;
}
if !cell_trimmed.contains('-') {
return None;
}
let left = cell_trimmed.starts_with(':');
let right = cell_trimmed.ends_with(':');
let alignment = match (left, right) {
(true, true) => TableAlignment::Center,
(false, true) => TableAlignment::Right,
_ => TableAlignment::Left,
};
alignments.push(alignment);
}
Some(alignments)
}
fn render_markdown_table(
table: &ParsedTable,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
const MIN_CELL_WIDTH: usize = 3;
if table.headers.is_empty() {
return render_markdown_text_block("", indent, available_width.max(1), base_style);
}
let indent_width = UnicodeWidthStr::width(indent);
let padding_cost = 1 + table.headers.len() * 3;
let available_content = available_width.saturating_sub(indent_width + padding_cost);
if available_content < table.headers.len().saturating_mul(MIN_CELL_WIDTH) {
return render_markdown_table_stacked(table, indent, available_width, base_style);
}
let mut desired_widths = vec![MIN_CELL_WIDTH; table.headers.len()];
for (index, header) in table.headers.iter().enumerate() {
desired_widths[index] = desired_widths[index].max(cell_display_width(header));
}
for row in &table.rows {
for (index, cell) in row.iter().enumerate().take(desired_widths.len()) {
desired_widths[index] = desired_widths[index].max(cell_display_width(cell));
}
}
let constrained = constrain_column_widths(&desired_widths, available_content, MIN_CELL_WIDTH)
.unwrap_or_else(|| vec![MIN_CELL_WIDTH; table.headers.len()]);
if constrained.iter().sum::<usize>() > available_content {
return render_markdown_table_stacked(table, indent, available_width, base_style);
}
render_markdown_table_grid(table, indent, available_width, base_style, &constrained)
}
fn render_markdown_table_grid(
table: &ParsedTable,
indent: &str,
available_width: usize,
base_style: Style,
column_widths: &[usize],
) -> Vec<Line<'static>> {
let mut output = Vec::new();
output.extend(render_table_summary_lines(
table,
indent,
available_width,
base_style,
));
output.push(blank_line(indent, base_style));
output.push(build_table_border_line(
'┌',
'┬',
'┐',
column_widths,
indent,
base_style,
));
let header_styles = vec![base_style.add_modifier(Modifier::BOLD); table.headers.len()];
output.extend(render_table_row_lines(
&table.headers,
column_widths,
&table.alignments,
indent,
base_style,
&header_styles,
));
if table.rows.is_empty() {
output.push(build_table_border_line(
'└',
'┴',
'┘',
column_widths,
indent,
base_style,
));
return output;
}
output.push(build_table_border_line(
'├',
'┼',
'┤',
column_widths,
indent,
base_style,
));
let body_styles = vec![base_style; table.headers.len()];
for (index, row) in table.rows.iter().enumerate() {
output.extend(render_table_row_lines(
row,
column_widths,
&table.alignments,
indent,
base_style,
&body_styles,
));
if index == table.rows.len() - 1 {
output.push(build_table_border_line(
'└',
'┴',
'┘',
column_widths,
indent,
base_style,
));
} else {
output.push(build_table_border_line(
'├',
'┼',
'┤',
column_widths,
indent,
base_style,
));
}
}
output
}
fn render_markdown_table_stacked(
table: &ParsedTable,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
let mut output = Vec::new();
output.extend(render_table_summary_lines(
table,
indent,
available_width,
base_style,
));
if table.rows.is_empty() {
output.push(blank_line(indent, base_style));
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
spans.push(Span::styled(
"(No rows)",
base_style.add_modifier(Modifier::ITALIC),
));
output.push(Line::from(spans));
return output;
}
output.push(blank_line(indent, base_style));
let indent_width = UnicodeWidthStr::width(indent);
let available = available_width.saturating_sub(indent_width).max(1);
let header_style = base_style.add_modifier(Modifier::BOLD);
for (row_index, row) in table.rows.iter().enumerate() {
if row_index > 0 {
output.push(blank_line(indent, base_style));
}
for (column_index, header) in table.headers.iter().enumerate() {
let value = row.get(column_index).map(String::as_str).unwrap_or("");
let bullet_prefix = if column_index == 0 {
format!("{}: ", header)
} else {
format!(" {}: ", header)
};
let prefix_width = UnicodeWidthStr::width(bullet_prefix.as_str());
let value_width = available.saturating_sub(prefix_width);
if value_width == 0 {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
spans.push(Span::styled(bullet_prefix.clone(), header_style));
output.push(Line::from(spans));
let wrapped_values = wrap_table_cell(value, available.max(1));
for wrapped in wrapped_values {
let mut continuation = Vec::new();
if !indent.is_empty() {
continuation.push(Span::styled(indent.to_string(), base_style));
}
continuation.push(Span::styled(" ".repeat(prefix_width), header_style));
continuation.push(Span::styled(wrapped, base_style));
output.push(Line::from(continuation));
}
continue;
}
let wrapped_values = wrap_table_cell(value, value_width.max(1));
for (line_index, wrapped) in wrapped_values.into_iter().enumerate() {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
if line_index == 0 {
spans.push(Span::styled(bullet_prefix.clone(), header_style));
} else {
spans.push(Span::styled(" ".repeat(prefix_width), header_style));
}
spans.push(Span::styled(wrapped, base_style));
output.push(Line::from(spans));
}
}
}
output
}
fn render_table_summary_lines(
table: &ParsedTable,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
let mut output = Vec::new();
let indent_width = UnicodeWidthStr::width(indent);
let summary_width = available_width.saturating_sub(indent_width).max(1);
let column_list = if table.headers.is_empty() {
String::from("No columns")
} else {
table.headers.join(", ")
};
let row_count = table.rows.len();
let prefix = if row_count == 0 {
"Table:".to_string()
} else if row_count == 1 {
"Table (1 row):".to_string()
} else {
format!("Table ({} rows):", row_count)
};
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
if summary_width <= prefix_width + 1 {
let mut combined = prefix.clone();
if !column_list.is_empty() {
combined.push(' ');
combined.push_str(&column_list);
}
let wrapped = {
let mut result = wrap_unicode(combined.as_str(), summary_width);
if result.is_empty() {
result.push(String::new());
}
result
};
for (index, text) in wrapped.into_iter().enumerate() {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
let style = if index == 0 {
base_style.add_modifier(Modifier::BOLD)
} else {
base_style
};
spans.push(Span::styled(text, style));
output.push(Line::from(spans));
}
return output;
}
let rest_width = summary_width.saturating_sub(prefix_width + 1).max(1);
let mut rest_lines = if column_list.is_empty() {
vec![String::new()]
} else {
let mut wrapped = wrap_unicode(column_list.as_str(), rest_width);
if wrapped.is_empty() {
wrapped.push(String::new());
}
wrapped
};
for (index, text) in rest_lines.drain(..).enumerate() {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
if index == 0 {
spans.push(Span::styled(
format!("{prefix} "),
base_style.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(text, base_style));
} else {
spans.push(Span::styled(" ".repeat(prefix_width + 1), base_style));
spans.push(Span::styled(text, base_style));
}
output.push(Line::from(spans));
}
output
}
fn build_table_border_line(
left: char,
mid: char,
right: char,
column_widths: &[usize],
indent: &str,
base_style: Style,
) -> Line<'static> {
let mut line = String::new();
line.push(left);
for (index, width) in column_widths.iter().enumerate() {
line.push_str(&"".repeat(width + 2));
if index == column_widths.len() - 1 {
line.push(right);
} else {
line.push(mid);
}
}
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
spans.push(Span::styled(line, base_style));
Line::from(spans)
}
fn render_table_row_lines(
cells: &[String],
column_widths: &[usize],
alignments: &[TableAlignment],
indent: &str,
border_style: Style,
cell_styles: &[Style],
) -> Vec<Line<'static>> {
let column_count = column_widths.len();
let mut column_lines: Vec<Vec<String>> = Vec::with_capacity(column_count);
for (index, width) in column_widths.iter().enumerate() {
let cell = cells.get(index).map(String::as_str).unwrap_or("");
column_lines.push(wrap_table_cell(cell, (*width).max(1)));
}
let max_height = column_lines
.iter()
.map(|lines| lines.len())
.max()
.unwrap_or(1);
let mut output = Vec::with_capacity(max_height);
for line_index in 0..max_height {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), border_style));
}
spans.push(Span::styled("".to_string(), border_style));
for (column_index, width) in column_widths.iter().enumerate() {
let content = column_lines
.get(column_index)
.and_then(|lines| lines.get(line_index))
.map(|s| s.as_str())
.unwrap_or("");
let alignment = alignments
.get(column_index)
.copied()
.unwrap_or(TableAlignment::Left);
let aligned = align_cell_line(alignment, content, *width);
let cell_style = cell_styles
.get(column_index)
.copied()
.unwrap_or(border_style);
spans.push(Span::styled(" ".to_string(), border_style));
spans.push(Span::styled(aligned, cell_style));
spans.push(Span::styled(" ".to_string(), border_style));
spans.push(Span::styled("".to_string(), border_style));
}
output.push(Line::from(spans));
}
output
}
fn align_cell_line(alignment: TableAlignment, content: &str, width: usize) -> String {
let display_width = UnicodeWidthStr::width(content);
if display_width >= width {
return content.to_string();
}
let padding = width - display_width;
match alignment {
TableAlignment::Left => format!("{content}{}", " ".repeat(padding)),
TableAlignment::Right => format!("{}{}", " ".repeat(padding), content),
TableAlignment::Center => {
let left = padding / 2;
let right = padding - left;
format!("{}{}{}", " ".repeat(left), content, " ".repeat(right))
}
}
}
fn wrap_table_cell(content: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
for segment in content.split('\n') {
let trimmed = segment.trim_end();
if trimmed.is_empty() {
lines.push(String::new());
continue;
}
let options = Options::new(width)
.word_separator(WordSeparator::UnicodeBreakProperties)
.break_words(true);
let wrapped = wrap(trimmed, options);
if wrapped.is_empty() {
lines.push(String::new());
} else {
lines.extend(wrapped.into_iter().map(|line| line.into_owned()));
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn constrain_column_widths(
desired: &[usize],
available: usize,
min_width: usize,
) -> Option<Vec<usize>> {
if desired.is_empty() {
return Some(Vec::new());
}
if available < min_width.saturating_mul(desired.len()) {
return None;
}
let mut widths: Vec<usize> = desired
.iter()
.map(|value| (*value).max(min_width))
.collect();
let mut total: usize = widths.iter().sum();
if total <= available {
return Some(widths);
}
while total > available {
let mut changed = false;
for value in &mut widths {
if total <= available {
break;
}
if *value > min_width {
*value -= 1;
total -= 1;
changed = true;
}
}
if !changed {
break;
}
}
if total > available {
None
} else {
Some(widths)
}
}
fn cell_display_width(value: &str) -> usize {
value
.split('\n')
.map(|segment| UnicodeWidthStr::width(segment.trim_end()))
.max()
.unwrap_or(0)
.max(1)
}
fn blank_line(indent: &str, base_style: Style) -> Line<'static> {
let mut spans = Vec::new();
if !indent.is_empty() {
spans.push(Span::styled(indent.to_string(), base_style));
}
spans.push(Span::styled(String::new(), base_style));
Line::from(spans)
}
fn wrap_markdown_spans(
spans: Vec<Span<'static>>,
indent: &str,
available_width: usize,
base_style: Style,
) -> Vec<Line<'static>> {
let width = available_width.max(1);
if spans.is_empty() {
let mut line_spans = Vec::new();
if !indent.is_empty() {
line_spans.push(Span::styled(indent.to_string(), base_style));
}
line_spans.push(Span::styled(String::new(), base_style));
return vec![Line::from(line_spans)];
}
let mut result: Vec<Line<'static>> = Vec::new();
let mut current: Vec<Span<'static>> = Vec::new();
let mut remaining = width;
if !indent.is_empty() {
current.push(Span::styled(indent.to_string(), base_style));
remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent));
}
for span in spans {
let mut content = span.content.into_owned();
let style = span.style;
if content.is_empty() {
continue;
}
while !content.is_empty() {
if remaining == 0 {
result.push(Line::from(std::mem::take(&mut current)));
if !indent.is_empty() {
current.push(Span::styled(indent.to_string(), base_style));
}
remaining = width;
if !indent.is_empty() {
remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent));
}
}
let available = remaining;
let mut take_bytes = 0;
let mut take_width = 0;
for grapheme in content.graphemes(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if take_width + grapheme_width > available {
break;
}
take_bytes += grapheme.len();
take_width += grapheme_width;
if take_width == available {
break;
}
}
if take_bytes == 0 {
result.push(Line::from(std::mem::take(&mut current)));
if !indent.is_empty() {
current.push(Span::styled(indent.to_string(), base_style));
}
remaining = width;
if !indent.is_empty() {
remaining = remaining.saturating_sub(UnicodeWidthStr::width(indent));
}
continue;
}
let chunk = content[..take_bytes].to_string();
content = content[take_bytes..].to_string();
current.push(Span::styled(chunk, style));
remaining = remaining.saturating_sub(take_width);
}
}
if current.is_empty() {
if !indent.is_empty() {
current.push(Span::styled(indent.to_string(), base_style));
}
current.push(Span::styled(String::new(), base_style));
}
result.push(Line::from(current));
result
}
fn wrap_highlight_segments(
segments: Vec<(Style, String)>,
code_width: usize,
theme: &Theme,
) -> Vec<Vec<(Style, String)>> {
let mut rows: Vec<Vec<(Style, String)>> = Vec::new();
let mut current: Vec<(Style, String)> = Vec::new();
let mut current_width: usize = 0;
let push_row = |rows: &mut Vec<Vec<(Style, String)>>,
current: &mut Vec<(Style, String)>,
current_width: &mut usize| {
rows.push(std::mem::take(current));
*current_width = 0;
};
for (style_raw, text) in segments {
let mut remaining = text.as_str();
if remaining.is_empty() {
continue;
}
while !remaining.is_empty() {
if current_width >= code_width {
push_row(&mut rows, &mut current, &mut current_width);
}
let available = code_width.saturating_sub(current_width);
if available == 0 {
push_row(&mut rows, &mut current, &mut current_width);
continue;
}
let mut take_bytes = 0;
let mut take_width = 0;
for grapheme in remaining.graphemes(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if take_width + grapheme_width > available {
break;
}
take_bytes += grapheme.len();
take_width += grapheme_width;
if take_width == available {
break;
}
}
if take_bytes == 0 {
push_row(&mut rows, &mut current, &mut current_width);
continue;
}
let chunk = &remaining[..take_bytes];
remaining = &remaining[take_bytes..];
let mut style = style_raw;
if style.fg.is_none() {
style = style.fg(theme.code_block_text);
}
style = style.bg(theme.code_block_background);
current.push((style, chunk.to_string()));
current_width += take_width;
}
}
if !current.is_empty() {
rows.push(current);
} else if rows.is_empty() {
rows.push(Vec::new());
}
rows
}
fn inline_code_spans_from_text(text: &str, theme: &Theme, base_style: Style) -> Vec<Span<'static>> {
let tick_count = text.matches('`').count();
if tick_count < 2 || (tick_count & 1) != 0 {
return vec![Span::styled(text.to_string(), base_style)];
}
let code_style = Style::default()
.fg(theme.code_block_text)
.bg(theme.code_block_background)
.add_modifier(Modifier::BOLD);
let mut spans = Vec::new();
let mut buffer = String::new();
let mut in_code = false;
for ch in text.chars() {
if ch == '`' {
if in_code {
if !buffer.is_empty() {
spans.push(Span::styled(buffer.clone(), code_style));
buffer.clear();
}
} else if !buffer.is_empty() {
spans.push(Span::styled(buffer.clone(), base_style));
buffer.clear();
}
in_code = !in_code;
} else {
buffer.push(ch);
}
}
if in_code {
return vec![Span::styled(text.to_string(), base_style)];
}
if !buffer.is_empty() {
spans.push(Span::styled(buffer, base_style));
}
if spans.is_empty() {
spans.push(Span::styled(String::new(), base_style));
}
spans
}
#[allow(clippy::too_many_arguments)]
fn append_code_block_lines(
rendered: &mut Vec<Line<'static>>,
indent: &str,
body_width: usize,
language: Option<&str>,
code_lines: &[String],
theme: &Theme,
syntax_highlighting: bool,
indicator_target: &mut Option<usize>,
) {
let body_width = body_width.max(4);
let inner_width = body_width.saturating_sub(2);
let code_width = inner_width.max(1);
let border_style = Style::default()
.fg(theme.code_block_border)
.bg(theme.code_block_background);
let label_style = Style::default()
.fg(theme.code_block_text)
.bg(theme.code_block_background)
.add_modifier(Modifier::BOLD);
let text_style = Style::default()
.fg(theme.code_block_text)
.bg(theme.code_block_background);
let mut top_spans = Vec::new();
top_spans.push(Span::styled(indent.to_string(), border_style));
top_spans.push(Span::styled("", border_style));
let language_label = language
.and_then(|lang| {
let trimmed = lang.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
})
.map(|label| format!(" {} ", label));
if inner_width > 0 {
if let Some(label) = language_label {
let label_width = UnicodeWidthStr::width(label.as_str());
if label_width < inner_width {
let left = (inner_width - label_width) / 2;
let right = inner_width - label_width - left;
if left > 0 {
top_spans.push(Span::styled("".repeat(left), border_style));
}
top_spans.push(Span::styled(label, label_style));
if right > 0 {
top_spans.push(Span::styled("".repeat(right), border_style));
}
} else {
top_spans.push(Span::styled("".repeat(inner_width), border_style));
}
} else {
top_spans.push(Span::styled("".repeat(inner_width), border_style));
}
}
top_spans.push(Span::styled("", border_style));
rendered.push(Line::from(top_spans));
let mut highlighter = if syntax_highlighting {
Some(highlight::build_highlighter_for_language(language))
} else {
None
};
let mut process_line = |line: &str| {
let segments = if let Some(highlighter) = highlighter.as_mut() {
let mut segments = highlight::highlight_line(highlighter, line);
if segments.is_empty() {
segments.push((Style::default(), String::new()));
}
segments
} else {
vec![(Style::default(), line.to_string())]
};
let has_content = segments.iter().any(|(_, text)| !text.is_empty());
let rows = if has_content {
wrap_highlight_segments(segments, code_width, theme)
} else {
vec![Vec::new()]
};
for row in rows {
let mut spans = Vec::new();
spans.push(Span::styled(indent.to_string(), border_style));
spans.push(Span::styled("", border_style));
let mut row_width = 0;
if row.is_empty() {
spans.push(Span::styled(" ".repeat(code_width), text_style));
} else {
for (style, piece) in row {
let width = UnicodeWidthStr::width(piece.as_str());
row_width += width;
spans.push(Span::styled(piece, style));
}
if row_width < code_width {
spans.push(Span::styled(" ".repeat(code_width - row_width), text_style));
}
}
spans.push(Span::styled("", border_style));
rendered.push(Line::from(spans));
*indicator_target = Some(rendered.len() - 1);
}
};
if code_lines.is_empty() {
process_line("");
} else {
for line in code_lines {
process_line(line);
}
}
let mut bottom_spans = Vec::new();
bottom_spans.push(Span::styled(indent.to_string(), border_style));
bottom_spans.push(Span::styled("", border_style));
if inner_width > 0 {
bottom_spans.push(Span::styled("".repeat(inner_width), border_style));
}
bottom_spans.push(Span::styled("", border_style));
rendered.push(Line::from(bottom_spans));
}
fn append_code_block_lines_plain(
output: &mut Vec<String>,
indent: &str,
body_width: usize,
language: Option<&str>,
code_lines: &[String],
indicator_target: &mut Option<usize>,
) {
let body_width = body_width.max(4);
let inner_width = body_width.saturating_sub(2);
let code_width = inner_width.max(1);
let mut top_line = String::new();
top_line.push_str(indent);
top_line.push('╭');
if inner_width > 0 {
if let Some(label) = language.and_then(|lang| {
let trimmed = lang.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}) {
let label_text = format!(" {} ", label);
let label_width = UnicodeWidthStr::width(label_text.as_str());
if label_width < inner_width {
let left = (inner_width - label_width) / 2;
let right = inner_width - label_width - left;
top_line.push_str(&"".repeat(left));
top_line.push_str(&label_text);
top_line.push_str(&"".repeat(right));
} else {
top_line.push_str(&"".repeat(inner_width));
}
} else {
top_line.push_str(&"".repeat(inner_width));
}
}
top_line.push('╮');
output.push(top_line);
if code_lines.is_empty() {
let chunks = wrap_code("", code_width);
for chunk in chunks {
let mut line = String::new();
line.push_str(indent);
line.push('│');
line.push_str(&chunk);
let display_width = UnicodeWidthStr::width(chunk.as_str());
if display_width < code_width {
line.push_str(&" ".repeat(code_width - display_width));
}
line.push('│');
output.push(line);
*indicator_target = Some(output.len() - 1);
}
} else {
for line_text in code_lines {
let chunks = wrap_code(line_text.as_str(), code_width);
for chunk in chunks {
let mut line = String::new();
line.push_str(indent);
line.push('│');
line.push_str(&chunk);
let display_width = UnicodeWidthStr::width(chunk.as_str());
if display_width < code_width {
line.push_str(&" ".repeat(code_width - display_width));
}
line.push('│');
output.push(line);
*indicator_target = Some(output.len() - 1);
}
}
}
let mut bottom_line = String::new();
bottom_line.push_str(indent);
bottom_line.push('╰');
if inner_width > 0 {
bottom_line.push_str(&"".repeat(inner_width));
}
bottom_line.push('╯');
output.push(bottom_line);
}
pub(crate) fn wrap_unicode(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return Vec::new();
}
let options = Options::new(width)
.word_separator(WordSeparator::UnicodeBreakProperties)
.break_words(false);
wrap(text, options)
.into_iter()
.map(|segment| segment.into_owned())
.collect()
}
#[derive(Debug, Clone)]
struct CloudSetupOptions {
provider: String,
endpoint: Option<String>,
api_key: Option<String>,
force_cloud_base_url: bool,
}
impl CloudSetupOptions {
fn parse(args: &[&str]) -> Result<Self> {
let mut options = CloudSetupOptions {
provider: "ollama_cloud".to_string(),
endpoint: None,
api_key: None,
force_cloud_base_url: false,
};
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.trim() {
"--provider" => {
let value = iter.next().ok_or_else(|| {
anyhow!("--provider expects a value (e.g. --provider ollama)")
})?;
options.provider = canonical_provider_name(value);
}
"--endpoint" => {
let value = iter.next().ok_or_else(|| {
anyhow!("--endpoint expects a URL (e.g. --endpoint https://ollama.com)")
})?;
options.endpoint = Some(value.trim().to_string());
}
"--api-key" => {
let value = iter.next().ok_or_else(|| {
anyhow!("--api-key expects a value (e.g. --api-key sk-...)")
})?;
options.api_key = Some(value.trim().to_string());
}
"--force-cloud-base-url" => {
options.force_cloud_base_url = true;
}
flag if flag.starts_with("--") => {
return Err(anyhow!("Unknown flag '{flag}' for :cloud setup"));
}
value => {
if options.api_key.is_none() {
options.api_key = Some(value.trim().to_string());
} else {
return Err(anyhow!(
"Unexpected argument '{value}'. Provide a single API key or use --api-key."
));
}
}
}
}
if options.provider.trim().is_empty() {
options.provider = "ollama_cloud".to_string();
}
options.provider = canonical_provider_name(&options.provider);
Ok(options)
}
}
fn canonical_provider_name(provider: &str) -> String {
let normalized = provider.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"" => "ollama_cloud".to_string(),
"ollama" => "ollama_cloud".to_string(),
"ollama_cloud" => "ollama_cloud".to_string(),
value => value.to_string(),
}
}
fn normalize_cloud_endpoint(endpoint: &str) -> String {
let trimmed = endpoint.trim().trim_end_matches('/');
if trimmed.is_empty() {
DEFAULT_CLOUD_ENDPOINT.to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::{ChatApp, ModelAvailabilityState, ModelScope, render_markdown_lines, wrap_unicode};
use crate::app::UiRuntime;
use futures_util::{future, stream};
use owlen_core::{
Provider, Result as CoreResult,
config::Config,
consent::ConsentScope,
llm::LlmProvider,
session::{ControllerEvent, SessionController},
storage::StorageManager,
types::{ChatRequest, ChatResponse, Message, ModelInfo, Role, ToolCall},
ui::NoOpUiController,
};
use ratatui::style::Style;
use ratatui::text::Line;
use serde_json::json;
use std::sync::Arc;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn lines_to_strings(lines: &[Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn render_markdown_table_draws_grid_when_width_allows() {
let lines = render_markdown_lines(
"| Name | Role |\n| --- | --- |\n| Alice | Developer |\n",
"",
60,
Style::default(),
);
let rendered = lines_to_strings(&lines);
assert!(
rendered.iter().any(|line| line.contains("Table (1 row):")),
"summary line should mention row count"
);
assert!(
rendered.iter().any(|line| line.contains('┌')),
"grid border should be present when width permits"
);
assert!(
rendered.iter().any(|line| line.contains("Alice")),
"table rows should include cell content"
);
}
#[test]
fn render_markdown_table_falls_back_when_narrow() {
let lines = render_markdown_lines(
"| Name | Role |\n| --- | --- |\n| Alice | Developer |\n",
"",
12,
Style::default(),
);
let rendered = lines_to_strings(&lines);
assert!(
rendered.iter().any(|line| line.contains("Name:")),
"stacked fallback should label headers inline"
);
assert!(
rendered.iter().all(|line| !line.contains('┌')),
"narrow layout should avoid grid borders"
);
}
#[test]
fn wrap_unicode_respects_cjk_display_width() {
let wrapped = wrap_unicode("你好世界", 4);
assert_eq!(wrapped, vec!["你好".to_string(), "世界".to_string()]);
}
#[test]
fn wrap_unicode_handles_emoji_graphemes() {
let wrapped = wrap_unicode("🙂🙂🙂", 4);
assert_eq!(wrapped, vec!["🙂🙂".to_string(), "🙂".to_string()]);
}
#[test]
fn wrap_unicode_zero_width_returns_empty() {
let wrapped = wrap_unicode("hello", 0);
assert!(wrapped.is_empty());
}
#[test]
fn extract_scope_status_includes_extended_metadata() {
let models = vec![ModelInfo {
id: "demo".to_string(),
name: "Demo".to_string(),
description: None,
provider: "demo".to_string(),
context_window: None,
capabilities: vec![
"scope:local".to_string(),
"scope-status:local:available".to_string(),
"scope-status-age:local:30".to_string(),
"scope-status-success-age:local:5".to_string(),
"scope-status-stale:local:1".to_string(),
"scope-status-message:local:Cached copy".to_string(),
],
supports_tools: false,
}];
let statuses = ChatApp::extract_scope_status(&models);
let entry = statuses.get(&ModelScope::Local).expect("local scope entry");
assert_eq!(entry.state, ModelAvailabilityState::Available);
assert_eq!(entry.last_checked_secs, Some(30));
assert_eq!(entry.last_success_secs, Some(5));
assert!(entry.is_stale);
assert_eq!(entry.message.as_deref(), Some("Cached copy"));
}
struct StubProvider;
impl LlmProvider for StubProvider {
type Stream = stream::Iter<std::vec::IntoIter<CoreResult<ChatResponse>>>;
type ListModelsFuture<'a>
= future::Ready<CoreResult<Vec<ModelInfo>>>
where
Self: 'a;
type SendPromptFuture<'a>
= future::Ready<CoreResult<ChatResponse>>
where
Self: 'a;
type StreamPromptFuture<'a>
= future::Ready<CoreResult<Self::Stream>>
where
Self: 'a;
type HealthCheckFuture<'a>
= future::Ready<CoreResult<()>>
where
Self: 'a;
fn name(&self) -> &str {
"stub-provider"
}
fn list_models(&self) -> Self::ListModelsFuture<'_> {
future::ready(Ok(vec![]))
}
fn send_prompt(&self, _request: ChatRequest) -> Self::SendPromptFuture<'_> {
let response = ChatResponse {
message: Message::assistant("stub response".to_string()),
usage: None,
is_streaming: false,
is_final: true,
};
future::ready(Ok(response))
}
fn stream_prompt(&self, _request: ChatRequest) -> Self::StreamPromptFuture<'_> {
let response = ChatResponse {
message: Message::assistant("stub response".to_string()),
usage: None,
is_streaming: false,
is_final: true,
};
future::ready(Ok(stream::iter(vec![Ok(response)])))
}
fn health_check(&self) -> Self::HealthCheckFuture<'_> {
future::ready(Ok(()))
}
}
#[tokio::test(flavor = "multi_thread")]
async fn tool_consent_denied_generates_fallback_message() {
let temp_dir = tempdir().expect("tempdir");
let storage_path = temp_dir.path().join("owlen-test.db");
let storage = Arc::new(
StorageManager::with_database_path(storage_path)
.await
.expect("storage"),
);
let mut config = Config::default();
config.privacy.encrypt_local_data = false;
let provider: Arc<dyn Provider> = Arc::new(StubProvider);
let ui = Arc::new(NoOpUiController);
let (event_tx, controller_event_rx) = mpsc::unbounded_channel::<ControllerEvent>();
let session = SessionController::new(provider, config, storage, ui, false, Some(event_tx))
.await
.expect("session");
let (mut app, session_rx) = ChatApp::new(session, controller_event_rx)
.await
.expect("chat app");
// Session events are not needed for this test
drop(session_rx);
let tool_call = ToolCall {
id: "call-1".to_string(),
name: "file_delete".to_string(),
arguments: json!({"path": "/tmp/example.txt"}),
};
let message_id = app
.controller
.conversation_mut()
.push_assistant_message("Preparing to modify files.");
app.controller
.conversation_mut()
.set_tool_calls_on_message(message_id, vec![tool_call.clone()])
.expect("tool calls");
app.pending_tool_execution = Some((message_id, vec![tool_call.clone()]));
app.controller.check_streaming_tool_calls(message_id);
UiRuntime::poll_controller_events(&mut app).expect("poll controller events");
let consent_state = app
.pending_consent
.as_ref()
.expect("pending consent")
.clone();
assert_eq!(consent_state.tool_name, "file_delete");
let resolution = app
.controller
.resolve_tool_consent(consent_state.request_id, ConsentScope::Denied)
.expect("resolution");
app.apply_tool_consent_resolution(resolution)
.expect("apply resolution");
assert!(app.pending_consent.is_none());
assert!(app.pending_tool_execution.is_none());
assert!(app.status.to_lowercase().contains("consent denied"));
let conversation = app.controller.conversation();
let last_message = conversation.messages.last().expect("last message");
assert_eq!(last_message.role, Role::Assistant);
assert!(
last_message.content.contains("consent was denied"),
"fallback message should acknowledge denial"
);
}
}
fn validate_relative_path(path: &Path, allow_nested: bool) -> Result<()> {
if path.as_os_str().is_empty() {
return Err(anyhow!("Path cannot be empty"));
}
if path.is_absolute() {
return Err(anyhow!("Path must be relative to the workspace root"));
}
let mut normal_segments = 0usize;
for component in path.components() {
match component {
Component::Normal(_) => {
normal_segments += 1;
}
Component::CurDir => {
return Err(anyhow!("Path cannot contain '.' segments"));
}
Component::ParentDir => {
return Err(anyhow!("Path cannot contain '..' segments"));
}
Component::RootDir | Component::Prefix(_) => {
return Err(anyhow!("Path must be relative to the workspace root"));
}
}
}
if !allow_nested && normal_segments > 1 {
return Err(anyhow!("Name cannot include path separators"));
}
Ok(())
}
fn configure_textarea_defaults(textarea: &mut TextArea<'static>) {
textarea.set_placeholder_text("Type your message here...");
textarea.set_tab_length(4);
textarea.set_style(
Style::default()
.remove_modifier(Modifier::UNDERLINED)
.remove_modifier(Modifier::ITALIC)
.remove_modifier(Modifier::BOLD),
);
textarea.set_cursor_style(Style::default());
textarea.set_cursor_line_style(Style::default());
}
impl MessageState for ChatApp {}
#[async_trait]
impl UiRuntime for ChatApp {
async fn handle_ui_event(&mut self, event: Event) -> Result<AppState> {
ChatApp::handle_event(self, event).await
}
async fn handle_session_event(&mut self, event: SessionEvent) -> Result<()> {
ChatApp::handle_session_event(self, event).await
}
async fn process_pending_llm_request(&mut self) -> Result<()> {
ChatApp::process_pending_llm_request(self).await
}
async fn process_pending_tool_execution(&mut self) -> Result<()> {
ChatApp::process_pending_tool_execution(self).await
}
fn poll_controller_events(&mut self) -> Result<()> {
loop {
match self.controller_event_rx.try_recv() {
Ok(event) => self.handle_controller_event(event)?,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
}
}
Ok(())
}
fn advance_loading_animation(&mut self) {
ChatApp::advance_loading_animation(self);
}
fn streaming_count(&self) -> usize {
ChatApp::streaming_count(self)
}
}