Skip to main content

pi/
interactive.rs

1//! Interactive TUI mode using charmed_rust (bubbletea/lipgloss/bubbles/glamour).
2//!
3//! This module provides the full interactive terminal interface for Pi,
4//! implementing the Elm Architecture for state management.
5//!
6//! ## Features
7//!
8//! - **Multi-line editor**: Full text area with line wrapping and history
9//! - **Viewport scrolling**: Scrollable conversation history with keyboard navigation
10//! - **Slash commands**: Built-in commands like /help, /clear, /model, /exit
11//! - **Token tracking**: Real-time cost and token usage display
12//! - **Markdown rendering**: Assistant responses rendered with syntax highlighting
13
14use asupersync::Cx;
15use asupersync::channel::mpsc;
16use asupersync::runtime::RuntimeHandle;
17use asupersync::sync::Mutex;
18use async_trait::async_trait;
19use bubbles::spinner::{SpinnerModel, TickMsg as SpinnerTickMsg, spinners};
20use bubbles::textarea::TextArea;
21use bubbles::viewport::Viewport;
22use bubbletea::{
23    Cmd, KeyMsg, KeyType, Message, Model as BubbleteaModel, Program, WindowSizeMsg, batch, quit,
24};
25use chrono::Utc;
26use crossterm::{cursor, terminal};
27use futures::future::BoxFuture;
28use glamour::StyleConfig as GlamourStyleConfig;
29use glob::Pattern;
30use serde_json::{Value, json};
31
32use std::collections::{HashMap, VecDeque};
33use std::fmt::Write as _;
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::sync::Mutex as StdMutex;
37use std::sync::atomic::{AtomicBool, Ordering};
38
39use crate::agent::{AbortHandle, Agent, AgentEvent, QueueMode};
40use crate::autocomplete::{AutocompleteCatalog, AutocompleteItem, AutocompleteItemKind};
41use crate::config::{Config, SettingsScope, parse_queue_mode_or_default};
42use crate::extension_events::{InputEventOutcome, apply_input_event_response};
43use crate::extensions::{
44    EXTENSION_EVENT_TIMEOUT_MS, ExtensionDeliverAs, ExtensionEventName, ExtensionHostActions,
45    ExtensionManager, ExtensionSendMessage, ExtensionSendUserMessage, ExtensionSession,
46    ExtensionUiRequest, ExtensionUiResponse,
47};
48use crate::keybindings::{AppAction, KeyBinding, KeyBindings};
49use crate::model::{
50    AssistantMessageEvent, ContentBlock, CustomMessage, ImageContent, Message as ModelMessage,
51    StopReason, TextContent, ThinkingLevel, Usage, UserContent, UserMessage,
52};
53use crate::models::{ModelEntry, ModelRegistry, default_models_path};
54use crate::package_manager::PackageManager;
55use crate::providers;
56use crate::resources::{DiagnosticKind, ResourceCliOptions, ResourceDiagnostic, ResourceLoader};
57use crate::session::{Session, SessionEntry, SessionMessage, bash_execution_to_text};
58use crate::theme::{Theme, TuiStyles};
59use crate::tools::{process_file_arguments, resolve_read_path};
60
61#[cfg(all(feature = "clipboard", feature = "image-resize"))]
62use arboard::Clipboard as ArboardClipboard;
63
64mod agent;
65mod commands;
66mod conversation;
67mod ext_session;
68mod file_refs;
69mod keybindings;
70mod model_selector_ui;
71mod perf;
72mod share;
73mod state;
74mod text_utils;
75mod tool_render;
76mod tree;
77mod tree_ui;
78mod view;
79
80use self::agent::{build_user_message, extension_commands_for_catalog};
81pub use self::commands::{
82    SlashCommand, model_entry_matches, parse_scoped_model_patterns, resolve_scoped_model_entries,
83    strip_thinking_level_suffix,
84};
85#[cfg(test)]
86use self::commands::{
87    api_key_login_prompt, format_login_provider_listing, format_resource_diagnostics, kind_rank,
88    normalize_api_key_input, normalize_auth_provider_input, remove_provider_credentials,
89    save_provider_credential,
90};
91use self::commands::{
92    format_startup_oauth_hint, parse_bash_command, parse_extension_command,
93    should_show_startup_oauth_hint,
94};
95use self::conversation::conversation_from_session;
96#[cfg(test)]
97use self::conversation::{
98    assistant_content_to_text, build_content_blocks_for_input, content_blocks_to_text,
99    split_content_blocks_for_input, tool_content_blocks_to_text, user_content_to_text,
100};
101use self::ext_session::{InteractiveExtensionHostActions, InteractiveExtensionSession};
102pub use self::ext_session::{format_extension_ui_prompt, parse_extension_ui_response};
103use self::file_refs::{
104    file_url_to_path, format_file_ref, is_file_ref_boundary, next_non_whitespace_token,
105    parse_quoted_file_ref, path_for_display, split_trailing_punct, strip_wrapping_quotes,
106    unescape_dragged_path,
107};
108use self::perf::{
109    CRITICAL_KEEP_MESSAGES, FrameTimingStats, MemoryLevel, MemoryMonitor, MessageRenderCache,
110    RenderBuffers, micros_as_u64,
111};
112#[cfg(test)]
113use self::state::TOOL_AUTO_COLLAPSE_THRESHOLD;
114pub use self::state::{AgentState, InputMode, PendingInput};
115use self::state::{
116    AutocompleteState, BranchPickerOverlay, CapabilityAction, CapabilityPromptOverlay, HistoryList,
117    InjectedMessageQueue, InteractiveMessageQueue, PendingLoginKind, PendingOAuth,
118    QueuedMessageKind, SessionPickerOverlay, SettingsUiEntry, SettingsUiState,
119    TOOL_COLLAPSE_PREVIEW_LINES, ThemePickerItem, ThemePickerOverlay, ToolProgress, format_count,
120};
121pub use self::state::{ConversationMessage, MessageRole};
122#[cfg(test)]
123use self::text_utils::push_line;
124use self::text_utils::{queued_message_preview, truncate};
125use self::tool_render::{format_tool_output, render_tool_message};
126#[cfg(test)]
127use self::tool_render::{pretty_json, split_diff_prefix};
128use self::tree::{
129    PendingTreeNavigation, TreeCustomPromptState, TreeSelectorState, TreeSummaryChoice,
130    TreeSummaryPromptState, TreeUiState, collect_tree_branch_entries,
131    resolve_tree_selector_initial_id, view_tree_ui,
132};
133
134// ============================================================================
135// Slash Commands
136// ============================================================================
137
138impl PiApp {
139    /// Returns true when the viewport is currently anchored to the tail of the
140    /// conversation content (i.e. the user has not scrolled away from the bottom).
141    fn is_at_bottom(&self) -> bool {
142        let content = self.build_conversation_content();
143        let trimmed = content.trim_end();
144        let line_count = trimmed.lines().count();
145        let visible_rows = self.view_effective_conversation_height().max(1);
146        if line_count <= visible_rows {
147            return true;
148        }
149        let max_offset = line_count.saturating_sub(visible_rows);
150        self.conversation_viewport.y_offset() >= max_offset
151    }
152
153    /// Rebuild viewport content after conversation state changes.
154    /// If `follow_tail` is true the viewport is scrolled to the very bottom;
155    /// otherwise the current scroll position is preserved.
156    fn refresh_conversation_viewport(&mut self, follow_tail: bool) {
157        let vp_start = if self.frame_timing.enabled {
158            Some(std::time::Instant::now())
159        } else {
160            None
161        };
162
163        // Capture scroll state before update if we aren't following tail.
164        // We want to maintain the distance from the bottom to keep the view stable
165        // if content is appended while the user is scrolled up.
166        let dist_from_bottom = if follow_tail {
167            None
168        } else {
169            let current_content_height = self.conversation_viewport.total_line_count();
170            let current_y_offset = self.conversation_viewport.y_offset();
171            Some(current_content_height.saturating_sub(current_y_offset))
172        };
173
174        let content = self.build_conversation_content();
175        let trimmed = content.trim_end();
176        let effective = self.view_effective_conversation_height().max(1);
177        self.conversation_viewport.height = effective;
178        self.conversation_viewport.set_content(trimmed);
179
180        if follow_tail {
181            self.conversation_viewport.goto_bottom();
182            self.follow_stream_tail = true;
183        } else if let Some(dist) = dist_from_bottom {
184            // Restore scroll position relative to the new bottom.
185            let new_content_height = trimmed.lines().count();
186            let new_y_offset = new_content_height.saturating_sub(dist);
187            self.conversation_viewport.set_y_offset(new_y_offset);
188        }
189
190        if let Some(start) = vp_start {
191            self.frame_timing
192                .record_viewport_sync(micros_as_u64(start.elapsed().as_micros()));
193        }
194    }
195
196    /// Scroll the conversation viewport to the bottom.
197    fn scroll_to_bottom(&mut self) {
198        self.refresh_conversation_viewport(true);
199    }
200
201    fn scroll_to_last_match(&mut self, needle: &str) {
202        let content = self.build_conversation_content();
203        let trimmed = content.trim_end();
204        let effective = self.view_effective_conversation_height().max(1);
205        self.conversation_viewport.height = effective;
206        self.conversation_viewport.set_content(trimmed);
207
208        let mut last_index = None;
209        for (idx, line) in trimmed.lines().enumerate() {
210            if line.contains(needle) {
211                last_index = Some(idx);
212            }
213        }
214
215        if let Some(idx) = last_index {
216            self.conversation_viewport.set_y_offset(idx);
217            self.follow_stream_tail = false;
218        } else {
219            self.conversation_viewport.goto_bottom();
220            self.follow_stream_tail = true;
221        }
222    }
223
224    fn apply_theme(&mut self, theme: Theme) {
225        self.theme = theme;
226        self.styles = self.theme.tui_styles();
227        self.markdown_style = self.theme.glamour_style_config();
228        if let Some(indent) = self
229            .config
230            .markdown
231            .as_ref()
232            .and_then(|m| m.code_block_indent)
233        {
234            self.markdown_style.code_block.block.margin = Some(indent as usize);
235        }
236        self.spinner =
237            SpinnerModel::with_spinner(spinners::dot()).style(self.styles.accent.clone());
238
239        self.message_render_cache.invalidate_all();
240        let content = self.build_conversation_content();
241        let effective = self.view_effective_conversation_height().max(1);
242        self.conversation_viewport.height = effective;
243        self.conversation_viewport.set_content(content.trim_end());
244    }
245
246    fn persist_project_theme(&self, theme_name: &str) -> crate::error::Result<()> {
247        let settings_path = self.cwd.join(Config::project_dir()).join("settings.json");
248        let mut settings = if settings_path.exists() {
249            let content = std::fs::read_to_string(&settings_path)?;
250            serde_json::from_str::<Value>(&content)?
251        } else {
252            json!({})
253        };
254
255        let obj = settings.as_object_mut().ok_or_else(|| {
256            crate::error::Error::config(format!(
257                "Settings file is not a JSON object: {}",
258                settings_path.display()
259            ))
260        })?;
261        obj.insert("theme".to_string(), Value::String(theme_name.to_string()));
262
263        if let Some(parent) = settings_path.parent() {
264            std::fs::create_dir_all(parent)?;
265        }
266        std::fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?;
267        Ok(())
268    }
269
270    fn apply_queue_modes(&self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
271        if let Ok(mut queue) = self.message_queue.lock() {
272            queue.set_modes(steering_mode, follow_up_mode);
273        }
274
275        if let Ok(mut agent_guard) = self.agent.try_lock() {
276            agent_guard.set_queue_modes(steering_mode, follow_up_mode);
277            return;
278        }
279
280        let agent = Arc::clone(&self.agent);
281        let runtime_handle = self.runtime_handle.clone();
282        runtime_handle.spawn(async move {
283            let cx = Cx::for_request();
284            if let Ok(mut agent_guard) = agent.lock(&cx).await {
285                agent_guard.set_queue_modes(steering_mode, follow_up_mode);
286            }
287        });
288    }
289
290    fn toggle_queue_mode_setting(&mut self, entry: SettingsUiEntry) {
291        let (key, current) = match entry {
292            SettingsUiEntry::SteeringMode => ("steeringMode", self.config.steering_queue_mode()),
293            SettingsUiEntry::FollowUpMode => ("followUpMode", self.config.follow_up_queue_mode()),
294            _ => return,
295        };
296
297        let next = match current {
298            QueueMode::All => QueueMode::OneAtATime,
299            QueueMode::OneAtATime => QueueMode::All,
300        };
301
302        let patch = match entry {
303            SettingsUiEntry::SteeringMode => json!({ "steeringMode": next.as_str() }),
304            SettingsUiEntry::FollowUpMode => json!({ "followUpMode": next.as_str() }),
305            _ => json!({}),
306        };
307
308        let global_dir = Config::global_dir();
309        if let Err(err) =
310            Config::patch_settings_with_roots(SettingsScope::Project, &global_dir, &self.cwd, patch)
311        {
312            self.status_message = Some(format!("Failed to update {key}: {err}"));
313            return;
314        }
315
316        match entry {
317            SettingsUiEntry::SteeringMode => {
318                self.config.steering_mode = Some(next.as_str().to_string());
319            }
320            SettingsUiEntry::FollowUpMode => {
321                self.config.follow_up_mode = Some(next.as_str().to_string());
322            }
323            _ => {}
324        }
325
326        let steering_mode = self.config.steering_queue_mode();
327        let follow_up_mode = self.config.follow_up_queue_mode();
328        self.apply_queue_modes(steering_mode, follow_up_mode);
329        self.status_message = Some(format!("Updated {key}: {}", next.as_str()));
330    }
331
332    fn persist_project_settings_patch(&mut self, key: &str, patch: Value) -> bool {
333        let global_dir = Config::global_dir();
334        if let Err(err) =
335            Config::patch_settings_with_roots(SettingsScope::Project, &global_dir, &self.cwd, patch)
336        {
337            self.status_message = Some(format!("Failed to update {key}: {err}"));
338            return false;
339        }
340        true
341    }
342
343    fn effective_show_hardware_cursor(&self) -> bool {
344        self.config.show_hardware_cursor.unwrap_or_else(|| {
345            std::env::var("PI_HARDWARE_CURSOR")
346                .ok()
347                .is_some_and(|val| val == "1")
348        })
349    }
350
351    fn apply_hardware_cursor(show: bool) {
352        let mut stdout = std::io::stdout();
353        if show {
354            let _ = crossterm::execute!(stdout, cursor::Show);
355        } else {
356            let _ = crossterm::execute!(stdout, cursor::Hide);
357        }
358    }
359
360    #[allow(clippy::too_many_lines)]
361    fn toggle_settings_entry(&mut self, entry: SettingsUiEntry) {
362        match entry {
363            SettingsUiEntry::SteeringMode | SettingsUiEntry::FollowUpMode => {
364                self.toggle_queue_mode_setting(entry);
365            }
366            SettingsUiEntry::QuietStartup => {
367                let next = !self.config.quiet_startup.unwrap_or(false);
368                if self.persist_project_settings_patch(
369                    "quietStartup",
370                    json!({ "quiet_startup": next }),
371                ) {
372                    self.config.quiet_startup = Some(next);
373                    self.status_message =
374                        Some(format!("Updated quietStartup: {}", bool_label(next)));
375                }
376            }
377            SettingsUiEntry::CollapseChangelog => {
378                let next = !self.config.collapse_changelog.unwrap_or(false);
379                if self.persist_project_settings_patch(
380                    "collapseChangelog",
381                    json!({ "collapse_changelog": next }),
382                ) {
383                    self.config.collapse_changelog = Some(next);
384                    self.status_message =
385                        Some(format!("Updated collapseChangelog: {}", bool_label(next)));
386                }
387            }
388            SettingsUiEntry::HideThinkingBlock => {
389                let next = !self.config.hide_thinking_block.unwrap_or(false);
390                if self.persist_project_settings_patch(
391                    "hideThinkingBlock",
392                    json!({ "hide_thinking_block": next }),
393                ) {
394                    self.config.hide_thinking_block = Some(next);
395                    self.thinking_visible = !next;
396                    self.message_render_cache.invalidate_all();
397                    self.scroll_to_bottom();
398                    self.status_message =
399                        Some(format!("Updated hideThinkingBlock: {}", bool_label(next)));
400                }
401            }
402            SettingsUiEntry::ShowHardwareCursor => {
403                let next = !self.effective_show_hardware_cursor();
404                if self.persist_project_settings_patch(
405                    "showHardwareCursor",
406                    json!({ "show_hardware_cursor": next }),
407                ) {
408                    self.config.show_hardware_cursor = Some(next);
409                    Self::apply_hardware_cursor(next);
410                    self.status_message =
411                        Some(format!("Updated showHardwareCursor: {}", bool_label(next)));
412                }
413            }
414            SettingsUiEntry::DoubleEscapeAction => {
415                let current = self
416                    .config
417                    .double_escape_action
418                    .as_deref()
419                    .unwrap_or("tree");
420                let next = if current.eq_ignore_ascii_case("tree") {
421                    "fork"
422                } else {
423                    "tree"
424                };
425                if self.persist_project_settings_patch(
426                    "doubleEscapeAction",
427                    json!({ "double_escape_action": next }),
428                ) {
429                    self.config.double_escape_action = Some(next.to_string());
430                    self.status_message = Some(format!("Updated doubleEscapeAction: {next}"));
431                }
432            }
433            SettingsUiEntry::EditorPaddingX => {
434                let current = self.editor_padding_x.min(3);
435                let next = match current {
436                    0 => 1,
437                    1 => 2,
438                    2 => 3,
439                    _ => 0,
440                };
441                if self.persist_project_settings_patch(
442                    "editorPaddingX",
443                    json!({ "editor_padding_x": next }),
444                ) {
445                    self.config.editor_padding_x = u32::try_from(next).ok();
446                    self.editor_padding_x = next;
447                    self.input
448                        .set_width(self.term_width.saturating_sub(4 + self.editor_padding_x));
449                    self.scroll_to_bottom();
450                    self.status_message = Some(format!("Updated editorPaddingX: {next}"));
451                }
452            }
453            SettingsUiEntry::AutocompleteMaxVisible => {
454                let cycle = [3usize, 5, 8, 10, 12, 15, 20];
455                let current = self.autocomplete.max_visible;
456                let next = cycle
457                    .iter()
458                    .position(|value| *value == current)
459                    .map_or(cycle[0], |idx| cycle[(idx + 1) % cycle.len()]);
460                if self.persist_project_settings_patch(
461                    "autocompleteMaxVisible",
462                    json!({ "autocomplete_max_visible": next }),
463                ) {
464                    self.config.autocomplete_max_visible = u32::try_from(next).ok();
465                    self.autocomplete.max_visible = next;
466                    self.status_message = Some(format!("Updated autocompleteMaxVisible: {next}"));
467                }
468            }
469            SettingsUiEntry::Theme => {
470                self.settings_ui = None;
471                self.theme_picker = Some(ThemePickerOverlay::new(&self.cwd));
472            }
473            SettingsUiEntry::Summary => {}
474        }
475    }
476
477    // ========================================================================
478    // Memory pressure actions (PERF-6)
479    // ========================================================================
480
481    /// Run memory pressure actions: progressive collapse (Pressure) and
482    /// conversation truncation (Critical). Called from update_inner().
483    fn run_memory_pressure_actions(&mut self) {
484        let level = self.memory_monitor.level;
485
486        // Progressive collapse: one tool output per second, oldest first.
487        if self.memory_monitor.collapsing
488            && self.memory_monitor.last_collapse.elapsed() >= std::time::Duration::from_secs(1)
489        {
490            if let Some(idx) = self.find_next_uncollapsed_tool_output() {
491                self.messages[idx].collapsed = true;
492                let placeholder = "[tool output collapsed due to memory pressure]".to_string();
493                self.messages[idx].content = placeholder;
494                self.messages[idx].thinking = None;
495                self.memory_monitor.next_collapse_index = idx + 1;
496                self.memory_monitor.last_collapse = std::time::Instant::now();
497                self.memory_monitor.resample_now();
498            } else {
499                self.memory_monitor.collapsing = false;
500            }
501        }
502
503        // Pressure level: remove thinking from messages older than last 10 turns.
504        if level == MemoryLevel::Pressure || level == MemoryLevel::Critical {
505            let msg_count = self.messages.len();
506            if msg_count > 10 {
507                for msg in &mut self.messages[..msg_count - 10] {
508                    if msg.thinking.is_some() {
509                        msg.thinking = None;
510                    }
511                }
512            }
513        }
514
515        // Critical: truncate old messages (keep last CRITICAL_KEEP_MESSAGES).
516        if level == MemoryLevel::Critical && !self.memory_monitor.truncated {
517            let msg_count = self.messages.len();
518            if msg_count > CRITICAL_KEEP_MESSAGES {
519                let remove_count = msg_count - CRITICAL_KEEP_MESSAGES;
520                self.messages.drain(..remove_count);
521                self.messages.insert(
522                    0,
523                    ConversationMessage::new(
524                        MessageRole::System,
525                        "[conversation history truncated due to memory pressure — see session file for full history]".to_string(),
526                        None,
527                    ),
528                );
529                self.memory_monitor.next_collapse_index = 0;
530                self.message_render_cache.clear();
531            }
532            self.memory_monitor.truncated = true;
533            self.memory_monitor.resample_now();
534        }
535    }
536
537    /// Find the next uncollapsed Tool message starting from `next_collapse_index`.
538    fn find_next_uncollapsed_tool_output(&self) -> Option<usize> {
539        let start = self.memory_monitor.next_collapse_index;
540        (start..self.messages.len())
541            .find(|&i| self.messages[i].role == MessageRole::Tool && !self.messages[i].collapsed)
542    }
543
544    fn format_settings_summary(&self) -> String {
545        let theme_setting = self
546            .config
547            .theme
548            .as_deref()
549            .unwrap_or("")
550            .trim()
551            .to_string();
552        let theme_setting = if theme_setting.is_empty() {
553            "(default)".to_string()
554        } else {
555            theme_setting
556        };
557
558        let compaction_enabled = self.config.compaction_enabled();
559        let reserve_tokens = self.config.compaction_reserve_tokens();
560        let keep_recent = self.config.compaction_keep_recent_tokens();
561        let steering = self.config.steering_queue_mode();
562        let follow_up = self.config.follow_up_queue_mode();
563        let quiet_startup = self.config.quiet_startup.unwrap_or(false);
564        let collapse_changelog = self.config.collapse_changelog.unwrap_or(false);
565        let hide_thinking_block = self.config.hide_thinking_block.unwrap_or(false);
566        let show_hardware_cursor = self.effective_show_hardware_cursor();
567        let double_escape_action = self
568            .config
569            .double_escape_action
570            .as_deref()
571            .unwrap_or("tree");
572
573        let mut output = String::new();
574        let _ = writeln!(output, "Settings:");
575        let _ = writeln!(
576            output,
577            "  theme: {} (config: {})",
578            self.theme.name, theme_setting
579        );
580        let _ = writeln!(output, "  model: {}", self.model);
581        let _ = writeln!(
582            output,
583            "  compaction: {compaction_enabled} (reserve={reserve_tokens}, keepRecent={keep_recent})"
584        );
585        let _ = writeln!(output, "  steeringMode: {}", steering.as_str());
586        let _ = writeln!(output, "  followUpMode: {}", follow_up.as_str());
587        let _ = writeln!(output, "  quietStartup: {}", bool_label(quiet_startup));
588        let _ = writeln!(
589            output,
590            "  collapseChangelog: {}",
591            bool_label(collapse_changelog)
592        );
593        let _ = writeln!(
594            output,
595            "  hideThinkingBlock: {}",
596            bool_label(hide_thinking_block)
597        );
598        let _ = writeln!(
599            output,
600            "  showHardwareCursor: {}",
601            bool_label(show_hardware_cursor)
602        );
603        let _ = writeln!(output, "  doubleEscapeAction: {double_escape_action}");
604        let _ = writeln!(output, "  editorPaddingX: {}", self.editor_padding_x);
605        let _ = writeln!(
606            output,
607            "  autocompleteMaxVisible: {}",
608            self.autocomplete.max_visible
609        );
610        let _ = writeln!(
611            output,
612            "  skillCommands: {}",
613            if self.config.enable_skill_commands() {
614                "enabled"
615            } else {
616                "disabled"
617            }
618        );
619
620        let _ = writeln!(output, "\nResources:");
621        let _ = writeln!(output, "  skills: {}", self.resources.skills().len());
622        let _ = writeln!(output, "  prompts: {}", self.resources.prompts().len());
623        let _ = writeln!(output, "  themes: {}", self.resources.themes().len());
624
625        let skill_diags = self.resources.skill_diagnostics().len();
626        let prompt_diags = self.resources.prompt_diagnostics().len();
627        let theme_diags = self.resources.theme_diagnostics().len();
628        if skill_diags + prompt_diags + theme_diags > 0 {
629            let _ = writeln!(output, "\nDiagnostics:");
630            let _ = writeln!(output, "  skills: {skill_diags}");
631            let _ = writeln!(output, "  prompts: {prompt_diags}");
632            let _ = writeln!(output, "  themes: {theme_diags}");
633        }
634
635        output
636    }
637
638    fn default_export_path(&self, session: &Session) -> PathBuf {
639        if let Some(path) = session.path.as_ref() {
640            let stem = path
641                .file_stem()
642                .and_then(|s| s.to_str())
643                .unwrap_or("session");
644            return self.cwd.join(format!("pi-session-{stem}.html"));
645        }
646        let id = crate::session_picker::truncate_session_id(&session.header.id, 8);
647        self.cwd.join(format!("pi-session-unsaved-{id}.html"))
648    }
649
650    fn resolve_output_path(&self, raw: &str) -> PathBuf {
651        let raw = raw.trim();
652        if raw.is_empty() {
653            return self.cwd.join("pi-session.html");
654        }
655        let path = PathBuf::from(raw);
656        if path.is_absolute() {
657            path
658        } else {
659            self.cwd.join(path)
660        }
661    }
662
663    fn spawn_save_session(&self) {
664        if !self.save_enabled {
665            return;
666        }
667
668        let session = Arc::clone(&self.session);
669        let event_tx = self.event_tx.clone();
670        let runtime_handle = self.runtime_handle.clone();
671        runtime_handle.spawn(async move {
672            let cx = Cx::for_request();
673
674            let mut session_guard = match session.lock(&cx).await {
675                Ok(guard) => guard,
676                Err(err) => {
677                    let _ = event_tx
678                        .try_send(PiMsg::AgentError(format!("Failed to lock session: {err}")));
679                    return;
680                }
681            };
682
683            if let Err(err) = session_guard.save().await {
684                let _ =
685                    event_tx.try_send(PiMsg::AgentError(format!("Failed to save session: {err}")));
686            }
687        });
688    }
689
690    fn maybe_trigger_autocomplete(&mut self) {
691        if self.agent_state != AgentState::Idle
692            || self.session_picker.is_some()
693            || self.settings_ui.is_some()
694        {
695            self.autocomplete.close();
696            return;
697        }
698
699        let text = self.input.value();
700        if text.trim().is_empty() {
701            self.autocomplete.close();
702            return;
703        }
704
705        // Autocomplete provider expects a byte offset cursor.
706        let cursor = self.input.cursor_byte_offset();
707        let response = self.autocomplete.provider.suggest(&text, cursor);
708        // Path completion is Tab-triggered to avoid noisy dropdowns for URL-like tokens.
709        if response
710            .items
711            .iter()
712            .all(|item| item.kind == AutocompleteItemKind::Path)
713        {
714            self.autocomplete.close();
715            return;
716        }
717        self.autocomplete.open_with(response);
718    }
719
720    fn trigger_autocomplete(&mut self) {
721        self.maybe_trigger_autocomplete();
722    }
723
724    /// Compute the conversation viewport height based on the current UI chrome.
725    ///
726    /// This delegates to [`view_effective_conversation_height`] so viewport
727    /// scroll math stays aligned with the rows actually rendered in `view()`.
728    fn conversation_viewport_height(&self) -> usize {
729        self.view_effective_conversation_height()
730    }
731
732    /// Return whether the generic "Processing..." spinner row should be shown.
733    ///
734    /// Once provider text/thinking deltas are streaming, that output already
735    /// acts as progress feedback; suppressing the extra animated status row
736    /// reduces redraw churn and visible flicker.
737    fn show_processing_status_spinner(&self) -> bool {
738        if self.agent_state == AgentState::Idle || self.current_tool.is_some() {
739            return false;
740        }
741
742        let has_visible_stream_progress = !self.current_response.is_empty()
743            || (self.thinking_visible && !self.current_thinking.is_empty());
744        !has_visible_stream_progress
745    }
746
747    /// Return whether any spinner row is currently visible in `view()`.
748    ///
749    /// The spinner is rendered either for tool execution progress, or for the
750    /// generic processing state before visible stream output appears.
751    fn spinner_visible(&self) -> bool {
752        if self.agent_state == AgentState::Idle {
753            return false;
754        }
755        self.current_tool.is_some() || self.show_processing_status_spinner()
756    }
757
758    /// Compute the effective conversation viewport height for the current
759    /// render frame, accounting for conditional chrome (scroll indicator,
760    /// tool status, status message) that reduce available space.
761    ///
762    /// Used in [`view()`] for conversation line slicing so the total output
763    /// never exceeds `term_height` rows.  The stored
764    /// `conversation_viewport.height` still drives scroll-position management.
765    fn view_effective_conversation_height(&self) -> usize {
766        // Fixed chrome:
767        // header(4) = title/model + hints + resources + spacer line
768        // footer(2) = blank line + footer line
769        let mut chrome: usize = 4 + 2;
770
771        // Budget 1 row for the scroll indicator.  Slightly conservative
772        // when content is short, but prevents the off-by-one that triggers
773        // terminal scrolling.
774        chrome += 1;
775
776        // Tool status: "\n  spinner Running {tool} ...\n" = 2 rows.
777        if self.current_tool.is_some() {
778            chrome += 2;
779        }
780
781        // Status message: "\n  {status}\n" = 2 rows.
782        if self.status_message.is_some() {
783            chrome += 2;
784        }
785
786        // Capability prompt overlay: ~8 lines (title, ext name, desc, blank, buttons, timer, help, blank).
787        if self.capability_prompt.is_some() {
788            chrome += 8;
789        }
790
791        // Branch picker overlay: header + N visible branches + help line + padding.
792        if let Some(ref picker) = self.branch_picker {
793            let visible = picker.branches.len().min(picker.max_visible);
794            chrome += 3 + visible + 2; // title + header + separator + items + help + blank
795        }
796
797        // Input area vs processing spinner.
798        let show_input = self.agent_state == AgentState::Idle
799            && self.session_picker.is_none()
800            && self.settings_ui.is_none()
801            && self.theme_picker.is_none()
802            && self.capability_prompt.is_none()
803            && self.branch_picker.is_none()
804            && self.model_selector.is_none();
805
806        if show_input {
807            // render_input: "\n  header\n" (2 rows) + input.height() rows.
808            chrome += 2 + self.input.height();
809        } else if self.show_processing_status_spinner() {
810            // Processing spinner: "\n  spinner Processing...\n" = 2 rows.
811            chrome += 2;
812        }
813
814        self.term_height.saturating_sub(chrome)
815    }
816
817    /// Set the input area height and recalculate the conversation viewport
818    /// so the total layout fits the terminal.
819    fn set_input_height(&mut self, h: usize) {
820        self.input.set_height(h);
821        self.resize_conversation_viewport();
822    }
823
824    /// Rebuild the conversation viewport after a height change (terminal resize or
825    /// input area growth). Preserves mouse-wheel settings and scroll position.
826    fn resize_conversation_viewport(&mut self) {
827        let viewport_height = self.conversation_viewport_height();
828        let mut viewport = Viewport::new(self.term_width.saturating_sub(2), viewport_height);
829        viewport.mouse_wheel_enabled = true;
830        viewport.mouse_wheel_delta = 3;
831        self.conversation_viewport = viewport;
832        self.scroll_to_bottom();
833    }
834
835    pub fn set_terminal_size(&mut self, width: usize, height: usize) {
836        let test_mode = std::env::var_os("PI_TEST_MODE").is_some();
837        let previous_height = self.term_height;
838        self.term_width = width.max(1);
839        self.term_height = height.max(1);
840        self.input
841            .set_width(self.term_width.saturating_sub(4 + self.editor_padding_x));
842
843        if !test_mode
844            && self.term_height < previous_height
845            && self.config.terminal_clear_on_shrink()
846        {
847            let _ = crossterm::execute!(
848                std::io::stdout(),
849                terminal::Clear(terminal::ClearType::Purge)
850            );
851        }
852
853        self.message_render_cache.invalidate_all();
854        self.resize_conversation_viewport();
855    }
856
857    fn accept_autocomplete(&mut self, item: &AutocompleteItem) {
858        let text = self.input.value();
859        let range = self.autocomplete.replace_range.clone();
860
861        // Guard against stale range if editor content changed since autocomplete was triggered.
862        let mut start = range.start.min(text.len());
863        while start > 0 && !text.is_char_boundary(start) {
864            start -= 1;
865        }
866        let mut end = range.end.min(text.len()).max(start);
867        while end < text.len() && !text.is_char_boundary(end) {
868            end += 1;
869        }
870
871        let mut new_text = String::with_capacity(text.len().saturating_add(item.insert.len()));
872        new_text.push_str(&text[..start]);
873        new_text.push_str(&item.insert);
874        new_text.push_str(&text[end..]);
875
876        self.input.set_value(&new_text);
877        self.input.cursor_end();
878    }
879
880    fn extract_file_references(&mut self, message: &str) -> (String, Vec<String>) {
881        let mut cleaned = String::with_capacity(message.len());
882        let mut file_args = Vec::new();
883        let mut idx = 0usize;
884
885        while idx < message.len() {
886            let ch = message[idx..].chars().next().unwrap_or(' ');
887            if ch == '@' && is_file_ref_boundary(message, idx) {
888                let token_start = idx + ch.len_utf8();
889                let parsed = parse_quoted_file_ref(message, token_start);
890                let (path, trailing, token_end) = parsed.unwrap_or_else(|| {
891                    let (token, token_end) = next_non_whitespace_token(message, token_start);
892                    let (path, trailing) = split_trailing_punct(token);
893                    (path.to_string(), trailing.to_string(), token_end)
894                });
895
896                if !path.is_empty() {
897                    let resolved =
898                        self.autocomplete
899                            .provider
900                            .resolve_file_ref(&path)
901                            .or_else(|| {
902                                let resolved_path = resolve_read_path(&path, &self.cwd);
903                                resolved_path.exists().then(|| path.clone())
904                            });
905
906                    if let Some(resolved) = resolved {
907                        file_args.push(resolved);
908                        if !trailing.is_empty()
909                            && cleaned.chars().last().is_some_and(char::is_whitespace)
910                        {
911                            cleaned.pop();
912                        }
913                        cleaned.push_str(&trailing);
914                        idx = token_end;
915                        continue;
916                    }
917                }
918            }
919
920            cleaned.push(ch);
921            idx += ch.len_utf8();
922        }
923
924        (cleaned, file_args)
925    }
926
927    #[allow(clippy::too_many_lines)]
928    fn load_session_from_path(&mut self, path: &str) -> Option<Cmd> {
929        let path = path.to_string();
930        let session = Arc::clone(&self.session);
931        let agent = Arc::clone(&self.agent);
932        let extensions = self.extensions.clone();
933        let event_tx = self.event_tx.clone();
934        let runtime_handle = self.runtime_handle.clone();
935
936        let (session_dir, previous_session_file) = {
937            let Ok(guard) = self.session.try_lock() else {
938                self.status_message = Some("Session busy; try again".to_string());
939                return None;
940            };
941            (
942                guard.session_dir.clone(),
943                guard.path.as_ref().map(|p| p.display().to_string()),
944            )
945        };
946
947        runtime_handle.spawn(async move {
948            let cx = Cx::for_request();
949
950            if let Some(manager) = extensions.clone() {
951                let cancelled = manager
952                    .dispatch_cancellable_event(
953                        ExtensionEventName::SessionBeforeSwitch,
954                        Some(json!({
955                            "reason": "resume",
956                            "targetSessionFile": path.clone(),
957                        })),
958                        EXTENSION_EVENT_TIMEOUT_MS,
959                    )
960                    .await
961                    .unwrap_or(false);
962                if cancelled {
963                    let _ = event_tx.try_send(PiMsg::System(
964                        "Session switch cancelled by extension".to_string(),
965                    ));
966                    return;
967                }
968            }
969
970            let mut loaded_session = match Session::open(&path).await {
971                Ok(session) => session,
972                Err(err) => {
973                    let _ = event_tx
974                        .try_send(PiMsg::AgentError(format!("Failed to open session: {err}")));
975                    return;
976                }
977            };
978            let new_session_id = loaded_session.header.id.clone();
979            loaded_session.session_dir = session_dir;
980
981            let messages_for_agent = loaded_session.to_messages_for_current_path();
982
983            // Replace the session.
984            {
985                let mut session_guard = match session.lock(&cx).await {
986                    Ok(guard) => guard,
987                    Err(err) => {
988                        let _ = event_tx
989                            .try_send(PiMsg::AgentError(format!("Failed to lock session: {err}")));
990                        return;
991                    }
992                };
993                *session_guard = loaded_session;
994            }
995
996            // Update the agent messages.
997            {
998                let mut agent_guard = match agent.lock(&cx).await {
999                    Ok(guard) => guard,
1000                    Err(err) => {
1001                        let _ = event_tx
1002                            .try_send(PiMsg::AgentError(format!("Failed to lock agent: {err}")));
1003                        return;
1004                    }
1005                };
1006                agent_guard.replace_messages(messages_for_agent);
1007            }
1008
1009            let (messages, usage) = {
1010                let session_guard = match session.lock(&cx).await {
1011                    Ok(guard) => guard,
1012                    Err(err) => {
1013                        let _ = event_tx
1014                            .try_send(PiMsg::AgentError(format!("Failed to lock session: {err}")));
1015                        return;
1016                    }
1017                };
1018                conversation_from_session(&session_guard)
1019            };
1020
1021            let _ = event_tx.try_send(PiMsg::ConversationReset {
1022                messages,
1023                usage,
1024                status: Some("Session resumed".to_string()),
1025            });
1026
1027            if let Some(manager) = extensions {
1028                let _ = manager
1029                    .dispatch_event(
1030                        ExtensionEventName::SessionSwitch,
1031                        Some(json!({
1032                            "reason": "resume",
1033                            "previousSessionFile": previous_session_file,
1034                            "targetSessionFile": path,
1035                            "sessionId": new_session_id,
1036                        })),
1037                    )
1038                    .await;
1039            }
1040        });
1041
1042        self.status_message = Some("Loading session...".to_string());
1043        None
1044    }
1045}
1046
1047const fn bool_label(value: bool) -> &'static str {
1048    if value { "on" } else { "off" }
1049}
1050
1051/// Run the interactive mode.
1052#[allow(clippy::too_many_arguments)]
1053pub async fn run_interactive(
1054    agent: Agent,
1055    session: Arc<Mutex<Session>>,
1056    config: Config,
1057    model_entry: ModelEntry,
1058    model_scope: Vec<ModelEntry>,
1059    available_models: Vec<ModelEntry>,
1060    pending_inputs: Vec<PendingInput>,
1061    save_enabled: bool,
1062    resources: ResourceLoader,
1063    resource_cli: ResourceCliOptions,
1064    extensions: Option<ExtensionManager>,
1065    cwd: PathBuf,
1066    runtime_handle: RuntimeHandle,
1067) -> anyhow::Result<()> {
1068    let show_hardware_cursor = config.show_hardware_cursor.unwrap_or_else(|| {
1069        std::env::var("PI_HARDWARE_CURSOR")
1070            .ok()
1071            .is_some_and(|val| val == "1")
1072    });
1073    let mut stdout = std::io::stdout();
1074    if show_hardware_cursor {
1075        let _ = crossterm::execute!(stdout, cursor::Show);
1076    } else {
1077        let _ = crossterm::execute!(stdout, cursor::Hide);
1078    }
1079
1080    let (event_tx, event_rx) = mpsc::channel::<PiMsg>(1024);
1081    let (ui_tx, ui_rx) = std::sync::mpsc::channel::<Message>();
1082
1083    runtime_handle.spawn(async move {
1084        let cx = Cx::for_request();
1085        while let Ok(msg) = event_rx.recv(&cx).await {
1086            if matches!(msg, PiMsg::UiShutdown) {
1087                break;
1088            }
1089            let _ = ui_tx.send(Message::new(msg));
1090        }
1091    });
1092
1093    let extensions = extensions;
1094
1095    if let Some(manager) = &extensions {
1096        let (extension_ui_tx, extension_ui_rx) = mpsc::channel::<ExtensionUiRequest>(64);
1097        manager.set_ui_sender(extension_ui_tx);
1098
1099        let extension_event_tx = event_tx.clone();
1100        runtime_handle.spawn(async move {
1101            let cx = Cx::for_request();
1102            while let Ok(request) = extension_ui_rx.recv(&cx).await {
1103                let _ = extension_event_tx.try_send(PiMsg::ExtensionUiRequest(request));
1104            }
1105        });
1106    }
1107
1108    let (messages, usage) = {
1109        let cx = Cx::for_request();
1110        let guard = session
1111            .lock(&cx)
1112            .await
1113            .map_err(|e| anyhow::anyhow!("Failed to lock session: {e}"))?;
1114        conversation_from_session(&guard)
1115    };
1116
1117    let app = PiApp::new(
1118        agent,
1119        session,
1120        config,
1121        resources,
1122        resource_cli,
1123        cwd,
1124        model_entry,
1125        model_scope,
1126        available_models,
1127        pending_inputs,
1128        event_tx,
1129        runtime_handle,
1130        save_enabled,
1131        extensions,
1132        None,
1133        messages,
1134        usage,
1135    );
1136
1137    Program::new(app)
1138        .with_alt_screen()
1139        .with_input_receiver(ui_rx)
1140        .run()?;
1141
1142    let _ = crossterm::execute!(std::io::stdout(), cursor::Show);
1143    println!("Goodbye!");
1144    Ok(())
1145}
1146
1147/// Custom message types for async agent events.
1148#[derive(Debug, Clone)]
1149pub enum PiMsg {
1150    /// Agent started processing.
1151    AgentStart,
1152    /// Trigger processing of the next queued input (CLI startup messages).
1153    RunPending,
1154    /// Enqueue a pending input (extensions may inject while idle).
1155    EnqueuePendingInput(PendingInput),
1156    /// Internal: shut down the async→UI message bridge (used for clean exit).
1157    UiShutdown,
1158    /// Text delta from assistant.
1159    TextDelta(String),
1160    /// Thinking delta from assistant.
1161    ThinkingDelta(String),
1162    /// Tool execution started.
1163    ToolStart { name: String, tool_id: String },
1164    /// Tool execution update (streaming output).
1165    ToolUpdate {
1166        name: String,
1167        tool_id: String,
1168        content: Vec<ContentBlock>,
1169        details: Option<Value>,
1170    },
1171    /// Tool execution ended.
1172    ToolEnd {
1173        name: String,
1174        tool_id: String,
1175        is_error: bool,
1176    },
1177    /// Agent finished with final message.
1178    AgentDone {
1179        usage: Option<Usage>,
1180        stop_reason: StopReason,
1181        error_message: Option<String>,
1182    },
1183    /// Agent error.
1184    AgentError(String),
1185    /// Credentials changed for a provider; refresh in-memory provider auth state.
1186    CredentialUpdated { provider: String },
1187    /// Non-error system message.
1188    System(String),
1189    /// System note that does not mutate agent state (safe during streaming).
1190    SystemNote(String),
1191    /// Update last user message content (input transform/redaction).
1192    UpdateLastUserMessage(String),
1193    /// Bash command result (non-agent).
1194    BashResult {
1195        display: String,
1196        content_for_agent: Option<Vec<ContentBlock>>,
1197    },
1198    /// Replace conversation state from session (compaction/fork).
1199    ConversationReset {
1200        messages: Vec<ConversationMessage>,
1201        usage: Usage,
1202        status: Option<String>,
1203    },
1204    /// Set the editor contents (used by /tree selection of user/custom messages).
1205    SetEditorText(String),
1206    /// Reloaded skills/prompts/themes/extensions.
1207    ResourcesReloaded {
1208        resources: ResourceLoader,
1209        status: String,
1210        diagnostics: Option<String>,
1211    },
1212    /// Extension UI request (select/confirm/input/editor/notify).
1213    ExtensionUiRequest(ExtensionUiRequest),
1214    /// Extension command finished execution.
1215    ExtensionCommandDone {
1216        command: String,
1217        display: String,
1218        is_error: bool,
1219    },
1220}
1221
1222/// Read the current git branch from `.git/HEAD` in the given directory.
1223///
1224/// Returns `Some("branch-name")` for a normal branch,
1225/// `Some("abc1234")` (7-char short SHA) for detached HEAD,
1226/// or `None` if not in a git repo or `.git/HEAD` is unreadable.
1227fn read_git_branch(cwd: &Path) -> Option<String> {
1228    let git_head = cwd.join(".git/HEAD");
1229    let content = std::fs::read_to_string(&git_head).ok()?;
1230    let content = content.trim();
1231    content.strip_prefix("ref: refs/heads/").map_or_else(
1232        || {
1233            // Detached HEAD — show short SHA
1234            (content.len() >= 7 && content.chars().all(|c| c.is_ascii_hexdigit()))
1235                .then(|| content[..7].to_string())
1236        },
1237        |ref_path| Some(ref_path.to_string()),
1238    )
1239}
1240
1241fn build_startup_welcome_message(config: &Config) -> String {
1242    if config.quiet_startup.unwrap_or(false) {
1243        return String::new();
1244    }
1245
1246    let mut message = String::from("  Welcome to Pi!\n");
1247    message.push_str("  Type a message to begin, or /help for commands.\n");
1248
1249    let auth_path = Config::auth_path();
1250    if let Ok(auth) = crate::auth::AuthStorage::load(auth_path) {
1251        if should_show_startup_oauth_hint(&auth) {
1252            message.push('\n');
1253            message.push_str(&format_startup_oauth_hint(&auth));
1254        }
1255    }
1256
1257    message
1258}
1259
1260/// The main interactive TUI application model.
1261#[allow(clippy::struct_excessive_bools)]
1262#[derive(bubbletea::Model)]
1263pub struct PiApp {
1264    // Input state
1265    input: TextArea,
1266    history: HistoryList,
1267    input_mode: InputMode,
1268    pending_inputs: VecDeque<PendingInput>,
1269    message_queue: Arc<StdMutex<InteractiveMessageQueue>>,
1270
1271    // Display state - viewport for scrollable conversation
1272    pub conversation_viewport: Viewport,
1273    /// When true, the viewport auto-scrolls to the bottom on new content.
1274    /// Set to false when the user manually scrolls up; re-enabled when they
1275    /// scroll back to the bottom or a new user message is submitted.
1276    follow_stream_tail: bool,
1277    spinner: SpinnerModel,
1278    agent_state: AgentState,
1279
1280    // Terminal dimensions
1281    term_width: usize,
1282    term_height: usize,
1283    editor_padding_x: usize,
1284
1285    // Conversation state
1286    messages: Vec<ConversationMessage>,
1287    current_response: String,
1288    current_thinking: String,
1289    thinking_visible: bool,
1290    tools_expanded: bool,
1291    current_tool: Option<String>,
1292    tool_progress: Option<ToolProgress>,
1293    pending_tool_output: Option<String>,
1294
1295    // Session and config
1296    session: Arc<Mutex<Session>>,
1297    config: Config,
1298    theme: Theme,
1299    styles: TuiStyles,
1300    markdown_style: GlamourStyleConfig,
1301    resources: ResourceLoader,
1302    resource_cli: ResourceCliOptions,
1303    cwd: PathBuf,
1304    model_entry: ModelEntry,
1305    model_entry_shared: Arc<StdMutex<ModelEntry>>,
1306    model_scope: Vec<ModelEntry>,
1307    available_models: Vec<ModelEntry>,
1308    model: String,
1309    agent: Arc<Mutex<Agent>>,
1310    save_enabled: bool,
1311    abort_handle: Option<AbortHandle>,
1312    bash_running: bool,
1313
1314    // Token tracking
1315    total_usage: Usage,
1316
1317    // Async channel for agent events
1318    event_tx: mpsc::Sender<PiMsg>,
1319    runtime_handle: RuntimeHandle,
1320
1321    // Extension session state
1322    extension_streaming: Arc<AtomicBool>,
1323    extension_compacting: Arc<AtomicBool>,
1324    extension_ui_queue: VecDeque<ExtensionUiRequest>,
1325    active_extension_ui: Option<ExtensionUiRequest>,
1326
1327    // Status message (for slash command feedback)
1328    status_message: Option<String>,
1329
1330    // Login flow state (awaiting sensitive credential input)
1331    pending_oauth: Option<PendingOAuth>,
1332
1333    // Extension system
1334    extensions: Option<ExtensionManager>,
1335
1336    // Keybindings for action dispatch
1337    keybindings: crate::keybindings::KeyBindings,
1338
1339    // Track last Ctrl+C time for double-tap quit detection
1340    last_ctrlc_time: Option<std::time::Instant>,
1341    // Track last Escape time for double-tap tree/fork
1342    last_escape_time: Option<std::time::Instant>,
1343
1344    // Autocomplete state
1345    autocomplete: AutocompleteState,
1346
1347    // Session picker overlay for /resume
1348    session_picker: Option<SessionPickerOverlay>,
1349
1350    // Settings UI overlay for /settings
1351    settings_ui: Option<SettingsUiState>,
1352
1353    // Theme picker overlay
1354    theme_picker: Option<ThemePickerOverlay>,
1355
1356    // Tree navigation UI state (for /tree command)
1357    tree_ui: Option<TreeUiState>,
1358
1359    // Capability prompt overlay (extension permission request)
1360    capability_prompt: Option<CapabilityPromptOverlay>,
1361
1362    // Branch picker overlay (Ctrl+B quick branch switching)
1363    branch_picker: Option<BranchPickerOverlay>,
1364
1365    // Model selector overlay (Ctrl+L)
1366    model_selector: Option<crate::model_selector::ModelSelectorOverlay>,
1367
1368    // Frame timing telemetry (PERF-3)
1369    frame_timing: FrameTimingStats,
1370
1371    // Memory pressure monitoring (PERF-6)
1372    memory_monitor: MemoryMonitor,
1373
1374    // Per-message render cache (PERF-1)
1375    message_render_cache: MessageRenderCache,
1376
1377    // Pre-allocated reusable buffers for view() hot path (PERF-7)
1378    render_buffers: RenderBuffers,
1379
1380    // Current git branch name (refreshed on startup + after each agent turn)
1381    git_branch: Option<String>,
1382    // Startup banner shown in an empty conversation.
1383    startup_welcome: String,
1384}
1385
1386impl PiApp {
1387    /// Create a new Pi application.
1388    #[allow(clippy::too_many_arguments)]
1389    #[allow(clippy::too_many_lines)]
1390    pub fn new(
1391        agent: Agent,
1392        session: Arc<Mutex<Session>>,
1393        config: Config,
1394        resources: ResourceLoader,
1395        resource_cli: ResourceCliOptions,
1396        cwd: PathBuf,
1397        model_entry: ModelEntry,
1398        model_scope: Vec<ModelEntry>,
1399        available_models: Vec<ModelEntry>,
1400        pending_inputs: Vec<PendingInput>,
1401        event_tx: mpsc::Sender<PiMsg>,
1402        runtime_handle: RuntimeHandle,
1403        save_enabled: bool,
1404        extensions: Option<ExtensionManager>,
1405        keybindings_override: Option<KeyBindings>,
1406        messages: Vec<ConversationMessage>,
1407        total_usage: Usage,
1408    ) -> Self {
1409        // Get terminal size
1410        let (term_width, term_height) =
1411            terminal::size().map_or((80, 24), |(w, h)| (w as usize, h as usize));
1412
1413        let theme = Theme::resolve(&config, &cwd);
1414        let styles = theme.tui_styles();
1415        let mut markdown_style = theme.glamour_style_config();
1416        if let Some(indent) = config.markdown.as_ref().and_then(|m| m.code_block_indent) {
1417            markdown_style.code_block.block.margin = Some(indent as usize);
1418        }
1419        let editor_padding_x = config.editor_padding_x.unwrap_or(0).min(3) as usize;
1420        let autocomplete_max_visible =
1421            config.autocomplete_max_visible.unwrap_or(5).clamp(3, 20) as usize;
1422        let thinking_visible = !config.hide_thinking_block.unwrap_or(false);
1423
1424        // Configure text area for input
1425        let mut input = TextArea::new();
1426        input.placeholder = "Type a message... (/help, /exit)".to_string();
1427        input.show_line_numbers = false;
1428        input.prompt = "> ".to_string();
1429        input.set_height(3); // Start with 3 lines
1430        input.set_width(term_width.saturating_sub(4 + editor_padding_x));
1431        input.max_height = 10; // Allow expansion up to 10 lines
1432        input.focus();
1433
1434        let spinner = SpinnerModel::with_spinner(spinners::dot()).style(styles.accent.clone());
1435
1436        // Configure viewport for conversation history.
1437        // Height budget at startup (idle):
1438        // header(4) + scroll-indicator reserve(1) + input_decoration(2) + input_lines + footer(2).
1439        let chrome = 4 + 1 + 2 + 2;
1440        let viewport_height = term_height.saturating_sub(chrome + input.height());
1441        let mut conversation_viewport =
1442            Viewport::new(term_width.saturating_sub(2), viewport_height);
1443        conversation_viewport.mouse_wheel_enabled = true;
1444        conversation_viewport.mouse_wheel_delta = 3;
1445
1446        let model = format!(
1447            "{}/{}",
1448            model_entry.model.provider.as_str(),
1449            model_entry.model.id.as_str()
1450        );
1451
1452        let model_entry_shared = Arc::new(StdMutex::new(model_entry.clone()));
1453        let extension_streaming = Arc::new(AtomicBool::new(false));
1454        let extension_compacting = Arc::new(AtomicBool::new(false));
1455        let steering_mode = parse_queue_mode_or_default(config.steering_mode.as_deref());
1456        let follow_up_mode = parse_queue_mode_or_default(config.follow_up_mode.as_deref());
1457        let message_queue = Arc::new(StdMutex::new(InteractiveMessageQueue::new(
1458            steering_mode,
1459            follow_up_mode,
1460        )));
1461        let injected_queue = Arc::new(StdMutex::new(InjectedMessageQueue::new(
1462            steering_mode,
1463            follow_up_mode,
1464        )));
1465
1466        let mut agent = agent;
1467        agent.set_queue_modes(steering_mode, follow_up_mode);
1468        {
1469            let steering_queue = Arc::clone(&message_queue);
1470            let follow_up_queue = Arc::clone(&message_queue);
1471            let injected_steering_queue = Arc::clone(&injected_queue);
1472            let injected_follow_up_queue = Arc::clone(&injected_queue);
1473            let steering_fetcher = move || -> BoxFuture<'static, Vec<ModelMessage>> {
1474                let steering_queue = Arc::clone(&steering_queue);
1475                let injected_steering_queue = Arc::clone(&injected_steering_queue);
1476                Box::pin(async move {
1477                    let mut out = Vec::new();
1478                    if let Ok(mut queue) = steering_queue.lock() {
1479                        out.extend(queue.pop_steering().into_iter().map(build_user_message));
1480                    }
1481                    if let Ok(mut queue) = injected_steering_queue.lock() {
1482                        out.extend(queue.pop_steering());
1483                    }
1484                    out
1485                })
1486            };
1487            let follow_up_fetcher = move || -> BoxFuture<'static, Vec<ModelMessage>> {
1488                let follow_up_queue = Arc::clone(&follow_up_queue);
1489                let injected_follow_up_queue = Arc::clone(&injected_follow_up_queue);
1490                Box::pin(async move {
1491                    let mut out = Vec::new();
1492                    if let Ok(mut queue) = follow_up_queue.lock() {
1493                        out.extend(queue.pop_follow_up().into_iter().map(build_user_message));
1494                    }
1495                    if let Ok(mut queue) = injected_follow_up_queue.lock() {
1496                        out.extend(queue.pop_follow_up());
1497                    }
1498                    out
1499                })
1500            };
1501            agent.register_message_fetchers(
1502                Some(Arc::new(steering_fetcher)),
1503                Some(Arc::new(follow_up_fetcher)),
1504            );
1505        }
1506
1507        let keybindings = keybindings_override.unwrap_or_else(|| {
1508            // Load keybindings from user config (with defaults as fallback).
1509            let keybindings_result = KeyBindings::load_from_user_config();
1510            if keybindings_result.has_warnings() {
1511                tracing::warn!(
1512                    "Keybindings warnings: {}",
1513                    keybindings_result.format_warnings()
1514                );
1515            }
1516            keybindings_result.bindings
1517        });
1518
1519        // Initialize autocomplete with catalog from resources
1520        let mut autocomplete_catalog = AutocompleteCatalog::from_resources(&resources);
1521        if let Some(manager) = &extensions {
1522            autocomplete_catalog.extension_commands = extension_commands_for_catalog(manager);
1523        }
1524        let mut autocomplete = AutocompleteState::new(cwd.clone(), autocomplete_catalog);
1525        autocomplete.max_visible = autocomplete_max_visible;
1526
1527        let git_branch = read_git_branch(&cwd);
1528        let startup_welcome = build_startup_welcome_message(&config);
1529
1530        let mut app = Self {
1531            input,
1532            history: HistoryList::new(),
1533            input_mode: InputMode::SingleLine,
1534            pending_inputs: VecDeque::from(pending_inputs),
1535            message_queue,
1536            conversation_viewport,
1537            follow_stream_tail: true,
1538            spinner,
1539            agent_state: AgentState::Idle,
1540            term_width,
1541            term_height,
1542            editor_padding_x,
1543            messages,
1544            current_response: String::new(),
1545            current_thinking: String::new(),
1546            thinking_visible,
1547            tools_expanded: true,
1548            current_tool: None,
1549            tool_progress: None,
1550            pending_tool_output: None,
1551            session,
1552            config,
1553            theme,
1554            styles,
1555            markdown_style,
1556            resources,
1557            resource_cli,
1558            cwd,
1559            model_entry,
1560            model_entry_shared: model_entry_shared.clone(),
1561            model_scope,
1562            available_models,
1563            model,
1564            agent: Arc::new(Mutex::new(agent)),
1565            total_usage,
1566            event_tx,
1567            runtime_handle,
1568            extension_streaming: extension_streaming.clone(),
1569            extension_compacting: extension_compacting.clone(),
1570            extension_ui_queue: VecDeque::new(),
1571            active_extension_ui: None,
1572            status_message: None,
1573            save_enabled,
1574            abort_handle: None,
1575            bash_running: false,
1576            pending_oauth: None,
1577            extensions,
1578            keybindings,
1579            last_ctrlc_time: None,
1580            last_escape_time: None,
1581            autocomplete,
1582            session_picker: None,
1583            settings_ui: None,
1584            theme_picker: None,
1585            tree_ui: None,
1586            capability_prompt: None,
1587            branch_picker: None,
1588            model_selector: None,
1589            frame_timing: FrameTimingStats::new(),
1590            memory_monitor: MemoryMonitor::new_default(),
1591            message_render_cache: MessageRenderCache::new(),
1592            render_buffers: RenderBuffers::new(),
1593            git_branch,
1594            startup_welcome,
1595        };
1596
1597        if let Some(manager) = app.extensions.clone() {
1598            let session_handle = Arc::new(InteractiveExtensionSession {
1599                session: Arc::clone(&app.session),
1600                model_entry: model_entry_shared,
1601                is_streaming: extension_streaming,
1602                is_compacting: extension_compacting,
1603                config: app.config.clone(),
1604                save_enabled: app.save_enabled,
1605            });
1606            manager.set_session(session_handle);
1607
1608            manager.set_host_actions(Arc::new(InteractiveExtensionHostActions {
1609                session: Arc::clone(&app.session),
1610                agent: Arc::clone(&app.agent),
1611                event_tx: app.event_tx.clone(),
1612                extension_streaming: Arc::clone(&app.extension_streaming),
1613                user_queue: Arc::clone(&app.message_queue),
1614                injected_queue,
1615            }));
1616        }
1617
1618        app.scroll_to_bottom();
1619
1620        // Version update check (non-blocking, cache-only on startup)
1621        if app.config.should_check_for_updates() {
1622            if let crate::version_check::VersionCheckResult::UpdateAvailable { latest } =
1623                crate::version_check::check_cached()
1624            {
1625                app.status_message = Some(format!(
1626                    "New version {latest} available (current: {})",
1627                    crate::version_check::CURRENT_VERSION
1628                ));
1629            }
1630        }
1631
1632        app
1633    }
1634
1635    #[must_use]
1636    pub fn session_handle(&self) -> Arc<Mutex<Session>> {
1637        Arc::clone(&self.session)
1638    }
1639
1640    /// Get the current status message (for testing).
1641    pub fn status_message(&self) -> Option<&str> {
1642        self.status_message.as_deref()
1643    }
1644
1645    /// Snapshot the in-memory conversation buffer (integration test helper).
1646    pub fn conversation_messages_for_test(&self) -> &[ConversationMessage] {
1647        &self.messages
1648    }
1649
1650    /// Return the memory summary string (integration test helper).
1651    pub fn memory_summary_for_test(&self) -> String {
1652        self.memory_monitor.summary()
1653    }
1654
1655    /// Install a deterministic RSS sampler for integration tests.
1656    ///
1657    /// This replaces `/proc/self` RSS sampling with a caller-provided function
1658    /// and enables immediate sampling cadence (`sample_interval = 0`).
1659    pub fn install_memory_rss_reader_for_test(
1660        &mut self,
1661        read_fn: Box<dyn Fn() -> Option<usize> + Send>,
1662    ) {
1663        let mut monitor = MemoryMonitor::new_with_reader_fn(read_fn);
1664        monitor.sample_interval = std::time::Duration::ZERO;
1665        monitor.last_collapse = std::time::Instant::now()
1666            .checked_sub(std::time::Duration::from_secs(1))
1667            .unwrap_or_else(std::time::Instant::now);
1668        self.memory_monitor = monitor;
1669    }
1670
1671    /// Force a memory monitor sample + action pass (integration test helper).
1672    pub fn force_memory_cycle_for_test(&mut self) {
1673        self.memory_monitor.maybe_sample();
1674        self.run_memory_pressure_actions();
1675    }
1676
1677    /// Force progressive-collapse timing eligibility (integration test helper).
1678    pub fn force_memory_collapse_tick_for_test(&mut self) {
1679        self.memory_monitor.last_collapse = std::time::Instant::now()
1680            .checked_sub(std::time::Duration::from_secs(1))
1681            .unwrap_or_else(std::time::Instant::now);
1682    }
1683
1684    /// Get a reference to the model selector overlay (for testing).
1685    pub const fn model_selector(&self) -> Option<&crate::model_selector::ModelSelectorOverlay> {
1686        self.model_selector.as_ref()
1687    }
1688
1689    /// Check if the branch picker is currently open (for testing).
1690    pub const fn has_branch_picker(&self) -> bool {
1691        self.branch_picker.is_some()
1692    }
1693
1694    /// Return whether the conversation prefix cache is currently valid for
1695    /// the current message count (integration test helper for PERF-2).
1696    pub fn prefix_cache_valid_for_test(&self) -> bool {
1697        self.message_render_cache.prefix_valid(self.messages.len())
1698    }
1699
1700    /// Return the length of the cached conversation prefix
1701    /// (integration test helper for PERF-2).
1702    pub fn prefix_cache_len_for_test(&self) -> usize {
1703        self.message_render_cache.prefix_get().len()
1704    }
1705
1706    /// Return the current view capacity hint from render buffers
1707    /// (integration test helper for PERF-7).
1708    pub fn render_buffer_capacity_hint_for_test(&self) -> usize {
1709        self.render_buffers.view_capacity_hint()
1710    }
1711
1712    /// Initialize the application.
1713    fn init(&self) -> Option<Cmd> {
1714        // Start text input cursor blink.
1715        // Spinner ticks are started lazily when we transition idle -> busy.
1716        let test_mode = std::env::var_os("PI_TEST_MODE").is_some();
1717        let input_cmd = if test_mode {
1718            None
1719        } else {
1720            BubbleteaModel::init(&self.input)
1721        };
1722        let pending_cmd = if self.pending_inputs.is_empty() {
1723            None
1724        } else {
1725            Some(Cmd::new(|| Message::new(PiMsg::RunPending)))
1726        };
1727
1728        // Batch commands
1729        batch(vec![input_cmd, pending_cmd])
1730    }
1731
1732    fn spinner_init_cmd(&self) -> Option<Cmd> {
1733        if std::env::var_os("PI_TEST_MODE").is_some() {
1734            None
1735        } else {
1736            BubbleteaModel::init(&self.spinner)
1737        }
1738    }
1739
1740    /// Handle messages (keyboard input, async events, etc.).
1741    #[allow(clippy::too_many_lines)]
1742    fn update(&mut self, msg: Message) -> Option<Cmd> {
1743        let update_start = if self.frame_timing.enabled {
1744            Some(std::time::Instant::now())
1745        } else {
1746            None
1747        };
1748        let was_busy = self.agent_state != AgentState::Idle;
1749        let was_spinner_visible = self.spinner_visible();
1750        let result = self.update_inner(msg);
1751        let became_busy = !was_busy && self.agent_state != AgentState::Idle;
1752        let spinner_became_visible = !was_spinner_visible && self.spinner_visible();
1753        let result = if became_busy || spinner_became_visible {
1754            batch(vec![result, self.spinner_init_cmd()])
1755        } else {
1756            result
1757        };
1758        if let Some(start) = update_start {
1759            self.frame_timing
1760                .record_update(micros_as_u64(start.elapsed().as_micros()));
1761        }
1762        result
1763    }
1764
1765    /// Inner update handler (extracted for frame timing instrumentation).
1766    #[allow(clippy::too_many_lines)]
1767    fn update_inner(&mut self, msg: Message) -> Option<Cmd> {
1768        // Memory pressure sampling + progressive collapse (PERF-6)
1769        self.memory_monitor.maybe_sample();
1770        self.run_memory_pressure_actions();
1771
1772        // Handle our custom Pi messages (take ownership to avoid per-token clone).
1773        // NOTE: We check with `is()` first, then `downcast().unwrap()` to consume
1774        // the message only when we know it will succeed. This avoids needing the
1775        // `try_downcast` method that only exists in the unpublished local
1776        // charmed-bubbletea (see Cargo.toml TODO for issue #5).
1777        if msg.is::<PiMsg>() {
1778            return self.handle_pi_message(msg.downcast::<PiMsg>().unwrap());
1779        }
1780
1781        if let Some(size) = msg.downcast_ref::<WindowSizeMsg>() {
1782            self.set_terminal_size(size.width as usize, size.height as usize);
1783            return None;
1784        }
1785
1786        // Ignore spinner ticks when no spinner row is visible so old tick
1787        // chains naturally stop and do not trigger hidden redraw churn.
1788        if msg.downcast_ref::<SpinnerTickMsg>().is_some() && !self.spinner_visible() {
1789            return None;
1790        }
1791
1792        // Handle keyboard input via keybindings layer
1793        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
1794            // Clear status message on any key press
1795            self.status_message = None;
1796            if key.key_type != KeyType::Esc {
1797                self.last_escape_time = None;
1798            }
1799
1800            // /tree modal captures all input while active.
1801            if self.tree_ui.is_some() {
1802                return self.handle_tree_ui_key(key);
1803            }
1804
1805            // Capability prompt modal captures all input while active.
1806            if self.capability_prompt.is_some() {
1807                return self.handle_capability_prompt_key(key);
1808            }
1809
1810            // Branch picker modal captures all input while active.
1811            if self.branch_picker.is_some() {
1812                return self.handle_branch_picker_key(key);
1813            }
1814
1815            // Model selector modal captures all input while active.
1816            if self.model_selector.is_some() {
1817                return self.handle_model_selector_key(key);
1818            }
1819
1820            // Theme picker modal captures all input while active.
1821            if self.theme_picker.is_some() {
1822                let mut picker = self
1823                    .theme_picker
1824                    .take()
1825                    .expect("checked theme_picker is_some");
1826                match key.key_type {
1827                    KeyType::Up => picker.select_prev(),
1828                    KeyType::Down => picker.select_next(),
1829                    KeyType::Runes if key.runes == ['k'] => picker.select_prev(),
1830                    KeyType::Runes if key.runes == ['j'] => picker.select_next(),
1831                    KeyType::Enter => {
1832                        if let Some(item) = picker.selected_item() {
1833                            let loaded = match item {
1834                                ThemePickerItem::BuiltIn(name) => Ok(match *name {
1835                                    "light" => Theme::light(),
1836                                    "solarized" => Theme::solarized(),
1837                                    _ => Theme::dark(),
1838                                }),
1839                                ThemePickerItem::File(path) => Theme::load(path),
1840                            };
1841
1842                            match loaded {
1843                                Ok(theme) => {
1844                                    let theme_name = theme.name.clone();
1845                                    self.apply_theme(theme);
1846                                    self.config.theme = Some(theme_name.clone());
1847                                    if let Err(e) = self.persist_project_theme(&theme_name) {
1848                                        self.status_message =
1849                                            Some(format!("Failed to persist theme: {e}"));
1850                                    } else {
1851                                        self.status_message =
1852                                            Some(format!("Switched to theme: {theme_name}"));
1853                                    }
1854                                }
1855                                Err(e) => {
1856                                    self.status_message =
1857                                        Some(format!("Failed to load selected theme: {e}"));
1858                                }
1859                            }
1860                        }
1861                        self.theme_picker = None;
1862                        return None;
1863                    }
1864                    KeyType::Esc => {
1865                        self.theme_picker = None;
1866                        self.settings_ui = Some(SettingsUiState::new());
1867                        return None;
1868                    }
1869                    KeyType::Runes if key.runes == ['q'] => {
1870                        self.theme_picker = None;
1871                        self.settings_ui = Some(SettingsUiState::new());
1872                        return None;
1873                    }
1874                    _ => {}
1875                }
1876                self.theme_picker = Some(picker);
1877                return None;
1878            }
1879
1880            // /settings modal captures all input while active.
1881            if self.settings_ui.is_some() {
1882                let mut settings_ui = self
1883                    .settings_ui
1884                    .take()
1885                    .expect("checked settings_ui is_some");
1886                match key.key_type {
1887                    KeyType::Up => {
1888                        settings_ui.select_prev();
1889                        self.settings_ui = Some(settings_ui);
1890                        return None;
1891                    }
1892                    KeyType::Down => {
1893                        settings_ui.select_next();
1894                        self.settings_ui = Some(settings_ui);
1895                        return None;
1896                    }
1897                    KeyType::Runes if key.runes == ['k'] => {
1898                        settings_ui.select_prev();
1899                        self.settings_ui = Some(settings_ui);
1900                        return None;
1901                    }
1902                    KeyType::Runes if key.runes == ['j'] => {
1903                        settings_ui.select_next();
1904                        self.settings_ui = Some(settings_ui);
1905                        return None;
1906                    }
1907                    KeyType::Enter => {
1908                        if let Some(selected) = settings_ui.selected_entry() {
1909                            match selected {
1910                                SettingsUiEntry::Summary => {
1911                                    self.messages.push(ConversationMessage {
1912                                        role: MessageRole::System,
1913                                        content: self.format_settings_summary(),
1914                                        thinking: None,
1915                                        collapsed: false,
1916                                    });
1917                                    self.scroll_to_bottom();
1918                                    self.status_message =
1919                                        Some("Selected setting: Summary".to_string());
1920                                }
1921                                _ => {
1922                                    self.toggle_settings_entry(selected);
1923                                }
1924                            }
1925                        }
1926                        self.settings_ui = None;
1927                        return None;
1928                    }
1929                    KeyType::Esc => {
1930                        self.settings_ui = None;
1931                        self.status_message = Some("Settings cancelled".to_string());
1932                        return None;
1933                    }
1934                    KeyType::Runes if key.runes == ['q'] => {
1935                        self.settings_ui = None;
1936                        self.status_message = Some("Settings cancelled".to_string());
1937                        return None;
1938                    }
1939                    _ => {
1940                        self.settings_ui = Some(settings_ui);
1941                        return None;
1942                    }
1943                }
1944            }
1945
1946            // Handle session picker navigation when overlay is open
1947            if let Some(ref mut picker) = self.session_picker {
1948                // If in delete confirmation mode, handle y/n/Esc/Enter
1949                if picker.confirm_delete {
1950                    match key.key_type {
1951                        KeyType::Runes if key.runes == ['y'] || key.runes == ['Y'] => {
1952                            picker.confirm_delete = false;
1953                            match picker.delete_selected() {
1954                                Ok(()) => {
1955                                    if picker.all_sessions.is_empty() {
1956                                        self.session_picker = None;
1957                                        self.status_message =
1958                                            Some("No sessions found for this project".to_string());
1959                                    } else if picker.sessions.is_empty() {
1960                                        picker.status_message =
1961                                            Some("No sessions match current filter.".to_string());
1962                                    } else {
1963                                        picker.status_message =
1964                                            Some("Session deleted.".to_string());
1965                                    }
1966                                }
1967                                Err(err) => {
1968                                    picker.status_message = Some(err.to_string());
1969                                }
1970                            }
1971                            return None;
1972                        }
1973                        KeyType::Runes if key.runes == ['n'] || key.runes == ['N'] => {
1974                            // Cancel delete
1975                            picker.confirm_delete = false;
1976                            picker.status_message = None;
1977                            return None;
1978                        }
1979                        KeyType::Esc => {
1980                            // Cancel delete
1981                            picker.confirm_delete = false;
1982                            picker.status_message = None;
1983                            return None;
1984                        }
1985                        _ => {
1986                            // Ignore other keys in confirmation mode
1987                            return None;
1988                        }
1989                    }
1990                }
1991
1992                // Normal picker mode
1993                match key.key_type {
1994                    KeyType::Up => {
1995                        picker.select_prev();
1996                        return None;
1997                    }
1998                    KeyType::Down => {
1999                        picker.select_next();
2000                        return None;
2001                    }
2002                    KeyType::Runes if key.runes == ['k'] && !picker.has_query() => {
2003                        picker.select_prev();
2004                        return None;
2005                    }
2006                    KeyType::Runes if key.runes == ['j'] && !picker.has_query() => {
2007                        picker.select_next();
2008                        return None;
2009                    }
2010                    KeyType::Backspace => {
2011                        picker.pop_char();
2012                        return None;
2013                    }
2014                    KeyType::Enter => {
2015                        // Load the selected session
2016                        if let Some(session_meta) = picker.selected_session().cloned() {
2017                            self.session_picker = None;
2018                            return self.load_session_from_path(&session_meta.path);
2019                        }
2020                        return None;
2021                    }
2022                    KeyType::CtrlD => {
2023                        picker.confirm_delete = true;
2024                        picker.status_message =
2025                            Some("Delete session? Press y/n to confirm.".to_string());
2026                        return None;
2027                    }
2028                    KeyType::Esc => {
2029                        self.session_picker = None;
2030                        return None;
2031                    }
2032                    KeyType::Runes if key.runes == ['q'] && !picker.has_query() => {
2033                        self.session_picker = None;
2034                        return None;
2035                    }
2036                    KeyType::Runes => {
2037                        picker.push_chars(key.runes.iter().copied());
2038                        return None;
2039                    }
2040                    _ => {
2041                        // Ignore other keys while picker is open
2042                        return None;
2043                    }
2044                }
2045            }
2046
2047            // Handle autocomplete navigation when dropdown is open.
2048            //
2049            // IMPORTANT: Enter submits the current editor contents; Tab accepts autocomplete.
2050            if self.autocomplete.open {
2051                match key.key_type {
2052                    KeyType::Up => {
2053                        self.autocomplete.select_prev();
2054                        return None;
2055                    }
2056                    KeyType::Down => {
2057                        self.autocomplete.select_next();
2058                        return None;
2059                    }
2060                    KeyType::Tab => {
2061                        // Accept the selected item
2062                        if let Some(item) = self.autocomplete.selected_item().cloned() {
2063                            self.accept_autocomplete(&item);
2064                        }
2065                        self.autocomplete.close();
2066                        return None;
2067                    }
2068                    KeyType::Enter => {
2069                        // Close autocomplete and allow Enter to submit.
2070                        self.autocomplete.close();
2071                    }
2072                    KeyType::Esc => {
2073                        self.autocomplete.close();
2074                        return None;
2075                    }
2076                    _ => {
2077                        // Close autocomplete on other keys, then process normally
2078                        self.autocomplete.close();
2079                    }
2080                }
2081            }
2082
2083            // Handle bracketed paste (drag/drop paths, etc.) before keybindings.
2084            if key.paste && self.handle_paste_event(key) {
2085                return None;
2086            }
2087
2088            // Convert KeyMsg to KeyBinding and resolve action
2089            if let Some(binding) = KeyBinding::from_bubbletea_key(key) {
2090                let candidates = self.keybindings.matching_actions(&binding);
2091                if let Some(action) = self.resolve_action(&candidates) {
2092                    // Dispatch action based on current state
2093                    if let Some(cmd) = self.handle_action(action, key) {
2094                        return Some(cmd);
2095                    }
2096                    // Action was handled but returned None (no command needed)
2097                    // Check if we should suppress forwarding to text area
2098                    if self.should_consume_action(action) {
2099                        return None;
2100                    }
2101                }
2102
2103                // Extension shortcuts: check if unhandled key matches an extension shortcut
2104                if self.agent_state == AgentState::Idle {
2105                    let key_id = binding.to_string().to_lowercase();
2106                    if let Some(manager) = &self.extensions {
2107                        if manager.has_shortcut(&key_id) {
2108                            return self.dispatch_extension_shortcut(&key_id);
2109                        }
2110                    }
2111                }
2112            }
2113
2114            // Handle raw keys that don't map to actions but need special behavior
2115            // (e.g., text input handled by TextArea)
2116        }
2117
2118        // Forward to appropriate component based on state
2119        if self.agent_state == AgentState::Idle {
2120            let old_height = self.input.height();
2121
2122            if let Some(key) = msg.downcast_ref::<KeyMsg>() {
2123                if key.key_type == KeyType::Space {
2124                    let mut key = key.clone();
2125                    key.key_type = KeyType::Runes;
2126                    key.runes = vec![' '];
2127
2128                    let result = BubbleteaModel::update(&mut self.input, Message::new(key));
2129
2130                    if self.input.height() != old_height {
2131                        self.refresh_conversation_viewport(self.follow_stream_tail);
2132                    }
2133
2134                    self.maybe_trigger_autocomplete();
2135                    return result;
2136                }
2137            }
2138            let result = BubbleteaModel::update(&mut self.input, msg);
2139
2140            if self.input.height() != old_height {
2141                self.refresh_conversation_viewport(self.follow_stream_tail);
2142            }
2143
2144            // After text area update, check if we should trigger autocomplete
2145            self.maybe_trigger_autocomplete();
2146
2147            result
2148        } else {
2149            // While processing, forward to spinner
2150            self.spinner.update(msg)
2151        }
2152    }
2153}
2154
2155#[cfg(test)]
2156mod tests;