Files
owlen/crates/owlen-tui/src/chat_app.rs
vikingowl 3e4eacd1d3 feat(tui): add Ctrl+←/→ shortcuts to resize files panel
- 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.
2025-10-13 22:14:19 +02:00

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(&current).cloned()
{
self.model_info_panel.set_model_info(updated);
}
let total = self.model_details_cache.len();
self.status = format!("Cached model details for {} model(s)", total);
self.error = None;
Ok(())
}
Err(err) => {
self.error = Some(format!("Failed to prefetch model info: {}", err));
Err(err.into())
}
}
}
pub fn auto_scroll(&self) -> &AutoScroll {
&self.auto_scroll
}
pub fn auto_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.auto_scroll
}
pub fn scroll(&self) -> usize {
self.auto_scroll.scroll
}
pub fn thinking_scroll(&self) -> &AutoScroll {
&self.thinking_scroll
}
pub fn thinking_scroll_mut(&mut self) -> &mut AutoScroll {
&mut self.thinking_scroll
}
pub fn thinking_scroll_position(&self) -> usize {
self.thinking_scroll.scroll
}
pub fn message_count(&self) -> usize {
self.controller.conversation().messages.len()
}
pub fn streaming_count(&self) -> usize {
self.streaming.len()
}
pub fn formatter(&self) -> &owlen_core::formatting::MessageFormatter {
self.controller.formatter()
}
pub fn input_buffer(&self) -> &owlen_core::input::InputBuffer {
self.controller.input_buffer()
}
pub fn input_buffer_mut(&mut self) -> &mut owlen_core::input::InputBuffer {
self.controller.input_buffer_mut()
}
pub fn textarea(&self) -> &TextArea<'static> {
&self.textarea
}
pub fn textarea_mut(&mut self) -> &mut TextArea<'static> {
&mut self.textarea
}
pub fn system_status(&self) -> &str {
&self.system_status
}
pub fn set_system_status(&mut self, status: String) {
self.system_status = status;
}
pub fn append_system_status(&mut self, status: &str) {
if !self.system_status.is_empty() {
self.system_status.push_str(" | ");
}
self.system_status.push_str(status);
}
pub fn clear_system_status(&mut self) {
self.system_status.clear();
}
pub fn show_tutorial(&mut self) {
self.error = None;
self.status = TUTORIAL_STATUS.to_string();
self.system_status = TUTORIAL_SYSTEM_STATUS.to_string();
let tutorial_body = concat!(
"Keybindings overview:\n",
" • Movement: h/j/k/l, gg/G, w/b\n",
" • Insert text: i or a (Esc to exit)\n",
" • Visual select: v (Esc to exit)\n",
" • Command mode: : (press Enter to run, Esc to cancel)\n",
" • Send message: Enter in Insert mode\n",
" • Help overlay: F1 or ?\n"
);
self.controller
.conversation_mut()
.push_system_message(tutorial_body.to_string());
}
pub fn command_buffer(&self) -> &str {
self.command_palette.buffer()
}
pub fn command_suggestions(&self) -> &[PaletteSuggestion] {
self.command_palette.suggestions()
}
fn set_input_mode(&mut self, mode: InputMode) {
if self.mode != mode {
self.mode_flash_until = Some(Instant::now() + Duration::from_millis(240));
}
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(&current_model_name)
|| config_model_provider != current_model_provider
{
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
if !errors.is_empty() {
self.error = Some(errors.join("; "));
}
self.update_command_palette_catalog();
Ok(())
}
pub async fn handle_event(&mut self, event: Event) -> Result<AppState> {
use crossterm::event::{KeyCode, KeyModifiers};
if let Some(last) = self.last_ctrl_c
&& last.elapsed() > DOUBLE_CTRL_C_WINDOW
{
self.last_ctrl_c = None;
}
match event {
Event::Tick => {
self.poll_repo_search();
self.poll_symbol_search();
self.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(&current_model_name)
|| config_model_provider != current_model_provider
{
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
if !errors.is_empty() {
self.error = Some(errors.join("; "));
} else {
self.error = None;
}
self.status = format!(
"Loaded {} models across {} provider(s)",
self.models.len(),
self.available_providers.len()
);
self.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(&current_model_id) {
self.set_selected_model_item(idx);
} else {
if let Some(idx) = self.index_of_first_model_for_provider(&self.selected_provider) {
self.set_selected_model_item(idx);
} else if let Some(idx) = self.index_of_header(&self.selected_provider) {
self.set_selected_model_item(idx);
} else if let Some(idx) = self.first_model_item_index() {
self.set_selected_model_item(idx);
} else {
self.ensure_valid_model_selection();
}
if let Some(model) = self.selected_model_info().cloned() {
self.selected_provider = model.provider.clone();
// Set the selected model asynchronously
self.controller.set_model(model.id.clone()).await;
self.controller.config_mut().general.default_model = Some(model.id.clone());
self.controller.config_mut().general.default_provider =
self.selected_provider.clone();
config_updated = true;
}
}
self.update_selected_provider_index();
if config_updated {
if let Err(err) = config::save_config(&self.controller.config()) {
self.error = Some(format!("Failed to save config: {err}"));
} else {
self.error = None;
}
}
}
pub fn set_viewport_dimensions(&mut self, height: usize, content_width: usize) {
self.viewport_height = height;
self.content_width = content_width;
}
pub fn set_thinking_viewport_height(&mut self, height: usize) {
self.thinking_viewport_height = height;
}
pub fn start_loading_animation(&mut self) {
self.is_loading = true;
self.loading_animation_frame = 0;
}
pub fn stop_loading_animation(&mut self) {
self.is_loading = false;
}
pub fn advance_loading_animation(&mut self) {
if self.is_loading {
self.loading_animation_frame = (self.loading_animation_frame + 1) % 8;
// 8-frame animation
}
}
pub fn get_loading_indicator(&self) -> &'static str {
if !self.is_loading {
return "";
}
match self.loading_animation_frame {
0 => "",
1 => "",
2 => "",
3 => "",
4 => "",
5 => "",
6 => "",
7 => "",
_ => "",
}
}
pub fn current_thinking(&self) -> Option<&String> {
self.current_thinking.as_ref()
}
/// Get a reference to the latest agent actions, if any.
pub fn agent_actions(&self) -> Option<&String> {
self.agent_actions.as_ref()
}
/// Set the current agent actions content.
pub fn set_agent_actions(&mut self, actions: String) {
self.agent_actions = Some(actions);
}
/// Check if agent mode is enabled
pub fn is_agent_mode(&self) -> bool {
self.agent_mode
}
/// Check if agent is currently running
pub fn is_agent_running(&self) -> bool {
self.agent_running
}
pub fn get_rendered_lines(&self) -> Vec<String> {
match self.focused_panel {
FocusedPanel::Chat => {
let conversation = self.conversation();
let mut formatter = self.formatter().clone();
let body_width = self.content_width.max(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());
}