Acceptance-Criteria:\n- spec-compliant names are shared via WEB_SEARCH_TOOL_NAME and ModeConfig checks canonical identifiers.\n- workspace depends on once_cell so regex helpers build without local target hacks. Test-Notes:\n- cargo test
13175 lines
528 KiB
Rust
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(), ®istry)
|
|
};
|
|
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(¤t).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(), ®istry);
|
|
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(¤t_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).copied();
|
|
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(¤t_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(¤t_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)
|
|
}
|
|
}
|