- Update help UI to show “Ctrl+←/→ → resize files panel”. - Change `set_file_panel_width` to return the clamped width. - Implement Ctrl+←/→ handling in keyboard input to adjust the files panel width, update status messages, and respect panel collapse state.
8947 lines
370 KiB
Rust
8947 lines
370 KiB
Rust
use anyhow::{Context, Result, anyhow};
|
|
use chrono::{DateTime, Local, Utc};
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
|
use owlen_core::mcp::remote_client::RemoteMcpClient;
|
|
use owlen_core::mcp::{McpToolDescriptor, McpToolResponse};
|
|
use owlen_core::{
|
|
Provider, ProviderConfig,
|
|
config::McpResourceConfig,
|
|
model::DetailedModelInfo,
|
|
oauth::{DeviceAuthorization, DevicePollState},
|
|
session::{SessionController, SessionOutcome},
|
|
storage::SessionMeta,
|
|
theme::Theme,
|
|
types::{ChatParameters, ChatResponse, Conversation, ModelInfo, Role},
|
|
ui::{AppState, AutoScroll, FocusedPanel, InputMode, RoleLabelDisplay},
|
|
};
|
|
use pathdiff::diff_paths;
|
|
use ratatui::style::{Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use textwrap::{Options, WordSeparator, wrap};
|
|
use tokio::{
|
|
sync::mpsc,
|
|
task::{self, JoinHandle},
|
|
};
|
|
use tui_textarea::{CursorMove, Input, TextArea};
|
|
use unicode_width::UnicodeWidthStr;
|
|
use uuid::Uuid;
|
|
|
|
use crate::commands;
|
|
use crate::config;
|
|
use crate::events::Event;
|
|
use crate::model_info_panel::ModelInfoPanel;
|
|
use crate::slash::{self, McpSlashCommand, SlashCommand};
|
|
use crate::state::{
|
|
CodeWorkspace, CommandPalette, FileFilterMode, FileIconResolver, FileNode, FileTreeState,
|
|
ModelPaletteEntry, PaletteSuggestion, PaneDirection, PaneRestoreRequest, RepoSearchMessage,
|
|
RepoSearchState, SplitAxis, SymbolSearchMessage, SymbolSearchState, WorkspaceSnapshot,
|
|
spawn_repo_search_task, spawn_symbol_search_task,
|
|
};
|
|
use crate::toast::{Toast, ToastLevel, ToastManager};
|
|
use crate::ui::format_tool_output;
|
|
// 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::{BTreeSet, HashMap, HashSet};
|
|
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::sync::Arc;
|
|
use std::time::{Duration, Instant, SystemTime};
|
|
|
|
use dirs::{config_dir, data_local_dir};
|
|
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 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);
|
|
|
|
#[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) enum ModelSelectorItemKind {
|
|
Header {
|
|
provider: String,
|
|
expanded: bool,
|
|
},
|
|
Model {
|
|
provider: String,
|
|
model_index: usize,
|
|
},
|
|
Empty {
|
|
provider: String,
|
|
},
|
|
}
|
|
|
|
impl ModelSelectorItem {
|
|
fn header(provider: impl Into<String>, expanded: bool) -> Self {
|
|
Self {
|
|
kind: ModelSelectorItemKind::Header {
|
|
provider: provider.into(),
|
|
expanded,
|
|
},
|
|
}
|
|
}
|
|
|
|
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>) -> Self {
|
|
Self {
|
|
kind: ModelSelectorItemKind::Empty {
|
|
provider: provider.into(),
|
|
},
|
|
}
|
|
}
|
|
|
|
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, .. } => Some(provider),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn kind(&self) -> &ModelSelectorItemKind {
|
|
&self.kind
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
},
|
|
ConsentNeeded {
|
|
tool_name: String,
|
|
data_types: Vec<String>,
|
|
endpoints: Vec<String>,
|
|
callback_id: Uuid,
|
|
},
|
|
/// 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
|
|
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_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
|
|
show_message_timestamps: bool, // Whether to render timestamps in chat headers
|
|
supports_extended_colors: bool, // Terminal supports 256-color output
|
|
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
|
|
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
|
|
system_status: String, // System/status messages (tool execution, status, etc)
|
|
toasts: ToastManager,
|
|
/// 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 tool_name: String,
|
|
pub data_types: Vec<String>,
|
|
pub endpoints: Vec<String>,
|
|
pub callback_id: Uuid, // ID to match callback with the request
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct MessageCacheEntry {
|
|
theme_name: String,
|
|
wrap_width: usize,
|
|
role_label_mode: RoleLabelDisplay,
|
|
syntax_highlighting: 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,
|
|
}
|
|
|
|
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,
|
|
) -> Self {
|
|
Self {
|
|
formatter,
|
|
role_label_mode,
|
|
body_width,
|
|
card_width,
|
|
is_streaming,
|
|
loading_indicator,
|
|
theme,
|
|
syntax_highlighting,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
) -> 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 = config_guard.general.default_provider.clone();
|
|
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 show_timestamps = config_guard.ui.show_timestamps;
|
|
let icon_mode = config_guard.ui.icon_mode;
|
|
drop(config_guard);
|
|
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);
|
|
|
|
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(),
|
|
available_providers: Vec::new(),
|
|
selected_provider: "ollama".to_string(), // Default, will be updated in initialize_models
|
|
selected_provider_index: 0,
|
|
selected_model_item: None,
|
|
model_selector_items: Vec::new(),
|
|
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,
|
|
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,
|
|
system_status: if show_onboarding {
|
|
ONBOARDING_SYSTEM_STATUS.to_string()
|
|
} else {
|
|
String::new()
|
|
},
|
|
toasts: ToastManager::new(),
|
|
_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,
|
|
supports_extended_colors: detect_extended_color_support(),
|
|
show_message_timestamps: show_timestamps,
|
|
};
|
|
|
|
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}");
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
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 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 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(());
|
|
};
|
|
|
|
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(());
|
|
}
|
|
|
|
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.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.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.close_code_view();
|
|
self.set_system_status(String::new());
|
|
}
|
|
|
|
self.operating_mode = mode;
|
|
self.status = format!("Switched to {} mode", mode);
|
|
self.error = None;
|
|
}
|
|
|
|
/// 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 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())
|
|
&& 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));
|
|
}
|
|
self.mode = 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 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();
|
|
}
|
|
|
|
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)
|
|
&& !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()
|
|
&& !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
|
|
&& let Some(Value::String(message)) = map.get("error")
|
|
&& !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
|
|
}
|
|
|
|
fn display_name_for_model(model: &ModelInfo) -> String {
|
|
if model.name.trim().is_empty() {
|
|
model.id.clone()
|
|
} else {
|
|
model.name.clone()
|
|
}
|
|
}
|
|
|
|
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.unfocused_panel_border),
|
|
Role::Tool => Style::default().fg(theme.info),
|
|
}
|
|
}
|
|
|
|
fn content_style(theme: &Theme, role: &Role) -> Style {
|
|
if matches!(role, Role::Tool) {
|
|
Style::default().fg(theme.tool_output)
|
|
} else {
|
|
Style::default()
|
|
}
|
|
}
|
|
|
|
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, show_timestamps) = {
|
|
let guard = self.controller.config();
|
|
(
|
|
guard.ui.show_cursor_outside_insert,
|
|
guard.ui.role_label_mode,
|
|
guard.ui.syntax_highlighting,
|
|
guard.ui.show_timestamps,
|
|
)
|
|
};
|
|
self.show_cursor_outside_insert = show_cursor;
|
|
self.syntax_highlighting = syntax_highlighting;
|
|
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 {
|
|
self.syntax_highlighting && self.supports_extended_colors
|
|
}
|
|
|
|
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,
|
|
} = 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);
|
|
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
|
|
&& let Some(entry) = self.message_line_cache.get(&message_id)
|
|
&& entry.wrap_width == card_width
|
|
&& entry.role_label_mode == role_label_mode
|
|
&& entry.syntax_highlighting == syntax_highlighting
|
|
&& 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 } => {
|
|
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 text = if indent.is_empty() {
|
|
chunk.clone()
|
|
} else {
|
|
format!("{indent}{chunk}")
|
|
};
|
|
rendered.push(Line::from(vec![Span::styled(text, content_style)]));
|
|
*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,
|
|
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>> {
|
|
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));
|
|
}
|
|
|
|
card_lines.push(Self::build_card_footer(card_width, theme));
|
|
card_lines
|
|
}
|
|
|
|
fn build_card_header(
|
|
role: &Role,
|
|
timestamp: Option<&str>,
|
|
markers: &[String],
|
|
card_width: usize,
|
|
theme: &Theme,
|
|
) -> Line<'static> {
|
|
let border_style = Style::default().fg(theme.unfocused_panel_border);
|
|
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) -> Line<'static> {
|
|
let border_style = Style::default().fg(theme.unfocused_panel_border);
|
|
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,
|
|
) -> Line<'static> {
|
|
let border_style = Style::default().fg(theme.unfocused_panel_border);
|
|
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];
|
|
}
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
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<()> {
|
|
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(());
|
|
}
|
|
|
|
if !matches!(self.operating_mode, owlen_core::mode::Mode::Code) {
|
|
self.set_mode(owlen_core::mode::Mode::Code).await;
|
|
}
|
|
|
|
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> {
|
|
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>) {
|
|
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<()> {
|
|
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> {
|
|
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) {
|
|
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};
|
|
|
|
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(20);
|
|
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) = self.collect_models_from_all_providers().await;
|
|
self.models = all_models;
|
|
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".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();
|
|
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.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::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 {
|
|
match key.code {
|
|
KeyCode::Char('1') => {
|
|
// Allow once
|
|
let tool_name = consent_state.tool_name.clone();
|
|
let data_types = consent_state.data_types.clone();
|
|
let endpoints = consent_state.endpoints.clone();
|
|
|
|
self.controller.grant_consent_with_scope(
|
|
&tool_name,
|
|
data_types,
|
|
endpoints,
|
|
owlen_core::consent::ConsentScope::Once,
|
|
);
|
|
self.pending_consent = None;
|
|
self.status = format!("✓ Consent granted (once) for {}", tool_name);
|
|
self.set_system_status(format!(
|
|
"✓ Consent granted (once): {}",
|
|
tool_name
|
|
));
|
|
return Ok(AppState::Running);
|
|
}
|
|
KeyCode::Char('2') => {
|
|
// Allow session
|
|
let tool_name = consent_state.tool_name.clone();
|
|
let data_types = consent_state.data_types.clone();
|
|
let endpoints = consent_state.endpoints.clone();
|
|
|
|
self.controller.grant_consent_with_scope(
|
|
&tool_name,
|
|
data_types,
|
|
endpoints,
|
|
owlen_core::consent::ConsentScope::Session,
|
|
);
|
|
self.pending_consent = None;
|
|
self.status = format!("✓ Consent granted (session) for {}", tool_name);
|
|
self.set_system_status(format!(
|
|
"✓ Consent granted (session): {}",
|
|
tool_name
|
|
));
|
|
return Ok(AppState::Running);
|
|
}
|
|
KeyCode::Char('3') => {
|
|
// Allow always (permanent)
|
|
let tool_name = consent_state.tool_name.clone();
|
|
let data_types = consent_state.data_types.clone();
|
|
let endpoints = consent_state.endpoints.clone();
|
|
|
|
self.controller.grant_consent_with_scope(
|
|
&tool_name,
|
|
data_types,
|
|
endpoints,
|
|
owlen_core::consent::ConsentScope::Permanent,
|
|
);
|
|
self.pending_consent = None;
|
|
self.status =
|
|
format!("✓ Consent granted (permanent) for {}", tool_name);
|
|
self.set_system_status(format!(
|
|
"✓ Consent granted (permanent): {}",
|
|
tool_name
|
|
));
|
|
return Ok(AppState::Running);
|
|
}
|
|
KeyCode::Char('4') | KeyCode::Esc => {
|
|
// Deny consent - clear both consent and pending tool execution to prevent retry
|
|
let tool_name = consent_state.tool_name.clone();
|
|
self.pending_consent = None;
|
|
self.pending_tool_execution = None; // Clear to prevent infinite retry
|
|
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));
|
|
return Ok(AppState::Running);
|
|
}
|
|
_ => {
|
|
// Ignore other keys when consent dialog is shown
|
|
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')
|
|
);
|
|
|
|
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_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('m'), KeyModifiers::NONE) => {
|
|
if let Err(err) = self.show_model_picker().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();
|
|
match self.process_slash_submission().await? {
|
|
SlashOutcome::NotCommand => {
|
|
self.send_user_message_and_request_response();
|
|
self.textarea = TextArea::default();
|
|
configure_textarea_defaults(&mut self.textarea);
|
|
self.set_input_mode(InputMode::Normal);
|
|
}
|
|
SlashOutcome::Consumed => {
|
|
self.textarea = TextArea::default();
|
|
configure_textarea_defaults(&mut self.textarea);
|
|
self.set_input_mode(InputMode::Normal);
|
|
}
|
|
SlashOutcome::Error => {
|
|
// Restore textarea content so the user can correct the command
|
|
self.sync_buffer_to_textarea();
|
|
}
|
|
}
|
|
}
|
|
(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
|
|
&& 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
|
|
&& 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
|
|
&& 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 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" => {
|
|
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();
|
|
}
|
|
}
|
|
"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" => {
|
|
// List available tools in current mode
|
|
let available_tools: Vec<String> = {
|
|
let config = self.config_async().await;
|
|
vec![
|
|
"web_search".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
|
|
|
|
if available_tools.is_empty() {
|
|
self.status = format!(
|
|
"No tools available in {} mode",
|
|
self.operating_mode
|
|
);
|
|
} else {
|
|
self.status = format!(
|
|
"Available tools in {} mode: {}",
|
|
self.operating_mode,
|
|
available_tools.join(", ")
|
|
);
|
|
}
|
|
}
|
|
"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().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 filter = args.join(" ");
|
|
if self.available_providers.is_empty()
|
|
&& let Err(err) = self.refresh_models().await
|
|
{
|
|
self.error = Some(format!(
|
|
"Failed to refresh providers: {}",
|
|
err
|
|
));
|
|
self.status = "Unable to refresh providers".to_string();
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
"models" => {
|
|
let outcome = if let Some(&"info") = args.first() {
|
|
let force_refresh = args
|
|
.get(1)
|
|
.map(|flag| {
|
|
matches!(*flag, "refresh" | "-r" | "--refresh")
|
|
})
|
|
.unwrap_or(false);
|
|
self.prefetch_all_model_details(force_refresh).await
|
|
} else {
|
|
Err(anyhow!("Usage: :models info [refresh]"))
|
|
};
|
|
|
|
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);
|
|
}
|
|
// "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));
|
|
}
|
|
}
|
|
}
|
|
"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));
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
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::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::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()
|
|
&& 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;
|
|
}
|
|
}
|
|
_ => {}
|
|
},
|
|
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)
|
|
&& 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)
|
|
}
|
|
|
|
/// 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 {
|
|
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::ConsentNeeded {
|
|
tool_name,
|
|
data_types,
|
|
endpoints,
|
|
callback_id,
|
|
} => {
|
|
// Show consent dialog
|
|
self.pending_consent = Some(ConsentDialogState {
|
|
tool_name,
|
|
data_types,
|
|
endpoints,
|
|
callback_id,
|
|
});
|
|
self.status = "Consent required - Press Y to allow, N to deny".to_string();
|
|
}
|
|
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 collect_models_from_all_providers(&self) -> (Vec<ModelInfo>, Vec<String>) {
|
|
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 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;
|
|
}
|
|
|
|
let canonical_name = if name.eq_ignore_ascii_case("ollama-cloud") {
|
|
"ollama".to_string()
|
|
} else {
|
|
name.clone()
|
|
};
|
|
|
|
// 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) => match client.list_models().await {
|
|
Ok(mut provider_models) => {
|
|
for model in &mut provider_models {
|
|
model.provider = canonical_name.clone();
|
|
}
|
|
models.extend(provider_models);
|
|
}
|
|
Err(err) => errors.push(format!("{}: {}", name, err)),
|
|
},
|
|
Err(err) => 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)
|
|
}
|
|
|
|
fn recompute_available_providers(&mut self) {
|
|
let mut providers: BTreeSet<String> =
|
|
self.controller.config().providers.keys().cloned().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".to_string());
|
|
}
|
|
|
|
self.available_providers = providers.into_iter().collect();
|
|
}
|
|
|
|
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_model_selector_items(&mut self) {
|
|
let mut items = Vec::new();
|
|
|
|
if self.available_providers.is_empty() {
|
|
items.push(ModelSelectorItem::header("ollama", false));
|
|
self.model_selector_items = items;
|
|
return;
|
|
}
|
|
|
|
let expanded = self.expanded_provider.clone();
|
|
|
|
for provider in &self.available_providers {
|
|
let is_expanded = expanded.as_ref().map(|p| p == provider).unwrap_or(false);
|
|
items.push(ModelSelectorItem::header(provider.clone(), is_expanded));
|
|
|
|
if is_expanded {
|
|
let relevant: Vec<(usize, &ModelInfo)> = self
|
|
.models
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, model)| &model.provider == provider)
|
|
.collect();
|
|
|
|
let mut best_by_canonical: HashMap<String, (i8, (usize, &ModelInfo))> =
|
|
HashMap::new();
|
|
|
|
let provider_lower = provider.to_ascii_lowercase();
|
|
|
|
for (idx, model) in relevant {
|
|
let canonical = model.id.to_string();
|
|
|
|
let is_cloud_id = model.id.ends_with("-cloud");
|
|
let priority = match provider_lower.as_str() {
|
|
"ollama" | "ollama-cloud" => {
|
|
if is_cloud_id {
|
|
1
|
|
} else {
|
|
2
|
|
}
|
|
}
|
|
_ => 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, &ModelInfo)> = best_by_canonical
|
|
.into_values()
|
|
.map(|entry| entry.1)
|
|
.collect();
|
|
|
|
matches.sort_by(|(_, a), (_, b)| a.id.cmp(&b.id));
|
|
|
|
if matches.is_empty() {
|
|
items.push(ModelSelectorItem::empty(provider.clone()));
|
|
} else {
|
|
for (idx, _) in matches {
|
|
items.push(ModelSelectorItem::model(provider.clone(), idx));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.model_selector_items = items;
|
|
self.ensure_valid_model_selection();
|
|
}
|
|
|
|
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::Model { provider, .. }
|
|
| ModelSelectorItemKind::Empty { provider } => {
|
|
self.selected_provider = provider.clone();
|
|
self.update_selected_provider_index();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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<()> {
|
|
if self.current_provider == provider_name {
|
|
return Ok(());
|
|
}
|
|
|
|
use owlen_core::config::McpServerConfig;
|
|
use std::collections::HashMap;
|
|
|
|
let canonical_name = if provider_name.eq_ignore_ascii_case("ollama-cloud") {
|
|
"ollama"
|
|
} else {
|
|
provider_name
|
|
};
|
|
|
|
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.to_string());
|
|
|
|
let provider: Arc<dyn owlen_core::Provider> = if let Some(path) = server_binary {
|
|
let config = McpServerConfig {
|
|
name: canonical_name.to_string(),
|
|
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 = provider_name.to_string();
|
|
self.model_details_cache.clear();
|
|
self.model_info_panel.clear();
|
|
self.set_model_info_visible(false);
|
|
self.update_command_palette_catalog();
|
|
Ok(())
|
|
}
|
|
|
|
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) = 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.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".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.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".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.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 show_model_picker(&mut self) -> Result<()> {
|
|
self.refresh_models().await?;
|
|
|
|
if self.models.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
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)
|
|
&& 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.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.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))) => {
|
|
self.stop_loading_animation();
|
|
self.status = "Ready".to_string();
|
|
self.error = None;
|
|
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)) => {
|
|
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();
|
|
}
|
|
self.stop_loading_animation();
|
|
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 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(());
|
|
};
|
|
|
|
// 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() {
|
|
// If a consent dialog is already being shown, don't send another request
|
|
// Just re-queue the tool execution and wait for user response
|
|
if self.pending_consent.is_some() {
|
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
|
return Ok(());
|
|
}
|
|
|
|
// Show consent for the first tool that needs it
|
|
// After consent is granted, the next iteration will check remaining tools
|
|
if let Some((tool_name, data_types, endpoints)) = consent_needed.into_iter().next() {
|
|
let callback_id = Uuid::new_v4();
|
|
let sender = self.session_tx.clone();
|
|
let _ = sender.send(SessionEvent::ConsentNeeded {
|
|
tool_name: tool_name.clone(),
|
|
data_types: data_types.clone(),
|
|
endpoints: endpoints.clone(),
|
|
callback_id,
|
|
});
|
|
self.pending_consent = Some(ConsentDialogState {
|
|
tool_name,
|
|
data_types,
|
|
endpoints,
|
|
callback_id,
|
|
});
|
|
// Re-queue the tool execution for after consent is granted
|
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
|
return Ok(());
|
|
} else {
|
|
// No consent entries found; treat as no-op and continue execution.
|
|
self.pending_tool_execution = Some((message_id, tool_calls));
|
|
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(20);
|
|
let card_width = body_width.saturating_add(4);
|
|
let inner_width = 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);
|
|
|
|
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()),
|
|
);
|
|
|
|
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 detect_extended_color_support() -> bool {
|
|
let term = std::env::var("TERM").unwrap_or_default();
|
|
if term.contains("256") || term.contains("direct") || term.contains("truecolor") {
|
|
return true;
|
|
}
|
|
|
|
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
|
colorterm.contains("24bit") || colorterm.contains("truecolor")
|
|
}
|
|
|
|
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) -> Vec<MessageSegment> {
|
|
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 });
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum CommentMarker {
|
|
DoubleSlash,
|
|
Hash,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum CodeState {
|
|
Normal,
|
|
String { delimiter: char, escaped: bool },
|
|
}
|
|
|
|
const RUST_KEYWORDS: &[&str] = &[
|
|
"as", "async", "await", "break", "const", "crate", "dyn", "else", "enum", "extern", "false",
|
|
"fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
|
|
"return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
|
|
"where", "while",
|
|
];
|
|
|
|
const PYTHON_KEYWORDS: &[&str] = &[
|
|
"and", "as", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except",
|
|
"false", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "none",
|
|
"nonlocal", "not", "or", "pass", "raise", "return", "true", "try", "while", "with", "yield",
|
|
];
|
|
|
|
const JS_KEYWORDS: &[&str] = &[
|
|
"async",
|
|
"await",
|
|
"break",
|
|
"case",
|
|
"catch",
|
|
"class",
|
|
"const",
|
|
"continue",
|
|
"debugger",
|
|
"default",
|
|
"delete",
|
|
"do",
|
|
"else",
|
|
"export",
|
|
"extends",
|
|
"finally",
|
|
"for",
|
|
"function",
|
|
"if",
|
|
"import",
|
|
"in",
|
|
"instanceof",
|
|
"let",
|
|
"new",
|
|
"return",
|
|
"switch",
|
|
"this",
|
|
"throw",
|
|
"try",
|
|
"typeof",
|
|
"var",
|
|
"void",
|
|
"while",
|
|
"with",
|
|
"yield",
|
|
];
|
|
|
|
const GO_KEYWORDS: &[&str] = &[
|
|
"break",
|
|
"case",
|
|
"chan",
|
|
"const",
|
|
"continue",
|
|
"default",
|
|
"defer",
|
|
"else",
|
|
"fallthrough",
|
|
"for",
|
|
"func",
|
|
"go",
|
|
"goto",
|
|
"if",
|
|
"import",
|
|
"interface",
|
|
"map",
|
|
"package",
|
|
"range",
|
|
"return",
|
|
"select",
|
|
"struct",
|
|
"switch",
|
|
"type",
|
|
"var",
|
|
];
|
|
|
|
const C_KEYWORDS: &[&str] = &[
|
|
"auto", "break", "case", "char", "const", "continue", "default", "do", "double", "else",
|
|
"enum", "extern", "float", "for", "goto", "if", "inline", "int", "long", "register",
|
|
"restrict", "return", "short", "signed", "sizeof", "static", "struct", "switch", "typedef",
|
|
"union", "unsigned", "void", "volatile", "while",
|
|
];
|
|
|
|
const BASH_KEYWORDS: &[&str] = &[
|
|
"case", "do", "done", "elif", "else", "esac", "fi", "for", "function", "if", "in", "select",
|
|
"then", "until", "while",
|
|
];
|
|
|
|
const JSON_KEYWORDS: &[&str] = &["false", "null", "true"];
|
|
|
|
const YAML_KEYWORDS: &[&str] = &["false", "no", "null", "true", "yes"];
|
|
|
|
const TOML_KEYWORDS: &[&str] = &["false", "inf", "nan", "true"];
|
|
|
|
fn keyword_list(language: &str) -> Option<&'static [&'static str]> {
|
|
match language {
|
|
"rust" | "rs" => Some(RUST_KEYWORDS),
|
|
"python" | "py" => Some(PYTHON_KEYWORDS),
|
|
"javascript" | "js" => Some(JS_KEYWORDS),
|
|
"typescript" | "ts" => Some(JS_KEYWORDS),
|
|
"go" | "golang" => Some(GO_KEYWORDS),
|
|
"c" | "cpp" | "c++" => Some(C_KEYWORDS),
|
|
"bash" | "sh" | "shell" => Some(BASH_KEYWORDS),
|
|
"json" => Some(JSON_KEYWORDS),
|
|
"yaml" | "yml" => Some(YAML_KEYWORDS),
|
|
"toml" => Some(TOML_KEYWORDS),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn comment_marker(language: &str) -> Option<CommentMarker> {
|
|
match language {
|
|
"rust" | "rs" | "javascript" | "js" | "typescript" | "ts" | "go" | "golang" | "c"
|
|
| "cpp" | "c++" | "java" => Some(CommentMarker::DoubleSlash),
|
|
"python" | "py" | "bash" | "sh" | "shell" | "yaml" | "yml" | "toml" => {
|
|
Some(CommentMarker::Hash)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn flush_normal_buffer(
|
|
buffer: &mut String,
|
|
keywords: Option<&[&str]>,
|
|
spans: &mut Vec<Span<'static>>,
|
|
base_style: Style,
|
|
keyword_style: Style,
|
|
) {
|
|
if buffer.is_empty() {
|
|
return;
|
|
}
|
|
|
|
if let Some(keys) = keywords {
|
|
let mut token = String::new();
|
|
|
|
for ch in buffer.chars() {
|
|
if ch.is_alphanumeric() || ch == '_' {
|
|
token.push(ch);
|
|
} else {
|
|
if !token.is_empty() {
|
|
let lower = token.to_ascii_lowercase();
|
|
let style = if keys.binary_search(&lower.as_str()).is_ok() {
|
|
keyword_style
|
|
} else {
|
|
base_style
|
|
};
|
|
spans.push(Span::styled(token.clone(), style));
|
|
token.clear();
|
|
}
|
|
|
|
let mut punct = String::new();
|
|
punct.push(ch);
|
|
spans.push(Span::styled(punct, base_style));
|
|
}
|
|
}
|
|
|
|
if !token.is_empty() {
|
|
let lower = token.to_ascii_lowercase();
|
|
let style = if keys.binary_search(&lower.as_str()).is_ok() {
|
|
keyword_style
|
|
} else {
|
|
base_style
|
|
};
|
|
spans.push(Span::styled(token.clone(), style));
|
|
}
|
|
} else {
|
|
spans.push(Span::styled(buffer.clone(), base_style));
|
|
}
|
|
|
|
buffer.clear();
|
|
}
|
|
|
|
fn highlight_code_spans(
|
|
chunk: &str,
|
|
language: Option<&str>,
|
|
theme: &Theme,
|
|
syntax_highlighting: bool,
|
|
) -> Vec<Span<'static>> {
|
|
let base_style = Style::default()
|
|
.fg(theme.code_block_text)
|
|
.bg(theme.code_block_background);
|
|
|
|
if !syntax_highlighting {
|
|
return vec![Span::styled(chunk.to_string(), base_style)];
|
|
}
|
|
|
|
let normalized = language.map(|lang| lang.trim().to_ascii_lowercase());
|
|
let lang_ref = normalized.as_deref();
|
|
let keywords = lang_ref.and_then(keyword_list);
|
|
let comment = lang_ref.and_then(comment_marker);
|
|
|
|
let keyword_style = Style::default()
|
|
.fg(theme.code_block_keyword)
|
|
.bg(theme.code_block_background)
|
|
.add_modifier(Modifier::BOLD);
|
|
let string_style = Style::default()
|
|
.fg(theme.code_block_string)
|
|
.bg(theme.code_block_background);
|
|
let comment_style = Style::default()
|
|
.fg(theme.code_block_comment)
|
|
.bg(theme.code_block_background)
|
|
.add_modifier(Modifier::ITALIC);
|
|
|
|
let mut spans = Vec::new();
|
|
let mut buffer = String::new();
|
|
let chars: Vec<char> = chunk.chars().collect();
|
|
let mut idx = 0;
|
|
let mut state = CodeState::Normal;
|
|
|
|
while idx < chars.len() {
|
|
match state {
|
|
CodeState::Normal => {
|
|
if let Some(marker) = comment {
|
|
let is_comment = match marker {
|
|
CommentMarker::DoubleSlash => {
|
|
chars[idx] == '/' && idx + 1 < chars.len() && chars[idx + 1] == '/'
|
|
}
|
|
CommentMarker::Hash => chars[idx] == '#',
|
|
};
|
|
|
|
if is_comment {
|
|
flush_normal_buffer(
|
|
&mut buffer,
|
|
keywords,
|
|
&mut spans,
|
|
base_style,
|
|
keyword_style,
|
|
);
|
|
let comment_text: String = chars[idx..].iter().collect();
|
|
spans.push(Span::styled(comment_text, comment_style));
|
|
return spans;
|
|
}
|
|
}
|
|
|
|
let ch = chars[idx];
|
|
if ch == '"' || ch == '\'' {
|
|
flush_normal_buffer(
|
|
&mut buffer,
|
|
keywords,
|
|
&mut spans,
|
|
base_style,
|
|
keyword_style,
|
|
);
|
|
buffer.push(ch);
|
|
state = CodeState::String {
|
|
delimiter: ch,
|
|
escaped: false,
|
|
};
|
|
} else {
|
|
buffer.push(ch);
|
|
}
|
|
idx += 1;
|
|
}
|
|
CodeState::String { delimiter, escaped } => {
|
|
let ch = chars[idx];
|
|
buffer.push(ch);
|
|
|
|
let mut next_state = CodeState::String {
|
|
delimiter,
|
|
escaped: false,
|
|
};
|
|
|
|
if escaped {
|
|
next_state = CodeState::String {
|
|
delimiter,
|
|
escaped: false,
|
|
};
|
|
} else if ch == '\\' {
|
|
next_state = CodeState::String {
|
|
delimiter,
|
|
escaped: true,
|
|
};
|
|
} else if ch == delimiter {
|
|
spans.push(Span::styled(buffer.clone(), string_style));
|
|
buffer.clear();
|
|
next_state = CodeState::Normal;
|
|
}
|
|
|
|
state = next_state;
|
|
idx += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
match state {
|
|
CodeState::String { .. } => {
|
|
spans.push(Span::styled(buffer.clone(), string_style));
|
|
}
|
|
CodeState::Normal => {
|
|
flush_normal_buffer(&mut buffer, keywords, &mut spans, base_style, keyword_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));
|
|
|
|
if code_lines.is_empty() {
|
|
let chunks = wrap_code("", code_width);
|
|
for chunk in chunks {
|
|
let mut spans = Vec::new();
|
|
spans.push(Span::styled(indent.to_string(), border_style));
|
|
spans.push(Span::styled("│", border_style));
|
|
|
|
let mut code_spans = highlight_code_spans(&chunk, language, theme, syntax_highlighting);
|
|
spans.append(&mut code_spans);
|
|
|
|
let display_width = UnicodeWidthStr::width(chunk.as_str());
|
|
if display_width < code_width {
|
|
spans.push(Span::styled(
|
|
" ".repeat(code_width - display_width),
|
|
text_style,
|
|
));
|
|
}
|
|
|
|
spans.push(Span::styled("│", border_style));
|
|
rendered.push(Line::from(spans));
|
|
*indicator_target = Some(rendered.len() - 1);
|
|
}
|
|
} else {
|
|
for line in code_lines {
|
|
let chunks = wrap_code(line.as_str(), code_width);
|
|
for chunk in chunks {
|
|
let mut spans = Vec::new();
|
|
spans.push(Span::styled(indent.to_string(), border_style));
|
|
spans.push(Span::styled("│", border_style));
|
|
|
|
let mut code_spans =
|
|
highlight_code_spans(&chunk, language, theme, syntax_highlighting);
|
|
spans.append(&mut code_spans);
|
|
|
|
let display_width = UnicodeWidthStr::width(chunk.as_str());
|
|
if display_width < code_width {
|
|
spans.push(Span::styled(
|
|
" ".repeat(code_width - display_width),
|
|
text_style,
|
|
));
|
|
}
|
|
|
|
spans.push(Span::styled("│", border_style));
|
|
rendered.push(Line::from(spans));
|
|
*indicator_target = Some(rendered.len() - 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::wrap_unicode;
|
|
|
|
#[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());
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|